diff --git a/packages/claude-code-plugin/hooks/lib/hud_state.py b/packages/claude-code-plugin/hooks/lib/hud_state.py index 75b9f264..f0002fdb 100644 --- a/packages/claude-code-plugin/hooks/lib/hud_state.py +++ b/packages/claude-code-plugin/hooks/lib/hud_state.py @@ -20,6 +20,8 @@ "councilActive": False, "councilStage": "", "councilCast": [], + # Clarification budget (#1371) — persisted across rounds + "questionBudget": 3, } try: @@ -93,6 +95,8 @@ def init_hud_state( "councilActive": False, "councilStage": "", "councilCast": [], + # Clarification budget (#1371) + "questionBudget": 3, "updatedAt": now, } _locked_write(state_file, data) diff --git a/packages/claude-code-plugin/hooks/lib/mode_engine.py b/packages/claude-code-plugin/hooks/lib/mode_engine.py index 54fd5d97..6e90ccfe 100644 --- a/packages/claude-code-plugin/hooks/lib/mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/mode_engine.py @@ -65,20 +65,23 @@ "PLAN": """# Mode: PLAN ## Agent: {agent_name} -You are in PLAN mode. Design the implementation approach. +You are in PLAN mode. Follow the staged planning flow. + +Stages: Discover → Design → Plan +1. DISCOVER: Surface questions, constraints, option space +2. DESIGN: Candidate approaches, trade-offs, risks +3. PLAN: Concrete step-by-step implementation plan Rules: -- Define test cases first (TDD perspective) -- Review architecture before implementation -- Output full plan in every response -- Do NOT auto-proceed to ACT — wait for user +- Start at Discover — ask before solutioning +- Advance stages only after user confirms direction - Consider alternatives for non-trivial decisions +- Do NOT auto-proceed to ACT — wait for user Checklist: -- [ ] Problem decomposed into sub-problems -- [ ] File paths identified -- [ ] TDD strategy defined -- [ ] Alternatives considered""", +- [ ] Questions and constraints surfaced (Discover) +- [ ] Approaches compared with trade-offs (Design) +- [ ] File paths identified, TDD strategy defined (Plan)""", "ACT": """# Mode: ACT ## Agent: {agent_name} @@ -281,6 +284,7 @@ def __init__(self, rules_dir: Optional[str] = None, cwd: Optional[str] = None): cwd: Working directory for resolution. Defaults to os.getcwd(). """ self.rules_dir = rules_dir or _resolve_rules_dir(cwd) + self._agent_json_cache: dict = {} def load_mode_rules(self, mode: str) -> Optional[str]: """ @@ -350,41 +354,69 @@ 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``. + def _read_agent_json(self, agent_name: str) -> Optional[dict]: + """Read and cache an agent JSON file from ``.ai-rules/agents/``. - Args: - agent_name: Agent file stem (e.g. ``technical-planner``). + Shared by ``_load_agent_details`` and ``_load_agent_eye`` to + avoid duplicate file reads (DRY) and N+1 I/O in council scene. - Returns: - Dict with ``name``, ``description``, ``expertise`` keys, - or None if the file is missing / unreadable. + Applies ``os.path.basename`` to the slug for path-traversal safety. """ + if agent_name in self._agent_json_cache: + return self._agent_json_cache[agent_name] + if not self.rules_dir: + self._agent_json_cache[agent_name] = None return None - agent_path = os.path.join(self.rules_dir, "agents", f"{agent_name}.json") + slug = agent_name.lower().replace(" ", "-").replace("_", "-") + slug = os.path.basename(slug) # path-traversal safety + agent_path = os.path.join(self.rules_dir, "agents", f"{slug}.json") if not os.path.isfile(agent_path): + self._agent_json_cache[agent_name] = None 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", []), - } + self._agent_json_cache[agent_name] = data + return data except (OSError, json.JSONDecodeError, ValueError): + self._agent_json_cache[agent_name] = None return None + def _load_agent_details(self, agent_name: str) -> Optional[dict]: + """Load agent profile from ``.ai-rules/agents/{agent_name}.json``.""" + data = self._read_agent_json(agent_name) + if not data: + return None + return { + "name": data.get("name", agent_name), + "description": data.get("description", ""), + "expertise": data.get("role", {}).get("expertise", []), + } + + def _load_agent_eye(self, agent_name: str) -> Optional[str]: + """Load the ``eye`` glyph from an agent JSON definition.""" + data = self._read_agent_json(agent_name) + if not data: + return None + return data.get("visual", {}).get("eye") + + def _make_face(self, agent_name: str) -> str: + """Build a face string from the agent eye glyph, or fall back to ●‿●.""" + eye = self._load_agent_eye(agent_name) + if eye: + return f"{eye}‿{eye}" + return "●‿●" + def build_council_scene(self, mode: str) -> Optional[dict]: """ Build council scene contract for eligible modes. Mirrors the MCP server's ``buildCouncilScene`` output so that standalone mode produces an equivalent first-response contract. + Loads real agent eye glyphs from ``.ai-rules/agents/*.json``. Args: mode: Mode name (PLAN, ACT, EVAL, AUTO) @@ -402,11 +434,13 @@ def build_council_scene(self, mode: str) -> Optional[dict]: return None cast = [ - {"name": preset["primary"], "role": "primary", "face": "●‿●"} + {"name": preset["primary"], "role": "primary", + "face": self._make_face(preset["primary"])} ] for specialist in preset["specialists"]: cast.append( - {"name": specialist, "role": "specialist", "face": "●‿●"} + {"name": specialist, "role": "specialist", + "face": self._make_face(specialist)} ) return { diff --git a/packages/claude-code-plugin/hooks/lib/permission_forecast.py b/packages/claude-code-plugin/hooks/lib/permission_forecast.py index f300eff3..bf757c75 100644 --- a/packages/claude-code-plugin/hooks/lib/permission_forecast.py +++ b/packages/claude-code-plugin/hooks/lib/permission_forecast.py @@ -8,9 +8,45 @@ from __future__ import annotations +import re from typing import Dict, List, Optional, Sequence +# ─── Prompt-signal patterns (mirrors MCP permission-forecast.ts) ───── + +_SHIP_RE = re.compile( + r"\b(ship|deploy|push|pr\b|pull\s*request|merge|release)\b", re.I +) +_TEST_RE = re.compile( + r"\b(tests?|lint|typecheck|format|check|verify|coverage)\b", re.I +) +_INSTALL_RE = re.compile( + r"\b(install|add\s+package|add\s+dependency|yarn\s+add|npm\s+install)\b", re.I +) +_DELETE_RE = re.compile( + r"\b(delete|remove|drop|reset|clean|destroy)\b", re.I +) +_REVIEW_RE = re.compile( + r"\b(review|comment|approve|feedback|pr\b|pull\s*request)\b", re.I +) + +# Pre-defined bundles (mirrors MCP permission-forecast.ts) +_SHIP_BUNDLE: Dict[str, str] = {"name": "Ship changes", "permissionClass": "external"} +_TEST_BUNDLE: Dict[str, str] = {"name": "Run checks", "permissionClass": "read-only"} +_INSTALL_BUNDLE: Dict[str, str] = { + "name": "Install dependencies", + "permissionClass": "network", +} +_DELETE_BUNDLE: Dict[str, str] = { + "name": "Delete files", + "permissionClass": "destructive", +} +_REVIEW_BUNDLE: Dict[str, str] = {"name": "Review PR", "permissionClass": "external"} + +# Canonical sort order for permission classes +_CLASS_ORDER = ["read-only", "repo-write", "network", "destructive", "external"] + + # ─── Permission class definitions ──────────────────────────────────── # Map of permission class to compact icon+label @@ -119,20 +155,46 @@ def format_permission_forecast_from_mcp( return format_permission_forecast(classes, bundles if bundles else None) -def generate_standalone_forecast(mode: str) -> str: - """Generate a permission forecast for standalone (non-MCP) mode. +def generate_standalone_forecast(mode: str, prompt: Optional[str] = None) -> str: + """Generate a permission forecast with optional prompt-aware analysis. - Uses the same base permission classes as the MCP server to keep - the display consistent regardless of backend. + When *prompt* is provided, analyzes it for task signals (install, + test, ship, delete, review) and enriches the forecast beyond the + static mode defaults — mirroring MCP ``permission-forecast.ts``. Args: mode: Mode name (PLAN, ACT, EVAL, AUTO). + prompt: Optional raw user prompt for pattern analysis. Returns: Compact status line string, or empty string for read-only modes. """ mode_upper = mode.upper() - classes = MODE_BASE_CLASSES.get(mode_upper, []) - bundles = MODE_DEFAULT_BUNDLES.get(mode_upper, []) - - return format_permission_forecast(classes, bundles if bundles else None) + classes = set(MODE_BASE_CLASSES.get(mode_upper, [])) + bundles: list[Dict[str, str]] = [] + + if prompt: + if _SHIP_RE.search(prompt): + classes.add("repo-write") + classes.add("external") + bundles.append(_SHIP_BUNDLE) + if _TEST_RE.search(prompt): + bundles.append(_TEST_BUNDLE) + if _INSTALL_RE.search(prompt): + classes.add("network") + bundles.append(_INSTALL_BUNDLE) + if _DELETE_RE.search(prompt): + classes.add("destructive") + bundles.append(_DELETE_BUNDLE) + if _REVIEW_RE.search(prompt) and mode_upper == "EVAL": + classes.add("external") + bundles.append(_REVIEW_BUNDLE) + + # ACT mode implicit bundle when no explicit bundles matched + if mode_upper == "ACT" and not bundles: + bundles = list(MODE_DEFAULT_BUNDLES.get("ACT", [])) + + # Sort classes canonically + sorted_classes = sorted(classes, key=lambda c: _CLASS_ORDER.index(c) if c in _CLASS_ORDER else 99) + + return format_permission_forecast(sorted_classes, bundles if bundles 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 6944e52e..f0576998 100644 --- a/packages/claude-code-plugin/hooks/lib/test_mode_engine.py +++ b/packages/claude-code-plugin/hooks/lib/test_mode_engine.py @@ -439,7 +439,8 @@ def test_no_council_scene_in_act_instructions(self): def test_council_scene_includes_face_name_role(self): result = self.engine.build_instructions("PLAN") - self.assertIn("●‿● technical-planner [primary]", result) + # Face uses real eye glyph from agent JSON (⎔ for technical-planner) + self.assertIn("technical-planner [primary]", result) self.assertIn("[specialist]", result) def test_council_scene_includes_moderator_copy(self): diff --git a/packages/claude-code-plugin/hooks/tests/test_permission_forecast.py b/packages/claude-code-plugin/hooks/tests/test_permission_forecast.py new file mode 100644 index 00000000..e1936d45 --- /dev/null +++ b/packages/claude-code-plugin/hooks/tests/test_permission_forecast.py @@ -0,0 +1,77 @@ +"""Tests for permission_forecast prompt-aware analysis (#1418).""" + +import sys +import os +import unittest + +# Ensure hooks/lib is importable +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), os.pardir, "lib") +) + +from permission_forecast import generate_standalone_forecast + + +class TestStandaloneNoPrompt(unittest.TestCase): + """Backward compatibility: mode-only forecast without prompt.""" + + def test_plan_returns_empty(self): + self.assertEqual(generate_standalone_forecast("PLAN"), "") + + def test_act_default_bundle(self): + result = generate_standalone_forecast("ACT") + self.assertIn("repo-write", result) + self.assertIn("Code changes", result) + + def test_eval_returns_empty(self): + self.assertEqual(generate_standalone_forecast("EVAL"), "") + + +class TestPromptAwareForecast(unittest.TestCase): + """Prompt-signal enrichment mirrors MCP permission-forecast.ts.""" + + def test_install_adds_network(self): + result = generate_standalone_forecast("ACT", prompt="install react-query") + self.assertIn("network", result) + self.assertIn("Install dependencies", result) + + def test_test_adds_run_checks(self): + result = generate_standalone_forecast("ACT", prompt="run tests") + self.assertIn("Run checks", result) + + def test_ship_adds_external(self): + result = generate_standalone_forecast("ACT", prompt="ship the changes") + self.assertIn("external", result) + self.assertIn("Ship changes", result) + + def test_delete_adds_destructive_bundle(self): + result = generate_standalone_forecast("ACT", prompt="delete old files") + self.assertIn("destructive", result) + self.assertIn("Delete files", result) + + def test_review_in_eval_adds_external(self): + result = generate_standalone_forecast("EVAL", prompt="review PR 42") + self.assertIn("external", result) + self.assertIn("Review PR", result) + + def test_review_in_act_no_review_bundle(self): + result = generate_standalone_forecast("ACT", prompt="review the code") + self.assertNotIn("Review PR", result) + + def test_install_and_test_combined(self): + result = generate_standalone_forecast( + "ACT", prompt="install react-query and run tests" + ) + self.assertIn("network", result) + self.assertIn("Install dependencies", result) + self.assertIn("Run checks", result) + + def test_plan_with_prompt_still_empty(self): + # PLAN mode is read-only even with prompt signals + result = generate_standalone_forecast("PLAN", prompt="install stuff") + # network is added but no bundles → still shows something + self.assertIn("network", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index 2f6d5192..1ca45c17 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -84,7 +84,7 @@ def main(): ) # Permission forecast hint (#1418): show standalone # forecast as a preview; parse_mode will refine it. - forecast_line = generate_standalone_forecast(detected_mode) + forecast_line = generate_standalone_forecast(detected_mode, prompt=prompt) if forecast_line: print(forecast_line) # MCP council preset for eligible modes (#1361) @@ -95,13 +95,34 @@ def main(): # the self-contained fallback is active and no MCP server # is required for mode handling. print("# Backend: standalone (self-contained, no MCP required)") + # Read persisted question budget from HUD state (#1371) + _question_budget = None + try: + from hud_state import read_hud_state + _hud = read_hud_state(fill_defaults=True) + _question_budget = _hud.get("questionBudget") + except Exception: + pass engine = ModeEngine(cwd=project_dir) instructions = engine.build_instructions( - detected_mode, prompt=prompt + detected_mode, prompt=prompt, + question_budget=_question_budget, ) print(instructions) + # Persist decremented budget back to HUD state (#1371) + # Detect clarification via build_instructions output + # (avoids duplicate evaluate_clarification_standalone call) + try: + from hud_state import update_hud_state + if (_question_budget is not None + and detected_mode in ("PLAN", "AUTO") + and "CLARIFICATION REQUIRED" in instructions): + new_budget = max((_question_budget - 1), 0) + update_hud_state(questionBudget=new_budget) + except Exception: + pass # Permission forecast for standalone mode (#1418) - forecast_line = generate_standalone_forecast(detected_mode) + forecast_line = generate_standalone_forecast(detected_mode, prompt=prompt) if forecast_line: print(forecast_line) # Standalone council preset from Tiny Actor presets (#1361)