CCLearn

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.

docs corroborate

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.

LayerFiresCatchesCost when it trips
PreToolUse hook, exit 2Before the edit is writtenThe change never lands on diskSeconds. Claude gets the stderr as feedback and adjusts
PostToolUse hook running your testAfter each editThe change lands, then is flaggedSeconds to a minute
Stop hook running your testBefore the turn can endAnything that broke during the turnA turn
Git pre-commit hookAt git commitAnything, but only once you commitPotentially a whole session of work the gate rejects
CIOn pushEverything, including what ran with the hook disabledA 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.

bash
# 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; PreToolUse exit 2 blocks an edit; a Stop hook 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

  1. Which of your current CLAUDE.md lines have you had to repeat to Claude more than twice, and what would the failing check for each one assert?

    Show answer

    That list is yours to make: open your own CLAUDE.md and 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 renderRow becomes a test that fails when the signature changes; always keep the version pill in the footer becomes the tip's grep -q '__BUILD_SHA__' src/components/Footer.tsx. A line you cannot turn into an assertion is context, and it stays prose.

  2. 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 answer

    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 a Stop hook 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 a Stop hook and ends the turn after 8 consecutive blocks, so a gate the agent cannot actually satisfy burns eight turns and then stops gating.

  3. Does your invariant's failure message tell the agent why the rule exists, or only that it was broken?

    Show answer

    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.md sentence 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

Official pages that back this