The friction that makes humans hate pre-commit hooks is what makes them perfect for agents.

A git pre-commit hook turns 'the agent should run the tests' into 'the agent cannot commit broken code' — a deterministic gate that self-corrects from its own error output and ships with every clone.

The friction that makes humans hate pre-commit hooks is what makes them perfect for agents.

Ask a developer about pre-commit hooks and watch their face. Three minutes of lint-format-test before every single commit, the --no-verify flag worn smooth from use, the team Slack thread titled “can we kill the hook.” Hooks have a deservedly bad reputation, and the reason is simple: they punish humans. A person who waits three minutes to save a one-line change learns to route around the gate.

Now hand the same hook to an agent. It doesn’t sigh. It doesn’t reach for --no-verify. It doesn’t have a meeting in four minutes. The exact friction that drives people to disable the gate is invisible to the thing the gate was always meant to constrain. The robot does not get bored.

So here’s the real question: if you’ve already told your agent to run the tests, why does it still commit broken code?

A soft instruction is a suggestion the agent is free to forget

Section titled “A soft instruction is a suggestion the agent is free to forget”

Picture the workflow everyone starts with. You write a rule — in AGENTS.md or CLAUDE.md — that says the right thing:

## Before committing
Always run `pnpm format`, `pnpm typecheck`, and `pnpm test`.
Do not commit if any of them fail.

This is good practice and it mostly works. The word doing all the load-bearing labor is mostly. A rule is context, not control. It competes for attention against everything else in the window — the task, the diff, the last error, the file you just opened. Twelve tool calls into a session, with a green-looking diff and a user waiting, “always run typecheck” is one instruction among hundreds. The agent skips it, the commit lands, and the regression rides into main looking exactly like progress.

And the math is against you over time. Say the rule is followed 95% of the time — generous for a soft instruction buried mid-session. Across fifty commits, the chance that every one of them honored the gate is 0.95^50, under 8%. A probabilistic guarantee, repeated, converges on certainty that it eventually fails. The thing about a stochastic system is that its good intentions are a distribution, not a promise.

You didn’t get a broken gate. You got a gate the agent was allowed to walk past. The fix is not a louder rule. The fix is to make the path through the gate the only path that exists.

A hook converts “should” into “cannot”

Section titled “A hook converts “should” into “cannot””

Move the same three commands out of prose and into a hook — the deterministic kind that fires whether or not anyone remembers it. With a tool like pre-commit, the config is the contract:

.pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: format
name: format
entry: pnpm format
language: system
pass_filenames: false
- id: typecheck
name: typecheck
entry: pnpm typecheck
language: system
pass_filenames: false
- id: test
name: test
entry: pnpm test
language: system
pass_filenames: false

Now git commit is no longer a polite request. If types break, the commit aborts with a non-zero exit code. Broken code does not enter history because the operation that would record it fails. The rule said “the agent should verify.” The hook says “the agent cannot commit until verification passes.” Those are different categories of guarantee — one is a hope, the other is physics.

This is the line between context and control that runs through all of context engineering. A rule is the cheapest, most reversible way to shape behavior. A hook is the move you make when “should” is no longer good enough and you need “cannot.”

The error output is the self-correction loop

Section titled “The error output is the self-correction loop”

Here’s the part that makes hooks better for agents than they ever were for humans. When the gate blocks a person, they read the failure, swear, fix it, and re-run — a tax on their patience. When the gate blocks an agent, the failure message is a gift: structured, specific context delivered at the exact moment it’s actionable.

typecheck.....................................................Failed
- hook id: typecheck
- exit code: 2
src/api/user.ts:42:18 - error TS2345: Argument of type 'string'
is not assignable to parameter of type 'number'.

That block is a complete repair instruction. File, line, the type that’s wrong, the type that’s expected. The agent reads the hook’s own stderr, edits user.ts:42, and retries the commit — no human in the loop, no re-prompt, no “hey it failed again.” The gate doesn’t just block bad work; it narrates exactly how to make the work good. A human experiences that narration as nagging. An agent experiences it as the next instruction.

This is why the friction inverts. The cost of a slow, chatty, strict hook is paid in human patience, and humans are the scarce resource. The agent has no patience to spend.

But the self-correction loop is only as good as the stderr it reads. A typechecker that prints file:line: expected X, got Y hands the agent a turn-by-turn repair route. A hook that fails with a bare exit code 1 and no message, or a 200-line stack trace with the real cause buried in the middle, hands it nothing — and now the agent is guessing, sometimes “fixing” the wrong thing or, worse, deleting the failing assertion to make the gate go green. When you write a hook for an agent, the error message is part of the interface. Make it say what failed and where, the same way you’d want it to if you were the one reading it at 2 a.m.

