产物格式
流水线写出的每一个文件、它们的 schema,以及读取时对其进行校验的东西。
工具链读取或写入的全部产物,按流水线顺序排列。所有 JSON/YAML 产物都带有 version
字段,并在读取时用来自 @handbooks/core 的 zod schema 校验。除非另有说明,路径均为相对
于被分析源码根目录的 POSIX 相对路径。
工作目录布局
<work>/
phase1/
graph.json the call graph (nodes + edges + selfAttrs + metadata)
functions.csv one row per internal function
graph.dot Graphviz view (files as clusters; await edges colored)
dropped-calls.json unresolved calls, categorized
scan-coverage.json files the scan could NOT turn into facts, and why
phase2/
cards/<rel>.json one card per source file (tree-mirrored paths)
cards/_coverage.json {nFiles, nDescribed, missing[]}
skeleton.yaml the stage skeleton
assignment.json file → stage
organization.yaml intra-stage groups + reading order
members.json (member strategy only) function → stage
phase3/
narration.json stage + system prose
registers.json cross-stage state registers
cache/ content-hash caches (safe to delete; costs a re-generation)phase1/graph.json
{
"version": 1,
"metadata": {
"generatedAt": "2026-08-02T10:00:00.000Z",
"language": "python | typescript | go | rust | shell | multi",
"sourceRoot": "/abs/path",
"scannedFiles": ["aggregate/rollup.rs", "…"], // only files that were actually read and parsed
"nInternalFunctions": 316,
"nBoundaryNodes": 45,
"nEdges": 903,
"policy": "Edges are emitted only when the callee resolves …",
"unparsedFiles": [
// optional; [] means every scanned file parsed cleanly
{ "file": "app/legacy.py", "reason": "partial", "detail": "…" },
],
},
"nodes": {
"app.main.main": {
// internal node (kind: "internal")
"id": "app.main.main",
"name": "main",
"qualname": "main",
"file": "ingest/collector.go",
"lineStart": 4,
"lineEnd": 9,
"signature": "def main()",
"isAsync": false,
"isMethod": false,
"className": null,
"decorators": [],
"kind": "internal",
"synthetic": false, // true = implied node (e.g. implicit constructor)
"selfAttrsRead": [],
"selfAttrsWritten": [],
"paramTypes": {},
"nCallees": 3,
"nCallers": 0,
},
"boundary:os.getpid": {
// boundary node (kind: "boundary")
"id": "boundary:os.getpid",
"name": "getpid",
"qualname": "os.getpid",
"module": "os",
"className": "",
"kind": "boundary",
"nCallees": 0,
"nCallers": 1,
},
},
"edges": [
{
"callerId": "app.main.main",
"calleeId": "ingest.collector.Source.Next",
"isAwait": false,
"callType": "internal_constructor",
"line": 6,
"raw": "c.source.Next",
},
],
"selfAttrs": { "Collector": { "dropped": { "readIn": ["…"], "writtenIn": ["…"] } } },
}callType ∈ self_method · self_attr_method · param_method · internal_func · internal_constructor · boundary · boundary_constructor(绝不会是 unresolved——那些
记录在 dropped-calls.json 里)。
phase1/dropped-calls.json
{
"version": 1,
"metadata": {
"generatedAt": "…",
"totalDropped": 12,
"byCategory": { "builtin": 7, "bare_name": 3, "local_var_method": 2 },
},
"edgesByCategory": {
"builtin": [
{ "caller": "app.main.main", "calleeRaw": "print", "isAwait": false, "line": 9, "raw": "print" },
],
},
}类别:inherited_method、self_attr_unknown、string_literal_method、builtin、
local_var_method、bare_name。
phase1/scan-coverage.json
dropped-calls.json 往上一层的姊妹文件:那份文件交代的是分析器拒绝去猜的每一个调用,
这份文件交代的是它拒绝声称自己分析过的每一个文件。
{
"version": 1,
"metadata": {
"generatedAt": "…",
"nScanned": 412, // files that reached the graph — i.e. graph.metadata.scannedFiles
"nUnparsed": 3,
"byReason": { "partial": 1, "unparsable": 1, "unreadable": 1 },
},
"files": [
// sorted by path, so an unchanged tree re-runs byte-identically
{ "file": "app/legacy.py", "reason": "partial", "detail": "the parse tree contains syntax errors…" },
{ "file": "ops/legacy.sh", "reason": "unparsable", "detail": "resolved is not a function" },
{ "file": "vendor/dangling.py", "reason": "unreadable", "detail": "ENOENT: no such file or directory…" },
],
}reason | 解析器拿到了什么 | 在 scannedFiles 里吗? | 会有卡片吗? |
|---|---|---|---|
unreadable | 什么都没有——读取失败 | ❌ | ❌ |
unparsable | 什么都没有——语法抛错 | ❌ | ❌ |
partial | 真实的事实,但不完整 | ✅ | ✅ |
unreadable——发现阶段列出了这个路径,但读取失败了:权限位、悬空的符号链接、运行途中 被构建删掉的文件。detail里带着 errno 消息。unparsable——语法抛错,或者根本没返回语法树。零事实。今天,含case的 shell 脚本 就是这个结果。partial——文件解析出来了,但rootNode.hasError:tree-sitter 把它读不懂的文本停放进 一个错误节点里,然后继续往下走。从文件其余部分提取到的一切都是真的——缺的是落在那个 节点里的东西,而它从外面看不见。正因如此,在相信关于这个文件的某一页之前,值得你自己去读 一遍源码。
files 数组为空是一句正面的断言——“每个扫描到的文件都干净地解析成功了”。而这份产物
不存在,意思是这次分析早于这项记录,两者并不是一回事。
为什么前两类会被移出 scannedFiles
一个没产出任何事实的文件,从前会继续留在 scannedFiles 里,于是 Phase 2a 为它写了一张卡片,_coverage.json
也把它算作已描述。手册随后就以解析器事实的名义断言:一个没人读过的文件里有零个函数。把这些路径从这里剔除,才能让一份清单只表示一件事:scannedFiles
是分析器读到的,scan-coverage.json 是它没能读到的。
phase2/cards/<rel>.json — FileCard
{
"version": 1,
"file": "ingest/collector.go",
"purpose": "Drains the queue and executes each task.", // "" = generation failed (backfilled)
"role": "domain_logic", // entrypoint|orchestration|domain_logic|io_transport|data_model|config|util|test|generated|other
"lifecycle": "main loop", // free-form short hint; "none" when not meaningful
"description": "…120-300 words…", // deep mode only
"functions": [
// deep mode only; facts from the graph, prose from the LLM
{
"id": "app.worker.Worker.run",
"qualname": "Worker.run",
"name": "run",
"className": "Worker",
"lineRange": [10, 13],
"signature": "def run(self)",
"calls": ["ingest.collector.valid"],
"calledBy": ["app.main.main"],
"extCalls": [],
"nCalls": 3,
"nCalledBy": 1,
"nExtCalls": 0,
"purpose": "…",
"dataFlow": "…",
"relations": "…", // may be empty; facts never are
},
],
}phase2/skeleton.yaml — Skeleton
metadata:
version: 1
archetype: demo task runner # one-phrase system shape
draftedBy: skeleton-synth # skeleton-synth | skeleton-doctor | user
stages:
- id:
stage-1 # any filename-safe id (^[A-Za-z0-9][A-Za-z0-9._-]*$);
# conventionally stage-N / stage-N.M / crosscut-N.
# Reserved page names (overview, index, register(s), …)
# are auto-suffixed by the normalizer.
title: Startup
description: Entry point wiring…
parent: null # substages point at their parent id
children: [stage-1.1] # derived; always rebuilt from parent on load
crosscut: false # true = cross-cutting infrastructure在使用 --strategy member / --skeleton 时,你手写的正是同一份 schema。children
可以省略,也可以是过期的——它会在加载时被规范化。
phase2/assignment.json — Assignment
{
"version": 1,
"fileStage": { "ingest/collector.go": { "stage": "stage-1", "also": [] } }, // "unassigned" allowed
"buckets": { "stage-1": ["ingest/collector.go"] }, // primary stage only; disjoint
"coverage": { "nFiles": 5, "nAssigned": 5, "unassigned": [] },
}phase2/organization.yaml — Organization
metadata: { version: 1, nStages: 4 }
stages:
stage-2:
title: Task execution
groups:
- title: Core flow
summary: Everything this stage owns, in execution order.
files:
- { file: ingest/collector.go, purpose: '…', role: domain_logic, nFunctions: 5 }
orderedFiles: [ingest/collector.go, ingest/http_source.go] # flat reading order across groups
coverage: { nFiles: 5, nOrganized: 5 }phase3/narration.json — Narration
{
"version": 1,
"lang": "en", // en | zh
"systemOverview": "…200-350 words…",
"stageSummaries": { "stage-1": "…100-200 words…" },
}phase3/registers.json — Registers
{
"version": 1,
"registers": [
{
"id": "reg-task-queue", // ^reg-[a-z0-9-]+$
"semantics": "The FIFO list of pending tasks…",
"stages": ["stage-1", "stage-2"],
}, // only real stage ids
],
}渲染出的手册(handbook render)
<out>/
overview.md H1 title + 🗺️ system overview + see-also links
index.md recursive stage index (heading depth = tree depth)
register.md | State register | Semantics | Stages touched | (only when registers exist)
<sid>.md one page per content-bearing stage (summary, sub-stages,
organization groups, per-file cards with function details,
📊 state-registers section when touched)
agent/ (--agent-site) index.md · symbols.tsv · files.tsv · calls.tsv · stages/<sid>.md
html/ (--html) self-contained multi-page site (no external requests)
handbook.html (--html-single) one self-contained page智能体索引(--agent-site)
<out>/agent/
index.md the only file meant to be read whole: lookup recipes, the stage
table, the register table, coverage
symbols.tsv name → path:startLine-endLine, kind, stage, nCalledBy, signature
files.tsv path → stage, role, nSymbols, purpose[prose]
calls.tsv call edges: the caller always located, the callee located or
marked boundary:<import specifier>
stages/<sid>.md second hop: the stage's file list and its co-change pairs人类产物负责解释,智能体产物负责定位。 它们不是同一份文本的两种渲染。智能体需要那段解释 时,解释就在一跳之外——每个阶段页面链接到对应的人类页面,而不是把它抄一遍。
为什么用 TSV 而不是 markdown 表格
- markdown 表格会无声地毁掉本仓库里的 338 行签名,因为 TypeScript 的联合类型里带
|。制表 符不会和源码里的文本撞车。 - 一行一个事实,被截断之后依然成立。 每条 grep 配方都在一行里给出完整答案——名字、位置、 kind、阶段、调用方和签名都在一起——所以哪怕结果被剪掉一截,照样能用。
- 一个制表符就锚定一整列:
grep "^scan\t"匹配的是名为scan的符号,而不是每一行含这个 词的内容。
列的顺序就是价值的顺序,文字放最后,这样一个会剪掉长行的消费者,先吃掉的是文字,而不是路径。
表头行
每张表都以 # 注释行开头,写明各列的名字和信任边界——流水线在别处一直在做的同一件披露,被移
到了真正承载它的那份产物上:
# name location kind stage nCalledBy signature
# parser facts. kind=fn is a function or method. kind=type:<class|interface|struct|record|enum|
# trait|alias|other> is a parsed type DECLARATION, span read off the declaration itself.
# kind=class-derived is the fallback where a language's adapter extracts no types: the SPAN is
# min..max of the class's METHODS, not of the declaration. Which languages are indexed and which
# fall back is stated in index.md under "coverage" — a miss here is not proof a name does not exist.
# nCalledBy counts callers inside the scanned set PLUS callers that reach it through an import
# (see calls.tsv boundary rows); a cross-package-only callee would otherwise read as dead code.calls.tsv 写的是与之对应的一条,并且点明了它承载的两种行的区别:
# callerQualname callerLocation calleeQualname calleeLocation
# calleeLocation is path:line when the analyzer resolved it, or boundary:<import specifier>
# when the call leaves the scanned set — the name is known, the location is not and is not guessed.
# A call the analyzer could not pin down at all is in phase1/dropped-calls.json,
# never guessed here — so absence is not proof nothing calls it.边界边,以及为什么 monorepo 需要它们
一个通过 import 离开被扫描范围的调用,其被调方位置写作 boundary:<specifier>,而绝不是一个路
径。名字是事实;位置不是,也不去猜。
在 monorepo 里这不是一个脚注——它几乎就是智能体最想知道的那部分。在本仓库上实测:3,565 条边
里有 1,063 条是边界边,其中 284 条指向 @handbooks/core。如果只收解析成功的边,
checkLanguage——它被另一个包调用了四次——会显示零个调用方,而智能体会把这读成死代码。那
是一个错误的指针,不是一处空白,而错误的指针正是这份产物要防的那种失败。
出于同样的理由,symbols.tsv 的 nCalledBy 把边界调用方和包内调用方一起计数,表头里也这么写
了。boundary: 不可能被误当成路径,所以把它们算进来并没有编造任何东西。
类型行,以及它们下面的回退
symbols.tsv 里有三种行。fn 是函数或方法。type:<kind> 是解析出来的类型声明——
跨度是从声明本身读出来的——取自一个封闭的词表:class、interface、struct、record、
enum、trait、alias、other。record 没有并进 struct,因为 Java 和 C# 的 record 是
引用类型,而 struct 恰恰是这个词表里同时意味着值类型的那个词。other 是有承重作用的,
不是垃圾桶:Go 的 defined type(type Celsius float64)不是别名,Rust 的 union 不是 struct,
Java 的 @interface 不是 interface——而 signature 里带的是原样写出的声明,
所以原生关键字永远不会丢。
哪些语言真的抽取类型,是按 adapter 声明并在 index.md 里披露的,
和分析保真度的做法一样(不变量 3)。AdapterCapabilities.typeKinds 是一个列表而不是布尔值,
因为一个 adapter 可能找得到 class 却漏掉全部 interface;[] 是一个肯定的声明,
而这个字段不存在意味着产物早于它,会被报成 unknown,绝不会报成零。
全部十二种精确解析的语言都会抽取——C++、C#、Dart、Go、Java、PHP、Python、Ruby、Rust、
Solidity、Swift、TypeScript。Shell 声明 [],因为它根本没有类型声明。
五种 generic tier 的语言(Kotlin、Objective-C、OCaml、Scala、Zig)故意声明 []:
它们的 adapter 是模式匹配而非精确解析,所以从那里出来的类型行在 IR 里
和精确解析的行长得一模一样、保真度却低一档——这正是不变量 3 要防的事。
它们继续走 class-derived 回退。
在真实仓库上实测,用行数对 grep 能看到的声明数:PHP 与 Solidity 100%、C# 98.9%、
Swift 97.0%、Dart 96.1%、Ruby 92.7%、C++ 87.5%(只算解析干净的文件;spdlog 里大量宏
把语法本身击穿了,这一点记在 scan-coverage.json 里)。每一处差额都是 adapter 拒绝去猜
的声明——写在函数体内部的类型,或者在无 arity 的 id 模型下撞名的名字——绝不是猜出来的跨度。
class-derived 是 adapter 抽不出类型时的回退:跨度取该类各个方法的 min…max——
那是成员所在的位置,不是声明所在的位置——所以它被明确标注,而不是当作解析出来的事实端上桌。
在本仓库上,加入真正的类型抽取后 class-derived 从 45 行降到 19 行,
而剩下的每一行都是对象字面量而不是类型声明——回退本来就该只抓这些。
取声明自身跨度的一个代价:当一个声明带有前置的 attribute 或注解时,跨度会从那里开始——
因为语法节点就是从那里开始的。签名被保护起来了:如果上限会把类型名切掉,
就改为省略前面的 attribute 并加一个前导 …,因为一个不写出自己声明了什么的签名,
不是"更短的签名",而是无用的签名。
披露比覆盖率更重要:一个 agent grep 一个类型名、什么都没搜到、于是断定这个类型不存在,
这正是这份产物要防的错误指针。常量、变量和宏在任何语言里都不被索引,index.md 写明了这一点。
新鲜度
index.md 的头部带着 HandbookModel.provenance——{ commit?, generatedAt },从运行清单里读
来。行号如今是主要的载荷,而过期的行号正是那种会无声出错的事实,所以这份产物会说清楚自己
是什么时候、对着什么生成的。
SKILL 包(handbook skill)
<out>/
SKILL.md frontmatter: name (<slug>-handbook) + description
("Use when … Do not use …"); body = routing protocol
references/
overview.md index.md registers.md
stages/<sid>.md
agent/ (--agent-dir) index.md · symbols.tsv · files.tsv ·
calls.tsv · stages/<sid>.md
coverage.json (optional) {schemaVersion, summary, files:[{path,stage,sha256}]}校验约定(handbook validate):frontmatter 恰好包含 name 与 description;description
同时说明何时使用与何时不要使用;正文引用 references/index.md 并指向真正的源码;
overview/index/registers/stages 都存在;index 链接到每一个阶段页面;coverage 路径没有重复;
带上 --source 时,哈希必须与实时的源码树一致。references/agent/ 目录是可选的,但只要它存
在,就必须带上 index.md 和全部三张表——索引和它的事实表要么一起交付,要么都不交付。
规划器输出(handbook plan)
一份 markdown 计划:散文式摘要 → EDIT 块 → 一个 declarations JSON 块。
### EDIT 1
- file: `app/engine.py`
- where: `Engine.spin (~5)` — add retry
```old
<byte-exact current text, ≥3 context lines each side, unique in the file>
```
```new
<replacement text>
```
```json
{ "will_modify": ["Engine.spin"], "will_add": [], "will_remove": [] }
```Resync case 目录(handbook resync --case)
<case>/
edited/ the changed source tree (required)
plan.md change description; its ```json declarations block
(will_modify/will_add/will_remove) sharpens scope (optional)
change.diff unified diff; PRESENT AND EMPTY = "nothing to resync" (optional)
resync-report.json written by resync: {skipped, changedFiles, addedFiles,
deletedFiles, affectedStages, cardsRegenerated, narrated}