From 7b998d1596e18b697a6425bf1d8714c299dfa51a Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 7 May 2026 19:46:14 -0700 Subject: [PATCH] feat(system): DPLAN-0167 Phase 3: hooks report command, snapshot testing, README accountability in subagent_stop_gate Co-Authored-By: @devpulse --- .claude/hooks/.diagnostics_state.json | 1 + .claude/hooks/README.md | 2 +- .claude/hooks/subagent_stop_gate.py | 53 +++- src/aipass/seedgo/.seedgo/bypass.json | 5 + src/aipass/seedgo/apps/modules/hooks.py | 12 +- src/aipass/seedgo/apps/modules/hooks_ext.py | 28 ++ .../tests/fixtures/branch_hooks_snapshot.json | 10 + .../fixtures/project_hooks_snapshot.json | 1 + .../fixtures/provider_hooks_snapshot.json | 30 +++ .../seedgo/tests/test_hooks_snapshot.py | 240 ++++++++++++++++++ src/aipass/seedgo/tests/test_hooks_track_a.py | 49 ++++ 11 files changed, 417 insertions(+), 14 deletions(-) create mode 100644 .claude/hooks/.diagnostics_state.json create mode 100644 src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json create mode 100644 src/aipass/seedgo/tests/fixtures/project_hooks_snapshot.json create mode 100644 src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json create mode 100644 src/aipass/seedgo/tests/test_hooks_snapshot.py diff --git a/.claude/hooks/.diagnostics_state.json b/.claude/hooks/.diagnostics_state.json new file mode 100644 index 00000000..d4c1f1b9 --- /dev/null +++ b/.claude/hooks/.diagnostics_state.json @@ -0,0 +1 @@ +{"file": "/home/patrick/Projects/AIPass/src/aipass/seedgo/tests/test_hooks_track_a.py", "errors": [{"line": 347, "message": "E501: Line too long (148 > 120)"}, {"line": 366, "message": "E501: Line too long (148 > 120)"}]} \ No newline at end of file diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md index fff0511f..d997a04c 100644 --- a/.claude/hooks/README.md +++ b/.claude/hooks/README.md @@ -86,7 +86,7 @@ Guarded: `global_prompt_loader.py`, `branch_prompt_loader.py`, ### Other events (provider) | Script | Event | Purpose | |--------|-------|---------| -| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files | +| `subagent_stop_gate.py` | SubagentStop | Runs seedgo checklist on subagent-modified files + hook README reminder | | `pre_compact.py` | PreCompact | Injects post-compact recovery context | | `stop_sound.py` | Stop | Plays achievement bell | | `notification_sound.py` | Notification | Plays notification sound | diff --git a/.claude/hooks/subagent_stop_gate.py b/.claude/hooks/subagent_stop_gate.py index 0b52b155..edbef876 100644 --- a/.claude/hooks/subagent_stop_gate.py +++ b/.claude/hooks/subagent_stop_gate.py @@ -100,14 +100,39 @@ def run_seedgo_checklist(file_path: str) -> list[str]: return [] +def check_hook_readme_accountability() -> str | None: + """Check if hook files changed but README wasn't updated. Returns reminder or None.""" + if AIPASS_ROOT is None: + return None + try: + result = subprocess.run( + ["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT) + ) + changed = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()] + + hook_files_changed = any(f.startswith(".claude/hooks/") and f.endswith(".py") for f in changed) + readme_changed = ".claude/hooks/README.md" in changed + + if hook_files_changed and not readme_changed: + return ( + "Hook files were modified but .claude/hooks/README.md was not updated. " + "Consider updating the README to reflect your changes." + ) + except Exception: + pass + return None + + def main(): try: - input_data = json.load(sys.stdin) + json.load(sys.stdin) modified = get_modified_py_files() if not modified: return # Nothing to check + readme_reminder = check_hook_readme_accountability() + all_violations = {} for f in modified: vs = run_seedgo_checklist(f) @@ -115,19 +140,23 @@ def main(): name = Path(f).name all_violations[name] = vs - if not all_violations: - return # All clear + if all_violations: + # Build the block reason + lines = ["Standards violations found in files you modified:\n"] + for fname, vs in all_violations.items(): + lines.append(f" {fname}:") + for v in vs: + lines.append(f" - {v}") + lines.append("\nFix these violations before finishing.") - # Build the block reason - lines = ["Standards violations found in files you modified:\n"] - for fname, vs in all_violations.items(): - lines.append(f" {fname}:") - for v in vs: - lines.append(f" - {v}") - lines.append("\nFix these violations before finishing.") + if readme_reminder: + lines.append(f"\n⚠️ {readme_reminder}") - output = {"decision": "block", "reason": "\n".join(lines)} - print(json.dumps(output)) + output = {"decision": "block", "reason": "\n".join(lines)} + print(json.dumps(output)) + elif readme_reminder: + output = {"decision": "allow", "reason": f"⚠️ {readme_reminder}"} + print(json.dumps(output)) except Exception: pass # Silent fail — don't block on errors diff --git a/src/aipass/seedgo/.seedgo/bypass.json b/src/aipass/seedgo/.seedgo/bypass.json index 736e2a07..7eaca033 100644 --- a/src/aipass/seedgo/.seedgo/bypass.json +++ b/src/aipass/seedgo/.seedgo/bypass.json @@ -274,6 +274,11 @@ "file": "tests/test_coverage_audit.py", "standard": "encapsulation", "reason": "Unit tests must import handlers directly to test them in isolation. Same pattern as test_checkers_batch5.py." + }, + { + "file": "tests/test_hooks_snapshot.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design." } ], "notes": { diff --git a/src/aipass/seedgo/apps/modules/hooks.py b/src/aipass/seedgo/apps/modules/hooks.py index f89f2b06..987d6966 100644 --- a/src/aipass/seedgo/apps/modules/hooks.py +++ b/src/aipass/seedgo/apps/modules/hooks.py @@ -52,7 +52,7 @@ from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.file import write_text_safe # Extended subcommands (test + list) -from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, run_hooks_test +from aipass.seedgo.apps.modules.hooks_ext import cmd_hooks_list, run_hooks_report, run_hooks_test # Rich output from rich.panel import Panel @@ -473,6 +473,11 @@ def _cmd_hooks_list() -> None: cmd_hooks_list(_get_repo_root()) +def _cmd_hooks_report(args: list) -> None: + """Run hook execution report — delegates to hooks_ext.""" + run_hooks_report(_get_repo_root(), args) + + # ============================================================================= # INTROSPECTION # ============================================================================= @@ -507,6 +512,7 @@ def print_introspection() -> None: " [green]drone @seedgo hooks probe --matrix[/green] [dim]# Full event matrix + markdown report[/dim]" ) console.print(" [green]drone @seedgo hooks test[/green] [dim]# Run hook test suite[/dim]") + console.print(" [green]drone @seedgo hooks report[/green] [dim]# Hook execution log report[/dim]") console.print(" [green]drone @seedgo hooks list[/green] [dim]# List all wired hooks[/dim]") console.print() @@ -574,6 +580,10 @@ def handle_command(command: str, args: List[str]) -> bool: _cmd_hooks_test() return True + if subcommand == "report": + _cmd_hooks_report(args[1:]) + return True + if subcommand == "list": _cmd_hooks_list() return True diff --git a/src/aipass/seedgo/apps/modules/hooks_ext.py b/src/aipass/seedgo/apps/modules/hooks_ext.py index 16837a46..837168f8 100644 --- a/src/aipass/seedgo/apps/modules/hooks_ext.py +++ b/src/aipass/seedgo/apps/modules/hooks_ext.py @@ -86,6 +86,34 @@ def run_hooks_test(repo_root: Path) -> None: ) +# ============================================================================= +# SUBCOMMAND: hooks report +# ============================================================================= + + +def run_hooks_report(repo_root: Path, args: list) -> None: + """Run hook execution report — delegates to hook_report.py.""" + import subprocess + + script = repo_root / ".claude" / "hooks" / "hook_report.py" + if not script.exists(): + warning(f"hook_report.py not found at {script}") + return + + cmd = ["python3", str(script)] + args + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.stdout: + console.print(result.stdout.rstrip()) + if result.stderr: + console.print(f"[red]{result.stderr.rstrip()}[/red]") + except subprocess.TimeoutExpired: + logger.info("hooks_ext.py: hook_report.py timed out after 30s") + warning("hook_report.py timed out after 30s") + + json_handler.log_operation("hooks_report", {"args": args}) + + # ============================================================================= # SUBCOMMAND: hooks list — helpers # ============================================================================= diff --git a/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json new file mode 100644 index 00000000..77dbc6f3 --- /dev/null +++ b/src/aipass/seedgo/tests/fixtures/branch_hooks_snapshot.json @@ -0,0 +1,10 @@ +{ + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": "python3 .claude/hooks/branch_prompt_loader.py"}]}, + {"hooks": [{"type": "command", "command": "python3 .claude/hooks/email_notification.py"}]}, + {"hooks": [{"type": "command", "command": "python3 .claude/hooks/identity_injector.py"}]} + ], + "PreCompact": [ + {"hooks": [{"type": "command", "command": "python3 .claude/hooks/pre_compact.py"}]} + ] +} diff --git a/src/aipass/seedgo/tests/fixtures/project_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/project_hooks_snapshot.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/aipass/seedgo/tests/fixtures/project_hooks_snapshot.json @@ -0,0 +1 @@ +{} diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json new file mode 100644 index 00000000..2e5cad32 --- /dev/null +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -0,0 +1,30 @@ +{ + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/global_prompt_loader.py"}]}, + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/branch_prompt_loader.py"}]}, + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/identity_injector.py"}]}, + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/email_notification.py"}]} + ], + "PreToolUse": [ + {"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/tool_use_sound.py"}]}, + {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/pre_edit_gate.py"}]}, + {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/git_gate.py"}]} + ], + "PostToolUse": [ + {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/auto_fix_diagnostics.py"}]}, + {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 /home/patrick/.claude/hooks/auto_watchdog.py"}]} + ], + "SubagentStop": [ + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/subagent_stop_gate.py"}]} + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/stop_sound.py"}]} + ], + "Notification": [ + {"hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/notification_sound.py"}]} + ], + "PreCompact": [ + {"matcher": "manual", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]}, + {"matcher": "auto", "hooks": [{"type": "command", "command": "python3 /home/patrick/Projects/AIPass/.claude/hooks/pre_compact.py", "timeout": 60}]} + ] +} diff --git a/src/aipass/seedgo/tests/test_hooks_snapshot.py b/src/aipass/seedgo/tests/test_hooks_snapshot.py new file mode 100644 index 00000000..e678b01b --- /dev/null +++ b/src/aipass/seedgo/tests/test_hooks_snapshot.py @@ -0,0 +1,240 @@ +"""Hook Configuration Snapshot Tests. + +Compares current settings.json hook configurations against known-good baselines. +Detects: hooks added/removed, command strings changed, matchers changed, events changed. + +Baselines in tests/fixtures/*_hooks_snapshot.json. + +# =================== META ==================== +# Name: test_hooks_snapshot.py +# Description: Snapshot tests for hook configurations across provider, project, and branch levels +# Version: 1.0.0 +# Created: 2026-05-07 +# Modified: 2026-05-07 +# ============================================= +""" + +import json +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _find_repo_root() -> Path: + """Walk up from this file to find the git repo root.""" + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / ".git").exists(): + return parent + return Path(__file__).resolve().parents[4] # fallback + + +_REPO_ROOT = _find_repo_root() + + +def _load_fixture(name: str) -> dict: + """Load a JSON fixture file by name from the fixtures directory.""" + path = FIXTURES / name + assert path.exists(), f"Fixture missing: {path}" + return json.loads(path.read_text(encoding="utf-8")) + + +def _load_settings_hooks(settings_path: Path) -> dict: + """Load the hooks dict from a settings.json file, returning empty dict if missing.""" + if not settings_path.exists(): + return {} + data = json.loads(settings_path.read_text(encoding="utf-8")) + return data.get("hooks", {}) + + +def _extract_hook_commands(hooks_config: dict) -> dict[str, list[str]]: + """Extract {event: [command_strings]} from a hooks config, sorted for comparison.""" + result = {} + for event, entries in hooks_config.items(): + commands = [] + for entry in entries: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + if cmd: + commands.append(cmd) + result[event] = sorted(commands) + return result + + +def _extract_hook_matchers(hooks_config: dict) -> dict[str, list[str]]: + """Extract {event: [matchers]} from a hooks config.""" + result = {} + for event, entries in hooks_config.items(): + matchers = [] + for entry in entries: + m = entry.get("matcher", "*") + matchers.append(m) + result[event] = sorted(matchers) + return result + + +# -- Provider hooks snapshot --------------------------------------------------- + + +class TestProviderHooksSnapshot: + """Compare ~/.claude/settings.json hooks against known-good baseline.""" + + @pytest.fixture() + def baseline(self): + """Load provider hooks baseline fixture.""" + return _load_fixture("provider_hooks_snapshot.json") + + @pytest.fixture() + def current(self): + """Load current provider hooks from ~/.claude/settings.json.""" + return _load_settings_hooks(Path.home() / ".claude" / "settings.json") + + def test_same_events(self, baseline, current): + """Verify the same hook events exist in baseline and current.""" + assert set(baseline.keys()) == set(current.keys()), ( + f"Event mismatch. Expected: {sorted(baseline.keys())}, Got: {sorted(current.keys())}" + ) + + def test_same_hook_count_per_event(self, baseline, current): + """Verify the same number of hooks per event in baseline and current.""" + for event in baseline: + expected = len(baseline[event]) + actual = len(current.get(event, [])) + assert expected == actual, f"{event}: expected {expected} hooks, got {actual}" + + def test_same_commands(self, baseline, current): + """Verify all hook command strings match between baseline and current.""" + expected = _extract_hook_commands(baseline) + actual = _extract_hook_commands(current) + assert expected == actual, f"Command mismatch:\nExpected: {expected}\nActual: {actual}" + + def test_same_matchers(self, baseline, current): + """Verify all hook matchers match between baseline and current.""" + expected = _extract_hook_matchers(baseline) + actual = _extract_hook_matchers(current) + assert expected == actual, f"Matcher mismatch:\nExpected: {expected}\nActual: {actual}" + + def test_no_unexpected_hooks_added(self, baseline, current): + """Detect any hooks added since the snapshot was taken.""" + baseline_cmds: set[str] = set() + current_cmds: set[str] = set() + for cmds in _extract_hook_commands(baseline).values(): + baseline_cmds.update(cmds) + for cmds in _extract_hook_commands(current).values(): + current_cmds.update(cmds) + added = current_cmds - baseline_cmds + assert not added, f"Hooks added since snapshot: {added}" + + def test_no_hooks_removed(self, baseline, current): + """Detect any hooks removed since the snapshot was taken.""" + baseline_cmds: set[str] = set() + current_cmds: set[str] = set() + for cmds in _extract_hook_commands(baseline).values(): + baseline_cmds.update(cmds) + for cmds in _extract_hook_commands(current).values(): + current_cmds.update(cmds) + removed = baseline_cmds - current_cmds + assert not removed, f"Hooks removed since snapshot: {removed}" + + +# -- Project hooks snapshot ---------------------------------------------------- + + +class TestProjectHooksSnapshot: + """Confirm project-root .claude/settings.json has NO hooks (correct state).""" + + @pytest.fixture() + def baseline(self): + """Load project hooks baseline fixture (expected empty).""" + return _load_fixture("project_hooks_snapshot.json") + + @pytest.fixture() + def current(self): + """Load current project-root hooks from .claude/settings.json.""" + return _load_settings_hooks(_REPO_ROOT / ".claude" / "settings.json") + + def test_project_has_no_hooks(self, baseline, current): + """Verify project root settings.json has no hooks configured.""" + assert current == baseline == {}, f"Project root should have no hooks, found: {list(current.keys())}" + + +# -- Double-fire assertion ----------------------------------------------------- + + +class TestDoubleFire: + """Verify no hook command string appears at both provider AND project level with different paths.""" + + def test_no_command_overlap_different_strings(self): + """Detect double-fire risk from same script at provider and project with different paths.""" + provider = _load_settings_hooks(Path.home() / ".claude" / "settings.json") + project = _load_settings_hooks(_REPO_ROOT / ".claude" / "settings.json") + + provider_cmds: set[str] = set() + project_cmds: set[str] = set() + for cmds in _extract_hook_commands(provider).values(): + provider_cmds.update(cmds) + for cmds in _extract_hook_commands(project).values(): + project_cmds.update(cmds) + + # Extract just the script filename from each command for overlap detection + def script_name(cmd: str) -> str: + """Extract the .py filename from a hook command string.""" + for part in cmd.split(): + if part.endswith(".py"): + return Path(part).name + return cmd + + provider_scripts = {script_name(c) for c in provider_cmds} + project_scripts = {script_name(c) for c in project_cmds} + overlap = provider_scripts & project_scripts + + # If same script appears in both, the command strings MUST be identical (dedup) + # or it will double-fire + for script in overlap: + p_cmds = [c for c in provider_cmds if script_name(c) == script] + j_cmds = [c for c in project_cmds if script_name(c) == script] + for pc in p_cmds: + for jc in j_cmds: + assert pc == jc, ( + f"Double-fire risk: {script} has different command strings at " + f"provider ({pc!r}) vs project ({jc!r}). " + f"Claude Code deduplicates by exact string — different strings = fires twice." + ) + + def test_branch_hooks_dont_duplicate_provider(self): + """Branch-level hooks should NOT include hooks that only work from provider level.""" + branch_baseline = _load_fixture("branch_hooks_snapshot.json") + provider_only_events = {"PreToolUse", "PostToolUse"} + + for event in provider_only_events: + assert event not in branch_baseline, ( + f"Branch baseline has {event} hooks — these only fire from provider settings" + ) + + +# -- Branch hooks snapshot ----------------------------------------------------- + + +class TestBranchHooksSnapshot: + """Verify branch-level settings match the known-good pattern.""" + + @pytest.fixture() + def baseline(self): + """Load branch hooks baseline fixture.""" + return _load_fixture("branch_hooks_snapshot.json") + + def test_branch_settings_match_baseline(self, baseline): + """Spot-check a few branches have the correct hooks.""" + branches_to_check = ["seedgo", "devpulse", "aipass"] + for branch in branches_to_check: + settings_path = _REPO_ROOT / "src" / "aipass" / branch / ".claude" / "settings.json" + if not settings_path.exists(): + continue + current = _load_settings_hooks(settings_path) + expected_cmds = _extract_hook_commands(baseline) + actual_cmds = _extract_hook_commands(current) + assert expected_cmds == actual_cmds, ( + f"Branch {branch} hooks don't match baseline.\nExpected: {expected_cmds}\nActual: {actual_cmds}" + ) diff --git a/src/aipass/seedgo/tests/test_hooks_track_a.py b/src/aipass/seedgo/tests/test_hooks_track_a.py index 563fb71e..24a0fae9 100644 --- a/src/aipass/seedgo/tests/test_hooks_track_a.py +++ b/src/aipass/seedgo/tests/test_hooks_track_a.py @@ -308,6 +308,7 @@ def test_subagent_gate_no_block_when_no_violations(capsys): patch("sys.stdin", io.StringIO(payload)), patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), patch.object(mod, "run_seedgo_checklist", return_value=[]), + patch.object(mod, "check_hook_readme_accountability", return_value=None), ): mod.main() @@ -324,6 +325,7 @@ def test_subagent_gate_blocks_on_violations(capsys): patch("sys.stdin", io.StringIO(payload)), patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), patch.object(mod, "run_seedgo_checklist", return_value=["open() without encoding='utf-8'"]), + patch.object(mod, "check_hook_readme_accountability", return_value=None), ): mod.main() @@ -331,3 +333,50 @@ def test_subagent_gate_blocks_on_violations(capsys): assert captured.out.strip() != "" output = json.loads(captured.out) assert output["decision"] == "block" + + +def test_subagent_gate_readme_reminder_soft(capsys): + """Hook files changed without README update → allow with reminder.""" + mod = _load_hook("subagent_stop_gate.py") + payload = json.dumps({"stop_hook_active": True}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), + patch.object(mod, "run_seedgo_checklist", return_value=[]), + patch.object( + mod, + "check_hook_readme_accountability", + return_value="Hook files were modified but .claude/hooks/README.md was not updated.", + ), + ): + mod.main() + + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["decision"] == "allow" + assert "README" in output["reason"] + + +def test_subagent_gate_readme_reminder_appended_to_block(capsys): + """Hook files changed + violations → block includes README reminder.""" + mod = _load_hook("subagent_stop_gate.py") + payload = json.dumps({"stop_hook_active": True}) + + with ( + patch("sys.stdin", io.StringIO(payload)), + patch.object(mod, "get_modified_py_files", return_value=["/tmp/foo.py"]), + patch.object(mod, "run_seedgo_checklist", return_value=["missing docstring"]), + patch.object( + mod, + "check_hook_readme_accountability", + return_value="Hook files were modified but .claude/hooks/README.md was not updated.", + ), + ): + mod.main() + + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["decision"] == "block" + assert "README" in output["reason"] + assert "missing docstring" in output["reason"]