Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/claude-code-plugin/hooks/lib/hud_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
137 changes: 137 additions & 0 deletions packages/claude-code-plugin/hooks/test_user_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
83 changes: 83 additions & 0 deletions packages/claude-code-plugin/hooks/tests/test_hud_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
23 changes: 19 additions & 4 deletions packages/claude-code-plugin/hooks/user-prompt-submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading