Runbook:把「代码为什么是现在这样」写进版本库

Write the Why into the Repo — A Self-Auditing Runbook Pipeline

写给 Agent 应用工程师。案例是这个仓库的 docs/runbook/:一条自动抄录与 Claude Code 的每一轮交互、再提炼成三份签入文档的管线。这篇讲三件事:它值在哪、怎么做的、以及 那份「提炼契约」里有哪些可以直接抄走的规矩。

先说结论

这套 runbook 最大的价值不是「有了历史记录」—— 那部分靠会话 transcript 本来就有, 不用额外做任何事。它干的是一件更特殊的活:把「为什么现在的代码是现在这样」从某个 人的脑子里搬到版本库里,并且用机器去逼它对齐提交历史。

CLAUDE.md 自己写着这条链路的痛苦:「改动前先知道为什么是现在这样,否则很容易改回去」。 这个仓库改回去的代价特别高 —— 它有一串静默失败模式:eval 的 answer 闸门一旦放宽, 分数一夜之间虚高接近满分;泄漏闸门漏扫中文路径,整片文档静默地不在检查范围内; --flatten 丢一个标记位,防自环就失效。每一条都是「当时为什么这么写」一旦丢了, 就会被「顺手改回正常」重新放出来的东西。而这些为什么大概率不在代码里,也不在任何 issue 里。

Write the Why into the Repo — A Self-Auditing Runbook Pipeline

它抓过一次自己的盲区,而且抓得很准

09-01 那天有 5 条实质提交:段级检索把 answer 从 44.1% 抬到 100%、persona 行为 eval、 回消息之前先标记已读、修掉 ERR_MODULE_NOT_FOUND、检索改成本地全文。而巡检的时候, 时间线里这些工作一条都没有 —— 09-01 只有 1 条、09-02 为 0。更刁钻的是,提炼稿看似 完全正常:水位照常推进,没有任何一个信号会红,而 5 条提交提都没提。任何计数型判据 都会判它是绿的。

这次事故催生了 auditCoverage:拿 git log --no-merges 的提交清单和时间线的日期分节 对照。硬缺口(某天有提交而时间线没有分节)响亮打日志;同时把每天的提交清单整个塞进 提炼 prompt,逼模型逐条核对「这条提交在时间线里有没有对应条目」。

这是「记录工具」自己打自己的脸,也是它最值钱的一笔:它证明了光靠纪律挡不住覆盖 缺口,缺的是信号。 而且它对自己的边界是诚实的 —— 这道闸只能抓「整天缺席」,抓不到 「分节在、但写的是别的事」;后者只有交给模型逐条核对。把一道闸能抓什么、抓不到什么 写清楚,是建闸的一部分,否则它会被当成「已经保证了覆盖」。

管线:抓取与提炼分成两个脚本

这整条管线的起点,是发给 Claude Code 的一句话:

「我希望这个项目里所有和 Claude Code 的交互(以我的输入为主)都自动记录进 docs/runbook 下,并且能自动提炼、自动维护」

一句话,剩下的全是工程。

你在 Claude Code 里发一句话
      │ Stop hook(每轮结束)
runbook-capture.mjs   纯 node,读会话 transcript,毫秒级,不调模型
      │               按 uuid 去重 → docs/runbook/raw/<日期>.md(gitignored)
      │ SessionEnd hook(会话结束)
runbook-distill.mjs   过三道闸后,后台起 headless `claude -p`
      │               按 DISTILL.md 提炼 → 三份签入的提炼稿
git diff docs/runbook/   ← 提炼稿要人读一眼再提交

分成两个脚本,因为失败代价不同:抓取必须每轮都成、不能花钱、不能出错;提炼要调 模型、可能提炼得不好、可以重跑。这是整条管线里最可迁移的一条判断 —— 两件事失败代价 不同,就不要塞进同一个进程。

抓取那半的难点是从会话 JSONL 里分辨「哪些是真人打的字」:type === 'user' 的条目有 八种,只有普通输入和 ! 命令这两种是真的输入,其余(工具结果、subagent 往返、系统 插话、压缩摘要、被中断的消息)全是噪声。过滤规则是拿真实 transcript 逐条核过的, 不是照文档猜的。

提炼那半先过三道闸:防递归锁还活着水位没推进。成功才推进水位 (var/runbook/watermark.json),失败故意不动,好让下次重试同一批素材。

防递归要有两半,少任何一半都会自我喂养。 一半是环境变量 RUNBOOK_DISTILL=1, 挡掉脚本本身;另一半是给提炼 prompt 加前缀标记 [runbook-distill],让抓取器把提炼 会话自己那句话滤掉 —— 不挡的话,runbook 会把「让我写 runbook」当成素材,越滚越多。

