Agents write code faster than anyone can review it. Here is the ratchet that keeps the codebase from rotting


You ask an agent to refactor a module. It hands back an 800-line diff, you skim it, you merge. Three months later you open that file: 4,000 lines, a dozen branches per function, and an hour of reading before you can safely change one line. The problem is not that the agent wrote bad code — it is that generation speed has outrun review speed. Something can add 5,000 lines a day; nobody can read 5,000 lines a day. Codebases are rarely ruined by one terrible commit; they are crushed by a thousand commits that each added “just a little”.

On October 5, 2026, the Hermes repository merged PR #132646, which installs an interesting mechanism: code health can only improve. It does not chase an ideal. It works like a ratchet — every function and every file keeps its own current level, and may only move in one direction. Here is how it works and what is worth stealing.

The core rule: separate ceilings, no borrowing

The rule is short and hard:

  • Whatever is already over target may not grow. A function at cyclomatic complexity (CC) 40 — a rough count of independent paths through the code — may stay at 40 or drop to 20, but never reach 41.
  • New code has to meet the target. New functions and files must satisfy: CC 20, 300 own lines per function, nesting 6, 2,000 lines per file.

The subtler half is that you cannot borrow budget. Pattern-rule violations (say, a swallowed exception) are tracked by fingerprint — the rule, the enclosing function, and the line text. Fixing one violation never pays for adding another, and nothing is pooled across files. Without that, a ratchet turns into a scoreboard to game.

No baseline file — the clever part

Most code-health tools want a baseline: record every existing violation, forbid exceeding it. That file becomes a conflict magnet, because two parallel PRs both want to edit it and whoever merges first wins.

Hermes stores no baseline. It measures on the fly: it takes the files a change touches, on both the base (the merge commit’s first parent) and the head, and compares. Three consequences:

  • an improvement that lands on main becomes the new cap immediately, with nobody remembering to “tighten the ratchet”;
  • parallel PRs never conflict over ratchet state;
  • both sides are always measured with the same tool version, so the comparison is meaningful.

Moved code keeps its ceiling; copied code does not

This is what keeps refactoring viable:

  • Functions are matched by a body hash that ignores the name. A function moved to another file and lightly edited (body similarity ≥ 0.6, one left and one arrived) keeps its ceiling, and module-level statements moved verbatim keep theirs.
  • A copy that leaves the original in place counts as new code, and has to meet the target.
  • Renamed files follow git’s rename detection rather than being treated as new.
  • Nested functions are their own units, so editing a closure is not charged to the function containing it. Python complexity uses ruff’s C901 minus the direct nested defs.

What else it blocks

Size is one category. The full rule set (with scope and the fix text the agent sees) lives in scripts/code_health/config.py:

Category Rules
Swallowed exceptions S110, BLE001, E722 (bare except:); a message-only logger.warning("...: %s", e) still counts as swallowed
Correctness F821/F823/F811/F841, RUF006
Can hang ASYNC230/ASYNC240, S113, HX006 (subprocess/urlopen without a timeout), HX007 (sync config I/O inside async def), HX008 (get_event_loop), HX009 (gather results checked against Exception)
Profile scope HX001 (hardcoded ~/.hermes), HX002 (a new HERMES_* env var), HX004 (UnscopedSecretError falling back to os.getenv), HX005 (import-time capture), HX012 (raw threading.Thread), plus PS-P05/PS-P06
Other HX003 (argv process identity), HX010 (bool() of an env string), HX011 (long if/elif ladders)

A subset is warning-only: HX001, HX003, HX010, HX012, PS-P05, PS-P06. Reviewers’ samples put their precision at roughly 12–60%, so they print on every run but never block a commit. Flipping one to blocking is a one-line change once there is replay evidence.

The escape hatch: waive it, but say why

Any rule needs exceptions. The syntax is:

# health: allow BLE001 -- <why>

The reason is required, and every allow used in a run is listed in the output, so a reviewer sees how many waivers a change adds and what they say. That is a real improvement over # noqa, which is usually silent.

One command, exactly what CI runs

