Handbooks
Referência

Formatos de artefato

Todo arquivo que o pipeline escreve, seu schema e o que o valida na leitura.

Todo artefato que a toolchain lê ou escreve, em ordem de pipeline. Todos os artefatos JSON/YAML carregam um campo version e são validados na leitura com schemas zod de @handbooks/core. Os caminhos são POSIX relativos à raiz do código-fonte analisado, salvo indicação em contrário.

Layout do diretório de trabalho

<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 (nunca unresolved — esses ficam em 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" },
    ],
  },
}

Categorias: inherited_method, self_attr_unknown, string_literal_method, builtin, local_var_method, bare_name.

phase1/scan-coverage.json

O irmão do dropped-calls.json, um nível acima: aquele arquivo presta contas de cada chamada que o analisador se recusou a adivinhar; este presta contas de cada arquivo que ele se recusou a alegar que tinha analisado.

{
  "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…" },
  ],
}
reasonO que o parser obteveEstá em scannedFiles?Ganha ficha?
unreadablenada — a leitura falhou
unparsablenada — a gramática lançou erro
partialfatos reais, mas incompletos
  • unreadable — a descoberta listou o caminho, mas a leitura falhou: um modo de permissão, um symlink quebrado, um arquivo que o build apagou por baixo da execução. O detail carrega a mensagem de errno.
  • unparsable — a gramática lançou erro, ou não devolveu árvore alguma. Zero fatos. É o que um script de shell contendo case produz hoje.
  • partial — o arquivo parseou, mas com rootNode.hasError: o tree-sitter estacionou em um nó de erro o texto que não conseguiu entender e seguiu em frente. Tudo o que foi extraído do resto do arquivo é real — o que falta é o que quer que estivesse dentro desse nó, e isso é invisível de fora. É esta a razão para ler o arquivo você mesmo antes de confiar em uma página sobre ele.

Um array files vazio é uma afirmação positiva — "todo arquivo varrido parseou sem problemas". O artefato estar ausente significa que a análise é anterior a esse registro, o que não é a mesma coisa.

Por que os dois primeiros saem de scannedFiles

Um arquivo que não rendia fato algum costumava permanecer em scannedFiles, então a Phase 2a escrevia uma ficha para ele e o _coverage.json o contava como descrito. O handbook então afirmava, como fato do parser, que um arquivo que ninguém tinha lido contém zero funções. Tirar esses caminhos daqui mantém cada lista significando uma coisa só: scannedFiles é o que o analisador leu, scan-coverage.json é o que ele não conseguiu.

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

