Форматы артефактов
Каждый файл, который пишет конвейер, его схема и то, что проверяет его при чтении.
Каждый артефакт, который инструментарий читает или пишет, в порядке конвейера. Все
JSON/YAML-артефакты несут поле version и при чтении проверяются zod-схемами из
@handbooks/core. Пути указаны в 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— грамматика упала или вообще не вернула дерева. Ноль фактов. Именно это сегодня даёт shell-скрипт сcase.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. Поле
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 строк с сигнатурами в этом репозитории,
потому что union-тип TypeScript содержит
|. Табуляция с текстом исходников не сталкивается. - Один факт на строку переживает обрезку. Любой grep-рецепт возвращает полный ответ одной строкой — имя, местоположение, вид, этап, вызывающие и сигнатура вместе, — поэтому и обрезанный результат остаётся пригодным к делу.
- Табуляция закрепляет целую колонку:
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.Граничные дуги и зачем они нужны монорепозиторию
Вызов, уходящий за пределы просканированного набора через import, получает в качестве
местоположения вызываемого boundary:<specifier>, но никогда не путь. Имя — это факт;
местоположение — нет, и его не угадывают.
В монорепозитории это не сноска, а большая часть того, что агент хочет знать. Измерено на этом
репозитории: 1 063 из 3 565 дуг — граничные, 284 из них ведут в @handbooks/core. Если
брать только разрешённые дуги, checkLanguage — вызываемая из другого пакета четыре раза —
показывалась с нулём вызывающих, что агент читает как мёртвый код. Это неверный
указатель, а не пробел, и неверный указатель — именно тот отказ, ради предотвращения которого
существует этот артефакт.
По той же причине nCalledBy в symbols.tsv считает граничных вызывающих наравне с
внутрипакетными, и его заголовок об этом говорит. boundary: невозможно принять за путь,
поэтому их включение ничего не выдумывает.
Строки типов и отступной вариант под ними
symbols.tsv содержит три вида строк. fn — функция или метод. type:<kind> — разобранное
объявление типа, где диапазон считан с самого объявления, из закрытого словаря: class,
interface, struct, record, enum, trait, alias, other. record не сложен внутрь
struct, потому что record в Java или C# — ссылочный тип, а struct — единственное
слово этого словаря, которое означает ещё и тип-значение. other не свалка, а несущий
элемент: определённый тип в Go (type Celsius float64) — не алиас, union в Rust — не
struct, @interface в Java — не интерфейс, и signature хранит объявление как оно
написано, так что родное ключевое слово не теряется никогда.
Какие языки действительно извлекают типы — объявляется на уровне адаптера и раскрывается в
index.md, так же как точность анализа (инвариант 3). AdapterCapabilities.typeKinds — это
список, а не логическое значение, потому что адаптер может находить классы и упускать все
интерфейсы; [] — положительное утверждение, а отсутствие поля означает, что артефакт
старше него, и это сообщается как unknown, но никогда как ноль.
Их извлекают все двенадцать точно разбираемых языков — C++, C#, Dart, Go, Java, PHP, Python,
Ruby, Rust, Solidity, Swift, TypeScript. Shell объявляет [], потому что в нём вообще нет
объявлений типов. Пять языков обобщённого уровня (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). Каждая недостача — объявление, которое адаптер отказался
угадывать: тип, объявленный внутри тела функции, или имя, сталкивающееся в модели
идентификаторов без арности. Никогда не выдуманный диапазон.
class-derived — отступной вариант там, где адаптер не извлекает типы: диапазон равен
min…max методов класса, то есть где находятся члены, а не где объявление, — поэтому он
помечен, а не подан как разобранный факт. На этом репозитории добавление настоящего извлечения
типов снизило class-derived с 45 строк до 19, и каждая из оставшихся — объектный литерал, а
не объявление типа, что ровно то, что этот отступной вариант и должен ловить.
Одна цена того, что берётся собственный диапазон объявления: если перед объявлением стоит
атрибут или аннотация, диапазон начинается с них, потому что именно там начинается узел
грамматики. Подпись от этого защищена: если предел срезал бы имя типа, вместо этого
опускаются атрибуты с ведущим …, потому что подпись, не называющая то, что она объявляет,
не короче — она бесполезна.
Раскрытие важнее покрытия: агент, который ищет имя типа через 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): фронтматтер содержит ровно name +
description; описание указывает, когда использовать И когда не использовать; тело
ссылается на references/index.md и направляет к настоящему исходнику; overview / index /
registers / stages присутствуют; индекс ссылается на каждую страницу этапа; нет
дублирующихся путей в покрытии; с --source хеши должны совпадать с живым деревом.
Каталог references/agent/ необязателен, но если он есть, в нём должны быть index.md и
все три таблицы: индекс и его таблицы фактов поставляются вместе или не поставляются
вовсе.
Результат планировщика (handbook plan)
Markdown-план: краткая проза → EDIT-блоки → один 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}Переменные окружения
Каждая переменная, которую читает Handbooks, правило именования, которое их порождает, каскад .env и те, что никогда не должны попадать в файл конфигурации.
Поддержка языков
18 языков в двух уровнях анализа — какие расширения забирает каждый, чем жертвует обобщённый уровень и две оговорки, о которых стоит знать заранее.