Andrey Kasatkin

An attempt to ensure that AI writes clean code without ever looking at the code

2026-09-16

When you ask a coding agent to write clean code, every rule you put in the instructions file eventually gets ignored. Not on purpose. The agent gets busy, and a rule in a file is a suggestion it can weigh against the task in front of it. Review cannot catch up either, because the agent produces changes faster than a person can read them.

So I moved the rules out of the file and into the turn itself, as gates the agent cannot argue with: small functions, few branches per function, no escape hatches, no copied code. Small functions are easier to test, and an agent can understand what one does and how to change it. This post is about where such a gate can sit in an agent’s turn, what it can and cannot do from each position, and what a few weeks of running them on real projects taught me.

One turn, three hooks

Here is one turn of a coding agent under a harness like Claude Code, and the three places a hook can run.

One agent turn: the agent proposes a tool call, PreToolUse runs, the tool runs, PostToolUse runs, the agent keeps working or says it is done, Stop runs. The guard sits on PreToolUse, the ratchets on Stop, CI is the authority.

The agent proposes a tool call, an edit, a file write, a shell command. Before the tool runs, the harness fires PreToolUse. After it runs, PostToolUse. The agent reads the result and either proposes another call or decides it is done. When it decides it is done, the harness fires Stop.

Each hook is a program. It receives the event on stdin as JSON, with the tool’s name and arguments, and it answers with an exit code. That exit code is the whole interface. Most of what people get wrong about hooks comes from not knowing what the two codes that matter actually do.

Exit 0 is invisible, exit 2 is the only voice

A hook that exits 0 changes nothing. Whatever it printed goes to a log the model never reads. A green gate that prints “all passed” on exit 0 is talking to itself.

A hook that exits 2 blocks the action, and the harness hands its stderr to the model as the next thing in its context. On PreToolUse, the tool call never runs and the reason is what the model reads next. On Stop, the turn does not end and the model gets the text as its next task.

PostToolUse is the odd one out. By the time it fires the tool has already run, so it cannot refuse anything. It is a place to observe, not to gate.

That asymmetry shapes the design. A gate has one channel to the model, and it only opens when the gate says no. So the output has to be worth reading: the file, the line, and what would fix it.

Where the gates go

Given those three positions, the placement almost writes itself.

PreToolUse gets the guard. There is one class of action an agent must never take on its own: loosening the standard. Accepting new debt into a baseline, editing the quality config, editing the gate scripts, editing the hooks. The guard reads the pending command or file path and refuses that class with exit 2. Everything else goes through, including the one baseline write the agent may run, a tighten, which drops entries the code no longer has and lowers values that improved. An agent can raise the standard but not lower it. Lowering it is an operator’s decision, and the tool enforces the difference rather than a rule asking for it. Refusing more than that would turn the guard into a permission system, and permission systems get worked around.

Stop gets the ratchets. One edit is not the unit that matters, the whole change is. So the ratchets run when the agent says it is done: complexity, escape hatches like any and unwrap(), duplication, the project’s own conventions, judged over the files this turn changed against the baseline. If a function came out too complex, the agent is sent back to rewrite it, with the file and line in front of it. If nothing got worse, the hook exits 0 and the agent never hears from it.

CI is the authority. The hooks nudge. They cannot see a session with different settings or a push from a plain terminal. So the same gates run in CI with a strict flag that also fails a baseline looser than the code, and that check is required to merge, with nobody able to bypass it. The hooks exist so the agent fixes things before CI would have refused them.

The whole wiring is two entries in the harness settings:

{
  "hooks": {
    "PreToolUse": [{ "matcher": "Bash|Edit|Write",
                     "hooks": [{ "type": "command", "command": "python3 quality/bin/gate.py --guard" }] }],
    "Stop":       [{ "hooks": [{ "type": "command", "command": "python3 quality/bin/gate.py --hook --changed" }] }]
  }
}

Why this matters more with a team

The benefit I did not expect is what happens when more than one person works on the project. The baseline and the gates live in the repository, the guard keeps any one session from loosening them, and CI holds everyone to the same file. So my agent and my colleague’s agent write at the same quality level, whatever each of us put in our own instructions. The standard stops being a matter of who wrote the better prompt.

What I got wrong the first time

The design above is the second version. The first one had four problems, all found by real adopters within a week, and all of them about the flow rather than the checks.

The Stop hook blocked once per stop, not once per failure. The harness sets a flag when the agent is already continuing because of a block, and the hook honored it. But every later turn is a fresh stop with the flag clear. A failure the agent could not fix, because it was a decision for a person, blocked every stop and re-sent the same eight-thousand-token report each time, eight times in one session. The fix was to fingerprint the report. An identical report blocks once, then gets one line and a clean exit. Any change in any gate’s output is news again.

The guard refused a mention, not an invocation. It matched the baseline-writing flag anywhere in a command. A subagent writing a design note through a heredoc that quoted the flag was refused and had to reach for a different tool. Now the guard reads the command as a shell would: what program runs, with what arguments, and whether the flag is an argument to it or text on its way into a file. A commit message naming the flag passes. A pipe into a shell does not. Doubt still resolves toward refusal.

A gate passed green because it measured nothing. The complexity reader handed absolute paths to its tool, and the project’s own exclude pattern for temp directories matched the checkout’s prefix inside an agent worktree. Zero functions judged, all baseline entries unmatched, gate green. The same tree was red in its primary checkout. A gate that can pass by measuring nothing is worse than no gate, because it looks like a result. It now runs from the repository root over relative paths.

Two runs at once removed each other’s files. The Stop hook fires whenever a turn ends, so a long release preflight can be racing a hook run at almost any moment. Both wrote a per-gate temp config to the same path, and whichever finished first deleted the other’s. The failure read as a missing file, which cost two release attempts before anyone suspected concurrency. The file now carries the process id. It was also the week I learned that keeping the gates green locally and in CI at the same time is its own kind of work, and that “passes here, fails after push” is usually the flow, not the check.

What I still want to know

None of those four problems would have been found by reading the hook documentation. They came from putting gates in the loop and watching what an agent did when it met them. An instruction fails silently. A gate fails in a way you can read.

The gates are open source, under the name cleat. One command attaches them to a project, writes the baselines from the code as it is today, and wires the two hooks above. The question I am still working on is what else a project needs so that it does not need a full refactoring within a year. If you have run agents against a codebase for long enough to have an answer, I would like to hear it.