Artefakt-Formate
Jede Datei, die die Pipeline schreibt, ihr Schema und was sie beim Lesen validiert.
Jedes Artefakt, das die Werkzeugkette liest oder schreibt, in Pipeline-Reihenfolge. Alle
JSON-/YAML-Artefakte tragen ein version-Feld und werden beim Lesen mit zod-Schemata aus
@handbooks/core validiert. Pfade sind POSIX-relativ zur analysierten Quell-Wurzel,
sofern nicht anders angegeben.
Aufbau des Arbeitsverzeichnisses
<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 (nie unresolved — die leben in
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" },
],
},
}Kategorien: inherited_method, self_attr_unknown, string_literal_method, builtin,
local_var_method, bare_name.
phase1/scan-coverage.json
Das Gegenstück zu dropped-calls.json, eine Ebene höher: Jene Datei führt jeden
Aufruf auf, den der Analyzer nicht raten wollte, diese hier jede Datei, von der er
nicht behaupten wollte, sie analysiert zu haben.
{
"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 | Was der Parser bekam | In scannedFiles? | Bekommt eine Karte? |
|---|---|---|---|
unreadable | nichts — das Lesen schlug fehl | ❌ | ❌ |
unparsable | nichts — die Grammatik hat geworfen | ❌ | ❌ |
partial | echte, aber unvollständige Fakten | ✅ | ✅ |
unreadable— die Erkennung hat den Pfad aufgelistet, aber das Lesen schlug fehl: ein Zugriffsrecht, ein ins Leere zeigender Symlink, eine Datei, die der Build während des Laufs gelöscht hat.detailträgt die errno-Meldung.unparsable— die Grammatik hat geworfen oder gar keinen Baum geliefert. Null Fakten. Genau das erzeugt heute ein Shell-Skript mitcase.partial— die Datei wurde geparst, aberrootNode.hasError: tree-sitter hat den Text, den es nicht verstand, in einem Fehlerknoten geparkt und ist weitergelaufen. Alles, was aus dem Rest der Datei extrahiert wurde, ist echt — es fehlt, was in jenem Knoten steckte, und das ist von außen unsichtbar. Genau deshalb lohnt es sich, die Datei selbst zu lesen, bevor man einer Seite über sie traut.
Ein leeres files-Array ist eine positive Aussage — „jede gescannte Datei wurde
sauber geparst“. Ist das Artefakt nicht vorhanden, ist die Analyse älter als dieser
Nachweis, was nicht dasselbe ist.
Warum die ersten beiden aus scannedFiles entfernt werden
Eine Datei, die keine Fakten ergab, blieb früher in scannedFiles, also schrieb Phase 2a eine Karte für sie
und _coverage.json zählte sie als beschrieben. Das Handbuch behauptete dann als Parser-Fakt, dass eine
Datei, die niemand gelesen hatte, null Funktionen enthält. Diese Pfade hier herauszunehmen sorgt dafür, dass
jede Liste genau eine Bedeutung behält: scannedFiles ist, was der Analyzer gelesen hat,
scan-coverage.json ist, was er nicht lesen konnte.
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 infrastructureGenau dieses Schema verfasst man von Hand für --strategy member / --skeleton.
children darf fehlen oder veraltet sein — es wird beim Laden normalisiert.
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
],
}Gerendertes Handbuch (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 pageAgenten-Index (--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 pairsDas Artefakt für Menschen erklärt; das Artefakt für Agenten verortet. Es sind nicht zwei Darstellungen desselben Textes. Wo ein Agent die Erklärung braucht, liegt sie einen Sprung entfernt — jede Etappenseite verlinkt die Seite für Menschen, statt sie zu kopieren.
Warum TSV und keine Markdown-Tabellen
- Eine Markdown-Tabelle würde 338 Signaturzeilen in diesem Repository stillschweigend
verstümmeln, denn ein TypeScript-Union-Typ enthält
|. Ein Tab kollidiert nicht mit Quelltext. - Ein Fakt pro Zeile übersteht das Abschneiden. Jedes grep-Rezept liefert eine vollständige Antwort auf einer Zeile — Name, Ort, Art, Etappe, Aufrufer und Signatur zusammen —, ein gekürztes Ergebnis bleibt also brauchbar.
- Ein Tab verankert eine ganze Spalte:
grep "^scan\t"trifft das Symbol namensscan, nicht jede Zeile, die das Wort enthält.
Die Spalten stehen in der Reihenfolge ihres Werts, Prosa zuletzt, damit beim Kürzen langer Zeilen zuerst Prosa wegfällt und nicht ein Pfad.
Die Kopfzeilen
Jede Tabelle beginnt mit #-Kommentarzeilen, die die Spalten und die Vertrauensgrenze
benennen — dieselbe Offenlegung, die die Pipeline überall sonst macht, hier auf das Artefakt
verschoben, das sie trägt:
# 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 sagt das Entsprechende und benennt den Unterschied zwischen den zwei Zeilenarten,
die es trägt:
# 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.Grenzkanten, und warum ein Monorepo sie braucht
Ein Aufruf, der die untersuchte Menge über einen Import verlässt, erhält als Ort des
Aufgerufenen boundary:<specifier> — nie einen Pfad. Der Name ist eine Tatsache; der Ort ist
keine, und er wird nicht geraten.
In einem Monorepo ist das keine Fußnote, sondern das meiste von dem, was ein Agent wissen
will. In diesem Repository gemessen: 1.063 von 3.565 Kanten sind Grenzkanten, 284 davon
nach @handbooks/core. Nur mit aufgelösten Kanten erschien checkLanguage — aus einem anderen
Paket viermal aufgerufen — mit null Aufrufern, was ein Agent als toten Code liest. Das ist
ein falscher Zeiger, keine Lücke, und ein falscher Zeiger ist genau das Versagen, dessen
Verhinderung dieses Artefakt existiert.
Aus demselben Grund zählt nCalledBy in symbols.tsv Grenz-Aufrufer zusammen mit denen aus
dem Paket selbst, und der Header sagt das. boundary: kann nicht mit einem Pfad verwechselt
werden, das Einbeziehen erfindet also nichts.
Typ-Zeilen, und der Rückfall darunter
symbols.tsv trägt drei Zeilenarten. fn ist eine Funktion oder Methode. type:<kind> ist
eine geparste Typdeklaration — die Spanne wird an der Deklaration selbst gelesen — über
einem geschlossenen Vokabular: class, interface, struct, record, enum, trait,
alias, other. record wird nicht in struct gefaltet, weil ein record in Java oder C#
ein Referenztyp ist und struct das eine Wort dieses Vokabulars ist, das auch Werttyp
bedeutet. other ist keine Restkiste, sondern tragend: ein definierter Typ in Go
(type Celsius float64) ist kein Alias, eine union in Rust ist kein struct, ein
@interface in Java ist kein Interface — und signature hält die Deklaration so, wie sie
geschrieben steht, das native Schlüsselwort geht also nie verloren.
Welche Sprachen wirklich Typen extrahieren, wird pro Adapter deklariert und in index.md
offengelegt, genau wie die Analysetreue (Invariante 3). AdapterCapabilities.typeKinds ist
eine Liste und kein Boolean, weil ein Adapter Klassen finden und jedes Interface verpassen
könnte; [] ist eine positive Aussage, und ein fehlendes Feld bedeutet, dass das Artefakt
älter ist als es — gemeldet als unknown, niemals als Null.
Alle zwölf präzise geparsten Sprachen extrahieren sie — C++, C#, Dart, Go, Java, PHP, Python,
Ruby, Rust, Solidity, Swift, TypeScript. Shell deklariert [], weil es überhaupt keine
Typdeklarationen hat. Die fünf Sprachen der generischen Stufe (Kotlin, Objective-C, OCaml,
Scala, Zig) deklarieren [] absichtlich: ihr Adapter erkennt Muster statt präzise zu
parsen, eine Typ-Zeile von dort wäre also im IR nicht von einer präzise geparsten zu
unterscheiden, bei geringerer Treue — genau das, dessen Verhinderung Invariante 3 existiert.
Für sie bleibt der class-derived-Rückfall.
An echten Repositories gemessen, Zeilen gegen die Deklarationen, die ein grep sieht: PHP und
Solidity 100 %, C# 98,9 %, Swift 97,0 %, Dart 96,1 %, Ruby 92,7 %, C++ 87,5 % (nur aus Dateien,
die sauber geparst wurden; die makrolastigen Header von spdlog besiegen die Grammatik selbst,
was scan-coverage.json festhält). Jeder Rückstand ist eine Deklaration, bei der der Adapter
das Raten verweigert hat — ein Typ innerhalb eines Funktionskörpers, oder ein Name, der im
aritätsfreien Id-Modell kollidiert — nie eine erratene Spanne.
class-derived ist der Rückfall dort, wo ein Adapter keine Typen extrahiert: die Spanne ist
min…max der Methoden der Klasse — wo die Mitglieder sind, nicht wo die Deklaration ist —
deshalb wird sie gekennzeichnet und nicht als geparste Tatsache präsentiert. In diesem
Repository fiel class-derived durch echte Typextraktion von 45 Zeilen auf 19, und jede
verbleibende ist ein Objektliteral und keine Typdeklaration — genau das, was der Rückfall
fangen soll.
Ein Preis dafür, die eigene Spanne der Deklaration zu nehmen: trägt sie vorn ein Attribut oder
eine Annotation, beginnt die Spanne dort, weil dort der Grammatikknoten beginnt. Die Signatur
ist davor geschützt: würde die Obergrenze den Namen des Typs abschneiden, werden stattdessen
die Attribute ausgelassen, mit einem führenden … — denn eine Signatur, die nicht nennt,
was sie deklariert, ist keine kürzere Signatur, sondern eine nutzlose.
Die Offenlegung zählt mehr als die Abdeckung: ein Agent, der einen Typnamen greppt, nichts
findet und schließt, dass der Typ nicht existiert, ist der falsche Zeiger, dessen
Verhinderung dieses Artefakt existiert. Konstanten, Variablen und Makros werden in keiner
Sprache indexiert, und index.md sagt das.
Frische
Der Kopf von index.md trägt HandbookModel.provenance — { commit?, generatedAt }, aus
dem Run-Manifest gelesen. Zeilennummern sind jetzt die eigentliche Nutzlast, und eine
veraltete Zeilennummer ist der eine Fakt, der stillschweigend falsch wird. Also sagt das
Artefakt, wann es entstanden ist und wogegen.
SKILL-Paket (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}]}Validierungsvertrag (handbook validate): das Frontmatter hat genau name +
description; die Beschreibung nennt Einsatz UND Nicht-Einsatz; der Rumpf verweist auf
references/index.md und leitet zur tatsächlichen Quelle; overview/index/registers/stages
sind vorhanden; der Index verlinkt jede Etappenseite; keine doppelten Abdeckungspfade; mit
--source müssen die Hashes zum lebenden Baum passen. Ein
Verzeichnis references/agent/ ist optional, aber wenn es existiert, muss es index.md
und alle drei Tabellen tragen — der Index und seine Faktentabellen werden zusammen
ausgeliefert oder gar nicht.
Planer-Ausgabe (handbook plan)
Ein Markdown-Plan: Fließtext-Zusammenfassung → EDIT-Blöcke → ein Deklarations-JSON-Block.
### 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-Fallverzeichnis (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}Umgebungsvariablen
Jede Variable, die Handbooks liest, die Benennungsregel, die sie erzeugt, die .env-Kaskade und die, die nie in eine Konfigurationsdatei gehören.
Sprachunterstützung
18 Sprachen in zwei Analysestufen — welche Endungen jede beansprucht, worauf die generische Stufe verzichtet, und die zwei Vorbehalte, die man kennen sollte, bevor man auf sie stößt.