Skip to content

Latest commit

 

History

History
883 lines (720 loc) · 47.1 KB

File metadata and controls

883 lines (720 loc) · 47.1 KB
Error in user YAML: (<unknown>): mapping values are not allowed in this context at line 2 column 788
---
name: codebase-cleanup
description: Full four-phase codebase cleanup — removes dead code (knip/vulture/cargo-udeps), consolidates shallow modules, reorganizes the folder structure and reshapes the functions that survived, running autonomously with atomic commits and rollback. Use WHENEVER the user mentions cleaning up, organizing, tidying or refactoring the project, or says "dar uma faxina" or "dá uma limpada"; talks about dead code, orphan files, unused dependencies, tech debt, god functions, messy folders or a bloated codebase; mentions duplicated code, "código duplicado" or "função repetida"; asks to "reorganiza essas pastas", or for an audit or health check; or says the repo "cresceu demais", "grew too big" or "tem coisa que ninguém usa". Also use for a single phase, or a single phase-1 category: "remove só as dependências não usadas", "só os órfãos", "só os dead exports". Do NOT use for formatting or lint, vulnerable dependency updates, bundle size optimization, database cleanup or git history rewriting.
---

Codebase Cleanup

Codebase cleanup in four sequential phases. The order is not negotiable:

Clear out the dead → decide the boundaries → move the files → reshape what is left.

In reverse order you reorganize garbage into pretty folders and then find out half of it should not exist. Each phase removes the noise that would get in the next one's way: the module graph is only trustworthy once the dead code is gone, folders only make sense once module boundaries have stabilized, and reshaping the inside of a function that phase 2 was about to consolidate away is work done twice.

Operating principle

Do as much as possible without asking. The user installed this skill to avoid typing fifteen commands — every unnecessary confirmation is a design failure, not a courtesy.

What makes this safe is not asking, it is the structure of the work: a dedicated branch, atomic commits per category, and a green gate (typecheck + tests) before every commit. If something breaks, the rollback is to discard whatever is not committed yet with git restore --staged --worktree . — without asking anyone, because the previous commit was already correct. Since every commit only happens on a green gate, HEAD is always a good state.

Restoring the tracked files to HEAD is a full rollback under two conditions, and both are the pipeline's job to guarantee: the tree was clean when the work started (Step 0 stops if it was not), and everything the step changed is staged before the gate runs — git restore --staged --worktree . undoes a staged new file, but leaves an untracked one behind. Hence the rule that repeats at every step: stage with pathspecs of this step's artifacts before the gategit add -- <paths this step produced or edited>, never git add -A or git add ., which would swallow unrelated untracked files (drafts, a local .env, secrets) into the category commit.

Never use git reset --hard or git clean. restore covers the case and survives environments with hooks that block destructive commands; git clean would additionally wipe untracked files that must survive — tool output, caches, local env files that never belonged to this cleanup.

