Chapter 2 of 9. CLAUDE.md is context. A hook is enforcement. Telling them apart is the difference between a system that mostly behaves and one that cannot misbehave. Prerequisite: chapter 1, on the mental model. You should already know what a session is, what a tool call is, and where
~/.claude/sits relative to a project.
You write a rule into CLAUDE.md. Claude follows it. You watch it follow the rule four more times, and somewhere in there you stop thinking of it as an instruction and start thinking of it as a setting. Then one afternoon it does the other thing, and the cost of that one miss is not one fifth of the value you got from the four hits. It is the whole thing.
Anthropic tell you this in their own documentation. Most people find out the other way. Everything below is against Claude Code 2.1.209, checked on 2026-07-14; hook events and settings keys move, so treat the version stamp as load-bearing.
CLAUDE.md is a message, not a config file
The memory documentation does not hedge:
Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead.
The mechanism behind that sentence:
CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance, especially for vague or conflicting instructions.
Your constitution arrives as a message in the conversation. The client never reads it as configuration. The same page draws the line from the other side, in its section on managing CLAUDE.md for large teams: settings rules are enforced by the client regardless of what Claude decides, while CLAUDE.md instructions shape Claude's behaviour without being a hard enforcement layer.
Here is the top of the real global constitution on this machine, verbatim:
# ================================================================
# MANDATORY RULES
# ================================================================
# SPEC: No implementation without an approved specification
# TESTS: Tests written before implementation — always
# SECRETS: No keys/tokens in code, git, or chat — ever
# BRANCHES: Never commit directly to main
# PORTS: Register all apps in ~/.claude/port-registry.json before running
Read the word MANDATORY there and then read the docs again. Nothing in the file makes any of those five mandatory. They are five sentences in a message, competing for adherence with the task you actually asked for. The capital letters are for me, not for the model.
The file loads in full at session start regardless of length, and it sits in the window for every turn after that. Caching softens the bill and changes nothing about the space: per the context windows documentation, a cached prefix still occupies the context window, because caching changes what you pay for those tokens rather than whether they count. Anthropic's stated budget is blunt, and this file blows straight through it: "target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence."
Mine is 430 lines. Its own version footer claims it was trimmed to about 140. A file whose entire job is to be right about things is wrong about itself, and nothing in the system noticed. That is what unchecked prose is worth.
A hook is shell on an event, and only exit 2 blocks
The hooks guide gives the sentence this whole course rests on:
Hooks are user-defined shell commands that execute at specific points in Claude Code's lifecycle. They provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them.
A hook is a process the harness runs. Its exit code is the contract. The hooks reference is precise about the part that trips everyone up:
For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use
exit 2.
Exit 1 is the Unix reflex and it does nothing here. Write a gate that returns 1 because that is what failure looks like everywhere else, and it will print an error and then let the commit through.
Hooks are declared in a hooks block in a settings file. Below is the PreToolUse array from ~/.claude/settings.json on this machine, truncated to its first entry. The real array carries two further command entries, both pointing at guard scripts for unrelated tooling; they are cut here because the argument rests on this one.
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'gh pr create|git push.*main|npm publish|bun publish' && echo '⚠️ PR/PUBLISH DETECTED — ensure build-validator passed first' || true"
}
]
}
]
Look at the trailing || true. That hook fires on a matched Bash command, prints a warning, and exits 0. It cannot block anything. It sits at the enforcement layer and behaves exactly like a line in CLAUDE.md.
Then read the regex. It catches gh pr create, a push whose command string mentions main, and two publish commands. My constitution states two rules more forcefully than anything else in it: never force push, never commit directly to main. Neither is in that pattern. A force push to a feature branch sails through untouched. git commit is a Bash command the matcher sees and the grep ignores, so the commit rule has no gate here at all. The strongest-worded rules in the document have the weakest enforcement in the estate. I did not notice until I read the file to write this chapter.
The difference between CLAUDE.md and a hook, row by row
| CLAUDE.md | Hook | |
|---|---|---|
| Is | A message in the context window | A shell command run by the harness |
| Runs | When the model reads it | On the event, always |
| Enforced by | The model's cooperation | An exit code |
| Can be ignored | Yes, silently, with nobody told | No. exit 2 blocks the tool call |
| Costs tokens | Yes, on every turn | No |
| Good at | Judgement, taste, why | Invariants, effects, no |
| Fails at | Anything you cannot afford to have skipped once | Anything that requires understanding the situation |
Read the last row first. It decides where a rule is allowed to live, and the two questions below are how I read it.
When to use a hook instead of CLAUDE.md: two questions
Most rules should not graduate. A hook that fires on things it should not fire on gets bypassed, and a bypassed gate teaches you to reach for the bypass.
- Is one miss recoverable?
If the rule gets skipped once, can you fix it afterwards? A missed devlog entry, you can. A username published to a live site and scraped, you cannot. Recoverable stays in prose. Permanent gets a gate.
- Is there an artefact a program can check?
Determinism needs something to be deterministic about. A commit has staged bytes. A tool call has a command string. Both are checkable by a program with no context window and no opinion. Now try writing a hook for "register the port before running the app." There is no artefact. Nothing about starting a dev server proves whether a JSON registry was consulted first. That rule is structurally incapable of graduating, and pretending otherwise wastes an afternoon.
Two yeses and you write the hook. One yes and the rule lives in prose forever. Knowing which pile it is in is the whole gain; the capital letters were never doing anything.
The rule that graduated the day it broke
On 2026-05-21 an article shipped on this site with my macOS username in it. Twice: once in a plist example inside the article body, once in a template plist committed under the repo's scripts directory. Both went through the normal review. Neither was caught.
The rule already existed as prose. The rule was fine. Prose is what failed.
The hook went in the same day, and its comment header records the reasoning better than I could rewrite it:
# Scans **added lines only** (unified=0 diff with the `+` prefix),
# not the whole file. Historical content stays invisible to the
# hook — only what *this commit introduces* is checked. Rewritten
# 2026-05-25 (was whole-file scan; bypass token saw too much use
# during CLAUDE.md/devlog edits where the patterns appeared in
# pre-existing prose). See git-hooks roadmap brainstorm B-001.
That comment is a post-mortem of the hook's own first version. It shipped scanning whole files, so editing the constitution or the devlog tripped the gate on prose that was already committed and already fine. I typed the bypass token. Then I typed it again. Four days later it was rewritten to look only at added lines, because a gate with a high false-positive rate is not a strict gate. It is a gate that trains you to walk around it.
Note where it lives: ~/.git-hooks/, wired globally with core.hooksPath, so it fires on git commit whether Claude is in the loop or not. What I shipped was never something Claude intended. It was text that got written into a file and then committed. Claude Code's hook layer only ever sees the intent. Git sees the bytes.
The middle rung: warn now, block later
Between "a paragraph nobody enforces" and "exit 2" there is a rung people skip, and two commit-msg hooks on this machine stand on it. Each one requires a roadmap or devlog update when the commit subject carries a phase ID. When that update is missing, the hook prints a warning and returns 0 for 30 days, measured from the script's own mtime, and after that it starts returning 1. The grace period is documented inside the file: it "lets the convention sink in without week-1 friction." Warn first when the rule is real and the habit is not there yet.
I thought I had a third example of that rung. I was wrong, and being wrong is worth more here than the example was.
The idea-capture system has a linter that walks every subagent definition in ~/.claude/agents/ and flags any agent declaring the Bash tool without mentioning the caller convention. I described it as advisory when I wrote up every post-merge fix was the same fix: it flags, it does not block. Reading the code for this chapter, the last line of the linter's main is:
return 1 if warnings else 0
The pre-commit hook that runs it exits 1 whenever the linter does, and git treats a non-zero pre-commit exit as a block. The word "warning" in that output is a label on a message. The commit does not happen. The tell was in front of me the whole time: the check ships with a bypass token, BBBRAIN_BBPARK_LINT_BYPASS=1. Advisory checks do not need one.
The failure mode nobody warns you about: a hook you only think is wired
Now the part I would rather not print.
My constitution documents two blocking PreToolUse hooks in its GOTCHAS section. One stops a git commit whose staged files span both code and tests without an independent audit. One blocks an Archon workflow spawn until I have stopped and looked at my session budget; its docstring is explicit that it computes nothing and thresholds nothing, and exists purely to make me pause before launching. Both live on disk, both exit 2, and both carry test suites of 30 and 21 cases. The GOTCHAS describe them as live global hooks.
$ grep -c preflight ~/.claude/settings.json
0
They are not registered. Two files with no event, and I cannot tell you when that happened. A hook outside the hooks block is a script sitting on a disk. Nothing told me, because nothing tests whether your enforcement is plugged in.
The GOTCHAS section is prose asserting that enforcement exists. Nothing checks that assertion. It is wrong right now, and the file went on saying it for the whole stretch of time I was relying on it.
The transferable rule
The best line in my whole system was written on 2026-04-18, after a session in which Claude Code used a general-purpose agent for an entire authentication implementation instead of the specialists the workflow named, and none of the nine steps happened:
# SessionStart hook prints 9-step workflow. Stop hook prints quality checklist.
# Lesson: Agents are voluntary. Hooks are not. Make the non-negotiables hooks.
Everything built since has either supported that sentence or complicated it honestly.
Sort every line of your CLAUDE.md into two piles. Pile one is judgement: the calls that depend on the situation, where enforcing them mechanically would be worse than missing them. Leave those in prose, and keep the file short enough that they get read. Pile two is invariants: a single miss is unrecoverable, and a program could see the violation in an artefact. Move those out. Write the hook, make it exit 2, give it a named bypass, give it a test, then go and check it is registered.
Anything landing in neither pile is a wish. Wishes are fine. Know you are making one.
The next chapter builds the gate itself: the stdin JSON a PreToolUse hook receives, the two output protocols it can answer with, and why every blocking hook in this estate fails open.
Graduate one rule through the two-question test
Run every rule in your own CLAUDE.md through the chapter's two questions: is one miss recoverable, and is there an artefact a program can check? Pick one rule that answers no to the first and yes to the second, and write it as a pre-commit hook in your own git hooks path so it fires whether or not Claude is in the loop. The gate must scan only the lines the commit introduces, carry a named bypass env var in a greppable namespace, and fail open on its own errors.
Expected behaviour
- Every CLAUDE.md rule sorted into the judgement pile or the invariant pile, with at least one rule honestly marked as structurally unable to graduate
- A pre-commit hook for one unrecoverable, artefact-checkable rule, firing on plain git commits made outside Claude entirely
- The hook inspects only added lines, so editing a file that already contains the pattern passes clean
- A named bypass token in a consistent namespace, plus fail-open behaviour on any unexpected exception
PROVE IT Stage a deliberate violation and show the commit refused, then rerun with the bypass token and show it landing with a greppable trace. A gate you have never watched refuse a commit is decoration.
Why does a MANDATORY heading in CLAUDE.md guarantee nothing?
Anthropic's stated budget for the length of a CLAUDE.md file, in lines?
The nameleak hook shipped scanning whole files and was rewritten four days later to scan added lines only. Why was the first version a worse gate, even though it inspected strictly more content?
Show answer
Whole-file scanning tripped on prose that was already committed and already fine, so routine edits to the constitution and the devlog forced the bypass token into regular use. A gate with a high false-positive rate trains you to reach for the bypass, and a gate you habitually walk around is no longer strict. Narrowing it to added lines made only what the commit introduces checkable, which is the actual invariant.
↺ re-read: “The rule that graduated the day it broke”