← Back to Writing

Lesson 3: The check that fails

About this artifact

essayon-the-recordmaintained

method · since 2026

What you'll learn: how to make a rule you keep repeating impossible to push past: a folder of small scripts that git runs before every push, each failing when its rule is broken, written by your agent from the rules you repeat. What you need: the specs/ directory from Lesson 1 with at least one spec carrying a verify: line, a project with a git remote, and the agent you already use. Time: fifteen minutes to set up, two per rule after. Cost: zero new dollars.

The idea#

You wrote the rule down. It is in the standing file, it was in three review comments, and last week you said it again in chat. Yesterday a session broke it anyway and pushed. A written rule works only on a reader who reaches it and still holds it while editing; a fresh session deep in a long task does neither reliably. A command that exits non-zero needs none of them: git asks the command, the command says no, and the push does not happen. The rule stops being advice to the reader and becomes a property of the repository.

Two rows compare a rule written in a README with the same rule written as a check: in the README, the session never reads it and the push goes through; as a check, git runs the command before the push, the command fails, and the push is blocked.

Why start here#

Lesson 1 gave every spec a verify: line and Lesson 2 put your decisions in front of every session. Both are still words on a page until something runs them. In July I counted the rules I had written down over two months and found fourteen. One was enforced by a command. The other thirteen were followed on the days someone remembered them. This lesson's folder is the smallest way to move a rule from the second column to the first.

The template#

Save this as hooks/pre-push, then run chmod +x hooks/pre-push and git config core.hooksPath hooks:

#!/bin/sh
# hooks/pre-push: git runs this before every push. One script per rule lives in checks/.
# A check fails by exiting non-zero; the first failure stops the push.
for check in checks/*.sh; do
  sh "$check" || { echo "BLOCKED by $check" >&2; exit 1; }
done
echo "all checks passed"

Then the first two checks. checks/no-tracked-env.sh is the example rule; replace it with yours:

# Rule: never commit a .env file, not even in a commit a later one fixes.
if git ls-files | grep -qE '(^|/)\.env$' ||
   git log --branches --not --remotes --diff-filter=A --name-only --format= |
   grep -qE '(^|/)\.env$'; then
  echo ".env is in a commit about to be pushed; undo that commit, then re-commit" >&2
  echo "the other files by path: git reset HEAD~" >&2
  exit 1
fi

checks/specs-verify.sh runs the verify command of every in-progress spec from Lesson 1:

# Rule: every in-progress spec must pass its own verify command (Lesson 1).
[ -d specs ] || cd ..   # after Lesson 4's split, specs/ sits beside this repository
[ -d specs ] || { echo "no specs/ directory found" >&2; exit 1; }
for spec in $(grep -rl '^status: in-progress' specs --include='*.md'); do
  cmd=$(sed -n 's/^verify: *//p' "$spec" | sed 's/ *#.*//' | head -1)
  [ -n "$cmd" ] || { echo "$spec has no verify command" >&2; exit 1; }
  sh -c "$cmd" || { echo "$spec failed its verify command: $cmd" >&2; exit 1; }
done

Add this to the file your agent reads at session start, under the lines from Lessons 1 and 2:

When I state a rule for the second time, write checks/<rule-name>.sh so it exits
non-zero when the rule is broken. Show me the check failing on a deliberate
violation, then passing after the fix, before you commit it.

Commit hooks/ and checks/ together; core.hooksPath makes git run the tracked folder instead of the untracked .git/hooks, so a fresh clone gets every check with one config command.

Each part, and the mistake it prevents#

The runner knows nothing about your rules. It runs whatever is in checks/ and stops at the first non-zero exit, so the folder grows without the runner changing.

Each check is one rule, one file, one message that names the fix. The filename is the rule's name, so ls checks/ is your list of rules and git log checks/ dates each one. A check small enough to run alone is small enough to break on purpose.

The exit code is what git reads. A check that warns and exits 0 has told git everything is fine.

The spec check is where Lesson 1 becomes enforceable. "Done" is judged by the verify command you wrote before the work started, not by the session grading itself. A spec with no verify line, or a missing specs/ directory, fails the push too.

The standing instruction moves the writing to the agent. You state a rule twice, the agent writes the check and proves it fails, you decide whether it lands.

A worked example#

A small site with one in-progress spec, specs/app/001-robots.md, whose frontmatter says verify: test -f public/robots.txt. The file does not exist yet.

$ git push
specs/app/001-robots.md failed its verify command: test -f public/robots.txt
BLOCKED by checks/specs-verify.sh
error: failed to push some refs

Create the file, commit, push: all checks passed. Then break the first rule on purpose:

$ echo SECRET=1 > .env && git add -f .env && git commit -m "oops"
$ git push
.env is in a commit about to be pushed; undo that commit, then re-commit
the other files by path: git reset HEAD~
BLOCKED by checks/no-tracked-env.sh
error: failed to push some refs

