Hook Audit:
Three Ways Hooks Die Quietly
Why This Audit Happened
A Stop hook had been checking a deleted folder every session since May, and nobody noticed. That prompted the question: how many other hooks are firing when they shouldn't, or not firing at all?
The answer turned out to be two, and one of them was the hook that enforces this very page existing.
What Was Broken
hub-first-check.py Stop hook, the one that enforces "publish to the hub before sharing," had never run. Not once. It was registered, the script existed, and it was silently doing nothing.The runtime trace showed why:
python.exe: can't open file
'...\AnnettesHealth\Usersannet.claudehookshub-first-check.py' The command was written as py -3.13 C:\Users\annet\.claude\hooks\hub-first-check.py, unquoted. Hook commands run through a POSIX shell, so every backslash was consumed as an escape character and the path collapsed into that garbage string. Python couldn't find the file, the hook failed, and nothing surfaced in normal use.
The fix: quote it and use forward slashes, matching the hooks that already worked.
py -3.13 "C:/Users/annet/.claude/hooks/hub-first-check.py" The second casualty was session-stop-drift-check.py, which watched three Desktop files deleted back in May. That one has been retired along with the whole desktop bookkeeping system.
The Three Failure Modes
These are genuinely different problems, and no single check finds all three.
| Mode | What it looks like | Why it stays hidden | Detected by |
|---|---|---|---|
| Bad quoting | Script exists, command is malformed, hook never runs | Config looks correct on inspection. The corruption happens in the shell. | Static sweep |
| Dead targets | Script runs fine, but the files it operates on are gone | Nothing errors. The base folder still exists, so it just processes nothing. | Static sweep, but only if it resolves runtime-composed paths |
| Fail-open silence | Hook breaks, swallows its own error, writes no log | Invisible by construction. This is the correct design, and the cost of it. | Runtime trace only |
- UTF-8 BOM in a settings file. A single invisible byte at the start makes Claude Code reject the entire file, killing every hook and every permission inside it. PowerShell's
Out-Fileadds one by default. UseSet-Content -Encoding utf8NoBOM. - Orphaned
hooks.json. Hooks are only loaded from thehookskey insidesettings.json. A standalone.claude/hooks/hooks.jsonis never auto-loaded. Four documented hooks sat in one for nearly three months and never fired once.
How To Check, Two Layers
py -3.13 C:\Users\annet\ClaudeProjects\MyExecAssistant\scripts\audit_hooks.py Reads every hook across user settings and all project settings, then classifies each one. It reconstructs paths that scripts build at runtime from variables, which is what the naive version missed. It also flags BOMs and orphaned hook files.
- DEAD the script does not exist, this hook can never succeed
- BROKEN unquoted Windows path, the shell eats the backslashes
- STALE script runs but the files it watches are gone
- COLD writes a log or state file that has not changed in 30 days
- DUPE the same hook is registered twice for one event
- UNVERIFIED fails open and writes no log, so a break would be invisible
claude --debug hooks --debug-file trace.log -p "reply with just: ok" Then search the log for error, canceled, can't open file, and Failed to parse hook output. This is the layer that caught the broken quoting, and no amount of config reading would have found it.
Current State
| Result | Count | Meaning |
|---|---|---|
| Total hooks | 18 | Across user settings plus 3 projects |
| DEAD / STALE / BROKEN | 0 | All repaired or retired |
| Unreadable settings files | 0 | No BOMs |
| Orphaned hooks.json | 0 | Previously fixed |
| UNVERIFIED | 6 | Working, but cannot self-report |
UNVERIFIED means "cannot be proven statically," not "broken." They all fail open and write no log, which is the right default (a broken hook should never block a session), but it means config inspection can never confirm them. Two pieces of evidence say they are fine: the skill-of-the-day hook demonstrably produced output, and the post-fix trace showed every Stop hook completing without error.
To make them self-reporting, each would need one line appending a timestamp to a log on success. The COLD check would then catch any that go quiet. That is a small, optional upgrade, not currently done.
A claude-mem plugin SessionEnd hook reported "canceled" in both traces. That may simply be an artifact of headless mode shutting down abruptly rather than a real defect. Not called a bug until it is seen in an interactive session.
Artifacts
Auditor: MyExecAssistant/scripts/audit_hooks.py, regression-tested against the known-broken drift-check script and unit-tested on the quoting detector.
Gotcha writeup: the hooks section of ~/memory/topics/tooling-gotchas.md, so this bites at most once.
Retired: catchup-indexes skill and session-stop-drift-check.py, archived with a written rationale.
Reference: Claude Code hooks documentation, and the BOM issue at claude-code#9906.
Internal systems audit. Findings verified by live runtime trace on Claude Code 2.1.210, not by configuration inspection alone.