The codebase is the agent's execution environment

I wrote 200 lines of rules. The agent ignored all of them.

That was the lesson that reorganized how I build this repo. An LLM doesn’t read your CONTRIBUTING.md the way a new hire does. It optimizes for the next green check. Advisory prose is a suggestion it can rationalize past; a failing build is a fact it can’t. So I stopped writing rules and started building an environment.

My CLAUDE.md is 64 lines now. Everything that actually matters — taste, security, architecture — is a deterministic check that pushes back the instant the agent gets it wrong. The split that organizes the whole codebase is simple: rules that need enforcement go into lint, types, tests, CI, and hooks; rules that need judgment go into skill files. Because an agent will talk its way past a paragraph, but it cannot talk its way past a red build.

Here’s what that buys you, in five moves.

1. Enforce in infrastructure, not prose

The repo’s prek pre-commit and pre-push stack wires up mypy, ty, ruff, biome, eslint, ast-grep, vulture, knip, complexipy, and a 90% coverage gate. This isn’t CI theater. It’s the agent’s reward signal. A model trained to make checks pass needs checks that fail precisely and locally, the moment the mistake is made — not a polite note in a doc it skimmed on load.

Keeping the prose short is part of the design, not a compromise. A 64-line CLAUDE.md the agent actually internalizes beats a 600-line one it pattern-matches against. The long version of every rule lives one Read away, in a skill, fetched only when it’s relevant.

2. The cheapest cheat is a suppression — so I banned it

Watch an agent hit a type error it can’t immediately solve. Nine times out of ten it reaches for # type: ignore. Problem “solved” — and one of my guardrails quietly deleted.

So scripts/ban-new-suppressions.py makes that the one move it can’t make. Any newly-added noqa, type: ignore, biome-ignore, eslint-disable, ts-expect-error, ast-grep-ignore — 17 markers across Python and TypeScript — fails the push. It even parses biome.json, the ruff config, and eslint.config.js structurally, so you can’t sneak in a config-level disable either.

It’s diff-scoped and append-only: it only flags what your diff adds, never legacy debt you didn’t introduce, and every legitimate exception is pinned to a named upstream bug (pydantic @computed_field → mypy#1362). The set of suppressions in the repo can only ever shrink.

That’s the asymmetry the whole harness is built around: the agent can tighten my invariants, but it can’t loosen them.

3. Verify the verifier

A guardrail the agent can route around is worse than no guardrail — because the agent trusts the green check as its correctness oracle, and a false pass is a confident lie.

So the gate has its own adversarial test. test-ban-new-suppressions.py runs the suppression-ban inside throwaway git repos against six named bypass vectors. One of them exists because git push exports GIT_DIR, which — without a scrub — makes every test subprocess target the outer repo and false-pass every case. I know, because it did, once.

The same instinct shows up in the coverage layer, which carries a tripwire that hard-fails when merged coverage has too few entries — “the CRAP gate would be a no-op → exit 1” — instead of silently passing when something upstream is miswired. The enforcement layer is load-bearing code. I test it like it is.

4. Every red light ships its own fix

When an agent hits a lint with no prescribed fix, it improvises the easiest escape: an as cast, a guard stuffed inside an effect, a suppression. So I never ship a ban without the fix attached.

The eslint rule that forbids useEffect doesn’t just say no. Its failure message names the four replacements — derive inline, useQuery, an event handler, or useMountEffect — and ends with See .claude/skills/no-use-effect/SKILL.md. The rule even carves out one sanctioned escape hatch, a five-line useMountEffect, so there’s a legitimate path that isn’t a suppression.

Enforcement and explanation are split — a fast deterministic gate, a judgment-rich skill — then wired together by the error string itself. The red light collapses the agent’s search space down to the one fix I actually endorse, so its first attempt passes review instead of landing green-by-kludge.

5. The harness is untouchable

The most dangerous moment in agentic coding is when a test finds a real bug — because the agent’s shortest path back to green is to weaken the test. Lower –max-examples. Exclude the failing operation. Add a suppression.

My OpenAPI fuzzer (schemathesis, seed=0 for determinism) generates the null, boundary, and absent-field inputs the agent never thought to handle. When it fails, the chief-property-testing-officer skill draws bright lines: “The test harness is untouchable. No narrowing the fuzzer’s input space, no lowering –max-examples, no schema mutation.” And it restates the objective so the agent can’t lose the plot: “cleaner, clearer, more correct code — not a green CI by way of kludges, epicycles, and regressions.” A contract can only be relaxed as data with an issue number attached, never as a quietly weaker test.

The environment came first

The pieces compound when I’m not watching. Touch a backend model and a pre-commit hook regenerates the typed frontend client — CI fails on a stale git diff, so the two halves of the stack physically can’t drift. An @claude-revisit 2wk comment becomes a tracking issue a daily cron fires on. An auto-simplify bot rewrites every push, guarded against looping on its own commits. Commits stay bisectable, so even a long, drifting session is partially salvageable instead of wholesale-rejected.

None of this came from theory. It came from doing the work by hand, watching exactly where the agent cut the corner, and turning that one spot into a gate. My own notes put it bluntly: don’t write CLAUDE.md until you’ve done the thing manually and seen what actually works.

The rules came last. The environment came first.