Este mesmo schema é o que você escreve à mão para --strategy member / --skeleton. children pode ser omitido ou estar desatualizado — ele é normalizado na carga.

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 renderizado (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

Índice para agentes (--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

O artefato humano explica; o artefato para agentes localiza. Não são duas renderizações de um mesmo texto. Onde um agente precisa da explicação, ela está a um salto de distância — cada página de etapa liga para a página humana em vez de copiá-la.

Por que TSV e não tabelas markdown

  • Uma tabela markdown estragaria silenciosamente 338 linhas de assinatura neste repositório, porque um tipo union do TypeScript contém |. Uma tabulação não colide com o texto do código-fonte.
  • Um fato por linha sobrevive ao truncamento. Toda receita de grep devolve uma resposta completa em uma linha — nome, localização, kind, etapa, quem chama e assinatura juntos — então um resultado cortado ainda é acionável.
  • Uma tabulação ancora uma coluna inteira: grep "^scan\t" casa com o símbolo chamado scan, e não com toda linha que contém a palavra.

A ordem das colunas é a ordem do valor, com a prosa por último, para que um consumidor que corta linhas longas coma a prosa antes de comer um caminho.

As linhas de cabeçalho

Toda tabela abre com linhas de comentário # que nomeiam as colunas e a fronteira de confiança — a mesma divulgação que o pipeline faz em todo o resto, trazida para o artefato que a carrega:

# 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.

O calls.tsv declara a sua, e nomeia a diferença entre os dois tipos de linha que ele carrega:

# 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.

Arestas de fronteira, e por que um monorepo precisa delas

Uma chamada que sai do conjunto analisado através de um import recebe boundary:<specifier> como localização do destino, nunca um caminho. O nome é um fato; a localização não é, e não é adivinhada.

Num monorepo isso não é uma nota de pé de página — é a maior parte do que um agente quer saber. Medido neste repositório: 1.063 de 3.565 arestas são de fronteira, 284 delas para @handbooks/core. Com apenas arestas resolvidas, checkLanguage — chamada quatro vezes de outro pacote — aparecia com zero chamadores, o que um agente lê como código morto. Isso é um ponteiro errado, não uma lacuna, e um ponteiro errado é justamente a falha que este artefato existe para evitar.

Pelo mesmo motivo, o nCalledBy de symbols.tsv conta os chamadores de fronteira junto com os do próprio pacote, e seu cabeçalho diz isso. boundary: não pode ser confundido com um caminho, então incluí-los não inventa nada.

Linhas de tipo, e o fallback abaixo delas

O symbols.tsv carrega três tipos de linha. fn é uma função ou um método. type:<kind> é uma declaração de tipo parseada — o intervalo é lido da própria declaração — sobre um vocabulário fechado: class, interface, struct, record, enum, trait, alias, other. record não é dobrado dentro de struct porque um record de Java ou C# é um tipo por referência, e struct é a única palavra deste vocabulário que também significa tipo por valor. other não é uma gaveta de descarte, e sim uma peça que sustenta: um tipo definido de Go (type Celsius float64) não é um alias, uma union de Rust não é um struct, um @interface de Java não é uma interface — e signature guarda a declaração como foi escrita, então a palavra-chave nativa nunca se perde.

Quais linguagens realmente extraem tipos é declarado por adaptador e divulgado no index.md, do mesmo jeito que a fidelidade da análise (invariante 3). AdapterCapabilities.typeKinds é uma lista e não um booleano, porque um adaptador poderia encontrar classes e perder todas as interfaces; [] é uma afirmação positiva, e o campo estar ausente significa que o artefato é anterior a ele — reportado como unknown, nunca como zero.

Todas as doze linguagens analisadas com precisão os extraem: C++, C#, Dart, Go, Java, PHP, Python, Ruby, Rust, Solidity, Swift e TypeScript. O Shell declara [] porque não tem declarações de tipo. As cinco de nível genérico (Kotlin, Objective-C, OCaml, Scala, Zig) declaram [] de propósito: o adaptador delas reconhece padrões em vez de fazer um parse preciso, então uma linha de tipo vinda dali seria indistinguível no IR de uma analisada com precisão, com fidelidade menor — exatamente o que o invariante 3 existe para evitar. Elas mantêm o fallback class-derived.

Medido em repositórios reais, comparando linhas com as declarações que um grep enxerga: PHP e Solidity 100%, C# 98,9%, Swift 97,0%, Dart 96,1%, Ruby 92,7%, C++ 87,5% (apenas dos arquivos que fizeram parse limpo; os headers cheios de macros do spdlog derrotam a própria gramática, o que o scan-coverage.json registra). Cada diferença é uma declaração que o adaptador se recusou a adivinhar — um tipo declarado dentro do corpo de uma função, ou um nome que colide sob o modelo de ids sem aridade — nunca um intervalo inventado.

class-derived é o fallback para quando um adaptador não extrai tipos: o intervalo é o min…max dos métodos da classe — onde os membros estão, não onde está a declaração — então ele é rotulado em vez de apresentado como um fato parseado. Neste repositório, acrescentar extração real de tipos derrubou class-derived de 45 linhas para 19, e cada uma das restantes é um objeto literal e não uma declaração de tipo, que é exatamente o que o fallback deveria estar pegando.

Um custo de usar o intervalo da própria declaração: quando ela leva um atributo ou uma anotação à frente, o intervalo começa ali, porque é ali que o nó da gramática começa. A assinatura está protegida disso: se o limite fosse cortar o nome do tipo, os atributos são elididos com um inicial, porque uma assinatura que não nomeia o que declara não é uma assinatura mais curta, e sim uma assinatura inútil.

A divulgação importa mais do que a cobertura: um agente que faz grep de um nome de tipo, não obtém nada e conclui que o tipo não existe é o ponteiro errado que este artefato existe para evitar. Constantes, variáveis e macros não são indexadas em nenhuma linguagem, e o index.md diz isso.

Atualidade

O cabeçalho do index.md carrega HandbookModel.provenance{ commit?, generatedAt }, lido do manifesto da execução. Os números de linha agora são a carga principal, e um número de linha desatualizado é o único fato que dá errado silenciosamente, então o artefato diz quando foi feito e contra o quê.

Pacote 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}]}

Contrato de validação (handbook validate): o frontmatter tem exatamente name + description; a description declara quando usar E quando não usar; o corpo referencia references/index.md e direciona para o código-fonte real; overview/index/registers/stages presentes; o índice liga todas as páginas de etapa; nenhum caminho de cobertura duplicado; com --source, os hashes precisam bater com a árvore ao vivo. Um diretório references/agent/ é opcional, mas quando existe ele precisa carregar o index.md e as três tabelas — o índice e suas tabelas de fatos são entregues juntos ou não são entregues.

Saída do planner (handbook plan)

Um plano em markdown: resumo em prosa → blocos EDIT → um bloco JSON de declarações.

### 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": [] }
```

Diretório de caso do 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}

Nesta página