AI Native Rewrite Playbook · v1.0
这份手册不是讲道理的,是照着做就能跑的。给的是一个绞杀者式增量替换的完整生命周期:迁移到 TypeScript/Node 或 Python、以 Claude Code 为主力、每一步都有命令、脚本、门禁和验收标准。从第一周该干什么到最后下线旧系统的一行开关,中间没有跳步。
Section 00
这份手册的每一章都服务于下面某一条论断。论断是可证伪的:如果你的系统在某一条上不成立,那一章的做法就该改或跳过。只读这一页,你也应该能判断自己要付出什么代价。
一、这条路技术上是通的,已有同规模、跨同种语言组合的公开现场纪录。二、它的成本大头不是 Token,是验收体系的建设,以及懂业务的人被占用的时间。三、最危险的做法是把旧系统交给 AI 让它「自由发挥」,那就等于把十年积累的业务规则一次性扔掉。
Section 01
不是所有遗留系统都适合走这条路。先花两周做五项闸门体检,用工具的真实输出填下面这张表,然后按表末的裁决规则选择路线。这五项的共同点是:它们决定了「你我有无办法证明新旧等价」——证明不了等价,绞杀者就退化成赌博。
| 闸门 | 怎么测(两周内) | 绿灯 | 黄灯 / 红灯意味着什么 |
|---|---|---|---|
| A. 生产调用面 | 网关或 Nginx 日志按 endpoint 聚合 90 天,统计每个 path 的 PV 与调用方来源 | ≥ 60% 的请求集中在 ≤ 20% 的 endpoint 上 | 黄:长尾很重,说明「隐藏使用者」多,需要更久的绞杀期;红:存在无法定位来源的调用(如 FTP 投递、直连 DB 的 Excel),必须先补齐访问控制与调用方登记 |
| B. 数据写入面 | 枚举所有写库入口:应用、定时任务、存储过程、触发器、外部 ETL、人工 SQL | 写入路径全部可枚举且有清单 | 红:存在绕过应用直写生产库的外部系统。这是跨语言迁移最大的暗礁,必须先收敛为 API,否则新系统的不变量会被外部写破坏 |
| C. 事务与 DB 逻辑 | 统计存储过程/触发器/视图数量,标记 Spring 事务边界 | 存储过程 < 50 个且无触发器写业务逻辑 | 黄:把存储过程的迁移单独列为一条工作线(见第 09 章);红:大量业务在存储过程里,TS/Python 侧无法天然继承事务语义,需先剥离 |
| D. 目标栈人力 | 问一句:谁能独立写完一个 TS/Python 服务并保证生产可用? | ≥ 3 人具备目标栈生产经验,且含 1 名深入过旧系统的老员工 | 红:只有 PM 会写 TS。这条红就把整条路线降级——因为人类验收者必须比生成器更懂目标系统 |
| E. 交付平台 | 量三个数:CI 全量时长、回滚耗时、灰度能力 | CI < 15 分钟、回滚 < 5 分钟、支持按百分比切流 | 黄:CI 40 分钟以上,AI 迭代循环会被拖垮,先优化 CI;红:无灰度开关,只能整包发布的风险不可承受 |
五绿或四绿一黄 → 走本手册的全量绞杀路线。
两黄及以上 → 降级为「核心域重写 + 外围通过防腐层沿用」,把 15 个模块里真正有价值的 3~5 个迁走,其余维持 Java 运维。
任一红 → 不要启动迁移。先花 2~3 个月做「原地现代化」:补 CI、补可被iftrastructure 观测、把直写数据库的入口收成 API、把一个模块抽成服务验证可行性。
下面这组基线数字会在后面每一步反复用到。它们不是装饰性指标,而是用来判断某一步是否真的推进了的唯一依据。
# 1. 真实体量:排除 generated / protobuf / 前端打包产物
find . -name "*.java" -not -path "*/generated/*" -not -path "*/target/*" | wc -l
find . -name "*.java" -not -path "*/generated/*" -not -path "*/target/*" -print0 \
| xargs -0 wc -l | tail -1
# 2. 每模块体量分布(决定切片优先看的战场)
for m in */pom.xml; do d=$(dirname "$m");
printf "%-28s %6s files %8s loc\n" "$d" \
"$(find "$d" -name '*.java' | wc -l | tr -d ' ')" \
"$(find "$d" -name '*.java' -print0 | xargs -0 cat 2>/dev/null | wc -l | tr -d ' ')"
done | sort -k4 -nr
# 3. 生产被调用的接口比例(从 Nginx / 网关 access log)
awk '{print $7}' access.log* | sed 's/[0-9]\{3,\}/:id:/g; s/?.*//' \
| sort | uniq -c | sort -nr > /tmp/ep.tsv
wc -l /tmp/ep.tsv # 分母:生产实际出现过的 URL 形态数
awk '$1<10' /tmp/ep.tsv | wc -l # 90 天内调用 < 10 次的长尾
# 4. 数据库写入面
grep -rn "@Insert\|@Update\|@Delete\|executeUpdate\|\.persist(\|\.save(" --include="*.java" . | wc -l
# 5. CI 与回滚耗时(直接问运维要最近 20 次发布的数字)
「清理掉 67% 代码」是单一案例(某美国金融机构),不是行业均值;「未使用函数中位数约 15%」来自 Romano 与 Scanniello 对一组 Java 仓库的样本研究;「典型 5%~30%」属于行业观察汇总。你自己的系统是什么比例,必须用第 05 章的运行时插桩去测,不能引用别人的数字来立项。证据强度:样本研究 > 行业观察 > 单一案例
Section 02
好的理论要能推导。这里每条根基都硬推出一份「必须做 / 不许做」的清单——后面所有章节的操作都是从这四条推出来的,遇到没写到的情况,回到这四条自行推导。
DORA 2025 对约 5000 名技术从业者的调研给出一个反复被验证的判断:AI 在软件交付中的作用是「放大器」。它放大高效团队的成效,也放大陷于混乱的团队的功能失调。同一份数据里还有一组常被忽略的张力:90% 的人已在日常使用 AI,八成以上说生产力提升了,但约三成对 AI 输出缺乏信任;与此同时 AI 采纳与软件交付不稳定性上升相关联。
遗留代码之所以难改,通常不是逻辑复杂,而是依赖藏得太深:函数中间直接连库、直接读时间、直接发 HTTP、直接碰全局单例。Michael Feathers 给的接缝(seam)定义是「无须在该处编辑代码就能改变其行为的位置」。有了接缝,你才能在旧系统上插探针、装容错、把流量引到新实现。
普通测试是「需求 → 写测试 → 让代码通过」。表征测试(characterization test,Michael Feathers 提出,也叫 Golden Master)反过来:跑一遍代码 → 观察输出 → 把这个输出钉死为期望值。它记录的是「它现在做什么」,而不是「它应该做什么」。对于一份跑了十年、需求文档早就对不上的内部系统,这是唯一诚实的规格来源。
// 表征测试的写法:先故意写错,拿到真实值,再把真实值回填为期望值
test('characterize: calcSettlement', async () => {
const out = await calcSettlement(fixture.order_20240701);
expect(out).toBe('THIS_IS_WRONG_ON_PURPOSE'); // 1. 跑 → 失败信息给出真实输出
});
// 2. 把失败信息里的 actual 回填进去,测试转绿 → 行为被钉住
// 3. 之后的任何重构,只要这个值变了,测试立刻报警
把 200 个输入的投影(不是原文)写进 master 文件,那往往是这份系统历史上第一份成文的行为描述。同时请记住:golden master 失败时是需要人来判定「这是预期的变更还是回归」——所以它应该生成一个待审阅的差异清单并显式签收,而不是简单地让 CI 变红。
MISSION+ 的 ACDC 循环里有一条纪律被反复强调:验证失败要把工作退回规格,而不是退进代码。这与 Endgate 的 ACDC 白皮书所述一致——当 intent 只存在于聊天记录里,团队就丢失了「为什么这么做」的记录,结果是产出更快、返工更多。落到做法上:
spec/<slice>/behavior.md,人签字后再让 AI 生成代码Section 03
整个工程分成七个阶段。其中前三个是一次性投入(约 3~4 个月),第五阶段是要被重复 15~25 次的核心循环,第六阶段与第五阶段并行推进,第七阶段收尾。真正决定周期长短的不是一次迁移多少行,而是你多久完成一次完整的切片循环。
| 阶段 | 产出物 | 完成判据(缺一不可) | 典型工期 | 失败时意味着什么 |
|---|---|---|---|---|
| 零 · 建 Harness | 可跑的 CI、灰度开关、护栏配置、仓库骨架 | CI 全量 < 15 min;回滚单点 < 5 min;Stop hook 能真实拦住不合格产物;.env 读被 deny | 2~4 周 | 平台撑不住 AI 的迭代速度,后面全是返工 |
| 一 · 清创减负 | 代码资产清册、存活率报告、删除批次 | 给出「生产 90 天未被调用的入口文件」清单,删除批次 ≥ 1 且配置值没变 | 3~6 周 | 后面每轮 AI 探索都要为噪音付费 |
| 二 · 抽取规格 | spec/ 目录、golden master 集 | 至少 1 个高价值域 ≤ 30 页 behavior.md,每条规则可追溯到代码位置或生产样本 | 4~8 周 | 新旧系统无法判定等价,绞杀死于无法验收 |
| 三 · 切片定序 | 接缝图、打分表、排期甘特 | ≥ 15 个候选切片全部有「入口、数据所有权、下游调用方、回滚方式」四项明确 | 1~2 周 | 切片边界会反复扯皮,团队失去节奏 |
| 四 · 切片循环 | 一个个已上线的替换 | 每片:双跑差异率 < 0.5% 且已知差异全部署名解释;灰度 0→1→5→20→100% 各档无告警 | 每片 4~8 周 | 触发止损线,回到第 11 章裁决 |
| 五 · 数据层 | 防腐层、CDC 管道、schema 迁移 | 新数据源独立可提供读能力,且双写期间两侧一致性可测 | 与四并行 | 见第 09 章,是最容易翻车的一条线 |
| 六 · 绞杀下线 | 旧模块报废、文档、团队交接 | 旧侧连续 30 天零调用;旧代码被物理删除而不是留注释 | 收尾 2~4 周 | 永远养着两套系统,成本翻倍 |
行业现场反复观察到的模式是:每个切片 4~8 周,切成一笔会谈专项demo,每个 demo 用来争取下一片的资源。组织看到的是连续进展而不是一个黑盒的大重写。这也让你在任何时刻都能停止,且手上是可用的东西。
Section 04
这一步完全不碰业务代码。它的产出是一个「可以让 AI 在里面连续工作而人敢离开」的环境。跳过这步的团队,通常会在第 3 个月发现自己在用人工 review 补 AI 的漏洞,速度优势全部吐回去。
先写 .claude/settings.json 并提交进仓库,让每个参与的人都继承同一套护栏。三条护栏对应三类真实事故:读走密钥、一次改动跨太多 file、以及「看起来完成了」。
// .claude/settings.json —— 提交进仓库,全团队继承
{
"permissions": {
"allow": [
"Bash(npm run verify)", "Bash(npm run test *)", "Bash(npx tsc *)",
"Bash(git status)", "Bash(git diff *)", "Bash(git log *)"
],
"deny": [
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
"Read(./src/main/resources/**/*.properties)",
"Bash(cat .env*)", "Bash(rm -rf *)"
]
},
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-on-write.sh" }] }
],
"Stop": [
{ "hooks": [{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-gate.sh" }] }
]
}
}
注意:权限是一层层叠加的(user → project → local),而 .claudeignore 或允许规则都可能被索引、全局搜索、系统提醒绕过;要真正挡住对密钥文件的读取,靠的是 PreToolUse hook 退出码 2,而不是某条 allow 规则。把 hook(覆盖动态逻辑和 Bash 读取)与 deny 规则(在权限层覆盖文件工具)成对使用。
# .claude/hooks/protect-files.sh —— 挡住 .env / lock 文件 / .git
#!/bin/bash
INPUT=$(cat)
FP=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
for p in ".env" "package-lock.json" ".git/"; do
if [[ "$FP" == *"$p"* ]]; then
echo "Blocked: $FP matches protected pattern '$p'" >&2
exit 2 # 退出码 2 = 阻断该次工具调用
fi
done
exit 0
# .claude/hooks/verify-gate.sh —— Stop hook:不通过就不许这一轮结束
#!/bin/bash
set -o pipefail
OUT=$(npm run verify 2>&1) # 指向你的 typecheck + lint + test + golden 比对
if [ $? -ne 0 ]; then
echo "$OUT" | tail -40 >&2 # 把失败输出喂回给模型,它会接着修
exit 2
fi
exit 0
它的契约极其简单:跑一遍验证脚本,通过就退出 0 让这一轮结束;失败就把失败输出打到 stderr 并返回退出码 2,模型会带着错误信息继续修。中间不必有人参与。记录里有提到:连续被拦 8 次后 Claude Code 会强制结束这一轮——这正好是「这个任务可能根本不具备自动验收条件」的天然信号,别把它当成要绕过的障碍。
npm run verify(或对应的 make verify)这一条命令是整个 Harness 的心脏。所有门禁最终都收敛到它身上,因为 hook 只能跑一个命令。它的设计原则:快、确定性、无副作用、失败信息可读。
// package.json (Python 侧对应 Makefile + pytest + ruff + mypy)
{
"scripts": {
"verify": "npm run typecheck && npm run lint && npm run test:unit && npm run golden",
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"test:unit": "vitest run --reporter=dot",
"golden": "node scripts/golden-compare.js --dir ./golden --fail-on-diff",
"shadow:report": "node scripts/shadow-diff.js --since 24h --threshold 0.005"
}
}
verify 必须能在 15 分钟内跑完,否则 AI 的迭代节奏被拖垮——这是阶段零要先优化 CI 的原因verify 不得触碰生产数据库或外部服务;golden 比对只读本地 fixtureverify 的失败输出必须包含「哪个输入、期望什么、实际得到什么」,否则模型修不动CLAUDE.md 骨架单文件 CLAUDE.md 超过一两百行后,指令遵循度会明显下降——模型会有选择地忽略。正确做法是分层加载:Claude Code 会向上加载从当前目录到根目录沿路所有 CLAUDE.md,子目录则是访问到才加载,兄弟目录永不互相加载。这意味着你可以精确控制「在某个切片里工作时,AI 看到什么」。
repo/
├── CLAUDE.md # 根:全局规则 + 架构地图 + 入口索引(≤ 200 行)
├── packages/
│ ├── gateway/CLAUDE.md # 仅在这个包工作时加载
│ ├── settlement/CLAUDE.md # 仅在这个包工作时加载
│ └── ...
└── spec/ # 规格库,见第 06 章
└── index.md # 所有 spec 的索引,根 CLAUDE.md 指向它
# CLAUDE.md(根,示范)
## 这是什么项目
旧系统 X(Java / 15 Maven 模块)正在被绞杀式替换为新栈(TypeScript)。
旧代码在 `legacy/`,只读,不要修改。新代码在 `packages/`。
## 永远不要做
- 不要把 `legacy/` 下的 Java 文件整份喂给上下文,按 `spec/` 里的 brief 走
- 不要在没写 spec 的情况下生成业务代码 —— 先建 `spec/<slice>/behavior.md`
- 不要读 `.env`、不要碰 `resources/*.properties`(已被 hook 拦截,这里再写一次)
## 这次任务怎么开始
1. 读 `spec/<slice>/behavior.md` —— 它是唯一的需求来源
2. 允许直接读的旧代码:behavior.md 里「溯源」字段列出的那些文件
3. 实现完成后跑 `npm run verify`;Stop hook 会替你跑,不要假装通过
## 验证纪录片
- `npm run verify` 主门禁(typecheck + lint + 单测 + golden)
- `npm run shadow:report` 生产影子流量差异率报告,阈值 0.5%
## 各包入口
- gateway: packages/gateway/src/index.ts
- settlement: packages/settlement/src/app.ts
(改动涉及多包时,先写 plan 到 `.plan/` 下的 md,再动手)
CLAUDE.md 之后请记得这句话CLAUDE.md、memory.md、constitution.md 都不保证任何事情。写 Rules 不要写感受——用具体可执行的指令,而不是抽象的「必须」。真正起作用的是结构化的 CLAUDE.md + 分层加载 + 机械化的 hook 三者叠加,缺一个就会漏。
Section 05
这是投产比最高、也最容易被跳过的一步。理由很直接:后面每一个 AI 探索任务,都要为仓库里的噪音付 Context 税。把 30% 的死代码删掉,等价于让后面所有对话免费获得 30% 的额外上下文预算。
不需要买商业工具。用正则做粗粒度近似就够了——因为它的作用只是筛出候选,定罪权交给运行时证据。
#!/usr/bin/env python3
"""fanin_scan.py —— 粗粒度静态存活性筛查(候选生成,不定罪)
用法: python3 fanin_scan.py /path/to/repo > /tmp/fanin.tsv"""
import re, sys, os, collections
ROOT = sys.argv[1]
CLASS_RE = re.compile(r'(?:class|interface|enum)\s+([A-Z][A-Za-z0-9_]*)')
IMPORT_RE = re.compile(r'import\s+(?:static\s+)?([\w.]+)\.([A-Z][A-Za-z0-9_]*)\s*;')
USAGE_RE = re.compile(r'\b([A-Z][A-Za-z0-9_]{2,})\b')
decls, files = {}, {}
for dp, _, fns in os.walk(ROOT):
if '/target/' in dp or '/generated/' in dp: continue
for fn in fns:
if not fn.endswith('.java'): continue
p = os.path.join(dp, fn)
src = open(p, encoding='utf-8', errors='ignore').read()
files[p] = src
for name in CLASS_RE.findall(src):
decls.setdefault(name, set()).add(p)
imp_cnt = collections.Counter()
body_cnt = collections.Counter()
for src in files.values():
for full, cls in IMPORT_RE.findall(src):
imp_cnt[cls] += 1
body = IMPORT_RE.sub('', src)
for name in USAGE_RE.findall(body):
body_cnt[name] += 1
print("class\timports\tbody_uses\tsource_file")
for name, paths in sorted(decls.items()):
i, b = imp_cnt.get(name, 0), body_cnt.get(name, 0)
if i == 0 and b <= 1: # 只出现在自己的声明里
print(f"{name}\t{i}\t{b}\t{','.join(sorted(paths))}")
# 出来的数字应该让你警觉:通常几百到一两千个类属于这一类
python3 fanin_scan.py . > /tmp/fanin.tsv
wc -l /tmp/fanin.tsv
awk -F'\t' '{print $4}' /tmp/fanin.tsv | tr ',' '\n' | sed 's#/[^/]*$##' | sort | uniq -c | sort -nr | head -20
# ↑ 按包聚合:死代码往往高度集中在几个「当年做完没上线」的模块里
静态分析会被反射、动态代理、字符串查表骗过去。唯一诚实的答案来自运行的 JVM。两个成本递增的做法:
// 在旧系统里临时加一个 actuator endpoint,dump 所有注册路由
@ReadOperation
public Map<String,String> routes(RequestMappingHandlerMapping m) {
return m.getHandlerMethods().entrySet().stream()
.collect(toMap(e -> e.getKey().toString(),
e -> e.getValue().getBeanType().getName() + "#"
+ e.getValue().getMethod().getName()));
}
拿这份清单和 90 天网关日志里的 URL 形态做交集,立刻得到「注册了但生产一年没被调用过的接口」——这类接口在你系统里通常有几百个,是最干净的第一批候选。
让 MyBatis/JPA 之外的基础设施记录「本次运行加载并执行过的类」—— 记录决策可以落在字节码解释器、AppCDS 或 JIT 侧。关键是给每次 JVM 运行打上环境标签(prod / uat / test),否则会出现「代码和单测互相证明对方活着」的死循环。观测窗口建议覆盖一整个业务周期,先把显而易见的清掉,剩下的随着窗口延长继续收敛。
# 每个文件的:最后修改时间 / 改动次数 / 独立作者数
git log --since="3 years ago" --name-only --format="COMMIT|%at|%ae" \
| awk -F'|' '/^COMMIT\|/{ t=$2; a=$3; next } NF{ print $0"\t"t"\t"a }' \
| sort | awk -F'\t' '{ c[$1]++; who[$1"|"$3]=1; if($2>last[$1]) last[$1]=$2 }
END{ for(f in c){ n=split("",x); # 统计 who[...]
for(k in who){ split(k,p,"|"); if(p[1]==f) authors[f]++ }
printf "%s\t%d\t%d\t%d\n", f, c[f], authors[f], last[f] } }' \
| sort -k4 -n > /tmp/git-heat.tsv
# 「两年没人动 + 只有 1 个作者 + 静态零引用」→ 高置信候选
join -t$'\t' <(sort /tmp/fanin-code.tsv) <(sort /tmp/git-heat.tsv) | awk -F'\t' '$5==1'
git revert 一次就回来删掉 20%~30% 代码的收益不止于「少几行」:新工程师的心智负担下降、PR 更小更易评审、CI 更快、AI 探索时能被更少的噪音污染。前面提到的行业实证里,清理后变更发行频次有显著改善,还解锁了此前被依赖卡住而无法升级的老库。
Section 06
这一步是整个工程的价值枢纽。请注意它不是「让 AI 读完代码生成一份设计文档」——那样做出来的文档漂亮但没法验收。规格 = 可被追溯的行为断言 + 人工签署。
| 层 | 从哪来 | 解决什么问题 | 典型规模(每个切片) |
|---|---|---|---|
| 输入/输出样本 | 生产影子请求、DB 快照、消息队列里的历史消息 | 把「散落在几十处的参数与分支组合」变成真实 fixtures | 100~500 组 |
| golden master | 旧系统对样本的实际输出(投影化后钉死) | 证明新旧等价的唯一可执行判据 | 与样本同数量 |
| behavior.md | AI 读该切片范围内的旧代码 + 上面的样本后写,人签字 | 把隐性规则写成规范陈述,供人与 AI 共同引用 | 15~40 页 |
那会形成自洽但错误的闭环——模型把自己写出来的行为当作「原本就是这样」。spec 必须始终双向溯源:向上追溯到业务方确认,向下追溯到旧系统的代码位置或生产样本。每条规则后必须附这两条溯源字段,缺任一条就不允许进 spec/。
在要迁移的切片入口插一层薄薄的拦截,把每次调用的入参与返回值落盘(脱敏后)。这就是 Feathers 说的 seam——不改业务逻辑,只为观测。
// 旧系统侧:一个 request-scoped 的采集切面,两周即可下线
@Aspect @Component @Profile("capture")
public class CaptureAspect {
@Around("within(com.corp.settlement..*) && @annotation(Capture)")
public Object capture(ProceedingJoinPoint pjp) throws Throwable {
long t0 = System.nanoTime();
Object out; Throwable err = null;
try { out = pjp.proceed(); }
catch (Throwable e) { err = e; throw e; }
finally {
Captured c = new Captured(
MDC.get("traceId"),
pjp.getSignature().toShortString(),
scrub(pjp.getArgs()), // 必须脱敏:手机号/身份证/金额
scrub(out), err,
System.nanoTime() - t0);
captureSink.write(c); // 落 Parquet / JSONL,勿写生产库
}
return out;
}
}
直接快照返回值在两种场景下会失效:输出本身不确定(时间戳、随机 ID、浮点结合率),以及 LLM 类组件的温度导致的抖动。正确的做法是钉住一个确定性投影函数的结果。常见投影,按稳定度大致排序:
| 投影 | 例子 | 它能抓到什么回归 |
|---|---|---|
| 结构(shape) | JSON 的 key 集合与各值类型 | 新增/删除字段、类型漂移——最容易在迁移中静默发生 |
| 分类(label) | 从固定集合中选出的那个标签 | 业务判定规则的回归,正是最常见的迁移破坏 |
| 副作用调用序列 | 调用了哪些外部模块、参数 key 列表,按顺序 | 某一步悄悄不再调用某个系统 |
| 实体召回 | 输入里的哪些标识符出现在输出里 | 某一行代码悄悄不再携带必需的事实 |
| 数值(带容差) | 金额、税率,比到两位小数 | 舍入规则的变化——财务类系统的头号事故来源 |
| 粗粒度桶 | 输出长度区间、语言、是否拒绝 | 单看很弱,聚合起来有用 |
// golden/api/settlement.calc.jsonl —— 每行一组输入,首行是 pin 下来的配置
{"_config":{"captured":"2026-08-11","samples":5,"old_version":"v10.4.2","note":"临时="-journalable"}}
{"case_id":"inv-0031","shape":["amount:currency:due_date:order_id"],
"label":"overdue","entities":["4417","CNY"],
"amount":128.40,"side_effects":["notifyFinance"]}
{"case_id":"inv-0032","shape":["amount:currency:due_date:order_id"],
"label":"partial_overdue","entities":["4418","CNY"],
"amount":0.00,"side_effects":[]}
做法:抽 150~300 组真实输入,固定模型 id / 参数 / SDK 版本,每组跑 3~5 遍;对每个候选投影计算跨运行的稳定性,只在全部样本都一致的投影写进 master。今天就跑飞的字段从来就不是保证,把它冻起来只会制造一个 flaky 测试。把不一致的那些样本也一并记下来——那些是真正在抖的输入,知道它们是哪些本身就是个发现。
这一步的 Prompt 要给「bound context」而不是「请你总结一下」。关键:在 Prompt 里指定可读哪些文件,这样探测器就不会被庞大的仓库污染。
> 你在为一个使用了 10 年的遗留系统做行为规格提取。严格遵守:
>
> 1. 只能读我列出的这些文件:`legacy/module-a/src/main/java/com/corp/settlement/` 下
> 的 Calc*.java、Rule*.java、DiscountPolicy.java。**不要**读 com.corp.settlement.batch/ 之外的目录,
> 不要读任何 *Test.java(那些是错的,别被它们误导)。
> 2. 同时读样本:`golden/api/settlement.calc.jsonl`(前 60 行即可),它是真实生产输入的投影。
> 3. 产出 spec/settlement/behavior.md,每条规则必须写成三段:
> 规则 / 判定: 〈一句话,必须是可被测试用例反驳的陈述〉
> 溯源: 〈代码位置,或某个 case_id 的样本〉——两者都没有就整条删掉
> 状态: confirmed-by-business |疑似bug-待确认 |无法确认
> 4. 遇到看起来像 bug 的行为:**照实记录**并标记「疑似bug-待确认」,
> 不许替我把 bug 修好。我们要的是「它现在做什么」,不是「它应该做什么」。
> 5. 用到 URL/IQ/报表等外部依赖时列清单,不要深入展开。
> 6. 每产出 10 条规则暂停,等我确认方向后继续。
# spec/settlement/behavior.md (示范片段)
## R-017 部分还款的逾期标签判定
**判定**:当还款额 > 0 但 < 应还额,且距到期日 > 3 天时,标签取 `partial_overdue`,
并且**不**触发催缴通知。
**溯源**:`DiscountPolicy.java:184-192`;样本 `inv-0032`(2026-08-11 采集)
**状态**:疑似bug-待确认 —— 业务方表示「应当触发催缴」,此行为疑似 2019 年为某大客户临时放宽
## R-018 外币结算的舍入方向
**判定**:外币金额一律按 `ROUND_HALF_UP` 保留两位,本位币按 `ROUND_HALF_EVEN`。
**溯源**:`CalcSettlement.java:311`;样本 `fx-0007`、`fx-0041`
**状态**:confirmed-by-business
用两个不同系列的模型各自独立提取同一切片,然后 diff。交集部分是高置信的;差异部分恰恰是值得人工介入的地方——通常落在反射调用、隐式约定、被注释掉但仍有副作用的代码这些位置。这比让一个模型反复检查自己便宜且有效。
behavior.md 必须由既懂业务又看得懂这份代码的人逐条签署,包括那些「疑似 bug」。签字的那一刻你在回答一个不可逆的问题:我们要把这个 bug 一起搬过去,还是趁此机会修正它?无论哪种都要写下来——这决定了后面双跑时哪些差异是「预期的」。
有一个现场经验值得记住:一个跑了十年、几乎没有文档、只有过时用户故事的系统,在重建之后拥有数十万行 markdown——其中约八成是模型改代码的副产物而不是额外劳动,其余约一成半由从旧代码抽出来的规格种子长出来。关键在于让规格成为流水线的副产物,而不是额外任务。
Section 07
这一步最常见的错误是:直接拿 Maven 模块当切片。15 个模块是按技术分层和历史演进切出来的,不是按业务接缝切的——你会发现「订单」这个业务概念横跨 6 个模块,而「报表」模块里躺着三种完全不相干的东西。切片必须按能被一条路由规则切出来的行为边界来定义。
把「module → module」的调用关系画成矩阵,再和数据库表的所有权叠起来看,通常几分钟就能看出哪几处才是真正的接缝。
# 1. 从 Maven 模块”到“模块”的调用强度矩阵(正则近似胜过没有)
python3 - <<'PY'
import re, os, collections, itertools
ROOT='.'; mods=['module-a','module-b','module-c'] # 换成你的 15 个模块名
imp = collections.Counter()
for dp,_,fns in os.walk(ROOT):
if '/target/' in dp: continue
for fn in fns:
if not fn.endswith('.java'): continue
src=open(os.path.join(dp,fn),encoding='utf-8',errors='ignore').read()
src_mod=next((m for m in mods if f'/{m}/' in dp), None)
if not src_mod: continue
for pkg,cls in re.findall(r'import\s+([\w.]+)\.([A-Z]\w+)\s*;', src):
tgt_mod=next((m for m in mods if f'corp.{m.replace("-",".")}' in pkg), None)
if tgt_mod and tgt_mod!=src_mod: imp[(src_mod,tgt_mod)]+=1
print('\t'+'\t'.join(mods))
for a in mods:
print(a+'\t'+'\t'.join(str(imp.get((a,b),0)) for b in mods))
PY
# 2. 把表的所有权叠加上去
SELECT table_schema, table_name, COUNT(*) AS write_sites
FROM information_schema.tables t JOIN audit_log_table_access a USING(table_name)
WHERE a.op IN ('INSERT','UPDATE','DELETE') GROUP BY 1,2 ORDER BY 3 DESC;
优先级分数 = 业务价值 × 团队熟悉度 ÷ (依赖出度 + 1)。分母用依赖出度是因为:一个被调用很多的地方迁移后要通知很多人,一个调用很多别人的地方迁移时要先把别人都带上。分子里的「团队熟悉度」常被忽略,但它是决定前几片能不能打胜仗的关键——第一批必须赢。
| 窗口 | 在做什么 | 里程碑(可对外展示的那种) | 此刻可以停下来的安全点 |
|---|---|---|---|
| 第 1~3 月 | 阶段零 + 阶段一:Harness、清创、调用方摸底 | 「连续 90 天一个请求都没来过的数百个接口」清单 + 首个删除批次上线 | 随时可停:删掉的代码本来就没用,平台改良不吃亏 |
| 第 4~6 月 | 阶段二 + 首个切片的双跑 | 第一个切片完成影子双跑,差异率降至阈值以下——这是整个项目的第一个真凭据 | 可以停在「有 spec 未 IBAN迁移」,此时已收获一份行为规格 |
| 第 7~12 月 | 切片 2~5 | 每 4~8 周一个 demo;小团队开始能独立跑完一轮 | 可停:已切的片都在生产上跑着 |
| 第 13~20 月 | 核心域:交易/结算/主数据 | 最值钱也最难的几片完成 | 不建议在此处停:半空的核心域是最难维护的状态 |
| 第 21~24 月 | 数据平台、批处理、报表,然后下线 | 旧系统变 Chernobyl 实例,最后一天物理删除 | 可停:只剩尾巴,成本极低 |
永远不要先迁移 schema 再迁移代码。在一个还活着的生产系统依赖它确切形状的时候,你没法轻松地迁移数据库。正确顺序是:先动代码 → 用仓储层把 schema 隔离起来 → 最后才动存储。把先后顺序搞反,是现代化项目最容易踩的坑之一。
Section 08
这是手册的核心。每一个切片都走同七步,不多不少。固定流程的价值在于:它让工作可批处理、可换人、可被 AI 半自动执行,也让「进度到哪了」变成一个客观数字。
在动任何代码之前,拿出这张切片的一张体检表,它同时也是最后验收时的对照组。
# 每片必须量化七个数,写在 spec/<slice>/README.md 顶部
日请求量: 128,000 P99 延迟: 340 ms
读 / 写比: 92 : 8 数据量: 表 14 张,主表 2.3 GB
下游依赖: 5 个内部服务 + 1 个外部 API
上游调用方: 11 个已登记,0 个未知
变更频率: 近 6 个月 34 次提交,3 个作者
「上游调用方 0 个未知」这一项做不到就先回到第 07 章的闸门二。
这一步交付的代码不改变任何行为,只增加一层可以被切换的开关。它的目的是让「替换」变成一个可以回滚的配置变更,而不是一次发布。
# Nginx 侧:先把镜像跑起来(响应丢弃,用户无感)
location /api/settlement/ {
proxy_pass http://legacy-upstream;
mirror /shadow;
mirror_request_body on;
}
location = /shadow {
internal;
proxy_pass http://new-svc$request_uri;
proxy_connect_timeout 1; # fire-and-forget:不等待影子结果
proxy_send_timeout 1;
proxy_read_timeout 1;
}
如果是服务网格,等价设施是 VirtualService 的 mirror + mirrorPercentage;用 10% 起是很常见的做法,因为 100% 镜像会让负载翻倍。
按第 06 章的工序执行。这一步不能压缩——它是后面所有「自动通过」的依据,也是 @失败时唯一能回退到的地方。产出要求:
这是唯一「AI 唱主角」的一步,也是最容易跑偏的一步。核心纪律:给它规格和样本,不给它整个旧模块。
> 实现 `packages/settlement/src/settle.ts`。
> 需求来源只有 `spec/settlement/behavior.md`(你现在读它),
> 验收标准是 `golden/settlement.jsonl`(跑 `npm run golden` 比对)。
>
> 硬性约束:
> - **不要**去读 legacy/ 下的 Java 源码来「对齐实现」。如果你发现 behavior.md
> 有遗漏或自相矛盾,停下来告诉我,我会补 spec,而不是让你自己悟出来。
> - 不确定性的来源必须作为参数注入:`now: Clock`、`random: Random`、`fx: RateProvider`。
> 不允许在函数内部直接调用 Date.now() 或 Math.random()。
> - 输出必须与 golden 里的 shape 完全一致;金额一律用 decimal 库,禁止浮点累加。
> - 完成后跑 `npm run verify`;Stop hook 会替你跑,但你也要自己看一次输出。
因为一旦读了,模型会复刻旧系统的偶然实现细节(包括那些 bug 和权宜之计),并且你会失去一次「用规格重新审视这个行为」的机会。要让 .claude/settings.json 真的帮你执行这条:把 legacy/ 放进 Read 的 deny 列表,只在需要时由人显式解除。
一次并行多个子任务时注意:探索类任务交给子智能体,主上下文只收结论。改动跨多个包时,先让 Claude 把 plan 写到一个 markdown 文件再动手——压缩过的历史会丢,写下来的 plan 不会。
这是整套方法唯一能把「我们认为行为一样」变成「有证据表明行为一样」的环节。三个必须做对的细节:
// scripts/shadow-diff.js —— 归一化后按 traceID 对齐比对
const normalize = (o) => ({
...omit(o, ['requestId','timestamp','latencyMs','serverHost']),
items: sortBy(o.items ?? [], 'id'), // 顺序无语义时先排序
amount: round2(o.amount),
});
for (const [tid, {old: a, new: b}] of aligned) {
const d = deepDiff(normalize(a), normalize(b));
if (!d) continue;
classify(tid, d);
// 三类:NEW_BUG / UNDOCUMENTED_OLD_BEHAVIOR / EXPECTED_CHANGE
}
report({ total, diffCount, rate: diffCount / total, unexplained });
退出判据要事先写死:差异率持续低于阈值(建议先设 0.5%),并且每一条剩余差异都有署名解释。没有这两个条件,不允许进入步骤⑥。
每一档都不是「等一会儿」,而是带着明确的观察指标等待。推荐每档观察窗:读接口 24 小时,写接口 72 小时或跨过一个完整工作日。需要盯的四件事:错误率、延迟分布、业务指标(下单成功率 / 金额正确率 / 下游告警)、以及差异率是否回升。
这是一个容易被无限推迟的动作,但它是整套方法唯一真正兑现收益的动作。经验规律很清楚:每一片被绞杀之后如果不马上把旧路径移除,你最终会永远养着两套系统。
2 名工程师 + AI 智能体池。其中一人必须同时熟悉旧系统和目标栈——「新团队去另起炉灶、老团队维持运转」的组合在这种项目里几乎必败。公开现场纪录里出现过「一名工程师调度每阶段最多 20 个子智能体」的配置,但那是在规格与门禁都已就位之后才成立的,不要倒因为果。
Section 09
把 Java 换成 TypeScript 或 Python,最容易被低估的不是语法,而是整套运行时语义的迁移:Spring 的事务传播、ORM 的一级缓存与延迟加载、AOP 切面、拦截器链、以及藏在存储过程里的业务。这一章单独列出这些「结构性无法一一对应」的地方。
当一个还活着的生产系统在依赖数据库的确切形状时,你无法轻松地迁移它。正确顺序是:① 先动代码 → ② 用仓储层(Repository)把 schema 隔离起来 → ③ 最后才动存储。把顺序搞反,是现代化项目里最经典的自杀方式之一。
| 旧侧机制 | 迁移时的坑 | 对策 |
|---|---|---|
@Transactional 传播 |
Spring 的传播行为(尤其是默认的 REQUIRED 与同类内自调用不经过代理那个经典坑)在 TS/Python 里没有内建对应物 |
在调用栈顶一处显式声明事务边界:用一个 UnitOfWork 对象把连接/会话显式传下去,禁止「隐形全局事务」。把旧侧每个 @Transactional 的传播类型抄进 spec,逐条对照 |
| ORM 一级缓存 | 同一 Session 内两次相同查询返回同一个对象引用,业务代码依赖了这个「隐式去重」和由此带来的写回 | 新侧默认不启用任何隐式缓存;如果 spec 里记录了依赖此行为的地方,改为显式查询一次传入,并在 behavior.md 标注 |
| 延迟加载 | Java 侧 LazyInitializationException 的存在意味着「某些访问会炸」,而它常常被用作隐式的数据访问边界 |
新侧用显式 eager_load,把「该次调用会碰哪些表」写进 spec 的副作用清单;用 golden 的 side_effects 投影去校验 SQL 序列 |
| AOP 切面与拦截器 | 日志、鉴权、审计、重试这些横切逻辑不在调用栈里可见,代码读起来仿佛不存在 | 这正是第 05 章「单一静态证据不可定罪」的典型场景:用运行时插桩列出每个入口实际经过的切面链,把结果作为 spec 的一部分 |
| 存储过程与触发器 | 业务藏在数据库里,应用写库时和有人绕过应用直写时都会触发,两边都看不见 | 单独列为一条工作线:先清点出来、翻译成服务层代码、加守卫;过渡期在 DB 侧保留 triggers 做 double-check,最后才下触发器 |
| 报表与批处理 | 常常直连库、用临时表、依赖时区与会话变量 | 放到最后再做。报表对行为的容忍度与联机交易不同:可以并行产出同一份报表做全量比对,这是天然的 golden master |
在「新旧并存」的那段时间,数据要两边同步。做法是在新侧前面加一层防腐层(Anti-Corruption Layer)——它的职责是把旧系统的数据与操作以干净的现代形态暴露出来,所有新消费者只跟它对话。它是这 24 个月里你要维护最久、也最应该写得无聊的一段代码。
# 数据面:CDC 把旧库的变更实时复制到新侧
# 1. 用 Debezium / Canal 之类抓 binlog,落到 Kafka
# 2. 消费者把变更 UPSERT 到新库,注意保留 __op 与 __ts_ms 以便回溯
# 3. 一致性校验:定期对边比对,不能只靠 CDC 的「我发了」
async function verifyConsistency(sampleRate = 0.01) {
const keys = await pickSampleKeys(sampleRate); // 按主键空间均匀抽样
let mismatch = 0;
for (const rowPk of keys) {
const [oldRow, newRow] = await Promise.all([
legacyRepo.find(rowPk), typedRepo.find(rowPk),
]);
if (canonicalize(oldRow) !== canonicalize(newRow)) {
mismatch++;
await driftLog.write({ rowPk, oldRow, newRow }); // 人工判定:CDC 延迟 or 真漂移
}
}
metrics.gauge('drift.rate', mismatch / keys.length); // 必须长期 < 0.1%
}
全表比对成本太高,但随机抽样会漏掉「刚好这一批数据有问题」的情况。更实用的法是主键空间均匀抽样 + 对最近变更过的行做全量,两者结合。
在双写阶段必须先定义清楚:谁是 source of truth?冲突以谁为准?写入失败一侧怎么办?这三个问题没有答案就开双写,等于在生产上做不可控实验。本书推荐的做法:过渡期旧侧仍是权威,新侧为追随者;只有当某一片完成 100% 切换后,该片的权威才转移。
Section 10
平台组 · 1~2 人
只负责 Harness:CI、灰度、门禁、安全防护、 spec 工具链。KPI 是切片 Pod 的周期时间,而不是自己交付多少功能。
切片 Pod · 2 人
一名熟旧系统 + 一名熟目标栈,;必须有一人两边都熟。KPI 是「本片完成 100% 切换且旧路径被删」。
业务方 · 兼职
唯一的职责是给 behavior.md 签字,以及参与差异归因会议。没有这一角色,spec 就退化为 AI 的猜测。
「新团队去创新 + 老团队维持运转」。现代化项目的工程师必须像熟悉新栈一样熟悉遗留系统。把两边分开,注定得到一个漏掉全部隐性规则的新系统。
| 循环 | 怎么跑 | 谁参与 | 频率 |
|---|---|---|---|
| 全自动短循环 | 智能体 A 写代码,智能体 B 审查,A 修 B 提的问题,往复到你设定的上限 | 无人 | 每次提交 |
| 人工门循环 | 在预设检查点停下等人。人的提问与反应会纠正方向 | 工程师 | 每隔几条提示 |
| 长周期再验证循环 | 回头整体复查已经上线的功能,每次换一个角度看;至少三分之一的项目时间花在这里 | 工程师 + 业务方 | 每 2~4 周 |
第三种循环是「能上线的原型」和「正在服务真实用户的系统」之间的差别。单个智能体每次只盯着一个任务,而「所有功能彼此怎么协调一致」这件事只存在于你的脑子里。所以需要一个位于单个任务之上的层:批量复查已建成的功能,而且每次刻意换角度。两类典型的启动提示:
> 【指定角度】看看 product 表是怎么实现的,然后把后台里所有类似的列表
> 在功能、展示、链接行为上对齐它。列一张不一致清单,逐条处理。
> 【开放角度】检查所有注册场景潜在的错误情况:网络超时、重复提交、
> 第三方返回未知状态、验证码过期。列出目前一处都没处理的场景。
一个很反直觉的教训:AI 辅助开发的质量,主要由智能体能访问到的上下文的多样性决定,而不是 Prompt 写得多么精巧。在那个 550K 行系统重建的现场,被允许的上下文远不止旧代码、文档和数据库 schema——还包括让智能体直接查询一份本地的旧数据库副本、让它自己去浏览旧站点的页面;接入不熟悉的外部域时,甚至直接问它「你还需要哪些上下文源」。
一个塞到 60% 满、充斥探索噪音的上下文窗口,表现不如只有 15% 精选信号的窗口。这被称作「笨区」:上下文过多会让智能体飘向复杂或错误的解法,甚至去解决不存在的问题。「给不给、什么时候给」这种取舍本身就是上下文工程的核心内容。另外提醒一句:编码类智能体默认不会主动搜索网页,需要时你得明说。
Section 11
度量要覆盖三层,并且每一层都要能被操纵至失效——所以配套给出反制。
| 层 | 指标 | 健康区间 | 反滥用条款 |
|---|---|---|---|
| 交付 | 切片周期(从开片到 100% 切换) | 4~8 周,且逐片下降 | 不许通过缩小切片来保证周期。同时看「周期 × 切片规模」的复合量 |
| 质量 | 双跑差异率、生产逃逸缺陷、回滚率 | < 0.5%、逐季下降、< 5% | 不许通过放宽归一化规则来降低差异率——归一化规则的每一次改动都要进评审 |
| 业务 | 需求交付时长、故障平均恢复时长 | 迁移期不应显著劣化 | 把这两条做成红线指标:任何劣化都优先于迁移进度 |
这类项目真正的分水岭,往往不在技术而在有没有一个人持续对「新旧是否等价」这个问题负责任。技术动作——绞杀、规格、双跑、灰度——都是已知的;组织迟迟拿不到进展的项目,几乎都能追溯到「没有人被明确指派去回答那句「你凭什么说它们一样」」。
Section 12
| 层 | 反模式 | 典型症状 | 修正 |
|---|---|---|---|
| 战略 | 一次性 big-bang 重写 | 18 个月后仍无可用产物;旧系统在期间继续生长,目标一直在跑 | 改为按行为接缝绞杀;每个循环 4~8 周并对外 demo |
| 先迁移数据库 | 应用天天为适应新 schema 打补丁,没有人能说清当前权威在哪一侧 | 先动代码 → 仓储层隔离 schema → 最后动存储 | |
| 让「新团队」独立于老团队 | 新系统缺全部隐性规则;生产一上线就爆出一整类未记录的行为差异 | 每片都必须有既熟旧系统又熟新栈的人 | |
| 工程 | 让 AI 逐行翻译 Java 文件 | 产出的 TypeScript 长着 Java 的样子;Spring 的事务/缓存语义悄悄丢失 | 改为按 behavior.md + golden set 重写实现,禁止参考旧源码 |
| 把整个仓库塞给 AI 分析 | 产出空泛、自我矛盾的总结;上下文迅速耗尽 | 分层 CLAUDE.md + 每接缝 brief + 子智能体卸载探索 | |
| golden master 快照原文 | 第一次重跑就红了,之后被加 --update 一路刷新,最终成为形式 | 改为钉住确定性投影(shape/label/side_effects/数值带容差) | |
| 跳过双跑直接灰度 | 差异在生产上才暴露,且此时已无退出路径 | 把双跑作为硬门禁:没有差异率报告不许进入灰度 | |
| 治理 | spec 由 AI 从其产物反推 | 文档自洽但与真实行为不符,所有人以为有文档 | spec 必须双向溯源 + 人工签字;溯源缺失即删除 |
| 差异参观了不归因 | 差异日志堆了几千条无人认领,最后被静音 | 差异分三类归档,每条必须有署名解释才允许放行 | |
| 切片上线后不删旧路径 | 系统变成双胞胎,成本翻倍,任何人都不敢动 | 把「旧代码物理删除」作为切片完成的唯一定义 | |
| 没有 kill criteria | 项目在第 18 个月变成政治问题而不是工程问题 | 立项时就写好四条止损线与降级路径 |
Section 13
| 什么时候 | 命令 |
|---|---|
| 量体量 | find . -name "*.java" -not -path "*/target/*" | wc -l find . -name "*.java" -print0 | xargs -0 wc -l | tail -1 |
| 生产调用面 | awk '{print $7}' access.log* | sed 's/[0-9]\{3,\}/:id:/g; s/?.*//' | sort | uniq -c | sort -nr |
| 静态存活性 | python3 fanin_scan.py . > /tmp/fanin.tsv(第 05 章脚本) |
| Git 热度 | git log --since="3 years ago" --name-only --format="COMMIT|%at|%ae" | ……(第 05 章) |
| 主门禁 | npm run verify = typecheck + lint + unit + golden |
| 差异率报告 | npm run shadow:report -- --since 24h --threshold 0.005 |
| 上下文用量 | /compact(建议用到 50% 就手动执行,别等自动压缩);探索任务用子智能体 |
| 权限 closed loop | 把 legacy/ 加入 deny,只在需要时由人显式解除 |
| 用途 | 模板要点 |
|---|---|
| 行为提取 | 限定可读文件清单 → 三条字段(判定/溯源/状态)→ 遇到疑似 bug 照实记录不许修好 → 每 10 条暂停 |
| 按规格实现 | 需求来源只有 behavior.md → 验收只有 golden set → 不确定性必须参数注入 → 发现 spec 矛盾就停下来问 |
| 差异归因 | 给出 traceID 清单 → 要求分三类(新缺陷 / 未记录的旧行为 / 预期变更)→ 每条给代码位置 → 不许只给结论 |
| 再验证循环 | 指定角度(对齐某处的实现)或开放角度(穷举某类错误场景)→ 要求产出清单而不是直接改代码 |
| 反向诘问 | 让另一个智能体专门尝试推翻上一份结论;执行任务者不得评判自己 |
spec/<slice>/
├── README.md # 七个数:请求量/P99/读写比/数据量/上下游/变更频率
├── behavior.md # 行为规格,人签字;每条含「判定/溯源/状态」
├── migration-plan.md # 接缝位置、路由开关名、回滚方式、灰度档位与观察窗
├── diff-log.md # 双跑差异归因记录,每条有署名解释
└── done.md # 完成证明:100% 切换日期 + 旧代码删除 commit 号
golden/<slice>.jsonl # 投影化的 golden master
packages/<slice>/ # 新实现
.claudeignore 和权限允许列表都可能被索引绕过,能用 PreToolUse hook 挡的就别指望权限规则