Six hundred lines or it does not ship

A 2,313-line orchestrator with a 1,073-line function is maintainable by a human with an IDE and unmaintainable by an agent. The refactor whose acceptance criterion was an agent behaviour, the CI guard that enforced it — and the fact that the guard is red as I write this.

Dex builds software autonomously by handing work to fresh Claude Code agents, one per stage. About six weeks in I tried to use it — or rather, an agent very like the ones it spawns — to make a change to Dex itself.

It went badly, and not in an interesting way. I asked for one new prerequisite check. The agent read the entire orchestrator, built a partial mental model of a 2,313-line file containing a 1,073-line function, and produced a change I did not trust enough to keep.

That is the whole problem, and it is worth being precise about it: that file was fine. I could edit it. I had an IDE, three years of context, and the muscle memory of having written every line of it. Nothing about it was unmaintainable in the way we normally mean.

It was unmaintainable by the thing I built the project around.

The numbers before

File Lines The specific problem
orchestrator.ts 2,313 contained a 1,073-line runLoop
useOrchestrator.ts 907 21 useState calls, a 25-case event switch
App.tsx 720 entry point doing view routing and run control

An agent asked to change one behaviour in any of those has to load the whole file to be sure it is not breaking something two hundred lines away. That consumes the context it needs for reasoning on recall instead — and it is exactly the failure mode the rest of Dex is designed to avoid. I had built a system premised on never letting an agent’s context fill with irrelevant material, and then wrote its engine as one very large file.

The acceptance criterion was a behaviour, not a metric

The thing I got right, and would do again, is how the refactor was specified. Not “split the orchestrator” or “reduce average file size”. This:

Given a fresh AI-agent session opening Dex, when asked to add a new prerequisite check, then the agent finds src/core/stages/prerequisites.ts, reads ≤600 lines, and adds one declarative entry without touching any other core file.

That is testable by anyone, on any afternoon, with no instrumentation: give an agent the task, give it nothing but the repo root, and watch whether it finds the right file by name on the first try. If it greps around, opens four files, and starts reading the orchestrator, the refactor is not done — regardless of what the line counts say.

Writing it that way did something I did not anticipate. It made naming a first-class deliverable. stages/prerequisites.ts is not a better name than orchestrator.ts because it is more descriptive. It is better because it is predictable from the task. An agent asked about prerequisites will look for a file about prerequisites, and the entire value of the split evaporates if the concept it is looking for lives in a file called core.ts or helpers.ts.

Two numbers, one script

The mechanical rules were: no file over 600 lines, no function over 120. Both arbitrary. Both chosen because a rule you can check beats a principle you can argue with, and because “roughly the amount of code an agent can hold while still reasoning about it” is a real constraint even if the exact number is not.

The check is forty lines of bash wired into npm test:

VIOLATIONS=$(
  find src -type f \( -name '*.ts' -o -name '*.tsx' \) -exec wc -l {} + \
    | awk '$2 != "total" && $1 > 600 { printf "%s %s\n", $1, $2 }'
)

The interesting part is not the check. It is the allow-list, and specifically the comments in it:

# Two perpetual exceptions only — all scheduled deferrals retired with their wave:
#   - main-loop.ts retired when A4.5 landed (824 → 573 LOC)
#   - useOrchestrator.ts retired when Wave B landed (910 → 511 LOC)
#   - App.tsx retired when Wave C-rest landed (717 → 506 LOC)
ALLOWLIST=(
  src/core/state.ts                       # perpetual — 01X-state-reconciliation
  src/core/agent/ClaudeAgentRunner.ts     # perpetual — TBD SDK-adapter spec
)

Every exception is either scheduled — with the wave that will retire it — or perpetual, with the spec number that would fix it. The three commented-out lines are graves. state.ts is exempt because the state model needs the reconciliation work I have not done, and saying so in the allow-list is more honest than a tidy list of two files with no explanation.

An allow-list without expiry dates is just a list of things you have decided not to care about.

Shipping it in waves

