diff --git a/packages/claude-code-plugin/hooks/lib/permission_forecast.py b/packages/claude-code-plugin/hooks/lib/permission_forecast.py new file mode 100644 index 00000000..f300eff3 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/permission_forecast.py @@ -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) diff --git a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py index 2577f06e..f0e76c42 100644 --- a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py +++ b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py @@ -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"]) diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index 97a5ac8e..2f6d5192 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -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): @@ -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: @@ -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 diff --git a/packages/claude-code-plugin/tests/test_permission_forecast.py b/packages/claude-code-plugin/tests/test_permission_forecast.py new file mode 100644 index 00000000..bc482c4f --- /dev/null +++ b/packages/claude-code-plugin/tests/test_permission_forecast.py @@ -0,0 +1,173 @@ +"""Tests for permission_forecast module (#1418). + +Covers: +- format_permission_forecast: no-forecast, read-only, repo-write, network, external +- format_permission_forecast_from_mcp: MCP response extraction +- generate_standalone_forecast: per-mode standalone forecasts +""" + +import sys +from pathlib import Path + +# Ensure lib/ is importable +_lib_dir = str(Path(__file__).resolve().parent.parent / "hooks" / "lib") +if _lib_dir not in sys.path: + sys.path.insert(0, _lib_dir) + +from permission_forecast import ( + format_permission_forecast, + format_permission_forecast_from_mcp, + generate_standalone_forecast, +) + + +class TestFormatPermissionForecast: + """Tests for format_permission_forecast().""" + + def test_empty_classes_returns_empty(self): + assert format_permission_forecast([]) == "" + + def test_readonly_only_returns_empty(self): + """read-only with no bundles is not worth displaying.""" + assert format_permission_forecast(["read-only"]) == "" + + def test_readonly_with_bundles_shows_bundles(self): + bundles = [{"name": "Run checks", "permissionClass": "read-only"}] + result = format_permission_forecast(["read-only"], bundles) + assert "Permissions:" in result + assert "Run checks" in result + + def test_repo_write_without_bundles(self): + result = format_permission_forecast(["read-only", "repo-write"]) + assert result == "Permissions: repo-write" + + def test_repo_write_with_bundle(self): + bundles = [{"name": "Code changes", "permissionClass": "repo-write"}] + result = format_permission_forecast(["read-only", "repo-write"], bundles) + assert result == "Permissions: repo-write (Code changes)" + + def test_multiple_bundles(self): + bundles = [ + {"name": "Code changes", "permissionClass": "repo-write"}, + {"name": "Ship changes", "permissionClass": "external"}, + ] + result = format_permission_forecast( + ["read-only", "repo-write", "external"], bundles + ) + assert "repo-write (Code changes)" in result + assert "external (Ship changes)" in result + assert " | " in result + + def test_network_class(self): + bundles = [{"name": "Install dependencies", "permissionClass": "network"}] + result = format_permission_forecast(["read-only", "network"], bundles) + assert "network (Install dependencies)" in result + + def test_destructive_class(self): + result = format_permission_forecast(["read-only", "destructive"]) + assert "destructive" in result + + def test_external_class_without_bundles(self): + result = format_permission_forecast(["read-only", "external"]) + assert result == "Permissions: external" + + def test_all_classes(self): + result = format_permission_forecast( + ["read-only", "repo-write", "network", "destructive", "external"] + ) + assert "repo-write" in result + assert "network" in result + assert "destructive" in result + assert "external" in result + + +class TestFormatPermissionForecastFromMcp: + """Tests for format_permission_forecast_from_mcp().""" + + def test_none_forecast_returns_empty(self): + assert format_permission_forecast_from_mcp(None) == "" + + def test_empty_dict_returns_empty(self): + assert format_permission_forecast_from_mcp({}) == "" + + def test_readonly_forecast(self): + forecast = { + "permissionClasses": ["read-only"], + "approvalBundles": [], + "permissionSummary": "PLAN mode requires read-only permissions", + } + assert format_permission_forecast_from_mcp(forecast) == "" + + def test_act_mode_forecast(self): + forecast = { + "permissionClasses": ["read-only", "repo-write"], + "approvalBundles": [ + { + "name": "Code changes", + "actions": ["file edit", "git add", "git commit"], + "permissionClass": "repo-write", + "reason": "Implementation involves editing files", + } + ], + "permissionSummary": "ACT mode requires repo-write permissions", + } + result = format_permission_forecast_from_mcp(forecast) + assert "repo-write (Code changes)" in result + + def test_ship_forecast(self): + forecast = { + "permissionClasses": ["read-only", "repo-write", "external"], + "approvalBundles": [ + { + "name": "Ship changes", + "actions": ["git add", "git commit", "git push", "gh pr create"], + "permissionClass": "external", + "reason": "Committing, pushing, and creating a PR", + } + ], + "permissionSummary": "ACT mode requires external permissions", + } + result = format_permission_forecast_from_mcp(forecast) + assert "external (Ship changes)" in result + + def test_multiple_bundles_from_mcp(self): + forecast = { + "permissionClasses": ["read-only", "repo-write", "network", "external"], + "approvalBundles": [ + {"name": "Ship changes", "permissionClass": "external"}, + {"name": "Install dependencies", "permissionClass": "network"}, + ], + } + result = format_permission_forecast_from_mcp(forecast) + assert "external (Ship changes)" in result + assert "network (Install dependencies)" in result + + +class TestGenerateStandaloneForecast: + """Tests for generate_standalone_forecast().""" + + def test_plan_mode_returns_empty(self): + """PLAN mode is read-only, no forecast needed.""" + assert generate_standalone_forecast("PLAN") == "" + + def test_eval_mode_returns_empty(self): + """EVAL mode is read-only, no forecast needed.""" + assert generate_standalone_forecast("EVAL") == "" + + def test_act_mode_shows_repo_write(self): + result = generate_standalone_forecast("ACT") + assert "Permissions:" in result + assert "repo-write" in result + assert "Code changes" in result + + def test_auto_mode_shows_repo_write_and_external(self): + result = generate_standalone_forecast("AUTO") + assert "repo-write" in result + assert "external" in result + + def test_case_insensitive(self): + assert generate_standalone_forecast("act") == generate_standalone_forecast("ACT") + assert generate_standalone_forecast("Plan") == generate_standalone_forecast("PLAN") + + def test_unknown_mode_returns_empty(self): + assert generate_standalone_forecast("UNKNOWN") == ""