From d050b10bfb4d5f8b9433097ba14ffa8dad21d38c Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Thu, 26 Mar 2026 18:12:26 -0700 Subject: [PATCH 1/2] perf: add matcher groups to PreToolUse/PostToolUse hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split PreToolUse from 1 group (10 hooks) into 7 matcher-filtered groups and PostToolUse from 1 group (13 hooks) into 7 groups. This prevents process spawning for non-matching tools — a Read/Grep/Glob call now spawns 0 PreToolUse hooks instead of 10. Matcher groups: Bash, Bash|Edit, Bash|Write|Edit, Write|Edit, Write, Edit, Agent, Read, Skill|Agent, and an unfiltered group for hooks that need all tool events. Remove dead tool_name self-filter code from 18 Python hooks — the matcher in settings.json now handles filtering before the process spawns. Dead constants (TARGET_TOOLS, TRACKED_TOOLS) also removed. --- .claude/settings.json | 156 +++++++++++++++------- hooks/adr-enforcement.py | 13 +- hooks/agent-grade-on-change.py | 6 +- hooks/ci-merge-gate.py | 5 +- hooks/post-tool-lint-hint.py | 10 +- hooks/posttool-security-scan.py | 9 +- hooks/posttool-session-reads.py | 6 +- hooks/pretool-adr-creation-gate.py | 6 +- hooks/pretool-branch-safety.py | 5 +- hooks/pretool-file-backup.py | 5 +- hooks/pretool-learning-injector.py | 9 +- hooks/pretool-plan-gate.py | 5 +- hooks/pretool-prompt-injection-scanner.py | 6 +- hooks/pretool-subagent-warmstart.py | 6 +- hooks/pretool-synthesis-gate.py | 5 +- hooks/record-activation.py | 9 +- hooks/retro-graduation-gate.py | 12 +- hooks/review-capture.py | 6 +- hooks/usage-tracker.py | 11 +- 19 files changed, 145 insertions(+), 145 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index ed78662..d77278a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -96,41 +96,57 @@ ], "PreToolUse": [ { + "matcher": "Bash|Write|Edit", "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/pretool-unified-gate.py\"", "description": "Unified gate: gitignore-bypass, git-submission, dangerous-command, creation-gate, sensitive-file (ADR-068)", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Bash", + "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-synthesis-gate.py\"", - "description": "Consultation synthesis gate: blocks implementation when ADR consultation is incomplete", + "command": "python3 \"$HOME/.claude/hooks/pretool-branch-safety.py\"", + "description": "Branch safety: blocks git commit on main/master, forces feature branches", "timeout": 3000 }, { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-branch-safety.py\"", - "description": "Branch safety: blocks git commit on main/master, forces feature branches", + "command": "python3 \"$HOME/.claude/hooks/ci-merge-gate.py\"", + "description": "Gate: block merge to main/master when CI checks are red", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Bash|Edit", + "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-plan-gate.py\"", - "description": "Plan gate: blocks implementation code without task_plan.md", + "command": "python3 \"$HOME/.claude/hooks/pretool-learning-injector.py\"", + "description": "Inject known error patterns before Bash/Edit tools run", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-adr-creation-gate.py\"", - "description": "ADR creation gate: blocks new components without an ADR in adr/", + "command": "python3 \"$HOME/.claude/hooks/pretool-synthesis-gate.py\"", + "description": "Consultation synthesis gate: blocks implementation when ADR consultation is incomplete", "timeout": 3000 }, { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-learning-injector.py\"", - "description": "Inject known error patterns before Bash/Edit tools run", + "command": "python3 \"$HOME/.claude/hooks/pretool-plan-gate.py\"", + "description": "Plan gate: blocks implementation code without task_plan.md", "timeout": 3000 }, { @@ -138,39 +154,51 @@ "command": "python3 \"$HOME/.claude/hooks/pretool-prompt-injection-scanner.py\"", "description": "Advisory scan for prompt injection patterns in agent context files (ADR-070)", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Write", + "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/pretool-subagent-warmstart.py\"", - "description": "Inject parent session context into subagent prompts (ADR-088)", - "timeout": 5000 - }, + "command": "python3 \"$HOME/.claude/hooks/pretool-adr-creation-gate.py\"", + "description": "ADR creation gate: blocks new components without an ADR in adr/", + "timeout": 3000 + } + ] + }, + { + "matcher": "Edit", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/pretool-file-backup.py\"", "description": "Backup files before Edit tool modifies them", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Agent", + "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/ci-merge-gate.py\"", - "description": "Gate: block merge to main/master when CI checks are red", - "timeout": 3000 + "command": "python3 \"$HOME/.claude/hooks/pretool-subagent-warmstart.py\"", + "description": "Inject parent session context into subagent prompts (ADR-088)", + "timeout": 5000 } ] } ], "PostToolUse": [ { + "matcher": "Write|Edit", "hooks": [ { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/post-tool-lint-hint.py\"" - }, - { - "type": "command", - "command": "python3 \"$HOME/.claude/hooks/error-learner.py\"", - "description": "Learn from tool errors and suggest solutions" + "command": "python3 \"$HOME/.claude/hooks/post-tool-lint-hint.py\"", + "description": "Gentle lint reminder after file modifications" }, { "type": "command", @@ -185,48 +213,82 @@ }, { "type": "command", - "command": "python3 \"$HOME/.claude/hooks/routing-gap-recorder.py\"", - "description": "Record /do routing gaps to learning DB for pattern tracking", - "timeout": 2000 - }, + "command": "python3 \"$HOME/.claude/hooks/posttool-security-scan.py\"", + "description": "Advisory scan for credentials and SQL injection in Write/Edit output", + "timeout": 3000 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/retro-graduation-gate.py\"", "description": "Warn about ungraduated retro entries when creating PRs in toolkit repo", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Edit|Write|Bash", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/record-activation.py\"", "description": "Record session activation stats for ROI tracking (ADR-032)" - }, - { - "type": "command", - "command": "python3 \"$HOME/.claude/hooks/record-waste.py\"", - "description": "Record wasted tokens from tool failures for ROI tracking (ADR-032)" - }, + } + ] + }, + { + "matcher": "Read", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/posttool-session-reads.py\"", "description": "Track files read this session for subagent warmstart (ADR-088)" - }, - { - "type": "command", - "command": "python3 \"$HOME/.claude/hooks/posttool-security-scan.py\"", - "description": "Advisory scan for credentials and SQL injection in Write/Edit output", - "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Skill|Agent", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/usage-tracker.py\"", "description": "Record Skill and Agent invocation analytics", "timeout": 3000 - }, + } + ] + }, + { + "matcher": "Agent", + "hooks": [ { "type": "command", "command": "python3 \"$HOME/.claude/hooks/review-capture.py\"", "description": "Capture CRITICAL/HIGH review findings to learning DB", "timeout": 3000 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$HOME/.claude/hooks/error-learner.py\"", + "description": "Learn from tool errors and suggest solutions" + }, + { + "type": "command", + "command": "python3 \"$HOME/.claude/hooks/routing-gap-recorder.py\"", + "description": "Record /do routing gaps to learning DB for pattern tracking", + "timeout": 2000 + }, + { + "type": "command", + "command": "python3 \"$HOME/.claude/hooks/record-waste.py\"", + "description": "Record wasted tokens from tool failures for ROI tracking (ADR-032)" }, { "type": "command", diff --git a/hooks/adr-enforcement.py b/hooks/adr-enforcement.py index 4f2d567..fb5bccf 100644 --- a/hooks/adr-enforcement.py +++ b/hooks/adr-enforcement.py @@ -180,17 +180,8 @@ def main() -> None: event = json.loads(raw) - # Only process PostToolUse events - event_type = event.get("hook_event_name") or event.get("type", "") - if event_type != _EVENT_NAME: - empty_output(_EVENT_NAME).print_and_exit(0) - return - - # Only act on Write or Edit tool calls - tool_name = event.get("tool_name", "") - if tool_name not in ("Write", "Edit"): - empty_output(_EVENT_NAME).print_and_exit(0) - return + # tool_name/event_type filters removed — matcher "Write|Edit" in settings.json + # prevents this hook from spawning for non-matching tools. # Extract file path from tool input tool_input = event.get("tool_input", {}) diff --git a/hooks/agent-grade-on-change.py b/hooks/agent-grade-on-change.py index 06303c1..4de4084 100644 --- a/hooks/agent-grade-on-change.py +++ b/hooks/agent-grade-on-change.py @@ -90,10 +90,8 @@ def main(): if not hook_input: return - # Check if this is a relevant tool call - tool_name = hook_input.get("tool_name", "") - if tool_name not in ("Edit", "Write"): - return + # tool_name filter removed — matcher "Write|Edit" in settings.json prevents + # this hook from spawning for non-matching tools. # Extract file path from tool input tool_input_data = hook_input.get("tool_input", {}) diff --git a/hooks/ci-merge-gate.py b/hooks/ci-merge-gate.py index f2ec425..f29d9ef 100644 --- a/hooks/ci-merge-gate.py +++ b/hooks/ci-merge-gate.py @@ -19,9 +19,8 @@ def main() -> None: data = json.loads(read_stdin(timeout=2)) - tool = data.get("tool_name", "") - if tool != "Bash": - return + # tool_name filter removed — matcher "Bash" in settings.json prevents + # this hook from spawning for non-Bash tools. command = data.get("tool_input", {}).get("command", "") diff --git a/hooks/post-tool-lint-hint.py b/hooks/post-tool-lint-hint.py index 87f9611..f93a012 100755 --- a/hooks/post-tool-lint-hint.py +++ b/hooks/post-tool-lint-hint.py @@ -69,14 +69,8 @@ def main(): event_data = read_stdin(timeout=2) event = json.loads(event_data) - # Check this is PostToolUse for Write or Edit - event_type = event.get("hook_event_name") or event.get("type", "") - if event_type != "PostToolUse": - return - - tool_name = event.get("tool_name", "") - if tool_name not in ("Write", "Edit"): - return + # tool_name/event_type filters removed — matcher "Write|Edit" in settings.json + # prevents this hook from spawning for non-matching tools. # Get the file path from tool input tool_input = event.get("tool_input", {}) diff --git a/hooks/posttool-security-scan.py b/hooks/posttool-security-scan.py index 8270b56..3fd0796 100755 --- a/hooks/posttool-security-scan.py +++ b/hooks/posttool-security-scan.py @@ -143,13 +143,8 @@ def main() -> None: raw = read_stdin(timeout=2) event = json.loads(raw) - event_type = event.get("hook_event_name") or event.get("type", "") - if event_type != "PostToolUse": - return - - tool_name = event.get("tool_name", "") - if tool_name not in ("Write", "Edit"): - return + # tool_name/event_type filters removed — matcher "Write|Edit" in settings.json + # prevents this hook from spawning for non-matching tools. tool_input = event.get("tool_input", {}) file_path = tool_input.get("file_path", "") diff --git a/hooks/posttool-session-reads.py b/hooks/posttool-session-reads.py index f1b2f62..a18400c 100755 --- a/hooks/posttool-session-reads.py +++ b/hooks/posttool-session-reads.py @@ -48,10 +48,8 @@ def main() -> None: event = json.loads(event_data) - # Only process Read tool results - tool_name = event.get("tool_name", "") - if tool_name != "Read": - return + # tool_name filter removed — matcher "Read" in settings.json prevents + # this hook from spawning for non-Read tools. # Extract file_path from tool_input tool_input = event.get("tool_input", {}) diff --git a/hooks/pretool-adr-creation-gate.py b/hooks/pretool-adr-creation-gate.py index 075c79a..a1bfd1d 100644 --- a/hooks/pretool-adr-creation-gate.py +++ b/hooks/pretool-adr-creation-gate.py @@ -70,10 +70,8 @@ def main() -> None: except (json.JSONDecodeError, ValueError): sys.exit(0) - # Only gate Write — edits to existing files are fine. - tool_name = event.get("tool_name", "") - if tool_name != "Write": - sys.exit(0) + # tool_name filter removed — matcher "Write" in settings.json prevents + # this hook from spawning for non-Write tools. # Bypass env var. if os.environ.get(_BYPASS_ENV) == "1": diff --git a/hooks/pretool-branch-safety.py b/hooks/pretool-branch-safety.py index 406dd58..5706a1e 100644 --- a/hooks/pretool-branch-safety.py +++ b/hooks/pretool-branch-safety.py @@ -60,9 +60,8 @@ def main() -> None: except (json.JSONDecodeError, ValueError): sys.exit(0) - tool_name = event.get("tool_name", "") - if tool_name != "Bash": - sys.exit(0) + # tool_name filter removed — matcher "Bash" in settings.json prevents + # this hook from spawning for non-Bash tools. command = event.get("tool_input", {}).get("command", "") if "git commit" not in command: diff --git a/hooks/pretool-file-backup.py b/hooks/pretool-file-backup.py index dab630a..9470068 100755 --- a/hooks/pretool-file-backup.py +++ b/hooks/pretool-file-backup.py @@ -49,9 +49,8 @@ def main() -> None: except (json.JSONDecodeError, ValueError): sys.exit(0) - tool_name = event.get("tool_name", "") - if tool_name != "Edit": - sys.exit(0) + # tool_name filter removed — matcher "Edit" in settings.json prevents + # this hook from spawning for non-Edit tools. tool_input = event.get("tool_input", {}) file_path = tool_input.get("file_path", "") diff --git a/hooks/pretool-learning-injector.py b/hooks/pretool-learning-injector.py index df5f982..5216335 100755 --- a/hooks/pretool-learning-injector.py +++ b/hooks/pretool-learning-injector.py @@ -31,9 +31,6 @@ EVENT_NAME = "PreToolUse" -# Tools that benefit from proactive learning injection -TARGET_TOOLS = {"Bash", "Edit"} - # Max characters in the injected context to stay lightweight MAX_CONTEXT_CHARS = 500 @@ -160,11 +157,9 @@ def main(): event = json.loads(event_data) - # Early exit for non-target tools + # tool_name filter removed — matcher "Bash|Edit" in settings.json prevents + # this hook from spawning for non-matching tools. tool_name = event.get("tool_name", "") - if tool_name not in TARGET_TOOLS: - empty_output(EVENT_NAME).print_and_exit() - tool_input = event.get("tool_input", {}) # Extract tags based on tool type diff --git a/hooks/pretool-plan-gate.py b/hooks/pretool-plan-gate.py index 04c7398..2b2fa0b 100644 --- a/hooks/pretool-plan-gate.py +++ b/hooks/pretool-plan-gate.py @@ -54,9 +54,8 @@ def main() -> None: except (json.JSONDecodeError, ValueError): sys.exit(0) - tool_name = event.get("tool_name", "") - if tool_name not in ("Write", "Edit"): - sys.exit(0) + # tool_name filter removed — matcher "Write|Edit" in settings.json prevents + # this hook from spawning for non-matching tools. # Bypass env var — set by the plans skill itself. if os.environ.get(_BYPASS_ENV) == "1": diff --git a/hooks/pretool-prompt-injection-scanner.py b/hooks/pretool-prompt-injection-scanner.py index 88348d6..d3502ae 100644 --- a/hooks/pretool-prompt-injection-scanner.py +++ b/hooks/pretool-prompt-injection-scanner.py @@ -268,11 +268,9 @@ def main() -> None: print(f"[injection-scanner] JSON parse failed: {e}", file=sys.stderr) empty_output(EVENT_NAME).print_and_exit() - # Field name compatibility: try new names first, fall back to old + # tool_name filter removed — matcher "Write|Edit" in settings.json prevents + # this hook from spawning for non-matching tools. tool = event.get("tool_name") or event.get("tool", "") - if tool not in ("Write", "Edit"): - empty_output(EVENT_NAME).print_and_exit() - tool_input = event.get("tool_input", event.get("input", {})) file_path = tool_input.get("file_path", "") if not file_path: diff --git a/hooks/pretool-subagent-warmstart.py b/hooks/pretool-subagent-warmstart.py index 2a1a871..1da4886 100755 --- a/hooks/pretool-subagent-warmstart.py +++ b/hooks/pretool-subagent-warmstart.py @@ -251,10 +251,8 @@ def main() -> None: event = json.loads(event_data) - # Only process Agent tool invocations - tool_name = event.get("tool_name", "") - if tool_name != "Agent": - return + # tool_name filter removed — matcher "Agent" in settings.json prevents + # this hook from spawning for non-Agent tools. # Gather context from various sources files = load_recent_reads(Path(SESSION_READS_FILE)) diff --git a/hooks/pretool-synthesis-gate.py b/hooks/pretool-synthesis-gate.py index 086932b..f092066 100755 --- a/hooks/pretool-synthesis-gate.py +++ b/hooks/pretool-synthesis-gate.py @@ -123,9 +123,8 @@ def main() -> None: except (json.JSONDecodeError, ValueError): sys.exit(0) - tool_name = event.get("tool_name", "") - if tool_name not in ("Write", "Edit"): - sys.exit(0) + # tool_name filter removed — matcher "Write|Edit" in settings.json prevents + # this hook from spawning for non-matching tools. # Bypass env var — set by the consultation skill itself. if os.environ.get(_BYPASS_ENV) == "1": diff --git a/hooks/record-activation.py b/hooks/record-activation.py index 9ac1cb3..7136465 100644 --- a/hooks/record-activation.py +++ b/hooks/record-activation.py @@ -28,18 +28,13 @@ from hook_utils import get_session_id from stdin_timeout import read_stdin -# Tools that represent meaningful work completing successfully -TRACKED_TOOLS = {"Edit", "Write", "Bash"} - - def main() -> None: """Record session activation stats on successful tool completions.""" try: hook_input = json.loads(read_stdin(timeout=2)) - tool_name = hook_input.get("tool_name", "") - if tool_name not in TRACKED_TOOLS: - return + # tool_name filter removed — matcher "Edit|Write|Bash" in settings.json + # prevents this hook from spawning for non-matching tools. tool_result = hook_input.get("tool_result", {}) if tool_result.get("is_error", False): diff --git a/hooks/retro-graduation-gate.py b/hooks/retro-graduation-gate.py index 76bcc3c..f7900b7 100644 --- a/hooks/retro-graduation-gate.py +++ b/hooks/retro-graduation-gate.py @@ -30,16 +30,8 @@ def main() -> None: empty_output(EVENT).print_and_exit(0) return - # Event type guard (defensive — matches peer hook pattern) - event_type = data.get("hook_event_name") or data.get("type", "") - if event_type and event_type != EVENT: - empty_output(EVENT).print_and_exit(0) - return - - # Early-exit: only care about Bash tool (PostToolUse schema: tool_name) - if data.get("tool_name") != "Bash": - empty_output(EVENT).print_and_exit(0) - return + # tool_name/event_type filters removed — matcher "Bash" in settings.json + # prevents this hook from spawning for non-Bash tools. # Early-exit: check if output indicates a PR was created (PostToolUse schema: tool_result.output) tool_result = data.get("tool_result", {}) diff --git a/hooks/review-capture.py b/hooks/review-capture.py index 724f1db..9883a06 100644 --- a/hooks/review-capture.py +++ b/hooks/review-capture.py @@ -117,10 +117,8 @@ def main() -> None: event = json.loads(event_data) - # Only process Agent tool results - tool_name = event.get("tool_name", "") - if tool_name != "Agent": - return + # tool_name filter removed — matcher "Agent" in settings.json prevents + # this hook from spawning for non-Agent tools. # Get tool result text tool_result = event.get("tool_result", "") diff --git a/hooks/usage-tracker.py b/hooks/usage-tracker.py index 6ea3847..73626de 100644 --- a/hooks/usage-tracker.py +++ b/hooks/usage-tracker.py @@ -32,17 +32,10 @@ def main(): event = json.loads(event_data) - # Only process PostToolUse events - event_type = event.get("hook_event_name") or event.get("type", "") - if event_type != "PostToolUse": - return - + # tool_name/event_type filters removed — matcher "Skill|Agent" in settings.json + # prevents this hook from spawning for non-matching tools. tool_name = event.get("tool_name", "") - # Only track Skill and Agent tools — exit silently for everything else - if tool_name not in ("Skill", "Agent"): - return - # Lazy import — only loaded when we actually need to record from hook_utils import get_project_dir, get_session_id from usage_db import record_agent, record_skill From f09a3e9553ae87cf845cef6dc4939d8b4b93f896 Mon Sep 17 00:00:00 2001 From: Nathan Oyler Date: Thu, 26 Mar 2026 18:17:01 -0700 Subject: [PATCH 2/2] fix: update tests and lint for matcher-based filtering Tests that verified tool_name self-filtering now document that filtering is handled by the matcher field in settings.json. Hooks still exit 0 for any input (non-blocking), so tests verify that contract instead. Fix ruff I001 import sorting in record-activation.py. --- hooks/record-activation.py | 1 + hooks/tests/test_post_tool_lint.py | 8 +++-- hooks/tests/test_posttool_session_reads.py | 33 +++++++---------- .../tests/test_pretool_subagent_warmstart.py | 35 +++++++------------ 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/hooks/record-activation.py b/hooks/record-activation.py index 7136465..e52fdfb 100644 --- a/hooks/record-activation.py +++ b/hooks/record-activation.py @@ -28,6 +28,7 @@ from hook_utils import get_session_id from stdin_timeout import read_stdin + def main() -> None: """Record session activation stats on successful tool completions.""" try: diff --git a/hooks/tests/test_post_tool_lint.py b/hooks/tests/test_post_tool_lint.py index 70ae6d3..88102b1 100755 --- a/hooks/tests/test_post_tool_lint.py +++ b/hooks/tests/test_post_tool_lint.py @@ -94,7 +94,11 @@ def test_ignores_non_lintable_files(): def test_ignores_read_tool(): - """Hook should only trigger for Write/Edit, not Read.""" + """Read tool filtering is now handled by matcher 'Write|Edit' in settings.json. + + When called directly (without matcher), the hook processes any tool_name. + This test verifies the hook still exits 0 (non-blocking) for any input. + """ setup() event = { "type": "PostToolUse", @@ -104,7 +108,7 @@ def test_ignores_read_tool(): stdout, stderr, code = run_hook(event) assert code == 0 - assert stdout == "" + # Note: hook may produce output since tool_name filter was moved to matcher def test_handles_missing_file_path(): diff --git a/hooks/tests/test_posttool_session_reads.py b/hooks/tests/test_posttool_session_reads.py index 8e0fc05..6105971 100644 --- a/hooks/tests/test_posttool_session_reads.py +++ b/hooks/tests/test_posttool_session_reads.py @@ -51,30 +51,23 @@ def run_hook(event: dict) -> tuple[str, str, int]: class TestToolNameFiltering: """Only Read tool events should be processed.""" - def test_ignores_write_tool(self, tmp_path, monkeypatch): - """Write tool events should produce no output and no file.""" - monkeypatch.chdir(tmp_path) - event = { - "tool_name": "Write", - "tool_input": {"file_path": "/some/file.py"}, - } - stdout, stderr, code = run_hook(event) - assert code == 0 - # No session-reads.txt should be created - assert not (tmp_path / ".claude" / "session-reads.txt").exists() + def test_nonread_tool_exits_zero(self, tmp_path, monkeypatch): + """Non-Read tool filtering is now handled by matcher 'Read' in settings.json. - def test_ignores_edit_tool(self, tmp_path, monkeypatch): - """Edit tool events should be ignored.""" + When called directly (without matcher), the hook processes any tool_name. + This test verifies the hook still exits 0 (non-blocking) for any input. + """ monkeypatch.chdir(tmp_path) - event = { - "tool_name": "Edit", - "tool_input": {"file_path": "/some/file.py"}, - } - stdout, stderr, code = run_hook(event) - assert code == 0 + for tool in ("Write", "Edit", "Bash"): + event = { + "tool_name": tool, + "tool_input": {"file_path": "/some/file.py"} if tool != "Bash" else {"command": "ls"}, + } + stdout, stderr, code = run_hook(event) + assert code == 0 def test_ignores_bash_tool(self, tmp_path, monkeypatch): - """Bash tool events should be ignored.""" + """Bash tool events should be ignored (no file_path to extract).""" monkeypatch.chdir(tmp_path) event = { "tool_name": "Bash", diff --git a/hooks/tests/test_pretool_subagent_warmstart.py b/hooks/tests/test_pretool_subagent_warmstart.py index f8a1b51..62da3c1 100644 --- a/hooks/tests/test_pretool_subagent_warmstart.py +++ b/hooks/tests/test_pretool_subagent_warmstart.py @@ -58,28 +58,19 @@ def run_hook(event: dict) -> tuple[str, str, int]: class TestToolNameFiltering: """Only Agent tool events should be processed.""" - def test_ignores_read_tool(self): - """Read tool events should produce no context output.""" - event = {"tool_name": "Read", "tool_input": {"file_path": "/x"}} - stdout, stderr, code = run_hook(event) - assert code == 0 - # Should be empty or empty hook output (no warmstart context) - if stdout.strip(): - output = json.loads(stdout) - hook_out = output.get("hookSpecificOutput", {}) - assert "additionalContext" not in hook_out or "[warmstart]" not in hook_out.get("additionalContext", "") - - def test_ignores_write_tool(self): - """Write tool events should be ignored.""" - event = {"tool_name": "Write", "tool_input": {"file_path": "/x"}} - stdout, stderr, code = run_hook(event) - assert code == 0 - - def test_ignores_bash_tool(self): - """Bash tool events should be ignored.""" - event = {"tool_name": "Bash", "tool_input": {"command": "ls"}} - stdout, stderr, code = run_hook(event) - assert code == 0 + def test_nonagent_tools_exit_zero(self): + """Non-Agent tool filtering is now handled by matcher 'Agent' in settings.json. + + When called directly (without matcher), the hook processes any tool_name. + This test verifies the hook still exits 0 (non-blocking) for any input. + """ + for tool, tool_input in [ + ("Read", {"file_path": "/x"}), + ("Write", {"file_path": "/x"}), + ("Bash", {"command": "ls"}), + ]: + stdout, stderr, code = run_hook({"tool_name": tool, "tool_input": tool_input}) + assert code == 0 def test_processes_agent_tool(self, tmp_path, monkeypatch): """Agent tool events should produce warmstart context."""