Enforce invariants with a failing test, not a warning
A rule you have had to repeat in CLAUDE.md is a missing test: encode it as a script that exits non-zero and wire it into a hook, because the docs state plainly that CLAUDE.md is advisory while hooks are deterministic.
The official documentation describes the mechanism this relies on. The technique itself is community practice.
What this costs you if it is wrong
A gate only helps at the layer you put it at: a pre-commit hook can let a long session run to completion before rejecting the work, and a slow gate slows every human commit too. Enforcement scripts are also code the agent runs on your machine — keep them in review, and note that a `Stop` hook stops blocking after 8 consecutive failures, so a gate the agent cannot satisfy silently stops gating.
You have written the same correction into CLAUDE.md three times — don't change the signature of renderRow, don't touch the migrations folder, always keep the version pill in the footer — and Claude still walks past it every few sessions. The instruction is not being ignored out of malice. It is advisory text competing with everything else in a long context, and by turn forty it has lost.
The move is to stop writing the rule as prose and start writing it as something that returns a non-zero exit code.
Why the prose version keeps losing
The docs are unusually blunt about this. memory says Claude "treats them as context, not enforced configuration" and points you at a PreToolUse hook to block an action regardless of what Claude decides. best-practices says the same thing from the other side: "Unlike CLAUDE.md instructions which are advisory, hooks are deterministic and guarantee the action happens."
So the reframe is not "write a better instruction." It is: an instruction you have had to repeat is a missing test.
The sharp version: "don't change this function signature" should not be a warning you hope lands. It should be an end-to-end test that fails when the signature changes. Claude sees the failure, reads it, and fixes it without you in the loop. Prose costs tokens on every turn and can be forgotten; a script costs milliseconds and cannot.
Where to put the gate
The community framing of this tip reaches for a git pre-commit hook. That works, but it is the weakest of the available layers, and the docs describe stronger ones. Pick by how early you want the failure.
| Layer | Fires | Catches | Cost when it trips |
|---|---|---|---|
PreToolUse hook, exit 2 | Before the edit is written | The change never lands on disk | Seconds. Claude gets the stderr as feedback and adjusts |
PostToolUse hook running your test | After each edit | The change lands, then is flagged | Seconds to a minute |
Stop hook running your test | Before the turn can end | Anything that broke during the turn | A turn |
| Git pre-commit hook | At git commit | Anything, but only once you commit | Potentially a whole session of work the gate rejects |
| CI | On push | Everything, including what ran with the hook disabled | A round trip |
hooks-guide has a worked PreToolUse example — a script that matches the target path against a protected-pattern list and exit 2s to block the edit, with the stderr message fed back to Claude. That is the shape to copy for "do not touch this file." For "do not break this behavior," a Stop hook running the test is the closer fit: best-practices calls it "a deterministic gate" that "blocks the turn from ending until it passes."
The invariant test, concretely
An invariant test is narrower than a normal test. It asserts one thing you have decided will not change, and its failure message says why, because the agent reading it has no other source for that.
# tests/invariants.sh — run by the Stop hook and by CI
set -euo pipefail
# The version pill is load-bearing: without it a stale service-worker
# cache is indistinguishable from a successful deploy.
grep -q '__BUILD_SHA__' src/components/Footer.tsx \
|| { echo "INVARIANT: Footer.tsx must render __BUILD_SHA__ (stale-cache detection)" >&2; exit 1; }Two properties matter more than the grep itself. The message names the consequence, not just the rule — an agent that knows why will find a correct fix instead of a fix that satisfies the grep. And it is fast, because it runs on every turn.
A detail that supports the pre-commit version
If you do use a git hook, there is a real reason it is harder to defeat than it looks. permission-modes lists .pre-commit-config.yaml, lefthook.yml and friends as protected files, and .husky as a protected directory. Writes to those are "never auto-approved, except in bypassPermissions mode and in planning sessions with bypass permissions available" — default and acceptEdits prompt, dontAsk denies outright, and auto routes the write to the classifier rather than to you. So the agent cannot quietly rewrite the gate while fixing the thing the gate caught, though in auto mode "quietly" is doing more work than in the modes that prompt.
What is unverified here
Say it plainly, because it is the part that decides how far to trust this:
- Corroborated by the docs: hooks are deterministic and CLAUDE.md is advisory;
PreToolUseexit 2 blocks an edit; aStophook gates the end of a turn; pre-commit config files are permission-protected. - Not in the docs at all: the practice of converting a repeated prose correction into a test, the word invariant (it appears nowhere across the 77 pages), and any suggestion that a git pre-commit hook is the right enforcement layer. The docs consistently point at Claude Code's own hook system instead.
- Untested claim, and the important one: that an agent stopped by a git pre-commit hook fixes the underlying problem rather than reaching for
git commit --no-verify. The docs say nothing either way. Nobody has run this.
To settle that last point, wire the check above into .git/hooks/pre-commit in a throwaway repo, give Claude a task that trips it, and then look at whether git log is empty and whether the transcript contains --no-verify. That single observation is what would move this entry from docs-corroborated to reproduced.
Checkpoint
-
Which of your current
CLAUDE.mdlines have you had to repeat to Claude more than twice, and what would the failing check for each one assert?Show answerAnswer
That list is yours to make: open your own
CLAUDE.mdand mark every line that is a correction you have had to repeat, as opposed to context Claude could not have known. The docs are why the repetition happens — memory files are treated as context, not enforced configuration — so the tip's reading of such a line is that it is a missing test.What each check asserts is the observable consequence, not the prose. Don't change the signature of
renderRowbecomes a test that fails when the signature changes; always keep the version pill in the footer becomes the tip'sgrep -q '__BUILD_SHA__' src/components/Footer.tsx. A line you cannot turn into an assertion is context, and it stays prose. -
For one of those, is the right layer
PreToolUse(never write it),Stop(never finish with it broken), or pre-commit (never commit it) — and what does a wrong choice cost you in wasted session time?Show answerAnswer
Pick by the shape of the rule. "Do not touch this path" is
PreToolUse: it fires before the edit is written, so the change never lands on disk and Claude gets your stderr as feedback within seconds — the shape the hooks guide's protected-pattern example already has. "Do not finish with this broken" is aStophook running the test, which costs a turn when it trips.The wrong choice costs you lateness. Pre-commit only fires at
git commit, so putting a behavioural invariant there can let a whole session run to completion before the gate rejects the work, and CI adds a round trip on top; a slow gate also slows every human commit. One limit on the other side: the docs say Claude Code overrides aStophook and ends the turn after 8 consecutive blocks, so a gate the agent cannot actually satisfy burns eight turns and then stops gating. -
Does your invariant's failure message tell the agent why the rule exists, or only that it was broken?
Show answerAnswer
Read your own failure string — that one is yours to check, and the test is whether it names a consequence. The tip's example does:
INVARIANT: Footer.tsx must render __BUILD_SHA__ (stale-cache detection)tells the agent what breaks if the rule is violated, and an agent that knows why can find a correct fix rather than one that merely satisfies the grep. A message that says only "rule broken" invites a fix aimed at the check.Keep both halves. Delete the
CLAUDE.mdsentence and the agent hits a wall it does not understand; keep only the sentence and you are back to advisory prose. One line of explanation plus one failing check is the pair.
Where this came from
- HN comment (cadamsdotcom) on the 'Separation of planning and execution' thread, 2026-02-22read 2026-09-09
- HN comment (mgrassotti): a PreToolUse hook is a guarantee, CLAUDE.md is a suggestion that can be forgottenread 2026-09-09
- Lobsters thread on hooks; top comment argues the real win is forcing a compile or test run after every writeread 2026-09-09