Handbooks
参考

产物格式

流水线写出的每一个文件、它们的 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": ["…"] } } },
}

callTypeself_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_methodself_attr_unknownstring_literal_methodbuiltinlocal_var_methodbare_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.tsvnCalledBy 把边界调用方和包内调用方一起计数,表头里也这么写 了。boundary: 不可能被误当成路径,所以把它们算进来并没有编造任何东西。

类型行,以及它们下面的回退

symbols.tsv 里有三种行。fn 是函数或方法。type:<kind>解析出来的类型声明—— 跨度是从声明本身读出来的——取自一个封闭的词表:classinterfacestructrecordenumtraitaliasotherrecord 没有并进 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 恰好包含 namedescription;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}

本页目录