Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/claude-code-plugin/hooks/lib/hud_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"councilActive": False,
"councilStage": "",
"councilCast": [],
# Clarification budget (#1371) — persisted across rounds
"questionBudget": 3,
}

try:
Expand Down Expand Up @@ -93,6 +95,8 @@ def init_hud_state(
"councilActive": False,
"councilStage": "",
"councilCast": [],
# Clarification budget (#1371)
"questionBudget": 3,
"updatedAt": now,
}
_locked_write(state_file, data)
Expand Down
84 changes: 59 additions & 25 deletions packages/claude-code-plugin/hooks/lib/mode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
78 changes: 70 additions & 8 deletions packages/claude-code-plugin/hooks/lib/permission_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion packages/claude-code-plugin/hooks/lib/test_mode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
27 changes: 24 additions & 3 deletions packages/claude-code-plugin/hooks/user-prompt-submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading