案例背景
我们的后端是一个跑了 4 年的 Node.js 单体,180 个 .js 文件,纯 CommonJS(require / module.exports),依赖 230 个 npm 包。随着 Node 22 把 ESM 作为推荐模块系统、越来越多新包只发 ESM 版本(如 nanoid v5、execa v8),继续留在 CJS 已经在拖后腿:
- 新版依赖装不上(
nanoid v5是 pure ESM,CJS 无法require) - Tree-shaking 失效,冷启动加载 280 个文件
- 调试时 source map 在 CJS 下表现差
人工迁移的预估是 3-4 人周:改 180 个文件的 import/export、处理 __dirname / __filename / require 动态加载、改 jest 配置、修 230 个依赖里的 CJS-ESM 互操作。我们决定用 Muse Code(刚发布的长任务编码 Agent)+ Memory MCP 来压这个工期。
工具组合
| 工具 | 角色 | 为什么选它 |
|---|---|---|
| Muse Code | 主执行 Agent | append-only 事件日志保证长任务可恢复;/plan+/grill+/goal 三件套适合大重构 |
| Memory MCP | 跨 session 记忆 | 存项目约定(哪些模块禁止动、__dirname 用什么替代),避免每个 session 复述 |
| Claude Code | 二次审核 | Muse Code 跑完后用 Claude Code 独立审一遍 diff,交叉验证 |
| 检查点协议 Prompt | 轨迹可回放 | 让 Agent 每步写 checkpoint,失败可从最近 checkpoint 恢复 |
实施步骤
第 1 步:Memory MCP 沉淀项目约定(半小时)
迁移前先把"铁律"存进 Memory,避免 Agent 跑偏:
claude
> 记住这个迁移项目的约定:
> 1. src/legacy/ 目录禁止改动,它是历史遗留,保留 require
> 2. __dirname 用 fileURLToPath(import.meta.url) 替代
> 3. 动态 require 用 createRequire(import.meta.url) 替代
> 4. jest.config.js 改成 jest.config.mjs
> 5. package.json 加 "type": "module" 放在最后一步,否则中途全崩
Memory MCP 存了 5 个约定实体。后续每个 Muse Code session 启动都 search_memories 召回,写代码时自动遵守。
第 2 步:Muse Code 生成迁移 plan(1 小时)
muse
> /plan 把整个项目从 CommonJS 迁移到 ESM,分 5 个阶段:
> 阶段1: 扫描所有 require/module.exports,列影响清单
> 阶段2: 处理 __dirname/__filename/动态 require 的替代
> 阶段3: 逐文件改 import/export(按依赖顺序,utils 层先改)
> 阶段4: 改 jest 配置和 babel 配置
> 阶段5: 加 "type":"module" 并跑全量测试验证
> 每阶段生成独立 commit,可单独回滚
Muse Code 生成 plan 文件,列出 180 个文件的影响清单和 5 阶段顺序。关键是每阶段独立 commit——这是回滚的基础。
第 3 步:/grill 压力测试 plan(半小时)
> /grill .muse/plans/esm-migration.md
/grill 挑出了 3 个 plan 漏掉的边界:
- 循环依赖:
userRepo.js和orderRepo.js互相 require,改成 ESM import 后循环依赖会变成undefined(ESM 的 live binding 在循环时还未初始化) - 动态 require 路径:
configLoader.js里有require(path.join(__dirname, env + '.js')),createRequire替代后路径解析行为不同 - 第三方包互操作:230 个包里有 12 个是 pure ESM,从 CJS require 它们会报错,迁移中途(还没加
type:module时)需要用 dynamic import 兜底
这三个点被 /grill 挑出来后,我们让 Muse Code 修订 plan,给每个边界加专项处理步骤。
第 4 步:/goal 执行迁移(2 天)
修订后的 plan 喂给 /goal 长跑:
> /goal 按 .muse/plans/esm-migration-v2.md 执行,开 subagent worktree 隔离,
> 每阶段完成跑一次 npm test,失败就从该阶段开头重试
Muse Code 用了 4 个并发 subagent(每个跑在一个 git worktree),分头改 utils 层、api 层、config 层、test 层。每步写 checkpoint,我们用检查点协议的轨迹回放器监控进度。
中途遇到一次 OOM(subagent 把整个 node_modules 塞进 context 了),muse resume 从最近 checkpoint 恢复,config.toml 把 max_tokens 从 500K 降到 200K 后稳定跑完。
关键 Prompt 复盘
循环依赖处理 Prompt
/grill 挑出循环依赖后,我们用这个 Prompt 让 Agent 专项处理:
你是 ESM 迁移专家。src/repo/userRepo.js 和 src/repo/orderRepo.js 存在循环依赖:
- userRepo.js require orderRepo.js(查用户订单)
- orderRepo.js require userRepo.js(查订单所属用户)
CJS 下 require 缓存能容忍循环,ESM 的 live binding 在循环时返回 undefined。
## 处理要求
1. 不要改业务逻辑
2. 把循环依赖打破:把共同依赖的 userId→user 解析抽到 src/repo/userRef.js,两个 repo 都依赖它
3. 改完后用 npm test 验证 user/order 相关用例全过
## 输出
- 改动文件清单
- 每个文件的 import 前后对比
- 测试结果
Agent 把 userRef.js 抽出来后,循环依赖打破,ESM live binding 问题解决。
动态 require 替代 Prompt
src/config/configLoader.js 里有:
const config = require(path.join(__dirname, process.env.NODE_ENV + '.js'))
改成 ESM 时用 createRequire:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const config = require(path.join(new URL('.', import.meta.url).pathname, process.env.NODE_ENV + '.js'));
但 createRequire 的路径解析和 __dirname 行略有差异(Windows 下 path.join 和 URL.pathname 分隔符不同)。
## 处理要求
1. 跨平台验证:macOS/Linux/Windows 都跑通
2. 给出三种方案的 diff:createRequire / 动态 import() / 静态 import 映射表
3. 推荐其中一种并说明理由
Agent 最终选了静态 import 映射表(const configs = { dev: () => import('./dev.js'), prod: () => import('./prod.js') }),因为静态映射可被 bundler 分析,且跨平台无路径解析差异。
效果数据
| 指标 | 人工预估 | Agent 实际 | 节省 |
|---|---|---|---|
| 工期 | 3-4 人周 | 3 天(含 0.5 天人工审核) | ~85% |
| 改动文件 | 180 | 187(多 7 个是抽公共模块打破循环依赖) | - |
| 测试通过率 | - | 阶段 1-4 全过,阶段 5 加 type:module 后 2 个用例失败(已修) | 98.9% |
| Token 成本 | - | Muse Code Standard 档,$1.25/MTok,总 3.2M token = $4 | - |
| 回滚次数 | - | 1 次(循环依赖那步,从 checkpoint 恢复) | - |
踩过的坑
- subagent 改同一文件:第一次跑没开
worktree = true,两个 subagent 同时改package.json互相覆盖。开启 worktree 隔离后解决。详见 Muse Code 接入教程 的故障排查表。 - Agent 把 node_modules 塞进 context:Muse Code 默认会把项目根目录递归塞 context,node_modules 直接撑爆 1M token。config.toml 加
[context] exclude = ["node_modules", ".git", "dist"]解决。 - 记忆污染:Agent 把临时讨论的"可能要加 logging 模块"当成永久决策存进 Memory。后来
read_graph发现后delete_entities清掉。重要决策存 Memory 前最好人工确认。
经验总结
- plan → grill → goal 是大重构标配:
/plan出方案,/grill挑边界,/goal长跑。跳过 /grill 直接 /goal,边界问题会在执行中爆雷,回滚成本更高。 - 每阶段独立 commit:迁移类任务最大的风险是"改到一半崩了无法回退"。每阶段独立 commit + checkpoint,出问题能精确回退到某个阶段,不用整体回滚。
- Memory MCP 先行:迁移前先把项目铁律存进 Memory,比每个 session 复述约定高效得多。Agent 自动 recall 比人工提醒更可靠。
- 交叉审核:Muse Code 跑完后用 Claude Code 独立审 diff,两个不同模型交叉验证,比单模型自审更能发现盲点。
- 控 context 成本:长任务最大的成本是 token,排除 node_modules/dist/.git 这类无关目录能把 context 从 500K 压到 200K,省 60% token 费用。
可复用的工作流
这套工作流不只适用于 ESM 迁移,任何"多文件批量改动 + 有明确阶段划分 + 需要可回滚"的重构都能套用:
- 框架升级(React 17→18、Vue 2→3、Express→Fastify)
- 类型化迁移(JS→TS 流式加类型注解)
- 依赖统一(moment→dayjs、axios→fetch、jest→vitest)
- 批量重构(命名规范统一、错误处理模式统一)
核心三件套不变:Muse Code 的 /plan+/grill+/goal + Memory MCP 记忆约定 + 检查点协议保证可恢复。换的是 plan 内容和每阶段的验证脚本。