アーティファクト形式
パイプラインが書き出すすべてのファイル、そのスキーマ、そして読み込み時に検証するもの。
ツールチェーンが読み書きするすべてのアーティファクトを、パイプライン順に示します。
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": ["…"] } } },
}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 の 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.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)は 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 は name と description をちょうど持つ
こと、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}