If a security hook blocks a command from the protocol, do not work around it. Branch on which command was blocked:

  • Rollback (git restore --staged --worktree .) — abort the pipeline. The failed category's work is still in the tree; the next stage would fold it into another category and destroy atomic revert. Report: the branch name, that the tree is dirty with the failed step's changes, and the exact command the user must run by hand (git restore --staged --worktree ., or their environment's approved equivalent). Do not start the next category or folder.
  • Anything else (branch creation, file deletion, …) — record the pending item in CLEANUP_PROGRESS.md, hand the ready-to-run command to the user and move on to the next step that does not depend on it.

A guard is environment policy, not an obstacle.

Installed as a plugin, this skill ships guards of its own (hooks/hooks.jsonscripts/guard.sh) for exactly the five commands the rules above forbid: git reset --hard, git clean, push, commit on main, and whole-tree staging. They are awake only inside a run — a cleanup/ branch, or an untracked CLEANUP_PROGRESS.md — and they fail open. Hitting one of them means the step was about to break the protocol, so the answer is never to rephrase the command until it slips through: re-read the rule the guard names and follow it. Everything the protocol actually runs is allowed, the rollback and pathspec staging included.

There are two scheduled checkpoints in the pipeline (phase 2, choosing the consolidation candidate; phase 3 on GREEN, confirming the folder plan before any git mv), one conditional checkpoint in phase 4 that exists only when the queue has a tier B operation in it, and one conditional stop at Step 0, when the working tree is dirty before anything starts. Everything else runs on its own — phase 4 included, as long as it stays in tier A.


Rules that apply to the whole pipeline

/clear between phases. Not optional. Accumulated context from the previous phase degrades the judgment of the next one, and CLEANUP_PROGRESS.md exists so that this costs nothing.

Never merge two steps. "Configure knip and delete whatever it finds" is how you delete a route handler registered by convention that knip did not know about. Deleting an orphan and updating CLEANUP_PROGRESS.md in the same commit is the same mistake one step later: reverting the category takes the record with it. The separation is what prevents that.

A red gate means rollback, not repair. If typecheck or tests fail, the previous commit was already correct. Revert, record, move on. Trying to fix it turns a cleanup into an unsolicited debugging session. A gate that times out (exit 4) follows the same path: it is inconclusive, so it rolls back like a red one and is recorded as a timeout — never as a green.

Never force push, never commit on main. All the work lives on the cleanup branch. Merging is the user's decision, on their own schedule.

A report that indicts everything is a report about the configuration. If nearly every source file comes back unused, the likeliest cause is that no entry point was found: with no root, nothing is reachable and a healthy project reads as dead. Zero configuration hints does not rule this out — a project with no main and no plugin the tool recognizes produces no hints and no entries. Anchor the entry points and run again. Never delete against a report whose finding is the whole project.

Step 0 — Calibrate autonomy

Before anything else, measure the safety net. The autonomy level is a function of it, not a preference.

git rev-parse --is-inside-work-tree       # is there a repo at all?
git rev-parse --verify HEAD               # is there a commit to roll back to?
git status --porcelain                    # anything uncommitted?
git branch --show-current                 # current branch (empty = detached)

Read those three answers before running anything else:

No commit to roll back to — either --is-inside-work-tree fails, or it succeeds and --verify HEAD fails. The level is RED, diagnosis only, whatever the gate says afterwards. Without commits there is no rollback, and the whole safety argument of this skill rests on being able to return to a good HEAD. Say so in one line and produce a report.

The second half is not a corner case dressed up as one: git init and nothing else is a work tree with an unborn HEAD, where --is-inside-work-tree answers true, git status --porcelain prints nothing, and git restore --staged --worktree . answers fatal: could not resolve 'HEAD'. Every measurement says go, and the rollback the whole pipeline rests on does not exist. Asking only whether a repository exists answers a different question from the one this rule states.

git branch --show-current, never rev-parse --abbrev-ref HEAD: on an unborn HEAD the latter exits 128 while printing the literal HEAD, which is what a detached HEAD prints too — a state this skill treats differently.

Dirty working tree (git status --porcelain prints anything). Stop and ask. This is the conditional stop, and it is not negotiable: the rollback of this pipeline is git restore --staged --worktree ., which throws away uncommitted work in the tracked files — including work that was already there when you arrived. Present the three ways out and wait for the user to pick one:

  1. git stash push -u — parks everything and gives you a clean tree;
  2. commit the pending work first, on the current branch, and then start;
  3. abort the cleanup.

Do not choose on the user's behalf, do not stash "to be helpful", do not start anyway because "the diff looks small". Only an explicit answer unblocks the pipeline. If they pick 1 or 2, run git status --porcelain again and confirm it is empty before moving on.

Detached HEAD (git branch --show-current prints nothing, and --verify HEAD succeeded — that pair is the whole test). Not a blocker: record the current commit in CLEANUP_PROGRESS.md and create the cleanup branch normally — it will branch off that commit, which is exactly what you want. Note in the final report that the work started from a detached HEAD, so the user knows where the branch came from.

With a clean tree and a repo, run the baseline gate with "${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh" and classify. The script takes the project directory as an argument and defaults to the current one, and detects the stack from the root manifest, running typecheck and tests for each one it finds. Which manifests it recognises, how it picks the npm script to run, and why 124 and 137 are reserved are in references/gate.md — read it when a result surprises you, not before.

Classify by the [gate] checks=... line, which lists what actually ran, and not by the exit code alone: GREEN requires typecheck and test in the list; a partial list caps at YELLOW, and the script itself says so. Exit 0 = everything that ran passed; 1 = a check failed, which is RED — and the script stops at the first red, so there is no checks= line to classify by, only the RED at '<cmd>' line naming what broke; 2 = bad path (the argument is not a directory the script can enter, so nothing was checked — fix the path and rerun); 3 = no runnable check or some detected stack had no toolchain (PARTIAL — including in a polyglot repo where another stack passed); 4 = a check hit the watchdog (GATE_TIMEOUT, 900s per check by default, 0 disables). In the exit-3 cases, finish the gate by hand before classifying.

Exit 4 is an inconclusive gate, not a green one. A timed-out check says nothing about the code: treat it as red (rollback, record what timed out in CLEANUP_PROGRESS.md) and never promote it to GREEN. On the Step 0 baseline, exit 4 means the safety net could not be measured — report it and do not run autonomously.

Signal Level Behavior
Typecheck and tests pass GREEN Runs phase 1 in full without asking. Phase 2 and phase 3 each stop at their human checkpoint before mutating. Phase 4 runs tier A per covered target and stops at a checkpoint for tier B.
A partial net, or no test file in the stack YELLOW Runs phase 1 (deps and orphan files only, not exports). Does not run phase 2, phase 3 or phase 4; reports and stops.
A check fails, or no tests and no typecheck RED Diagnoses only. Does not delete, does not move, does not commit. May create the cleanup branch; does not commit CLEANUP_PROGRESS.md. Delivers a report.
No repository, or a repository with no commit RED Diagnoses only, regardless of the gate result — there is no HEAD to roll back to.

Take the quality baseline in the same breath, at every level including RED — the measurer is read-only, and this is the only chance to see the repo before the run changes it:

"${CLAUDE_PLUGIN_ROOT:-.}/scripts/metrics.sh" . > /tmp/metrics-before.txt

Copy the [metrics] lines into CLEANUP_PROGRESS.md as well, so the baseline survives a /clear and a swept temp directory. Exit 2 means the path was wrong and 3 that no source was recognized; neither is a gate result and neither changes the level — note it and move on. Without this file the final report has no delta to show.

Stack caps in references/other-stacks.md override the GREEN column. Python always confirms before deleting; JVM and Ruby are diagnosis / YELLOW by default; .NET is YELLOW by default for code (deps flagged by the compiler may go). Read that file before autonomous deletion — a GREEN gate on a stack whose graph is unreliable does not unlock code deletion.

checks=typecheck because the stack has no test file is YELLOW, not GREEN, even though the gate exits 0 — a suite that does not exist cannot pass. The script names the stack in the 'test' not counted line, and every stack whose suite could not be counted goes through it. Read those lines, not only checks=: in a polyglot repo one stack supplies the suite while another has none, so checks=typecheck,test can be describing two different stacks. The gate refuses to announce GREEN there and says why (a detected stack has no countable suite). Only the user promotes a cap, by pointing at the suite that lives somewhere the gate does not look; the skill never promotes itself.

A baseline that already fails is RED, not YELLOW. Exit 1 says a check broke, and a broken baseline leaves no way to tell what the cleanup broke from what was already broken. It also has nowhere to go: every commit here needs a green gate, so a run that starts red deletes, rolls back and commits nothing, category after category. Fix it or warn before touching anything — until then the deliverable is the diagnosis, naming the check that failed.

Announce the detected level in one line and move on. Do not ask permission for the level.

git checkout -b cleanup/$(date +%Y%m%d)

Two cleanups on the same day collide on that name, and checkout -b fails instead of overwriting anything. Resolve it without asking: if the existing branch has a CLEANUP_PROGRESS.md, it is an interrupted run of this same skill — check it out and resume from the file. Otherwise, suffix the name (-2, -3, and so on) until git rev-parse --verify says the ref is free. Never force, never delete the branch that is in the way.

Step 0.1 — Persistent state

At RED, skip this step. Do not create or commit CLEANUP_PROGRESS.md — diagnosis goes only into the final report. The cleanup branch from Step 0 may exist; nothing else is written into the repo.

On GREEN/YELLOW, create CLEANUP_PROGRESS.md at the root and commit it right away (chore: start cleanup log), before any other work. It is the only artifact that has to outlive a rollback: an untracked file survives git restore --staged --worktree . by accident, a committed one survives by design, and a staged one would be thrown away with the failed step. Keep it updated at the end of every step, in a commit of its own — never in the same commit as that step's source changes. Reverting a category must not take the log with it; the log is the memory of why the category happened. Stage the log with a pathspec (git add -- CLEANUP_PROGRESS.md), never git add -A.

Phases are separated by /clear (dirty context from one phase degrades the next), so this file is what allows resuming without the user re-explaining anything.

# Cleanup Progress
Branch: cleanup/20260808 · Level: GREEN · Started: 2026-08-08

- [x] Phase 1.1 — knip hints down to zero (3 rounds)
- [x] Phase 1.2 — production report ready
- [ ] Phase 1.3 — unused deps
- [ ] Phase 1.3 — orphan files
- [ ] Phase 1.3 — dead exports
...
## Preview (phase 1.3)
- unused deps: lodash.merge, left-pad (2)
- orphan files: src/legacy/unused.ts (1)
- dead exports: formatPercent @ src/utils.ts (1)  # GREEN only; omit on YELLOW
Recorded after 1.2 and before the first 1.3 category mutates. GREEN proceeds
without asking; YELLOW/cap and user scope already gate what may run.
## Coverage
### 1.4 audit
- reviewed: 18 · skipped: 2 · coverage_rate: 100% (20/20 units)
- skipped: src/vendor/big.ts — generated, out of scope
### 1.5 duplication
- reviewed: 12 · skipped: 1 · coverage_rate: 100% (13/13 pairs)
- skipped: src/generated/a.ts ↔ src/generated/b.ts — generated sources
Every unit from the scan ends `reviewed` or `skipped` with a reason. A rate
below 100% means the sweep is unfinished — finish or record the gap under
Decisions before closing the step.
## Decisions
- `lodash.merge` kept: used by a build script outside the graph
## Pending for the human
- (none)

When invoked, always read this file first. If it exists, resume where it stopped instead of starting over.

Step 0.2 — Single session or subagents

Full pipeline on a real repo: if the environment has subagents, run as orchestrator — each phase goes to an implementation subagent with disposable context, which is the same effect as /clear without depending on the user remembering. Single-phase request or small repo: single session, no orchestration.

Installed as a plugin, the delegations are declared and you call them by name instead of composing them each time:

Agent Scope
codebase-cleanup:cleanup-phase-1 phases 1 and 1.5
codebase-cleanup:cleanup-phase-2-survey the candidates, read-only
codebase-cleanup:cleanup-phase-2-impl the consolidation the user chose
codebase-cleanup:cleanup-phase-3-survey the structure plan, read-only
codebase-cleanup:cleanup-phase-3-impl the approved moves
codebase-cleanup:cleanup-phase-4-survey the reshaping queue, read-only
codebase-cleanup:cleanup-phase-4-impl tier A, plus the approved tier B

The split into survey and implementation is where the checkpoint lives: the survey agents cannot write at all, so the question reaches the user before anything changed, and the implementation agents only start after the answer. Phase 4 keeps the split for the same reason on a narrower question — tier B changes a name in the domain, and no test can vouch for that choice.

Without plugin agents the same contract is composed by hand, and it is the same four points:

  • the path to this skill (the subagent reads SKILL.md and follows it, with references/ and scripts/ alongside) and the path to the repo;
  • the instruction to read CLEANUP_PROGRESS.md before anything else;
  • the scope of one phase — step 1.5 belongs to the phase 1 delegation (the /clear at its end is the phase 1 → phase 2 boundary), and phase 2 becomes two delegations: the survey returns the candidates, the implementation only starts after the user's choice;
  • returning a summary of what it did, with CLEANUP_PROGRESS.md updated as the canonical state.

Step 0 (calibrating the level, creating the branch), the phase 2 checkpoint, the phase 3 checkpoint and the phase 4 tier B checkpoint stay with the orchestrator — a subagent does not talk to the user. Level and branch reach the subagents ready-made, through CLEANUP_PROGRESS.md.


PHASE 1 — Dead or alive

Goal: separate what is real code from what can disappear.

Do not use LLM judgment as the judge here. Use a tool, and interpret the result. "This file looks unused" is a guess; "no entry point reaches this file in the module graph" is a fact.

For non-JS/TS stacks, read references/other-stacks.md. The rest of this phase assumes JS/TS (step 1.5 names its own fallback for other stacks).

1.1 Configure knip until the hints reach zero

Run npx knip@6.32.0 with no config at all first (pin verified 2026-08-09; never bare npx knip). knip has plugins for the vast majority of the ecosystem's tools (Next, Vitest, ESLint, Playwright, and dozens of others) that read their configuration and work out entry points on their own — writing config before seeing what it already knows is wasted work.

Handle the configuration hints before looking at any finding. Hints mean knip could not resolve a dependency, plugin or entry file — that is, the graph is incomplete and every finding derived from it is suspect. A good chunk of the "unused files" list evaporates on its own once the hints are gone.

Iterate on your own: write knip.json, run again, adjust, repeat. Do not show every round to the user — report at the end how many rounds it took and how many findings disappeared.

Configuration details (entry, project, paths, monorepo, when to use each ignore*) are in references/knip-config.md. Read it before writing the file.

When the hints reach zero, commit knip.json on its own (chore: knip config), before deleting anything. It cost several rounds to get right and it is the input to every deletion that follows — if the first category fails its gate, the rollback must not take the config with it.

The rule that saves the most rework: do not use the ignore option. It does not exclude anything from the analysis, it only hides the report — creating a blind spot. If something shows up wrong, the fix is to teach the graph (entry, project, paths, plugin), not to silence the output.

1.2 Run in production mode

npx knip@6.32.0 --production --no-exit-code --reporter json > knip-report.json.tmp && mv knip-report.json.tmp knip-report.json

Write to a temp file and move only on success. A plain > knip-report.json truncates the file before knip even starts, so a crash leaves an empty report that 1.3 reads as "nothing to delete" — a silent failure. --no-exit-code is what makes the && usable: knip exits 1 whenever it finds issues, which is the normal case here, and 2 only when it actually failed.

Check the report before 1.3 consumes it: knip-report.json.tmp has to be gone, and knip-report.json has to be non-empty and parse as JSON. The temp file is the check that catches a failed run — when knip breaks, the && skips the mv and the previous report stays on disk, non-empty and perfectly parseable, so the content checks alone would wave a stale list through. A .tmp left behind means the run did not finish: delete it and fix knip instead of proceeding.

Production mode excludes tests and devDependencies automatically. That matters because a function imported only by a test is technically alive, but it is dead as far as the application is concerned — and that is exactly the code you want to find.

Never exclude tests with ignore to get the same effect.

1.3 Delete in atomic commits, one per category

Preview before the first mutation. After the 1.2 report is trustworthy and before any delete, install, or pathspec stage, write a ## Preview (phase 1.3) section into CLEANUP_PROGRESS.md: one bullet per category that this run will actually touch — categories the level forbids and categories the user scoped out (see Partial run below) are omitted here; the scoped-out ones still get a ## Decisions line. List the concrete deps / files / exports that the current report would remove (counts plus names; truncate long lists with … and N more after ~20 items). Commit that log update alone (chore: preview phase 1 deletions) — still no source change. On GREEN, proceed autonomously after the preview is recorded; do not ask for confirmation of the list. On YELLOW (or any stack cap that already forbids a category), the preview simply omits what the level will not touch — the cap is the confirmation. A run that mutates without a preview in the log skipped a named step; do not skip it to "save a commit".

Default scope. Run all three without asking (GREEN level) or the first two (YELLOW). Each one is: delete → (deps only: install / re-resolve) → stage pathspecs → gate → commit → log-only CLEANUP_PROGRESS.md → regenerate the report. For the gate, use "${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh" (it detects the stack and the package manager and runs typecheck + tests in the right order); if it exits with code 3, find the stack's own check commands — the package.json scripts, the tox env, the Makefile target, whatever this repo uses — and run them by hand.

Partial run by category. If the user asked for only one (or some) of the three categories — e.g. "remove só as dependências não usadas" — run exactly those, in the order below, and do not run the others. Record every skipped category under ## Decisions in CLEANUP_PROGRESS.md as out of scope for this run (orphan files: out of scope — user asked deps only), and repeat that list in the final report under "Failed / not done". A category the level already forbids (YELLOW × exports) is a level cap, not a user scope skip — say which.

Staging — pathspecs only. Staging before the gate is what makes the rollback complete. Stage only the paths this category produced or edited:

git add -- path/to/edited path/to/removed-file package.json package-lock.json
# deletions of tracked files: `git add -- path` (or `git rm` already staged them)

Never git add -A or git add .. Step 0's dirty-tree stop is still the precondition that nothing of the user's was already pending; pathspecs are what keep a draft or local .env that appears mid-run out of the commit.

Never touch the agent tooling directories. .claude/, .agents/ and .cursor/ hold instructions, skills and hooks — the tools working on the repo, not the code of the repo. This skill can be installed inside the project it is cleaning (.claude/skills/codebase-cleanup/), which is a documented route, so "unused file" there can mean the protocol you are executing. Measured on a real run before this rule existed: the Step 0 baseline reported files=12 loc=3270 with the longest function at gate.sh:329, in a repository whose entire source was two files of one line each. A baseline that counts the tool has no relation to the repository being cleaned, and every delta measured against it is noise. They are out of scope for deletion, for moves and for measurement — say so in the report if the user asks about them, and never propose them as targets.

Artifact hygiene (exclude + close). Before the first category, put knip-report.json and knip-report.json.tmp in the repo's exclude file — and, before the first coverage run in phase 4, the coverage artifacts of the stack's tool (coverage/, .coverage, lcov.info) alongside them — repo-local, so the user's .gitignore stays untouched. Ask git where it is rather than typing the path: git rev-parse --git-path info/exclude, because in a linked worktree, a submodule or a --separate-git-dir checkout .git is a file and appending to .git/info/exclude fails with Not a directory. Otherwise every category would be tempted to commit the report the previous one was read from, and the user reverting chore: remove unused deps would get a tool artifact back along with the dependencies.

That exclude only reaches untracked paths. If a previous run already committed knip-report.json, git keeps seeing it whatever the exclude says, and the regeneration below puts a fresh diff on disk. Check with git status --porcelain after writing the exclude lines; if the report still shows up, find out whose it is before touching it. A tool artifact left by an earlier run of this skill gets untracked in a commit of its own, before the first category. Anything else is the user's: leave it tracked, say so in the report, and never pathspec-add it into a category commit. On the way out, delete only report files that are artifacts of this run — never a knip-report.json the user tracks on purpose. Drop from the exclude only the lines you added, because the exclude makes a leftover invisible to git status and nobody would find it later. references/knip-config.md has the commands.

Also confirm the repo ignores node_modules before the deps category — a global .gitignore does not travel with the repo — and add it to info/exclude if it does not. Empty directories are not staged by pathspec adds either: git tracks files. The leftovers mkdir -p creates in phase 3 are a working-tree problem and are swept there.

1. unused deps        → "chore: remove unused deps"
2. orphan files       → "chore: remove orphan files"
3. dead exports       → "chore: remove dead exports"

Kept separate because if something breaks in production two weeks from now, the user needs to revert one commit — not a 400-file cleanup.

The log is not a category pathspec. After the category commit lands, update CLEANUP_PROGRESS.md and commit that update alone. Staging the log into chore: remove orphan files merges the record with the deletion — and the final report's git revert <sha> promise exists so undoing the work does not erase why it was done.

Unused deps: install after pruning the manifest. Removing an entry from package.json does not remove the package from node_modules, and the gate never installs — the resolver still finds the package on disk, typecheck and tests pass, and the break only surfaces on CI or on the next machine that installs from the pruned manifest. So, after editing the manifest and before staging pathspecs, run the package manager's plain install (non-JS/TS stacks: see the matching re-resolve / tidy / restore step in references/other-stacks.md):

npm install                          # npm
pnpm install --no-frozen-lockfile    # pnpm
yarn install --no-immutable          # yarn berry (yarn 1: yarn install)
bun install                          # bun

Plain, never the frozen form. --frozen-lockfile, --immutable and the CI defaults that turn them on refuse a lockfile that no longer matches the manifest — which is exactly the state a correct prune produces, so a good deletion would come back as a red gate for the wrong reason. npm ci is the same refusal under another name: it aborts with EUSAGE when package.json and package-lock.json are out of sync, and it never writes the lockfile, so it cannot get the prune out of this state either. The updated lockfile goes in this category's commit: it is what carries the prune to every other machine.

Regenerate the report between categories. After each category's commit, run the 1.2 command again — same hardened form, same file — and read the next category from the fresh report. The three feed each other: deleting orphan files kills exports the old report still saw as alive and frees deps it saw as used, while some exports it lists as dead live in files the previous category already removed. On a frozen report those second-order items survive the cleanup and a category tries to edit paths that no longer exist. The cost is one extra knip run per category that commits.

A category that is skipped (YELLOW does not run exports) or that fails its gate leaves no commit and nothing in the tree for knip to read differently, so there is nothing to regenerate from — keep the current report and go to the next category. The regeneration after the last category that did commit is the one the final report counts against.

If the gate fails: git restore --staged --worktree ., record the category as failed in CLEANUP_PROGRESS.md along with the error, and move on to the next category. If that restore is blocked by a hook, abort — see the operating principle; do not start the next category with a dirty tree. Do not try to fix a red gate — if typecheck broke, knip was wrong about that category, and the useful information is which category, not a patch. On the deps category, a successful restore brings back package.json and the lockfile but not node_modules: run the install again before starting orphan files.

Do not run knip --fix until the config has settled for two or three rounds with no surprises.

1.4 Full audit

With the garbage gone, the graph is clean and the audit becomes precise. Follow the protocol in references/audit.md — nine dimensions with file:line citations, severity and effort per finding, the coverage mandate for large sweeps, and the deliverable template for TECH_DEBT_AUDIT.md. The protocol is distilled from ksimback's tech-debt-audit (MIT; credited in the README), so no other skill needs to be installed for this step.

Always include a "looks bad but is fine" section — the calls you considered making and decided not to make, with the reason. If that section comes out empty, the audit did not look deep enough and you must go back.

Coverage mandate. Before closing 1.4, every unit from the upfront list in references/audit.md ends as reviewed or skipped with a one-line reason. On a normal repo that list is the intersection of the 20 largest and 20 hottest files, plus each top-level module outside that intersection; on a large repo it is one unit per module dispatched to a subagent. Dimension 9 and the README contradiction check are units too when they apply. Write the counts and coverage_rate under ## Coverage### 1.4 audit in CLEANUP_PROGRESS.md. A rate below 100% means the step is unfinished: finish the checklist or record the gap under ## Decisions before committing the audit. Cutting a large repo short without that record is the failure mode this mandate exists to catch.

Commit the report (GREEN/YELLOW; at RED nothing is committed — it goes into the final report instead). Update CLEANUP_PROGRESS.md.

1.5 Duplicate functions (report only)

Goal: functions with different names doing the same thing — duplicated intent. This runs here for a reason: earlier, the pair list would be full of code that 1.2–1.3 was about to delete anyway; later is too late, because these pairs are the best input the phase 2 survey will get. A 90% pair across two folders is a more obvious consolidation candidate than anything depth analysis surfaces alone.

For JS/TS the ladder is: similarity-ts (AST comparison per function) if it is on PATH, else npx fallow@3.14.0 dupes (pin verified 2026-08-09; never bare npx fallow); other stacks fall back to npx jscpd@5.0.14. Tools, flags, thresholds, the coverage mandate and the report format are in references/duplication.md — read it before running anything.

The churn rule. High similarity is not a verdict. Check whether the pair changes together in git history: pairs that co-change are real duplication and become phase 2 candidates; pairs that evolve independently are structural coincidence — two domains that will diverge, where abstracting early costs more than duplicating. Record those as "left alone on purpose" with the churn evidence.

Coverage mandate. Every candidate pair the detector emitted ends as reviewed (verdict filled: phase 2 candidate / left alone / no signal) or skipped with a reason (generated code, minified vendor, binary, tool noise). Write the counts and coverage_rate under ## Coverage### 1.5 duplication in CLEANUP_PROGRESS.md before the survey commit. A thin sample that ignores most of the detector output without skipped reasons is an incomplete sweep — same rule as 1.4.

Nothing is merged or deleted in this step — which of two duplicate functions survives is a naming-and-intent decision, and that belongs to the phase 2 checkpoint. Commit the report (chore: duplication survey) on GREEN/YELLOW; at RED nothing is committed and the pair table goes into the final report instead. Update CLEANUP_PROGRESS.md. Step 1.5 closes phase 1: tell the user to run /clear before phase 2.

No pairs is a result, and it gets committed too. "No duplicate pairs among the N files that survived phase 1" is what this step found, and the next session reads it to know the question was asked and answered rather than skipped. Do not treat an empty pair table as "not applicable" and move on: the commit is the only durable mark that phase 1 reached its end, and a judgement that lives in the chat is gone with the session. Record coverage as reviewed: 0 · skipped: 0 · coverage_rate: 100% (0/0 pairs) so the mandate is visibly satisfied, not omitted.


PHASE 2 — Consolidate modules

Goal: find out where "these three modules should be one".

Read references/phase-2-consolidation.md for the full survey protocol and the analysis vocabulary.

The irreducible checkpoint

Phase 2 is the irreducible domain checkpoint: consolidating modules changes responsibility boundaries, and that is a decision about the domain, not about the code. Green tests do not prove the new boundary is the right one — they prove the behavior did not change, which is a different thing. Phase 3 adds a second scheduled checkpoint (plan confirmation before any git mv); both are required on GREEN.

Keep the cost of the checkpoint to a minimum:

  1. Present at most 5 candidates ranked by confidence.
  2. For each one: which modules, why they are shallow, what it becomes afterwards, and the risk of touching it.
  3. Recommend one explicitly, with the reason in a single sentence.
  4. Ask one question: which one to go with. Do not run a multi-round interview.

If the user answers "go" or equivalent, proceed with your recommendation.

Decision criterion: the deletion test

Imagine deleting the module. If the complexity disappears, it was pass-through and should be consolidated. If the complexity reappears spread across N callers, it was paying for itself and should stay.

A shallow module is one whose interface is almost as complex as what it hides — the cost of learning the interface approaches the cost of simply reading the implementation. Consolidate clusters of small, tightly coupled modules, not large isolated files (a large file is a phase 1 problem, and a large module with a simple interface is exactly what you want).

Implementation

After the choice, run on your own: one module at a time, one commit per consolidation subject refactor(consolidate): <what>, and "${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh" once — after staging pathspecs of what this consolidation touched, right before the commit.

The subject is fixed because this phase leaves no other trace. Phase 1 is found by its chore: remove … commits, phase 3 by the renames in the history, phase 4 by refactor(<operation-id>). A consolidation is an edit and a deletion, which looks like any other edit and deletion — so without a subject the phase that moves responsibility boundaries is the one nobody can locate six months later, and git revert <sha> per category, which the final report promises, stops being answerable without reading diffs. refactor and not chore, because consolidating preserves behavior and touches source; scope consolidate and never a catalogued operation id, which is what keeps it distinguishable from phase 4. Do not gate between the intermediate steps: with the new interface in place and the callers not migrated yet, the build is red by construction, and a gate you expect to fail teaches nothing. The green that matters is the one at the end of the consolidation, which is exactly the state that gets committed. Same rollback protocol as phase 1 (including abort if a hook blocks the restore).

One candidate per session. Do not stack two — the second refactor inherits the dirty context of the first and the error rate goes up.

Update CLEANUP_PROGRESS.md. Recommend /clear before phase 3.


PHASE 3 — Folder structure

Goal: readable organization of directories and files.

It comes last because consolidating modules changes what the folders should be. Moving a file before deciding the boundary is guaranteed rework.

Read references/phase-3-structure.md for the organization patterns and how to choose between them.

Diagnosis first, moves after

Produce a phased plan before moving anything: a map of the current structure, circular dependencies, god modules, leaking abstractions, and the target structure with a rationale. Only then execute.

Checkpoint, then autonomous execution

YELLOW does not run phase 3 — stop after the phase 1 report (and after phase 2 only if the user later promotes the level). RED never reaches here.

GREEN: human checkpoint before any move. Present the phased plan from the diagnosis (current map → target structure → ordered moves, at most a short list). Recommend one first move in a single sentence. Ask one question: whether to proceed with that plan (or which slice). Do not start git mv until the user answers. If they say "go" or equivalent, execute the agreed plan; if they narrow the scope, record the rest as out of scope in CLEANUP_PROGRESS.md.

After the checkpoint, run the agreed plan without re-asking per folder. One folder per commit:

mkdir -p src/features/billing
git mv src/utils/format.ts src/features/billing/format.ts   # always git mv

git mv preserves history — rm + create destroys that file's git blame, which is precisely the information someone will want six months from now.

Prefer updating path aliases over rewriting 200 imports — when every consumer honors tsconfig.json paths. If the project uses @/features/*, moving a folder can be one line in the tsconfig instead of a 3,000-line diff. Bundler, test runner, linter and Docker build each resolve paths on their own, though, so check the "Do not forget" list in references/phase-3-structure.md before relying on the one-liner.

The move, the import or alias update and the CLAUDE.md update go in the same commit. Splitting them would put a commit that does not build in the history, and there is no gate that a half-done move can pass.

Stage with pathspecs of what this folder move touched (git add -- …), never git add -A, and then "${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh" at the end of each folder — typecheck alone misses what a move actually breaks (config paths, dynamic imports; the "Do not forget" list in references/phase-3-structure.md has the rest). Failed: git restore --staged --worktree ., record it, next folder. If that restore is blocked by a hook, abort the pipeline.

Phase 3 closes the same way phase 1 does: tell the user to run /clear before phase 4. The folder plan is the largest context the pipeline carries, and none of it helps decide whether a function is worth reshaping.


PHASE 4 — Local reshaping

Goal: the inside of the functions that survived the first three phases.

It comes last for the reason that orders everything else: reshaping a function inside a module phase 2 consolidates away, or inside a file phase 3 moves, is work done twice. What reaches phase 4 is code that is alive, whose boundary is settled and whose path is final.

Read references/phase-4-refactor.md for the protocol and references/refactoring-catalog.md for the operations themselves — eleven, in two tiers, with a worked before/after for each.

Targets are inherited, never invented. They come from the 1.4 audit (TECH_DEBT_AUDIT.md, dimensions 1 and 3) and from the 1.5 pairs phase 2 did not take, filtered by the churn ranking: only what is hot gets in the queue. A file that is big, ugly and cold belongs in "looks bad but is fine", with the numbers next to it.

The safety net is measured per target, not per repository. GREEN says the suite that exists passes; it says nothing about which lines it executes. To rewrite the inside of a function, what matters is whether that function is exercised — a repo at GREEN with 4% coverage authorizes nothing, because the green gate after the change proves only that the code still compiles, which is what a broken refactor proves too. Uncovered target: skip it and record it, or write a characterization test as its own commit first. There is no third exit.

Tier A runs on its own (extract-function, guard-clauses, named-constant, dead-branch, rename-local): mechanical, local, and the gate is real evidence for it. Tier B stops at a checkpoint (extract-class, domain-type, polymorphism, parameter-object, delegation, type-boundary): it picks an abstraction or a domain name, and a green test proves the behavior did not change — not that the choice was right. Same argument as phase 2, one altitude down. type-boundary is the typing and nothing else: adding schema validation at the boundary rejects input that used to pass, which is a behavior change and therefore not a refactor here — it leaves as a recommendation in the report, never as a refactor() commit.

One operation per commit, refactor(<operation-id>): <what>. Stage with pathspecs of what the operation touched (git add -- src/billing/invoice.ts), then "${CLAUDE_PLUGIN_ROOT:-.}/scripts/gate.sh", then commit. Unlike phase 2 there is no intermediate red state here: each operation is atomic, so a red gate means the operation was wrong, not that it was unfinished. Failed: git restore --staged --worktree ., record it, next target. If that restore is blocked by a hook, abort the pipeline.

Cap per session: 5 tier A operations, 1 tier B. A review limit, not a performance one: whoever merges this branch reads the diff. Hitting the cap is a normal ending — record what is still in the queue and let the next session take it. Update CLEANUP_PROGRESS.md after every operation, with the skipped targets and the reason they were skipped.

YELLOW does not run phase 4 — it reports the queue and stops. RED never reaches here. Phase 4 closes the pipeline: go to the final report.


Final report

Close hygiene first (GREEN/YELLOW, when the run wrote artifacts): delete knip-report.json.tmp if present, delete the coverage artifacts this run produced (coverage/, .coverage, lcov.info — phase 4 only, and only when the run created them), and delete knip-report.json only when it is an untracked tool artifact from this run (see Artifact hygiene above) — if the user tracks it on purpose, leave the tracked file alone; drop from info/exclude only the lines this run added; leave CLEANUP_PROGRESS.md and TECH_DEBT_AUDIT.md committed unless the user asked to remove them — they are the durable record, not tool noise. Say in the summary what was cleaned.

When wrapping up (or when interrupted), deliver a summary carrying: the branch, the level and the commit count; one row per phase with what that phase actually removed; the quality delta against the Step 0 baseline, measured by running "${CLAUDE_PLUGIN_ROOT:-.}/scripts/metrics.sh" again and diffing it; how to revert (git revert <sha> — commits are atomic per category); what failed or was left undone, and why; residual risks from the 1.4 audit still open, each with a severity (Critical / High / Medium / Low); and what is pending the user's decision. The template and the rules for filling each part are in references/final-report.md.

When the run produced a cleanup/ branch, add one short Optional next step line pointing at references/complementarity-opencodereview.md — OCR review/delegate on that branch is optional QA for logical regressions the gate may miss. Never install OCR, never treat it as required, never vendor its code or prompts.

If the level was RED, the report is diagnosis only: list what you would do and what needs to exist or be fixed (tests, typecheck, a baseline that passes) to make it possible. Where the gap is the suite itself, name the critical path and propose the minimal characterization test that would pin it down — the one piece of work that turns this repo into one the pipeline can act on. Proposing it is not promoting the level: the user decides, and the next run measures again. No CLEANUP_PROGRESS.md commit on RED.