Handbooks
リファレンス

アーティファクト形式

パイプラインが書き出すすべてのファイル、そのスキーマ、そして読み込み時に検証するもの。

ツールチェーンが読み書きするすべてのアーティファクトを、パイプライン順に示します。 JSON/YAML のアーティファクトはすべて version フィールドを持ち、読み込み時に @handbooks/core の zod スキーマで検証されます。特に断りがない限り、パスは解析対象の ソースルートからの 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_constructorunresolved になることは決して ありません — それらは 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 の 1 つ上の階層にある兄弟です。あちらがアナライザの推測を拒んだすべての 呼び出しを記録するのに対し、こちらは「解析した」と主張することを拒んだすべての ファイルを記録します。

{
  "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 を含むシェルスクリプトがこれに当たります。
  • partial — パースはできたが rootNode.hasError: tree-sitter は理解できなかった テキストをエラーノードに置いたまま先へ進みました。ファイルの残りから抽出されたものはすべて 本物です — 欠けているのはそのノードの中にあったもので、外からは見えません。そのページを 信用する前に自分でファイルを読むべき理由が、これです。

files 配列が空であることは肯定的な主張です — 「スキャンしたすべてのファイルがきれいに パースできた」。成果物が存在しないことは、その解析がこの記録より前だという意味であって、 同じことではありません。

なぜ前の 2 つは scannedFiles から取り除かれるのか

かつては、事実を何も生まなかったファイルも scannedFiles に残っていました。そのため Phase 2a はそれにカードを書き、_coverage.json はそれを記述済みとして数えていました。そしてハンドブックは、誰も読んでいないファイルに関数が 0 個あると、パーサーの事実として断言していたのです。ここでそれらのパスを落とすことで、1 つのリストが 1 つの意味だけを持ちます: 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 のために手で書くのも、まったく同じスキーマです。 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

人間向けアーティファクトは説明し、エージェント向けアーティファクトは位置を示します。 両者は 1 つのテキストを 2 通りにレンダリングしたものではありません。エージェントに説明が 必要なときは、それは 1 ホップ先にあります — 各ステージページは、人間向けページをコピー するのではなく、そこへリンクします。

なぜ markdown テーブルではなく TSV なのか

  • markdown テーブルは、このリポジトリの 338 行のシグネチャを静かに壊します。 TypeScript の union 型が | を含むからです。タブがソーステキストと衝突することは ありません。
  • 1 行 1 事実なので、切り詰められても生き残ります。 どの grep レシピも、名前・位置・ kind・ステージ・呼び出し元・シグネチャをまとめて 1 行で返すので、途中で切れた結果でも そのまま使えます。
  • タブは列全体を固定します: 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 に向かっています。解決済みのエッジだけを載せると、別パッケージから 4 回呼ばれ ている checkLanguage呼び出し元ゼロとして現れ、エージェントはそれを死んだコードと読み ます。それは誤ったポインタであって欠落ではありません。そして誤ったポインタこそ、この成果物が 防ぐために存在している失敗です。

同じ理由で symbols.tsvnCalledBy は境界の呼び出し元もパッケージ内の呼び出し元と一緒に数 え、ヘッダにもそう書いてあります。boundary: はパスと見間違えようがないので、それらを含めても 何も捏造していません。

型の行、そしてその下にあるフォールバック

symbols.tsv には三種類の行があります。fn は関数またはメソッドです。type:<kind>解析された型宣言で、範囲は宣言そのものから読み取られ、閉じた語彙から取られます: classinterfacestructrecordenumtraitaliasotherrecordstruct にまとめていないのは、Java や C# の record参照型であり、 struct はこの語彙の中で値型も意味する唯一の語だからです。other はゴミ箱ではなく荷重を 受け持ちます: Go の defined type(type Celsius float64)は alias ではなく、Rust の union は struct ではなく、Java の @interface は interface ではありません。そして signature は 宣言を書かれたまま保持するので、その言語固有のキーワードが失われることはありません。

どの言語が実際に型を抽出するかはアダプタごとに宣言され、解析の忠実度と同じやり方で (不変条件 3)index.md で開示されます。AdapterCapabilities.typeKinds が真偽値ではなく リストなのは、あるアダプタがクラスは見つけられても interface を全部取りこぼす可能性が あるからです。[] は肯定的な主張であり、フィールドが存在しないことは成果物がその機能 より古いという意味で、unknown として報告され、ゼロとして報告されることはありません。

精密に解析される十二の言語すべてが抽出します——C++、C#、Dart、Go、Java、PHP、Python、Ruby、 Rust、Solidity、Swift、TypeScript。Shell は型宣言そのものが存在しないため [] を宣言します。 generic tier の五言語(Kotlin、Objective-C、OCaml、Scala、Zig)は意図的に [] を宣言 します。それらのアダプタは精密な解析ではなくパターン照合なので、そこから出た型の行は 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 に 記録されます)。不足しているものはすべて、アダプタが推測を拒否した宣言です——関数本体の 中で宣言された型、あるいはアリティを持たない id モデルで衝突する名前——決して推測された 範囲ではありません。

class-derived はアダプタが型を抽出しない場合のフォールバックです。範囲はそのクラスの メソッドmin…max、つまりメンバーがどこにあるかであって宣言がどこにあるかではないので、 解析済みの事実として出さずにラベル付けされます。このリポジトリでは、本物の型抽出を入れた ことで class-derived は 45 行から 19 行に減り、残っているものはすべて型宣言ではなく オブジェクトリテラルです。フォールバックが捕まえるべきなのはまさにそれです。

宣言そのものの範囲を採ることの代償が一つあります。宣言の前に attribute や注釈が付いている 場合、範囲はそこから始まります——文法のノードがそこから始まるからです。シグネチャはこれから 守られています: 上限が型の名前を切り落としてしまう場合は、代わりに前置の attribute を省略 して先頭に を付けます。何を宣言しているのか書いていないシグネチャは、短いシグネチャでは なく役に立たないシグネチャだからです。

開示はカバレッジより重要です。エージェントが型名を 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 と 3 つの テーブルすべてを備えていなければなりません — インデックスとその事実テーブルは、まとめて 同梱されるか、まったく同梱されないかのどちらかです。

プランナーの出力(handbook plan

markdown の計画書です:散文の要約 → EDIT ブロック → 1 つの 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 ケースディレクトリ(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}

このページの内容