diff --git a/packages/claude-code-plugin/hooks/lib/council_badge.py b/packages/claude-code-plugin/hooks/lib/council_badge.py new file mode 100644 index 00000000..e9edb28c --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/council_badge.py @@ -0,0 +1,149 @@ +"""Compact council badge formatter for PreToolUse statusMessage (#1367). + +Reads HUD state and builds a short, badge-style string that conveys +which agent is acting, current focus, and blocker status. + +Examples: + [โ—ฎ secu] [๐Ÿงช auth] [โš 1] + [โŠ™ test] [๐Ÿ” retry] [โœ“] +""" + +import os +from typing import Optional + +from hud_state import read_hud_state + +# Stage-specific icons for the focus badge. +_STAGE_ICON = { + "opening": "\U0001f50d", # ๐Ÿ” + "reviewing": "\U0001f9ea", # ๐Ÿงช + "consensus": "\U0001f91d", # ๐Ÿค + "done": "\u2705", # โœ… +} + +_DEFAULT_ICON = "\U0001f50d" # ๐Ÿ” + +# Common suffixes stripped before shortening. +_STRIP_SUFFIXES = ("-specialist", "-developer", "-engineer", "-agent") + +# Fallback eye when agent visual is unavailable. +_FALLBACK_EYE = "\u25c6" # โ—† + +# Maximum focus label length in badge. +_MAX_FOCUS_LEN = 12 + + +def shorten_agent_name(name: str) -> str: + """Shorten an agent name to a compact label (max 4 chars). + + Strips common suffixes and takes the first 4 characters of the + first hyphen-separated segment. + + Examples: + "security-specialist" -> "secu" + "frontend-developer" -> "fron" + "auto-mode" -> "auto" + """ + if not name: + return "" + shortened = name + for suffix in _STRIP_SUFFIXES: + shortened = shortened.replace(suffix, "") + shortened = shortened.strip("-") + first_segment = shortened.split("-")[0] + return first_segment[:4] + + +def format_council_badge( + *, + agent_eye: str, + agent_short: str, + focus: Optional[str] = None, + stage: str = "", + blocker_count: int = 0, +) -> str: + """Pure formatter: build a compact badge string. + + Args: + agent_eye: Single eye character from agent visual (e.g. โ—ฎ). + agent_short: Shortened agent name (e.g. "secu"). + focus: Current focus label (truncated to 12 chars). + stage: Council stage (opening/reviewing/consensus/done). + blocker_count: Number of outstanding blockers. + + Returns: + A single-line string like ``[โ—ฎ secu] [๐Ÿงช auth] [โš 1]``. + """ + parts = [f"[{agent_eye} {agent_short}]"] + + if focus: + icon = _STAGE_ICON.get(stage, _DEFAULT_ICON) + truncated = focus[:_MAX_FOCUS_LEN] + parts.append(f"[{icon} {truncated}]") + + if blocker_count > 0: + parts.append(f"[\u26a0{blocker_count}]") + else: + parts.append("[\u2713]") + + return " ".join(parts) + + +def build_council_badge( + *, + state_file: Optional[str] = None, + project_root: Optional[str] = None, +) -> Optional[str]: + """Read HUD state and build a council badge if the council is active. + + Returns None when the council is inactive, no active agent is set, + or the state file is unreadable. + + Respects ``CODINGBUDDY_HUD_STATE`` env var as an override for the + default state file path. + """ + kwargs = {} + if state_file: + kwargs["state_file"] = state_file + elif os.environ.get("CODINGBUDDY_HUD_STATE"): + kwargs["state_file"] = os.environ["CODINGBUDDY_HUD_STATE"] + + state = read_hud_state(fill_defaults=True, **kwargs) + if not state.get("councilActive"): + return None + + active_agent = state.get("activeAgent") or "" + if not active_agent: + return None + + eye = _get_agent_eye(active_agent, project_root) + short = shorten_agent_name(active_agent) + + return format_council_badge( + agent_eye=eye, + agent_short=short, + focus=state.get("focus"), + stage=state.get("councilStage", ""), + blocker_count=state.get("blockerCount", 0), + ) + + +def _get_agent_eye(agent_name: str, project_root: Optional[str] = None) -> str: + """Look up the single eye character for an agent. + + Falls back to โ—† if the agent JSON is missing or unreadable. + """ + try: + from agent_status import _load_agent_visual + + if project_root is None: + project_root = os.environ.get( + "CLAUDE_PROJECT_DIR", + os.environ.get("CLAUDE_CWD", os.getcwd()), + ) + visual = _load_agent_visual(agent_name, project_root) + if visual: + return visual.get("eye", _FALLBACK_EYE) + return _FALLBACK_EYE + except Exception: + return _FALLBACK_EYE diff --git a/packages/claude-code-plugin/hooks/pre-tool-use.py b/packages/claude-code-plugin/hooks/pre-tool-use.py index ffc440af..2eade928 100644 --- a/packages/claude-code-plugin/hooks/pre-tool-use.py +++ b/packages/claude-code-plugin/hooks/pre-tool-use.py @@ -201,6 +201,19 @@ def _handle(data: dict) -> Optional[dict]: else: status_msg = tdd_indicator + # Append compact council badge when council is active (#1367) + try: + from council_badge import build_council_badge + + badge = build_council_badge() + if badge: + if status_msg: + status_msg = f"{status_msg} {badge}" + else: + status_msg = badge + except Exception: + pass + tool_name = data.get("tool_name", "") contexts = [] diff --git a/packages/claude-code-plugin/tests/test_council_badge.py b/packages/claude-code-plugin/tests/test_council_badge.py new file mode 100644 index 00000000..7a070030 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_council_badge.py @@ -0,0 +1,235 @@ +"""Tests for hooks/lib/council_badge.py โ€” compact council badge formatter (#1367).""" +import json +import os +import sys + +import pytest + +# Add hooks/lib to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks", "lib")) + +from council_badge import ( + build_council_badge, + format_council_badge, + shorten_agent_name, +) + + +class TestShortenAgentName: + """Tests for agent name shortening.""" + + def test_specialist_suffix_removed(self): + assert shorten_agent_name("security-specialist") == "secu" + + def test_developer_suffix_removed(self): + assert shorten_agent_name("frontend-developer") == "fron" + + def test_engineer_suffix_removed(self): + assert shorten_agent_name("test-engineer") == "test" + + def test_multi_word_takes_first(self): + assert shorten_agent_name("code-quality-specialist") == "code" + + def test_short_name_unchanged(self): + assert shorten_agent_name("auto-mode") == "auto" + + def test_single_word(self): + assert shorten_agent_name("reviewer") == "revi" + + def test_empty_string(self): + assert shorten_agent_name("") == "" + + +class TestFormatCouncilBadge: + """Tests for the pure badge formatter.""" + + def test_basic_badge_with_all_fields(self): + result = format_council_badge( + agent_eye="\u25ae", # โ—ฎ + agent_short="secu", + focus="auth", + stage="reviewing", + blocker_count=1, + ) + assert result == "[\u25ae secu] [\U0001f9ea auth] [\u26a01]" + + def test_badge_no_focus(self): + result = format_council_badge( + agent_eye="\u2299", # โŠ™ + agent_short="test", + blocker_count=0, + ) + assert result == "[\u2299 test] [\u2713]" + + def test_badge_zero_blockers_shows_check(self): + result = format_council_badge( + agent_eye="\u25cf", + agent_short="qual", + focus="login.ts", + stage="consensus", + blocker_count=0, + ) + assert "[\u2713]" in result + + def test_badge_multiple_blockers(self): + result = format_council_badge( + agent_eye="\u25cf", + agent_short="arch", + focus="api", + blocker_count=3, + ) + assert "[\u26a03]" in result + + def test_focus_truncated_at_12_chars(self): + result = format_council_badge( + agent_eye="\u25cf", + agent_short="secu", + focus="a-very-long-focus-label", + blocker_count=0, + ) + # Focus should be truncated + assert "a-very-long-" in result + assert "a-very-long-focus-label" not in result + + def test_stage_icons(self): + """Each stage should use a different icon.""" + for stage, expected_icon in [ + ("opening", "\U0001f50d"), # ๐Ÿ” + ("reviewing", "\U0001f9ea"), # ๐Ÿงช + ("consensus", "\U0001f91d"), # ๐Ÿค + ("done", "\u2705"), # โœ… + ]: + result = format_council_badge( + agent_eye="\u25cf", + agent_short="test", + focus="x", + stage=stage, + blocker_count=0, + ) + assert expected_icon in result, f"Stage '{stage}' should use icon {expected_icon}" + + def test_unknown_stage_defaults_to_magnifier(self): + result = format_council_badge( + agent_eye="\u25cf", + agent_short="test", + focus="x", + stage="unknown", + blocker_count=0, + ) + assert "\U0001f50d" in result # ๐Ÿ” + + def test_output_is_single_line(self): + result = format_council_badge( + agent_eye="\u25ae", + agent_short="secu", + focus="auth-module", + stage="reviewing", + blocker_count=2, + ) + assert "\n" not in result + + +class TestBuildCouncilBadge: + """Tests for build_council_badge which reads HUD state.""" + + def test_returns_none_when_council_not_active(self, tmp_path): + state_file = str(tmp_path / "hud.json") + _write_state(state_file, { + "councilActive": False, + "activeAgent": "security-specialist", + }) + result = build_council_badge(state_file=state_file) + assert result is None + + def test_returns_none_when_no_active_agent(self, tmp_path): + state_file = str(tmp_path / "hud.json") + _write_state(state_file, { + "councilActive": True, + "activeAgent": None, + }) + result = build_council_badge(state_file=state_file) + assert result is None + + def test_returns_none_when_state_file_missing(self, tmp_path): + state_file = str(tmp_path / "nonexistent.json") + result = build_council_badge(state_file=state_file) + assert result is None + + def test_returns_badge_when_council_active(self, tmp_path, monkeypatch): + agents_dir = tmp_path / ".ai-rules" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-specialist.json").write_text(json.dumps({ + "name": "Security Specialist", + "visual": {"eye": "\u25ae", "colorAnsi": "red"}, + })) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) + + # Clear agent_status cache + from agent_status import clear_cache + clear_cache() + + state_file = str(tmp_path / "hud.json") + _write_state(state_file, { + "councilActive": True, + "activeAgent": "security-specialist", + "councilStage": "reviewing", + "focus": "auth", + "blockerCount": 1, + }) + result = build_council_badge(state_file=state_file) + assert result is not None + assert "\u25ae" in result # eye char + assert "secu" in result # short name + assert "auth" in result # focus + assert "\u26a01" in result # โš 1 + + def test_badge_with_no_focus(self, tmp_path, monkeypatch): + agents_dir = tmp_path / ".ai-rules" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "test-engineer.json").write_text(json.dumps({ + "name": "Test Engineer", + "visual": {"eye": "\u2299", "colorAnsi": "green"}, + })) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) + + from agent_status import clear_cache + clear_cache() + + state_file = str(tmp_path / "hud.json") + _write_state(state_file, { + "councilActive": True, + "activeAgent": "test-engineer", + "councilStage": "opening", + "focus": None, + "blockerCount": 0, + }) + result = build_council_badge(state_file=state_file) + assert result is not None + assert "test" in result + assert "[\u2713]" in result # โœ“ for zero blockers + + def test_fallback_eye_when_agent_json_missing(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) + + from agent_status import clear_cache + clear_cache() + + state_file = str(tmp_path / "hud.json") + _write_state(state_file, { + "councilActive": True, + "activeAgent": "unknown-agent", + "councilStage": "", + "focus": "api", + "blockerCount": 0, + }) + result = build_council_badge(state_file=state_file) + assert result is not None + # Should use fallback eye character + assert "\u25c6" in result # โ—† fallback + + +def _write_state(path: str, data: dict) -> None: + """Helper to write a HUD state file.""" + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) diff --git a/packages/claude-code-plugin/tests/test_pre_tool_use.py b/packages/claude-code-plugin/tests/test_pre_tool_use.py index 4f58117c..5027a077 100644 --- a/packages/claude-code-plugin/tests/test_pre_tool_use.py +++ b/packages/claude-code-plugin/tests/test_pre_tool_use.py @@ -317,6 +317,81 @@ def test_test_suggestion_shows_count_not_files(self, monkeypatch, capsys): assert ".test.ts" not in ctx +class TestPreToolUseCouncilBadge: + """Tests for council badge integration in statusMessage (#1367).""" + + def test_council_badge_in_status_when_active(self, monkeypatch, capsys, tmp_path): + """Council badge should appear in statusMessage when council is active.""" + # Set up agent visual + agents_dir = tmp_path / ".ai-rules" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "security-specialist.json").write_text(json.dumps({ + "name": "Security Specialist", + "visual": {"eye": "\u25ae", "colorAnsi": "red"}, + })) + monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "security-specialist") + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) + + from agent_status import clear_cache + clear_cache() + + # Set up HUD state with council active + state_file = str(tmp_path / "hud-state.json") + with open(state_file, "w") as f: + json.dump({ + "councilActive": True, + "activeAgent": "security-specialist", + "councilStage": "reviewing", + "focus": "auth", + "blockerCount": 1, + }, f) + + monkeypatch.setenv("CODINGBUDDY_HUD_STATE", state_file) + + result = _run_hook( + {"tool_name": "Read", "tool_input": {"file_path": "/foo"}}, + monkeypatch, capsys, + ) + assert result is not None + status = result["hookSpecificOutput"]["statusMessage"] + # Should contain both agent status AND council badge + assert "security-specialist" in status + assert "secu" in status # short name in badge + assert "\u25ae" in status # eye char + assert "auth" in status # focus + + def test_no_council_badge_when_inactive(self, monkeypatch, capsys, tmp_path): + """No council badge when council is not active.""" + agents_dir = tmp_path / ".ai-rules" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "test-agent.json").write_text(json.dumps({ + "name": "Test Agent", + "visual": {"eye": "\u2605", "colorAnsi": "yellow"}, + })) + monkeypatch.setenv("CODINGBUDDY_ACTIVE_AGENT", "test-agent") + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path)) + + from agent_status import clear_cache + clear_cache() + + # HUD state with council INactive + state_file = str(tmp_path / "hud-state.json") + with open(state_file, "w") as f: + json.dump({"councilActive": False, "activeAgent": "test-agent"}, f) + monkeypatch.setenv("CODINGBUDDY_HUD_STATE", state_file) + + result = _run_hook( + {"tool_name": "Read", "tool_input": {"file_path": "/foo"}}, + monkeypatch, capsys, + ) + assert result is not None + status = result["hookSpecificOutput"]["statusMessage"] + # Should have agent status but NO council badge markers + assert "test-agent" in status + assert "[\u2713]" not in status + assert "[\u26a0" not in status + + class TestPreToolUseStatusMessage: """Tests for agent statusMessage in hook output (#974)."""