Skip to content

Commit 7c94ca4

Browse files
committed
feat(plugin): surface permission forecasts before execution (#1418)
- Add permission_forecast.py module to format permission classes and approval bundles into compact status lines - Display forecast in user-prompt-submit hook output after mode indicator - Derive permission hints from mode type in standalone mode - Add tests for formatting and display logic
1 parent 7006180 commit 7c94ca4

5 files changed

Lines changed: 446 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,15 @@ def build_instructions(
465465
if enrichment:
466466
instructions += "\n\n" + enrichment
467467

468+
# Permission forecast for non-read-only modes (#1418)
469+
try:
470+
from permission_forecast import generate_standalone_forecast
471+
forecast_line = generate_standalone_forecast(mode_upper)
472+
if forecast_line:
473+
instructions += "\n\n" + forecast_line
474+
except Exception:
475+
pass
476+
468477
# MCP enhancement hint (always last)
469478
mcp_hint = (
470479
"\n\nIf mcp__codingbuddy__parse_mode is available, "
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Permission forecast formatting for CodingBuddy plugin (#1418).
2+
3+
Formats permission forecast data from parse_mode MCP responses and
4+
generates standalone forecasts for self-contained mode.
5+
6+
All public functions are pure — no I/O, no side effects.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Dict, List, Optional, Sequence
12+
13+
14+
# ─── Permission class definitions ────────────────────────────────────
15+
16+
# Map of permission class to compact icon+label
17+
PERMISSION_CLASS_LABELS: Dict[str, str] = {
18+
"read-only": "read-only",
19+
"repo-write": "repo-write",
20+
"network": "network",
21+
"destructive": "destructive",
22+
"external": "external",
23+
}
24+
25+
# Icon for each permission class (low-noise, single character)
26+
PERMISSION_CLASS_ICONS: Dict[str, str] = {
27+
"read-only": "\U0001f4d6", # open book
28+
"repo-write": "\u270f\ufe0f", # pencil
29+
"network": "\U0001f310", # globe
30+
"destructive": "\u26a0\ufe0f", # warning
31+
"external": "\U0001f517", # link
32+
}
33+
34+
35+
# ─── Standalone forecasts per mode ────────────────────────────────────
36+
37+
# Default permission classes per mode (mirrors MCP server MODE_BASE_CLASSES)
38+
MODE_BASE_CLASSES: Dict[str, List[str]] = {
39+
"PLAN": ["read-only"],
40+
"ACT": ["read-only", "repo-write"],
41+
"EVAL": ["read-only"],
42+
"AUTO": ["read-only", "repo-write", "external"],
43+
}
44+
45+
# Default approval bundles per mode for standalone
46+
MODE_DEFAULT_BUNDLES: Dict[str, List[Dict[str, str]]] = {
47+
"PLAN": [],
48+
"ACT": [
49+
{"name": "Code changes", "permissionClass": "repo-write"},
50+
],
51+
"EVAL": [],
52+
"AUTO": [
53+
{"name": "Code changes", "permissionClass": "repo-write"},
54+
{"name": "Ship changes", "permissionClass": "external"},
55+
],
56+
}
57+
58+
59+
# ─── Public API ───────────────────────────────────────────────────────
60+
61+
62+
def format_permission_forecast(
63+
permission_classes: Sequence[str],
64+
approval_bundles: Optional[Sequence[Dict[str, str]]] = None,
65+
) -> str:
66+
"""Format permission forecast data as a compact status line.
67+
68+
Args:
69+
permission_classes: List of permission class names
70+
(e.g. ["read-only", "repo-write"]).
71+
approval_bundles: Optional list of bundle dicts, each with
72+
at least ``name`` and ``permissionClass`` keys.
73+
74+
Returns:
75+
Compact one-line string, e.g.
76+
``Permissions: repo-write (Code changes) | external (Ship changes)``
77+
78+
Returns empty string when there are no permission classes
79+
or only "read-only" with no bundles.
80+
"""
81+
if not permission_classes:
82+
return ""
83+
84+
# Filter out read-only when it is the only class and there are no bundles
85+
non_readonly = [c for c in permission_classes if c != "read-only"]
86+
if not non_readonly and not approval_bundles:
87+
return ""
88+
89+
parts: list[str] = []
90+
91+
if approval_bundles:
92+
# Group bundles by permission class for compact display
93+
for bundle in approval_bundles:
94+
name = bundle.get("name", "")
95+
pclass = bundle.get("permissionClass", "")
96+
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
97+
parts.append(f"{label} ({name})")
98+
else:
99+
# No bundles — just list the non-readonly classes
100+
for pclass in non_readonly:
101+
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
102+
parts.append(label)
103+
104+
if not parts:
105+
return ""
106+
107+
return "Permissions: " + " | ".join(parts)
108+
109+
110+
def format_permission_forecast_from_mcp(
111+
forecast: Optional[Dict],
112+
) -> str:
113+
"""Extract and format permission forecast from a parse_mode MCP response.
114+
115+
Args:
116+
forecast: The ``permissionForecast`` dict from parse_mode, or None.
117+
118+
Returns:
119+
Compact status line string, or empty string if no forecast data.
120+
"""
121+
if not forecast:
122+
return ""
123+
124+
classes = forecast.get("permissionClasses", [])
125+
bundles = forecast.get("approvalBundles", [])
126+
127+
return format_permission_forecast(classes, bundles if bundles else None)
128+
129+
130+
def generate_standalone_forecast(mode: str) -> str:
131+
"""Generate a permission forecast for standalone (non-MCP) mode.
132+
133+
Uses the same base permission classes as the MCP server to keep
134+
the display consistent regardless of backend.
135+
136+
Args:
137+
mode: Mode name (PLAN, ACT, EVAL, AUTO).
138+
139+
Returns:
140+
Compact status line string, or empty string for read-only modes.
141+
"""
142+
mode_upper = mode.upper()
143+
classes = MODE_BASE_CLASSES.get(mode_upper, [])
144+
bundles = MODE_DEFAULT_BUNDLES.get(mode_upper, [])
145+
146+
return format_permission_forecast(classes, bundles if bundles else None)

packages/claude-code-plugin/hooks/test_user_prompt_submit.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,114 @@ def test_auto_mode_seeds_council(self):
506506
assert state["councilCast"][0] == "auto-mode"
507507

508508

509+
class TestPermissionForecastIntegration:
510+
"""#1418: Permission forecast display in hook output."""
511+
512+
def _run_hook(self, prompt, home_dir, mcp_enabled=False):
513+
import os as _os
514+
515+
hook_path = Path(__file__).parent / "user-prompt-submit.py"
516+
input_data = json.dumps({"prompt": prompt})
517+
env = _os.environ.copy()
518+
env["HOME"] = str(home_dir)
519+
env["CLAUDE_PROJECT_DIR"] = str(home_dir)
520+
env.pop("CODINGBUDDY_RULES_DIR", None)
521+
env.pop("CODINGBUDDY_HUD_STATE_FILE", None)
522+
523+
if mcp_enabled:
524+
claude_dir = Path(home_dir) / ".claude"
525+
claude_dir.mkdir(exist_ok=True)
526+
mcp_json = claude_dir / "mcp.json"
527+
mcp_json.write_text(json.dumps({
528+
"mcpServers": {
529+
"codingbuddy": {"command": "codingbuddy", "args": ["mcp"]}
530+
}
531+
}))
532+
533+
return subprocess.run(
534+
[sys.executable, str(hook_path)],
535+
input=input_data,
536+
capture_output=True,
537+
text=True,
538+
env=env,
539+
cwd=str(home_dir),
540+
)
541+
542+
def test_act_mode_shows_forecast_standalone(self):
543+
"""ACT mode in standalone should show repo-write permission forecast."""
544+
import tempfile
545+
546+
with tempfile.TemporaryDirectory() as tmpdir:
547+
Path(tmpdir, ".claude").mkdir()
548+
result = self._run_hook("ACT: implement the feature", tmpdir)
549+
assert result.returncode == 0
550+
assert "Permissions:" in result.stdout
551+
assert "repo-write" in result.stdout
552+
553+
def test_act_mode_shows_forecast_mcp(self):
554+
"""ACT mode in MCP should show repo-write permission forecast."""
555+
import tempfile
556+
557+
with tempfile.TemporaryDirectory() as tmpdir:
558+
result = self._run_hook(
559+
"ACT: implement the feature", tmpdir, mcp_enabled=True
560+
)
561+
assert result.returncode == 0
562+
assert "Permissions:" in result.stdout
563+
assert "repo-write" in result.stdout
564+
565+
def test_plan_mode_no_forecast(self):
566+
"""PLAN mode is read-only, no permission forecast needed."""
567+
import tempfile
568+
569+
with tempfile.TemporaryDirectory() as tmpdir:
570+
result = self._run_hook(
571+
"PLAN: design the architecture for the auth module",
572+
tmpdir,
573+
mcp_enabled=True,
574+
)
575+
assert result.returncode == 0
576+
assert "Permissions:" not in result.stdout
577+
578+
def test_eval_mode_no_forecast(self):
579+
"""EVAL mode is read-only, no permission forecast needed."""
580+
import tempfile
581+
582+
with tempfile.TemporaryDirectory() as tmpdir:
583+
result = self._run_hook(
584+
"EVAL: review the code quality of the auth module",
585+
tmpdir,
586+
mcp_enabled=True,
587+
)
588+
assert result.returncode == 0
589+
assert "Permissions:" not in result.stdout
590+
591+
def test_auto_mode_shows_forecast(self):
592+
"""AUTO mode should show repo-write and external permissions."""
593+
import tempfile
594+
595+
with tempfile.TemporaryDirectory() as tmpdir:
596+
result = self._run_hook(
597+
"AUTO: build the complete user dashboard feature",
598+
tmpdir,
599+
mcp_enabled=True,
600+
)
601+
assert result.returncode == 0
602+
assert "Permissions:" in result.stdout
603+
assert "repo-write" in result.stdout
604+
assert "external" in result.stdout
605+
606+
def test_no_keyword_no_forecast(self):
607+
"""Regular messages should not show any forecast."""
608+
import tempfile
609+
610+
with tempfile.TemporaryDirectory() as tmpdir:
611+
Path(tmpdir, ".claude").mkdir()
612+
result = self._run_hook("Hello world", tmpdir)
613+
assert result.returncode == 0
614+
assert "Permissions:" not in result.stdout
615+
616+
509617
if __name__ == "__main__":
510618
import pytest
511619
pytest.main([__file__, "-v"])

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def main():
7171
try:
7272
from runtime_mode import is_mcp_available
7373
from mode_engine import ModeEngine, COUNCIL_PRESETS
74+
from permission_forecast import generate_standalone_forecast
7475

7576
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
7677
if is_mcp_available(project_dir=project_dir):
@@ -81,6 +82,11 @@ def main():
8182
"If mcp__codingbuddy__parse_mode is available, "
8283
"call it for enhanced features."
8384
)
85+
# Permission forecast hint (#1418): show standalone
86+
# forecast as a preview; parse_mode will refine it.
87+
forecast_line = generate_standalone_forecast(detected_mode)
88+
if forecast_line:
89+
print(forecast_line)
8490
# MCP council preset for eligible modes (#1361)
8591
council_preset = COUNCIL_PRESETS.get(detected_mode)
8692
else:
@@ -94,6 +100,10 @@ def main():
94100
detected_mode, prompt=prompt
95101
)
96102
print(instructions)
103+
# Permission forecast for standalone mode (#1418)
104+
forecast_line = generate_standalone_forecast(detected_mode)
105+
if forecast_line:
106+
print(forecast_line)
97107
# Standalone council preset from Tiny Actor presets (#1361)
98108
try:
99109
from tiny_actor_presets import CAST_PRESETS

0 commit comments

Comments
 (0)