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": ["…"] } } },
}callType ∈ self_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…" },
],
}reason | O que o parser obteve | Está em scannedFiles? | Ganha ficha? |
|---|---|---|---|
unreadable | nada — a leitura falhou | ❌ | ❌ |
unparsable | nada — a gramática lançou erro | ❌ | ❌ |
partial | fatos 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. Odetailcarrega 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 contendocaseproduz hoje.partial— o arquivo parseou, mas comrootNode.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 infrastructureEste 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 pairsO 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 chamadoscan, 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}Variáveis de ambiente
Cada variável que o Handbooks lê, a regra de nomenclatura que as gera, a cascata de .env e quais delas jamais podem entrar em um arquivo de configuração.
Suporte a linguagens
18 linguagens em dois níveis de análise — quais extensões cada uma reivindica, do que o nível generic abre mão e as duas ressalvas que vale conhecer antes de esbarrar nelas.