A rule in CLAUDE.md is a request. A hook is a guarantee. Mixing these
two up is the most common config mistake I see: teams write "never
commit secrets" as prose, then act surprised when a secret gets
committed. This post is the split I use: what belongs in prose, what
belongs in a hook, and the four-level ladder that decides.
The difference in one sentence
Prose config changes what the model tries to do. Hooks and CI change
what is possible to do. Anything that must never happen does not
belong in prose, because prose is sampled, not executed.
The four-level enforcement ladder
For any rule you want, ask which level it actually needs:
Level 1 Linter/types/CI deterministic, catches most code issues
Level 2 Hooks (PreToolUse) blocks the command/file before it runs
Level 3 Scoped rules prose, loads only for matching files
Level 4 Always-on config prose, worth spending context on every call
The ladder is ordered by reliability, and the reliable end is free of
tokens. Every rule you can push down a level saves context and removes
a failure mode.
What belongs in hooks
Concrete examples from my own setup:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "command": "block-dangerous.sh" },
{ "matcher": "Edit|Write", "command": "protect-env.sh" }
]
}
}
-
Destructive commands:
rm -rfoutside a target dir, force-push to main,drop table. A regex in a PreToolUse hook blocks these with certainty. In prose, "be careful with rm" works maybe nine times out of ten, and you only hear about the tenth. -
File protection:
.env, keys, generated files. The hook returns a block reason and the agent adapts. No token spent until the moment of the attempt. - Format gates: prettier/eslint run on save via hook rather than "always format your code" in config. Deterministic, zero context.
What belongs in prose config
The things hooks cannot decide, because they need judgment:
- Definition of done: what "verified" means for this repo. A hook can run tests; only prose can say what the agent should do when they fail (fix, not revert-and-move-on).
- Priorities: smallest diff wins, investigate before fix, stop after verify. These shape choices, not actions.
- Context the model cannot infer: why the weird workaround in auth exists, which parts of the codebase are generated, who consumes the API.
The trap: putting CI-fixable rules in prose
The clearest smell is any line in CLAUDE.md that starts with "always
run" or "never commit". If CI can check it, CI should check it.
Every prose duplicate of a linter rule is a line of context spent on
something the machine already guarantees, and a contradiction waiting
when the two drift.
Our kits keep prose for judgment and push everything checkable down
the ladder. That is also why the validator rejects placeholder
instruction files: prose that restates the linter is worse than no
file, because it trains you to stop reading your own config.
When you have no hook system
Older setups (plain .cursorrules, bare AGENTS.md) have no hook layer.
The ladder still applies, one rung lower: CI and linters carry the
hard guarantees, prose carries judgment, and the "hard guarantees in
prose" gap is covered by a pre-commit hook in git itself, which every
setup has.
Related reading
- What makes a good AI coding rule: the prose side of this split.
- The 300-line instruction budget: why context spent on prose rules must be earned.
- Versioning your agent configs: hooks belong in git too.
Top comments (3)
Really clean framing â "prose is sampled, not executed" is the sentence I'll be quoting back at people. One thing I'd add to the Level 2 (hooks) rung: the matcher only controls which tool calls the hook process for, not which agent process invoked them. If you're running Claude Code from more than one entry point (terminal, IDE extension, headless CI runner), a hook that shells out to a project tool (git, a linter binary) can silently no-op if that binary isn't on PATH in that particular process's environment â so "the hook exists" and "the hook actually enforced anything" are two different claims worth testing separately, ideally by deliberately trying to trigger it from each entry point once. Otherwise you get a guarantee that's real from the terminal and completely absent from the IDE, which is a worse failure mode than not having the hook at all, since nobody's checking prose for it anymore.
You're right that the matcher is scoped to tool calls, not to the processes that invoke them, and the PATH case is the one that bites in practice. I hit the IDE variant of this: a format hook worked from the terminal and silently did nothing from the VS Code extension, because the extension process had been launched from the GUI and got the system default PATH, not my shell's. Two things fixed it for me. I resolve hook binaries to absolute paths at setup time, since the hook config is versioned anyway, and every hook appends one line to a log before doing anything else, so a no-op from an entry point shows up as a missing line instead of a silent pass. That log is what my once-per-entry-point trigger test reads now, same deliberate probe you describe.
That absolute-path-at-setup-time fix is the right call â versioning the resolved path instead of trusting PATH resolution removes the whole "silently launched with a different environment" failure class outright. The append-a-log-line-first pattern is underrated too: it turns "did my hook even fire" from a debugging session into a grep. We ran into a version of the same problem on the precedence side â when a project-level hook, a user-level hook, and CLAUDE.md config all touch the same tool call and disagree, which one actually wins isn't documented anywhere, so we had to test it directly and write the order down: dev.to/hamid_ahmadian_3570449f72/c.... Same "test it, don't assume it" lesson as your per-entry-point probe.