Code quality guards for AI code: the whole setup, from hook to CI
10m read time

Code quality guards for AI code: the whole setup, from hook to CI

The full quality setup for a project where an agent writes code: what belongs in Claude Code hooks, pre-commit, pre-push, CI and the weekly mutation run. And the one property every guard needs, which is that it can fail, with the evidence from three of my own repositories.

A quality guard has one job: say no when the code got worse. Most of the broken guards I have found had quietly lost the ability to say no and kept reporting green. Knossos, the architecture scanner I build, has the best line on it, in a comment in its own quality script: "a gate that cannot fail is worse than no gate because it reads as if it checked."

This post is the setup I run, layer by layer: what goes where, what each layer costs, and the handful of rules that keep every guard able to fail. It pulls the eight earlier posts in this series into one project. The evidence comes from three repositories: Knossos, this blog's own repository, and a Python and Vue app I have not released yet.

Guard layers on a time axis from milliseconds to a week, with pre-commit, pre-push and CI all calling one quality script

The setup at a glance ​

One rule decides where a check goes: put it in the weakest layer that still holds it every time. Enforcement costs friction and moving parts. A convention that survives being followed most of the time can live in a prompt. A rule that is wrong on its first violation needs a hook or a gate.

LayerRunsTakesPut here
Contextevery sessionnothingCLAUDE.md, area rules and ADRs: the why
Agent hooksevery tool call or turn endmillisecondswhat must be caught while the agent is still working
Pre-commitevery commitsecondsthe fast lane: format, lint, types, secrets
Pre-pushevery pushminutesthe full lane: tests, coverage floors, duplication, dead code
CIevery push, pull request, nightminutesthe same lanes, plus end-to-end tests and the baseline gate
Scheduledweeklyan hour or moremutation testing, dependency audits, consolidation

Layer by layer ​

Context: the why, never the guarantee ​

CLAUDE.md is followed probabilistically. An ADR in this blog's repository puts it plainly: "Most of the time is not every time, and on a long task 'most of the time' compounds the wrong way." Use context for the reasoning behind a rule, and move anything that has to hold every time further down the table. That is the enforcement stack from an earlier post, and it has held up.

Agent hooks: catch it while the agent is still working ​

A hook fires on the agent's own actions, so its failure goes straight back into the session and the agent fixes it on the spot. Three hooks cover most of what is worth catching that early:

  • Protect the harness (PreToolUse on Edit and Write). Block writes to .env and build output, and force a permission prompt on tests and on every config file that gates the repo. The reason, from that same ADR: "the cheapest way to turn a failing suite green is to lower the bar." If you add one hook to a project, make it this one.
  • Check what was just written (PostToolUse). On this blog it checks the house style's absolute rules and reports each violation with a line number and the fix. Run across all 196 content files on the day it was written, it found three violations that had survived review.
  • Make the turn review-ready (Stop). Run lint and the type checker when source files changed, and hand any failure back so the agent keeps working until the turn is ready for review. On a session that only touched content it exits in 8 ms.

A hook can also just advise. Knossos's hooks never deny anything: they brief the agent on the architecture at session start, and on a read or an edit add a short note on what depends on the file, which boundary rules bind it and which tests reach it. A dependency graph is evidence, and evidence belongs in front of the agent while it decides. The tool page covers what else Knossos does.

Git hooks: one entry point, two profiles ​

An agent hook only covers that agent, in that client, under that permission configuration. Refusal belongs one layer down, where every commit goes through regardless of who or what made it. That is why Knossos and the unreleased app both refuse in git and CI, and why the unreleased app has no Claude Code hooks at all.

Route every check through one entry point. Knossos has a single tools/quality script with lanes for static checks, tests, the Rust worker, coverage, release checks and the architecture gate. Pre-commit calls the fast profile and pre-push calls the full one. The unreleased app does the same with a commit tier and a push tier in its pre-commit config.

What runs in those lanes is unglamorous: strict types (mypy in strict mode, PHPStan), complexity and function-length caps, duplication detection, dead-code detection, secret scanning, per-file coverage floors, a bundle-size budget and dependency audits.

CI: the same script, plus what is too slow locally ​

CI calls the same lanes, in Knossos as a matrix, in the unreleased app through one reusable verify workflow. One entry point means "it passed locally" and "it passed in CI" are the same sentence. In Knossos I renamed the old composer check to check:quick, with the reason in the commit: "it is not the authoritative gate".

On top come the checks nobody waits for on a laptop: end-to-end tests, a scan of the container image, and the baseline gate. The Knossos gate scans both the baseline commit and the change, then holds the result against the budgets in knossos.json: zero new cycles, zero boundary violations.

