Nine times out of ten the hook is running. It just has no way to tell you it failed.
Last updated: 2026-09-21 · checked against Claude Code 2.1.278
Most “my hook never runs” reports are a hook that does run and cannot tell you anything. A hook’s stderr goes to the debug log only — never to the transcript. So a script that fires and crashes on line one looks exactly like a script that was never wired up.
Before changing any configuration, make it speak:
claude --debug hooks
That prints hook resolution, which file each hook came from, the process spawn, and the error if there is one. In-session, /hooks lists what is actually loaded. If the hook is not in that list, it is a configuration problem. If it is in that list, it is a script problem, and those are two different afternoons.
| # | Cause | How you tell |
|---|---|---|
| 1 | The hook runs and fails silently. stderr never reaches the transcript | claude --debug hooks shows the spawn and the error |
| 2 | The matcher does not match. A plain tool name is an exact, case-sensitive string, not a glob | Edit matches; edit and Edit(*) match nothing |
| 3 | The script exits 1 expecting to block | The action goes through anyway. Only exit 2 blocks |
| 4 | The script wants a terminal | Works when you run it by hand, dies as a hook |
| 5 | It is loaded under an event you did not mean | /hooks shows the event and the source file |
Note what is not on this list: needing to restart. Claude Code watches the settings files, so an edit is picked up without restarting the session. If restarting appeared to fix it once, something else changed too.
It is not a glob, and it is not always a regex. Three cases:
| You write | It means |
|---|---|
"*", "", or no matcher key at all | Every event of that type |
"Edit" or "Edit|Write" | Exact tool names, case-sensitive. A pipe or comma separated list means any of them |
"mcp__.*" | Because it contains regex characters, it is treated as an unanchored regular expression |
So Edit(*) is not “Edit with any arguments”. It is a literal string that matches no tool at all. The Bash(npm test:*) shape you may be copying from is permission syntax for permissions.allow, which is a different mechanism with different rules — see Claude Code keeps asking permission.
The shape, then the three details that decide whether it works:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/format.sh" }
]
}
]
}
}
tool_name, tool_input, cwd and session_id — not command-line arguments.Hooks can arrive from five places at once, and they merge rather than override:
| File | Scope |
|---|---|
~/.claude/settings.json | Every project you open |
.claude/settings.json | This project, committed, shared with the team |
.claude/settings.local.json | This project, just you, usually gitignored |
| A managed policy file | Set by an organisation, and not yours to disable |
A plugin’s hooks/hooks.json | Whatever the plugin brought with it |
Because they merge, deleting a hook from your project file does not stop it if the same hook also sits in your user file or arrived with a plugin. /hooks names the source file for each one, which is the fastest way to find the copy you forgot about.
Put the output somewhere you will actually see it. stderr is invisible; a file is not:
claude --debug hooks --debug-file /tmp/cc-debug.log
Then tail -f /tmp/cc-debug.log in a second terminal and trigger the tool. If the hook never appears, it is not loaded, and the problem is in one of the five files above rather than in your script.
The free starter repo ships a PostToolUse formatter and a Stop reminder that both work, which is often a faster fix than debugging your own.
Flat prices, written scope, stop at any rung. No call to book, no retainer.
| Rung | What you get | Clock |
|---|---|---|
| Free — starter repo | A real setup you copy yourself | now |
| $29 — Setup Lite | One project: CLAUDE.md, allowlist, one skill, as a pull request you review | 24 hours |
| $99 — Setup Sprint | 3–5 skills, hooks, up to 3 MCP servers, plus a runbook | 48 hours |
| $79 — Surface Audit | Already set up and still fighting it: contradictions and an exact fix list | 48 hours |
Hooks and MCP servers are in Sprint, not Lite — which one you actually need. Clocks run from a complete intake, not from the Stripe receipt.