Handbooks
Referenz

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": ["…"] } } },
}

callTypeself_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…" },
  ],
}
reasonWas der Parser bekamIn scannedFiles?Bekommt eine Karte?
unreadablenichts — das Lesen schlug fehl
unparsablenichts — die Grammatik hat geworfen
partialechte, 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. detail trägt die errno-Meldung.
  • unparsable — die Grammatik hat geworfen oder gar keinen Baum geliefert. Null Fakten. Genau das erzeugt heute ein Shell-Skript mit case.
  • partial — die Datei wurde geparst, aber rootNode.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 infrastructure

Genau 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 page

Agenten-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 pairs

Das 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 namens scan, 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}

Auf dieser Seite