Skip to content

Commit ab22313

Browse files
committed
feat(plugin): seed request-level council state from UserPromptSubmit (#1361)
When entering PLAN, EVAL, or AUTO mode, UserPromptSubmit now seeds council HUD state (councilActive, councilStage, councilCast) so downstream surfaces can render a first-scene immediately. - MCP mode: uses COUNCIL_PRESETS from mode_engine - Standalone mode: uses CAST_PRESETS from tiny_actor_presets - ACT mode: council remains inactive (compact/opt-in)
1 parent 37f618c commit ab22313

4 files changed

Lines changed: 260 additions & 5 deletions

File tree

packages/claude-code-plugin/hooks/lib/hud_helpers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import json
1818
import os
1919
from pathlib import Path
20-
from typing import List, Optional
20+
from typing import Dict, List, Optional
2121

2222
from hud_state import update_hud_state
2323

@@ -55,17 +55,27 @@ def read_installed_version(
5555
}
5656

5757

58+
# Modes eligible for council state seeding (#1361)
59+
_COUNCIL_ELIGIBLE_MODES = {"PLAN", "EVAL", "AUTO"}
60+
61+
5862
def on_mode_entry(
5963
mode: str,
6064
*,
65+
council_preset: Optional[Dict] = None,
6166
state_file: Optional[str] = None,
6267
) -> None:
6368
"""Reset workflow fields when a new mode is entered.
6469
6570
Called from UserPromptSubmit after mode keyword detection.
71+
When *council_preset* is supplied and the mode is eligible
72+
(PLAN/EVAL/AUTO), council state is seeded immediately so
73+
downstream surfaces can render a first-scene (#1361).
6674
6775
Args:
6876
mode: The detected mode (PLAN, ACT, EVAL, AUTO).
77+
council_preset: Optional dict with ``primary`` (str) and
78+
``specialists`` (list[str]) keys. Ignored for ACT mode.
6979
state_file: Optional explicit path; uses default when None.
7080
"""
7181
try:
@@ -84,6 +94,16 @@ def on_mode_entry(
8494
"councilStage": "",
8595
"councilCast": [],
8696
}
97+
98+
# Seed council state for eligible modes (#1361)
99+
if council_preset and mode.upper() in _COUNCIL_ELIGIBLE_MODES:
100+
primary = council_preset.get("primary", "")
101+
specialists = council_preset.get("specialists", [])
102+
cast = [primary] + list(specialists) if primary else list(specialists)
103+
kwargs["councilActive"] = True
104+
kwargs["councilStage"] = "opening"
105+
kwargs["councilCast"] = cast
106+
87107
if state_file:
88108
update_hud_state(state_file=state_file, **kwargs)
89109
else:

packages/claude-code-plugin/hooks/test_user_prompt_submit.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,143 @@ def test_mcp_backend_marker(self):
369369
assert "# Backend: standalone" not in result.stdout
370370

371371

372+
class TestCouncilStateSeedingIntegration:
373+
"""#1361: UserPromptSubmit seeds council state for eligible modes."""
374+
375+
def _run_hook_with_hud(self, prompt, home_dir, mcp_enabled, hud_state_file):
376+
"""Run hook with a custom HOME and HUD state file."""
377+
import os as _os
378+
379+
hook_path = Path(__file__).parent / "user-prompt-submit.py"
380+
input_data = json.dumps({"prompt": prompt})
381+
env = _os.environ.copy()
382+
env["HOME"] = str(home_dir)
383+
env["CLAUDE_PROJECT_DIR"] = str(home_dir)
384+
env["CODINGBUDDY_HUD_STATE_FILE"] = str(hud_state_file)
385+
env.pop("CODINGBUDDY_RULES_DIR", None)
386+
return subprocess.run(
387+
[sys.executable, str(hook_path)],
388+
input=input_data,
389+
capture_output=True,
390+
text=True,
391+
env=env,
392+
cwd=str(home_dir),
393+
)
394+
395+
def _init_hud_state(self, hud_file):
396+
"""Create a minimal HUD state file."""
397+
import os as _os
398+
_hooks_dir = Path(__file__).parent
399+
_lib_dir = _hooks_dir / "lib"
400+
if str(_lib_dir) not in sys.path:
401+
sys.path.insert(0, str(_lib_dir))
402+
from hud_state import init_hud_state
403+
init_hud_state("test-session", "5.0.0", state_file=str(hud_file))
404+
405+
def _read_hud(self, hud_file):
406+
return json.loads(Path(hud_file).read_text())
407+
408+
def test_plan_mode_seeds_council_in_standalone(self):
409+
import tempfile
410+
411+
with tempfile.TemporaryDirectory() as tmpdir:
412+
claude_dir = Path(tmpdir) / ".claude"
413+
claude_dir.mkdir()
414+
hud_file = Path(tmpdir) / "hud-state.json"
415+
self._init_hud_state(hud_file)
416+
417+
result = self._run_hook_with_hud(
418+
"PLAN: test", tmpdir, mcp_enabled=False, hud_state_file=hud_file,
419+
)
420+
assert result.returncode == 0
421+
422+
state = self._read_hud(hud_file)
423+
assert state["councilActive"] is True
424+
assert state["councilStage"] == "opening"
425+
assert len(state["councilCast"]) > 0
426+
assert state["councilCast"][0] == "technical-planner"
427+
428+
def test_plan_mode_seeds_council_in_mcp(self):
429+
import tempfile
430+
431+
with tempfile.TemporaryDirectory() as tmpdir:
432+
claude_dir = Path(tmpdir) / ".claude"
433+
claude_dir.mkdir()
434+
mcp_json = claude_dir / "mcp.json"
435+
mcp_json.write_text(json.dumps({
436+
"mcpServers": {
437+
"codingbuddy": {"command": "codingbuddy", "args": ["mcp"]}
438+
}
439+
}))
440+
hud_file = Path(tmpdir) / "hud-state.json"
441+
self._init_hud_state(hud_file)
442+
443+
result = self._run_hook_with_hud(
444+
"PLAN: test", tmpdir, mcp_enabled=True, hud_state_file=hud_file,
445+
)
446+
assert result.returncode == 0
447+
448+
state = self._read_hud(hud_file)
449+
assert state["councilActive"] is True
450+
assert state["councilStage"] == "opening"
451+
assert state["councilCast"][0] == "technical-planner"
452+
453+
def test_act_mode_does_not_seed_council(self):
454+
import tempfile
455+
456+
with tempfile.TemporaryDirectory() as tmpdir:
457+
claude_dir = Path(tmpdir) / ".claude"
458+
claude_dir.mkdir()
459+
hud_file = Path(tmpdir) / "hud-state.json"
460+
self._init_hud_state(hud_file)
461+
462+
result = self._run_hook_with_hud(
463+
"ACT: implement", tmpdir, mcp_enabled=False, hud_state_file=hud_file,
464+
)
465+
assert result.returncode == 0
466+
467+
state = self._read_hud(hud_file)
468+
assert state["councilActive"] is False
469+
assert state["councilCast"] == []
470+
471+
def test_eval_mode_seeds_council(self):
472+
import tempfile
473+
474+
with tempfile.TemporaryDirectory() as tmpdir:
475+
claude_dir = Path(tmpdir) / ".claude"
476+
claude_dir.mkdir()
477+
hud_file = Path(tmpdir) / "hud-state.json"
478+
self._init_hud_state(hud_file)
479+
480+
result = self._run_hook_with_hud(
481+
"EVAL: review", tmpdir, mcp_enabled=False, hud_state_file=hud_file,
482+
)
483+
assert result.returncode == 0
484+
485+
state = self._read_hud(hud_file)
486+
assert state["councilActive"] is True
487+
assert state["councilStage"] == "opening"
488+
assert state["councilCast"][0] == "code-reviewer"
489+
490+
def test_auto_mode_seeds_council(self):
491+
import tempfile
492+
493+
with tempfile.TemporaryDirectory() as tmpdir:
494+
claude_dir = Path(tmpdir) / ".claude"
495+
claude_dir.mkdir()
496+
hud_file = Path(tmpdir) / "hud-state.json"
497+
self._init_hud_state(hud_file)
498+
499+
result = self._run_hook_with_hud(
500+
"AUTO: build", tmpdir, mcp_enabled=False, hud_state_file=hud_file,
501+
)
502+
assert result.returncode == 0
503+
504+
state = self._read_hud(hud_file)
505+
assert state["councilActive"] is True
506+
assert state["councilCast"][0] == "auto-mode"
507+
508+
372509
if __name__ == "__main__":
373510
import pytest
374511
pytest.main([__file__, "-v"])

packages/claude-code-plugin/hooks/tests/test_hud_helpers.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,89 @@ def test_noop_when_no_args(self, state_file):
430430

431431
# ---- Full lifecycle transition test ----
432432

433+
class TestOnModeEntryCouncilSeeding:
434+
"""UserPromptSubmit: on_mode_entry seeds council state for eligible modes (#1361)."""
435+
436+
SAMPLE_PRESET = {
437+
"primary": "technical-planner",
438+
"specialists": ["architecture-specialist", "security-specialist"],
439+
}
440+
441+
def test_plan_mode_seeds_council_when_preset_provided(self, state_file):
442+
on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file)
443+
state = _read(state_file)
444+
assert state["councilActive"] is True
445+
assert state["councilStage"] == "opening"
446+
assert state["councilCast"] == [
447+
"technical-planner",
448+
"architecture-specialist",
449+
"security-specialist",
450+
]
451+
452+
def test_eval_mode_seeds_council_when_preset_provided(self, state_file):
453+
preset = {
454+
"primary": "code-reviewer",
455+
"specialists": ["security-specialist", "performance-specialist"],
456+
}
457+
on_mode_entry("EVAL", council_preset=preset, state_file=state_file)
458+
state = _read(state_file)
459+
assert state["councilActive"] is True
460+
assert state["councilStage"] == "opening"
461+
assert state["councilCast"] == [
462+
"code-reviewer",
463+
"security-specialist",
464+
"performance-specialist",
465+
]
466+
467+
def test_auto_mode_seeds_council_when_preset_provided(self, state_file):
468+
preset = {
469+
"primary": "auto-mode",
470+
"specialists": ["architecture-specialist", "code-quality-specialist"],
471+
}
472+
on_mode_entry("AUTO", council_preset=preset, state_file=state_file)
473+
state = _read(state_file)
474+
assert state["councilActive"] is True
475+
assert state["councilStage"] == "opening"
476+
477+
def test_act_mode_does_not_seed_council_even_with_preset(self, state_file):
478+
on_mode_entry("ACT", council_preset=self.SAMPLE_PRESET, state_file=state_file)
479+
state = _read(state_file)
480+
assert state["councilActive"] is False
481+
assert state["councilStage"] == ""
482+
assert state["councilCast"] == []
483+
484+
def test_no_preset_keeps_council_inactive_for_plan(self, state_file):
485+
on_mode_entry("PLAN", state_file=state_file)
486+
state = _read(state_file)
487+
assert state["councilActive"] is False
488+
assert state["councilStage"] == ""
489+
assert state["councilCast"] == []
490+
491+
def test_council_cast_includes_primary_and_specialists(self, state_file):
492+
on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file)
493+
state = _read(state_file)
494+
cast = state["councilCast"]
495+
assert cast[0] == "technical-planner" # primary first
496+
assert "architecture-specialist" in cast
497+
assert "security-specialist" in cast
498+
assert len(cast) == 3 # 1 primary + 2 specialists
499+
500+
def test_mode_entry_resets_then_seeds(self, state_file):
501+
"""Council fields should be reset first, then seeded from preset."""
502+
from hud_state import update_hud_state
503+
update_hud_state(
504+
state_file=state_file,
505+
councilActive=True,
506+
councilStage="consensus",
507+
councilCast=["old-agent"],
508+
)
509+
on_mode_entry("PLAN", council_preset=self.SAMPLE_PRESET, state_file=state_file)
510+
state = _read(state_file)
511+
assert state["councilActive"] is True
512+
assert state["councilStage"] == "opening" # reset to opening, not consensus
513+
assert "old-agent" not in state["councilCast"]
514+
515+
433516
class TestFullLifecycle:
434517
"""End-to-end test of HUD state through a complete session lifecycle."""
435518

