Handbooks
Справочник

Форматы артефактов

Каждый файл, который пишет конвейер, его схема и то, что проверяет его при чтении.

Каждый артефакт, который инструментарий читает или пишет, в порядке конвейера. Все 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": ["…"] } } },
}

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_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}

На этой странице