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
138 changes: 138 additions & 0 deletions packages/claude-code-plugin/hooks/lib/permission_forecast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Permission forecast formatting for CodingBuddy plugin (#1418).

Formats permission forecast data from parse_mode MCP responses and
generates standalone forecasts for self-contained mode.

All public functions are pure — no I/O, no side effects.
"""

from __future__ import annotations

from typing import Dict, List, Optional, Sequence


# ─── Permission class definitions ────────────────────────────────────

# Map of permission class to compact icon+label
PERMISSION_CLASS_LABELS: Dict[str, str] = {
"read-only": "read-only",
"repo-write": "repo-write",
"network": "network",
"destructive": "destructive",
"external": "external",
}

# ─── Standalone forecasts per mode ────────────────────────────────────

# Default permission classes per mode (mirrors MCP server MODE_BASE_CLASSES)
MODE_BASE_CLASSES: Dict[str, List[str]] = {
"PLAN": ["read-only"],
"ACT": ["read-only", "repo-write"],
"EVAL": ["read-only"],
"AUTO": ["read-only", "repo-write", "external"],
}

# Default approval bundles per mode for standalone
MODE_DEFAULT_BUNDLES: Dict[str, List[Dict[str, str]]] = {
"PLAN": [],
"ACT": [
{"name": "Code changes", "permissionClass": "repo-write"},
],
"EVAL": [],
"AUTO": [
{"name": "Code changes", "permissionClass": "repo-write"},
{"name": "Ship changes", "permissionClass": "external"},
],
}


# ─── Public API ───────────────────────────────────────────────────────


def format_permission_forecast(
permission_classes: Sequence[str],
approval_bundles: Optional[Sequence[Dict[str, str]]] = None,
) -> str:
"""Format permission forecast data as a compact status line.

Args:
permission_classes: List of permission class names
(e.g. ["read-only", "repo-write"]).
approval_bundles: Optional list of bundle dicts, each with
at least ``name`` and ``permissionClass`` keys.

Returns:
Compact one-line string, e.g.
``Permissions: repo-write (Code changes) | external (Ship changes)``

Returns empty string when there are no permission classes
or only "read-only" with no bundles.
"""
if not permission_classes:
return ""

# Filter out read-only when it is the only class and there are no bundles
non_readonly = [c for c in permission_classes if c != "read-only"]
if not non_readonly and not approval_bundles:
return ""

parts: list[str] = []

if approval_bundles:
# Group bundles by permission class for compact display
for bundle in approval_bundles:
name = bundle.get("name", "")
pclass = bundle.get("permissionClass", "")
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
parts.append(f"{label} ({name})")
else:
# No bundles — just list the non-readonly classes
for pclass in non_readonly:
label = PERMISSION_CLASS_LABELS.get(pclass, pclass)
parts.append(label)

if not parts:
return ""

return "Permissions: " + " | ".join(parts)


def format_permission_forecast_from_mcp(
forecast: Optional[Dict],
) -> str:
"""Extract and format permission forecast from a parse_mode MCP response.

NOTE: Reserved for future MCP integration — not yet called by production code.

Args:
forecast: The ``permissionForecast`` dict from parse_mode, or None.

Returns:
Compact status line string, or empty string if no forecast data.
"""
if not forecast:
return ""

classes = forecast.get("permissionClasses", [])
bundles = forecast.get("approvalBundles", [])

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.

Uses the same base permission classes as the MCP server to keep
the display consistent regardless of backend.

Args:
mode: Mode name (PLAN, ACT, EVAL, AUTO).

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)
108 changes: 108 additions & 0 deletions packages/claude-code-plugin/hooks/test_user_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,114 @@ def test_auto_mode_seeds_council(self):
assert state["councilCast"][0] == "auto-mode"


class TestPermissionForecastIntegration:
"""#1418: Permission forecast display in hook output."""

def _run_hook(self, prompt, home_dir, mcp_enabled=False):
import os as _os

hook_path = Path(__file__).parent / "user-prompt-submit.py"
input_data = json.dumps({"prompt": prompt})
env = _os.environ.copy()
env["HOME"] = str(home_dir)
env["CLAUDE_PROJECT_DIR"] = str(home_dir)
env.pop("CODINGBUDDY_RULES_DIR", None)
env.pop("CODINGBUDDY_HUD_STATE_FILE", None)

if mcp_enabled:
claude_dir = Path(home_dir) / ".claude"
claude_dir.mkdir(exist_ok=True)
mcp_json = claude_dir / "mcp.json"
mcp_json.write_text(json.dumps({
"mcpServers": {
"codingbuddy": {"command": "codingbuddy", "args": ["mcp"]}
}
}))

return subprocess.run(
[sys.executable, str(hook_path)],
input=input_data,
capture_output=True,
text=True,
env=env,
cwd=str(home_dir),
)

def test_act_mode_shows_forecast_standalone(self):
"""ACT mode in standalone should show repo-write permission forecast."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
Path(tmpdir, ".claude").mkdir()
result = self._run_hook("ACT: implement the feature", tmpdir)
assert result.returncode == 0
assert "Permissions:" in result.stdout
assert "repo-write" in result.stdout

def test_act_mode_shows_forecast_mcp(self):
"""ACT mode in MCP should show repo-write permission forecast."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
result = self._run_hook(
"ACT: implement the feature", tmpdir, mcp_enabled=True
)
assert result.returncode == 0
assert "Permissions:" in result.stdout
assert "repo-write" in result.stdout

def test_plan_mode_no_forecast(self):
"""PLAN mode is read-only, no permission forecast needed."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
result = self._run_hook(
"PLAN: design the architecture for the auth module",
tmpdir,
mcp_enabled=True,
)
assert result.returncode == 0
assert "Permissions:" not in result.stdout

def test_eval_mode_no_forecast(self):
"""EVAL mode is read-only, no permission forecast needed."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
result = self._run_hook(
"EVAL: review the code quality of the auth module",
tmpdir,
mcp_enabled=True,
)
assert result.returncode == 0
assert "Permissions:" not in result.stdout

def test_auto_mode_shows_forecast(self):
"""AUTO mode should show repo-write and external permissions."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
result = self._run_hook(
"AUTO: build the complete user dashboard feature",
tmpdir,
mcp_enabled=True,
)
assert result.returncode == 0
assert "Permissions:" in result.stdout
assert "repo-write" in result.stdout
assert "external" in result.stdout

def test_no_keyword_no_forecast(self):
"""Regular messages should not show any forecast."""
import tempfile

with tempfile.TemporaryDirectory() as tmpdir:
Path(tmpdir, ".claude").mkdir()
result = self._run_hook("Hello world", tmpdir)
assert result.returncode == 0
assert "Permissions:" not in result.stdout


if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
10 changes: 10 additions & 0 deletions packages/claude-code-plugin/hooks/user-prompt-submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def main():
try:
from runtime_mode import is_mcp_available
from mode_engine import ModeEngine, COUNCIL_PRESETS
from permission_forecast import generate_standalone_forecast

project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
if is_mcp_available(project_dir=project_dir):
Expand All @@ -81,6 +82,11 @@ def main():
"If mcp__codingbuddy__parse_mode is available, "
"call it for enhanced features."
)
# Permission forecast hint (#1418): show standalone
# forecast as a preview; parse_mode will refine it.
forecast_line = generate_standalone_forecast(detected_mode)
if forecast_line:
print(forecast_line)
# MCP council preset for eligible modes (#1361)
council_preset = COUNCIL_PRESETS.get(detected_mode)
else:
Expand All @@ -94,6 +100,10 @@ def main():
detected_mode, prompt=prompt
)
print(instructions)
# Permission forecast for standalone mode (#1418)
forecast_line = generate_standalone_forecast(detected_mode)
if forecast_line:
print(forecast_line)
# Standalone council preset from Tiny Actor presets (#1361)
try:
from tiny_actor_presets import CAST_PRESETS
Expand Down
Loading
Loading