节选一:提炼契约里可以直接抄走的规矩

这条管线唯一决定质量的地方是 DISTILL.md —— 一份手写的、只被读不被写的契约。脚本 只负责把原始输入搬过来,搬完之后好不好全看它。下面六条是里面最可迁移的部分,换个 项目照抄就能用。

① 「你是在续写和合并,不是从头生成。」 契约明写:已有内容不要重写、不要重排、 不要「顺手优化」。这条是防模型本能的 —— 让模型维护一份长文档,它默认想重写全文, 而一次「顺手优化」就能把之前几十轮攒下来的为什么冲掉,且 diff 大到没人会逐行看。

② 弯路要留,不要删。 被推翻的判断、试过不行的方案、返工的那一轮,恰恰是最贵的 信息 —— 而摘要的本能正好相反:留结论、删过程。所以契约里给了具体抓法:原始输入里有 明显的推翻信号(「不对」「改回去」「复原」「先不用…」),顺着它往回找那次尝试,记成 「弯路:… → 结论:…」。

③ 每条教训必须带「为什么」。 「我们决定用 X」是废话,要写「用 X 是因为 Y 会在 Z 情况下静默失败」。判断标准很简单:读者是三个月后的自己,那时候没人记得当时的上下文。 我在修 Bug 的真正目的里从另一个角度说过同一件事:修 Bug 的价值在于让它沉淀为下次能复用的资产。那篇沉淀给 Harness 的是「让 AI 下次修得对」,这里沉淀进版本库的是「让人别改回去」——方向相反,逻辑同构。

④ 一句话摘要要具体。 「讨论了部署」是废话,「把 dsh 从源码 checkout 换成 npm pin, 因为源码路要预编译产物」才是摘要。这条看着像文风要求,其实是防摘要退化成目录。

⑤ 不确定就不写。 从原始输入推不出结论的地方留空,宁可少一条 —— 因为编出来的 「为什么」比没有更糟,它会被后来人当成判据。脱敏拿不准的同理,不写,并在那节末尾留 一行 <!-- 有一条因脱敏存疑略去 -->,让「这里少了东西」这件事本身留下痕迹。

⑥ 收尾不许 commit。 契约最后一句是「不要 git commit、不要 git add、不要 push」 —— 提炼稿要人读一眼再进版本库。自动写 + 自动提交 = 人不在环,错误会一轮轮累积,而 这份东西的全部价值就在于它是可信的。

契约同时把边界写死:只许改那三个文件,不许新建章节文件,不许碰 raw/,仓库里其它 文件(CLAUDE.md、SPEC、代码)一概不动。给模型的写权限要显式枚举,不要靠「它应该 不会乱改」。

节选二:管线运维上的三条

① hook 配在项目级 .claude/settings.json 并签入版本库。 clone 下来就生效,不用 每个人各配一遍 —— 一条「需要每人手动配置才生效」的管线,等于只有作者一个人在用。

