Skip to content

Commit d34126c

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) - PLAN template: replace full-plan-first with Discover→Design→Plan staged flow - Clarification budget: persist questionBudget in HUD state across rounds - Council scene: load real agent eye glyphs from .ai-rules/agents/*.json replacing ●‿● placeholders with actual per-agent faces
1 parent 71f3581 commit d34126c

5 files changed

Lines changed: 137 additions & 23 deletions

File tree

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

Lines changed: 2 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:

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

Lines changed: 42 additions & 11 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
@@ -379,12 +382,38 @@ def _load_agent_details(self, agent_name: str) -> Optional[dict]:
379382
except (OSError, json.JSONDecodeError, ValueError):
380383
return None
381384

385+
def _load_agent_eye(self, agent_name: str) -> Optional[str]:
386+
"""Load the ``eye`` glyph from an agent JSON definition.
387+
388+
Falls back to None when the file is missing or unreadable.
389+
"""
390+
if not self.rules_dir:
391+
return None
392+
slug = agent_name.lower().replace(" ", "-").replace("_", "-")
393+
agent_path = os.path.join(self.rules_dir, "agents", f"{slug}.json")
394+
if not os.path.isfile(agent_path):
395+
return None
396+
try:
397+
with open(agent_path, "r", encoding="utf-8") as f:
398+
data = json.load(f)
399+
return data.get("visual", {}).get("eye")
400+
except (OSError, json.JSONDecodeError, ValueError):
401+
return None
402+
403+
def _make_face(self, agent_name: str) -> str:
404+
"""Build a face string from the agent eye glyph, or fall back to ●‿●."""
405+
eye = self._load_agent_eye(agent_name)
406+
if eye:
407+
return f"{eye}{eye}"
408+
return "●‿●"
409+
382410
def build_council_scene(self, mode: str) -> Optional[dict]:
383411
"""
384412
Build council scene contract for eligible modes.
385413
386414
Mirrors the MCP server's ``buildCouncilScene`` output so that
387415
standalone mode produces an equivalent first-response contract.
416+
Loads real agent eye glyphs from ``.ai-rules/agents/*.json``.
388417
389418
Args:
390419
mode: Mode name (PLAN, ACT, EVAL, AUTO)
@@ -402,11 +431,13 @@ def build_council_scene(self, mode: str) -> Optional[dict]:
402431
return None
403432

404433
cast = [
405-
{"name": preset["primary"], "role": "primary", "face": "●‿●"}
434+
{"name": preset["primary"], "role": "primary",
435+
"face": self._make_face(preset["primary"])}
406436
]
407437
for specialist in preset["specialists"]:
408438
cast.append(
409-
{"name": specialist, "role": "specialist", "face": "●‿●"}
439+
{"name": specialist, "role": "specialist",
440+
"face": self._make_face(specialist)}
410441
)
411442

412443
return {

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

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,41 @@
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+
_REVIEW_BUNDLE: Dict[str, str] = {"name": "Review PR", "permissionClass": "external"}
41+
42+
# Canonical sort order for permission classes
43+
_CLASS_ORDER = ["read-only", "repo-write", "network", "destructive", "external"]
44+
45+
1446
# ─── Permission class definitions ────────────────────────────────────
1547

1648
# Map of permission class to compact icon+label
@@ -119,20 +151,45 @@ def format_permission_forecast_from_mcp(
119151
return format_permission_forecast(classes, bundles if bundles else None)
120152

121153

122-
def generate_standalone_forecast(mode: str) -> str:
123-
"""Generate a permission forecast for standalone (non-MCP) mode.
154+
def generate_standalone_forecast(mode: str, prompt: Optional[str] = None) -> str:
155+
"""Generate a permission forecast with optional prompt-aware analysis.
124156
125-
Uses the same base permission classes as the MCP server to keep
126-
the display consistent regardless of backend.
157+
When *prompt* is provided, analyzes it for task signals (install,
158+
test, ship, delete, review) and enriches the forecast beyond the
159+
static mode defaults — mirroring MCP ``permission-forecast.ts``.
127160
128161
Args:
129162
mode: Mode name (PLAN, ACT, EVAL, AUTO).
163+
prompt: Optional raw user prompt for pattern analysis.
130164
131165
Returns:
132166
Compact status line string, or empty string for read-only modes.
133167
"""
134168
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)
169+
classes = set(MODE_BASE_CLASSES.get(mode_upper, []))
170+
bundles: list[Dict[str, str]] = []
171+
172+
if prompt:
173+
if _SHIP_RE.search(prompt):
174+
classes.add("repo-write")
175+
classes.add("external")
176+
bundles.append(_SHIP_BUNDLE)
177+
if _TEST_RE.search(prompt):
178+
bundles.append(_TEST_BUNDLE)
179+
if _INSTALL_RE.search(prompt):
180+
classes.add("network")
181+
bundles.append(_INSTALL_BUNDLE)
182+
if _DELETE_RE.search(prompt):
183+
classes.add("destructive")
184+
if _REVIEW_RE.search(prompt) and mode_upper == "EVAL":
185+
classes.add("external")
186+
bundles.append(_REVIEW_BUNDLE)
187+
188+
# ACT mode implicit bundle when no explicit bundles matched
189+
if mode_upper == "ACT" and not bundles:
190+
bundles = list(MODE_DEFAULT_BUNDLES.get("ACT", []))
191+
192+
# Sort classes canonically
193+
sorted_classes = sorted(classes, key=lambda c: _CLASS_ORDER.index(c) if c in _CLASS_ORDER else 99)
194+
195+
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):

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

Lines changed: 26 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,36 @@ 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+
try:
114+
from hud_state import update_hud_state
115+
if _question_budget is not None and detected_mode in ("PLAN", "AUTO"):
116+
from mode_engine import evaluate_clarification_standalone
117+
_cl = evaluate_clarification_standalone(
118+
prompt, _question_budget
119+
)
120+
if _cl is not None:
121+
# Clarification fired — budget decremented
122+
new_budget = (_question_budget - 1) if _question_budget > 0 else 0
123+
update_hud_state(questionBudget=new_budget)
124+
except Exception:
125+
pass
103126
# Permission forecast for standalone mode (#1418)
104-
forecast_line = generate_standalone_forecast(detected_mode)
127+
forecast_line = generate_standalone_forecast(detected_mode, prompt=prompt)
105128
if forecast_line:
106129
print(forecast_line)
107130
# Standalone council preset from Tiny Actor presets (#1361)

0 commit comments

Comments
 (0)