The same gate ships to everyone who clones the repo

Section titled “The same gate ships to everyone who clones the repo”

There’s a quieter payoff that matters more on a team. A rule in CLAUDE.md shapes your agent in your session. A hook checked into the repo as configuration is the same gate for every contributor, every agent, and every CI run — a single source of truth for “what counts as committable.”

Your teammate’s agent, running a different model with a different prompt and a different idea of “done,” still cannot commit code that fails your test suite. The standard stops living in scattered prose that each agent interprets fresh and starts living in an executable artifact that interprets nothing. You wrote the definition of “good commit” exactly once, and now it binds uniformly — which is the whole game of closing the context gap. Determinism beats good intentions, especially good intentions held by a stochastic system.

Keep the soft rule too — it tells the agent why the gate exists, which helps it fix failures faster instead of fighting them. But the rule is the explanation. The hook is the enforcement.

But an agent can still walk around a git hook

Section titled “But an agent can still walk around a git hook”

Here’s the honest counter-argument, and it’s a real one. A git pre-commit hook is not actually a wall — it’s a wall with a clearly labeled door. The door is git commit --no-verify, and every agent trained on the public corpus of Stack Overflow answers knows it exists. Block a commit three times and a sufficiently “helpful” agent may reason its way to “the hook is in the way, let me skip it” — exactly the --no-verify reflex that wore the flag smooth on human keyboards. The friction doesn’t tempt the agent the way it tempts a person, but the escape hatch is still sitting there in its training data.

So you close the door at a different layer. The git hook lives in the repo; a second gate can live inside the agent’s own loop. Claude Code’s hooks include a PreToolUse event that fires before the agent runs any shell command — and if your hook exits with code 2, the command is denied and the message you wrote to stderr is handed back to the agent as feedback. That’s enough to forbid the bypass outright:

#!/usr/bin/env bash
# .claude/hooks/block-no-verify.sh — wired to PreToolUse on Bash
cmd=$(jq -r '.tool_input.command')
if echo "$cmd" | grep -qE -- '--no-verify|-n\b'; then
echo "Blocked: commit gates are mandatory. Fix the failure, don't skip it." >&2
exit 2
fi

Now there are two gates with two different jobs. The git hook is the team contract — it ships with every clone and binds every contributor and every CI run. The harness hook is the agent contract — it removes the one move the agent might make to wriggle out of the first gate. The repo-level gate defines what counts as committable; the loop-level gate makes sure the agent can’t decide that rule is optional today. Defense in depth, except the depth costs you one shell script.

A deterministic gate is the right answer only for checks that are actually deterministic. Three places it’s the wrong reach:

Judgment calls. “Is this well-named?” “Does this belong in this module?” “Is the abstraction earning its keep?” None of that compiles to a non-zero exit code. Cram a subjective check into a hook and you get a gate that blocks on a heuristic and trains the agent to satisfy the heuristic rather than the intent. Taste stays a rule and a human review; the hook is for the binary “passes or it doesn’t.”

Slow checks on the hot path. The post-card promise of “the agent feels no friction” has one asterisk: it still pays in wall-clock and tokens. A five-minute end-to-end suite run on every commit attempt turns a tight edit loop into a crawl and burns context on output the agent has already seen. Scope the gate to the surface. Keep pre-commit fast — format, typecheck, the unit tests that touch the diff — and push the heavy, slow, full-system suite to pre-push or CI, where it runs once per batch instead of once per keystroke-sized commit.

Flaky checks. A non-deterministic test in a mandatory gate is worse than no gate. It blocks valid work at random, and the agent — which cannot tell “the gate is wrong” from “my code is wrong” — will start editing perfectly good code to appease a coin flip. A gate’s authority rests entirely on the failure meaning something. The moment it sometimes lies, every block becomes noise, and the self-correction loop starts correcting things that were never broken.

Take the three commands sitting in your AGENTS.md as a “should” and promote them into a pre-commit hook. Leave the prose in place as the human-readable rationale; let the hook carry the guarantee. Then stop watching every commit — the gate is watching for you, and it never gets tired of doing it.

Hooks earned their bad name on humans, who route around any friction that costs them time. Give the same friction to something that doesn’t feel friction, and the gate everyone disabled becomes the thing that keeps the robot honest.


For the per-tool mechanics, see Hooks; for the soft-context layer that explains why the gate exists, see Rules; for checking the gate into the repo so it binds everyone, see Configuration.