Skip to main content
Technology & EngineeringAgentic Loops361 lines

migration-loop

A scout → pipeline → gate-each → residue-loop pattern for a large MECHANICAL change

Quick Summary32 lines
A repeatable loop for a large, mechanical change — `oldApi()` → `newApi()`, `react-router@5`
→ `@6`, `moment` → `dayjs`, a path-alias rewrite. The naïve approach (one agent, one giant
diff) fails two ways: it overflows context at ~50 files, and it produces a 400-file diff no
human will ever review honestly. This loop instead **discovers every call-site up front,

## Key Points

- **Scout before you orchestrate, not before you task.** You don't need to understand each
- **Prefer a deterministic codemod; reserve the agent for judgment.** A jscodeshift/ts-morph
- **Idempotency is what makes the residue loop safe.** Re-running the codemod over an
- **The gate is per-item, and it's two checks, not one.** A file that *compiles* is not a
- **Failures are the next round's input, not a dead end.** Every site that fails the gate
- **The long tail is the job.** 5% of sites will be 50% of the effort: the re-exported
- **`if (!transform(file)) … return` (idempotency).** A file the codemod no-op'd is *already
- **`git checkout -- <file>` on failure.** A file that failed the gate must be returned to
1. Read the file. Find the getUser(...) call the codemod could not safely rewrite.
2. Rewrite it to the new options-object shape, preserving exact argument semantics
3. Touch ONLY this call and its imports. Do NOT refactor surrounding code.
4. Your edit MUST pass: tsc --noEmit clean AND the targeted test. If you cannot make it

## Quick Example

```bash
npx tsc --noEmit                              # (1) the file's types still resolve
npx vitest run --changed --reporter=dot       # (2) the changed surface still behaves
```

```bash
npx tsc --noEmit && npx vitest run            # full typecheck + full suite, 0 / 0
```
skilldb get agentic-loops-skills/migration-loopFull skill: 361 lines
Paste into your CLAUDE.md or agent config

Migration Loop

A repeatable loop for a large, mechanical change — oldApi()newApi(), react-router@5@6, momentdayjs, a path-alias rewrite. The naïve approach (one agent, one giant diff) fails two ways: it overflows context at ~50 files, and it produces a 400-file diff no human will ever review honestly. This loop instead discovers every call-site up front, transforms each one independently behind a per-file gate, and pumps the failures back through as the next round's input — so the work-list provably shrinks to zero.

The core move: a codemod is dumb on purpose. It nails the 95% boring cases deterministically and idempotently; the 5% weird call-sites — the ones with a spread argument, a re-exported alias, a // @ts-expect-error riding along — fail the gate and land in the residue, where an agent (which has judgment a codemod lacks) handles them one at a time. Residue → 0 is the finish line.

It was run against a 412-site getUser(id)getUser({ id }) signature change: 412 → 38 → 4 → 0 across three rounds, with tsc 0 on every file and the changed surface re-tested.


