Skip to content

Commit ca16373

Browse files
committed
feat(plugin): close standalone surface gaps for v5.4.0 features
- Permission forecast: add prompt-aware pattern analysis mirroring MCP server (SHIP/TEST/INSTALL/DELETE/REVIEW signals) with DELETE bundle - PLAN template: replace full-plan-first with Discover→Design→Plan staged flow - Clarification budget: persist questionBudget in HUD state across rounds (init_hud_state schema synced, double-eval eliminated) - Council scene: load real agent eye glyphs from .ai-rules/agents/*.json via shared _read_agent_json with per-instance cache and path-traversal safety - Add 11 unit tests for prompt-aware permission forecast
1 parent 71f3581 commit ca16373

6 files changed

Lines changed: 236 additions & 37 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"councilActive": False,
2121
"councilStage": "",
2222
"councilCast": [],
23+
# Clarification budget (#1371) — persisted across rounds
24+
"questionBudget": 3,
2325
}
2426

2527
try:
@@ -93,6 +95,8 @@ def init_hud_state(
9395
"councilActive": False,
9496
"councilStage": "",
9597
"councilCast": [],
98+
# Clarification budget (#1371)
99+
"questionBudget": 3,
96100
"updatedAt": now,
97101
}
98102
_locked_write(state_file, data)

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

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,23 @@
6565
"PLAN": """# Mode: PLAN
6666
## Agent: {agent_name}
6767
68-
You are in PLAN mode. Design the implementation approach.
68+
You are in PLAN mode. Follow the staged planning flow.
69+
70+
Stages: Discover → Design → Plan
71+
1. DISCOVER: Surface questions, constraints, option space
72+
2. DESIGN: Candidate approaches, trade-offs, risks
73+
3. PLAN: Concrete step-by-step implementation plan
6974
7075
Rules:
71-
- Define test cases first (TDD perspective)
72-
- Review architecture before implementation
73-
- Output full plan in every response
74-
- Do NOT auto-proceed to ACT — wait for user
76+
- Start at Discover — ask before solutioning
77+
- Advance stages only after user confirms direction
7578
- Consider alternatives for non-trivial decisions
79+
- Do NOT auto-proceed to ACT — wait for user
7680
7781
Checklist:
78-
- [ ] Problem decomposed into sub-problems
79-
- [ ] File paths identified
80-
- [ ] TDD strategy defined
81-
- [ ] Alternatives considered""",
82+
- [ ] Questions and constraints surfaced (Discover)
83+
- [ ] Approaches compared with trade-offs (Design)
84+
- [ ] File paths identified, TDD strategy defined (Plan)""",
8285
"ACT": """# Mode: ACT
8386
## Agent: {agent_name}
8487
@@ -281,6 +284,7 @@ def __init__(self, rules_dir: Optional[str] = None, cwd: Optional[str] = None):
281284
cwd: Working directory for resolution. Defaults to os.getcwd().
282285
"""
283286
self.rules_dir = rules_dir or _resolve_rules_dir(cwd)
287+
self._agent_json_cache: dict = {}
284288

285289
def load_mode_rules(self, mode: str) -> Optional[str]:
286290
"""
@@ -350,41 +354,69 @@ def get_default_agent(self, mode: str) -> dict:
350354
mode_upper = mode.upper()
351355
return DEFAULT_AGENTS.get(mode_upper, DEFAULT_AGENTS["ACT"])
352356

353-
def _load_agent_details(self, agent_name: str) -> Optional[dict]:
354-
"""
355-
Load agent profile from ``.ai-rules/agents/{agent_name}.json``.
357+
def _read_agent_json(self, agent_name: str) -> Optional[dict]:
358+
"""Read and cache an agent JSON file from ``.ai-rules/agents/``.
356359
357-
Args:
358-
agent_name: Agent file stem (e.g. ``technical-planner``).
360+
Shared by ``_load_agent_details`` and ``_load_agent_eye`` to
361+
avoid duplicate file reads (DRY) and N+1 I/O in council scene.
359362
360-
Returns:
361-
Dict with ``name``, ``description``, ``expertise`` keys,
362-
or None if the file is missing / unreadable.
363+
Applies ``os.path.basename`` to the slug for path-traversal safety.
363364
"""
365+
if agent_name in self._agent_json_cache:
366+
return self._agent_json_cache[agent_name]
367+
364368
if not self.rules_dir:
369+
self._agent_json_cache[agent_name] = None
365370
return None
366371

367-
agent_path = os.path.join(self.rules_dir, "agents", f"{agent_name}.json")
372+
slug = agent_name.lower().replace(" ", "-").replace("_", "-")
373+
slug = os.path.basename(slug) # path-traversal safety
374+
agent_path = os.path.join(self.rules_dir, "agents", f"{slug}.json")
368375
if not os.path.isfile(agent_path):
376+
self._agent_json_cache[agent_name] = None
369377
return None
370378

371379
try:
372380
with open(agent_path, "r", encoding="utf-8") as f:
373381
data = json.load(f)
374-
return {
375-
"name": data.get("name", agent_name),
376-
"description": data.get("description", ""),
377-
"expertise": data.get("role", {}).get("expertise", []),
378-
}
382+
self._agent_json_cache[agent_name] = data
383+
return data
379384
except (OSError, json.JSONDecodeError, ValueError):
385+
self._agent_json_cache[agent_name] = None
380386
return None
381387

388+
def _load_agent_details(self, agent_name: str) -> Optional[dict]:
389+
"""Load agent profile from ``.ai-rules/agents/{agent_name}.json``."""
390+
data = self._read_agent_json(agent_name)
391+
if not data:
392+
return None
393+
return {
394+
"name": data.get("name", agent_name),
395+
"description": data.get("description", ""),
396+
"expertise": data.get("role", {}).get("expertise", []),
397+
}
398+
399+
def _load_agent_eye(self, agent_name: str) -> Optional[str]:
400+
"""Load the ``eye`` glyph from an agent JSON definition."""
401+
data = self._read_agent_json(agent_name)
402+
if not data:
403+
return None
404+
return data.get("visual", {}).get("eye")
405+
406+
def _make_face(self, agent_name: str) -> str:
407+
"""Build a face string from the agent eye glyph, or fall back to ●‿●."""
408+
eye = self._load_agent_eye(agent_name)
409+
if eye:
410+
return f"{eye}{eye}"
411+
return "●‿●"
412+
382413
def build_council_scene(self, mode: str) -> Optional[dict]:
383414
"""
384415
Build council scene contract for eligible modes.
385416
386417
Mirrors the MCP server's ``buildCouncilScene`` output so that
387418
standalone mode produces an equivalent first-response contract.
419+
Loads real agent eye glyphs from ``.ai-rules/agents/*.json``.
388420
389421
Args:
390422
mode: Mode name (PLAN, ACT, EVAL, AUTO)
@@ -402,11 +434,13 @@ def build_council_scene(self, mode: str) -> Optional[dict]:
402434
return None
403435

404436
cast = [
405-
{"name": preset["primary"], "role": "primary", "face": "●‿●"}
437+
{"name": preset["primary"], "role": "primary",
438+
"face": self._make_face(preset["primary"])}
406439
]
407440
for specialist in preset["specialists"]:
408441
cast.append(
409-
{"name": specialist, "role": "specialist", "face": "●‿●"}
442+
{"name": specialist, "role": "specialist",
443+
"face": self._make_face(specialist)}
410444
)
411445

412446
return {

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

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,45 @@
88

99
from __future__ import annotations
1010

11+
import re
1112
from typing import Dict, List, Optional, Sequence
1213

1314

15+
# ─── Prompt-signal patterns (mirrors MCP permission-forecast.ts) ─────
16+
17+
_SHIP_RE = re.compile(
18+
r"\b(ship|deploy|push|pr\b|pull\s*request|merge|release)\b", re.I
19+
)
20+
_TEST_RE = re.compile(
21+
r"\b(tests?|lint|typecheck|format|check|verify|coverage)\b", re.I
22+
)
23+
_INSTALL_RE = re.compile(
24+
r"\b(install|add\s+package|add\s+dependency|yarn\s+add|npm\s+install)\b", re.I
25+
)
26+
_DELETE_RE = re.compile(
27+
r"\b(delete|remove|drop|reset|clean|destroy)\b", re.I
28+
)
29+
_REVIEW_RE = re.compile(
30+
r"\b(review|comment|approve|feedback|pr\b|pull\s*request)\b", re.I
31+
)
32+
33+
# Pre-defined bundles (mirrors MCP permission-forecast.ts)
34+
_SHIP_BUNDLE: Dict[str, str] = {"name": "Ship changes", "permissionClass": "external"}
35+
_TEST_BUNDLE: Dict[str, str] = {"name": "Run checks", "permissionClass": "read-only"}
36+
_INSTALL_BUNDLE: Dict[str, str] = {
37+
"name": "Install dependencies",
38+
"permissionClass": "network",
39+
}
40+
_DELETE_BUNDLE: Dict[str, str] = {
41+
"name": "Delete files",
42+
"permissionClass": "destructive",
43+
}
44+
_REVIEW_BUNDLE: Dict[str, str] = {"name": "Review PR", "permissionClass": "external"}
45+
46+
# Canonical sort order for permission classes
47+
_CLASS_ORDER = ["read-only", "repo-write", "network", "destructive", "external"]
48+
49+
1450
# ─── Permission class definitions ────────────────────────────────────
1551

1652
# Map of permission class to compact icon+label
@@ -119,20 +155,46 @@ def format_permission_forecast_from_mcp(
119155
return format_permission_forecast(classes, bundles if bundles else None)
120156

121157

122-
def generate_standalone_forecast(mode: str) -> str:
123-
"""Generate a permission forecast for standalone (non-MCP) mode.
158+
def generate_standalone_forecast(mode: str, prompt: Optional[str] = None) -> str:
159+
"""Generate a permission forecast with optional prompt-aware analysis.
124160
125-
Uses the same base permission classes as the MCP server to keep
126-
the display consistent regardless of backend.
161+
When *prompt* is provided, analyzes it for task signals (install,
162+
test, ship, delete, review) and enriches the forecast beyond the
163+
static mode defaults — mirroring MCP ``permission-forecast.ts``.
127164
128165
Args:
129166
mode: Mode name (PLAN, ACT, EVAL, AUTO).
167+
prompt: Optional raw user prompt for pattern analysis.
130168
131169
Returns:
132170
Compact status line string, or empty string for read-only modes.
133171
"""
134172
mode_upper = mode.upper()
135-
classes = MODE_BASE_CLASSES.get(mode_upper, [])
136-
bundles = MODE_DEFAULT_BUNDLES.get(mode_upper, [])
137-
138-
return format_permission_forecast(classes, bundles if bundles else None)
173+
classes = set(MODE_BASE_CLASSES.get(mode_upper, []))
174+
bundles: list[Dict[str, str]] = []
175+
176+
if prompt:
177+
if _SHIP_RE.search(prompt):
178+
classes.add("repo-write")
179+
classes.add("external")
180+
bundles.append(_SHIP_BUNDLE)
181+
if _TEST_RE.search(prompt):
182+
bundles.append(_TEST_BUNDLE)
183+
if _INSTALL_RE.search(prompt):
184+
classes.add("network")
185+
bundles.append(_INSTALL_BUNDLE)
186+
if _DELETE_RE.search(prompt):
187+
classes.add("destructive")
188+
bundles.append(_DELETE_BUNDLE)
189+
if _REVIEW_RE.search(prompt) and mode_upper == "EVAL":
190+
classes.add("external")
191+
bundles.append(_REVIEW_BUNDLE)
192+
193+
# ACT mode implicit bundle when no explicit bundles matched
194+
if mode_upper == "ACT" and not bundles:
195+
bundles = list(MODE_DEFAULT_BUNDLES.get("ACT", []))
196+
197+
# Sort classes canonically
198+
sorted_classes = sorted(classes, key=lambda c: _CLASS_ORDER.index(c) if c in _CLASS_ORDER else 99)
199+
200+
return format_permission_forecast(sorted_classes, bundles if bundles else None)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,8 @@ def test_no_council_scene_in_act_instructions(self):
439439

440440
def test_council_scene_includes_face_name_role(self):
441441
result = self.engine.build_instructions("PLAN")
442-
self.assertIn("●‿● technical-planner [primary]", result)
442+
# Face uses real eye glyph from agent JSON (⎔ for technical-planner)
443+
self.assertIn("technical-planner [primary]", result)
443444
self.assertIn("[specialist]", result)
444445

445446
def test_council_scene_includes_moderator_copy(self):
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for permission_forecast prompt-aware analysis (#1418)."""
2+
3+
import sys
4+
import os
5+
import unittest
6+
7+
# Ensure hooks/lib is importable
8+
sys.path.insert(
9+
0, os.path.join(os.path.dirname(__file__), os.pardir, "lib")
10+
)
11+
12+
from permission_forecast import generate_standalone_forecast
13+
14+
15+
class TestStandaloneNoPrompt(unittest.TestCase):
16+
"""Backward compatibility: mode-only forecast without prompt."""
17+
18+
def test_plan_returns_empty(self):
19+
self.assertEqual(generate_standalone_forecast("PLAN"), "")
20+
21+
def test_act_default_bundle(self):
22+
result = generate_standalone_forecast("ACT")
23+
self.assertIn("repo-write", result)
24+
self.assertIn("Code changes", result)
25+
26+
def test_eval_returns_empty(self):
27+
self.assertEqual(generate_standalone_forecast("EVAL"), "")
28+
29+
30+
class TestPromptAwareForecast(unittest.TestCase):
31+
"""Prompt-signal enrichment mirrors MCP permission-forecast.ts."""
32+
33+
def test_install_adds_network(self):
34+
result = generate_standalone_forecast("ACT", prompt="install react-query")
35+
self.assertIn("network", result)
36+
self.assertIn("Install dependencies", result)
37+
38+
def test_test_adds_run_checks(self):
39+
result = generate_standalone_forecast("ACT", prompt="run tests")
40+
self.assertIn("Run checks", result)
41+
42+
def test_ship_adds_external(self):
43+
result = generate_standalone_forecast("ACT", prompt="ship the changes")
44+
self.assertIn("external", result)
45+
self.assertIn("Ship changes", result)
46+
47+
def test_delete_adds_destructive_bundle(self):
48+
result = generate_standalone_forecast("ACT", prompt="delete old files")
49+
self.assertIn("destructive", result)
50+
self.assertIn("Delete files", result)
51+
52+
def test_review_in_eval_adds_external(self):
53+
result = generate_standalone_forecast("EVAL", prompt="review PR 42")
54+
self.assertIn("external", result)
55+
self.assertIn("Review PR", result)
56+
57+
def test_review_in_act_no_review_bundle(self):
58+
result = generate_standalone_forecast("ACT", prompt="review the code")
59+
self.assertNotIn("Review PR", result)
60+
61+
def test_install_and_test_combined(self):
62+
result = generate_standalone_forecast(
63+
"ACT", prompt="install react-query and run tests"
64+
)
65+
self.assertIn("network", result)
66+
self.assertIn("Install dependencies", result)
67+
self.assertIn("Run checks", result)
68+
69+
def test_plan_with_prompt_still_empty(self):
70+
# PLAN mode is read-only even with prompt signals
71+
result = generate_standalone_forecast("PLAN", prompt="install stuff")
72+
# network is added but no bundles → still shows something
73+
self.assertIn("network", result)
74+
75+
76+
if __name__ == "__main__":
77+
unittest.main()

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def main():
8484
)
8585
# Permission forecast hint (#1418): show standalone
8686
# forecast as a preview; parse_mode will refine it.
87-
forecast_line = generate_standalone_forecast(detected_mode)
87+
forecast_line = generate_standalone_forecast(detected_mode, prompt=prompt)
8888
if forecast_line:
8989
print(forecast_line)
9090
# MCP council preset for eligible modes (#1361)
@@ -95,13 +95,34 @@ def main():
9595
# the self-contained fallback is active and no MCP server
9696
# is required for mode handling.
9797
print("# Backend: standalone (self-contained, no MCP required)")
98+
# Read persisted question budget from HUD state (#1371)
99+
_question_budget = None
100+
try:
101+
from hud_state import read_hud_state
102+
_hud = read_hud_state(fill_defaults=True)
103+
_question_budget = _hud.get("questionBudget")
104+
except Exception:
105+
pass
98106
engine = ModeEngine(cwd=project_dir)
99107
instructions = engine.build_instructions(
100-
detected_mode, prompt=prompt
108+
detected_mode, prompt=prompt,
109+
question_budget=_question_budget,
101110
)
102111
print(instructions)
112+
# Persist decremented budget back to HUD state (#1371)
113+
# Detect clarification via build_instructions output
114+
# (avoids duplicate evaluate_clarification_standalone call)
115+
try:
116+
from hud_state import update_hud_state
117+
if (_question_budget is not None
118+
and detected_mode in ("PLAN", "AUTO")
119+
and "CLARIFICATION REQUIRED" in instructions):
120+
new_budget = max((_question_budget - 1), 0)
121+
update_hud_state(questionBudget=new_budget)
122+
except Exception:
123+
pass
103124
# Permission forecast for standalone mode (#1418)
104-
forecast_line = generate_standalone_forecast(detected_mode)
125+
forecast_line = generate_standalone_forecast(detected_mode, prompt=prompt)
105126
if forecast_line:
106127
print(forecast_line)
107128
# Standalone council preset from Tiny Actor presets (#1361)

0 commit comments

Comments
 (0)