② hook 永远 exit 0,代价是失败完全静默。 两个脚本都挂在会话生命周期上,非零退出 会打断你正在做的事。所以它们出错时只往 var/runbook/*.log 写一行,界面上什么都不显示。 推论要写进文档:「runbook 好久没更新了」的第一诊断动作是 tail var/runbook/*.log, 不是读脚本。

③ 手动那条路比自动强一档,且要说清强在哪。 /runbook 跑在 当前会话 里,手上有 本轮完整上下文:为什么这么改、哪个方案试过不行、哪条判断后来被推翻。headless 只能从 raw 的文本反推 —— 而 raw 里只有人说过的话,没有这些话导致了什么。契约里「弯路要留」 「每条教训带为什么」这两条,主要靠这份上下文才写得出来。重要的一轮做完,值得手动 打一次。

现在它哪里是坏的

distill.log 尾部是两次 Cloudflare 网关的 120 秒读超时(API Error 524),随后一次 换了个形状再失败,水位故意不推进 —— 从 09-02 傍晚开始,后台 headless 提炼一次都没 成功,而这一切都是静默的。README 里警告过的「水位不推进 = 再也不提炼」正在真实发生。 现在时间线的更新全靠 /runbook 手动撑着。

这不是这套设计的败笔,反倒印证了上面第 ② 条:失败静默是刻意换来的(不能让记录 工具打断正在干的活),代价就是必须有另一条路能看见真相 —— 前台跑一次 make runbook, 原因立刻显形。

目录分工与脱敏

docs/runbook/ 是刻意选的位置:它和 CLAUDE.md、SPEC 是同一层知识,只是分工不同 —— CLAUDE.md 答「当前是什么」,runbook 答「当初为什么这么定、哪条被推翻」。前者是事实, 后者是过程,而防「改回去」的信息全在后者。

一个必须分开的边界:raw/(逐条原始输入)gitignored,三份提炼稿 进版本库。 因为 脱敏发生在提炼那一步 —— 契约里写死了不写组织 id、nodeId、内部群名与人名, 再由 tests/profile.test.mjs 的泄漏闸门兜底(16 位以上混合大小写长串直接判红)。 原始输入不经过滤,永远不该进一个计划开源的仓库。

一句直话

对 Agent 应用工程师,可带走的教训是这一条:在 agent 系统里,静默失败是默认模式。 runbook 管线自己就是案例 —— 它的自动链路静默地死了好几天,没有一处红。能信的不是 纪律,是信号:响亮失败的闸门、不推进的水位、拿提交历史当对照物的审计。

这和我们数字员工生产环境里踩出来的是同一个坑:在健康检查全绿,数字员工失联了 16 分钟里,教训是「静默失败必须被改造成显式报警」。那篇管的是运行时的故障,这篇管的是记录的覆盖 —— 对象不同,原则一模一样:绿不是证据,响才是。

而如果只抄一样东西走,抄那份契约。脚本谁都写得出来,决定这堆自动生成的文字有没有用 的,是「续写不是重生成」「弯路要留」「不确定就不写」这几行 —— 它们是人写的,也只能 人写。


附录:两份来源文档的全文

正文里的节选是从下面两份原文里拣的。它们出自本仓库 docs/runbook/,一并贴上, 读者可对照原文判断取舍 —— 尤其是节选一那六条,原文的措辞比节选更有分量。

附录 A · docs/runbook/README.md 原文

# runbook —— 这个项目是怎么被「说」出来的

这里记录**和 Claude Code 的交互**:我发过的提示词、做过的决定、走过的弯路。它不是使用手册(那是仓库根的 `README.md`),也不是设计事实(那是 `CLAUDE.md`)—— 它是**过程**,用来回答「当初为什么这么决定」和「这句话该怎么说才有效」。

| 文件 | 装什么 | 谁维护 |
|---|---|---|
| [提示词.md](提示词.md) | 可复用的提示词,按做事的顺序 | 提炼续写 |
| [决策与教训.md](决策与教训.md) | 定下来的事、被推翻的事,每条带**为什么** | 提炼续写 |
| [时间线.md](时间线.md) | 按日期的一句话摘要,兼作提炼水位 | 提炼追加 |
| [DISTILL.md](DISTILL.md) | 提炼契约:读什么、写哪几个文件、什么绝不能抄 | **人写** |
| `raw/<日期>.md` | 逐条原始输入 | 自动抄录,**已 gitignore** |

## 这条管线怎么工作

```
你在 Claude Code 里发一句话
 │ Stop hook(每轮结束)
scripts/runbook-capture.mjs 纯 node,读会话 transcript,毫秒级,不调模型
 │ 按 uuid 去重 → docs/runbook/raw/<日期>.md(gitignored)
 │ SessionEnd hook(会话结束)
scripts/runbook-distill.mjs 过三道闸后,后台起 headless `claude -p`
 │ 按 DISTILL.md 提炼 → 上面那三个提炼稿
git diff docs/runbook/ ← 提炼稿要人读一眼再提交
```

抓取和提炼是**分开的**,因为它们的失败代价不一样:抓取必须每轮都成、不能花钱、不能出错;提炼要调模型、可能提炼得不好、可以重跑。

hook 配在 `.claude/settings.json`(项目级,签入仓库,clone 下来就生效)。

## 手动跑

```bash
make runbook # 抓一次 + 强制提炼一次(忽略水位闸,前台跑,看得见输出)
make runbook-backfill # 回填:扫本项目所有历史会话(去重,可重复跑)
/runbook # 在当前会话里提炼 —— 质量最好,见下
```

`/runbook``.claude/commands/runbook.md`)比后台的 headless 提炼强一档:它跑在**当前会话**里,手上有本轮完整上下文(为什么这么改、哪条判断被推翻了、哪个方案试过不行)。headless 只能从 raw 的文本反推。**重要的一轮做完,值得手动打一次 `/runbook`。**

## 两个静默失败模式

- **hook 永远 exit 0。** capture 和 distill 都挂在会话生命周期上,非零退出会打断你正在做的事 —— 所以它们出错时只往 `var/runbook/capture.log` / `distill.log` 写一行,界面上什么都不显示。**「runbook 好久没更新了」的第一诊断动作是 `tail var/runbook/*.log`**,不是读脚本。
- **水位不推进 = 再也不提炼。** 提炼只在「抓到的条数 > 上次提炼时的条数」时触发(水位存 `var/runbook/watermark.json`)。提炼失败时水位**故意不动**,好让下次重试同一批素材;但如果失败是持续性的(比如 claude 没登录),表现就是「一直不提炼,也一直不报错」。`make runbook` 前台跑一次能立刻看到真正的原因。

状态全在 `var/runbook/`(已 gitignore):`seen.json` 是已抄录的 uuid,删掉它再跑 `make runbook-backfill` 就是全量重抄;`raw/` 删掉不会自动重建,要靠回填。

附录 B · docs/runbook/DISTILL.md 原文

# DISTILL.md —— 提炼契约

这份文件是给**执行提炼的那个模型**看的(headless 的 `claude -p`,或你打 `/runbook` 时的我自己)。
它是这条管线唯一决定质量的地方 —— 脚本只负责把原始输入搬过来,搬完之后好不好全看这里。

## 你要做的事

`docs/runbook/raw/*.md` 里**尚未提炼**的原始输入,整理进下面三个文件。**只许改这三个**:

| 文件 | 写什么 | 怎么写 |
|---|---|---|
| `docs/runbook/时间线.md` | 每个已提炼的日期一节,每节 1–5 条一句话摘要 | **追加**。最后一条的日期就是提炼水位 |
| `docs/runbook/提示词.md` | 值得复用的提示词,按做事的顺序编号 | 就地合并 —— 同一件事的多次尝试合成一条最好的写法 |
| `docs/runbook/决策与教训.md` | 定下来的事、被推翻的事,每条带**为什么** | 就地合并 —— 同一个主题往下追加,不新开条目 |

不许新建章节文件,不许改 `README.md` / `DISTILL.md`,不许碰 `raw/`(那是自动抄录的,改了会被覆盖或错位)。仓库里其它文件(`CLAUDE.md``SPEC.md`、代码)一概不动 —— 提炼只写 runbook。

## 从哪读到哪

1.`docs/runbook/时间线.md` 的最后一节,拿到**上次提炼到的日期**。
2.`docs/runbook/raw/` 里那个日期**之后**(含当天未覆盖的部分)的文件。
3. 读现有的 `提示词.md``决策与教训.md` —— 你是在**续写和合并**,不是从头生成。已有内容不要重写、不要重排、不要「顺手优化」。

时间线是空的(第一次跑)就从最早的 raw 开始。

## 质量标准

写之前先明白:这份 runbook 的读者是**三个月后的我们自己**,那时候没人记得当时的上下文。

- **每条教训必须带「为什么」。** 承袭 `CLAUDE.md` 的第一原则:改动前先知道为什么是现在这样,否则很容易改回去。只写「我们决定用 X」是废话,要写「用 X 是因为 Y 会在 Z 情况下静默失败」。
- **弯路要留,不要删。** 被推翻的判断、试过不行的方案、返工的那一轮 —— 这些恰恰是最贵的信息。记成「弯路:… → 结论:…」。原始输入里有明显的推翻信号(「不对」「改回去」「复原」「先不用…」),顺着它往回找那次尝试。
- **提示词提炼成可重跑的形状。** 原始输入常常是几轮才把事说清楚(「补上」「继续」这种)。合并成一条完整的、别人照着发一次就能得到同样结果的提示词,措辞可以优化,意图不许改。
- **一句话摘要要具体。** 「讨论了部署」是废话,「把 dsh 从源码 checkout 换成 npm pin,因为源码路要预编译产物」才是摘要。
- **中文。** 仓库约定(注释、commit、spec、runbook 正文都用中文)。
- **不确定就不写。** 从原始输入推不出结论的地方留空,不要编。宁可少一条。

## 脱敏纪律(硬约束)

raw 是 gitignored 的,提炼稿**会进版本库、仓库计划开源**。落笔前逐条过一遍:

- 不写真实的组织 id / 应用 clientId / 知识库 id / 文档 nodeId / session id —— `tests/profile.test.mjs` 的泄漏闸门会扫签入的文件,**16 位以上的混合大小写长串直接判红**,包括 uuid 片段。
- 不写任何凭据、token、API key,哪怕是片段。
- 不写内部群名、同事真名、内部制度文档的标题 —— 用「某个内部群」「HR 制度文档」这类指代。原始输入里出现过的具体名字(群名、人名)一律不带进提炼稿。
- 不复制内部文档原文;引用只写题号与结论。

有一条拿不准要不要写,就不写,并在那节末尾留一行 `<!-- 有一条因脱敏存疑略去 -->`
## 收尾

改完就结束,**不要 git commit、不要 git add、不要 push** —— 提炼稿要人读一眼再进版本库。
最后用一两句话说明你改了什么、跳过了什么。

原文是按当时的仓库状态抄的;若本仓库后续有变化,以 docs/runbook/ 下的当前版本为准。


See also