Weekly: the expensive truths ​

Some checks cost too much for every push, and they are the ones that tell you whether the rest is worth anything. Run mutation testing on a schedule. It is the only layer that asks whether a test would notice a bug. In Knossos it found a subtraction of a timeout budget that could be mutated into an addition with the whole suite staying green, which handed a pair of subprocesses up to twice the time the caller asked for.

Every guard must be able to fail ​

Everything above is worthless if a guard can go green without checking. Each of these happened in one of the three repositories, and each has a fix you can apply today.

  • No baseline. A gate that compares against a baseline commit will, without history, compare a tree with itself and pass. Make it refuse to run instead. The Knossos gate does, and failed the v0.14.0 release on exactly that the day it was wired in: "The guard did its job; the wiring did not exist."
  • A hook that never fires. This blog's hooks were committed without their exec bit, and "a write to dist/ sailed through with no error anywhere." The unit test passed that day, because it piped JSON into the script instead of triggering the hook. Invoke hooks as bash <script>, and test them by triggering them.
  • A number nobody reads. The unreleased app's mutation job counted surviving mutants, printed the count and exited on infrastructure failure alone, so a target could go from 0 survivors to 40 and stay green. If a check computes a number, a threshold must consume it.
  • An empty scan. A guard that walks a directory and asserts its list of offenders is empty passes just as well when the walk finds nothing. That pattern works well for an allowlist you have worked down to zero, as long as the guard proves the scan saw files. In the unreleased app a grep stopped matching after an unrelated edit, its loop ran zero times and the check passed. Only a test of that specific guard caught it.
  • A warning that exits 0. In Knossos, PHPUnit warnings did not fail the run, so a mutant that made an early test warn stopped the run with exit 0 and counted as a survivor. Nine of ten reported survivors in one file were phantoms of that kind. failOnWarning is now on, and a test keeps it on.
  • A guard that pins the mistake. A consistency audit misread a token-scope check as a role check, and once I applied its fix, the restricted token issued during 2FA enrolment could change the account password. The integration tests caught it while the unit suite stayed green. Worse, the guard added alongside the change asserted the new behaviour, so it "would have enforced the regression permanently". Give every new guard a positive control: a planted violation it is shown to reject.

And no guard may fail all the time ​

The mirror image does as much damage. When I added a Knossos config to the unreleased app, I declared only the boundary rules that hold at zero today, because one sanctioned exception list would have produced ten violations on every run, and "a gate that always fails is a gate nobody reads." Rules you cannot meet yet belong in a ratchet with a baseline.

The same goes for a gate that is red for good news. The unreleased app's mutation ratchet originally failed in both directions and went red on four consecutive runs that measured nothing but improvements. It now passes an improvement and opens a pull request that tightens the baseline. A red run means one thing: something got worse.

Ratchet only from a full run ​

A ratchet is only as honest as the measurement behind it. On 22 July the minimum mutation score in Knossos went from 53 to 76 on test batches measured file by file. The next day it went back to "the measured 53", and today it sits at 63, measured by a single run over the whole tree that took almost six hours. The rule now in the config: a floor is never set above what a full run measured.

Drift runs the other way too. The unreleased app's frontend coverage floors never moved, but the measurement sank towards them until branch coverage sat just six branch arcs above its floor, with every gate green. In the words of its config: "The floors never moved; the measurement came down to meet them." Re-cutting two floors downward to buy headroom back was the honest fix.

Building this into your own project ​

  1. Protect the harness first. A PreToolUse hook that asks before anything touches tests, coverage config or lint config. Everything else depends on it.
  2. One entry point, three callers. Pre-commit, pre-push and CI run the same script with different profiles.
  3. Give every guard a positive control. A planted violation it is shown to reject, and for a hook, a real trigger instead of piped JSON.
  4. A computed number must gate something. If a job prints it and exits 0, delete the job or wire the threshold.
  5. Gate only what holds at zero today. Everything else goes in a ratchet that only tightens.
  6. Ratchet from full runs. Never from a sample, never from one file.
  7. Red means worse. Good news opens a pull request.
  8. Advice where the agent works, refusal where every commit goes through.

Knossos once had a budgeted metric that drifted from 68 to 158 against a limit of 110 between 2 August and 10 September, and "no run said so". The limit was fine. What was missing was a run that could fail on it, and once that existed it failed a release the same day. Most of what keeps code good is guards like that one: dull, specific, and able to say no.

(9 of 9)