The refactor touched eight extracted core modules, five split renderer hooks, six service wrappers, seven component splits, and a new test layer. As one pull request that is unreviewable, and the risk is not that a reviewer misses something — it is that a behavioural regression introduced in week one surfaces in week three with twenty commits of unrelated churn on top of it.

So: waves. Each wave its own PR, each ending at a gate — typecheck, tests, a clean smoke run, a checkpoint smoke run, and a golden-trace diff.

That last one carried most of the weight, and it only exists because of the mock agent backend. Dex emits a stream of events as it runs; against a scripted backend that stream is deterministic. So you capture it before the wave, capture it after, and diff. Behaviour preservation stops being a claim in a PR description and becomes a file comparison.

I cannot overstate how much this changed the character of the work. A large refactor normally trades certainty for structure — you know it is better, you hope it is the same. A byte-identical event trace means you know.

The comment block that turned out to matter most

Every extracted module got a three-line header:

/**
 * What: Naming + classification for checkpoint tags, selected branches, and step parallelism.
 * Not: Does not touch git or filesystem; pure functions only. Jumps live in jumpTo.ts.
 * Deps: ../types.js (StepType).
 */

What and Deps are ordinary documentation. Not is the one that earns its place, and I did not expect that.

A filename tells an agent what might be inside. It cannot tell it what is deliberately absent, and absence is what causes the expensive failure: the agent that decides tags.ts looks like a reasonable home for a git call, adds one, and quietly recreates the coupling the split existed to remove. Not: does not touch git closes that off in nine words, at the top of the file, where something is guaranteed to read it.

It is also the single cheapest thing in this entire post to adopt. You do not need a refactor, a spec, or a CI guard. You need one line per file saying what the file refuses to do.

The guard is red right now

I ran it before publishing this, because it seemed dishonest not to:

FAIL: src/renderer/components/checkpoints/timelineLayout.ts (697 LOC > 600)
FAIL: src/renderer/components/checkpoints/TimelineGraph.tsx (614 LOC > 600)
FAIL: src/core/stages/main-loop.ts (643 LOC > 600)

Three files over the line, none of them on the allow-list. Two are the timeline canvas, which grew when branch management landed. The third is main-loop.ts, which went 824 → 573 during the refactor and has since drifted back to 643. The ratchet slipped, in the most ordinary way possible: subsequent features were built, each addition was individually reasonable, and nobody re-ran the guard as a gate.

I am leaving that in rather than fixing it before publishing, because the failure mode it illustrates is more useful than the win. A quality guard is only a ratchet if it blocks something. check:size is wired into npm test, which is exactly right, and it is worth nothing if the last few merges did not run npm test. The guard did its job — it told me. The process around it did not.

Is any of this actually good architecture?

The uncomfortable question, and I want to answer it honestly rather than defensively.

There is a real argument that this is bonsai — that I have pruned a codebase to fit a constraint which will be ten times looser in a year, and that “600 lines” will read like “640K ought to be enough” by the time anyone gets around to quoting it back at me.

I think that argument is right about the number and wrong about the practice.

“One concept per file, small enough to hold in one head, with an explicit statement of what it does not do” was good advice before agents existed. Every one of those properties helps a human reviewer, a new contributor, a debugger at 2am. What agents changed is not the advice — it is the price of ignoring it. A 2,313-line file costs a human twenty minutes of orientation, once, amortised over years of familiarity. It costs an agent that same orientation on every single task, forever, out of the budget it needed for the actual thinking.

So the constraint is real even if the number is arbitrary, and I would rather have an arbitrary enforced number than a correct unenforced principle. That is the whole argument for the script.

What I would change: I set the limit and built the guard, but I never made the guard load-bearing in the merge path. Six weeks later it is red in three places. The lesson there is not about file size at all.


That is the series. Dex is on GitHub — Electron, React, the Claude Agent SDK, no database, all state in files and git refs. Five posts covering the architecture, making it testable, the state bug that taught me the most, the features I deleted, and this one.

The through-line, if there is one: almost none of the hard problems in building an autonomous coding agent were about the model. They were about state, undo, determinism, and knowing what the system had actually done — the same problems as any distributed system, arriving in a costume.