Yesterday I wrote about a codebase with twenty ways to format money. The obvious follow-up question: what happened to them? Nineteen are gone. So are the date formatters I stopped counting at a dozen, and the rest of the inconsistencies. Clearing all of it took one day.
The day is the interesting part. Not the deletions, those were easy. The fact that the day existed at all. Cleaning up AI-generated code has a strange status in most planning: everyone agrees it is necessary, nobody ever schedules it, and so it lives permanently in the sprint that starts once things calm down.
Things do not calm down.
Why the cleanup never happens by itself
Tech debt used to get paid down as a side effect. You came back to a file you wrote, felt the friction, and tidied while you were in there. Refactor-as-you-go worked because the person who made the mess kept returning to the scene of it.
Generated code breaks that loop twice. The agent that wrote it will never be back: every session starts cold, with no memory and no shame. And you never fully inhabited the code yourself, so returning to it doesn't itch the way returning to your own mess does.
The numbers say this is structural, and it's the same evidence behind the churn you can already measure in git log. GitClear's analysis of 211 million changed lines found that moved code, the fingerprint of refactoring, fell from about a quarter of all changes in 2021 to under ten percent in 2024, while copy-pasted code rose past it for the first time on record.
Agents add. They do not consolidate. Nothing in the loop pushes back.
Which leaves one option: consolidation becomes a scheduled activity, like backups. A recurring slot, with a name, that ends in a merged diff.
How I run a consolidation pass
A small, fixed slot. Half a day, every week, same day. Small and regular beats the heroic cleanup quarter, because the quarter never survives contact with a roadmap, and by the time it would have started, the lava has hardened. A weekly half-day catches the debt while it is still warm enough to move. An inherited codebase earns a bigger first pass, mine took the whole day, but after that the weekly slot holds the line.
Start with a map, not a fix. The first half hour produces a shortlist, nothing else. A clone detector like jscpd for the structural near-duplicates. Grep by verb for the fragmented helpers. git log for the files being quietly rewritten every fortnight. The output is "money formatting, date handling, three competing API clients", ranked. Resist fixing anything during the survey. The survey is what keeps the pass honest.
Clean where you are about to build. If next week's work touches pricing, this week's pass consolidates money. Cleanup pays interest only when someone walks over the ground you cleared, so clear the ground in front of the work.
One job per pass. Pick one duplicated responsibility. Crown one implementation, migrate every call site to it, delete the rest. The deliverable is deletions: a good consolidation pass has a net negative diff, and "how many lines left" is a better score for the slot than "how many lines changed".
Lock it in before you stop. The last twenty minutes go into making the cleanup stick: a CLAUDE.md line naming the one blessed helper, a lint rule banning the old imports, a hook if you have one. Skip this and you are mopping with the tap running. Next month the agent will happily write formatter twenty-one.
What I let the agent clean up
The tool that caused the mess turns out to be genuinely good at the mopping. That's less ironic than it sounds. Consolidation is the rare task with exactly the shape agents handle well: the target is known, the behaviour is pinned, and the work is mechanical.
- The inventory. Finding the near-clones, listing every call site, and building the comparison table: which of the twenty rounds half-up, which truncates, which hard-codes the euro sign. This is hours of tedious reading that the agent does in minutes, and it only has to be right in ways I can check.
- Characterisation tests. Before anything moves, the agent writes tests that pin what each variant currently does, including the behaviour that is clearly a bug. Pin first, decide later. Without this net, consolidation is just churn with better intentions.
- The migration. Once I've picked the survivor, rewriting two hundred call sites to use it is precisely the kind of work I want an agent for: fully specified, verifiable per call site, boring beyond belief.
What I keep for myself
- Choosing the survivor. Four formatters rounded four different ways. That is three bugs and one intention, and nothing in the code says which is which. The agent will pick whichever behaviour occurs most often. The correct one lives in the invoice module, used in exactly one place. That call needs domain knowledge, and domain knowledge is the one thing the agent definitionally lacks.
- Deleting "dead" code. The agent cannot know what is dead. It doesn't see the reflection call, the runtime dispatch, the one cron job that imports the module twice a year. It will confidently delete revenue. Deletion decisions stay human.
- The boundaries. Two functions that look alike are not always one function. Deciding whether a similarity is an abstraction or a coincidence is design work, and design work done by pattern-matching is how the mess started.
The split fits in one line: the agent does the migration, I do the selection.
Package the pass as a skill
That split is teachable, and I got tired of re-typing it every week, so it lives in a Claude Code skill now. The step that matters is the hard stop: the skill forbids the agent from choosing the survivor, which turns the human gate from personal discipline into tooling. It deliberately hard-codes no tooling either, so the same file works on a TypeScript monorepo and an inherited PHP codebase alike. And because findings can be plausible and still wrong, the survey gets checked before I ever see it: a fresh subagent, blind to how the list was built, tries to refute each claimed near-clone. Lookalikes that turn out to serve different jobs die there instead of in my review. This is the actual file, at .claude/skills/consolidation-pass/SKILL.md:
---
name: consolidation-pass
description: Use when running a scheduled cleanup pass on duplicated
helpers or near-clones in any codebase. Surveys ONE duplicated
responsibility, has a fresh subagent verify every finding, pins
behaviour with tests, then STOPS for a human to pick the surviving
implementation before migrating anything.
---
# Consolidation pass
Run ONE pass, for ONE duplicated responsibility (money formatting, date
handling, an API client, retry logic). Never widen the scope mid-pass.
The pass must work in any language and any repo: discover the project's
own tooling first, never assume a specific tool.
## Step 0: Discover the project
Establish, from the repo itself:
- The language(s) and package manager (lockfiles, manifests).
- How tests run (CI config, Makefile, package scripts). Every later
step uses THIS command, never a guessed one.
- Which duplication tooling fits the ecosystem (`jscpd`, PMD CPD,
`pylint`'s duplicate-code check, a language-specific linter). If
nothing is available or installable, fall back to structural grep.
- The project's linter and how it is configured (needed for lock-in).
## Step 1: Survey (read-only)
- Run the clone detector over the source directories.
- Grep by verb, not by name: the naming patterns of the responsibility
under review (`format*`, `to*`, `display*`, `parse*`, `*Client`) plus
the primitives underneath (number/date formatting calls, date-library
imports, HTTP client constructors).
- Check import spread: how many different libraries or hand-rolled
versions serve the same job.
- Output ONE table: variant name, file, call-site count, behavioural
differences (rounding, timezone, locale, error handling, defaults).
- Do not fix, rename, or "quickly improve" anything in this step.
## Step 2: Verify the findings
- Spawn a fresh subagent per claimed clone group, blind to how the
survey built the list, and have it try to REFUTE the finding: do
these variants really do the same job, or do they only look alike?
- The subagent re-reads every variant and its call sites cold, and
checks the claimed behavioural differences and call-site counts.
- Drop refuted findings. Mark doubtful ones as doubtful in the table.
- Only verified findings reach the human in step 4.
## Step 3: Pin behaviour
- Write characterization tests for EVERY verified variant, in the
project's own test framework and conventions.
- Capture what each variant does today, including behaviour that looks
like a bug. Pin first, decide later.
- Run the full suite with the project's own test command. It must be
green before step 4.
## Step 4: STOP for the human
- Present the table and the behavioural differences.
- Ask which implementation survives, and which pinned behaviours are
bugs to fix versus intent to keep.
- Never choose a survivor yourself: the most common behaviour is not
necessarily the correct one.
## Step 5: Migrate
- Move the survivor to the project's canonical location for shared
code. Follow the existing structure; ask if there is none.
- Rewrite every call site to the survivor, in small commits, tests
green after each one.
- Where a pinned behaviour was declared a bug in step 4, fix it in the
survivor only, in its own commit, updating the test deliberately.
## Step 6: Delete
- Remove the losing variants and their now-redundant tests.
- The pass fails if the final diff is not net negative.
## Step 7: Lock in
- Add the survivor to the project's agent memory (CLAUDE.md or
equivalent): "there is exactly one X: `<path>`. Extend it, never
write a new one."
- Ban the old imports with a lint rule, using the project's own linter.
- Log the responsibilities you found but did not touch as candidates
for the next pass.
## Hard rules
- One responsibility per pass.
- Read-only until the step 4 question is answered.
- Never delete code the characterization tests do not cover.
- Use the project's own test command, linter, and conventions. Never
introduce new tooling without asking.Invoke it at the start of the weekly slot with /consolidation-pass, point it at one responsibility, and the pass runs the same way every week, on my own repos and on inherited ones.
How you know it is working
Three signals, all free. The clone count from the survey step goes down between passes. The two-week retention rate of new code goes up. And total line count starts drifting downward while features still ship, which is the healthiest thing a post-AI codebase can do.
There's a quieter fourth signal: the agent itself gets better. It stops finding five formatters to choose from, stops importing the wrong one, needs fewer attempts per change. Consolidated code is easier for the next session to work in, so every pass makes the next sprint's generated code slightly less wrong.
And if the slot keeps getting cancelled because feature pressure always wins, you've learned something too. That is a process problem wearing an AI costume, and no tooling will fix it for you.
The twenty formatters were the symptom of a loop with no consolidation step in it, and every codebase with an agent in the loop is growing its own twenty right now. The pass is how you put the step back.
One place to be right again. Twenty formatters down to one, the dates down to one, one day, on the calendar.
Next Friday's pass starts with an empty list. Its job is to keep it that way.