One morning I was reviewing a diff, and my hand froze.
The SwiftLint check that always runs before a commit hadn't run. It hadn't failed with an error — the hook had simply passed through, quietly, with no sign it had ever fired.
I had upgraded Claude Code to the latest version the night before. Nothing had broken loudly. Instead, "nothing happened." This kind of silent failure is the sort that quietly eats your afternoon.
The cause turned out to be a behavior change in the hook matcher introduced in v2.1.195. If you, like me, were writing matchers that contain a hyphen, you may have fallen into the same trap. Let me leave the whole isolation process here as a record.
What was actually happening
In my setup, I inject hooks only for specific tool calls. The relevant part of settings.json looks roughly like this.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/pre-edit-check.sh" }
]
}
]
}
}This part worked fine. The problem was a different matcher, in another project, that targeted a custom tool name.
Tools exposed through MCP get names like mcp__myserver__run-lint, which contain hyphens. I had targeted one of them like this.
{ "matcher": "run-lint" }Up through v2.1.194, this run-lint fired via a partial match against mcp__myserver__run-lint. But in v2.1.195, matchers containing a hyphen were changed to be treated as exact matches.
run-lint does not exactly equal mcp__myserver__run-lint. So, without emitting any error, it simply stopped matching.
What I suspected first
I suspected the wrong things, in order. For what it's worth, here is the path I took.
First I suspected the shell script's permissions. I checked chmod +x and ran it directly — the script itself worked correctly. That ruled out a fault in the hook body.
Next I suspected broken JSON. I validated settings.json, and the syntax was clean.
Third, I launched a session with claude --debug and followed the hook evaluation log. This gave me the decisive clue. PreToolUse evaluation was running, but the number of hooks that matched my matcher was logged as "0."
The script was fine, the JSON was fine, evaluation was running. All that remained was the matcher's matching logic. Only then did my mind turn back to the previous night's update.
Confirming the cause
Checking the changelog, v2.1.195 included a fix stating that hook matchers containing a hyphen would be treated as exact matches rather than partial matches.
In other words, matchers that had implicitly been picked up by partial matching stop matching the moment they cross a hyphen boundary. My run-lint was a textbook case.
The tricky part is that this is a "bug fix." The old partial matching had a side effect of catching unintended tools too, so tightening it is the correct direction. But for existing configurations it is a breaking behavior change, and because it emits no error, it takes a while to notice.
The conclusion was simple: my configuration had been relying on the old, implicit behavior.
The fix
The remedy is straightforward. Rewrite the matcher so it correctly matches the actual tool name. There are three main options.
The first is to spell out the tool name exactly. This is the safest and makes the intent clearest.
{ "matcher": "mcp__myserver__run-lint" }The second is to target it with a regular expression. This suits cases where you want to catch MCP tools per server.
{ "matcher": "mcp__myserver__.*" }The third is to use a wildcard for all MCP tools. The scope is broad, so it assumes the hook itself decides whether it should really act.
{ "matcher": "mcp__.*" }I went with the first. When you are targeting a single tool, spelling out the name is both misfire-free and easy to read. After rewriting, I ran the tool with claude --debug and confirmed the evaluation log showed the hook count as "1." Only after seeing that could I relax.
For reference, here is the before-and-after behavior.
| Matcher | Up to v2.1.194 | v2.1.195 and later |
|---|---|---|
run-lint | Partial match on mcp__myserver__run-lint (fires) | No match (does not fire) |
mcp__myserver__run-lint | Match (fires) | Match (fires) |
mcp__myserver__.* | Match (fires) | Match (fires) |
Standard tool names without a hyphen, such as Edit or Write, are unaffected by this change. The only matchers that break are the ones that relied on partial matching across a hyphen.
Preventing a recurrence
To avoid stepping on the same silent failure twice, I put a few operational safeguards in place.
First, I made it a rule that matchers "spell out the tool name exactly, rather than relying on partial matching." Abbreviated forms look convenient, but the more you lean on implicit matching semantics, the more an update can pull the rug out from under you.
Second, I made hooks log whether they actually fired. Even a single line written to standard error at the top of the script lets you tell afterward whether it passed through or ran.
#!/usr/bin/env bash
echo "[pre-edit-check] fired at $(date -u +%FT%TZ)" >&2
# the real workThe greatest enemy of a silent failure is that the failure leaves no record. Keeping the mere fact that a hook fired gives you a starting point for isolation the next time an update changes behavior.
Finally, I added a small ritual: after updating Claude Code, run the key hook-related operations by hand once to confirm them. It isn't flashy, but the more unattended a pipeline is, the more this check pays off.
As an indie developer juggling several projects, I have entrusted a fair amount of work to automation hooks myself. That is exactly why this incident stayed with me as a lesson: lean on implicit behavior for the sake of convenience, and it will break quietly.
If you've ever struggled with reading hook evaluation logs in the first place, Claude Code hooks production debugging methodology may help as well. If you want to get matcher syntax right from the ground up, see the complete guide to Claude Code hooks, and for a different update-triggered problem, see handling Claude Code auto-update failures.
What to check next: open your own settings.json and review, just once, whether any matcher uses a hyphenated string as a partial match. If it does, replace it with an exact tool name or a regular expression to be safe.
If even one person is spared from losing time to this silent failure, I'll be glad. Thank you for reading.