Merge pull request #535 from AIOSAI/work/system-dplan-0167-phase-3-hooks-report-command-snapshot-t
feat(system): DPLAN-0167 Phase 3: hooks report command, snapshot testing, README accountability in subagent_stop_gate
This commit is contained in:
@@ -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)"}]}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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"}]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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}]}
|
||||
]
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user