Quartet / Quintet

2026-09-05  ·  claudecode · ai · configuration · devops

Your Hook Is Configured Correctly and Never Runs

You write a hook. The JSON is valid. The keys are spelled right. No error appears.

Nothing happens.

The hard part is that nothing points at the mistake. Startup is clean, --debug says nothing, and the hook simply does not run.

.claude/ has several states that are syntactically correct and semantically inert. Below are the ones the official documentation explicitly describes as ignored or non-functional.

1. Some events silently discard matcher

This one is stated outright:

If you add a matcher field to an event without matcher support, it is silently ignored. — Hooks

Silently ignored. Not an error.

The events with no matcher support, taken from the documentation's own table:

CwdChanged, UserPromptSubmit, PostToolBatch, Stop, TeammateIdle,
TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay

So this:

{
  "hooks": {
    "UserPromptSubmit": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "./guard.sh" }] }
    ]
  }
}

The matcher disappears. guard.sh runs on every prompt submission.

The person who wrote it believes it fires only for Bash. It fires every time, and nothing in the system suggests otherwise.

This is the failure where a filter you thought you applied was never applied. It can be worse than the hook not running at all.

2. if is only evaluated on tool events

if narrows on the tool name and its arguments together.

For tool events, you can filter more narrowly by setting the if field on individual hook handlers.

Tool events is the condition. There are five:

PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied

Write if on anything else and that handler never runs.

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "./init.sh", "if": "Bash(git *)" }] }
    ]
  }
}

SessionStart has no tool. There is nothing for the condition to match, so it never fires.

The two failures point in opposite directions:

what you wrote what happens
matcher ignored narrowed runs every time
if not evaluated conditional never runs

Neither produces an error.

3. Deprecated keys still parse

Old settings survive in copied configs and old blog posts.

key in your file what replaced it
ignorePatterns permissions.deny
includeCoAuthoredBy attribution
disableArtifact enableArtifact (the boolean inverts)
voiceEnabled voice.enabled

disableArtifact is the trap. The replacement inverts the sense. Rewriting "disableArtifact": false to "enableArtifact": false mechanically flips the meaning.

4. Required handler fields depend on the type

{ "type": "command" }

No command. Nothing happens.

type required
command command
http url
mcp_tool server, tool
prompt prompt
agent prompt

The common shape is changing the type and leaving the old field behind — switching command to http, forgetting to delete command, forgetting to add url.

5. Plugin-provided MCP tools have different names

Here the matcher is written and simply never matches.

Tools from an MCP server bundled by a plugin carry the plugin name in the tool name:

mcp__plugin_<plugin-name>_<server-name>__<tool>

A matcher written against the bare server key will never fire for them.

✗ mcp__db__.*                    misses everything from the plugin
✓ mcp__plugin_my-plugin_db__.*

Why this class is the bad one

They share one property: the failure is not observable.

typo         -> error at startup, fixed in a minute
silently ignored -> nothing happens, and it is operated for months
                    by someone who believes it works

Hooks are especially bad for this. If you write a hook to block dangerous commands and it is inert, you find out on the day it fails to block one.

The config file cannot tell you this

The only way is to check it from outside. I wrote a small tool that reads .claude/ and reports only what the documentation explicitly calls ignored or broken:

$ ccheck
error .claude/settings.json  matcher on CwdChanged is silently ignored
      https://code.claude.com/docs/en/hooks
warn  .claude/settings.json  ignorePatterns is deprecated; use permissions.deny
      https://code.claude.com/docs/en/settings-reference

I gave it one constraint:

A rule may exist only if the official documentation says the thing errors, is skipped, or is ignored.

That is why every finding carries a source URL. A rule I cannot cite does not get written, because at that point it is my taste, not a defect.

The constraint left a lot unchecked: model name validity, path pattern correctness, unknown keys. All of them look useful. All of them would be guesses, and a checker that guesses produces false positives.

A checker stops being read after the first false positive.

MIT:

https://github.com/quintetkit/ccheck

Takeaways


Related


I publish the configuration for splitting Claude Code into separate personas — Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run ./setup.sh, and it works. It does not depend on your tech stack.

https://github.com/quintetkit/quartet

I built one real tool using nothing but this workflow. Every Issue, PR, review and merge is still there. The parts that went wrong were not deleted.

https://github.com/quintetkit/mdlinkcheck

The version that adds a UI Designer persona, review criteria, a per-Issue parallel execution script and a 10-chapter guide is on the product page.

The full kit — five personas, the scripts and the complete guide in English and Japanese — is on BOOTH, a Japanese store with an English interface that takes international cards.

https://quartet-dev.booth.pm/items/8807156

The workflow itself is available

Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 10-chapter guide.

See the free version Product page