五个阶段
每个生成阶段做什么、花多少钱、失败时降级成什么,以及如何只重跑其中一个。
handbook generate 运行五个阶段。只有第一个是免费的;其余都要和你的 LLM 端点对话。
| Phase | 产出 | LLM? | 可单独重跑? |
|---|---|---|---|
| 1 | 调用图 | ❌ | ✅ |
| 2a | 每个扫描到的文件一张卡片 | ✅ | ✅ |
| 2b | 阶段骨架 + 文件归属 | ✅ | ✅ |
| 2c | 阶段内分组与排序 | ✅ | ✅ |
| 3 | 叙述 + 跨阶段状态寄存器 | ✅ | ✅ |
--phase all # everything (default)
--phase 1 # just the call graph
--phase 2 # 2a + 2b + 2c
--phase 2a # one phase
--phase 2c,3 # a comma listPhase 1——调用图
不用 LLM。确定性。免费。
语言适配器用 tree-sitter 解析每个文件,产出一种与语言无关的中间表示。图构建器随后把边划分为保留与丢弃两类,标注出入度,并为那些被引用但从未显式定义的构造函数合成节点。
它还会为每个扫描过的文件盖上内容哈希。正是这个哈希,让 resync 之后能发现那种行号和签名都没动的就地函数体修改——纯结构性 diff 会完全漏掉的情形。
它读不进来的那些文件
发现阶段列出了、但分析器没能变成事实的文件,会被如实记下,绝不悄悄跳过。每一个都带着原因落进 phase1/scan-coverage.json:
unreadable——读取这一步本身就失败了(权限位、悬空的符号链接、运行途中被构建删掉的文件)。没有任何事实。unparsable——语法抛错,或者没有返回语法树。没有任何事实。含case的 shell 脚本就是最常见的那一类。partial——文件解析出来了,但带着语法错误。在其余部分里找到的函数和调用都是真的;缺的是落在那个错误节点里的东西。
前两类文件还会被从 scannedFiles 中剔除,因为一个什么都没产出的文件,不该被当成一个空文件交给 Phase 2a。Phase 1 收尾时会在日志里把这个缺口点出来:
[scan] coverage: 409 files analyzed; 3 recorded in scan-coverage.json (partial=1 unparsable=1 unreadable=1)这份产物里 files 数组为空,是同一句话的正面说法:全都解析成功了。
产出: phase1/graph.json、functions.csv、graph.dot、dropped-calls.json、scan-coverage.json。
永远先跑这个
handbook analyze 就是这个阶段。它分文不花,而且是唯一能让你在花 token 之前就发现自己正在扫描
node_modules、或者漏掉了一整门语言的办法。
Phase 2a——文件卡片
用 LLM。通常是最贵的阶段。
Phase 1 真正读到的每个文件都会得到一张卡片——也就是 graph.json 里的 scannedFiles,其中不含记在 scan-coverage.json 里的那些 unreadable 和 unparsable 路径:
- purpose——一两句大白话
- role——取自封闭词表(
entrypoint、domain_logic、io_transport……) - lifecycle——
startup、main loop、cross-cutting、none…… - 在
--detail deep下还有:一段 120–300 词的讲解,外加合并到图事实上的每函数用途、数据流和关系
如何分批
每个请求 --read-batch-size 个文件,同时在途 --read-workers 个批次。deep 模式默认每批一个文件,因为深卡片的输出量很大,把几个文件塞进同一条回复正是回复被截断的原因。
三级降级
如果一批回复解析不了:
- 把这一批拆成单个文件重试;
- 对超大的单个文件,改为按函数分块重试;
- 仍然失败,就写一张诚实的空卡片——只有结构,没有文字。
一个文件绝不会因为它的文字生成失败而从手册中消失。 每次缺失都列在 phase2/cards/_coverage.json 里,而那些没产出任何可用内容的回复会被保留(上限 20 个,按哈希命名)在 phase2/cards/_rejected/ 下,让你能读到到底哪里出了问题,而不是靠猜。
恢复
卡片完成一张写一张。Ctrl-C 是安全的,--resume 会跳过在请求的深度下已经有完整卡片的文件。
handbook generate --source $REPO --work $WORK --phase 2a --detail deep --resumePhase 2b——骨架与归属
用 LLM。决定这本手册是什么的阶段。
两种模式。
--synth-mode oneshot(默认)
从 nav-pack(目录汇总 + 入口点)合成一个阶段骨架,然后分批把每个文件归入且仅归入一个阶段。
便宜,而且通常足以判断形状对不对。
--synth-mode doctor
一个 actor–critic 修复循环。每一轮:
-
actor 依据真实调用图的统计数据,提出至多三项结构改动——拆分、合并、移动、改标题、改父级;
-
三个 critic 并行评审,各盯一种失败:
Critic 找什么 工程师 这和代码实际做的事对得上吗?被引用的条目真实存在吗? 架构师 边界不清、臃肿的阶段、饥饿的阶段、放错位置的横切关注点 读者 结果更好读了吗?页面内聚、标题直观、叙事能一路跟下来 -
幸存的改动会针对调用图再做一次机械校验——点名了不存在的阶段、或会让文件失去归属的改动,在触碰骨架之前就被拒绝;
-
受影响的文件被重新归属。
当没有文件未归属、也没有改动通过评审时停止;或到达 --max-doctor-rounds(默认 6);或连续两轮没有进展。
REJECT 计。失效的评审者绝不能放行改动。自带骨架
handbook generate --source $REPO --work $WORK --skeleton my-skeleton.yaml文件会被归入你的阶段。使用 --strategy member 时,改为对单个函数分类,文件级产物由此推导。
产出: phase2/skeleton.yaml、phase2/assignment.json、phase2/strategy.json。
Phase 2c——组织
用 LLM,但便宜。可降级为确定性顺序。
在每个阶段内部,文件按调用图拓扑排序,并分成 2–8 个带标题的小组,每组配一行摘要。
任何失败都降级为确定性的扁平顺序。文件绝不会被丢弃。 整个阶段就是围绕这条不变式写的:分组不好看是外观问题,文件缺失是正确性问题。
使用 --strategy member 时,此阶段是空操作——组织在 2b 就已经确定性地推导出来了,所以单独跑一次 --phase 2c 根本不需要 LLM。
产出: phase2/organization.yaml。
Phase 3——叙述与寄存器
用 LLM。缓存力度很大。
自底向上的叙述
先叶子阶段,再父阶段——这样父阶段的总结在动笔时已经知道子阶段说了什么——最后是通晓一切之后写下的系统总览。
每次文字生成调用都缓存在 phase3/cache/ 下,键由提示词版本、语言和完整提示词哈希组成。改动一个阶段后重跑 Phase 3,只会重新叙述一个阶段。
状态寄存器
“寄存器”是一段跨阶段流动的状态——连接池、功能开关、重试预算、认证令牌。提取采用问到没有为止的补漏循环:不断追问,直到某一轮再也找不出新东西。
对于扇出式改动,这是最有用的一件产物,因为*“哪些阶段碰这份状态”*正是一次分散式改动要问的问题。
产出: phase3/narration.json、phase3/registers.json。
两种策略
--strategy file(默认) | --strategy member | |
|---|---|---|
| 骨架 | 由 LLM 合成 | 你来编写 skeleton.yaml |
| 叶子单元 | 一个源文件 | 一个函数或方法 |
| Phase 2b | 把文件归入阶段 | 对每个成员分类,再推导文件级产物 |
| Phase 2c | LLM 分组 | 已经完成——确定性 |
| 适合 | 你还不了解的仓库 | 你已经心中有数的仓库 |
| 成本 | 较低 | 较高——每个成员都要分类 |
所选策略记录在 phase2/strategy.json。换了 --strategy 却不带 --phase 2b 的部分重跑会被拒绝——file 策略的默认值悄悄覆盖掉由 member 推导的组织,正是那种事后极难察觉的损坏。
一次运行会为自己记录什么
{
"version": 1,
"model": "gpt-4o-mini",
"phases": ["1", "2a", "2b", "2c", "3"],
"startedAt": "2026-08-08T13:02:11.004Z",
"finishedAt": "2026-08-08T13:19:44.881Z",
"usage": { "promptTokens": 1840221, "completionTokens": 214880, "totalTokens": 2055101 },
"stats": { "phasesRun": ["1", "2a", "2b", "2c", "3"], "nCards": 412, "nStages": 9, "nRegisters": 6 }
}它描述的是最后一次成功的运行。失败的运行不动之前的清单,被中止的运行则什么都不写。