Automate Multi-File Refactors with Claude Code Subagents
Build a scout-and-surgeon subagent pipeline behind a /refactor command that cannot touch your test suite.
What you'll build
A two-subagent refactor pipeline in Claude Code: a read-only scout that inventories every call site, a surgeon that edits one file at a time, and a /refactor slash command that drives both behind a permission boundary that can't touch your tests.
Prerequisites
Verified on Claude Code 2.1.263 (macOS/Linux/WSL) and Node.js 20.19.6. Node 22+ if you install Claude Code through npm.
claude --version # 2.1.263 (Claude Code)
node --version # v20.19.6 or later
You need a Claude Pro, Max, Team, Enterprise, or Console account. The Edit-rule startup warning in Troubleshooting requires 2.1.210 or later.
1. Seed a demo repo
Three modules log with string concatenation. A shared logger exists but nobody calls it. The test encodes the target state, so it fails until the refactor lands.
mkdir refactor-demo && cd refactor-demo && git init -q
mkdir -p src/lib test
echo '{"name":"refactor-demo","type":"module","private":true}' > package.json
cat > src/lib/logger.js <<'EOF'
export const records = [];
export const logger = {
info(event, fields = {}) {
records.push({ event, ...fields });
},
};
EOF
cat > src/checkout.js <<'EOF'
export function checkout(cartId, total) {
console.log("checkout " + cartId + " total=" + total);
return { cartId, total };
}
EOF
cat > src/orders.js <<'EOF'
export function placeOrder(orderId, cartId) {
console.log("order placed " + orderId + " from " + cartId);
return { orderId, cartId };
}
EOF
cat > src/users.js <<'EOF'
export function createUser(userId, email) {
console.log("user created " + userId + " <" + email + ">");
return { userId, email };
}
EOF
cat > test/logging.test.js <<'EOF'
import test from "node:test";
import assert from "node:assert/strict";
import { records } from "../src/lib/logger.js";
import { checkout } from "../src/checkout.js";
import { placeOrder } from "../src/orders.js";
import { createUser } from "../src/users.js";
test("every module logs through the shared logger", () => {
records.length = 0;
checkout("cart_1", 42);
placeOrder("ord_1", "cart_1");
createUser("u_1", "a@example.com");
assert.deepEqual(
records.map((r) => r.event),
["checkout", "order_placed", "user_created"],
);
});
EOF
git add -A && git commit -qm "seed"
2. Write the read-only scout
Subagents are Markdown files with YAML frontmatter in .claude/agents/. Omitting tools inherits everything, so name them explicitly; that's what keeps this one incapable of writing.
mkdir -p .claude/agents
cat > .claude/agents/refactor-scout.md <<'EOF'
---
name: refactor-scout
description: Inventories every call site for a refactor and returns a file-by-file plan. Read-only.
tools: Read, Grep, Glob
model: sonnet
color: cyan
---
You map refactors. You never edit files.
Given a scope glob and a goal:
1. Glob the scope, then Grep for every construct the goal touches.
2. Read each match with enough surrounding lines to see the call shape.
3. Return a Markdown table with columns: file, line, current call, replacement, risk.
4. End with a line starting `BLOCKERS:` listing anything ambiguous — dynamic call
sites, re-exports, generated files — or `BLOCKERS: NONE`.
Report only. Do not propose a diff.
EOF
3. Write the surgeon
One file per invocation. Narrow, stateless tasks can run in parallel without stepping on each other, and a bad edit stays one file wide.
cat > .claude/agents/refactor-surgeon.md <<'EOF'
---
name: refactor-surgeon
description: Applies one file's rows from an approved refactor plan. Use after refactor-scout.
tools: Read, Edit, Grep, Glob
disallowedTools: Bash
model: sonnet
color: orange
---
You apply exactly one file's edits from a plan you are handed.
- Edit only the file named in your task. If the plan implies changes elsewhere,
report that instead of editing.
- Keep it minimal: no reformatting, no renames beyond the plan, no new dependencies.
- Add whatever import the replacement needs, with the correct relative path.
- Finish by reporting the lines you changed and anything you skipped.
EOF
Subagents start with no conversation history, so the surgeon only knows what the orchestrator pastes into its task. Hand it the plan rows verbatim.
4. Fence off the blast radius
Permission rules apply to subagents too, and deny beats allow. Denying Edit(test/**) turns the test suite into an oracle the agents can't rewrite.
cat > .claude/settings.json <<'EOF'
{
"permissions": {
"allow": [
"Bash(node --test *)",
"Bash(git status *)",
"Bash(git diff *)",
"Edit(src/**)"
],
"deny": [
"Edit(test/**)",
"Edit(package.json)",
"Bash(git push *)"
]
}
}
EOF
Use Edit(...) for file rules, never Write(...). Only Edit and Read rules are consulted by file permission checks.
5. Add the /refactor slash command
A directory under .claude/skills/ becomes a slash command named after the directory. The !`cmd` syntax runs a shell command before Claude sees the file and injects the output.
mkdir -p .claude/skills/refactor
cat > .claude/skills/refactor/SKILL.md <<'EOF'
---
name: refactor
description: Scout a multi-file refactor, then apply it one file at a time
argument-hint: "[scope-glob] [goal]"
disable-model-invocation: true
allowed-tools: Bash(git status *) Bash(git diff *) Bash(node --test *) Read Grep Glob
---
## Working tree before we start
!`git status --short`
## Task
Full request: $ARGUMENTS
Scope glob: $0
1. Run the `refactor-scout` subagent over the scope glob with that goal. Wait for it.
2. Print its table. If the `BLOCKERS:` line is anything but `NONE`, stop and ask me.
3. Launch one `refactor-surgeon` subagent per file in the plan, in parallel, each
given only that file's rows. They touch disjoint files.
4. Run `node --test test/`. Never edit a test to make it pass.
5. Print `git diff --stat`.
EOF
disable-model-invocation: true keeps Claude from firing this on its own. You invoke it or nobody does.
flowchart LR
C["/refactor"] --> M[main session]
M -->|Agent| S["refactor-scout<br/>Read Grep Glob"]
S -->|plan table| M
M -->|Agent x3| U["refactor-surgeon<br/>Read Edit Grep Glob"]
U --> T["node --test test/"]
6. Run it
claude
Then, at the prompt:
/refactor src/**/*.js replace every console.log call with logger.info(event, fields) from src/lib/logger.js, using snake_case event names
Verify it works
Type /skills and refactor appears in the list. After the run finishes:
node --test test/
✔ every module logs through the shared logger (0.84ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 39.87
Confirm the blast radius held:
grep -rn "console.log" src/ # no output, exit 1
git diff --stat
src/checkout.js | 4 +++-
src/orders.js | 4 +++-
src/users.js | 4 +++-
3 files changed, 9 insertions(+), 3 deletions(-)
Three source files touched, test/ and package.json untouched. A surgeon that tried either would have been blocked by the deny rule.
Troubleshooting
/agents prints a reminder instead of opening a wizard. As of v2.1.198 the interactive creation UI is gone; it tells you to ask Claude or edit .claude/agents/ directly. Writing the file yourself, as above, is the supported path.
Permission deny rule (.claude/settings.json): Write(src/**) is not matched by file permission checks — only Edit(path) rules are. You wrote a path rule for Write, NotebookEdit, MultiEdit, or Glob. Claude Code keeps the rule but never consults it. Replace with Edit(src/**); Edit rules cover every file-editing tool.
Error: Shell command failed for pattern "!`node --test test/`" A ! injected command that exits non-zero aborts the whole skill invocation, and a failing test suite exits 1. Keep test runs in the skill body as an instruction (as in step 5) rather than an injected command, or append || true. grep, git diff, and find are exempt: exit code 1 is treated as normal for those.
A surgeon reports "I can't find the file you mentioned." Subagents receive no conversation history: only their system prompt, the task text, and CLAUDE.md. Paste the file path and the plan rows into the task instead of referring back to earlier turns.
Next steps
Add permissionMode: plan to the scout so it's read-only twice over. Set isolation: worktree on the surgeon to run edits in a throwaway git worktree. For large sweeps, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS (default 20) caps the fan-out, and a SubagentStop hook in .claude/settings.json can run your linter after every file.
Sources & further reading
- Create custom subagents — code.claude.com
- Slash commands — code.claude.com
- Configure permissions — code.claude.com
- Error reference — code.claude.com
- Advanced setup — code.claude.com
- Node.js test runner — nodejs.org
Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.
Discussion 4
scout and surgeon pattern is solid, but how does it handle circular dependencies or when the test boundary itself needs refactoring. feels like the permission model might become its own maintenance debt.
okay but what stops the surgeon from accidentally breaking stuff that isn't tests. like, how confident should i be running this on production code
the scout-then-surgeon pattern is solid, wonder if this scales to monorepos or if you'd hit token limits fast
the token limit thing is real, but i suspect you'd hit coordination chaos way before hitting claude's context window—like keeping state consistent across multiple surgeon passes in a monorepo sounds nightmarish without some solid transaction log between runs. i'm gonna spin this up on my homelab anyway and see where it actually breaks.