◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-07-01Intermediate

My Claude Code Hooks Stopped Firing After an Update — the Hyphenated Matcher Exact-Match Change in v2.1.195

In Claude Code v2.1.195, hook matchers containing a hyphen switched from partial match to exact match, silently disabling an existing PreToolUse hook. Here is how I isolated the cause and how to write matchers that won't break.

Claude Code264hooks22troubleshooting92matcherautomation109

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.

MatcherUp to v2.1.194v2.1.195 and later
run-lintPartial match on mcp__myserver__run-lint (fires)No match (does not fire)
mcp__myserver__run-lintMatch (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 work

The 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-10-05
I Wrote "I'll Do the Commits" in CLAUDE.md. The Next Morning git log Had One More Line.
When Claude Code commits despite being told not to: undo with git reset --soft, then stop it physically with permissions.deny plus a PreToolUse hook. Covers the git -c form that deny rules let through, and how to test the hook without starting Claude Code.
⟐ Claude Code2026-09-02
Two hooks that record every model switch and stop the ones you never agreed to
Build a PostModelSwitch hook that logs every model change and a PreModelSwitch hook that blocks unapproved switches during unattended runs. Complete scripts, measured timings, and the reason the two jobs must stay separate.
⟐ Claude Code2026-08-28
Don't let your verification script's full output flow back through a hook
The check was working the whole time. The conversation was what ran out of room. Measured side by side: 58 bytes from one scan of 833 files, 42KB from another. Which paths actually reach the model, and how to enforce a budget on them.