Skip to content

Commit 0d9c99b

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 0d9c99b

4 files changed

Lines changed: 429 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
# ─── Standalone forecasts per mode ────────────────────────────────────
26+
27+
# Default permission classes per mode (mirrors MCP server MODE_BASE_CLASSES)
28+
MODE_BASE_CLASSES: Dict[str, List[str]] = {
29+
"PLAN": ["read-only"],
30+
"ACT": ["read-only", "repo-write"],
31+
"EVAL": ["read-only"],
32+
"AUTO": ["read-only", "repo-write", "external"],
33+
}
34+
35+
# Default approval bundles per mode for standalone
36+
MODE_DEFAULT_BUNDLES: Dict[str, List[Dict[str, str]]] = {
37+
"PLAN": [],
38+
"ACT": [
39+
{"name": "Code changes", "permissionClass": "repo-write"},
40+
],
41+
"EVAL": [],
42+
"AUTO": [
43+
{"name": "Code changes", "permissionClass": "repo-write"},
44+
{"name": "Ship changes", "permissionClass": "external"},
45+
],
46+
}
47+
48+
49+
# ─── Public API ───────────────────────────────────────────────────────
50+
51+
52+
def format_permission_forecast(
53+
permission_classes: Sequence[str],
54+
approval_bundles: Optional[Sequence[Dict[str, str]]] = None,
55+
) -> str:
56+
"""Format permission forecast data as a compact status line.
57+
58+
Args:
59+
permission_classes: List of permission class names
60+
(e.g. ["read-only", "repo-write"]).
61+
approval_bundles: Optional list of bundle dicts, each with
62+
at least ``name`` and ``permissionClass`` keys.
63+
64+
Returns:
65+
Compact one-line string, e.g.
66+
``Permissions: repo-write (Code changes) | external (Ship changes)``
67+
68+
Returns empty string when there are no permission classes
69+
or only "read-only" with no bundles.
70+
"""
71+
if not permission_classes:
72+
return ""
73+
74+
# Filter out read-only when it is the only class and there are no bundles
75+
non_readonly = [c for c in permission_classes if c != "read-only"]
76+
if not non_readonly and not approval_bundles:
77+
return ""
78+
79+
parts: list[str] = []
80+
81+
if approval_bundles:
82+
# Group bundles by permission class for compact display
83+
for bundle in approval_bundles:
84+
name = bundle.get("name", "")
85+
pclass = bundle.get("permissionClass", "")
86+
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
87+
parts.append(f"{label} ({name})")
88+
else:
89+
# No bundles — just list the non-readonly classes
90+
for pclass in non_readonly:
91+
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
92+
parts.append(label)
93+
94+
if not parts:
95+
return ""
96+
97+
return "Permissions: " + " | ".join(parts)
98+
99+
100+
def format_permission_forecast_from_mcp(
101+
forecast: Optional[Dict],
102+
) -> str:
103+
"""Extract and format permission forecast from a parse_mode MCP response.
104+
105+
NOTE: Reserved for future MCP integration — not yet called by production code.
106+
107+
Args:
108+
forecast: The ``permissionForecast`` dict from parse_mode, or None.
109+
110+
Returns:
111+
Compact status line string, or empty string if no forecast data.
112+
"""
113+
if not forecast:
114+
return ""
115+
116+
classes = forecast.get("permissionClasses", [])
117+
bundles = forecast.get("approvalBundles", [])
118+
119+
return format_permission_forecast(classes, bundles if bundles else None)
120+
121+
122+
def generate_standalone_forecast(mode: str) -> str:
123+
"""Generate a permission forecast for standalone (non-MCP) mode.
124+
125+
Uses the same base permission classes as the MCP server to keep
126+
the display consistent regardless of backend.
127+
128+
Args:
129+
mode: Mode name (PLAN, ACT, EVAL, AUTO).
130+
131+
Returns:
132+
Compact status line string, or empty string for read-only modes.
133+
"""
134+
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)

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)