1. The philosophy

  • Scout before you orchestrate, not before you task. You don't need to understand each call-site to start; you need the list of them to fan out. Discovery (grep/AST) is cheap and is the only thing the orchestrator needs before round 1. The shape of any individual site is discovered by the transform, not by you.
  • Prefer a deterministic codemod; reserve the agent for judgment. A jscodeshift/ts-morph pass on 412 sites is seconds and is reviewable as code (you review the codemod, not 412 diffs). An agent per site is slow, non-deterministic, and re-introduces variance into a mechanical task. Use the agent only where the codemod would have to guess.
  • Idempotency is what makes the residue loop safe. Re-running the codemod over an already-migrated file must be a no-op (match the old shape only; if it's already new, skip). Without this, round 2 re-mangles round 1's successes and the loop never converges.
  • The gate is per-item, and it's two checks, not one. A file that compiles is not a file that works. tsc proves the rename is type-valid; it does not prove you swapped argument order correctly. You need a runtime/test check on the changed surface too.
  • Failures are the next round's input, not a dead end. Every site that fails the gate goes to the residue list with its error. The residue is the round-2 work-list. A migration is done when the residue is empty — that's a measurable, honest stop.
  • The long tail is the job. 5% of sites will be 50% of the effort: the re-exported helper, the dynamically-built call, the one in a .d.ts. Don't make the codemod clever enough to "handle" these — make it refuse them cleanly so the agent gets them.

2. The loop (one round)

┌──────────────────────────────────────────────────────────────────────┐
│  ROUND INPUT: a work-list of call-sites (round 1 = scout output;     │
│               round N = the previous round's RESIDUE)                 │
│                                                                       │
│   pipeline(sites):  each site flows independently, no barrier ─┐     │
│     1. TRANSFORM   codemod the single file (or agent if routed) │     │
│     2. GATE        tsc --noEmit <file> == 0                      │     │
│                    AND targeted test of the changed surface == 0 │     │
│     3a. PASS  →  keep the edit                                   │     │
│     3b. FAIL  →  `git checkout -- <file>`, push {site,error}     │     │
│                  to RESIDUE (do NOT leave a half-edit on disk)   │     │
└─────────────────────────────────────────────┬────────────────────────┘
                                              │
              RESIDUE empty? ── no ──► feed RESIDUE in as next round
                    │ yes
                    ▼
         WHOLE-REPO GATE (tsc + full test build) → ONE commit per round

The pipeline is the point: site #200 doesn't wait for site #199's gate to finish before its transform starts. Each file runs transform→verify on its own; round time is the slowest single file, not the sum of every stage across every file.


3. Component A — the scout (discover the work-list inline)

Don't guess the count, don't trust a stale TODO. Enumerate every call-site now, because the list size is your round-1 input and your convergence denominator. ripgrep for the cheap pass; fall back to an AST query when the textual match is ambiguous (e.g. getUser the method vs. getUser the variable).

scout.mjs:

import { execFileSync } from "node:child_process";

// Textual pass: every file that mentions the old symbol. -l = files only, --json for ranges.
function scout(pattern) {
  const out = execFileSync("rg", [
    "--type", "ts", "--type", "tsx",
    "-l",                       // file list, not lines
    "-g", "!**/*.d.ts",         // route declaration files to the agent residue, not the codemod
    "-g", "!**/node_modules/**",
    pattern,
  ], { encoding: "utf8" });
  return out.split("\n").filter(Boolean);
}

const sites = scout("\\bgetUser\\s*\\(");   // word-boundary: don't match `getUserName(`
console.log(`SCOUT: ${sites.length} candidate files`);   // → "SCOUT: 412 candidate files"

No silent caps. If the scout finds 412 files, every round must account for all 412 (migrated + residue + intentionally-excluded). The moment a file vanishes from the accounting — dropped by a glob you forgot, swallowed by a head -n, skipped by a silent try/catch — you have a migration that looks done and isn't. Log the count at every stage; the sum must hold.


4. Component B — the pipeline (transform → gate-each → residue)

pipeline(files, transform, verify) runs each file as an independent transform→verify flow and partitions the results into done and residue. The transform is the codemod for the common case; the gate is the two-check per-file gate.

import { execFileSync } from "node:child_process";

const sh = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", stdio: "pipe" });

// TRANSFORM: deterministic codemod on ONE file. Returns true if it changed anything.
function transform(file) {
  const before = sh("git", ["hash-object", file]).trim();
  // jscodeshift in-place, single file, our codemod. -p prints nothing; it edits on disk.
  sh("npx", ["jscodeshift", "-t", "codemods/getUser-to-options.js", file, "--parser=tsx"]);
  const after = sh("git", ["hash-object", file]).trim();
  return before !== after;             // false = codemod no-op'd (idempotent) → nothing to gate
}

// GATE: two checks. Compiles AND the changed surface still behaves.
function verify(file) {
  // (1) does THIS file (and its type graph) typecheck? tsc has no per-file mode, so we run
  //     the project build but key the pass/fail on whether NEW errors mention this file.
  const tsc = run(["npx", "tsc", "--noEmit"]);            // {code, out}
  if (tsc.out.includes(file)) return { ok: false, why: `tsc: ${firstError(tsc.out, file)}` };
  // (2) compiles ≠ correct. Run the targeted test for the surface this file touches.
  const test = run(["npx", "vitest", "run", "--changed", "--reporter=dot"]);
  if (test.code !== 0) return { ok: false, why: `test: ${tailLines(test.out, 3)}` };
  return { ok: true };
}

async function migrate(files, { transform, verify }) {
  const done = [], residue = [];
  await pipeline(files, async (file) => {       // pipeline: per-file flow, no cross-file barrier
    if (!transform(file)) { done.push({ file, note: "already-migrated (no-op)" }); return; }
    const g = verify(file);
    if (g.ok) { done.push({ file }); }
    else {
      sh("git", ["checkout", "--", file]);      // REVERT — never leave a half-applied edit on disk
      residue.push({ file, error: g.why });     // failure becomes next round's input
    }
  });
  return { done, residue };
}

Two non-obvious lines carry the design:

  • if (!transform(file)) … return (idempotency). A file the codemod no-op'd is already migrated. It's done, not a failure — this is exactly why re-running the whole loop is safe.
  • git checkout -- <file> on failure. A file that failed the gate must be returned to its original state on disk, not left half-edited. Otherwise round 2 transforms a corrupt starting point, and the residue compounds instead of converging.

Worktree isolation — only when agents write in parallel

The codemod pass above is single-writer per file (each transform(file) owns that file; the whole-repo tsc is a read). That needs no worktree isolation — and worktrees are expensive (a full checkout + node_modules link per agent). Add them only for the residue stage if you fan out agents that mutate files concurrently, where two agents could touch the same shared import. Rule of thumb: read-or-single-writer → no worktrees; concurrent multi-writer → one worktree per agent.


5. Component C — the codemod and its escape hatch

Keep the codemod dumb and refusing. It matches the old call shape only, rewrites the boring case, and bails (leaving the file untouched) on anything it can't prove safe — so that site fails the gate and routes to the agent residue. Do not teach the codemod to guess.

codemods/getUser-to-options.js (jscodeshift):

module.exports = function (file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root.find(j.CallExpression, { callee: { name: "getUser" } }).forEach((p) => {
    const args = p.node.arguments;

    // REFUSE the long tail — don't guess. These fall to the agent via a gate failure.
    if (args.length !== 1) return;                       // getUser() or getUser(id, opts)
    if (args[0].type === "SpreadElement") return;        // getUser(...rest)
    if (args[0].type === "ObjectExpression") return;     // ALREADY migrated → idempotent no-op

    // The boring 95%: getUser(id)  →  getUser({ id })
    p.node.arguments = [j.objectExpression([
      j.property("init", j.identifier("id"), args[0]),
    ])];
  });

  return root.toSource({ quote: "single" });
};

The ObjectExpression early-return is the idempotency guarantee in one line: a site that's already { id } is left alone, so re-running is a no-op. The other early-returns are the escape hatch: the codemod would rather refuse than corrupt, and refusal is exactly what sends a site to the agent.

The agent, for residue only

When the residue lands on an agent (round 2+), it gets one site at a time, the same two-check gate, and a contract that mirrors the codemod's intent:

You are migrating ONE call-site that the codemod refused. File: <file>. Error: <residue.error>.
  1. Read the file. Find the getUser(...) call the codemod could not safely rewrite.
  2. Rewrite it to the new options-object shape, preserving exact argument semantics
     (a spread → reconstruct the object; a re-exported alias → follow it).
  3. Touch ONLY this call and its imports. Do NOT refactor surrounding code.
  4. Your edit MUST pass: tsc --noEmit clean AND the targeted test. If you cannot make it
     pass safely, leave the file unchanged and report changed=false with the blocker.

6. The gate (non-negotiable)

Per item, before a site is counted doneboth checks, every time:

npx tsc --noEmit                              # (1) the file's types still resolve
npx vitest run --changed --reporter=dot       # (2) the changed surface still behaves

And once per round, before the commit — the whole-repo gate:

npx tsc --noEmit && npx vitest run            # full typecheck + full suite, 0 / 0

Why both, not just tsc: the canonical migration bug is the change that compiles and is wronggetUser(id)getUser({ name: id }) typechecks perfectly (both are string) and ships a silent data bug. Only a runtime/test assertion on the changed surface catches it. A site that doesn't pass both checks didn't migrate — it's residue.


7. The residue loop (re-run until dry)

The migration isn't "run the codemod once." It's: feed the previous round's residue back in as the next round's work-list, and repeat until the residue is empty — not until a fixed number of rounds.

let worklist = scout("\\bgetUser\\s*\\(");      // round 1 = the whole scout
let round = 0, log = [];

while (worklist.length) {
  round++;
  const useAgent = round > 1;                   // round 1 = codemod; residue → agent
  const { done, residue } = await migrate(worklist, {
    transform: useAgent ? agentTransform : transform,
    verify,
  });
  log.push({ round, in: worklist.length, done: done.length, residue: residue.length });
  if (residue.length === worklist.length) {     // SAFETY: a round that fixed nothing = stuck
    throw new Error(`stalled at round ${round}: ${residue.length} sites, none resolved`);
  }
  worklist = residue.map(r => r.file);          // residue IS the next round
}

The residue.length === worklist.length guard is the anti-busywork tripwire: if a round resolves zero sites, the loop is stuck (a codemod bug, a genuinely-blocked site, a flaky test) and must stop and surface, not spin. Convergence requires strict shrinkage.


8. When to stop (convergence)

The residue count is the signal, and the target is 0. From the getUser run:

RoundInMigratedResidueWhat the residue was
141237438codemod refused: spreads, 2-arg calls, .d.ts, re-exports
238344agent: 3 spread-reconstructs, 1 dynamic obj[fn]() call
3440agent: a re-exported alias + a test mock that shadowed the real fn

412 → 38 → 4 → 0. Stop when residue is 0 and the whole-repo gate is green. If a site genuinely cannot migrate (a vendored file, a deliberate legacy shim), it doesn't sit in the residue forever — it goes on an explicit exclude list with a reason, and the accounting becomes migrated + excluded = scouted. An empty residue with an unexplained gap in the count is not convergence; it's a silent cap you haven't found yet.


9. How to re-run it

# 1. Scout — re-enumerate every call-site (the count is your denominator).
node scout.mjs                      # → "SCOUT: N candidate files"

# 2. Dry-run the codemod on a COPY to eyeball the boring case before trusting it.
git stash || true
npx jscodeshift -t codemods/getUser-to-options.js src --parser=tsx --dry --print | head -40

# 3. Run the loop: codemod round 1, agent on the residue, until residue == 0.
node migrate-loop.mjs               # logs: round / in / migrated / residue each round

# 4. Whole-repo gate, then ONE commit per round.
npx tsc --noEmit && npx vitest run
git add -A && git commit -m "refactor(api): migrate getUser(id) → getUser({id}) — round N (M sites)"

Because the codemod is idempotent, re-running step 3 from scratch is always safe: already- migrated sites no-op, and only the true residue does work.


10. Why it works

  • Scout-then-pipeline turns an unbounded refactor into a bounded one. You start with a number (412) and a denominator, so "done" is measurable, not a vibe.
  • The codemod does the volume; the agent does the judgment. 95% deterministic and reviewable-as-code, 5% routed to the one tool that can reason about the weird cases — and never the reverse.
  • The two-check gate makes per-file autonomy safe. Hundreds of independent edits are fine when each must typecheck and pass a runtime test on the surface it changed before it counts.
  • The residue loop makes it converge instead of spin. Failures become the next work-list, the count strictly shrinks, the stall-guard kills busywork, and the migration ends — at residue 0 — instead of in a 400-file diff nobody reviewed.

*Generated from a 412-site getUser(id) → getUser({id}) signature migration (3 rounds, codemod

  • agent residue, tsc 0 / tests green). Reusable for any large mechanical change — API rename, framework upgrade, library swap, import rewrite.*

Install this skill directly: skilldb add agentic-loops-skills

Get CLI access →