From 1eb7251956ae805d5045d07d625f516e7a03aeef Mon Sep 17 00:00:00 2001 From: Sam Zoloth Date: Mon, 30 Mar 2026 15:10:14 -0600 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-001=20=E2=80=94=20Claude=20Code=20c?= =?UTF-8?q?onditional=20hooks=20(if=20field)=20exploration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied `if` field filtering to ~/.claude/settings.json for 4 Bash hooks (conventional_commits, tsc_precommit, git_safety, pnpm_enforcer). Hook processes now only spawn when their specific command pattern matches. Removed redundant early-exit guards from the three scripts that did internal command matching. Documents the writing hook exclusion rationale (auto-generated script + Write|Edit matcher restructuring complexity). Closes SAM-218 Co-Authored-By: Claude Sonnet 4.6 --- DECISIONS.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/DECISIONS.md b/DECISIONS.md index cf76856e..42477725 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,3 +5,53 @@ See `~/.claude/templates/DECISIONS.md` for the ADR template. --- + +## ADR-001: Claude Code conditional hooks (`if` field) + +**Date:** 2026-03-30 +**Status:** Applied +**Ticket:** SAM-218 + +### Context + +Claude Code v2.1.85+ supports an `if` field on hook handlers that uses permission rule syntax (e.g., `Bash(git *)`) to pre-filter which tool calls spawn the hook process. Without it, every Bash call spawns all Bash-matcher hook processes — they run and exit immediately if the command doesn't match, wasting startup overhead. + +### Decision + +Apply `if` field filtering to Bash hooks that do internal command matching. Move the filtering to the Claude Code runtime level so hook processes only spawn when they have work to do. + +### Changes applied to `~/.claude/settings.json` + +| Hook | `if` condition | Before | +|------|----------------|--------| +| `conventional_commits.py` | `Bash(git commit *)` | spawned on every Bash call | +| `tsc_precommit.py` | `Bash(git commit *)` | spawned on every Bash call | +| `git_safety.py` | `Bash(git *)` | spawned on every Bash call | +| `pnpm_enforcer.py` | `Bash(npm *)` + `Bash(yarn *)` (two entries) | spawned on every Bash call | +| `trauma_guard.py` | none (unchanged) | dynamic JSONL patterns — must run on all Bash | + +### Corresponding script simplifications + +- `conventional_commits.py`: removed `if 'git commit' not in cmd: sys.exit(0)` (now pre-filtered) +- `tsc_precommit.py`: removed `if "git commit" not in cmd: sys.exit(0)` (now pre-filtered) +- `pnpm_enforcer.py`: removed `re.search(npm|yarn)` early-exit guard (now pre-filtered by two `if` entries) + +### Hooks intentionally excluded + +- `writing_guard.py`: auto-generated by `margin export profile` CLI; script changes would be overwritten on next export. The `Write|Edit` matcher would need to be restructured into per-extension handlers (10+ entries for `.md`, `.mdx`, `.txt`, `.html`, `.htm` × Write + Edit). Marginal gain vs complexity; deferred. +- `voice_loader.py`: same Write|Edit matcher restructuring required. Excluded for now. +- `trauma_guard.py`: patterns loaded at runtime from `~/.cass-memory/traumas.jsonl`; `if` field cannot express dynamic conditions. + +### Validation + +All hooks pass functional tests post-change: +- `conventional_commits.py`: blocks bad commit messages, passes good ones, exits silently on non-commit input +- `tsc_precommit.py`: syntax valid; only runs when `if` pre-filter passes +- `git_safety.py`: blocks `git push --force`, exits silently on non-git Bash +- `pnpm_enforcer.py`: blocks `npm install` in pnpm project, exits silently on git/other commands + +### Notes + +- `Bash(npm *)` catches all npm commands (including `npm --version`), not just install/add/etc. Wider than the previous subcommand filter, but acceptable — all npm use in pnpm projects should use pnpm. +- Multiple `if` values on one handler are not supported; OR conditions require separate handler entries (hence two entries for pnpm_enforcer). +- Glob brace expansion for Write/Edit (`Edit(*.{md,mdx})`) was not explored — deferred with writing hooks.