Run the suggested fix, push: all checks passed, and the remote never saw the file. Four pushes, two blocked for the right reason, two allowed; the transcript is from the run, not from memory.

When it grows#

The folder is the unit, not the hook. Rung one is this lesson: git runs checks/ on your machine before a push, catches the ordinary mistake in seconds, and can be skipped with --no-verify. Rung two runs the same folder in the pipeline before merge, where no flag skips it; that is Lesson 7. Rung three is the hosting platform refusing to merge until rung two is green. The checks are written once; the rungs only change who runs them.

From the field#

In June I wrote a check for a style rule I had been repeating in review for weeks, wired it into the tool my agents run in, and tested it by feeding the script a sample payload. It refused the sample. I moved on. Five weeks later the rule was still being broken in live sessions and the check had never fired once: the sessions ran in a mode that ignored the specific signal the script used to say no. The script was correct and the script was dead, and my test could not tell the difference because I had tested the script instead of the path. The real test took one minute: type the violation in a live session. Rewriting the check to refuse by exit code, which every mode honors, took ten more. The audit that followed produced the fourteen-and-one count.

Exercise#

  1. Create hooks/pre-push and the two files in checks/ from the template, make the hook executable, and run git config core.hooksPath hooks.
  2. Add the standing instruction to the file your agent reads at session start.
  3. Make sure one spec in specs/ has status: in-progress and a verify: line that currently fails.
  4. Push. Read which check blocked you and why.
  5. Fix the spec's condition, push again, and confirm all checks passed.
  6. Tell the agent your most-repeated rule. If you are not sure which it is, search your review comments for the sentence you have typed most. Let it write the check and show you the failure.
  7. Break that rule on purpose in a throwaway commit. Push. Then undo it.

You're done when#

You have watched the push stop twice, once for the spec check and once for a rule the agent wrote, each with a message that named the fix, and watched it pass after each fix. Reading a check and agreeing it looks right is not done. Running it by hand is not done either; the test is whether git stops the push. If the push went through with the rule broken, the usual cause is a skipped core.hooksPath step.

Common mistakes#

  • Testing the script instead of the path. Break the rule where the rule is meant to bite.
  • A check that warns and exits 0. Git reads the exit code, not your message.
  • Twenty rules on day one. Add a rule when it has been broken, not when it has been imagined.
  • Treating the hook as the last line. git push --no-verify skips it; I confirmed that in the same run above. Rung two is where the skip goes away.
  • Letting the agent commit a check you never saw fail.
  • Deleting a committed secret in a later commit. The push still carries the commit that added it, which is why the check reads unpushed commits too. Rotate any secret that already reached a remote.
  • Never testing again. A check that worked in June can die in July when the tool around it changes. Once a month, break one on purpose; Lesson 9 makes that a scheduled drill.

Fifteen minutes, one runner, and zero new dollars; from here on a rule costs the two minutes the agent spends writing the check, paid once, instead of the review comment you were going to write every week.

Further reading#

The git hooks chapter of Pro Git covers every hook point git offers. The pre-commit framework is the next step when you want checks shared across projects. GitHub's protected branches page is rung three. Claude Code's hooks reference applies the same exit-code idea inside the agent's own tool calls, which is where the check in From the field lived.

Next lesson#

Why one repository with everything in it makes delegation frightening, and how splitting your project along its natural seams gives every agent a blast radius you can live with.

Questions this post answers

Why do AI coding agents keep breaking rules that are written in the project's README or standing file?
A written rule only works if the reader reaches it, still has attention left, and remembers it while editing. A fresh session may skip the paragraph, or read it and lose it under a long task. A command that exits non-zero needs none of that. Git stops the push whether or not anyone read the rule.
What should the first check in a project enforce?
Pick the one rule you have repeated most often in review comments or chat, and write it as a script that exits non-zero when the rule is broken. In this lesson that rule is never commit a .env file, and the script greps the tracked files and the commits about to be pushed. The second check runs each in-progress spec's verify command from Lesson 1, so a machine grades your acceptance criteria instead of the session that did the work.
How do I keep track of the rules once there are many?
One script per rule in a checks folder, named for the rule. The folder listing is the index, git history shows when each rule arrived, and every check can be run alone or broken on purpose. The agent writes new checks from your repeated rules; you name the rule and watch the check fail once before it is committed.
How do I know a check is alive?
Break the rule on purpose and watch the push stop. A check you have never seen fail is indistinguishable from a dead one. Run the break-it test when a check is written, whenever the tool around it changes, and on a schedule. Lesson 9 turns that schedule into a monthly drill that breaks one check and confirms the alarm fires.
Can a git hook be bypassed?
Yes. The author can pass --no-verify, and a hook only runs on the machine where it is installed. That is why the checks are committed to the repository, and why a later lesson runs the same folder in the pipeline before merge, where no flag skips it.