diff --git a/packages/claude-code-plugin/hooks/lib/hud_helpers.py b/packages/claude-code-plugin/hooks/lib/hud_helpers.py index 193dfc9e..795bd9b7 100644 --- a/packages/claude-code-plugin/hooks/lib/hud_helpers.py +++ b/packages/claude-code-plugin/hooks/lib/hud_helpers.py @@ -17,7 +17,7 @@ import json import os from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional from hud_state import update_hud_state @@ -55,17 +55,27 @@ def read_installed_version( } +# Modes eligible for council state seeding (#1361) +_COUNCIL_ELIGIBLE_MODES = {"PLAN", "EVAL", "AUTO"} + + def on_mode_entry( mode: str, *, + council_preset: Optional[Dict] = None, state_file: Optional[str] = None, ) -> None: """Reset workflow fields when a new mode is entered. Called from UserPromptSubmit after mode keyword detection. + When *council_preset* is supplied and the mode is eligible + (PLAN/EVAL/AUTO), council state is seeded immediately so + downstream surfaces can render a first-scene (#1361). Args: mode: The detected mode (PLAN, ACT, EVAL, AUTO). + council_preset: Optional dict with ``primary`` (str) and + ``specialists`` (list[str]) keys. Ignored for ACT mode. state_file: Optional explicit path; uses default when None. """ try: @@ -84,6 +94,16 @@ def on_mode_entry( "councilStage": "", "councilCast": [], } + + # Seed council state for eligible modes (#1361) + if council_preset and mode.upper() in _COUNCIL_ELIGIBLE_MODES: + primary = council_preset.get("primary", "") + specialists = council_preset.get("specialists", []) + cast = [primary] + list(specialists) if primary else list(specialists) + kwargs["councilActive"] = True + kwargs["councilStage"] = "opening" + kwargs["councilCast"] = cast + if state_file: update_hud_state(state_file=state_file, **kwargs) else: diff --git a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py index 736942ce..2577f06e 100644 --- a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py +++ b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py @@ -369,6 +369,143 @@ def test_mcp_backend_marker(self): assert "# Backend: standalone" not in result.stdout +class TestCouncilStateSeedingIntegration: + """#1361: UserPromptSubmit seeds council state for eligible modes.""" + + def _run_hook_with_hud(self, prompt, home_dir, mcp_enabled, hud_state_file): + """Run hook with a custom HOME and HUD state file.""" + import os as _os + + hook_path = Path(__file__).parent / "user-prompt-submit.py" + input_data = json.dumps({"prompt": prompt}) + env = _os.environ.copy() + env["HOME"] = str(home_dir) + env["CLAUDE_PROJECT_DIR"] = str(home_dir) + env["CODINGBUDDY_HUD_STATE_FILE"] = str(hud_state_file) + env.pop("CODINGBUDDY_RULES_DIR", None) + return subprocess.run( + [sys.executable, str(hook_path)], + input=input_data, + capture_output=True, + text=True, + env=env, + cwd=str(home_dir), + ) + + def _init_hud_state(self, hud_file): + """Create a minimal HUD state file.""" + import os as _os + _hooks_dir = Path(__file__).parent + _lib_dir = _hooks_dir / "lib" + if str(_lib_dir) not in sys.path: + sys.path.insert(0, str(_lib_dir)) + from hud_state import init_hud_state + init_hud_state("test-session", "5.0.0", state_file=str(hud_file)) + + def _read_hud(self, hud_file): + return json.loads(Path(hud_file).read_text()) + + def test_plan_mode_seeds_council_in_standalone(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + hud_file = Path(tmpdir) / "hud-state.json" + self._init_hud_state(hud_file) + + result = self._run_hook_with_hud( + "PLAN: test", tmpdir, mcp_enabled=False, hud_state_file=hud_file, + ) + assert result.returncode == 0 + + state = self._read_hud(hud_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + assert len(state["councilCast"]) > 0 + assert state["councilCast"][0] == "technical-planner" + + def test_plan_mode_seeds_council_in_mcp(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + mcp_json = claude_dir / "mcp.json" + mcp_json.write_text(json.dumps({ + "mcpServers": { + "codingbuddy": {"command": "codingbuddy", "args": ["mcp"]} + } + })) + hud_file = Path(tmpdir) / "hud-state.json" + self._init_hud_state(hud_file) + + result = self._run_hook_with_hud( + "PLAN: test", tmpdir, mcp_enabled=True, hud_state_file=hud_file, + ) + assert result.returncode == 0 + + state = self._read_hud(hud_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + assert state["councilCast"][0] == "technical-planner" + + def test_act_mode_does_not_seed_council(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + hud_file = Path(tmpdir) / "hud-state.json" + self._init_hud_state(hud_file) + + result = self._run_hook_with_hud( + "ACT: implement", tmpdir, mcp_enabled=False, hud_state_file=hud_file, + ) + assert result.returncode == 0 + + state = self._read_hud(hud_file) + assert state["councilActive"] is False + assert state["councilCast"] == [] + + def test_eval_mode_seeds_council(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + hud_file = Path(tmpdir) / "hud-state.json" + self._init_hud_state(hud_file) + + result = self._run_hook_with_hud( + "EVAL: review", tmpdir, mcp_enabled=False, hud_state_file=hud_file, + ) + assert result.returncode == 0 + + state = self._read_hud(hud_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + assert state["councilCast"][0] == "code-reviewer" + + def test_auto_mode_seeds_council(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + hud_file = Path(tmpdir) / "hud-state.json" + self._init_hud_state(hud_file) + + result = self._run_hook_with_hud( + "AUTO: build", tmpdir, mcp_enabled=False, hud_state_file=hud_file, + ) + assert result.returncode == 0 + + state = self._read_hud(hud_file) + assert state["councilActive"] is True + assert state["councilCast"][0] == "auto-mode" + + if __name__ == "__main__": import pytest pytest.main([__file__, "-v"]) diff --git a/packages/claude-code-plugin/hooks/tests/test_hud_helpers.py b/packages/claude-code-plugin/hooks/tests/test_hud_helpers.py index 3ab82b78..4d40fd35 100644 --- a/packages/claude-code-plugin/hooks/tests/test_hud_helpers.py +++ b/packages/claude-code-plugin/hooks/tests/test_hud_helpers.py @@ -430,6 +430,89 @@ def test_noop_when_no_args(self, state_file): # ---- Full lifecycle transition test ---- +class TestOnModeEntryCouncilSeeding: + """UserPromptSubmit: on_mode_entry seeds council state for eligible modes (#1361).""" + + SAMPLE_PRESET = { + "primary": "technical-planner", + "specialists": ["architecture-specialist", "security-specialist"], + } + + def test_plan_mode_seeds_council_when_preset_provided(self, state_file): + on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + assert state["councilCast"] == [ + "technical-planner", + "architecture-specialist", + "security-specialist", + ] + + def test_eval_mode_seeds_council_when_preset_provided(self, state_file): + preset = { + "primary": "code-reviewer", + "specialists": ["security-specialist", "performance-specialist"], + } + on_mode_entry("EVAL", council_preset=preset, state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + assert state["councilCast"] == [ + "code-reviewer", + "security-specialist", + "performance-specialist", + ] + + def test_auto_mode_seeds_council_when_preset_provided(self, state_file): + preset = { + "primary": "auto-mode", + "specialists": ["architecture-specialist", "code-quality-specialist"], + } + on_mode_entry("AUTO", council_preset=preset, state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" + + def test_act_mode_does_not_seed_council_even_with_preset(self, state_file): + on_mode_entry("ACT", council_preset=self.SAMPLE_PRESET, state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is False + assert state["councilStage"] == "" + assert state["councilCast"] == [] + + def test_no_preset_keeps_council_inactive_for_plan(self, state_file): + on_mode_entry("PLAN", state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is False + assert state["councilStage"] == "" + assert state["councilCast"] == [] + + def test_council_cast_includes_primary_and_specialists(self, state_file): + on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file) + state = _read(state_file) + cast = state["councilCast"] + assert cast[0] == "technical-planner" # primary first + assert "architecture-specialist" in cast + assert "security-specialist" in cast + assert len(cast) == 3 # 1 primary + 2 specialists + + def test_mode_entry_resets_then_seeds(self, state_file): + """Council fields should be reset first, then seeded from preset.""" + from hud_state import update_hud_state + update_hud_state( + state_file=state_file, + councilActive=True, + councilStage="consensus", + councilCast=["old-agent"], + ) + on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file) + state = _read(state_file) + assert state["councilActive"] is True + assert state["councilStage"] == "opening" # reset to opening, not consensus + assert "old-agent" not in state["councilCast"] + + class TestFullLifecycle: """End-to-end test of HUD state through a complete session lifecycle.""" diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index 097bc0eb..142a2238 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -66,9 +66,11 @@ def main(): if _lib_dir not in sys.path: sys.path.insert(0, _lib_dir) + council_preset = None + try: from runtime_mode import is_mcp_available - from mode_engine import ModeEngine + from mode_engine import ModeEngine, COUNCIL_PRESETS project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()) if is_mcp_available(project_dir=project_dir): @@ -79,6 +81,8 @@ def main(): "If mcp__codingbuddy__parse_mode is available, " "call it for enhanced features." ) + # MCP council preset for eligible modes (#1361) + council_preset = COUNCIL_PRESETS.get(detected_mode) else: # Standalone mode: full enriched instructions. # Diagnostic marker (#1384): make it obvious to users that @@ -88,6 +92,12 @@ def main(): engine = ModeEngine(cwd=project_dir) instructions = engine.build_instructions(detected_mode) print(instructions) + # Standalone council preset from Tiny Actor presets (#1361) + try: + from tiny_actor_presets import CAST_PRESETS + council_preset = CAST_PRESETS.get(detected_mode) + except Exception: + council_preset = COUNCIL_PRESETS.get(detected_mode) except Exception: # Fallback: minimal instruction if imports fail. # Still mark this as the standalone-minimal path so it is @@ -99,14 +109,19 @@ def main(): "call it for enhanced features." ) - # Update HUD state with detected mode and reset workflow fields (#1090, #1324) + # Update HUD state with detected mode, reset workflow fields, + # and seed council state for eligible modes (#1090, #1324, #1361) try: from hud_helpers import on_mode_entry state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE") if state_file: - on_mode_entry(detected_mode, state_file=state_file) + on_mode_entry( + detected_mode, + council_preset=council_preset, + state_file=state_file, + ) else: - on_mode_entry(detected_mode) + on_mode_entry(detected_mode, council_preset=council_preset) except Exception: pass