packages/claude-code-plugin/hooks/user-prompt-submit.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,11 @@ def main():
6666
if _lib_dir not in sys.path:
6767
sys.path.insert(0, _lib_dir)
6868

69+
council_preset = None
70+
6971
try:
7072
from runtime_mode import is_mcp_available
71-
from mode_engine import ModeEngine
73+
from mode_engine import ModeEngine, COUNCIL_PRESETS
7274

7375
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
7476
if is_mcp_available(project_dir=project_dir):
@@ -79,6 +81,8 @@ def main():
7981
"If mcp__codingbuddy__parse_mode is available, "
8082
"call it for enhanced features."
8183
)
84+
# MCP council preset for eligible modes (#1361)
85+
council_preset = COUNCIL_PRESETS.get(detected_mode)
8286
else:
8387
# Standalone mode: full enriched instructions.
8488
# Diagnostic marker (#1384): make it obvious to users that
@@ -88,6 +92,12 @@ def main():
8892
engine = ModeEngine(cwd=project_dir)
8993
instructions = engine.build_instructions(detected_mode)
9094
print(instructions)
95+
# Standalone council preset from Tiny Actor presets (#1361)
96+
try:
97+
from tiny_actor_presets import CAST_PRESETS
98+
council_preset = CAST_PRESETS.get(detected_mode)
99+
except Exception:
100+
council_preset = COUNCIL_PRESETS.get(detected_mode)
91101
except Exception:
92102
# Fallback: minimal instruction if imports fail.
93103
# Still mark this as the standalone-minimal path so it is
@@ -99,14 +109,19 @@ def main():
99109
"call it for enhanced features."
100110
)
101111

102-
# Update HUD state with detected mode and reset workflow fields (#1090, #1324)
112+
# Update HUD state with detected mode, reset workflow fields,
113+
# and seed council state for eligible modes (#1090, #1324, #1361)
103114
try:
104115
from hud_helpers import on_mode_entry
105116
state_file = os.environ.get("CODINGBUDDY_HUD_STATE_FILE")
106117
if state_file:
107-
on_mode_entry(detected_mode, state_file=state_file)
118+
on_mode_entry(
119+
detected_mode,
120+
council_preset=council_preset,
121+
state_file=state_file,
122+
)
108123
else:
109-
on_mode_entry(detected_mode)
124+
on_mode_entry(detected_mode, council_preset=council_preset)
110125
except Exception:
111126
pass
112127

0 commit comments

Comments
 (0)