diff --git a/packages/claude-code-plugin/hooks/lib/mode_engine.py b/packages/claude-code-plugin/hooks/lib/mode_engine.py index b619cc30..163b887e 100644 --- a/packages/claude-code-plugin/hooks/lib/mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/mode_engine.py @@ -5,10 +5,16 @@ Reads .ai-rules/ files directly and outputs complete mode instructions. """ +import json import os +import re from typing import Optional +# Hard limit for hook output +CHAR_LIMIT = 2000 + + # Default agents per mode DEFAULT_AGENTS = { "PLAN": {"name": "technical-planner", "title": "Technical Planner"}, @@ -117,6 +123,10 @@ def load_mode_rules(self, mode: str) -> Optional[str]: """ Read core.md and extract mode-specific section. + Finds the ``### {Mode} Mode`` header and collects lines until + hitting another ``### `` header whose name does not contain the + mode keyword (case-insensitive). + Args: mode: Mode name (PLAN, ACT, EVAL, AUTO) @@ -132,10 +142,38 @@ def load_mode_rules(self, mode: str) -> Optional[str]: try: with open(core_path, "r", encoding="utf-8") as f: - return f.read() + content = f.read() except OSError: return None + mode_upper = mode.upper() + mode_headers = { + "PLAN": "### Plan Mode", + "ACT": "### Act Mode", + "EVAL": "### Eval Mode", + "AUTO": "### Auto Mode", + } + + header = mode_headers.get(mode_upper) + if not header: + return None + + start = content.find(header) + if start == -1: + return None + + # Collect from header until the next ### header unrelated to this mode + after_header = start + len(header) + mode_word = mode_upper.lower() + lines = content[after_header:].split("\n") + collected: list[str] = [header] + for line in lines: + if re.match(r"^### ", line) and mode_word not in line.lower(): + break + collected.append(line) + + return "\n".join(collected).strip() or None + def get_default_agent(self, mode: str) -> dict: """ Return default agent for the given mode. @@ -149,18 +187,48 @@ def get_default_agent(self, mode: str) -> dict: mode_upper = mode.upper() return DEFAULT_AGENTS.get(mode_upper, DEFAULT_AGENTS["ACT"]) + def _load_agent_details(self, agent_name: str) -> Optional[dict]: + """ + Load agent profile from ``.ai-rules/agents/{agent_name}.json``. + + Args: + agent_name: Agent file stem (e.g. ``technical-planner``). + + Returns: + Dict with ``name``, ``description``, ``expertise`` keys, + or None if the file is missing / unreadable. + """ + if not self.rules_dir: + return None + + agent_path = os.path.join(self.rules_dir, "agents", f"{agent_name}.json") + if not os.path.isfile(agent_path): + return None + + try: + with open(agent_path, "r", encoding="utf-8") as f: + data = json.load(f) + return { + "name": data.get("name", agent_name), + "description": data.get("description", ""), + "expertise": data.get("role", {}).get("expertise", []), + } + except (OSError, json.JSONDecodeError, ValueError): + return None + def build_instructions(self, mode: str) -> str: """ Build complete mode instructions for hook output. - Output is kept within ~2000 char limit for UserPromptSubmit hooks. - Falls back to minimal template if .ai-rules/ is not found. + Enriches the static template with actual ``.ai-rules/`` data when + available and enforces the ``CHAR_LIMIT`` (2000) ceiling. Falls + back to the minimal template when ``.ai-rules/`` is absent. Args: mode: Mode name (PLAN, ACT, EVAL, AUTO) Returns: - Complete mode instructions string. + Complete mode instructions string (≤ 2000 chars). """ mode_upper = mode.upper() agent = self.get_default_agent(mode_upper) @@ -168,10 +236,50 @@ def build_instructions(self, mode: str) -> str: instructions = template.format(agent_name=agent["name"]) - # Add MCP enhancement hint + # Enrich with .ai-rules data + enrichment = self._build_rules_snippet(mode_upper, agent["name"]) + if enrichment: + instructions += "\n\n" + enrichment + + # MCP enhancement hint (always last) mcp_hint = ( "\n\nIf mcp__codingbuddy__parse_mode is available, " "call it for enhanced features (checklists, specialist agents, context tracking)." ) - return instructions + mcp_hint + result = instructions + mcp_hint + + # Enforce hard limit + if len(result) > CHAR_LIMIT: + result = result[: CHAR_LIMIT - 3] + "..." + + return result + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _build_rules_snippet(self, mode: str, agent_name: str) -> Optional[str]: + """Compose an enrichment snippet from .ai-rules data.""" + parts: list[str] = [] + + # Agent expertise + details = self._load_agent_details(agent_name) + if details and details.get("expertise"): + expertise_str = ", ".join(details["expertise"][:5]) + parts.append(f"Agent expertise: {expertise_str}") + + # Mode rules — extract key bullet points + rules_text = self.load_mode_rules(mode) + if rules_text: + key_lines: list[str] = [] + for line in rules_text.split("\n")[1:]: # skip header + stripped = line.strip() + if stripped.startswith(("**", "- ")): + key_lines.append(stripped) + if len(key_lines) >= 6: + break + if key_lines: + parts.append("From .ai-rules:\n" + "\n".join(key_lines)) + + return "\n\n".join(parts) if parts else None diff --git a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py index 2d282ede..b73b209c 100644 --- a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py @@ -1,10 +1,11 @@ """Tests for ModeEngine — self-contained mode instruction generator.""" +import json import os import tempfile import unittest -from mode_engine import ModeEngine, _resolve_rules_dir, DEFAULT_AGENTS, MODE_TEMPLATES +from mode_engine import ModeEngine, _resolve_rules_dir, DEFAULT_AGENTS, MODE_TEMPLATES, CHAR_LIMIT class TestResolveRulesDir(unittest.TestCase): @@ -118,48 +119,271 @@ def test_case_insensitive_mode(self): class TestModeEngineLoadRules(unittest.TestCase): - """Test loading rules from filesystem.""" + """Test loading rules from filesystem — section extraction.""" + + SAMPLE_CORE = ( + "# Core Rules\n" + "### Plan Mode\n" + "**Important:** PLAN is the default\n" + "- Create actionable plans\n" + "### API Assumption Verification (PLAN mode)\n" + "Check assumptions\n" + "### Act Mode\n" + "**Important:** ACT executes the plan\n" + "- Red -> Green -> Refactor\n" + "### Branch Discipline\n" + "Safety rules here\n" + "### Eval Mode\n" + "**Important:** EVAL reviews\n" + "- Check code quality\n" + "### Auto Mode\n" + "**Important:** AUTO is autonomous\n" + "- PLAN -> ACT -> EVAL cycle\n" + "### Communication Rules\n" + "General stuff\n" + ) + + def _make_rules_dir(self, tmpdir, core_content=None): + rules_dir = os.path.join(tmpdir, "rules") + os.makedirs(rules_dir) + with open(os.path.join(rules_dir, "core.md"), "w") as f: + f.write(core_content or self.SAMPLE_CORE) + return tmpdir def test_load_with_nonexistent_rules_dir_returns_none(self): engine = ModeEngine(rules_dir="/nonexistent/path/nowhere") result = engine.load_mode_rules("PLAN") self.assertIsNone(result) - def test_load_with_valid_core_md(self): + def test_load_missing_core_md_returns_none(self): with tempfile.TemporaryDirectory() as tmpdir: - rules_dir = os.path.join(tmpdir, "rules") - os.makedirs(rules_dir) - core_path = os.path.join(rules_dir, "core.md") - with open(core_path, "w") as f: - f.write("# Core Rules\n## PLAN\nPlan rules here") - engine = ModeEngine(rules_dir=tmpdir) result = engine.load_mode_rules("PLAN") + self.assertIsNone(result) + + def test_extracts_plan_section(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.load_mode_rules("PLAN") self.assertIsNotNone(result) - self.assertIn("Core Rules", result) + self.assertIn("### Plan Mode", result) + self.assertIn("Create actionable plans", result) + # Should include PLAN-related sub-section + self.assertIn("API Assumption Verification", result) + # Should NOT include Act Mode content + self.assertNotIn("### Act Mode", result) + + def test_extracts_act_section(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.load_mode_rules("ACT") + self.assertIsNotNone(result) + self.assertIn("### Act Mode", result) + self.assertIn("Red -> Green -> Refactor", result) + self.assertNotIn("### Eval Mode", result) - def test_load_missing_core_md_returns_none(self): + def test_extracts_eval_section(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.load_mode_rules("EVAL") + self.assertIsNotNone(result) + self.assertIn("### Eval Mode", result) + self.assertIn("Check code quality", result) + self.assertNotIn("### Auto Mode", result) + + def test_extracts_auto_section(self): with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.load_mode_rules("AUTO") + self.assertIsNotNone(result) + self.assertIn("### Auto Mode", result) + self.assertIn("PLAN -> ACT -> EVAL cycle", result) + + def test_unknown_mode_returns_none(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.load_mode_rules("UNKNOWN") + self.assertIsNone(result) + + def test_no_rules_dir_returns_none(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Empty dir has no rules/core.md → returns None engine = ModeEngine(rules_dir=tmpdir) result = engine.load_mode_rules("PLAN") self.assertIsNone(result) +class TestLoadAgentDetails(unittest.TestCase): + """Test _load_agent_details from agent JSON files.""" + + SAMPLE_AGENT = { + "name": "Technical Planner", + "description": "Low-level implementation planning", + "role": { + "title": "Technical Planner", + "expertise": ["Planning", "TDD Strategy", "Task Decomposition"], + }, + } + + def _make_agent(self, tmpdir, agent_name, data=None): + agents_dir = os.path.join(tmpdir, "agents") + os.makedirs(agents_dir, exist_ok=True) + with open(os.path.join(agents_dir, f"{agent_name}.json"), "w") as f: + json.dump(data or self.SAMPLE_AGENT, f) + return tmpdir + + def test_loads_agent_details(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_agent(tmpdir, "technical-planner") + engine = ModeEngine(rules_dir=rd) + result = engine._load_agent_details("technical-planner") + self.assertIsNotNone(result) + self.assertEqual(result["name"], "Technical Planner") + self.assertEqual(result["description"], "Low-level implementation planning") + self.assertIn("TDD Strategy", result["expertise"]) + + def test_returns_none_for_missing_agent(self): + with tempfile.TemporaryDirectory() as tmpdir: + os.makedirs(os.path.join(tmpdir, "agents")) + engine = ModeEngine(rules_dir=tmpdir) + result = engine._load_agent_details("nonexistent-agent") + self.assertIsNone(result) + + def test_returns_none_without_agents_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Empty dir has no agents/ → returns None + engine = ModeEngine(rules_dir=tmpdir) + result = engine._load_agent_details("technical-planner") + self.assertIsNone(result) + + def test_handles_malformed_json(self): + with tempfile.TemporaryDirectory() as tmpdir: + agents_dir = os.path.join(tmpdir, "agents") + os.makedirs(agents_dir) + with open(os.path.join(agents_dir, "bad.json"), "w") as f: + f.write("{invalid json") + engine = ModeEngine(rules_dir=tmpdir) + result = engine._load_agent_details("bad") + self.assertIsNone(result) + + def test_handles_missing_role_key(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._make_agent(tmpdir, "minimal", {"name": "Minimal"}) + engine = ModeEngine(rules_dir=rd) + result = engine._load_agent_details("minimal") + self.assertIsNotNone(result) + self.assertEqual(result["expertise"], []) + + +class TestBuildInstructionsEnriched(unittest.TestCase): + """Test build_instructions with .ai-rules data present.""" + + SAMPLE_CORE = ( + "### Plan Mode\n" + "**Important:** PLAN is the default\n" + "- Create actionable plans\n" + "- Define test cases first\n" + "### Act Mode\n" + "**Important:** ACT executes\n" + "- Red -> Green -> Refactor\n" + "### Eval Mode\n" + "**Important:** EVAL reviews\n" + "- Check quality\n" + "### Auto Mode\n" + "**Important:** AUTO is autonomous\n" + "- Cycle until done\n" + ) + + SAMPLE_AGENT = { + "name": "Technical Planner", + "description": "Planning specialist", + "role": {"expertise": ["Planning", "TDD", "Decomposition"]}, + } + + def _setup_rules_dir(self, tmpdir): + rules_dir = os.path.join(tmpdir, "rules") + os.makedirs(rules_dir) + with open(os.path.join(rules_dir, "core.md"), "w") as f: + f.write(self.SAMPLE_CORE) + agents_dir = os.path.join(tmpdir, "agents") + os.makedirs(agents_dir) + with open(os.path.join(agents_dir, "technical-planner.json"), "w") as f: + json.dump(self.SAMPLE_AGENT, f) + return tmpdir + + def test_includes_rules_content_when_present(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._setup_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.build_instructions("PLAN") + self.assertIn("From .ai-rules:", result) + + def test_includes_agent_expertise(self): + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._setup_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + result = engine.build_instructions("PLAN") + self.assertIn("Agent expertise:", result) + self.assertIn("Planning", result) + + def test_falls_back_without_rules_data(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Empty dir → no enrichment data available + engine = ModeEngine(rules_dir=tmpdir) + result = engine.build_instructions("PLAN") + self.assertIn("# Mode: PLAN", result) + self.assertNotIn("From .ai-rules:", result) + + def test_output_never_exceeds_char_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + # Create oversized core.md to stress the limit + rules_dir = os.path.join(tmpdir, "rules") + os.makedirs(rules_dir) + big_content = "### Plan Mode\n" + ("- Rule line\n" * 500) + big_content += "### Act Mode\n" + ("- Rule line\n" * 500) + big_content += "### Eval Mode\n" + ("- Rule line\n" * 500) + big_content += "### Auto Mode\n" + ("- Rule line\n" * 500) + with open(os.path.join(rules_dir, "core.md"), "w") as f: + f.write(big_content) + engine = ModeEngine(rules_dir=tmpdir) + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = engine.build_instructions(mode) + self.assertLessEqual( + len(result), CHAR_LIMIT, f"{mode} exceeds {CHAR_LIMIT} chars" + ) + + def test_all_modes_within_limit_with_real_rules(self): + """Even with full .ai-rules data, every mode stays within limit.""" + with tempfile.TemporaryDirectory() as tmpdir: + rd = self._setup_rules_dir(tmpdir) + engine = ModeEngine(rules_dir=rd) + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = engine.build_instructions(mode) + self.assertLessEqual(len(result), CHAR_LIMIT) + + class TestModeEngineGracefulFallback(unittest.TestCase): """Test graceful degradation when .ai-rules/ is missing.""" - def test_build_instructions_without_rules_dir(self): - engine = ModeEngine(rules_dir=None) - result = engine.build_instructions("PLAN") - # Should still produce valid instructions from templates - self.assertIn("# Mode: PLAN", result) - self.assertIn("technical-planner", result) + def test_build_instructions_without_rules_data(self): + with tempfile.TemporaryDirectory() as tmpdir: + engine = ModeEngine(rules_dir=tmpdir) + result = engine.build_instructions("PLAN") + # Should still produce valid instructions from templates + self.assertIn("# Mode: PLAN", result) + self.assertIn("technical-planner", result) - def test_all_modes_work_without_rules_dir(self): - engine = ModeEngine(rules_dir=None) - for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: - result = engine.build_instructions(mode) - self.assertTrue(len(result) > 0, f"{mode} produced empty output") + def test_all_modes_work_without_rules_data(self): + with tempfile.TemporaryDirectory() as tmpdir: + engine = ModeEngine(rules_dir=tmpdir) + for mode in ["PLAN", "ACT", "EVAL", "AUTO"]: + result = engine.build_instructions(mode) + self.assertTrue(len(result) > 0, f"{mode} produced empty output") if __name__ == "__main__":