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:
AIPass
2026-05-07 20:14:29 -07:00
committed by GitHub
11 changed files with 417 additions and 14 deletions
+1
View File
@@ -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)"}]}
+1 -1
View File
@@ -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 |
+41 -12
View File
@@ -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
+5
View File
@@ -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": {
+11 -1
View File
@@ -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"]