The most practical part is the local experience. python scripts/check runs ruff, the guard checks (footgun / shebang / no-tmp / config-writer / OS-marker / case-collision / lazy-deps / profile-archive) and the ratchet itself in parallel, using CI’s pinned ruff — taken from the pyproject dev group, falling back to uvx ruff@<pin> rather than silently using whatever ruff you have. About ten seconds on the tree.

python scripts/check                # everything, against the merge-base with origin/main
python scripts/check --only health  # just the code-health ratchet
python scripts/check --staged       # judge the index (what the pre-commit hook runs)
python scripts/check --commit REV   # judge one commit (what the pre-push hook runs)
python scripts/check --install-hook pre-push
python scripts/check --uninstall-hook

Hooks are opt-in and skippable with --no-verify; CI is not. scripts/check needs Python 3.11+ (for tomllib) and says so plainly, pointing at the repo virtualenv, instead of dying in a traceback on every hooked commit.

There is also a master switch: ENFORCEMENT = "blocking" | "advisory" | "off" in scripts/code_health/config.py. Crucially it is always read from main — in CI, from the merge commit’s first parent; locally, from origin/main — never from the branch under test. A one-line commit to main turns the ratchet down for every open PR, and no PR can relax its own check.

It was validated on a frozen replay before it blocked anything

The worst failure mode for a new gate is blocking things that are actually fine, so it was replayed against history first: every PR merged between 2026-10-02 and 2026-10-03.

Check Result
Frozen replay (318 PRs) 59 would be blocked. 38 by file growth alone; 21 by other rules: CC growth or a new function over CC 20 in 10 PRs, 13 blind excepts in 9 PRs, five F841 in one test file, a test F811 (a shadowed test that never ran), one S110, one new 300+-line function
File-size ratchet The sole blocker for 38 of 318 PRs (12%) in that window, mostly +1–50 lines into files already past 2,000 (main.ts, auxiliary_client.py, cron/scheduler.py, the 23k-line test_tui_gateway_server.py)
Full-tree report on main Units over target today: 451 CC, 55 function-length, 28 nesting; 119 files; BLE001 6,516, S110 828, HX012 227, HX006 209, HX005 53 — existing debt that only blocks once it grows

That last row is worth a second. The mechanism does not ask anyone to pay down the debt. The 6,516 existing swallowed exceptions are frozen, not scheduled; they only matter if they grow. That is what makes a gate like this survivable — demanding a clean slate on day one guarantees nobody turns the switch on.

What to steal for your own repo

If your team is also letting agents write a lot of code, several of these decisions transfer directly:

  1. A ratchet, not a target. Not “CC must stay under 20 from now on”, but “what is already over may not get worse, and new code must meet the bar”. The first version blocks every edit to legacy code.
  2. Measure on the fly, store no baseline file. No parallel-PR conflicts, and no discipline problem about remembering to tighten it.
  3. Fingerprint pattern violations so a fix never buys a new violation.
  4. Ship new rules as warnings first, prove their precision on historical replays, then make them blocking.
  5. Require a reason for every waiver, and print the waivers — a silent exception is the same as no rule.
  6. Make the local command identical to CI, so nobody has to guess what the pipeline will say.

For context, this is the same repository that an agent refactored at scale in September: 1,393 subagents cut non-test Python by 34.4%. That proved agents can rewrite a codebase in bulk. The ratchet answers the follow-up question — how to stop it from slowly growing back. If you care about engineering constraints landing in CI rather than in a wiki, the plugin catalog supply-chain checks are another version of “write the rule into the pipeline”.

The design lesson

The people who built this made one deliberately modest choice: do not pursue the ideal, just hold the line. The targets — CC 20, 300-line functions, nesting 6, 2,000-line files — are not a standard of virtue; they are the gate at which “getting worse” stops. What makes it survive in practice are three details: existing debt is frozen rather than liquidated, the enforcement switch is always read from main rather than from the branch being judged, and every rule has to prove on historical data that it will not produce false alarms. When generating code gets close to free, mechanisms like this get more valuable, not less — because the one thing getting more expensive is the time a person needs to read it.