Formatos de los artefactos
Cada archivo que escribe el pipeline, su esquema y qué lo valida al leerlo.
Cada artefacto que la cadena de herramientas lee o escribe, en orden de pipeline. Todos
los artefactos JSON/YAML llevan un campo version y se validan con esquemas zod de
@handbooks/core al leerlos. Las rutas son relativas POSIX a la raíz del código fuente
analizado salvo que se indique lo contrario.
Estructura del directorio de trabajo
<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 — esas viven
en 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" },
],
},
}Categorías: inherited_method, self_attr_unknown, string_literal_method, builtin,
local_var_method, bare_name.
phase1/scan-coverage.json
El hermano de dropped-calls.json, un nivel más arriba: aquel da cuenta de cada
llamada que el analizador se negó a adivinar; este da cuenta de cada archivo que se
negó a dar por analizado.
{
"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 | Qué obtuvo el parser | ¿En scannedFiles? | ¿Recibe ficha? |
|---|---|---|---|
unreadable | nada — falló la lectura | ❌ | ❌ |
unparsable | nada — la gramática lanzó una excepción | ❌ | ❌ |
partial | hechos reales, pero incompletos | ✅ | ✅ |
unreadable— el descubrimiento listó la ruta, pero la lectura falló: un modo de permisos, un symlink colgante, un archivo que el build borró bajo los pies de la ejecución.detaillleva el mensaje de errno.unparsable— la gramática lanzó una excepción, o no devolvió árbol alguno. Cero hechos. Es lo que produce hoy un script de shell que contienecase.partial— el archivo parseó, perorootNode.hasError: tree-sitter aparcó en un nodo de error el texto que no supo entender y siguió adelante. Todo lo extraído del resto del archivo es real — lo que falta es lo que quedó dentro de ese nodo, que desde fuera es invisible. Esta es la razón para leer tú mismo el archivo antes de fiarte de una página sobre él.
Un array files vacío es una afirmación positiva — «todos los archivos escaneados
parsearon limpiamente». Que el artefacto esté ausente significa que el análisis es
anterior a este registro, que no es lo mismo.
Por qué los dos primeros se quitan de scannedFiles
Antes, un archivo que no daba ningún hecho se quedaba en scannedFiles, así que la phase 2a le escribía una
ficha y _coverage.json lo contaba como descrito. El handbook afirmaba entonces, como hecho del parser,
que un archivo que nadie había leído no contiene ninguna función. Quitar esas rutas de aquí mantiene una
lista con un solo significado: scannedFiles es lo que el analizador leyó, y scan-coverage.json lo que no
pudo leer.
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 mismo esquema es el que escribes a mano para --strategy member / --skeleton.
children puede omitirse o estar desactualizado — se normaliza al cargar.
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 pairsEl artefacto humano explica; el artefacto para agentes ubica. No son dos renderizados de un mismo texto. Donde un agente necesita la explicación, la tiene a un salto de distancia — cada página de etapa enlaza a la página humana en lugar de copiarla.
Por qué TSV y no tablas markdown
- Una tabla markdown estropearía en silencio 338 filas de firma de este repositorio,
porque un tipo unión de TypeScript contiene
|. Un tabulador no colisiona con el texto del código fuente. - Un hecho por línea sobrevive al truncamiento. Cada receta de grep devuelve una respuesta completa en una sola línea — nombre, ubicación, kind, etapa, quiénes lo llaman y firma, todo junto — así que un resultado recortado sigue siendo accionable.
- Un tabulador ancla una columna entera:
grep "^scan\t"coincide con el símbolo llamadoscan, no con cada línea que contiene esa palabra.
El orden de las columnas es orden de valor, con la prosa al final, de modo que un consumidor que recorte las líneas largas se coma la prosa antes que una ruta.
Las líneas de cabecera
Cada tabla se abre con líneas de comentario # que nombran las columnas y la frontera de
confianza — la misma divulgación que el pipeline hace en todas partes, trasladada al
artefacto que la lleva:
# 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 declara la suya, equivalente, y nombra la diferencia entre los dos tipos de fila
que lleva:
# 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.Aristas de frontera, y por qué un monorepo las necesita
Una llamada que sale del conjunto analizado a través de un import recibe
boundary:<specifier> como ubicación del destino, nunca una ruta. El nombre es un hecho; la
ubicación no lo es, y no se adivina.
En un monorepo esto no es una nota al pie: es la mayor parte de lo que un agente quiere saber.
Medido en este repositorio: 1.063 de 3.565 aristas son de frontera, 284 de ellas hacia
@handbooks/core. Con solo aristas resueltas, checkLanguage — llamada cuatro veces desde otro
paquete — aparecía con cero llamadores, lo que un agente lee como código muerto. Eso es un
puntero equivocado, no una laguna, y un puntero equivocado es precisamente el fallo que este
artefacto existe para evitar.
Por la misma razón, nCalledBy en symbols.tsv cuenta los llamadores de frontera además de
los del propio paquete, y su encabezado lo dice. boundary: no puede confundirse con una
ruta, así que incluirlos no inventa nada.
Filas de tipo, y el respaldo que hay debajo
symbols.tsv lleva tres clases de fila. fn es una función o un método. type:<kind> es una
declaración de tipo parseada — el rango se lee de la declaración misma — sobre un
vocabulario cerrado: class, interface, struct, record, enum, trait, alias,
other. record no se pliega dentro de struct porque un record de Java o C# es un tipo
por referencia, y struct es la única palabra de este vocabulario que además significa
tipo por valor. other no es un cajón de sastre sino una pieza que carga peso: un tipo
definido de Go (type Celsius float64) no es un alias, una union de Rust no es un struct, un
@interface de Java no es una interfaz — y signature conserva la declaración tal como está
escrita, así que la palabra clave nativa nunca se pierde.
Qué lenguajes extraen tipos de verdad se declara por adaptador y se divulga en index.md,
igual que la fidelidad del análisis (invariante 3). AdapterCapabilities.typeKinds es una
lista y no un booleano, porque un adaptador podría encontrar clases y perderse todas las
interfaces; [] es una afirmación positiva, y que el campo esté ausente significa que el
artefacto es anterior a él, lo que se reporta como unknown, nunca como cero.
Los doce lenguajes analizados con precisión los extraen: C++, C#, Dart, Go, Java, PHP, Python,
Ruby, Rust, Solidity, Swift y TypeScript. Shell declara [] porque no tiene declaraciones de
tipo en absoluto. Los cinco de nivel genérico (Kotlin, Objective-C, OCaml, Scala, Zig) declaran
[] a propósito: su adaptador reconoce patrones en vez de analizar con precisión, así que
una fila de tipo suya sería indistinguible en el IR de una analizada con precisión, con una
fidelidad menor — precisamente lo que el invariante 3 existe para evitar. Conservan el
respaldo class-derived.
Medido contra repositorios reales, comparando filas con las declaraciones que un grep ve:
PHP y Solidity 100%, C# 98,9%, Swift 97,0%, Dart 96,1%, Ruby 92,7%, C++ 87,5% (solo de los
archivos que se analizaron sin errores; las cabeceras cargadas de macros de spdlog derrotan a
la propia gramática, algo que scan-coverage.json registra). Cada diferencia es una
declaración que el adaptador se negó a adivinar — un tipo declarado dentro del cuerpo de
una función, o un nombre que colisiona bajo el modelo de identificadores sin aridad — nunca un
rango inventado.
class-derived es el respaldo para cuando un adaptador no extrae tipos: el rango es el
min…max de los métodos de la clase — donde están los miembros, no donde está la
declaración — así que va etiquetado en vez de presentarse como un hecho parseado. En este
repositorio, añadir extracción real de tipos bajó class-derived de 45 filas a 19, y cada una
de las que quedan es un objeto literal y no una declaración de tipo, que es exactamente lo que
el respaldo debería estar capturando.
Un costo de tomar el rango propio de la declaración: cuando lleva un atributo o una anotación
delante, el rango empieza ahí, porque ahí empieza el nodo de la gramática. La firma está
protegida de eso: si el límite fuera a cortar el nombre del tipo, se eliden los atributos
con un … inicial, porque una firma que no nombra lo que declara no es una firma más corta,
sino una firma inútil.
La divulgación importa más que la cobertura: un agente que hace grep de un nombre de tipo, no
obtiene nada y concluye que el tipo no existe es el puntero equivocado que este artefacto
existe para evitar. Las constantes, las variables y las macros no se indexan en ningún
lenguaje, y index.md lo dice.
Frescura
La cabecera de index.md lleva HandbookModel.provenance — { commit?, generatedAt },
leído del manifiesto de la ejecución. Los números de línea son ahora la carga principal, y
un número de línea obsoleto es el único hecho que se estropea en silencio, así que el
artefacto dice cuándo se hizo y contra qué.
Paquete 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 validación (handbook validate): el frontmatter tiene exactamente name +
description; la descripción declara cuándo usarlo Y cuándo no usarlo; el cuerpo
referencia references/index.md y remite al código fuente real;
overview/index/registers/stages presentes; el índice enlaza todas las páginas de etapa;
sin rutas de cobertura duplicadas; con --source, los hashes deben coincidir con el
árbol vivo. Un directorio references/agent/ es opcional, pero cuando existe debe llevar
index.md y las tres tablas — el índice y sus tablas de hechos se incluyen juntos o no
se incluyen.
Salida del planificador (handbook plan)
Un plan en markdown: resumen en prosa → bloques EDIT → un bloque JSON de declaraciones.
### 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": [] }
```Directorio de caso de 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}Variables de entorno
Todas las variables que Handbooks lee, la regla de nombres que las genera, la cascada de .env y cuáles no deben entrar nunca en un archivo de configuración.
Soporte de lenguajes
18 lenguajes repartidos en dos niveles de análisis — qué extensiones reclama cada uno, a qué renuncia el nivel generic y las dos salvedades que conviene conocer antes de toparte con ellas.