Files
chemenu/CHANGES.md
T
torben 82a22eaa93
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s
stack: Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention (schliesst #55)
Files changed:
- .gitea/workflows/ci.yml
- .gitignore
- CHANGES.md
- EVALS.md
- INSTALL-MCP.md
- INSTALL.md
- VERSION
- instructions/setup-instance.md
- reports/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/mcp/server.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/telemetry/schema.py
- tools/chemenu/telemetry/writer.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_telemetry_emit.py
- tools/chemenu/tests/test_telemetry_policy.py
- tools/chemenu/version.py
2026-09-10 23:38:22 +02:00

5075 lines
327 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Changelog
This file tracks changes to the **wiki stack itself** - `AGENTS.md`, the
`instructions/` layer, `tools/wikitool`, and the contracts. It is distinct from
`kb/log.md`, which is the audit trail of *wiki content* operations (ingests,
queries, lints, page creates/updates) performed by the LLM against `kb/`.
Previously each of `AGENTS.md` and `README.md` carried its own "Version
History" table. Those have been consolidated here so there is one place to
look for "what changed in the tooling/schema, and when." From now on,
document any change to the stack (schema, instructions, `wikitool` commands,
contracts) as a new entry at the top of this file instead of editing inline
version history tables.
Since `0.1.0` an entry's heading also carries the stack version it describes
(`## <version> - <date> - <title>`). `wikitool version bump` writes that
heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
their date-only headings.
Since `4.4.0` the stack carries **one running candidate** between two
releases rather than a fresh version per bump - see
`instructions/dev/version-parts.md`. While a candidate is open its heading
names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
every bump of that same candidate updates this one entry in place rather than
opening another: the heading's version/date/title move, and the bump's
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
the entry's `**Author:**` line - written and read by `wikitool version bump`,
never by hand. `wikitool version release` is what closes a candidate: it
strips the suffix and turns the entry into an ordinary, suffix-free one,
leaving the bump-title list as the record of what happened. A distributed
instance never sees a `-beta.` version at all (`release.yml` only ever
releases a fixed one), so this suffix and the list beneath it are a
dev-checkout concern - readable here, never shipped as something to parse.
---
## 5.0.0-beta.9 - 2026-09-10 - Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- status/incoming: menschliche Stubs werden ausgearbeitet, nie so umgesetzt
- page move: eine kb-Seite folgt ihrem Subtype ins Verzeichnis, das ihr Type-Spec berechnet
- kb/CONTRACT.md: Tiefe 1 als Grenze - Katalog liest nur eine Area-Ebene
- raw accept: incoming/ als abgeleiteter Rohablage-Eingang (schliesst #58)
- raw accept: Stem-Eindeutigkeit im Typverzeichnis erzwingen, --replaces als einziger Weg daran vorbei (schliesst #64)
- update entity naming conventions to use singular form for consistency
- kb/concepts/ bekommt Areas: layout: fuer concept, Area-Titel aus jedem Type-Spec, Schwellen-Empfehlung im lint (schliesst #59)
- source_type: Default streichen, unclassified als sichtbares Fach, layout: fuer source (schliesst #66)
- raw accept: Datums-Shard statt Typverzeichnis, fidelity/authority am Drop-Punkt (schliesst #67)
- source_type ist Instanzsache: Profilkatalog, Setup-Frage, evolve-subtypes-Instruction (#68)
- instructions/CONTRACT.md: Skill-H1, Referenztiefe und Begruendungsprosa praezisiert (#71, #72, #79)
- SKILL.md-Sanierung: Checklisten, Kommandolisten, wiki-status-Hard-Rule, wiki-query-Filingpruefung (#70, #74, #75, #78)
- Ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
- TOC-Pflicht fuer Referenzdateien ueber 100 Zeilen; session-setup.md/gates.md nennen die tatsaechliche Budget-Ausnahmeliste (schliesst #73, #76)
- tools/CONTRACT.md: docs toc im Fehlerkontrakt, docs-verify-Zeile nennt die TOC-Pruefung; tools/README.md korrigiert das --major-Kriterium
- dist export erzeugt die TOC-Region nach dem Marker-Strip neu (CI-Fund im Export-Replay)
- Konfidenz-Mechanismus ersatzlos entfernt
- Konfidenz-Mechanismus ersatzlos entfernt
- version-parts.md dokumentiert den --migration-required-Ruecknahmepfad
- wiki-status verweist auf session-setup.md (schliesst #84)
- CLAUDE.md-Importkette entdrifted, Modellwahl nach docs/ verschoben (schliesst #81)
- Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention
<!-- /wikitool:bumps -->
**Breaking Change:** docs verify verlangt eine aktuelle Inhaltsverzeichnis-Region (<!-- wikitool:toc -->) auf AGENTS.md, jedem Stage-/Collection-Contract und jeder flachen instructions/**.md-Datei ueber 100 Zeilen - eine bestehende Instanz mit einer eigenen instructions/*.md-Datei ueber 100 Zeilen ohne TOC sieht docs verify nach dem Tool-Update neu fehlschlagen, bis einmalig 'wikitool docs toc --apply' laeuft und der Diff committet wird. Ausserdem verschwinden wikitool confidence decay und confidence init-base ersatzlos, touch --confidence-base ebenso, und die Schemas fuer entity/concept verlieren confidence/confidence_base vollstaendig (additionalProperties: false) - jede bestehende Instanz muss den Korpus migrieren, sonst werden alle Entity-/Concept-Seiten beim Schema-Update sofort schemainvalide. Ablauf: instructions/migrations/5.0.0-confidence-removal.md
Das Label `status/incoming` gibt es seit heute in Gitea: der Mensch legt einen
Stub an — zwei Sätze, ein Verdacht, ein „wäre interessant" — und der Stack
komplettiert ihn. `instructions/dev/issue-tracking.md` beschrieb es nicht, und
das ist die gefährlichere Hälfte: ein Stub *sieht aus wie* ein Body, und der
Body ist genau das, was eine Sitzung nach Schritt 2 als Spec glaubt. Zwei
solche Issues lagen bereits offen auf dem Board (#60, #61).
Neu in der Instruction ist deshalb ein eigener Abschnitt „Incoming stubs" mit
dem Verbot als erstem Satz — ein `status/incoming`-Issue wird nie so umgesetzt,
wie es dasteht — und der Ausarbeitung als sechsschrittigem Ablauf: den Wortlaut
als Absichtserklärung lesen, gegen den Baum prüfen, den Originaltext wörtlich in
den Kommentar retten, bevor der Rewrite ihn überschreibt, offene Fragen benennen
statt beantworten (`kind/decision`), erst dann die vier Pflichtlabel, dann das
Flag entfernen. Zwei Ausgänge wie bei `status/unconfirmed`: ausgearbeitet oder
mit Begründung geschlossen.
Der interessante Punkt ist Schritt 4: `status/incoming` ist das einzige Flag,
das Schritt 4 nicht qualifiziert, sondern **aussetzt**. Die vier Pflichtachsen
fehlen einem Stub nicht, sie sind noch nicht fällig — `kind/`, `prio/` und
`size/` sind Antworten auf Fragen, die niemand gegen den Baum geprüft hat. Ein
Stub auf Sicht durchzulabeln ist der Fehler, nicht das Weglassen: es lässt
Ungeprüftes triagiert aussehen.
Der Schaden, den das verhindert, ist derselbe wie bei #30, nur eine Stufe
früher: dort schrieb ein Body einen Mechanismus vor und bekam dessen Bugs
gebaut, hier schreibt ein Body gar nichts vor und bekommt die Lücke von
demjenigen gefüllt, der ihn am schnellsten gelesen hat — inklusive Close, womit
die Frage, für die der Stub stand, nie wieder gestellt wird.
Keine neue Schrittnummer, bewusst: `stack-close/SKILL.md` und ältere
`CHANGES.md`-Einträge verweisen namentlich auf „Schritte 2-3 und 7". Eine
Umnummerierung hätte diese Verweise still falsch gemacht — genau der Zerfall,
den dieselbe Datei in § Renames beschreibt.
Geändert: [instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md)
(Frontmatter, § When to run, Schritt 5, neuer § Incoming stubs, § What no tool
checks, § Decision points) und die Routing-Zeile in
`instructions/dev/stack-dev/SKILL.md`. Dev-only — `dist export` schließt
`instructions/dev/` aus, eine ausgelieferte Instanz sieht davon nichts, deshalb
PATCH.
Nachgezogen in derselben Sitzung, aber als eigenes Paket (#63, Commit
`a0aecfc`): `kb/concepts/Issue Label Scheme.md` beschrieb weiter nur die beiden
alten `status/`-Flags und zählte sechzehn statt siebzehn Labels. Das ist
`kb/`-Inhalt und brauchte nach Invariante 3 erst eine Quelle — die Threadkopie
von #62/#63 in `raw/notes/` — also eine `wiki-manage`-Sitzung statt dieser
hier. Die Seite trägt jetzt die dritte `status/`-Zeile samt Verbot und einen
Kernpunkt dazu, dass dieses Flag die Vier-Achsen-Pflicht aussetzt.
Offen bleibt die erste tatsächliche Anwendung der Regel: #60 und #61 sind
weiterhin unausgearbeitete Stubs.
**`wikitool move`** (#56): Nichts im Stack bewegte bisher eine Seite über eine
Verzeichnisgrenze — `rename` schreibt laut eigenem Docstring nie über
`target.path.parent` hinaus, und `new_page._target_dir` rechnete die
Platzierung nur beim Anlegen. Ändert sich `entity_type` später, blieb die
Datei still am alten Ort liegen, und nichts prüfte das nach: weder
`lint_core.py` noch `doctor.py` verglichen den Ist-Ort einer Seite mit dem,
was ihr Type-Spec berechnen würde.
Die Platzierungslogik selbst gibt es jetzt genau einmal:
`TypeResolver.compute_target_dir` (plus `TypeResolver.subtype_dir` für den
`layout:`-Teil), `new_page._target_dir` delegiert nur noch dorthin. Darauf
aufbauend zwei neue Stücke:
- `wikitool move --page "<Titel>"` bewegt eine Seite an den berechneten Ort;
`--reconcile` wendet dieselbe Regel auf den ganzen Bestand an und ist
idempotent (ein zweiter Lauf meldet nichts mehr zu tun). Beide Modi fassen
weder Body noch Frontmatter an, und der Titel — die einzige Identität einer
Seite — ändert sich nie, also folgt kein Referenz-Update. Ein bereits
belegtes Ziel (ein alter Stem-Kollisionsrest) wird verweigert statt still
überschrieben. Top-level registriert, wie `rename`/`rm`, nicht unter einem
`page`-Unterbefehl.
- `lint` bekommt einen neuen Befund, **Misplaced Pages**, mit Ist- und
Soll-Pfad. Bewusst nicht in `HARD_ERROR_KEYS`: ein von Hand platzierter
Bestand ist keine kaputte Seite, nur eine, die `move` aufräumen könnte — auf
dieser Instanz sind das aktuell die drei `kb/entities/projects/*/`-Seiten,
die #57 separat behandelt.
Ein Fund unterwegs, der ohne #56 unsichtbar geblieben wäre: `migrate verify`
schlüsselte Seiten über den repo-relativen **Pfad**. Ein reiner Move ergab
„N removed, N added, 0 compared" und lief grün durch — der eine mechanische
Check, für den `instructions/migrate-corpus.md` existiert, hätte bei genau der
Operation nichts geprüft, die dieses Issue einführt. Behoben: `PageShape`
trägt jetzt zusätzlich den Pfad, `_shapes_at_revision`/`_shapes_now`
schlüsseln über den Titel (die einzige Identität einer Seite), und
`CorpusDiff.moved` meldet einen reinen Ortswechsel separat — informativ,
niemals als Finding. Regressionstest deckt drei verschobene Seiten mit
`compared == 3, added == 0, removed == 0` ab.
Bewusst nicht Teil dieses Pakets: die drei realen Seiten aus #57 bleiben
liegen (kein Korpus-Publish hier, nur Stack), und `raw rename` (#16) — das
`git mv`-mit-`mv`-Fallback aus #56s Entwurf war für Rohdateien gedacht;
`rename`/`rm`/`move` bewegen kb-Seiten über ein einfaches `Path.rename`, weil
`publish` ohnehin über `git add -A` staged.
Geändert: `tools/chemenu/type_resolver.py` (`compute_target_dir`,
`subtype_dir`), `tools/chemenu/commands/new_page.py` (delegiert),
`tools/chemenu/commands/page_ops.py` (`move_command`),
`tools/chemenu/lint_core.py` (`find_misplaced`, `misplaced_pages`),
`tools/chemenu/corpus_diff.py` und `tools/chemenu/commands/migrate_cmd.py`
(Titel-Schlüsselung, `moved`), `tools/chemenu/commands/log_append.py` (`--op
move`), `tools/CONTRACT.md`, `instructions/page-lifecycle.md`,
`instructions/publish-cycle.md`. MINOR: rückwärts liest ein älterer Stack eine
verschobene Seite unverändert (Identität ist der Titel, nicht der Ort),
vorwärts reines Überkopieren.
**Katalogtiefe** (#57): `index_build.group_pages` las bislang genau zwei
Pfadsegmente unter `kb/` (`parts[0]` als Collection, `parts[1]` als Area) und
faltete alles darunter still in die Level-1-Area. Real betroffen waren die
drei Seiten aus #56s Befund, `kb/entities/projects/{kfchou,vanillaflava,
yugasun}/*.md` — im generierten Katalog nicht als eigener Ort sichtbar,
sondern als läge jede direkt in `entities/projects/`. `kb/CONTRACT.md` §
Collections beschrieb bis heute nur eine Ebene, ohne eine zweite
auszuschließen; der Baum hatte trotzdem eine, handplatziert, ohne
unterstützten Weg dorthin.
Entscheidung war (b) aus dem Issue: die drei Verzeichnisse auflösen statt den
Katalog rekursiv zu machen. Die Gruppierungsachse dahinter — Owner
(`kfchou`/`vanillaflava`/`yugasun`) — kommt aus keinem Frontmatter-Feld und
aus keinem Type-Spec, sondern aus einer Ad-hoc-Entscheidung beim Anlegen; sie
verdient keine zweite Verzeichnisebene. Tiefe 1 ist jetzt geschriebene Regel
in `kb/CONTRACT.md` § Collections, mit dieser Begründung.
Vier Stücke setzen das um:
- **`kb_scan.find_nested_pages`** liefert `(title, page, depth)` für jede
Seite mehr als ein Verzeichnis unterhalb ihrer Collection — reine
Pfadtiefe, unabhängig davon, ob der Typ auflöst, damit auch eine Seite mit
kaputtem `type:` nicht durchrutscht.
- **`lint`** bekommt den Befund **Nested Pages**, und anders als
`misplaced_pages` **hart**: eine fehlplatzierte Seite katalogisiert noch
korrekt von der falschen Stelle aus, eine verschachtelte macht den
*generierten* Katalog selbst falsch, und es gibt keine Version, ab der das
toleriert würde.
- **`index rebuild`** lehnt eine verschachtelte Seite nicht ab, sondern warnt
(Entscheidung aus der Session: melden statt verweigern, damit ein
Fremdinstanz-Upgrade mit handverschachtelten Seiten nicht hart bricht) —
`group_pages` faltet weiterhin wie zuvor, die Warnung ist die neue
Sichtbarkeit, nicht eine Verhaltensänderung der Faltung selbst.
- **`TypeResolver.get_layout`** validiert `layout: {dir: ...}` jetzt auf
einen einzelnen Pfadabschnitt (kein `/`, kein `\`, kein `.`/`..`, nicht
leer) und schlägt fehl statt eine zweite Ebene über den einzigen
unterstützten Weg — ein Type-Spec — entstehen zu lassen.
Ein fünftes Stück, das das Issue selbst nicht explizit forderte, aber die
`move`-Mechanik aus #56 sonst mit toten Verzeichnissen zurückgelassen hätte:
**`move`** (`--page` wie `--reconcile`) entfernt jetzt ein Verzeichnis, das es
durch den Wegzug seiner letzten Seite geleert hat — symmetrisch zum
`mkdir(parents=True)` auf der Zielseite. Ohne das hätten `kfchou/`,
`vanillaflava/`, `yugasun/` den eigenen Fix überlebt, leer und für git
unsichtbar, aber für einen verzeichnisbasierten Test sichtbar.
Auf dieser Instanz angewendet: `wikitool move --reconcile` hat die drei
Seiten nach `kb/entities/projects/` gezogen und die drei leeren
Owner-Verzeichnisse mitentfernt. `migrate verify --from HEAD` bestätigt
`compared == 182, added == 0, removed == 0`, alle drei als `moved` markiert.
Titelkollision trat wie im Issue erwartet keine auf.
Geändert: `tools/chemenu/kb_scan.py` (`find_nested_pages`),
`tools/chemenu/lint_core.py` (`nested_pages`, `HARD_ERROR_KEYS`),
`tools/chemenu/commands/index_build.py` (Rebuild-Warnung),
`tools/chemenu/type_resolver.py` (`get_layout`-Validierung),
`tools/chemenu/commands/page_ops.py` (`_rmdir_if_emptied`), `tools/CONTRACT.md`,
`kb/CONTRACT.md` § Collections, plus die drei realen Seiten unter
`kb/entities/projects/`. PATCH: reine Codeänderung ohne Schnittstellenwechsel,
gefaltet in den offenen `4.8.0`-Kandidaten (max-wins gegen die MINOR-Bewegung
aus #56); die drei bewegten Seiten sind Korpus dieser Instanz, kein
ausgelieferter Inhalt.
Schließt #57.
**`raw accept`** (#58): `raw/CONTRACT.md`s Routing-Tabelle war bislang eine Regel für Menschen —
wer eine Datei ablegt, wählt `articles/`/`documents/`/`notes/`/`assets/` selbst, und mehrere
Dateien einer logischen Quelle waren im Dateisystem nicht als zusammengehörig erkennbar. Neu ist
ein gitignorierter Eingang `incoming/`, der dieselben vier Typverzeichnisse spiegelt: der Mensch
klassifiziert nur, indem er dort ablegt, `tools/wikitool raw accept <datei> [<datei> ...]
[--page "<Titel>"]` berechnet die Beförderung nach `raw/`.
Zwei Entscheidungen, gegen die ursprüngliche Skizze im Issue: ein Bundle-Verzeichnis
(`raw/<typ>/<stamm>/`, benannt nach der ersten Datei) entsteht erst ab der zweiten Datei, nie
einheitlich — damit sind die 29 heute flach liegenden Bestandsdateien keine Ausnahme, sondern
bereits die Regelform, und die Frage „was passiert mit dem Bestand" beantwortet sich von selbst.
Und der Typ wird über das Eingangs-Unterverzeichnis deklariert, nicht über ein `--type`-Flag: die
Erklärung wird abgegeben, wenn der Mensch die Datei in der Hand hat, statt im Moment des
`accept`-Aufrufs neu geraten werden zu müssen.
`--page` deckt den Wachstumsfall ab: erweitert `raw_files:` einer bestehenden Source-Seite und
faltet deren schon abgelegte Einzeldatei ins neue Bundle, sobald das die Seite über eine Datei
hinaus wachsen lässt — ohne ein Fenster, in dem `raw_files:` ins Leere zeigt. Die dafür nötige
Rückwärtssuche und der Mehrfach-Owner-Schutz sind keine neue Mechanik, sondern
`provenance.source_pages_by_raw_file`, das `lint` schon für `duplicate_raw_file_owners` benutzt —
ein Owner-Konflikt lehnt die Beförderung ab, statt eine andere Seite unbemerkt zu brechen.
`incoming/` ist für `sources coverage` und `lint` unsichtbar (beide laufen ausschließlich über
`config.iter_raw_files(config.RAW_DIR)`), und dass keine Datei dort je committet werden kann, ist
über `docs_verify.REQUIRED_IGNORE_CANARIES` bewiesen, nicht nur zugesichert. `RAW_SUBDIRS`
(`dist_cmd.py`) bleibt die einzige Quelle der Vier-Verzeichnis-Liste: `docs verify`
(`check_raw_subdirs`) hält `raw/CONTRACT.md`s Tabelle jetzt in beiden Richtungen dagegen, und
`dist export` sät `incoming/<typ>/.gitkeep` neben `raw/<typ>/.gitkeep`; `instructions/bootstrap.md`
legt den Eingang für einen bestehenden Klon nach, da er dort nie aus git kommt.
Geändert: `tools/chemenu/commands/raw_cmd.py` (neu, `raw accept`), `tools/chemenu/cli.py`,
`tools/chemenu/commands/dist_cmd.py` (`RAW_SUBDIRS`-Kommentar, `incoming/*/.gitkeep`),
`tools/chemenu/commands/docs_verify.py` (`check_raw_subdirs`, `incoming/`-Ignore-Kanarie),
`.gitignore`, `raw/CONTRACT.md`, `tools/CONTRACT.md`, `instructions/bootstrap.md`,
`instructions/wiki-ingest/SKILL.md`. MINOR: eine Umsortierung des Bestands wäre die Grenze
gewesen, findet aber unter der Bundle-erst-ab-zwei-Regel nicht statt — der Bestand bleibt
unangetastet, keine fremde Instanz muss migrieren, vorwärts wie rückwärts reines Überkopieren.
Schließt #58.
`raw accept` prüfte Kollisionen bisher nur auf einzelnen Dateipfaden
(`dst.exists()`), nie auf dem Bundle-Verzeichnis selbst. Weil der Bundle-Name
aus dem Stem der Primärdatei entsteht, konnte eine zweite, unabhängige Quelle
wortlos in das Bundle einer ersten wandern, sobald die Dateinamen zufällig
nicht kollidierten — verifiziert mit einem Wegwerf-Test: `handbuch.txt` +
`anhang.md` ohne `--page` landeten unbemerkt in einem bestehenden
`raw/documents/handbuch/`. `lint` meldete nichts, weil beide Quellen ihre
Dateien korrekt abdeckten.
Die Menge der Namen auf `raw/<typ>/`-Ebene — Dateistämme plus
Bundle-Verzeichnisnamen — ist jetzt eindeutig erzwungen (`_occupied_stems` in
`raw_cmd.py`), unter Ausnahme dessen, was der Aufruf selbst schon besitzt: ein
Bundle, das über `--page` wächst, oder ein bereits registriertes, sich
fortsetzendes Bundle. Ein belegter Stem wird mit Exit 1 abgelehnt und nennt
beide Auswege, ohne einen zu empfehlen — `--replaces` oder Umbenennen in
`incoming/`.
`--replaces <raw-pfad>` ist der einzige sanktionierte Weg, eine Rohdatei
wortwörtlich zu ersetzen: genau eine eingehende Datei, identischer Dateiname,
gleiches Typverzeichnis, Ablehnung bei mehr als einem Owner. `raw_files:`
bleibt unverändert, es wird keine `kb/`-Seite geschrieben, und die Altfassung
lebt ausschließlich in `git log --follow` weiter — kein Archivverzeichnis,
kein Hash im Dateinamen, kein neues Frontmatter-Feld. Nach einem Ersatz nennt
das Kommando die Source-Seite und ihre zitierenden Seiten, damit deren
Nachzug im selben Commit passiert wie die Ersetzung.
`raw/CONTRACT.md` § Rules trägt beide Regeln aus dieser Entscheidung
(unveränderlich, aber ersetzbar als Ganzes); `instructions/wiki-ingest/SKILL.md`
benennt den Kollisionsfall als Haltepunkt, an dem eine Sitzung die Meldung dem
Menschen vorlegt statt selbst zu entscheiden — dieselbe Klasse wie AGENTS.md
Invariante 6, auch ohne dass hier ein Exit-42-Gate greift.
Geändert: `tools/chemenu/commands/raw_cmd.py` (`_occupied_stems`,
`_stem_collision_message`, `--replaces`/`_replace`),
`tools/chemenu/tests/test_raw_cmd.py` (16 neue Tests), `raw/CONTRACT.md`,
`tools/CONTRACT.md`, `instructions/wiki-ingest/SKILL.md`. MINOR: `raw accept`
wurde nie released (letztes Release `v4.7.4`), die Verschärfung kostet also
keine Kompatibilitätsfrage, solange sie vor `4.8.0` landet.
Schließt #64.
**`kb/concepts/` bekommt Areas** (#59): Sharden ist längst automatisch —
`index_build.SHARD_THRESHOLD = 50`, hergeleitet aus der wikieigenen Seite
`Index Scaling` — aber es passiert **pro Area**, und eine Area legt niemand an.
`kb/concepts/` hatte keine, also war die Schwelle dort ein toter Wert: 80 Seiten
in einer einzigen Tabelle, weit über der eigenen Grenze, ohne dass je etwas
gefeuert hätte. Die Ursache war eine Asymmetrie in den Type-Specs — `entity`
deklarierte ein `layout:`, `concept` nicht, obwohl das Subtype-Feld fertig dalag.
`types/concept.md` deklariert es jetzt für alle sechs `concept_type`-Werte
(`architectures/`, `patterns/`, `protocols/`, `workflows/`, `decisions/`,
`problems/`). Für `source` bewusst **nicht**: 25 von 29 Seiten sind `notes`, die
Aufteilung ergäbe eine Area und vier Splitter, und `kb/sources/` liegt mit 29
Seiten ohnehin unter der Schwelle. Ein Subtype-Feld zu haben ist kein Grund, es
als Achse zu benutzen.
Zwei Dinge im Code, beide Folgen desselben Befunds. `_area_titles()` in
`index_build.py` löste `entity` fest über `find_type_by_name("entity")` auf und
las nur dessen `layout:` — jeder zweite Typ mit einem `layout:` hätte
`.title()`-Namen auf dem Verzeichnisnamen bekommen statt der deklarierten Titel.
Es liest jetzt jedes Type-Spec, und zwar **pro Collection** geschlüsselt, damit
zwei Typen denselben Area-Namen für Verschiedenes benutzen dürfen. Und der neue
`lint`-Befund meldet eine Collection über der Schwelle **ohne** Areas, mit der
Verteilung ihres Subtype-Felds — als Empfehlung, nicht als Failure, und nur
dann, wenn die Aufteilung jede entstehende Area unter die Schwelle drückt. Das
begrenzt sich selbst in beide Richtungen: `kb/comparisons/` mit einer Seite
feuert nie, und die schlechte Aufteilung nach `source_type` unterbleibt von
allein, ohne dass der Check etwas über Sources wüsste.
Zwei Dinge fielen unterwegs an, die das Issue nicht vorhergesehen hatte.
`lint_core.py` durfte `SHARD_THRESHOLD`/`group_pages` nicht aus
`commands/index_build.py` importieren — `test_api.py` prüft strukturell, dass
`chemenu.api` kein Modul unter `chemenu.commands` lädt, und der Import hätte den
ganzen CLI-Kopf mitgezogen. Die Gruppierung liegt deshalb neu in
`tools/chemenu/catalog.py`, entlang derselben Linie wie `lint_core.py`:
Korpusform hier, Darstellung dort. Und `_anchor()` strich mit `[^a-z0-9\s-]`
jeden Nicht-ASCII-Buchstaben ersatzlos — die Karte verlinkte auf `#ablufe`,
während die Überschrift im Shard `#abläufe` heißt. Vorher fiel das keinem auf,
weil alle Entity-Area-Titel zufällig ASCII sind; `Abläufe` ist der erste, der es
nicht ist.
Auf dieser Instanz angewendet: `wikitool move --reconcile` hat alle 80
Concept-Seiten in ihre Area gezogen, `migrate verify --from HEAD` bestätigt
`182 compared, 0 added, 0 removed, 80 moved, 0 findings` — kein Titel, kein
Body, kein Frontmatter-Feld angefasst. `index rebuild` erzeugt sechs Areas
(Abläufe 28, Architekturen 20, Muster 17, Entscheidungen 7, Problemstellungen 5,
Protokolle 3); keine über der Schwelle, also kein eigener Shard, und die
Schwelle wirkt wieder als Schwelle.
Geändert: `types/concept.md` (`layout:`), `types/type-spec.md` (wann ein
`layout:` sich lohnt), `tools/chemenu/catalog.py` (neu),
`tools/chemenu/commands/index_build.py` (`area_titles`, `_anchor`),
`tools/chemenu/lint_core.py` (`unsharded_collections`),
`tools/chemenu/tests/` (Fixture-Concept liegt jetzt in seiner Area, plus neun
neue Tests), `tools/CONTRACT.md`, `tools/README.md`, `README.md`,
`kb/concepts/COLLECTION.md`, sowie die 80 bewegten Seiten unter `kb/concepts/`.
**MINOR**, nicht MAJOR: der Umzug ist ein **Angebot**, kein Zwang. Eine
bestehende Instanz, die `move --reconcile` nicht laufen lässt, bleibt
funktionsfähig — `group_pages` liest das Dateisystem, nicht das `layout:`, also
landen flache Bestandsseiten in der Area „All" und neu angelegte in ihrer
eigenen; beides rendert. Der gemischte Zustand meldet sich als `lint`-Befund
*Misplaced Pages*, der seit jeher advisory ist. Und ein Downgrade auf einen
Stack ohne dieses `layout:` funktioniert weiter: die Verzeichnisse bleiben
Verzeichnisse, nur die Anzeigetitel fallen auf `.title()` zurück. Kosmetik, kein
Bruch der Austauschbarkeit in beiden Richtungen.
Schließt #59.
**Nachzug an `63b4bb8`:** die Umstellung der Namenskonvention auf `HA Integration`
hatte in `README.md` das Gegenbeispiel verloren — die Zeile las
``Use singular for entities: `HA Integration.md` (not `HA Integration.md`)``, beide
Seiten des „not" identisch, also eine Regel ohne Fall, an dem sie greift.
`kb/CONVENTIONS.md` und `kb/entities/COLLECTION.md` hatten im selben Commit das
korrekte Paar bekommen; `README.md` zieht jetzt mit `HA Integrations.md` nach.
`source_type` hatte in `types/source.schema.yaml` ein `default: notes` — der Compiler wählte
das Sammelbecken, sobald niemand widersprach, nicht ein Mensch. #59 hatte den Bestand deshalb
für lopsided gehalten (25 von 29 Seiten `notes`) und `source` bewusst flach gelassen; nachgezählt
nach dem, was die Seiten tatsächlich sind (Dateiname, `author:`, Rohdatei), waren es 16
Session-Transkripte, 4 LLM-Analysen, 2 Tracker-Exporte und nur 3 echte Notizen. Der Default war
der Fehler, nicht das Enum.
Umgesetzt: `default:` gestrichen, `wikitool new source` verweigert jetzt ohne expliziten Wert.
Enum neu: `transcript`, `analysis`, `article`, `document`, `notes`, `tracker`, `unclassified` —
`spec` und `image` entfallen (null Seiten, nie am echten Material bewährt). `unclassified` ist
das neue, sichtbare Fach für eine Quelle, deren Kategorie noch nicht feststeht — eigene Area,
beratender `lint`-Befund (`unclassified_source_pages`), keine harte Fehlerklasse. `types/source.md`
deklariert jetzt ein `layout:` für alle sieben Werte.
Auf dieser Instanz angewendet, in einem eigenen `work/reclassify-source-types/`-Lauf: 22 der 29
Source-Seiten per `wikitool touch --set source_type=<wert>` auf ihren tatsächlichen Wert
korrigiert (16 transcript, 4 analysis, 2 tracker), 7 unverändert. `wikitool move --reconcile` hat
alle 29 danach in ihre Area gezogen (transcripts 16, analyses 4, articles 3, notes 3, trackers 2,
documents 1, unclassified 0). `migrate verify --from HEAD` bestätigt `182 compared, 0 added,
0 removed, 29 moved, 22 findings` — die 22 sind exakt die beabsichtigten `source_type`-Änderungen,
kein Titel, kein Body, keine Wikilink- oder Zitatzahl angefasst. `lint --fail-on-error` grün,
insbesondere ohne *Misplaced Pages*, *Nested Pages* oder den neuen *Unclassified Source Pages*.
Geändert: `types/source.schema.yaml` (Enum, kein Default), `types/source.md` (`layout:`),
`types/type-spec.md` (`source` als Beispiel für ein bewusst fehlendes `layout:` ersetzt —
ein lopsided Feld wird repariert, nicht dauerhaft flach gelassen), `tools/chemenu/lint_core.py`
(`unclassified_source_pages`, neu), `tools/CONTRACT.md`, `instructions/wiki-ingest/SKILL.md`
(`--set source_type=`, `unclassified` als Ausweg), `instructions/dev/corpus-policy.md`
(Floor-Ausnahme für `unclassified`), `kb/sources/COLLECTION.md` (Areas, Autorschaft trennt
`analysis` von `document`), fünf Tests umgehängt (davon einer auf einen neuen
Fixture-Type-Spec, weil `source` als „hat Subtype-Feld, kein `layout:`"-Beispiel wegfällt),
sowie die 29 bewegten und 22 reklassifizierten Seiten unter `kb/sources/`.
**MINOR**, geprüft am Drop-in-Test: eine bestehende Instanz besitzt ihr eigenes
`types/source.md` (Auslieferung nur als `.template`), kopiert `tools/`/`types/`/`instructions/`
über sich und bleibt unverändert funktionsfähig — kein Downgrade-Bruch, kein umgeschriebenes
maschinengelesenes Format. Kein `--breaking`, kein Migrationsdokument.
Schließt #66.
**`raw accept`: Datums-Shard statt Typverzeichnis, `fidelity`/`authority` am Drop-Punkt** (#67,
Paket B von vier — A ist #66 oben, C ist #68, D ist #69): zwei unabhängige Befunde, ein
Codepfad.
Befund 1: `raw/CONTRACT.md`s vier Typverzeichnisse (`articles/`, `documents/`, `notes/`,
`assets/`) lösten keinen der drei Gründe ein, die einen Verzeichnis-Split rechtfertigen —
`raw/` wird nie durchgeblättert, keine Klausel dieser Datei galt je pro Verzeichnis, alle vier
verrotten gleich (unveränderlich, nie gelöscht). Der Split kostete real: der Mensch trifft beim
Ablegen in `incoming/<typ>/` eine Routing-Entscheidung, die später blind nach `source_type:`
abgeschrieben wird — genau darüber entstand der in #66 korrigierte Bias (`raw/notes/` hielt laut
altem Contract-Text „Gesprächsprotokolle", 16 der 25 Dateien dort waren tatsächlich Transkripte).
Befund 2: was am Drop-Punkt bekannt ist und danach nirgends mehr — wie treu eine Erfassung ist
und was das Material über seinen Gegenstand behaupten darf. Zwei neue, unabhängige Achsen auf
`types/source.md`: `fidelity` (`verbatim`/`published`/`secondhand`/`nontextual`) und `authority`
(`normative`/`reporting`/`opinion`), beide mit `unknown` als backfill-only-Wert.
Umgesetzt:
- `raw accept` adressiert eine Datei jetzt über `raw/<YYYY>/<MM>/`, berechnet aus dem
Annahmedatum — eine reine Funktion von etwas Unveränderlichem, kann also nie rebalancieren und
keinen `[^cite-id]`-Anker brechen. `incoming/` wird flach; ein Unterverzeichnis wird toleriert
und ignoriert statt inspiziert (alte `incoming/<typ>/`-Skripte laufen unverändert weiter).
Bestandsdateien in `raw/articles|documents|notes|assets/` bleiben unbewegt und weiter gültige
`--replaces`-Ziele — das Layout war nirgends versioniert, es gibt also keine „zwei Korpusformen".
Wächst eine bereits promotete Einzeldatei zum Bündel, entsteht das Bündel an ihrem eigenen
Speicherort, nie im heutigen Shard — ein Bündel aus altem und neuem Datum hätte keine
eindeutig richtige Adresse.
- Stem-Eindeutigkeit (#64) gilt jetzt global über `raw/`, nicht mehr pro Typverzeichnis —
ohne Typverzeichnisse als Grenze wäre die Prüfung sonst wirkungslos gegen ein Bündel in einem
anderen Shard oder einem Alt-Verzeichnis.
- `fidelity`/`authority`: neu in `types/source.schema.yaml`, ohne `default:` und bewusst
**nicht** in `required:` (sonst bricht jede bestehende Instanz an der Validierung — die
MINOR-Einstufung unten hängt daran). Erzwungen stattdessen im Werkzeug: `raw accept` verlangt
beide Flags immer; trägt der Aufruf `--page`, schreibt es sie direkt auf die Zielseite, sonst
druckt es die fertige `new source --set fidelity=... --set authority=...`-Folgezeile, und
`new source` verweigert seinerseits ohne beide Werte. `unknown` ist backfill-only — weder
`raw accept` noch `new source` dürfen es schreiben.
- Capture-Felder sind **fill-once**, nicht auf `touch.py`s `UNSETTABLE`-Denylist: eine Denylist
hätte auch den ersten (Backfill-)Schreibzugriff verboten, den der Migrationslauf braucht.
`touch --set <feld>=<wert>` schreibt nur, solange das Feld fehlt, und verweist danach auf
`raw accept --replaces` als einzigen Korrekturweg — der einzige Aufruf, der einen bereits
gesetzten Capture-Wert überschreiben darf, weil eine korrigierte Erfassung eine neue Edition
der Quelle ist, keine Bearbeitung der Seite. `types/source.md` deklariert die Feldliste selbst
(`capture_fields:`), gelesen über `TypeResolver.get_capture_fields` statt an drei Stellen
hartkodiert.
- `lint` bekommt einen neuen beratenden Befund, *Confidence Above Source Standing*: die
Autoritätsbewertung, die `kb/CONVENTIONS.md`s Confidence-Rubrik seit je verlangt („+0.1 für
offizielle Doku"), aber nirgends festhielt. Eine stackseitige Obergrenzentabelle in
`kb/CONTRACT.md` (`reporting` 0.8, `opinion` 0.6, `secondhand`/`nontextual` 0.7, `normative`/
`verbatim`/`published`/`unknown` ohne Obergrenze) begrenzt `confidence_base`, ersetzt es aber
nicht — eine Formel hätte zwei widersprechende Ableitungen derselben Zahl, und Autorität ist
eine Obergrenze, kein Determinant. Nicht in `HARD_ERROR_KEYS`. Auf dieser Instanz meldet der
Befund aktuell nichts: kein Bestand trägt die neuen Felder, das ist erwartet, nicht geprüft.
- Aufgeräumt: `dist_cmd.RAW_SUBDIRS` und der darauf laufende `docs verify`-Check
(`check_raw_subdirs`) entfallen ersatzlos, `raw/CONTRACT.md`s Routing-Tabelle beschreibt
stattdessen den Shard, die Ignore-Kanarie wandert von `incoming/documents/probe.pdf` auf
`incoming/probe.pdf`.
**MINOR**, `4.8.0-beta.8` desselben Kandidaten — drei geprüfte Bedingungen: `raw accept` nimmt
weiterhin Dateien aus `incoming/<irgendwas>/` an, statt sie zu verweigern; `fidelity`/`authority`
stehen nicht in `required:`; die zwei neuen Pflichtflags an `raw accept` sind eine
Verhaltensänderung, aber dieselbe Einstufung, die #66s `new source`-Verweigerung im selben
Kandidaten schon bekam. Kein `--breaking`, kein Migrationsdokument nötig — kein Bestand wird
durch diesen Bump ungültig.
Geändert: `raw/CONTRACT.md`, `types/source.md`, `types/source.schema.yaml`,
`tools/chemenu/commands/raw_cmd.py` (Neufassung), `tools/chemenu/commands/new_page.py`
(Capture-Feld-Pflicht), `tools/chemenu/commands/touch.py` (`_capture_field_or_fail`,
Fill-once), `tools/chemenu/type_resolver.py` (`get_capture_fields`),
`tools/chemenu/commands/dist_cmd.py`/`docs_verify.py` (Aufräumen), `tools/chemenu/lint_core.py`
(neuer Befund), `kb/CONTRACT.md` (Obergrenzentabelle), `tools/CONTRACT.md`, `.gitignore`,
`instructions/bootstrap.md`, `instructions/wiki-ingest/SKILL.md` (Schritt 1 und 6), zugehörige
Tests.
Der Backfill über den Bestand lief als eigener `work/`-Lauf (`backfill-capture-fields`,
Commit `00220f8`) hinterher, nach `instructions/migrate-corpus.md` und durch das
Mass-Update-Gate: **29 Source-Seiten**, nicht 31 wie zwischenzeitlich im Issue notiert — die
höhere Zahl zählte `INDEX.md` und `COLLECTION.md` mit.
Die Regel dieses Laufs war enger als die des laufenden Betriebs, weil ein nachgetragener
Capture-Wert *erschlossen* ist und nicht *erhoben*: ein echter Wert nur dort, wo die Art des
Artefakts ihn aus dem Material selbst festlegt, sonst `unknown`. Ergebnis: `verbatim`+`reporting`
18 (16 Gesprächstranskripte, 2 Tracker-Exporte — beide wörtliche Mitschnitte, beide Protokoll
statt Festlegung), `secondhand`+`opinion` 3 (LLM-Analysen), `published`+`reporting` 2,
`published`+`normative` 1 (Karpathys Idea-File, das definierende Dokument seines eigenen
Gegenstands), `verbatim`+`normative` 1 (das qmd-README, dessen Rohdatei ihre Treue selbst
deklariert), `unknown`+`normative` 1, `unknown`+`reporting` 3.
**`fidelity: unknown` steht auf 4 der 29 Seiten, `authority: unknown` auf keiner.** Die
Asymmetrie ist der interessante Teil: wer für einen Gegenstand zuständig war, ließ sich überall
aus dem Material beantworten — wie treu ein selbstverfasstes Cheat Sheet oder ein Anweisungsdokument
„erfasst", nicht, weil der Enum für ein originär geschriebenes Artefakt keinen Wert hat. Das ist
kein Backfill-Fehler, sondern genau die Grenze, die `unknown` markieren soll.
Und der `lint`-Befund hat einen Fall — 67 sogar: nach dem Backfill melden **67 von 152 Seiten**
mit `confidence_base` mehr Konfidenz, als die Quellenlage trägt (47 gegen die 0.8-Grenze für
`reporting`, 20 gegen die 0.6-Grenze für `opinion`). Das ist kein Fehlalarm und auch keine
Nacharbeit dieses Eintrags: der Korpus ist zu gut der Hälfte aus Gesprächstranskripten kompiliert,
und die Rubrik in `kb/CONVENTIONS.md` lässt Quellenzahl und Aktualität allein bis 0.95 laufen,
während der Autoritätsterm der kleinste Summand ist. Ob daraus folgt, dass 67 Seiten überbewertet
sind oder dass die 0.8-Grenze für einen selbstdokumentierenden Korpus zu eng ist, ist eine
Entscheidung und keine Korrektur — sie hängt als Messung am Stub #60, der genau diesen Verdacht
ohne Zahlen aufgeschrieben hatte. Die Transkripte wurden ausdrücklich **nicht** auf `normative`
hochgestuft, nur damit der Report leiser wird.
Schließt #67.
**`source_type` ist Instanzsache: Profilkatalog, Setup-Frage, `evolve-subtypes`-Instruction**
(#68, Paket C von vier — A ist #66, B ist #67, D ist #69): reine Doku- und Instruction-Arbeit,
kein Korpus-Sweep.
Befund: `source_type`s sieben Werte (`transcript`, `analysis`, `article`, `document`, `notes`,
`tracker`, `unclassified`) beschreiben *diese* Instanz, nicht den Stack — gegen drei
hypothetische Zielinstanzen (Handball-Verein, Produktentwicklung, Pen-&-Paper) hat die Liste
fast nichts gemeinsam, während `fidelity`/`authority` (#67) in allen vieren dieselben Werte
bleiben. Architektonisch war das längst wahr (`types/source.md` trägt `root: kb`, `dist export`
liefert es nur als `.template`), nur stellte nichts die Frage: `instructions/kb-profiles.md`
riet im `entities`-Abschnitt „Adapt the area list first", sagte im `sources`-Abschnitt aber kein
Wort zu `source_type`. Zweiter Befund, aus #66 mitgenommen: das `unclassified`-Fach bekam einen
beratenden `lint`-Befund, aber nie eine Prozedur, es wieder zu leeren.
Umgesetzt:
- `kb-profiles.md`s `sources`-Abschnitt behandelt `source_type` jetzt wie `entities` seine Area-
Liste: als das, was zuerst anzupassen ist, mit zwei ausformulierten Domänenprofilen
(Handball-Verein, Pen-&-Paper) als Anschauung, und dem ausdrücklichen Gegenbeispiel
`fidelity`/`authority` — die sind Stack-Vokabular und stehen nicht zur Wahl.
- `instructions/setup-instance.md` Schritt 5 bekommt einen neuen Unterschritt: nach dem
Anwendungsgebiet fragen, `source_type`-Vorschlag ableiten, Enum und `layout:` in derselben
Bearbeitung setzen. Mit der Ansage, dass der Betreiber zum Setup-Zeitpunkt null Quellen hat und
seine Taxonomie vor jedem Material rät — das Ergebnis ist ein Startpunkt, keine Festlegung, und
`unclassified` bleibt in jedem Vorschlag erhalten.
- Neue Instruction `instructions/evolve-subtypes.md`, `manual: true`: benennt die
Weiterentwicklungsschleife, die werkzeugseitig schon vollständig existierte (Fach sehen →
Wert samt `layout:` ergänzen → `touch`/`move --reconcile` → `index rebuild`/`migrate verify`),
über alle drei Subtype-Achsen (`entity_type`, `concept_type`, `source_type` — alle drei tragen
`subtype_field:` **und** `layout:`; `comparison` keins von beidem). Zwei Regeln im Body: Wert
und Sweep sind untrennbar (ein deklarierter Wert ohne Seite lädt zum Raten ein — genau der
`notes`-Fall aus #66), und eine Aufnahmeschwelle von ≥3 Seiten, mit `spec`/`image` aus #66 als
Gegenbeispiel und einer benannten-Ausnahme-Klausel für Fälle wie `tracker` bei zwei Seiten.
`manual: true` verhindert, dass eine Taxonomie-Änderung in einen laufenden Ingest hineinstolpert
— erwähnt aus `kb-profiles.md`, `setup-instance.md` und `kb/sources/COLLECTION.md`, aus keinem
Skill, keiner AGENTS.md, keiner CLAUDE.md verlinkt.
- `kb/sources/COLLECTION.md` benennt `evolve-subtypes.md` an der `unclassified/`-Zeile.
Die vom Vorbereitungs-Body übernommene, ursprünglich vierte Maßnahme entfiel: die
`corpus-policy.md`-Floor-Ausnahme für `unclassified` steht dort bereits seit #66.
**MINOR**, geprüft gegen den Drop-in-Test: eine Instanz kopiert `instructions/` und `types/`
über sich, nichts wird umbenannt oder entfernt, kein Kommando, kein Flag, kein
maschinengelesenes Format. Kein `--breaking`, kein Migrationsdokument. `4.8.0-beta.9` desselben
Kandidaten.
Geändert: `instructions/kb-profiles.md`, `instructions/setup-instance.md`,
`instructions/evolve-subtypes.md` (neu), `kb/sources/COLLECTION.md`.
Schließt #68.
### `instructions/CONTRACT.md` § Writing an instruction: drei offene Fragen entschieden
Drei Issues aus der #65-Analyse zeigten auf denselben Abschnitt. Alle drei
enden dort, wo die Regel steht, nicht in einem Issue-Kommentar.
**Der Imperativ-Titel bindet eine Instruction, nicht ein `SKILL.md` (#71).**
Die Regel griff dem Wortlaut nach auf alle fünf Skills durch, deren H1
Nomenphrasen sind. Geprüft gegen die Primärquelle: Anthropic normiert `name`
und `description` und sagt zur Body-Überschrift nichts; die eigenen
Beispiel-Skills heißen `# PDF Processing`, `# BigQuery Data Analysis`. Dazu
das Sachargument — der H1 liegt auf keinem Retrieval-Pfad, weil über die
Aufnahme eines Skills die `description` entscheidet, die ab Sessionstart im
Kontext steht, während der Body erst beim Zugriff gelesen wird. In der Sitzung
kam ein Beleg dazu, den keines der Issues kannte: der vendorierte
`commonplace`-Korpus trägt dieselbe Imperativ-Titel-Regel, unabhängig
entstanden, und macht im selben Absatz dieselbe Ausnahme („for promoted skills,
the skill name is the title"). Die fünf Titel bleiben unverändert.
**Referenztiefe: Anthropics „one level deep" gilt gebündeltem Material (#72).**
Weg 2 der drei zur Wahl stehenden. Der Beleg für die Reichweite steht im
vendorierten `codex-skill-creator/SKILL.md`: die Beispiele der Regel sind
`DOCX-JS.md`, `REDLINING.md`, `OOXML.md` — alles Dateien *im Skill-Bündel*. Kein
Skill dieses Repos hat heute eine solche Datei, die Regel bindet hier also
wörtlich nichts. Für den Link von einem Skill auf einen repo-weiten Contract
fallen die beiden Hälften der Frage auseinander: die Mechanik (Zweit-Hop wird
womöglich nur angelesen) ist real und verzeichnisunabhängig, die Vorschrift ist
für diesen Fall von Anthropic nicht belegt. Die geteilten Contracts bleiben
geteilt — Invariante 8 hat sie dorthin gestellt, und § Frontload verlangt, dass
ein *Schritt* ohne Vorkontext entscheidbar ist, nicht dass jede Regel an ihm
wiederholt wird. Als Auflage bleibt das Billigere: ein Link sagt, was der
Schritt aus der Datei braucht.
Ob die Mechanik hier überhaupt beißt, wurde vor der Entscheidung im Eval-Aufbau
nachgesehen, und die Antwort ist: nicht messbar. Die L2-Trajectory-Regeln lesen
ausschließlich `wikitool.call`, `gate.*`, `publish.commit` und
`prompt.submitted` — keine Dateizugriffe eines Agenten; auf Claude Code ist
überhaupt kein Tool-Hook verdrahtet, ein `head -100` hinterlässt also keine
Spur. Die zweite Hälfte der Behauptung, was am Ende im Kontextfenster stand,
erzeugt konstruktionsbedingt nirgends ein Event. Ein kausaler A/B-Vergleich
bräuchte den L3-Runner, der entworfen und nicht gebaut ist. Das steht jetzt im
Contract: eine Festlegung über Reichweite, keine Messung. Der Nebenfund — L2
sieht auf dem primären Harness gar keine Tool-Calls — ist ein eigenes Issue
wert und nicht Teil dieser Änderung.
**Wieviel Begründung ein Schritt tragen darf, ist jetzt messbar (#79).** Der
alte Satz („Keep reasoning out of the body […] keep only enough reasoning to
decide edge cases") zog in zwei Richtungen, und die größte Instruction des
Repos lebte in der Lücke. Neu sind eine Keep/Cut-Tabelle und zwei Tests:
*Substitution* — die Passage streichen und den Schritt noch einmal lesen; rät
ein Agent ohne Vorkontext jetzt, war es eine Entscheidungshilfe und sie bleibt,
egal wie lang. *Once* — eine Entscheidungshilfe steht an dem Schritt, an dem die
Entscheidung fällt, und an genau einem solchen (Invariante 8). Danach gemessen
stand die `source_type`/Capture-Asymmetrie in `wiki-ingest` zweimal; sie steht
jetzt einmal, in Schritt 1, und Schritt 6 trägt die Anweisung plus Verweis.
Die beiden anderen in #79 genannten Stellen — Namenskollision in Schritt 1,
`## Not Extracted` in Schritt 6 — bestehen den Substitutionstest und bleiben.
214 → 211 Zeilen; die Kürzung ist nicht der Zweck, die Eindeutigkeit ist es.
**PATCH**, geprüft gegen den Drop-in-Test: eine Instanz kopiert
`instructions/` über sich, nichts wird umbenannt oder entfernt, kein Kommando,
kein Flag, kein maschinengelesenes Format, und der Rückweg funktioniert
genauso. Kein `--breaking`, kein Migrationsdokument.
Geändert: `instructions/CONTRACT.md` (§ Writing an instruction, drei neue
Unterabschnitte), `instructions/wiki-ingest/SKILL.md` (Schritte 1 und 6).
Schließt #71, #72 und #79.
### Vier Befunde in der Skill-Prosa, ein Publish
Der Rest der #65-Analyse, soweit er die fünf `SKILL.md` selbst betrifft. Vier
Issues, fünf Dateien, kein Codeanteil.
**Die Hard Rule von `wiki-status` war falsch (#70).** Sie sagte „read-only.
Never writes, scaffolds, or modifies any file" — und Schritt 2 ruft `lint`,
schreibt also einen Report, was Schritt 2 sogar selbst beschreibt. Ein Agent,
der die Regel wörtlich nimmt, kann den Skill nicht ausführen; einer, der ihn
ausführt, hat die stärkste Aussage des Dokuments gebrochen, bevor er Schritt 5
erreicht. Das ist die teurere Sorte Widerspruch, weil die Hard Rule genau die
Stelle ist, an der ein Konflikt entschieden wird. Sie lautet jetzt wie die von
`wiki-query` — read-only gegenüber Wiki-*Inhalt* — und benennt den einen Write
mitsamt Grund: `reports/` ist gitignored und trägt keine Wiki-Seite. Schritt 5
behauptet nicht mehr, es sei keine Datei geschrieben worden, sondern sagt, was
mit der geschriebenen *nicht* passiert (Semantic Review bleibt leer, nichts
wird ausgetragen — das ist `wiki-lint` Schritt 9). Der Decision Point „Never
publishes — nothing was written" trägt jetzt den wahren Grund: unter `kb/` hat
sich nichts geändert, und der Report kann gar nicht in einen Commit geraten.
**`wiki-ingest` und `wiki-lint` bekommen einen Abhak-Block (#74).** Anthropics
Skill-Doku empfiehlt für „particularly complex workflows" eine Checkliste, die
der Agent in die Antwort kopiert und mitführt. Zwölf Schritte fallen
unzweifelhaft darunter. Der Ausschlag gibt aber nicht die Länge, sondern was
still ausfällt: `## Not Extracted` in Schritt 6, die Coverage-Prüfung in
Schritt 10, die Lint-Kadenz in Schritt 12 — keiner davon erzeugt eine
Fehlermeldung, wenn er ausbleibt.
Die offene Frage des Issues — ob `wiki-lint` denselben Block bekommt — ist mit
ja beantwortet: neun Schritte, davon 3-6 reines Judgment, und ein Lauf, der
leise nur seine mechanische Hälfte gemacht hat, sieht aus wie ein
vollständiger. Damit haben zwei von fünf Skills einen Block und drei nicht, und
genau das wäre ohne festgeschriebenes Kriterium die nächste strukturelle
Ungleichheit im Sinne von #78. `instructions/CONTRACT.md` § Writing an
instruction trägt sie deshalb jetzt: ein Block, wenn **ein** Ablauf acht
Schritte oder mehr hat **und** darin still ausfallende Schritte stehen. Beide
Hälften nötig — ein langer Ablauf aus reinen Tool-Calls meldet seine Lücken
selbst, weil der nächste Call ohne den vorigen scheitert. Der Abschnitt nennt
die drei anderen Skills mit ihren Schrittzahlen, damit niemand aus Symmetrie
einen vierten Block nachrüstet.
**`wiki-query` prüfte nicht, bevor es filete (#75).** Die drei Kriterien
(Synthese über mehrere Seiten, etwas noch nicht Dokumentiertes, wird wieder
gefragt) standen im Filing-Schritt selbst, und der Skill darf mehrere Seiten
je Sitzung anlegen — es gab also keine Stelle, an der *jede* geplante Seite
einzeln gemessen wurde. Neuer Schritt 5 vor dem ersten `new`: Kandidaten
benennen, jeden für sich gegen alle drei halten, ein Stapel wird nie als Stapel
beurteilt. Wer durchfällt, wird nicht angelegt, sondern in der Antwort mit
einem Satz genannt — der Nutzer kann ihn trotzdem verlangen. Der bisherige
Filing-Schritt ist Schritt 6, `log append` Schritt 7, die Hard Rule zieht mit.
Der Mass-Update-Gate-Hinweis bleibt, sagt aber jetzt dazu, dass er keine
Ersatzprüfung ist: das Gate zählt Dateien und weiß nichts über Berechtigung,
und ein Stapel unter der Schwelle ist von ihm nicht freigegeben, nur nicht
angehalten worden. Dazu die `session-setup.md`-Zeile in derselben Form wie in
den drei anderen — Schritt 7 läuft *immer* und der Filing-Pfad zieht `new`,
`xref add` und die Rebuilds nach sich.
**Die Kommandolisten gingen mit den Schritten auseinander (#78).**
`cite add` fehlte in `wiki-ingest` und `wiki-manage`, obwohl beide es
ausdrücklich vorschreiben; `types describe` fehlte in `wiki-ingest`, wo
Schritt 6 die `source_type`-Werte daraus zieht; `xref add` fehlte in
`wiki-lint`, wo Schritt 1 das Umlabeln einer schwachen Kante darauf stützt;
`publish` fehlte in `wiki-lint` und `wiki-query`, wo je ein Decision Point es
beim Namen nennt. In die andere Richtung: `rm` stand in `wiki-lint`s Liste,
ohne dass ein Schritt es begründet — die gefährlichere Richtung der Drift, weil
`rm` Seiten löscht. Dazu `log status` (entscheidet den Trigger, gelaufen wird
es von `wiki-ingest`) und die nie benutzten Flags `lint --markdown` und
`lint --json`. Beide Streichungen stehen jetzt als **Deliberately absent** unter
der Liste, mit Grund — sonst trägt sie jemand aus Vollständigkeit wieder ein.
Nicht als Drift gezählt und bewusst gelistet geblieben: `publish`,
`log append`, `index rebuild` und `sources rebuild-index`, wo ein Skill sie
über `publish-cycle.md` delegiert. Ebenso `xref remove` in `wiki-manage`, das
zum Unlinking-Fall gehört, den der Skill als Ganzes an `page-lifecycle.md`
abgibt; auch das steht jetzt als Satz dort, nicht als stille Annahme.
Der zweite Teil von #78 ist die Symmetrie: `wiki-manage` und `wiki-status`
tragen jetzt einen Beispielblock wie die drei anderen. Fünf Skills mit
demselben Aufbau sollten denselben Aufbau haben — ein fehlender Abschnitt liest
sich sonst als Aussage („hier gibt es keine typischen Fälle"), die niemand
gemeint hat.
Die offene Frage aus #78 — ob `instructions verify` diesen Abgleich künftig
selbst macht — ist mit ja beantwortet und als #83 ausgelagert. Trivial ist er
nicht: die drei Ausnahmen oben (Delegation über `publish-cycle.md`, benannte
Delegation an eine andere Instruction, „Deliberately absent") müsste ein Prüfer
alle kennen, sonst meldet er bei jedem Lauf dieselben Stellen. Nur die dritte
hat heute einen maschinenlesbaren Anker — den Absatz, den dieser Eintrag oben
eingeführt hat.
**PATCH**, geprüft gegen den Drop-in-Test: eine Instanz kopiert `instructions/`
über sich, nichts wird umbenannt oder entfernt, kein Kommando, kein Flag, kein
maschinengelesenes Format, und der Rückweg funktioniert genauso. Kein
`--breaking`, kein Migrationsdokument.
Geändert: `instructions/wiki-status/SKILL.md`, `instructions/wiki-query/SKILL.md`,
`instructions/wiki-ingest/SKILL.md`, `instructions/wiki-lint/SKILL.md`,
`instructions/wiki-manage/SKILL.md`, `instructions/CONTRACT.md` (§ Writing an
instruction, neuer Unterabschnitt „When a skill carries a copy-in checklist").
`wiki-ingest` bleibt mit 233 Zeilen unter Anthropics 500er-Schwelle.
Schließt #70, #74, #75 und #78.
**Issue-Nummern in ausgelieferter Doku** (#77): `dist export` lieferte
Dateien aus, die im Fließtext auf Issue-Nummern dieses Trackers verwiesen —
„flat since Gitea #67", „`new source` refuses without it (Gitea #66)". In einer
verteilten Instanz zeigt das auf nichts. Der Leser kann den Verweis weder
auflösen noch als unauflösbar erkennen, und eine Regel sieht damit so aus, als
stütze sie sich auf einen Beleg, den niemand beibringen kann. Das Board liegt
im Ursprungs-Repo, und `instructions/dev/issue-tracking.md` — die einzige Datei,
die das überhaupt sagt — wird von `dist export` mit dem Rest von
`instructions/dev/` weggeschnitten. Gegenprobe zum eigenen Anspruch aus
`instructions/CONTRACT.md` § „Writing an instruction": „self-contained enough
for an agent with no prior context".
Gemessen statt geschätzt: ein Export in ein leeres Verzeichnis, `grep -rn
'#[0-9]'`, ergab **43 Treffer in 16 Dateien** außerhalb von `tools/**/*.py` —
`raw/CONTRACT.md` allein acht. Das Issue hatte zehn gelistet.
Aufgelöst wurde nicht durch eine Markierung, sondern durch Umformulierung:
**die Nummer fällt weg, die Datierung geht in Worte.** Aus „flat since Gitea
#67" wird „flat since the addressing scheme dropped type directories", aus
„**Pre-#67 files are not moved**" wird „**Files promoted under the old type
directories are not moved**". Der Satz trägt sich damit selbst — es gibt keine
repoweite Notation zu definieren und an genau einer Stelle zu halten
(Invariante 8), und kein Leser von `README.md` muss `AGENTS.md` geladen haben,
um sie aufzulösen. Rückverfolgbar bleibt es hier über `git blame` → Commit-
Message; die tragen die Nummern ohnehin.
Zwei Stellen, an denen der Zeiger *der ganze Wert* des Satzes war und in Worten
nichts übrig geblieben wäre, stehen jetzt in einem
`<!-- dist:strip-start/end -->`-Block: in `instructions/CONTRACT.md` (was ein
Test der Kontextfenster-Behauptung kosten würde) und in `EVALS.md` (wo die
Coverage-Lücken geschlossen werden). Im Dev-Repo sichtbar, im Export weg — die
bestehende Konvention aus `instructions/CONTRACT.md` § `instructions/dev/`, hier
zum zweiten Mal angewandt statt neu erfunden.
**`docs verify` prüft es jetzt** — die offene Frage des Issues, mit Ja
beantwortet. `check_no_issue_references` liest nicht den Arbeitsbaum, sondern
den Text, den `dist_cmd.build_plan()` schreiben würde: dort leben `ROOT_FILES`,
der `instructions/dev/`-Ausschluss und das `.template`-Rekeying schon, und der
Text hat seine Marker-Blöcke bereits verloren. Deshalb ist ein Strip-Block
automatisch exemptiert, ohne dass der Check ihn kennen müsste.
Der Einwand aus `instructions/dev/issue-tracking.md` § „What no tool checks" —
`wikitool` soll den Tracker nicht kennen — trägt hier nicht, und das ist die
Grenze, die der Abschnitt jetzt selbst zieht: `re.compile(r"#\d+")` hat keinen
Client, keine URL und keinen Begriff vom Zustand eines Issues. Der Check sieht
eine Eigenschaft des *Dokuments*, nicht des Boards. Gemessen: null False
Positives über den gesamten Export, weil Markdown-Anker aus Wortzeichen
bestehen (`](#gates)` matcht nicht). Der erste Fund war prompt der Satz, den
diese Sitzung selbst in `tools/CONTRACT.md` geschrieben hatte, um die Regel zu
erklären.
**`tools/**/*.py` bleibt bewusst außen vor**, mit ~90 Treffern in Docstrings und
Kommentaren. Ein Code-Kommentar adressiert, wer die Zeile editiert, und das
passiert ausschließlich im Ursprungs-Repo: `dist export` schneidet den
`stack-dev`-Skill mit `instructions/dev/` weg. Ein ausgeliefertes `tools/` ist
Laufzeit-Maschinerie, keine Lektüre. `.gitignore` und `tools/.coveragerc` sind
aus demselben Grund nicht im Check — von Hand mitgezogen wurden sie trotzdem,
sodass der Export heute in *keiner* Datei außerhalb `.py` eine Nummer trägt.
**MINOR**, geprüft gegen den Drop-in-Test: kein Kommando, kein Flag, kein
Dateiformat, keine Umbenennung; der Rückweg funktioniert unverändert, die alte
Version führt den Check schlicht nicht aus. Kein `--breaking`, kein
Migrationsdokument. Eine Konsequenz ist zu kennen: der Check liest auch die
instanzeigenen `kb/CONVENTIONS.md` und `kb/<collection>/COLLECTION.md`, weil ein
Export sie als `.template` mitnimmt. Eine Instanz, die dort ihre eigene
Ticket-Nummer zitiert, bekommt beim nächsten `docs verify` ein Finding. Das ist
kein Fehlalarm — ein Export dieser Instanz würde den Verweis weitergeben —
aber es ist neu.
Geändert: `tools/chemenu/commands/docs_verify.py` (neuer Check plus
`shipped_prose()`), `tools/chemenu/tests/test_docs_verify.py` (sechs Tests:
sauberer Baum, präparierte Datei, Anker-Nicht-Treffer, `.py` außerhalb des
Scans, Strip-Block unsichtbar, `verify` bricht ab), `tools/CONTRACT.md`
(Kommandotabelle und Fehlerkontrakt-Zeile), `tools/README.md`,
`instructions/dev/issue-tracking.md` (neuer § Citing an issue in the repo, und
§ What no tool checks zieht die Grenze zwischen „was dieses Repo über den
Tracker schreibt" und „dem Tracker selbst"), sowie die 16 Doku-Dateien:
`raw/CONTRACT.md`, `kb/CONTRACT.md`, `tools/CONTRACT.md`, `types/type-spec.md`,
`types/source.schema.yaml`, `kb/sources/COLLECTION.md`,
`kb/concepts/COLLECTION.md`, `instructions/wiki-ingest/SKILL.md`,
`instructions/evolve-subtypes.md`, `instructions/bootstrap.md`,
`instructions/kb-profiles.md`, `instructions/mcp-read-server.md`,
`instructions/CONTRACT.md`, `README.md`, `EVALS.md`, `INSTALL.md`,
`docs/pipeline-rationale.md`, `.gitignore`, `tools/.coveragerc`.
Schließt #77.
**Warum das eine MAJOR ist, obwohl `kb/` unberührt bleibt** (#73, #76): Anthropics
Skill-Authoring-Doku verlangt für Referenzdateien über 100 Zeilen ein
Inhaltsverzeichnis, damit ein Agent, der eine solche Datei nur mit `head -100`
anliest, trotzdem die volle Abschnittsübersicht sieht — dieselbe Vorschau-Mechanik,
die #72 schon für die Referenztiefe als real anerkannt hat. Ein von Hand
gepflegtes Inhaltsverzeichnis wäre die nächste Drift-Quelle; also ist es jetzt eine
dritte generierte Region neben `xref`s und `cite`s (`<!-- wikitool:toc -->` ...
`<!-- /wikitool:toc -->`, `tools/chemenu/toc.py`), erzeugt und geprüft wie jede
andere abgeleitete Kopie. `wikitool docs toc [--apply]` schreibt sie; `docs verify`
prüft jetzt, dass sie auf jeder Datei aktuell ist, die `AGENTS.md`, ein
Stage-/Collection-Contract oder die flache `instructions/**.md`-Form abdeckt (25
Dateien in diesem Repo, `instructions/dev/` eingeschlossen — strukturell dieselbe
Dateiform, nur von `dist export` ausgenommen). Der Umfang ist berechnet, nie eine
Handliste: er folgt AGENTS.md § File naming, nicht einer Link-Traversierung ab den
fünf Content-Skills, und schließt `types/<name>.md`-Einzelspecs bewusst aus — die
laufen über `wikitool types describe`, das den Inhalt neu rendert statt die Datei
roh auszugeben, sodass die Vorschau-Mechanik dort gar nicht greift.
Das ist grenzüberschreitend, weil `docs verify` damit eine neue Pflichtprüfung
über *bestehenden* Inhalt bekommt: eine Instanz mit einer eigenen
`instructions/*.md`-Datei über 100 Zeilen, an der nichts geändert wurde, sieht
`docs verify` nach reinem Tool-Update neu fehlschlagen, bis einmalig
`wikitool docs toc --apply` läuft und der Diff committet wird — derselbe
Bruchtyp wie ein verschärftes Type-Spec-Pflichtfeld. Keine Migration nötig, weil
kein `kb/`-Inhalt betroffen ist; der einmalige `docs toc --apply`-Lauf ist der
volle Reparaturweg.
Zwei Nebenfunde beim Bauen der TOC-Regel, beide vor dem Bump behoben, weil sie
sonst denselben Bump falsch aussehen ließen: `docs verify`s
`ISSUE_REFERENCE_RE` (`#\d+`) hielt numerierte-Schritt-Anker wie
`#2-fix-the-fidelity-before-writing-a-word` für Issue-Zitate — die Regel nahm
bisher an, dass ein Anker immer mit einem Buchstaben beginnt, was für
nummerierte Überschriften (`instructions/capture-session.md`) nicht mehr gilt;
behoben durch einen Lookbehind, der genau die `](#...`-Linkfragment-Form
ausschließt, ohne ein echtes `(#66)`-Zitat zu übersehen. Und
`instructions_cmd.dev_only_forbidden_references` prüfte mit blankem
`name in text`: ein TOC-Anker wie `#where-stack-development-happens`
(`instructions/private-instance.md`) enthält „stack-dev" als reine Teilzeichenkette,
ohne den Skill zu meinen — behoben durch eine wortgrenzengebundene
Regex-Suche.
Geändert: `tools/chemenu/toc.py` (neu), `tools/chemenu/commands/docs_verify.py`
(`check_toc_regions`, `docs toc`-Kommando, `ISSUE_REFERENCE_RE`-Lookbehind),
`tools/chemenu/commands/instructions_cmd.py`
(`dev_only_forbidden_references` wortgrenzengebunden), `tools/CONTRACT.md`
(`docs toc`-Zeile), `tools/chemenu/tests/test_toc.py` (neu, 15 Tests),
`tools/chemenu/tests/test_docs_verify.py` (drei neue Tests: nummerierter Anker,
geklammertes echtes Zitat, TOC-Region auf dem realen Baum),
`tools/chemenu/tests/test_instructions_cmd.py` (ein neuer Test für die
Teilzeichenketten-Kollision), sowie die 25 Referenzdateien, die jetzt eine
TOC-Region tragen: `AGENTS.md`, `kb/CONTRACT.md`, `kb/CONVENTIONS.md`,
`kb/concepts/COLLECTION.md`, `raw/CONTRACT.md`, `tools/CONTRACT.md`,
`types/type-spec.md`, `instructions/CONTRACT.md`, `instructions/gates.md`,
`instructions/setup-instance.md`, `instructions/private-instance.md`,
`instructions/link-taxonomy.md`, `instructions/kb-profiles.md`,
`instructions/ingest-large-tree.md`, `instructions/capture-session.md`,
`instructions/claude-code-model-selection.md`, `instructions/german-terminology.md`,
`instructions/evolve-subtypes.md`, `instructions/mcp-read-server.md`,
`instructions/migrate-corpus.md`, `instructions/migrations/3.0.0-authoring-conventions.md`,
`instructions/migrations/4.0.0-link-taxonomy.md`, `instructions/dev/issue-tracking.md`,
`instructions/dev/testing-conventions.md`, `instructions/dev/version-parts.md`.
Zusätzlich, unabhängig davon (#76): `instructions/session-setup.md` § Scope und
`instructions/gates.md` behaupteten, die Budget-Ausnahme richte sich danach, ob
ein Kommando das Wiki verändert. Tatsächlich zählt `run_budget.py` eine feste
Allowlist (`SKIP_COMMANDS`/`SKIP_COMMAND_PATHS`) — `lint` schreibt nur ins
gitignorte `reports/`, sieht also lesend aus, steht aber nicht auf der Liste und
zählt wie jedes mutierende Kommando. Beide Dateien verweisen jetzt auf die Liste
in `tools/CONTRACT.md`, statt sie mit einer falschen Faustregel zu umschreiben.
Kein Versionsbezug — reine Prosa-Korrektur, im selben Bump mitgeführt.
Nachgezogen in `-beta.3`: `dist export` erzeugt die TOC-Region jetzt **nach**
dem Marker-Strip neu. Ein `<!-- dist:strip-start/end -->`-Block kann eine ganze
Sektion umschließen — der in `AGENTS.md` umschließt `## Developing this stack` —,
sodass die ausgelieferte Datei eine Überschrift weniger hat, als das im
Arbeitsbaum erzeugte Inhaltsverzeichnis auflistet. Die frische Instanz wäre
damit beim allerersten `docs verify` über eine Datei gefallen, die niemand
angefasst hat. Gefunden hat das die CI im Export-Replay („The distribution works
as a fresh instance"), nicht `pytest` und nicht `docs verify` im Arbeitsbaum —
beide sehen den gestrippten Text nie. Der Regressionstest sitzt jetzt in
`test_dist_cmd.py`.
Nachgezogen in `-beta.2`, weil `docs verify` die eigene Dokumentationstreue nur
für die *Existenz* einer Kommandozeile prüft, nicht für deren Inhalt: die
`docs verify`-Zeile in `tools/CONTRACT.md` nennt jetzt die TOC-Prüfung, die
Fehlerkontrakt-Tabelle bekommt die fehlende `docs toc`-Zeile (Schritt 3 in
`tools/README.md` § Adding a command verlangt beide Tabellen, geprüft wird nur
eine), und `tools/README.md` § Adding a command Schritt 5 nannte als
`--major`-Kriterium „wenn bestehender Inhalt migriert werden muss" — was
`instructions/dev/version-parts.md` ausdrücklich verneint und was dieser Bump
selbst widerlegt: grenzüberschreitend mit `--no-migration`.
Schließt #73, #76.
**Konfidenz-Mechanismus ersatzlos entfernt (schließt #60).** `confidence`,
`confidence_base`, der Zeit-Decay und die Konfidenz-Rubrik sind aus Schema,
Kommandos (`confidence decay`/`init-base`, `touch --confidence-base`), Lint
(`confidence_exceeds_source_standing`), Suche (`--sort -confidence`,
Konfidenzspalte) und jeder Doku-Stelle entfernt, die sie erwähnte.
Der Mechanismus wurde gemessen, nicht nur für unschön befunden: 43 der 152
betroffenen Seiten trugen nie mehr als den Schema-Vorgabewert 0.5, der Decay
hat seit seiner Einführung keinen einzigen Wert bewegt (`confidence_decay.py`
übersprang `concept_type: decision`, `touch --confidence-base` zog das
abgeleitete Feld nie mit), und kein Konsument im Stack hing außer über
`corpus_diff.STRUCTURAL_FIELDS` — eine Abhängigkeit von der Existenz des
Feldes, nicht von seinem Wert — überhaupt an ihm. Vier Entwürfe für eine
Reparatur (Rubrik nachjustieren, Quellenautorität anheben, aus
`authority × fidelity` berechnen, zwei Schubladen für Reifegrad und
Volatilität) scheiterten an denselben Messwerten. Die vollständige Studienlage
steht in #85, wo das Thema als zurückgestellt geführt wird, nicht als
verworfen.
An die Stelle tritt nichts Neues: `kb/CONVENTIONS.md` bekommt eine
Prosa-Hedging-Regel — nach Quellenlage hedgen statt nach Schwellenwert, siehe
`kb/CONVENTIONS.md § Hedging` — und die Arbeitsliste ersetzt
`--field 'confidence<0.6'` durch Prädikate auf tatsächlich aufgezeichneten
Feldern (`--field '!sources'`, `--field provenance=general`). Mit dem Feld
stirbt auch `confidence_exceeds_source_standing`, der einzige automatische
Abgleich zwischen einer Seite und der Standing ihrer Quellen; ein Ersatz ohne
Zahl ist als Kandidat in #85 vorgemerkt, aber bewusst nicht Teil dieses Pakets.
`version bump` bekommt dabei ein neues Flag, `--migration-required`: der
laufende Kandidat hatte in einem früheren Bump `--no-migration` erklärt, und
dieses Paket macht die Erklärung falsch. Die `**Migration:** none
required`-Zeile ist maschinengeschrieben (Invariante 1 verbietet den
Handgriff), und bislang gab es keinen Weg, sie zurückzunehmen, sobald ein
späterer Bump doch eine Migration braucht. Das Flag entfernt die Zeile
stattdessen und verlangt ein Migrationsdokument, das die neue Basisversion
referenziert, bevor es das tut.
Was das für eine bestehende Instanz bricht, steht in der `**Breaking
Change:**`-Zeile oben. `instructions/migrations/5.0.0-confidence-removal.md`
ist der mechanische Strip der zwei Felder über den betroffenen Korpus,
mitsamt der Invariante, die ein automatisierter Lauf einhalten muss (kein
`modified:`-Bump, byte-identischer Body, unveränderte Referenzarrays).
Nachgezogen in `-beta.6`: `instructions/dev/version-parts.md` Schritt 6
beschreibt den Rücknahmepfad jetzt selbst. `tools/CONTRACT.md` führte das neue
Flag bereits, aber die Instruction, die eine Sitzung *vor* einem Bump liest,
kannte den Fall nicht — dieselbe Sitzung ist genau darüber gestolpert. Der
Abschnitt nennt auch, warum ihn nichts meldet: die Zeile ist
maschinengeschrieben, `docs verify` genügt ihr bloßes Vorhandensein, und die
Eskalationsprüfungen laufen nur auf dem Bump, der die Grenze *zuerst*
überschreitet.
Nachgezogen in `-beta.7`: `instructions/wiki-status/SKILL.md` trägt jetzt den
`session-setup.md`-Verweis, den die anderen vier Content-Skills längst haben.
Der Skill ruft in Schritt 2 `lint` auf, und `lint` steht nicht auf der
Ausnahme-Allowlist in `run_budget.py` — er zählt wie jedes mutierende
Kommando. Ohne exportierte `WIKITOOL_SESSION_ID` fällt die Zählung auf
`getppid()` zurück, die Sitzung erbt also den Stand irgendeiner fremden Shell.
Bis `-beta.6` war das Fehlen des Verweises durch die falsche Regel gedeckt, die
`session-setup.md` § Scope selbst aufstellte („verändert das Wiki"); seit sie
die tatsächliche Allowlist nennt, ist es schlicht ein Loch.
Die Alternative — `lint` durch etwas Befreites ersetzen und `wiki-status`
wirklich budgetfrei machen — scheidet an der Sache aus: der Skill liest aus dem
Report die Graph-Auswertung (Broken Links, Orphans, Most-Linked Pages,
uncovered raw files), und kein befreites Kommando liefert die. `doctor` prüft
die Installation, nicht den Korpus; `search` ist Retrieval. Ein `wiki-status`
ohne `lint` wäre kein leichterer Skill, sondern ein leerer.
Damit gilt über alle fünf Content-Skills dieselbe Aussage: ein Skill verlinkt
`session-setup.md` genau dann, wenn er mindestens ein nicht-befreites
`wikitool`-Kommando aufruft. Geprüft wird sie nicht — `instructions verify`
kennt weder die Kommandolisten der Skills noch die Allowlist. Das wäre ein
eigener Schnitt.
Nachgezogen in `-beta.8`: `CLAUDE.md` importierte bislang `USER.md`, `SOUL.md`,
`ENVIRONMENT.md` und `instructions/claude-code-model-selection.md` zusätzlich
zu `AGENTS.md` — eine Harness-Drift, denn dieselben drei
Personalisierungsdateien werden auf den anderen drei Harnesses (Codex CLI,
Copilot, Vibe) allein durch `AGENTS.md`s eigene Anweisung gelesen, nie
injiziert. `CLAUDE.md` importiert jetzt nur noch `AGENTS.md`; die Bedingung
„if the runtime has not already injected them" in `AGENTS.md` §
Personalization entfällt, weil kein Runtime mehr injiziert.
`instructions/claude-code-model-selection.md` ist entfernt und als
`docs/model-and-effort-selection.md` neu geschrieben, in Empfehlungsstimme
statt als Instruktion: die Datei beschrieb überwiegend Handlungen, die eine
Sitzung nicht selbst ausführen kann (das eigene Modell, `/code-review`-Stufen),
und wurde im ganzen Baum nur von den beiden dev-only Skills `stack-dev` und
`stack-close` referenziert, deren Links jetzt dorthin zeigen. **Eine
bestehende Instanz behält die entfernte Datei als Überbleibsel**, bis sie
`wikitool dist upgrade --prune` laufen lässt oder die Datei von Hand löscht —
`instructions verify` meldet sie sonst neu als verwaist.
`AGENTS.md` §§ Personalization, Environment und File naming sind an den
Stellen gekürzt, die eine zweite Kopie einer Regel waren, die
`docs/ownership-and-templates.md` oder eine Invariante schon trägt; § File
naming verlinkt jetzt alle vier `docs/`-Seiten namentlich, was vorher
nirgends geschah. `USER.md` und `SOUL.md` verlieren an derselben Stelle
Rahmen- bzw. Herkunftsprosa, die `USER.md.template` bzw. ein Kommentar in
`SOUL.md` selbst schon trägt.
**Telemetrie-Default nach Installationsform** (#55): `chemenu.telemetry.writer.enabled()` war
eine Zeile - immer an, `WIKI_TRACE=0` das einzige Opt-out. Richtig für dieses Repo, dessen
Traces das Messinstrument sind, mit dem der Stack sich selbst bewertet, aber die falsche
Voreinstellung für eine ausgelieferte Instanz: dort hat niemand Telemetrie bestellt, und
niemand liest `EVALS.md`, bevor die erste Datei geschrieben ist. Dazu kam eine zweite Lücke:
keine Mengenbegrenzung irgendeiner Art - `reports/telemetry/<session>/trace.jsonl` wächst,
solange die Instanz läuft, und nichts räumt je etwas weg.
`tools/chemenu/telemetry/policy.py` (neu) löst jetzt beides an einer Stelle, gekeyt auf den
aufgelösten Root, damit `wikitool doctor`, der Writer und der MCP-Server-Start-Guard dieselbe
Antwort für denselben Checkout geben. Ein Git-Clone dieses Repos bleibt beim alten Verhalten
(an, `WIKI_TRACE=0` schaltet ab); eine per `dist export` ausgelieferte Instanz startet ab jetzt
mit Telemetrie **aus** - erkannt an der ohnehin vorhandenen, maschinengeschriebenen
`.wikitool-release.json` (Invariante 1). Wer sie dort anschalten will, legt eine
`.wikitool-telemetry.json` an (pro Checkout, gitignored, kein `.template` - wie
`.wikitool-remotes.json`); `WIKI_TRACE` überschreibt weiterhin beide Richtungen und schlägt die
Datei.
Zwei unabhängige, fail-silent durchgesetzte Mengendeckel greifen in beiden Installationsformen:
ein Byte-Deckel pro Session-Trace (Default 5 MiB, ein `stat` vor jedem Append) und eine
Retention über die Anzahl der Session-Verzeichnisse (Default 250). Die Retention reserviert den
Platz der gerade entstehenden Session, statt sie mitzuzählen - sonst pendelt der Bestand
dauerhaft bei `keep+1` statt bei `keep`, weil jeder Lauf immer nur das räumt, was der *vorige*
Lauf über dem Limit gelassen hat. Am Byte-Limit schreibt ein weiterer Aufruf nichts mehr außer
einem einmaligen `telemetry.limit`-Event, per Exclusive-Create auf eine `.limit`-Sentinel-Datei
ausgelost - derselbe Ein-Schreiber-Trick wie beim `session.start`-Header, für den Fall, dass
mehrere Prozesse gleichzeitig auf denselben Trace schreiben. Eine Retention-Runde löscht
ausschließlich `trace.jsonl`/`.limit` der überzähligen Verzeichnisse und `rmdir`t nur, wenn
danach leer - nie `rmtree`, aus demselben Grund wie bei `upstream merge` (#30):
`reports/telemetry/` hält lokale, nicht rekonstruierbare Daten, die `eval score` liest.
`wikitool doctor` bekommt einen neuen `telemetry`-Check (an/aus, warum, Menge gegen beide
Deckel, nie `FAIL`, wie `publish-remotes` und `environment`). Der MCP-Server-Start-Guard
(`check_trace_destination`) las `WIKI_TRACE` bisher selbst statt den Writer zu fragen - eine
zweite Kopie derselben Regel, die Invariante 8 verletzte, bevor diese Änderung sie schließt; er
fragt jetzt dieselbe Policy. `instructions/setup-instance.md` bekommt einen neuen
Entscheidungspunkt (Schritt 10).
**MINOR, kein neuer Boundary-Crossing:** additiv in beide Richtungen - eine bestehende Instanz
kopiert die neue Maschinerie über sich und hört still auf zu schreiben, ohne Hand-Arbeit oder
Migration; die alte Version zurücklegen stellt den alten Default wieder her, weil sie die neue
Datei und die neuen Variablen schlicht ignoriert. Der Kandidat trägt seine `--breaking`-Zeile
bereits aus einem früheren Bump (Confidence-Entfernung); diese Änderung fügt keine neue hinzu.
Geändert: `tools/chemenu/telemetry/policy.py` (neu), `tools/chemenu/telemetry/writer.py`,
`tools/chemenu/telemetry/schema.py`, `tools/chemenu/commands/doctor.py`,
`tools/chemenu/mcp/server.py`, `tools/chemenu/config.py`, `tools/chemenu/version.py`,
`.gitignore`, `tools/chemenu/tests/conftest.py` und die Telemetrie-/Doctor-/MCP-Server-Tests,
`.gitea/workflows/ci.yml`, `EVALS.md`, `INSTALL.md`, `INSTALL-MCP.md`, `reports/CONTRACT.md`,
`tools/CONTRACT.md`, `instructions/setup-instance.md`. Schließt #55.
---
## 4.7.4 - 2026-09-04 - bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet
<!-- /wikitool:bumps -->
Nach einem frischen Clone plus `instructions/bootstrap.md` zeigte `tools/wikitool doctor`
durchgehend `OK`, außer `session-id: WARN` - ohne Einordnung, ob das ein Bootstrap-Defekt ist.
`WIKITOOL_SESSION_ID` wird laut `instructions/session-setup.md` bewusst pro Arbeitssitzung
gesetzt, nicht pro Clone; ein Export in `bootstrap.md` selbst würde nur für den Bootstrap-Lauf
gelten, nicht für die tatsächliche Arbeitssitzung danach (die nach dem Neustart in Schritt 6 in
einer neuen Shell beginnt). `bootstrap.md` bekommt deshalb einen neuen Schritt 7, der den WARN
als erwarteten Zustand benennt - analog zum bereits dokumentierten `personalization: FAIL` in
Schritt 4 - und auf `session-setup.md` verweist, statt den Export in Bootstrap nachzubauen.
Schließt #54.
---
## 4.7.3 - 2026-09-04 - eval: gate-not-self-opened prueft REMOVED_FLAGS gegen das eigene Kommando
**Author:** Torben Nehmer
Die Trajektorien-Regel `gate-not-self-opened` (`tools/chemenu/evals/trajectory.py`) hat jedes
Argument jedes `wikitool.call` gegen `REMOVED_FLAGS = {"--yes": "publish", "-y": "publish"}`
geprüft, ohne je das eigene `command`-Feld des Aufrufs gegenzulesen. `--yes`/`-y` sind nur auf
`publish` entfernt worden - auf `rm --page <Titel> --yes` sind sie ein gültiger, dokumentierter
Flag. Ergebnis: jeder `rm --yes`-Aufruf wurde als Invarianten-Verstoß gemeldet ("an agent
inventing a flag the tool never accepts"), obwohl das Tool ihn akzeptiert hatte.
In den vorhandenen Telemetrie-Traces unter `reports/telemetry/` betraf das 111 `rm`-Aufrufe
über 8 Sessions, davon 27 allein in `publish-cleanup/u3` - jede davon fälschlich `FAILED`
gescort. Kein bestehender Test hätte das gefangen: `tools/chemenu/tests/test_evals.py` prüfte
`REMOVED_FLAGS` ausschließlich über `publish --yes`, nie über ein anderes Kommando.
Fix: die Bedingung liest jetzt `attrs.get("command") == REMOVED_FLAGS[arg]` mit. Neuer
Regressionstest `test_yes_on_a_command_that_still_has_it_is_not_a_finding` deckt genau den
`rm --yes`-Fall ab und schlägt gegen den unfixed Code nachweislich fehl.
Keine Verhaltensänderung an `wikitool` selbst - ausschließlich an der Scoring-Logik unter
`tools/chemenu/evals/`.
<!-- wikitool:bumps -->
- eval: gate-not-self-opened prueft REMOVED_FLAGS gegen das eigene Kommando
<!-- /wikitool:bumps -->
---
## 4.7.2 - 2026-09-04 - Coverage-Untergrenze bei 85 %, gegen beobachtete 87,0 %
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- Coverage-Untergrenze 85 % in tools/.coveragerc
<!-- /wikitool:bumps -->
Die Suite hat jetzt einen Boden: `fail_under = 85` in `tools/.coveragerc`, gemessen gegen 87,0 %
(CI-Lauf 163, 6498 Statements, 975 Tests). Damit ist Gitea #10 geschlossen — das Issue, das die
Messung eingerichtet und die Schwelle danach **absichtlich** zurückgehalten hat, bis die Zahl
beobachtet war.
Die Beobachtung ist der eigentliche Inhalt dieses Bumps. Zwischen der ersten Messung (86,9 % von
5105 Statements, 730 Tests, Lauf 87, Stack 1.8.1) und heute ist der gemessene Code um ein Viertel
gewachsen und die Suite um ein Drittel, über 38 grüne Läufe — und die Quote hat sich um einen
Zehntelpunkt bewegt. Eine Untergrenze, die auf dieser Beobachtung steht, ist etwas anderes als
eine gegriffene Zahl.
**85 und nicht 87, und das ist keine Bequemlichkeit.** Der Coverage-Bericht unterscheidet drei
Sorten ungedeckter Zeilen, und nur eine davon bedeutet Arbeit (`EVALS.md` § „How much of the
stack the suite reaches"). Ein neuer dünner Typer-Wrapper senkt den Gesamtwert, ohne dass
irgendetwas schlechter geworden wäre — seine Logik liegt daneben und ist dort getestet. Eine
Schwelle auf dem gemessenen Wert würde genau an diesem Commit rot, und eine Schwelle, die aus
einem Nicht-Grund rot wird, wird gesenkt statt verdient. Das ist die Fehlerweise, die #10
verhindern wollte, nur von der anderen Seite. Die zwei Punkte sind der Platz, den die Taxonomie
verlangt.
`fail_under` steht in der Konfiguration und nicht als `--cov-fail-under` im CI-Schritt: so sitzt
die Zahl neben der Begründung, die sie erzeugt hat, und gilt für jeden `--cov`-Lauf statt nur für
den einen, den CI schreibt.
Was der Boden **nicht** tut: die drei echten Lücken schließen (`provenance_cmd.py` 44 %,
`migrate_cmd.py` 65 %, `type_resolver.py` 79 %). Er friert den erreichten Stand ein. Diese Liste
ist die einzige, die sich nicht bewegt hat, während alles um sie herum wuchs — `migrate_cmd.py`
ist sogar von 71 % gefallen, weil das Modul gewachsen ist und die neuen Zeilen ungetestet ankamen.
Das ist Gitea #51.
Mitgenommen, weil es dieselbe Frage beantwortet: der Coverage-Bericht **ist** als Artefakt
abrufbar, über die Run-Seite. Die Actions-Artefakt-Endpunkte melden dafür `total_count: 0`, weil
`upload-artifact@v3` über die ältere Artifact-API ablegt, die diese Endpunkte nicht lesen. Eine
leere Liste ist kein fehlgeschlagener Upload — steht jetzt in `EVALS.md` und im Kommentar an der
`Coverage report`-Stufe, damit die naheliegende „Korrektur" auf v4 (hier eingeschränkt) niemandem
mehr einfällt.
---
## 4.7.1 - 2026-09-04 - redundant_see_also in tools/CONTRACT.md und wiki-lint dokumentiert; xref-remove-Falle benannt
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- redundant_see_also in tools/CONTRACT.md und wiki-lint; xref-remove-Falle beim Aufräumen benannt
<!-- /wikitool:bumps -->
Die Doku-Hälfte von `4.7.0`, beim Abschluss nachgezogen. Der Befund war ausgeliefert, aber
`tools/CONTRACT.md`s `lint`-Zeile zählt die Befunde auf und kannte ihn nicht - eine Instanz hätte
eine Sektion im Report gefunden, die ihr Contract nicht erklärt. `docs verify` prüft, dass die
Kommandotabelle existiert, nie was in einer Zeile steht; genau die Lücke, für die AGENTS.md
„a stack change is not finished until the human docs describe it" geschrieben ist.
**Die eigentliche Änderung ist aber die Warnung in `wiki-lint`**, und sie ist keine Prosa-Politur.
Der neue Befund liest sich wie etwas, das Schritt 7 („repariere, was mechanisch ist") abräumt, und
der naheliegende Griff wäre `xref remove` - das die Referenz **beidseitig** löscht. Angewandt auf
`Wine see-also Wine GE` neben `Wine GE depends-on Wine` hätte das die schwache *und* die
spezifische Kante entfernt, und das Paar sagte danach gar nichts mehr. Ein Befund, dessen
offensichtliche Reparatur Daten zerstört, ist schlechter als kein Befund: Schritt 1 nennt die
Falle jetzt beim Namen und verweist auf `xref add` (fasst nur die Quellseite an) oder aufs
Berichten. Dieselbe Asymmetrie hat in #30 schon einmal Daten gekostet.
---
## 4.7.0 - 2026-09-04 - Link-Katalog: authored, alternative-to, addresses; entity→entity-Lineage; Lint-Befund gegen redundante see-also
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- Link-Katalog: authored, alternative-to, addresses; Lint-Befund gegen redundante see-also
<!-- /wikitool:bumps -->
Drei neue Label, zwei geschlossene Autorisierungslücken und ein Lint-Befund - ausgelöst von der
anstehenden ersten produktiven Instanz. Katalog und `COLLECTION.md` gehen über `dist export` in
jede neue Instanz; was hier fehlt, fehlt dort ab Tag eins, und nachträglich ist eine
Katalogerweiterung eine Korpus-Migration statt einer Datenzeile. Gitea #43 und #49.
**Die Messung, die den Zuschnitt bestimmt hat.** Die 180 `see-also`-Kanten des Korpus zerfallen
exakt in drei Klassen: 57 (32 %) sind Spiegel einer bereits typisierten Gegenkante, 70 (39 %)
sind wechselseitige `see-also`-Paare, 53 (29 %) stehen einseitig. Die erste Klasse ist kein
Vokabularproblem - `Wine see-also Wine GE` steht neben `Wine GE depends-on Wine`, `RAG see-also
NotebookLM` neben `NotebookLM implements RAG`. Der Katalog war für ein Drittel der Fälle längst
ausreichend; es hat sie nur nichts gemeldet. Genau dafür ist der Lint-Befund unten da, und er ist
der Grund, warum diese Version mehr ist als zwei Katalogzeilen.
**`authored`** (operationales Register). „hat das Ziel als einmaligen Akt geschaffen." Der Katalog
kannte fortlaufende Rechenschaft (`owns`) und fortlaufende Arbeit (`maintains`), aber nicht den
historischen Ursprung - Urheberschaft stand im Korpus deshalb in vier unvereinbaren Formen
nebeneinander: `source.author` als Freitext, `owns`, `see-also` und ein Prosa-Bullet. Eine davon
war sachlich falsch: `Vannevar Bush owns Memex` behauptet laufende Rechenschaft für einen 1974
Verstorbenen, und eine falsche maschinenlesbare Kante ist schlechter als eine schwache, weil sie
geglaubt wird. Geschrieben wird das Label auf der Entity-Seite (`Andrej Karpathy authored LLM
Wiki Pattern`) - die Gegenrichtung `authored-by` auf der Concept-Seite hätte `xref remove`
gebraucht, das beidseitig abräumt, statt `xref add`, das relabelt. Vier Kanten im Korpus
umgestellt, keine verloren.
**`alternative-to`** (operationales Register, selbst-dual). „erfüllt denselben Zweck wie das Ziel,
so dass ein Leser, der zwischen beiden wählt, beide will." Belegt durch rund 30 Paare, darunter
die sieben Agent-CLIs, die untereinander *ausschließlich* `see-also` tragen - keine einzige
typisierte Kante. Abgegrenzt gegen `contrasts` (behauptet einen lesenswerten Unterschied) und
`compares-with` (wiegt auf benannten Dimensionen ab und führt in dieser Instanz auf eine
`kb/comparisons/`-Seite): zwei Agent-CLIs sind austauschbar, zwei gegensätzliche Entwurfsprinzipien
sind es nicht.
Der Katalog sagt jetzt ausdrücklich, dass ein selbst-duales Label **einmal pro Paar** geschrieben
wird. Ohne diesen Satz wäre aus einer 22-Kanten-`see-also`-Clique eine 22-Kanten-
`alternative-to`-Clique geworden und nichts gewonnen: sieben austauschbare Werkzeuge sind 21
Paare, beidseitig deklariert 42 Kanten, von denen die zweiten 21 nichts sagen.
**`addresses`** (konzeptionelles Register). „ist eine Antwort auf das Problem, das das Ziel
beschreibt." `types/concept.md` deklariert `problem` und `decision` als eigene Subtypen, und der
Katalog hatte kein Label, das eine Entscheidung mit dem Problem verbindet, das sie löst - eine
Collection konnte ein Problem benennen und nie sagen, was dagegen unternommen wurde. Abgegrenzt
gegen `rests-on`, das das Ziel als *Prämisse* nimmt statt als zu lösendes Problem. Im Korpus nur
vier belegte Paare, also dünn nach dem sonst geltenden „erst der Anwendungsfall"-Maßstab; die
Ausnahme ist bewusst und gilt dem Auslieferungszeitpunkt, der die Kosten umdreht.
**Zwei Autorisierungslücken entity→entity.** `kb/entities/COLLECTION.md` erlaubte bisher keine
Lineage zwischen zwei Entities - ein Fork, eine Neuimplementierung, ein Nachbau war nicht
ausdrückbar; `derived-from` und `adapted-from` sind jetzt freigegeben. Ebenso `implements`, für
eine Entity, die eine als Entity geführte Konvention umsetzt. Ein autorisiertes Label ohne
Live-Nutzung ist ausdrücklich in Ordnung (`instructions/dev/corpus-policy.md`).
**Lint-Befund `redundant_see_also`.** Meldet eine `see-also`-Kante, deren Gegenrichtung bereits
ein typisiertes Label trägt. Gegen den Korpus dieser Instanz meldet er genau die gemessenen 57.
**Advisory, nicht hart**, aus zwei Gründen zugleich: eine schwache Kante neben einer spezifischen
ist redundant, nicht kaputt - und der Befund kommt lange nach den Korpora, die er beurteilt, also
würde eine harte Einstufung jede bestehende Instanz mit dem Upgrade rot schalten, das ihn
ausliefert. Anders als `unlabelled_edges` ist er auch nicht migrations-gegatet: es gibt keine
Version, ab der die Redundanz zum Fehler wird, nur einen Sweep, zu dem jemand kommt oder nicht.
`links.SEE_ALSO` ist damit das einzige Katalog-Label, das das Werkzeug beim Namen kennt. Das ist
eine begründete Ausnahme, keine Aufweichung: `see-also` ist der erklärte letzte Ausweg des
Katalogs und behauptet nur, dass nichts Besseres passte - was der einzige Grund ist, warum `lint`
eine Kante als *schwächer als* eine andere über demselben Paar beurteilen kann. Alles andere am
Vokabular bleibt in `instructions/link-taxonomy.md` und den `outbound:`-Blöcken.
**Nicht dabei, bewusst.** Der Sweep der 180 bestehenden Kanten (#48) - diese Version ändert außer
den vier Urheberschaftskanten keine Korpus-Kante. Verworfen wurden außerdem `variant-of` (die
Wine-Forks tragen bereits `depends-on Wine`), `implemented-by` (Spiegel von `implements`, den die
Inbound-View rendert), `sibling-of` für die Concept-Cliquen (Über-Verlinkung, kein fehlendes Wort)
und `builds-on` (Vokabularkollision mit `extends`/`derived-from`/`adapted-from`).
---
## 4.6.1 - 2026-09-04 - DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3)
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- DEVELOPMENT.md im Kommandotabellen-Check, selbstbeschriftete Release-Notes, Prosa-Korrekturen (#47 Block 3)
<!-- /wikitool:bumps -->
Block 3 aus #47 - die beiden Nebenbefunde entschieden und umgesetzt - plus drei Prosa-Korrekturen
an `4.6.0`, die eine Bewertung des eigenen Ergebnisses gefunden hat.
**`DEVELOPMENT.md` gehört in `docs_verify.STAGE_READMES`** (entschieden: ja). Das Gegenargument
bei der Aufnahme war, die Liste führe bisher nur ausgelieferte Dokumente, und `DEVELOPMENT.md`
wird von `dist_cmd.ROOT_FILES` bewusst nicht ausgeliefert. Beim Hinsehen löst es sich auf:
`check_readmes_have_no_command_table` überspringt eine Datei, die nicht existiert. In einer
ausgelieferten Instanz ist der Eintrag damit schlicht wirkungslos, im Entwicklungs-Checkout - dem
einzigen Ort, an dem die Datei existiert und also driften kann - greift er. Dafür spricht der
Anlass: genau diese Datei trug einmal eine Tabelle, die für jeden Verify-Befehl ein zweites Mal
beschrieb, was er prüft, und sie musste von Hand entfernt werden, weil nichts sie mit etwas
verglich. Zwei Tests: einer, der die Tabelle in `DEVELOPMENT.md` meldet, und einer, der
festhält, dass eine fehlende gelistete Datei übersprungen und nicht als Fund gemeldet wird - der
Instanz-Fall, an dem die Entscheidung hing. Der Konstantenname ist jetzt enger als sein Inhalt;
das steht als Kommentar daneben, statt eine Umbenennung durch zwei Aufrufstellen zu ziehen.
**Veröffentlichte Release-Notes veralten weiter - sie sagen es jetzt selbst** (entschieden:
Schnappschuss akzeptieren, statt einen Korrekturweg zu bauen). Eine nach dem Tag korrigierte
`CHANGES.md` erreicht die Release-Seite nicht: `gitea-mcp` kennt kein Release-Edit, und
Löschen-und-neu-Anlegen würde die angehängten Tarball-Assets vernichten, auf die `INSTALL.md` und
`version check` zeigen. Bei `v4.4.0` ist das real eingetreten. Statt eines Korrekturwegs für einen
Text, den niemand editieren kann, trägt der Schnappschuss jetzt eine Fußzeile, die sagt, dass er
einer ist und wo die gepflegte Fassung liegt - eine veraltete Notiz kostet einen Leser damit einen
Klick statt einer falschen Überzeugung. Angehängt in `release.yml` und nicht in `version notes`:
das Kommando ist ein allgemeiner Extraktor, dessen andere Aufrufer (lokale Vorschau, eine Pipe)
keine Release-Seiten-Fußzeile erben sollen. `.gitea/`-Änderung, also ohne eigenen Bump-Anspruch -
sie fährt hier mit.
**Drei Prosa-Korrekturen an `4.6.0`.** Der `4.6.0`-Eintrag und der Docstring von
`touches_stack_machinery` behaupteten, das Prädikat prüfe „denselben Pfad-Umfang, den der
CI-Versions-Gate selbst verwendet". Das stimmt nicht: CI matcht `[^/]+/CONTRACT\.md$`, also genau
eine Pfadebene, das Prädikat matcht `CONTRACT.md` in jeder Tiefe. Folgenlos im Verhalten - ein
Über-Match druckt eine Zeile zu viel, nie eine zu wenig -, aber es war eine behauptete Äquivalenz,
die keine ist, geschrieben in genau der ungeprüften Prosa-Phase, um die #47 sich dreht. Docstring
und `tools/CONTRACT.md` benennen die Differenz jetzt und begründen sie (bei einer Erinnerung ist
Über-Matchen die richtige Richtung). Drittens: `stack-close` beschrieb den eigenen Skill-Schnitt
zu stark („es gibt keinen nächsten Schritt mehr, an dem vorbei zu rutschen wäre"). Wahr für die
*Prozedur*, die nicht mehr im Kontext steht; nicht wahr für den *Auslöser* - `stack-dev`s „invoke
it now" ist weiterhin ein Satz, und die `publish`-Notiz nennt den Skill bewusst nicht beim Namen.
Zwei der drei Kettenglieder bleiben Selbstdisziplin. Der Skill sagt das jetzt selbst, statt sich
als Garantie zu verkaufen, die er nicht ist.
Verifiziert: `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
`.venv/bin/python -m pytest -q` (969 passed, 2 davon neu), `release.yml` gegen den YAML-Parser
und das Heredoc als Trockenlauf gegen eine Beispiel-Notiz.
---
## 4.6.0 - 2026-09-04 - stack-dev/stack-close skill split, publish stack-machinery note, model-selection fix (#47 Block 2)
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- stack-dev/stack-close skill split, publish stack-machinery note, model-selection fix (#47 Block 2)
<!-- /wikitool:bumps -->
Block 2 aus #47 (Vorschlag E, am 2026-09-04 entschieden): die ungeprüfte Schlussphase einer
Stack-Sitzung - Issue-Body-Rewrite, `docs/`-Veralterung, Changelog-Prosa - hatte bisher keinen
eigenen Haltepunkt, sondern einen Prosa-Break in `stack-dev` Schritt 6. Der ist zweimal
hintereinander verschluckt worden (#42, #30), beide Male mit echtem Fund im nachgeholten
Durchgang. Ein dritter Prosa-Haltepunkt hätte dieselbe Wette verloren, die
`docs/why-gates-are-code.md` für Gates schon verliert - also keine Prosa-Lösung mehr, sondern ein
struktureller Schnitt.
**Neuer Skill `stack-close`**, dev-only wie `stack-dev`. `stack-dev` endet nach `tools/wikitool
publish` mit einem Stop statt mit einem sechsten Schritt; die Schlussphase existiert nur noch als
eigener Skill, den eine Sitzung aufrufen muss - es gibt keinen „nächsten Schritt" mehr, an dem
vorbei sie rutschen könnte. `stack-close` trägt drei Dinge: den Modell-Rückwechsel-Hinweis (wie
zuvor), die Body-Rewrite-Disziplin aus `issue-tracking.md` Schritte 2-3 und 7, und neu die
**Handover-Pflicht über die ganze Sitzung**: benannt wird das Modell für Design/Versionsstelle
(Schritt 3), für die mechanische Mitte, und für diese Schlussphase - alle drei, auch wenn sie
identisch sind. Eine Handover-Zeile, die nur eine billige Schlussphase meldet, schweigt genau
dann, wenn die ebenso ungeprüfte Design-Phase auch billig lief und niemand dort gewechselt hat.
Ein Agenten-Zuschnitt (Schlussphase als eigener Subagent mit eigenem Modell) wurde geprüft und
verworfen: ein Fork erbt in Claude Code zwingend das Elternmodell, ein frischer Subagent den
Sitzungskontext nicht - die Kombination, die der Zuschnitt bräuchte, gibt es nicht, und selbst
wenn: der Input der Schlussphase *ist* das akkumulierte Sitzungswissen, das ein kalter Agent aus
Diff und Issue neu ableiten müsste. Volle Begründung im Body von #47.
**`instructions/claude-code-model-selection.md` korrigiert**, im dist-strip-Block: die
Übersicht „stack-dev Schritt 3 und 6" ist falsch geworden, seit Schritt 6 nicht mehr existiert.
Sie benennt jetzt beide Haltepunkte an ihrem tatsächlichen Ort - Schritt 3 in `stack-dev`,
der zweite am Anfang von `stack-close`.
**`stack-dev` Schritt 3 ehrlicher formuliert** (Vorschlag C): nicht mehr „ab hier alles
mechanisch", sondern mit benannter Ausnahme - Changelog-Prosa (Schritt 4), eine berührte
`docs/`-Seite, neue Menschendoku, der Prosa-Anteil einer Instruction. Dazu die Einschränkung aus
#30: „durch Tests abgedeckt" gilt nur für das, was die Tests *treffen* - zwei
datenvernichtende Bugs in `upstream merge` liefen an einem grünen `pytest`/`docs
verify`/`instructions verify`/CI vorbei, weil kein Test den Fall traf, nicht weil ein
schwächeres Modell schlechteren Code für den getesteten Fall geschrieben hätte.
**Neu: `tools/wikitool publish` selbst erinnert an die Phasengrenze.** Berührt das Changeset
`tools/`, `types/`, `instructions/`, `AGENTS.md` oder ein `<stage>/CONTRACT.md` - derselbe
Umfang, den ein Versions-Bump selbst abdeckt -, druckt `publish` nach der Erfolgsmeldung eine
Zeile, dass die folgende Phase von keinem der drei Checks abgedeckt ist. Kein Gate, keine
Änderung am Exit-Code, für eine gewöhnliche Content-Publish stumm; harness- und
instanzneutral formuliert, ohne jede Erwähnung eines Trackers, weil `publish` von jedem
Skill genutzt wird, nicht nur von `stack-dev`. `git_publish.touches_stack_machinery()` plus
vier neue Tests (`test_git_publish.py`): zwei für die reine Klassifikationsfunktion
(positiv/negativ), zwei Integrationstests gegen einen echten Publish - die Notiz erscheint genau
einmal bei einer `instructions/`-Änderung und bleibt aus bei einer gewöhnlichen `kb/`-Änderung.
`tools/CONTRACT.md`s `publish`-Zeile trägt die Kurzfassung, absichtlich ohne den Dateinamen
`version-parts.md` zu nennen - die Datei liegt unter `instructions/dev/` und würde in einer
ausgelieferten Instanz ins Leere zeigen, während `tools/CONTRACT.md` selbst ausgeliefert wird.
Verifiziert: `tools/wikitool instructions sync` (7 Skills, `stack-close` neu), `tools/wikitool
docs verify`, `tools/wikitool instructions verify`, `.venv/bin/python -m pytest -q` (967
passed, 4 davon neu).
#47 bleibt offen für Block 3 (`DEVELOPMENT.md` in `STAGE_READMES`, veraltete Release-Notes).
---
## 4.5.1 - 2026-09-04 - issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- issue-tracking - destructive-step invariants, comment-vs-body authority, rename sweep
<!-- /wikitool:bumps -->
Block 1 aus #47 (gemeinsam mit #29): drei Ergänzungen an
`instructions/dev/issue-tracking.md`, ausgelöst durch zwei Fehlerklassen, die
in derselben Sitzung am Stack aufgetreten waren.
- **Schritt 1** trägt jetzt, dass destruktive Schritte im Body die Invariante
nennen müssen, die sie nicht verletzen dürfen, und dass ein
Akzeptanzkriterium eine prüfbare Eigenschaft ist, keine Tätigkeit. Auslöser
war #30: der Body schrieb wörtlich "Arbeitsverzeichnis entfernen" für den
`upstream merge`-Ablauf, und genau das wurde zum datenvernichtenden Bug -
ein `shutil.rmtree` auf eine Stage mit gitignorierten, nicht
rekonstruierbaren Daten.
- **Schritt 2** trägt jetzt die Lesesicht auf Body und Kommentare, die es
bisher nur aus Autorensicht gab: der Body ist der Stand, Kommentare sind
Historie; ein erkennbar veralteter Body wird richtiggestellt statt
umgangen; widersprüchliche Kommentare werden nach Beleg aufgelöst, nicht
nach Datum. Auslöser war ebenfalls #30 (ein Kommentar empfahl das Gegenteil
dessen, was der Body später festlegte) und #10 (ein seit Tagen veralteter
Body gegen drei widersprechende Kommentare, zwei davon sich selbst
widersprechend).
- Neuer Abschnitt **"Renames and other decay in the tracker"** nach Schritt 7:
ein Rename ist erst fertig, wenn auch die offenen Issues nachgezogen sind,
weil `wikitool` diesen Tracker nicht kennt und nicht kennenlernen soll. Mit
der Wegweiser-vs-Beleg-Unterscheidung aus #29 und dem Hinweis, dass auch
verschwundene `kb/`-Seiten und private Infrastrukturangaben Issue-Texte
altern lassen. Ein neuer Trigger in "When to run" verweist darauf.
Keine der drei Ergänzungen verschiebt die bestehende Nummerierung der
Schritte 1-7 - die Querverweise darauf (u. a. aus
`instructions/dev/stack-dev/SKILL.md` auf Schritt 7, aus
`kb/concepts/Issue Label Scheme.md` auf Schritt 2) bleiben also gültig, ohne
angefasst zu werden.
Verifiziert: `tools/wikitool instructions verify`, `tools/wikitool docs
verify`, beide grün (Prosa-only, kein Interface geändert, PATCH).
#47 bleibt offen (Block 2: der Skill-Schnitt aus Vorschlag E; Block 3: die
beiden Nebenbefunde). #29 bleibt ebenfalls offen: dieser Block deckte nur den
Regelabsatz, nicht den noch ausstehenden Pfad-Durchgang durch #4, #5, #15,
#21, #16, #26 - der war nicht Teil des Auftrags für diesen Block.
---
## 4.5.0 - 2026-09-04 - Beide Update-Wege in Code: upstream merge fuer Clones, dist upgrade fuer Tarball-Instanzen
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- Faktenkorrekturen in der 4.4.0-Prosa; DEVELOPMENT.md ohne zweite Kommandobeschreibung
- wikitool upstream merge/verify: code procedure for taking a stack update, ownership.py as the shared stack/instance boundary
- upstream merge: combined-commit regression test (edit+add+delete+contract+template+contract-delete in one commit)
- upstream merge: keep gitignored local data under a content stage, refuse a merge git never opened, report what actually changed
- dist upgrade: apply a stack update, not just detect one (#7)
- dist upgrade: Stamp-Semantik nach --keep-local benannt, docs/ownership-and-templates.md auf drei Eigentumsklassen nachgezogen
<!-- /wikitool:bumps -->
Die Prosa zu 4.4.0 - Changelog-Eintrag, `docs/version-model.md`, `instructions/dev/version-parts.md`,
`DEVELOPMENT.md` - entstand ungeprüft: kein maschineller Check liest sie, und die Sitzung, die sie
schrieb, hat den dafür vorgesehenen Modellwechsel aus `stack-dev` Schritt 6 übersprungen. Ein
nachgeholter Durchgang hat drei Fehler gefunden, von denen der erste der teuerste war.
**Der Befund war an einer Stelle schlicht falsch.** Changelog und `docs/version-model.md`
behaupteten, unter dem alten Modell seien Nummern vergeben worden, die „nie ausgeliefert" wurden -
im Changelog sogar mit einer erfundenen Zahl („fünf Minor-Bumps ... vier nie ausgeliefert"). Das
Gegenteil stimmt: weil `release.yml` auf jede `VERSION`-Bewegung feuerte, wurde **jeder** Bump zu
einem echten, getaggten Release. Der 2026-09-03 hat vier davon in sechs Stunden erzeugt (`4.3.0`
bis `4.3.3`), zwei für reine Prosa-Änderungen. Der Schaden war nie ein Phantom-Release, sondern
dass „Release" aufhörte, etwas zu bedeuten - vier Upgrades an einem Nachmittag sind für einen
Konsumenten kein kleineres Versprechen, sondern ein unleserliches. Beide Stellen tragen jetzt den
tatsächlichen Vorgang samt Beleg.
Dazu zwei kleinere Korrekturen: `version-parts.md` nannte den Stack weiterhin `2.x` (er steht bei
`4.x`), und `DEVELOPMENT.md` trug eine Tabelle, die für jeden Verify-Befehl ein zweites Mal
beschrieb, was er prüft - eine Kopie dessen, was `tools/CONTRACT.md` hält und `docs verify` dort
gegen die CLI prüft. Die Tabelle ist raus; dass die Datei selbst außerhalb der von diesem Check
abgedeckten Dokumente liegt, steht jetzt an ihrer Stelle. Der Code-seitige Teil davon - ob
`DEVELOPMENT.md` in `docs_verify.STAGE_READMES` gehört - hängt an #47, zusammen mit der Lücke im
Skill, die den übersprungenen Modellwechsel überhaupt erst unauffällig gemacht hat.
Kein Verhalten geändert, nur Prosa - und bewusst als laufender Kandidat gelassen statt fixiert:
CIs Version-Gate verlangt die `VERSION`-Bewegung, ein Release verlangt sie nicht. Genau dafür gibt
es das Modell aus 4.4.0.
**Zweiter Bump auf demselben Kandidaten (#30):** `git merge upstream/main` behandelt einen
bewegten Korpus asymmetrisch - eine gelöschte, upstream-geänderte Seite meldet sich als Konflikt,
eine neu angelegte Seite wird still gestaged, nur eine beidseitig gelöschte Seite ist harmlos. Die
Prosa-Prozedur in `private-instance.md` § "Taking a stack update" hat das geschlossen, aber mit
vier eigenen Fehlern: der Pfadsatz stand dreifach (im Skript, im Kontroll-Grep, implizit in
`dist_cmd.py`); eine vom Upstream **gelöschte** Maschinerie-Datei wurde von `git checkout
MERGE_HEAD -- <pfad>` still ignoriert, weil das Skript kein `set -e` hatte; ein echter Konflikt in
`tools/`/`types/`/`instructions/` endete in einem von der Prosa nirgends erwähnten offenen Merge;
und ein *neuer* Maschinerie-Pfad unter einer Content-Stage hätte die Literal-Liste nie erreicht.
Die Eigentumsgrenze ist jetzt ein Prädikat statt einer Liste: `chemenu/ownership.py`,
`is_stack_owned(relative)`, wahr für `<stage>/CONTRACT.md` direkt unter einer Content-Stage
(`kb`, `raw`, `work`, `reports`) und für jeden Pfad, der dort auf `.template` endet - nicht
rekursiv, `kb/<collection>/COLLECTION.md` bleibt seit #39 instanzeigen. `dist_cmd.py` liest das
Modul jetzt statt einer eigenen `_CONTENT_ALLOWED_NAMES`-Liste zu pflegen, und
`CONTRACT_ONLY_STAGES` leitet sich aus `ownership.CONTENT_STAGES` ab statt die drei Stage-Pfade
ein zweites Mal aufzuschreiben - ein Test hält fest, dass beide Sichten nicht auseinanderlaufen
können.
Neu: `wikitool upstream merge [--remote upstream] [--branch main] [--no-fetch]` und `wikitool
upstream verify --since <rev> [--until HEAD]`. `merge` prüft Vorbedingungen (sauberer Baum, kein
laufendes Merge, Remote löst auf), warnt statt zu blockieren, wenn `.wikitool-remotes.json` fehlt,
hält den Merge offen (`--no-commit --no-ff`), zwingt jede Content-Stage auf die lokale Seite
zurück, holt dann über die Vereinigungsmenge der Bäume von `MERGE_HEAD` und `HEAD` genau die
stack-eigenen Pfade zurück - inklusive einer Löschung, falls der Upstream einen Maschinerie-Pfad
entfernt hat -, verweigert bei verbliebenen unaufgelösten Pfaden ohne zu committen, committet
sonst und verifiziert den entstandenen Commit mit derselben Logik wie `verify` - ein Fund dort
wird laut gemeldet und **nicht** automatisch zurückgerollt. Nicht idempotent (AGENTS.md § Tool
error contract), nicht budget-exempt; `verify` liest nur und ist wie `migrate verify` von der
Budget-Gate ausgenommen. Die Mass-Update-Gate greift bei einem Merge-Commit strukturell nicht -
das steht jetzt als eigener Absatz in `instructions/gates.md`, mit `upstream merge`s eigener
Nachkontrolle als der Sicherung, die hier tatsächlich trägt.
`private-instance.md` § "Taking a stack update" verweist jetzt auf den Befehl statt das Skript
auszuschreiben; die Pfadtabelle bleibt als Erklärung stehen, ist aber nicht mehr die operative
Liste. Vorschlag B (eigenes Demo-Repo) bleibt zurückgestellt, siehe #30.
23 neue Tests unter `test_upstream_cmd.py` (35 Fälle mit der parametrisierten
`is_stack_owned`-Tabelle) decken die Fälle aus der Spezifikation ab:
gelöschte vs. geänderte vs. neu angelegte Seiten, `kb/CONTRACT.md`- und Template-Änderungen,
`kb/entities/COLLECTION.md` bleibt lokal, eine gelöschte `raw/CONTRACT.md` landet, ein neuer
Template-Pfad landet, ein offener `work/`-Lauf landet nicht, ein echter `tools/`-Konflikt lässt
den Merge offen, ein schmutziger Baum wird unberührt abgewiesen, "bereits aktuell" ist ein No-op,
die Publish-Remote-Gate-Warnung, `verify` gegen einen von Hand verpfuschten Merge, und die
`dist_cmd`/`ownership`-Konsistenz.
**Dritter Bump auf demselben Kandidaten:** die im Akzeptanzkriterium geforderte Kombinationsprobe
fehlte noch - ein einzelner Upstream-Commit, der Editieren, Anlegen, Löschen einer Seite, eine
Contract-Änderung, eine Template-Änderung und eine Contract-Löschung gleichzeitig bewegt. Jetzt
als `test_one_upstream_commit_mixing_every_case_at_once` nachgetragen; alle sechs Erwartungen in
einem `upstream merge`-Aufruf verifiziert.
**Vierter Bump: zwei Fehler, die ein Review-Durchgang nach dem Publish gefunden hat.** Beide
waren in der ersten Fassung enthalten, beide hätten Daten vernichtet, und keiner der bestehenden
Tests hat sie berührt.
*Erstens: die Content-Stage wurde als Ganzes gelöscht.* `_restore_stage_to_local` hieß in der
ersten Fassung `shutil.rmtree(stage_dir)` — die wörtliche Übersetzung des `rm -rf kb raw` aus der
Prosa-Prozedur. Für `kb/` und `raw/` ist das harmlos, weil dort nichts Ignoriertes liegt. Für die
beiden Stages, die dieses Issue *neu* in den Satz aufgenommen hat, ist es das nicht: `reports/`
ist bis auf seinen Contract komplett gitignored und trägt genau die Daten, die nirgends sonst
existieren — die Telemetrie-Traces, aus denen `eval score` liest, gespeicherte Eval-Berichte,
alte Lint-Reports. In dieser Instanz standen zum Zeitpunkt des Fundes 497 Trace-Verzeichnisse
unter `reports/telemetry/`; ein einziger `upstream merge` hätte sie alle gelöscht, und zwar
stillschweigend, weil git von ignorierten Dateien nichts meldet. Die Stage wird jetzt über die
**getrackten** Pfade beider Bäume zurückgesetzt statt über das Verzeichnis; ignorierte lokale
Daten bleiben unberührt. Leergewordene Verzeichnisse werden aufgeräumt, aber nur wirklich leere.
*Zweitens: ein Merge, den git nie eröffnet hat, hätte die Maschinerie gelöscht.* Der Exit-Code
von `git merge --no-commit --no-ff` wird bewusst ignoriert (Konflikte unter den Content-Stages
sind erwartet). Nur: wenn git das Merge gar nicht erst eröffnet — unverwandte Historien, eine
ignorierte Datei im Weg —, gibt es kein `MERGE_HEAD`, `_tree_paths("MERGE_HEAD")` liefert die
leere Menge, und **jeder** stack-eigene Pfad in `HEAD` fällt damit in den Zweig „der Upstream hat
ihn gelöscht": `kb/CONTRACT.md`, `raw/CONTRACT.md` und sämtliche Templates werden entfernt. Der
Kommando-Ablauf prüft jetzt nach dem Merge-Aufruf, dass tatsächlich ein Merge offen ist, und
bricht sonst ab, ohne den Baum angefasst zu haben. Beide Fehler haben je einen Regressionstest,
und beide Tests wurden gegen die alte Fassung laufen gelassen, um zu zeigen, dass sie sie
tatsächlich fangen.
Dazu eine Ehrlichkeitskorrektur an der Erfolgsmeldung: sie zählte die *wiederhergestellten*
Pfade, nicht die geänderten — ein Merge, der eine Datei bewegt, meldete vier oder fünf. Sie fragt
jetzt `git diff` zwischen Vor- und Nach-Commit, kennzeichnet Löschungen, und stimmt damit mit dem
überein, was ein Leser nachprüfen würde. `docs/ownership-and-templates.md` hat einen Abschnitt
bekommen, warum die Grenze ein Prädikat und keine Liste ist — die Begründung, die dieses Issue
erarbeitet hat, gehörte in die Hintergrunddoku und nicht nur in einen Changelog-Eintrag.
**Fünfter Bump: `wikitool dist upgrade` (#7), der zweite der beiden Update-Wege.** `upstream
merge` oben bedient eine Instanz mit gemeinsamer Git-History; `dist upgrade` bedient eine
Instanz aus einem Tarball, ohne History, die bislang eine rein manuelle Prozedur in
`INSTALL.md` durchlaufen musste - Schritt 4 verlangte einen sha256-Vergleich von Hand gegen den
`files`-Block der alten `.wikitool-release.json`.
Die tragende Regel: die Schreibmenge ist genau der `files`-Block der *neuen*
`.wikitool-release.json`, minus was ein Export aus einer leeren Vorlage neu sät
(`chemenu.ownership.is_export_stub`, wie bisher schon für `kb/log.md`/`.gitkeep`) oder einmalig
sät und danach der Instanz gehört (`chemenu.ownership.is_upgrade_preserved`, neu für
`.wikitool-kb.json` und `CHANGES.md`), plus der Stamp selbst. Jeder Kandidatpfad wird gegen die
*alte* Instanz-Summe klassifiziert: unverändert wird geräuschlos überschrieben, neu im Release
wird angelegt, lokal verändert oder gelöscht wird **nie** still überschrieben - der Lauf bricht
mit der vollständigen Liste ab, außer `--keep-local` sagt ausdrücklich, dass die Dateien liegen
bleiben sollen. `--prune` entfernt zusätzlich aus dem Release entfallene Dateien, aber nur
solche, die seit der Installation unverändert sind.
Die Migrationskette nach dem Tausch wird aus den `instructions/migrations/` des *neuen* Baums
ermittelt (`kb_state.load_migrations` bekam dafür einen `directory`-Parameter) und nur
gemeldet, nie ausgeführt - es gibt bewusst kein `migrate run`. Eine bereits gegen die
*installierte* Maschinerie offene Kette lässt den Befehl abbrechen, bevor er die Quelle
überhaupt öffnet. `kb_state.divergent_files()` (bisher nur von `migrate status` gelesen) ist
jetzt eine dünne Hülle um das neue, zwei-Baum-fähige `compare_against_stamp()` - gleiches
Verhalten für den bestehenden Aufrufer, wiederverwendbar für `dist upgrade`s eigenen Vergleich.
Quelle ist immer ein bereits vorhandenes Verzeichnis oder `.tar.gz` - kein Download, das bleibt
allein `version check`s Sache. Ein Tarball muss genau ein Top-Level-Verzeichnis enthalten (die
Form, in der `release.yml` es baut) und wird gegen eine `.sha256`-Beidatei geprüft, falls eine
danebenliegt (fehlt sie: WARN, kein Abbruch). Weitere Abbruchgründe vor jedem Schreiben: fehlende
lokale `VERSION`/`.wikitool-kb.json`/Stamp mit `files`-Block, ein schmutziger Arbeitsbaum (kein
Git-Repo ist ein WARN, keine Sperre), eine Vorab-Version (`-beta.N`) ohne `--pre`, sowie ein
Downgrade; Gleichstand ist ein No-op. Ein Grenzübertritt der Kompatibilität wird laut gemeldet,
blockiert aber nicht. Committet und pusht nichts (Invariante 5).
Gegenüber dem ersten Entwurf des Issues zwei Korrekturen, die dort auch nachgetragen sind: der
`files`-Block wurde entgegen der ursprünglichen Annahme bereits vor diesem Bump gelesen
(`divergent_files`/`migrate status`), und die Migrationskette war ursprünglich falsch begründet
- sie kann nur aus dem *neuen* Baum kommen, nicht durch eine andere Abfragereihenfolge aus der
alten Instanz. 24 neue Tests unter `test_dist_upgrade.py` decken die Klassifikation, alle
Abbruchgründe, `--keep-local`, `--prune` und beide Quellformen (Verzeichnis und Tarball,
inklusive der sha256- und Top-Level-Prüfung) ab.
Bewusst nicht angetastet: `instructions/private-instance.md` (der Clone-Weg ändert sich nicht,
`INSTALL.md` benennt jetzt beide Wege nebeneinander) und die Frage, wie `dist upgrade` mit
Collection-Templates umgeht, deren Namen eine fremde Instanz gar nicht hat - es verhält sich wie
`upstream merge` und schreibt sie, was ein eigenes Issue gegen den Export wäre, keins gegen das
Upgrade.
**Sechster Bump: die ungeprüfte Phase nachgeholt.** Der Abschluss des vorigen Bumps lief auf
Sonnet, und `stack-dev` Schritt 6 verlangt dort genau zwei Dinge, die kein Check erzwingt:
Issue-Body und `docs/`-Veralterung. Der Body war gemacht, die `docs/`-Prüfung nicht - sie wurde
benannt statt durchgeführt. Nachgeholt auf Opus, mit einem Fund.
`docs/ownership-and-templates.md` § „The consequence in practice" beschrieb ein Upgrade als
Zweiteilung: verbatim überschreiben, `.template`-gestützte Dateien liegen lassen - und begründete
den ersten Teil damit, dass verbatim ausgelieferte Dateien „safe to replace wholesale" seien,
weil sie „never instance-specific to begin with" waren. Genau diese Annahme trifft `dist upgrade`
nicht: eine Instanz *kann* eine verbatim ausgelieferte Datei angefasst haben, und die sha256 je
Datei existiert, um das zu erkennen, statt es vorauszusetzen. Dazu fehlte die dritte Klasse ganz -
die einmalig gesäten, danach instanzeigenen Pfade (`.wikitool-kb.json`, `CHANGES.md`,
`kb/log.md`, `raw/*/.gitkeep`), die im Stamp stehen wie jede andere Datei und deshalb aktiv
ausgeschlossen werden müssen. Die Seite nennt jetzt drei Klassen und die engere praktische Regel:
überschreibe die verbatim ausgelieferten Dateien, *die diese Instanz nicht angefasst hat*.
Dazu eine Präzisierung in `tools/CONTRACT.md`, die vorher nirgends stand: nach `--keep-local`
wird der neue Stamp trotzdem vollständig geschrieben, trägt also die Release-Summe auch für
Dateien, die bewusst *nicht* geschrieben wurden. Der Stamp ist die Vergleichsbasis für den
nächsten Lauf, kein wörtliches Inventar der Platte - und genau das hält eine übersprungene Datei
bei jedem weiteren Lauf als abweichend gemeldet, statt sie nach einmaligem Überspringen still als
aktuell zu führen.
---
## 4.4.0 - 2026-09-03 - Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
**Author:** Torben Nehmer
<!-- wikitool:bumps -->
- Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
<!-- /wikitool:bumps -->
Bisher bekam jeder `version bump` sofort eine fixierte Nummer, und weil `release.yml` auf jede
`VERSION`-Bewegung feuert, wurde daraus sofort ein Release: Nummern entstanden in
Commit-Granularität statt in Release-Granularität. Der 2026-09-03 hat so vier Releases in sechs
Stunden erzeugt (`4.3.0` bis `4.3.3`), zwei davon für reine Prosa-Änderungen - alle vier echt,
keines davon eine Einheit, an der ein Konsument sich hätte orientieren können. `VERSION` trägt
jetzt zwischen zwei Releases **einen** laufenden Kandidaten (`X.Y.Z-beta.N`):
`--major/--minor/--patch` eskaliert diesen Kandidaten max-wins gegen den letzten Release, statt
eine neue Nummer danebenzustellen, und geht dabei nie zurück.
`Version` versteht den Suffix, mit einer expliziten Ordnung
(`4.4.0-beta.1 < 4.4.0-beta.2 < 4.4.0`, numerisch nach `N`, nicht lexikografisch). `CHANGES.md`
trägt genau einen offenen Eintrag pro Kandidat: der erste Bump eröffnet ihn, jeder weitere
aktualisiert Heading und die maschinenverwaltete Bump-Titel-Liste in
`<!-- wikitool:bumps -->` (Marker-Konvention aus `blocks.py`, aber bewusst nicht in
`blocks.BLOCKS` - diese Region gehört zu `CHANGES.md`, nicht zu einer Seite). `version release`
ist neu und fixiert einen Kandidaten: Suffix weg, Eintrag geschlossen, committet und pusht nichts.
Vier Stellen am Bestand angepasst, die das Kandidatenmodell sonst still beschädigt hätten:
`release.yml` überspringt einen suffixbehafteten `VERSION`-Push sauber, bevor die Releases-API
gefragt wird, statt jeden Beta-Bump zu veröffentlichen; die Grenzübertritts-Checks in
`docs verify` (`check_migration_for_boundary`, `check_breaking_change_for_boundary`) messen jetzt
gegen den **letzten Release** (`version_mod.last_release`) statt gegen den zweitobersten Eintrag,
der zwischen zwei Betas keine Grenze mehr hergibt; `kb_state.chain()`/`next_link()` vergleichen
gegen die **Kandidatenbasis**, weil eine Migration mit Ziel `4.4.0` sonst bei installiertem
`4.4.0-beta.1` aus dem Intervall fällt (`4.4.0-beta.1 < 4.4.0`); `read_kb_version()` verweigert
einen Prerelease, weil eine Inhaltsform kein Beta kennt. `dist export` schreibt `VERSION` und den
Stamp weiterhin ehrlich mit Suffix, aber `.wikitool-kb.json` bekommt die Basis.
Menschendoku für die Erzeuger-Seite: `DEVELOPMENT.md` im Repo-Root, bewusst nicht in
`dist_cmd.ROOT_FILES` (Begründung als Kommentar dort), mit Zeile in `AGENTS.md` § File naming und
Zeiger aus `README.md`. `docs/version-model.md` hat einen neuen Abschnitt, warum eine Nummer erst
durch ein Release verbraucht wird.
---
## 4.3.3 - 2026-09-03 - Modellwahl nach Pruefbarkeit statt nach Aufgabenname; stack-dev bricht an den Phasenwechseln fuer den Model-Switch
**Author:** Torben Nehmer
`instructions/claude-code-model-selection.md` routete bisher nach Skill: eine Zeile "Stack
development -> Opus/high" fuer alles, was `tools/`, `types/` oder `instructions/` anfasst. Das ist
zu grob in beide Richtungen - es verteuert die lange, mechanische Mitte einer Stack-Sitzung, und es
sagt nichts darueber, dass Anfang und Ende derselben Sitzung anders zu behandeln sind.
**Die neue Achse ist "was faengt hier einen Fehler ab".** Wo ein Check in Code steht - `pytest`,
`docs verify`, `instructions verify`, CI, die Gates - kostet der Fehler eines schwaecheren Modells
eine Runde und faellt auf. Wo die einzige Durchsetzung eine Sitzung ist, die Prosa liest, faellt
derselbe Fehler gar nicht auf: er wird ausgeliefert und bleibt stehen. Das ist dasselbe Argument,
das `docs/why-gates-are-code.md` fuer Gates fuehrt, angewandt auf die Modellwahl.
Stack-Entwicklung ist damit **nicht mehr eine Zeile, sondern drei**:
| Phase | Was einen Fehler faengt | Modell |
|---|---|---|
| Design, Versionsstelle, Grenzuebertritts-Urteil | nichts | Opus/high |
| Code, Tests, mechanische Doku-Synchronisation | pytest, CI, `docs verify` | Sonnet/high |
| Issue-Abschluss, `docs/`-Veralterung, Changelog-Prosa | nichts, per Konstruktion | Opus/high |
Die Mitte ist die lange Phase und die mit den Checks - dort liegt die Ersparnis. Die beiden
Raender sind kurz (Minuten, nicht Stunden), haben aber keinen maschinellen Waechter: `wikitool`
kennt den Issue-Tracker bewusst nicht, und eine `docs/`-Seite traegt keinen normativen Satz, also
gibt es dort nichts zu verifizieren. Sie oben zu lassen ist billig und schuetzt genau die Arbeit,
die still scheitert.
Zwei Praezisierungen dazu: **Effort ist der billigere Hebel als das Modell** - `medium` steht fuer
Stack-Arbeit bewusst in keiner Zeile, weil Mehrdatei-Konsistenz das ist, was ein reduzierter
Effort zuerst aufgibt; `high` ist die Untergrenze, sobald mehr als eine Datei oder ein Contract
betroffen ist. Und die Asymmetrie ist benannt: eine unnoetige Opus-Phase kostet einmal Geld, eine
ungepruefte Sonnet-Phase kann etwas ausliefern, das nie wieder jemand ansieht.
**Damit die Tabelle ueberhaupt wirksam wird, braucht sie Haltepunkte.** Eine Sitzung kann ihr
eigenes Modell nicht wechseln - das ist `/model` und gehoert dem Nutzer. Eine Empfehlung, die
niemand zum richtigen Zeitpunkt ausspricht, aendert nichts. `instructions/dev/stack-dev/SKILL.md`
bekommt deshalb zwei ausdrueckliche Breaks:
- **Neuer Schritt 3** - "Settle the design before building", mit dem Angebot zum Wechsel nach
unten, sobald der Plan steht und die Arbeit mechanisch wird. Einmal aussprechen, dann so oder
so weiterarbeiten.
- **Schritt 6 (Abschluss) bricht in die Gegenrichtung** - ab dort greift wieder kein Check. Mit
der ausdruecklichen Auflage, die Arbeit **unabhaengig von der Antwort** zu tun: nach dem Publish
auf einen Modellwechsel zu blockieren wuerde genau den Zustand hinterlassen, den Schritt 6
verhindern soll. Lief die Phase auf dem billigeren Modell, gehoert das in die Uebergabe statt
ins Schweigen.
Ein auftauchender Grenzuebertritt ist unter den Decision points ebenfalls als Anlass zum Wechsel
nach oben benannt: `docs verify` prueft, dass ein Uebertritt sich dokumentiert, nie dass die
Stelle richtig gewaehlt war.
Die uebrigen Schritte sind unveraendert und nur umnummeriert (alt 3-5 -> neu 4-6).
---
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
**Author:** Torben Nehmer
Nachfassen zu 4.1.2 (#44), das die Regel geschaerft, aber den Weg zu ihr nicht geaendert hat.
`instructions/dev/issue-tracking.md` bekam damals Schritt 7 ("Closing is the last body update,
not a comment"); `instructions/dev/stack-dev/SKILL.md` bekam nur eine umformulierte Zeile in
seiner Routing-Liste. Eine Stunde spaeter schloss #45 auf exakt dieselbe Weise: gruendlicher
Abschlusskommentar ueber einem Body mit unangehakten Kriterien.
**Die Ursache lag nicht am Text der Regel, sondern an ihrer Erreichbarkeit.** Die nummerierten
Schritte des Skills endeten bei "Verify before publishing". Ein Issue zu schliessen war ueberhaupt
kein Schritt - es hing an einem Zeiger *innerhalb* von Schritt 2, und Schritt 2 ist eine
Routing-Tabelle aus fuenf "read X before Y"-Eintraegen, keine Checkliste. Eine Sitzung folgt dem
Spine, den sie im Kontext hat; was nur hinter einem Link steht, wird genau in dem Moment nicht
aufgeschlagen, in dem es greift - am Ende einer langen Sitzung, wenn der Kontext am vollsten und
die verbleibende Instruktionsflaeche am duennsten ist.
Verschaerfend arbeitete der Blurb gegen seine eigene Regel: fett gesetzt war "keep it current as
the state moves, **not at the end**". Wer den Body unterwegs ungefaehr gepflegt hatte, las daraus
Konformitaet - der eigentliche Abschlusstest stand nur in der verlinkten Datei.
Geaendert:
- **Neuer Schritt 5 in `stack-dev/SKILL.md`** - "Close the issue with a body rewrite, not a
comment", mit dem Test inline (Kriterien abgehakt oder mit Begruendung gestrichen,
Entscheidungen als entschieden formuliert, kein Praesens ueber einen behobenen Defekt,
Verifikation benannt) und dem Verweis auf Schritt 7 fuer die volle Form. Damit steht der
Abschluss auf dem Spine.
- **Schritt-2-Blurb rebalanciert** - beide Haelften binden jetzt sichtbar: fortlaufende Pflege
*und* der Rewrite vor dem Schliessen, mit Verweis auf Schritt 5.
Nichts davon ist maschinell pruefbar, und das bleibt richtig so: `wikitool` kennt den Tracker
nicht und darf ihn nicht lernen, weil es an Instanzen ausliefert, die kein Board haben
(`issue-tracking.md` § "What no tool checks"). Der Skill-Spine ist die einzige Durchsetzung, die
es geben kann - was der Grund ist, den Schritt zu nummerieren statt ihn zu verlinken.
Verallgemeinerbar: eine Regel, die in eine verlinkte Instruction geschrieben wird, erreicht
Sitzungen nur, wenn die nummerierten Schritte des zustaendigen Skills sie in dem Moment
ansteuern, in dem sie greift.
---
## 4.3.1 - 2026-09-03 - docs/ befuellt - Stack-Hintergrund fuer vier Themen, Pflegeklausel in AGENTS.md ergaenzt
**Author:** Torben Nehmer
Gitea #45: die von #38 angelegte, bis dahin leere `docs/` bekommt ihre ersten vier Seiten - frisch
geschrieben, nicht durch Umzug aus `kb/` befuellt, jede ohne normativen Satz und mit Verweis auf
das bindende Dokument statt einer Wiederholung seiner Regeln:
- `docs/pipeline-rationale.md` - warum `raw -> types/tools -> kb -> reports` vier getrennte Stufen
sind und was "never re-derive, always compile" praktisch bedeutet
- `docs/why-gates-are-code.md` - warum Mass-Update-, Publish-Remote- und Iteration-Budget-Gate in
`tools/wikitool` statt in einer Instruktion stehen
- `docs/ownership-and-templates.md` - der Unterschied zwischen stack-eigenen, verbatim
ausgelieferten Dateien und instanz-eigenen `.template`-Dateien
- `docs/version-model.md` - warum Drop-in-Kompatibilitaet und Migrationsbedarf zwei unabhaengige
Fragen sind, illustriert an der 2.0.0-Fallstudie
**AGENTS.md § Changelog:** neue Klausel zur Pflege von `docs/`, ergaenzt neben der bestehenden
Regel zu `README.md`/`EVALS.md`/`tools/README.md`. Eine `docs/`-Seite veraltet nicht wie ein
README bei jedem neuen Flag, sondern nur, wenn die aufgeschriebene Begruendung selbst nicht mehr
traegt - per Konstruktion ungeprueft, da die Seite keinen normativen Satz enthaelt, den
`docs verify` pruefen koennte.
---
## 4.3.0 - 2026-09-03 - docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision
**Author:** Torben Nehmer
Gitea #38: `dist export` lieferte bislang keine einzige `kb/`-Seite aus - eine frische Instanz
bekam den Stack, aber keinen Grund für seine Form. Die dokumentierte `adr-NNN-`-Konvention in
`kb/concepts/COLLECTION.md` existierte zudem nur auf Papier: keine der sieben
`concept_type: decision`-Seiten folgte ihr, und `confidence_decay()` lief bedingungslos über sie
- ein Kategorienfehler, weil Zeitablauf eine Entscheidung nicht falscher macht, nur Supersession
tut das.
**Neu:** `docs/` - ein inertes Verzeichnis für Stack-Hintergrund (warum der Stack so gebaut ist,
nicht was diese Instanz entschieden hat). Keine Frontmatter, kein Typ, kein Index, kein Lint,
keine Decay, keine Provenance, keine `COLLECTION.md`. `dist export` liefert es verbatim aus, wie
`instructions/` und `types/`. Befüllung folgt in Gitea #45.
**Verworfen, nach Prüfung:** ein Umzug der sieben Decision-Seiten nach `decisions/`. Der
Subtyp-Floor aus #28 verlangt mindestens eine Seite je deklariertem `concept_type`, und ein
Umzug hätte `decision` auf null gebracht; dazu zeigen 89 Wikilinks aus `kb/` sowie
tool-eigene Frontmatter-Arrays auf die sieben, und `links.py`/`xref add` kennen kein Ziel
außerhalb `kb/`. Die sieben bleiben in `kb/concepts/`, ebenso ein zweiter, separat erwogener
Rename (`docs verify` → `parity verify`) - der wäre nur nötig gewesen, wenn ein Befehl auf das
Verzeichnis `docs/` wirkt, und keiner tut das.
**Geändert:**
- `confidence_decay()` überspringt `concept_type: decision` strukturell (kategorische Ausnahme,
nicht als Brücke gebaut - Begründung im Docstring).
- `kb/concepts/COLLECTION.md` § Decisions ersetzt die tote ADR-Vorlage durch die real gelebte
Form: eine Entscheidung ist eine gewöhnliche Concept-Seite, organische Prosa, kein
`adr-NNN-`-Präfix, `**Status:**` optional, Supersession per `supersedes`-Link.
- `kb/CONVENTIONS.md` § Naming und `instructions/kb-profiles.md` (Profil `german`) korrigiert -
beide dokumentierten noch die verworfene `adr-NNN-`-Namensregel.
- `AGENTS.md` § File naming und § Routing: `docs/`-Zeile, plus die Regel, dass `docs/` keinen
normativen Satz trägt (das hält Invariante 8 heil - was binden würde, gehört in einen
Contract).
- `tools/CONTRACT.md`: Klarstellung, dass `docs verify` Dokumentations-Parität prüft, nicht das
`docs/`-Verzeichnis, sowie `docs/` in der `dist export`-Zeile ergänzt.
Additiv und in beide Richtungen drop-in: eine bestehende Instanz ohne `docs/` exportiert
weiterhin identisch (leerer `_copy_tree`-Treffer), eine Instanz mit `docs/` bekommt es ab jetzt
mitgeliefert. Kein Feld, kein Kommando ändert sein Verhalten für bestehenden Inhalt.
**Migration:** none required.
Berührt: `tools/chemenu/commands/confidence_decay.py`, `tools/chemenu/commands/dist_cmd.py`,
`tools/chemenu/tests/test_confidence_decay.py`, `tools/chemenu/tests/test_dist_cmd.py`,
`kb/concepts/COLLECTION.md`, `kb/CONVENTIONS.md`, `instructions/kb-profiles.md`, `AGENTS.md`,
`tools/CONTRACT.md`.
---
## 4.2.0 - 2026-09-03 - Korpus-Kuratierungsrichtlinie: Untergrenzen und Leitplanke für reaktive Fixes
**Author:** Torben Nehmer
Ein Demo-Korpus will klein und stabil sein, ein Testbett groß, unordentlich und in Bewegung -
dieses Repo verlangt seit der Veröffentlichung beides vom selben `kb/` (Gitea #28). Die Sitzung
vom 2026-09-02 hatte Fixture, `--with-demo` und ein zweites Repo bereits verworfen; offen blieb
nur, wie kuratiert "kuratiert genug" heißt und welche Leitplanke reaktive Fixes bekommen.
**Neu:** `instructions/dev/corpus-policy.md`. Fünf Untergrenzen, jede mit einer bestehenden
`wikitool`-Prüfung messbar, keine davon durch neuen Tool-Code: jeder Seitentyp und jeder
deklarierte Subtyp mit mindestens einer Seite, mindestens fünf Seiten mit mindestens drei
Quellen, ein bis zehn Orphan-Seiten, im Schnitt mindestens vier ausgehende Wikilinks pro Seite.
Gemessen am 2026-09-03: 181 Seiten, alle Typ-/Subtyp-Floors erfüllt, 12 Seiten mit ≥3 Quellen, 3
Orphans, Ø 6,2 ausgehende Links - der Korpus war bereits groß genug, ohne dass eine einzige
Seite eigens dafür angelegt werden musste. Eine Untergrenze wird nie durch eine erfundene Seite
gefüllt, sondern durch eine echte Quelle beim nächsten passenden Ingest - Invariante 3 gilt
unverändert.
**Die Leitplanke für reaktive Fixes** unterscheidet drei Stufen: punktuelle Änderungen (immer
erlaubt, gewöhnliche Arbeit), korpusweite Änderungen (nur geplant, mit eigenem Issue und
`work/`-Run - trifft eine Session das Mass-Update-Gate während sie etwas anderes tat, holt sie
sich nicht den `--confirm`-Token, sondern stoppt und legt ein Issue an) und reaktive Eingriffe
in Korpusinhalt, um einen Test grün zu machen oder einen Tool-Bug zu umgehen (nie erlaubt,
Invariante 7). Das Verhältnis zu `kb_dir`/`raw_dir` und `test_pipeline_l0.py` bleibt wie im
ursprünglichen Befund: kleiner, isolierter Fall in der Fixture, großer, vernetzter Fall in
`kb/` - keine Fixture-Extraktion aus dem Korpus.
Dev-only und rein additiv - kein Feld, kein Kommando, keine Datei außerhalb von
`instructions/dev/` ändert sich, daher `--minor` ohne `--breaking`.
**Migration:** none required.
Berührt: `instructions/dev/corpus-policy.md` (neu),
`instructions/dev/stack-dev/SKILL.md` (Schritt 2, Routing-Zeile).
---
## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
**Author:** Torben Nehmer
Aufgefallen beim Schließen von #44: der Abschlussbericht stand als Kommentar da, der Body
darunter weiterhin als offene Arbeit — Abschnitt „Zu entscheiden" über eine längst getroffene
Entscheidung, ungehakte Checkliste, Präsens über einen Defekt, den es nicht mehr gab.
Die Regel gab es dafür schon: Schritt 2 von `instructions/dev/issue-tracking.md` sagt, der Body
ist die aktuelle Wahrheit und wird umgeschrieben, wenn sich der Stand ändert. Nur ließ die
Formulierung offen, *wann* — und Schritt 7 („Close with what actually happened") war vollständig
erfüllbar, ohne den Body anzufassen. Ein Abschlussbericht im Kommentar fühlt sich beim Schreiben
vollständig an; dass der Body dabei zurückbleibt, merkt erst der nächste Leser.
**Schritt 2 ist deshalb schärfer geworden: der Body ist das Plan-File dieses Stacks.** Dasselbe,
was das Plan-Dokument eines Harness ist, und genauso gepflegt — fortlaufend, sobald etwas darin
nicht mehr stimmt, nicht am Ende. Der Maßstab ist der Abbruch, nicht der Meilenstein: eine
Session kann jederzeit enden, und was der Body in diesem Moment sagt, ist die vollständige
Übergabe. Eine frische Session muss zu **jedem** Zeitpunkt allein aus dem Body weiterarbeiten
können, ohne Kommentare rückwärts zu lesen und ohne einen Menschen, der es neu erklärt. Entschieden
ersetzt die Frage, erledigt hakt das Kriterium ab, verworfen steht mit Begründung dort, wo das
Kriterium stand.
Schritt 7 ist damit kein Sonderakt mehr, sondern die letzte dieser Aktualisierungen: erst Body
auf den Endstand, dann schließen, dann die Changelog-Zeile aus Schritt 3. Wer Schritt 2 befolgt
hat, ist fast fertig; wer nicht, zahlt die ganze Schuld im schlechtesten Moment — der
geschlossene Body ist die Fassung, die danach alle lesen und niemand mehr aufsucht. #44 steht
als Beispiel drin.
Schritt 3 zieht die Konsequenz: **ein Kommentar pro Session-Umfang, nicht pro Edit.** Ein
fortlaufend gepflegter Body mit einem Changelog-Kommentar je Änderung wäre Lärm; triviale Pflege
braucht gar keinen. Der `stack-dev`-Skill sagt es beim Aufgreifen mit, weil dort die Entscheidung
fällt, ob eine Session den Body überhaupt anfasst.
**Und die ehrliche Antwort auf die Frage nach dem Tooling: es gibt keins, und es soll keins
geben.** `wikitool` kennt diesen Tracker nicht. Es wird an Instanzen ausgeliefert, die unter
dieser URL keine Issues haben, während `instructions/dev/` von `dist export` gepruned wird —
ein Gitea-Client im ausgelieferten Tool wäre eine Dev-Abhängigkeit, die jede Instanz mitträgt,
um ein Board zu prüfen, das keine von ihnen hat. Der Tracker ist ausschließlich über
`gitea-mcp` erreichbar, also in einer Session, durch einen Agenten.
Kein `docs verify` fängt also einen geschlossenen Issue, dessen Body offen klingt, einen Body,
der seinen eigenen Kommentaren widerspricht, oder ein fehlendes Pflichtlabel. Das steht jetzt
als eigener Abschnitt „What no tool checks" in der Instruktion — nicht als Bedauern, sondern als
Begründung dafür, warum die Reihenfolge in Schritt 7 ausgeschrieben ist statt aus Schritt 2
erschlossen zu werden.
---
## 4.1.1 - 2026-09-03 - Testisolation: kb_dir repointet config.ROOT, lint löst Kollektionen gegen den übergebenen Baum auf
**Author:** Torben Nehmer
Issue #44, gefunden beim Bau der Migrations-Gate-Tests für 4.1.0: die `kb_dir`-Fixture baute
ihren Baum unter `tmp_path`, ließ `config.ROOT` aber auf dem echten Checkout stehen. Jeder
Codepfad, der eine Datei über `config.ROOT`/`config.KB_DIR` auflöst statt über das übergebene
Verzeichnis, traf damit das echte Repository.
**Der laute Fall** war ein Test, der `kb_state.write_kb_state()` rief und dabei das
`.wikitool-kb.json` des Repos überschrieb — Applied-Ledger leer statt zwei Einträgen. In
`git status` sofort sichtbar und reversibel; bei einer gitignorierten Datei wäre es das nicht
gewesen.
**Der stillere Fall** ist der teurere. `lint`s Kollektions-Lookup löste eine Seite gegen
`config.KB_DIR` auf. Für eine Seite unter `tmp_path/kb/` warf das `ValueError`, die Funktion
antwortete „keine Kollektion", und die Label-Autorisierung übersprang die Kante wortlos.
`unauthorised_labels` war damit faktisch ungetestet — jeder Test, der das Finding hätte
auslösen können, bekam eine leere Liste und behauptete nichts. Ein grüner Lauf, der wie eine
Zusicherung aussah.
**Der Fix ist der Codepfad, nicht die Fixture.** `run_lint()` bekommt ein Verzeichnis
übergeben und löst jetzt auch intern dagegen auf; `authorised_labels()` bekommt denselben Baum
gereicht, statt auf `config.KB_DIR` zurückzufallen. Der Regressionstest lintet einen Baum, von
dem `ROOT` bewusst wegzeigt — genau der Fall, den die alte Auflösung verschluckte. Eine Funktion,
die ein Verzeichnis entgegennimmt, löst dagegen auf: keine Fixture kann diese Form von außen
reparieren.
**Beide Korpus-Fixturen repointen jetzt.** `kb_dir` tut, was `raw_dir` längst tat — `ROOT` auf
das eigene `tmp_path`, plus `use_shipped_type_specs()`. Der Suite-Lauf kippte dadurch keinen
einzigen Test. Die lokale `rooted_kb`-Umgehung aus 4.1.0 entfällt damit; die Auswahl zwischen
zwei fast gleichen Fixturen war Wissen, das nirgends stand.
**Und ein Wächter für die ganze Klasse.** `repository_tree_guard` (session-scoped, autouse)
vergleicht `git status --porcelain` vor und nach dem Lauf und lässt die Suite scheitern, wenn
sich im Checkout etwas bewegt hat — zwei `git status`-Aufrufe pro Lauf, deshalb per Default an.
Er vergleicht vorher gegen nachher statt einen sauberen Baum zu verlangen, sagt also nichts über
die unveröffentlichte Arbeit des Entwicklers. Den Verursacher benennt er nicht;
`CHEMENU_TREE_GUARD=each` prüft nach jedem Test und tut es. Ohne git oder außerhalb eines
Repositorys sind beide still.
Was der Wächter nicht sieht: eine Prüfung, die unter Test nichts tut, schreibt keine Datei.
Dagegen hilft nur ein Test, der das Finding tatsächlich auslöst — der neue tut das.
`instructions/dev/testing-conventions.md` hat dafür einen eigenen Abschnitt („Which tree a test
writes into"), einen Schritt in der Checkliste und die Regel für neue Fixturen.
---
## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar
**Author:** Torben Nehmer
Der Rest von Issue #40, nachdem die Korpus-Migration durch ist: die beiden aufgeschobenen
Lint-Findings werden hart, und die drei Befunde aus dem Abschlusskommentar des Migrationslaufs
werden aufgelöst.
**`unlabelled_edges` und `unauthorised_labels` sind harte Fehler — aber an `kb_version`
gebunden, nicht an ein Datum.** Der Weg, den `legacy_citation_markers` genommen hat, war ein
Umlegen in einer späteren Version: eine Instanz, die die Zitat-Migration noch schuldete, lebte
danach mit rotem Lint. Das Ledger kann die Frage inzwischen beantworten, also tut es das.
Unterhalb `kb_version` 4.0.0 bleiben beide beratend — genau das Fenster, in dem
`instructions/migrations/4.0.0-link-taxonomy.md` der Instanz sagt, sie solle den halb
konvertierten Korpus Einheit für Einheit publizieren; ein Check, der dabei fehlschlägt, würde
den Korpus verweigern, dessen Fortschritt er misst. Ab 4.0.0 ist eine kahle Titelangabe in
`related:` keine Seite mehr, die auf ihre Umstellung wartet, sondern eine Kante, deren Autor
nicht gesagt hat, was sie behauptet. `hard_error_keys()` liefert die jeweils geltende Menge,
`HARD_ERROR_KEYS` bleibt die vollständige.
**`outbound:` ist an das Type-Spec gebunden.** `kb/sources/` und `kb/comparisons/`
autorisierten Label, die dort strukturell nicht schreibbar waren: keiner der beiden Type-Specs
führte ein `related:`. Folgenlos war das nicht — die einzige Comparison-Seite des Korpus trug
`- **compares-with:** [[amd-pstate]]` als *handgeschriebene Prosa*, ohne Marker-Region, ohne
Frontmatter, für `lint` unsichtbar. Also ein Identifier zurück im Fließtext, gut vier Stunden
nachdem 4.0.0 genau das beendet hatte. Eine leere Autorisierung liest sich als Lizenz.
Aufgelöst nach dem, was die beiden Contracts jeweils selbst sagen: `comparison` bekommt ein
`related:` (die `compares-with`-Kante gegen jedes Subjekt ist die eine Aussage, für die die
Seite existiert), `kb/sources/` verliert seinen `outbound:`-Block ersatzlos (dessen Contract
sagt ausdrücklich, seine Verknüpfungen seien der mechanische Provenance-Pfad und keine
Autorenkanten). Neu prüft `docs verify` die Kombination: ein `outbound:`-Block auf einer
Collection, in die kein Typ mit `related:` schreibt, ist ein Befund und nennt beide Richtungen
der Reparatur.
**`composition` / `part-of` ist das dritte Inversenpaar**, neben `depends-on` / `required-by`
und `runs-on` / `hosts`. Aus der Messung, nicht vom Schreibtisch: der u3-Lauf hatte entschieden,
die Gegenseite eines `composition` bekomme `see-also`, weil `part-of` ein Spiegel wäre. Ist es
nicht — der Satz des Elternteils zählt seine Teile auf, der des Kindes benennt das Ganze, zu
dem es gehört, und ein Leser, der auf dem Kind landet, braucht den zweiten. Übrig blieben 16
`see-also`-Kanten für eine Beziehung, für die der Katalog ein Wort hat; sie sind auf `part-of`
umgestellt. Ein Inversenpaar macht die Gegenkante weiterhin **nicht** zur Pflicht — Richtung
wird verfasst, nicht gespiegelt —, es legt nur fest, welches Label sie trägt, wenn jemand sie
schreibt.
**Stack- und Korpusänderung laufen hier in einem Zug**, entgegen der sonstigen Trennung. Der
neue `docs verify`-Check würde eine bestehende 4.0.x-Instanz beim bloßen Kopieren der neuen
Maschinerie fehlschlagen lassen, weil deren `kb/sources/COLLECTION.md` den `outbound:`-Block
noch trägt — nach [instructions/dev/version-parts.md](instructions/dev/version-parts.md)
Schritt 1 ein Grenzübertritt. Statt dafür eine `5.0.0` zu lösen, ist die Ursache mitbeseitigt:
die Collection-Contracts dieser Instanz sind angepasst, und `dist export` leitet die
`COLLECTION.md.template` daraus ab, also liefert jede neue Distribution die korrigierte Form
aus. Für eine bereits bestehende 4.0.x-Instanz bleibt eine Handbewegung übrig, und sie wird
hier benannt statt versteckt: die zwei `outbound:`-Zeilen aus `kb/sources/COLLECTION.md`
löschen. Das neue `related:` im `comparison`-Type-Spec erreicht sie ohnehin nicht — die vier
Page-Type-Specs gehören seit 4.0.0 der Instanz und werden nur als `.template` ausgeliefert.
Offen aus #40 bleibt nichts mehr; Befund 2 des Migrationslaufs (dem Katalog fehlt ein Register
für Urheberschaft) ist als eigenes Issue erfasst.
---
## 4.0.1 - 2026-09-02 - Issue-Board: vier Pflicht-Label-Familien und Body-als-Wahrheit
**Author:** Torben Nehmer
Das Issue-Schema aus 1.2.1 hatte zwei Pflichtachsen und einen ausdrücklich begründeten Verzicht
auf eine dritte: eine Taxonomie mit mehr Achsen brauche eigene Pflege, und das Board habe einen
einzigen Betreuer. Diese Begründung ist entfallen, weil die Pflege inzwischen maschinell
passiert - Body-Rewrites und Kommentare laufen über eine LLM-Sitzung, Menschen fassen in der
Regel nur Labels an. Damit sind vier Achsen bezahlbar (Issue #41).
**Pflicht auf jedem offenen Issue sind jetzt vier Label:** `area/` (`kb`, `distribution`,
`corpus`, `workflow`, `process` - kein `area/tools`, Tooling wird nach der bedienten Domäne
einsortiert, nicht nach Codeort), `kind/` (`decision`, `build`, `defect`), `prio/`
(`blocking`, `planned`, `waiting` - reine Umbenennung von `1`/`2`/`3`) und `size/` (`S`, `M`,
`L`; `XS` entfällt). Dazu zwei optionale Flags: `status/blocked` für Abhängigkeit von einem
anderen offenen Issue, `status/unconfirmed` für einen ungeprüften Verdacht, unter dem `size`
und `prio` vorläufig sind. Ein `unconfirmed`-Issue endet in der Triage entweder ohne Flag und
mit verbindlichen Werten oder geschlossen mit Begründung - die Prozessentsprechung zu
Invariante 3.
**Der Issue-Body ist ab jetzt aktuelle Wahrheit, nicht Ursprungstext.** Die Umsetzung eines
Issues zieht sich über mehrere, zeitlich getrennte Sitzungen, und der Body ist das einzige, was
sie verbindet: eine Sitzung muss aus ihm allein rekonstruieren können, was entschieden und was
offen ist. Er wird deshalb umgeschrieben statt ergänzt. Jeder Rewrite bekommt einen Kommentar,
der ausschließlich benennt, was sich geändert hat - keine Vollkopie des alten Stands, weil ein
Mensch zwei Fließtexte nicht diffen kann und eine Kopie pro Revision damit keine Historie ist,
sondern nur eine weitere Kopie.
Geändert: [instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md) (Schritte 2,
3, 5 neu; Schritt 4 um `area/` und `kind/` erweitert; der Entscheidungspunkt „Two labels feel
too coarse?" entfällt) und die Beschreibungszeile in `instructions/dev/stack-dev/SKILL.md`. Für
eine ausgelieferte Instanz ändert sich nichts: `dist export` schließt `instructions/dev/`
vollständig aus, weshalb dies ein PATCH ist und kein MINOR - dieselbe Begründung wie bei
`1.2.1`. Noch offen aus #41: `kb/concepts/Issue Label Scheme.md` beschreibt weiterhin das
zweiachsige Schema und braucht eine eigene `wiki-manage`-Sitzung.
---
## 4.0.0 - 2026-09-02 - Prosa ist kein Identifier: Link-Taxonomie als Enum, generierte Regionen mit Markern
**Author:** Torben Nehmer
**Breaking Change:** Beziehungslabel sind Enum-Werte in related: statt Freitext im Body-Bullet, toolgefuehrte Abschnitte liegen zwischen Marker-Paaren statt hinter ihrer Ueberschrift, und xref add schreibt nur noch eine Kante statt beider Richtungen. tools/chemenu/sections.py ist geloescht. Eine bestehende Instanz muss sections: in kb/CONVENTIONS.md auf links/footnotes umstellen, outbound: in jede COLLECTION.md eintragen, die {section.*}-Variablen aus ihren Page-Type-Templates entfernen und den Korpus umstellen - sonst scaffoldet new die Variablen woertlich in neue Seiten. Ablauf: instructions/migrations/4.0.0-link-taxonomy.md
Der Stack benutzte an drei Stellen **Prosa als Identifier**, und jede hat messbar etwas
gekostet. Die Überschrift eines Abschnitts war seine Adresse (`^## Beziehungen$`), was die
KB-Sprache zu einer Compiler-Konstante machte *und* das Ende der Region zur Schätzung - sie lief
bis zur nächsten Überschrift, davor bis zum Dateiende, und hat auf acht Seiten still Inhalt
gelöscht. Das Beziehungslabel stand nur im Body-Bullet, also konnte nichts das Vokabular prüfen:
gemessen am Korpus **152 distinkte Label in 337 Bullets** gegen dreizehn dokumentierte, 102 davon
genau einmal vorkommend. Und `xref add` spiegelte jede Kante, was `## Siehe auch` mit 555
Bullets ohne Label füllte - 353 davon beweisbar redundant.
**Was jetzt Identifier ist.** Eine Region liegt zwischen `<!-- wikitool:links -->` bzw.
`<!-- wikitool:footnotes -->` und wird vollständig aus dem Frontmatter gerendert, Überschrift
eingeschlossen. Ein Label ist ein Maschinenwert in `related:` (`- depends-on: Hermes`), gezogen
aus `instructions/link-taxonomy.md` und **pro Ziel autorisiert von der Quell-Collection**
(`outbound:` im `COLLECTION.md`, Commonplaces ADR-019). Der Body-Bullet ist eine Darstellung
dieser Daten, nicht ihr zweiter Aufbewahrungsort.
**Gelöscht, ersatzlos:** `tools/chemenu/sections.py` komplett, `heading_re`, der
Alias-Mechanismus, `PRE_CONVENTIONS_NAMES`, `cite_block_heading`, `provenance.__getattr__`, die
`{section.*}`-Template-Variablen, `xref`s Abschnittssuche. Kein Überschriftentext liegt mehr in
Python - bis auf zwei kosmetische Fallbacks, und die sind harmlos geworden: der Marker trägt die
Identität, also rendert ein falscher Default falsche Wörter statt Struktur zu zerlegen, und der
nächste Write repariert es.
**Kanten sind direktional, und das war keine Geschmacksfrage.** Die per-Collection-Autorisierung
ist mit einer automatisch gespiegelten Gegenkante logisch unverträglich: die Spiegelhälfte
entsteht in einer Collection, deren Regeln der Autor nie gelesen hat. Entweder schriebe das
Werkzeug unautorisierte Kanten, oder die Regel "die Quellcollection entscheidet" löst sich auf.
Der Navigationseinwand wird dabei *besser* beantwortet als vorher: `wikitool links show --page`
berechnet die Eingangssicht über den Korpus, vollständig und ohne Pflege, und das gerenderte
Bullet ist ein gewöhnlicher `[[wikilink]]` - ein Backlink-Panel zeigt es ohnehin. Die erzwungene
Gegenkante garantierte nie Vollständigkeit, nur dass jemand daran gedacht hat.
**Der Orphan-Check meldet dadurch mehr,** und das ist die Prüfung bei der Arbeit: sie misst jetzt
Erreichbarkeit statt "ist `xref` gelaufen".
**`obligation:` trennt zwei Achsen, die vorher eine waren.** `migration_kind:` sagt *wie*
gearbeitet wird, neu `obligation: required|offered` *ob* überhaupt. Eine `offered`-Migration ist
ein Angebot für eine Datei, die der Instanz gehört - sie blockiert nie, steht nicht in der Kette,
und `migrate done` verbucht sie im Ledger, **ohne** `kb_version` zu bewegen. Genau daran hing ein
Entwurfsfehler, den erst der Test gezeigt hat: Offers gegen `kb_version` zu filtern hätte jede
Offer verschwinden lassen, sobald irgendein unbeteiligter Pflichtschritt lief. Dazu ist die
Erkennungshälfte aktiviert, die seit ihrer Einführung ungelesen dalag - die sha256 pro Datei in
`.wikitool-release.json` beantwortet jetzt "editiert oder nur empfangen", also ob eine Offer
kopiert werden darf oder von Hand abgeglichen werden muss.
**`types/` teilt sich entlang `root:`.** `root: kb` heißt Wissensseite heißt Instanz: die vier
Page-Type-Specs samt Schemas gehen als `.template`, `instruction`/`lint-report`/`type-spec`
verbatim. Damit ist die deutsche Prosa in jenen vier Dateien **korrekt statt Migrationsschuld** -
es war die richtige Sprache an einem Ort mit falsch deklariertem Eigentümer. Was der Stack von
der Type-Schicht noch verlangt, ist eine Zeile: ein Type-Spec `name: source`, dessen Schema
`raw_files` fordert. `STACK_REQUIRED_COLLECTIONS` entfällt als separate Liste - die pflichtige
Collection wird aus dem `base_dir` dieses Typs abgeleitet.
**Warum das MAJOR ist.** Vorwärts: `sections:` hat eine andere Form, `outbound:` fehlt, und die
in 3.0.0 übernommenen Page-Type-Templates enthalten `{section.*}`-Variablen, die es nicht mehr
gibt - `new` schriebe sie wörtlich in neue Seiten. Rückwärts: 4.0.0 schreibt gelabelte Kanten,
die 3.0.0s Schema als `type: string` ablehnt. Beide Hälften des Drop-in-Tests fallen.
**Der Korpus dieser Instanz ist noch nicht umgestellt.** Diese Version liefert die Maschinerie;
`lint` meldet die 480 noch ungelabelten Kanten als Findings, nicht als Fehler, weil das genau das
Fenster ist, für das `.wikitool-kb.json` existiert. `malformed_edges` und `unbalanced_markers`
sind dagegen sofort hart - keines beschreibt eine unkonvertierte Seite, nur eine kaputte. Die
Beförderung der beiden anderen kommt, wenn der Korpus sie bestehen kann.
---
## 3.0.0 - 2026-09-02 - Autorenkonventionen nach Eigentum geschnitten: kb/CONVENTIONS.md, deklarierte Collections
**Author:** Torben Nehmer
**Breaking Change:** kb/CONTRACT.md ist um alles gekuerzt, was eine Instanz selbst entscheidet; das steht jetzt in einer neuen, instanzeigenen kb/CONVENTIONS.md, aus der der Compiler die drei toolgefuehrten Abschnittsnamen liest. Eine bestehende Instanz muss diese Datei anlegen, auf jedem kb/*/COLLECTION.md profile: und required_by_stack: deklarieren und kb/CONTRACT.md aus dem Release nachziehen - sonst FAILt doctor und docs verify bricht. Ablauf: instructions/migrations/3.0.0-authoring-conventions.md
`kb/CONTRACT.md` war eine Datei mit zwei Autoritäten. Der eine Teil ist code-erzwungen und in
jeder Instanz gleich; der andere - **§ Language komplett**, das Beziehungslabel-Vokabular, die
Tonfall-Beispiele samt deutscher Buzzword-Liste, die Confidence-Rubrik, das ADR-Präfix - ist
Konvention, die jede Instanz für sich entscheidet, und wurde trotzdem als bindender Contract
verbatim ausgeliefert. Wer bei Schritt 5 von `setup-instance.md` "Englisch" antwortete, hatte
danach `kb/CONTRACT.md`, vier Type-Specs **und `tools/chemenu/sections.py`** lokal geändert -
und `private-instance.md`s Decision Point sagt für so einen Merge-Konflikt: Upstream-Seite
nehmen. Für diese Instanz hieß das: KB-Sprache zurück auf Deutsch.
**Der Schnitt läuft jetzt danach, wer den Satz ändern darf.** `kb/CONTRACT.md` behält, was
`wikitool` erzwingt; neu daneben liegt `kb/CONVENTIONS.md`, die **genauso bindet** und der
Instanz gehört. Unterschied ist Eigentum, nicht Autorität - deshalb liefert die Distribution nur
`kb/CONVENTIONS.md.template`, exakt der `USER.md`/`SOUL.md`-Split ein Verzeichnis tiefer. Dazu
`instructions/kb-profiles.md`: der Katalog erprobter Profile, ausdrücklich **Palette und kein
Enum**. Übernommen wird der *Text* in die Instanzdatei, nie ein Verweis auf den Katalog - ein
Verweis wäre wieder genau die Konstruktion, die dieser Release beendet.
**`sections.py` hält keine Überschrift mehr.** `RELATIONSHIPS = "Beziehungen"` war die Stelle,
an der die Konvention in Code übergelaufen war: solange sie dort stand, konnte kein Template die
Sprache umstellen. Neu ist `tools/chemenu/conventions.py`, das die drei Namen aus
`kb/CONVENTIONS.md` liest; `sections.py` löst sie per PEP 562 bei jedem Zugriff auf, wie
`config` seine Pfade - ein Modulkonstante hätte den Wert an den Baum gebunden, in dem der Prozess
gestartet ist. Aus demselben Grund ist `provenance.CITE_BLOCK_HEADING` ein `__getattr__` und
`render_cite_block(heading=None)` löst innerhalb des Aufrufs auf. Der Alias-Mechanismus, den das
Modul schon hatte, **ist** der Migrationspfad: erkannt wird die kanonische Form plus die
deklarierten `section_aliases:` plus das, was dieser Stack vor der Konventionsdatei geschrieben
hat. Ohne Datei antwortet dieser Fallback - richtig für jeden Korpus, der ihn erreichen kann,
denn der wurde unter genau diesen Namen geschrieben; `doctor` ist die laute Hälfte davon.
**Die vier Page-Type-Specs schreiben `## {section.relationships}`** statt einer Überschrift.
Neue Template-Variablen `{section.relationships}` / `{section.see_also}` / `{section.footnotes}`,
gefüllt aus der Instanzdeklaration. Damit ändert eine anderssprachige Instanz **keine Datei unter
`tools/` oder `types/`** mehr - was Schritt 5 von `setup-instance.md` von fünf Editierstellen
über drei Schichten auf eine Entscheidung reduziert.
**`COLLECTION.md` bekommt Frontmatter.** Bisher wurde eine Collection rein an der Dateipräsenz
erkannt; die Deklaration brauchte einen Träger, sonst wäre der Ortsschnitt nur durch einen
Prosaschnitt ersetzt worden. `profile:` nennt den übernommenen Katalogeintrag (Freitext - eine
selbst angelegte Collection hat dort keinen), `required_by_stack:` sagt, ob `wikitool` die
Collection *namentlich* auflöst. Das zweite ist **nicht** die Wahl der Instanz: `docs verify`
prüft es beidseitig gegen `kb_collections.STACK_REQUIRED_COLLECTIONS`. Heute steht dort genau
`sources` - `sources coverage`, die `[^cite-id]`-Auflösung und `kb/provenance.md` hängen an dem
Namen, `entities` an keinem.
**Das zweite Leck der Merge-Prozedur ist zu.** `git checkout HEAD -- kb raw` holte *alles* unter
beiden Stages auf den Vor-Merge-Stand - auch `kb/CONTRACT.md` und `raw/CONTRACT.md`. Änderte der
Upstream einen davon, warf die Prozedur das Update still weg, und die Kontrollzeile meldete dabei
*leer*, bestätigte den Fehler also, statt ihn zu fangen. `private-instance.md` nimmt die
Upstream-Seite jetzt für die drei Maschinerie-Pfade unter den Content-Stages zurück
(`kb/CONTRACT.md`, `kb/CONVENTIONS.md.template`, `raw/CONTRACT.md`) und schließt sie aus der
Kontrollzeile aus. Dieselbe Altlast in der Tarball-Richtung: `INSTALL.md` Schritt 3 fasste `kb/`
gar nicht an und zog `kb/CONTRACT.md` damit nie nach - jetzt ausdrücklich benannt.
**Verworfen, gemessen: `sources/` aus `kb/` herausziehen.** Der Graph ist einwurzelig
(`kb_scan.iter_kb_pages` macht ein `rglob` über `kb/`, darauf sitzen Link-Graph, Orphan-Check,
`index rebuild` und `search`), und Source-Seiten sind darin der dichteste Knotentyp. Ein Hoist
machte jede Graph-Operation dauerhaft zweiwurzelig, um ein Verzeichnis umzubenennen. Vor allem
aber kann der *Ort* Eigentum ohnehin nicht kodieren, sobald Collections offen sind: eine selbst
angelegte liegt im selben `kb/` wie die Defaults. Eigentum ist eine deklarierte Eigenschaft -
daher das Frontmatter oben. Gitea #39 trägt die Ablehnung im Volltext.
**Warum das MAJOR ist.** Die Rückwärtshälfte des Drop-in-Tests hält - 2.5.0 ignoriert beide neuen
Deklarationen folgenlos. Die Vorwärtshälfte nicht: nach dem Kopieren der Maschinerie FAILt
`doctor` auf der fehlenden `kb/CONVENTIONS.md`, `docs verify` bricht auf den undeklarierten
Collections, und `kb/CONTRACT.md` muss aus dem Release nachgezogen werden. Ein Shim war die
Alternative (`doctor` nur WARN, Pflichtfelder tolerant) und wurde verworfen: er hätte genau den
Zustand normalisiert, in dem eine Instanz glaubt, sie habe entschieden, während in Wahrheit der
Fallback antwortet - für eine englische Instanz hieße das `## Beziehungen` in englischen Seiten.
Die Handarbeit ist eine Datei und zwei Frontmatter-Zeilen je Collection; keine einzige `kb/`-Seite
ändert sich, weshalb `migrate done 3.0.0 --pages 0` ehrlich und kein Platzhalter ist.
---
## 2.5.0 - 2026-09-02 - Versionsstelle: Kompatibilitaet statt Inhaltsmigration, Breaking-Change-Vermerk erzwungen
**Author:** Torben Nehmer
Die Doku des Stacks führte für die Wahl der Versionsstelle zwei Fragen zusammen, die nicht
dieselbe sind: *muss der Korpus migriert werden?* und *ist die neue Version ein
Drop-in-Ersatz?* An allen drei Stellen, die einer Session vor dem Bump begegnen - die Tabelle
in `stack-dev`, der Modul-Docstring in `version.py`, `INSTALL.md` § Version und Updates -
stand nur die erste. Nur `version bump --help` formulierte es richtig, also ausgerechnet die
Stelle, die man erst liest, wenn die Entscheidung gefallen ist. Bei `2.0.0` hat genau diese
Lücke zu `1.9.0` geführt (Gitea #26; der Eintrag zu `2.0.0` trägt den Fall im Volltext).
**Die Regel, jetzt an einer Stelle.** Neu ist
`instructions/dev/version-parts.md`: der Drop-in-Test in zwei Hälften - vorwärts (kopieren,
fertig, keine Handarbeit durch Nutzer oder Migrationsskript) und rückwärts (die alte Version
lässt sich noch zurückinstallieren) -, der Katalog der Brüche, die die Grenze bei völlig
unangetastetem `kb/` überschreiten (Update-Pfad, Artefaktname, Import-Name, Flags, Envvars,
Shape maschinengelesener Dateien), und der Fall `2.0.0` als Fallbeispiel. `instructions/dev/`,
also nicht in einer ausgelieferten Instanz - die Datei entscheidet, wer den Stack entwickelt.
Die ausgelieferten Artefakte tragen deshalb die Kurzform der Regel, aber keinen Verweis auf
sie: `instructions verify` meldet einen solchen Verweis, weil er nach `dist export` ins Leere
zeigen würde.
**Breaking Changes sind teuer, also genehmigungspflichtig.** `stack-dev` hat einen
Entscheidungspunkt bekommen: stellt sich eine Änderung als nicht drop-in heraus, wird nicht
aus eigener Initiative über die Grenze gebumpt. Vorgelegt werden der konkrete Bruch, was jede
bestehende Instanz von Hand tun muss, und die Alternativen - Bruch per Shim vermeiden,
aufschieben und mit dem nächsten bündeln, oder in einen kompatiblen und einen brechenden Teil
mit Deprecation-Fenster spalten -, dazu eine Empfehlung. Dann Freigabe abwarten.
**`--breaking` ist neu und bei jedem Grenzübertritt Pflicht.** Es schreibt eine
`**Breaking Change:**`-Zeile in den Eintrag, vor die Migrationszeile: das ist, was der Operator
einer bestehenden Instanz beim Lesen der Release-Notes zuerst braucht. `docs verify` prüft den
neuesten grenzüberschreitenden Eintrag darauf, analog zu `**Migration:** none required` und aus
demselben Grund - eine Prosa-Regel driftet, ein Marker nicht. Auf einem kompatiblen Bump wird
`--breaking` verweigert, damit es keine Gewohnheit wird. Beide Prüfungen bleiben getrennt:
`check_migration_for_boundary` fragt, ob der Inhalt sich bewegt, `check_breaking_change_for_boundary`,
ob dem Operator gesagt wurde, dass der Tausch keiner zum Drüberkopieren ist. Ein Rebranding
besteht die erste und fällt durch die zweite.
**Was ausdrücklich nicht passiert:** `docs verify` prüft weiterhin nicht, *ob die Stelle
richtig gewählt* wurde. Kein Validator unterscheidet ein umbenanntes Flag von einem neuen; das
bleibt Urteil, und dieser Eintrag existiert, weil das Urteil eine Anleitung braucht, keinen
Automaten.
Berührt: `instructions/dev/version-parts.md` (neu), `instructions/dev/stack-dev/SKILL.md`,
`tools/chemenu/version.py`, `tools/chemenu/commands/version_cmd.py`,
`tools/chemenu/commands/docs_verify.py`, `tools/CONTRACT.md`, `INSTALL.md`. Sechs neue Tests.
---
## 2.4.1 - 2026-09-02 - INSTALL-MCP.md: Menschendoku fuer den Leseserver, und sie wird mit ausgeliefert
**Author:** Torben Nehmer
Nachtrag zu 2.4.0. Der Server war ausgeliefert und für Menschen nur über `tools/CONTRACT.md` und
eine Instruktion beschrieben — beide für Agenten geschrieben. `INSTALL-MCP.md` schließt das,
neben `INSTALL.md` und in derselben Form: Voraussetzungen, sechs Schritte, Verifikation,
Troubleshooting.
**Was drinsteht, das sonst nirgends zusammen stand:** die Client-Konfiguration als JSON-Block;
dass `WIKI_TRACE=0` nicht optional ist und wie die Startverweigerung aussieht; dass
`--host 0.0.0.0` bewusst nicht der Default ist; und der Verweis auf die
Traefik-ForwardAuth-Middleware unter <https://gitea.nehmer.net/torben/gitea-mcp-forward-auth>
samt dem, was sie erwartet (Bearer-Token gegen SHA-256-Hashes, `AUTH_PROXY_TOKEN_HASHES_DIR`).
Der Verifikationsabschnitt ist ein lauffähiges Skript — wörtlich so ausgeführt, bevor es hier
landete.
**`dist export` liefert die Datei mit.** `ROOT_FILES` ist eine Allowlist, also hätte sie sonst
keine Instanz erreicht: ein ausgelieferter Server, dessen Bedienungsanleitung im Ursprungs-Repo
zurückbleibt, ist einer, den der Empfänger durch Quelltextlesen findet. `INSTALL.md` bekommt
zusätzlich `CHEMENU_ROOT` und `WIKI_TRACE`/`WIKI_TRACE_DIR` in seine Konfigurationstabelle —
beide waren seit 2.3.0 bzw. seit jeher wirksam und dort nicht aufgeführt.
**Dateien:** `INSTALL-MCP.md` (neu), `INSTALL.md`, `README.md`,
`tools/chemenu/commands/dist_cmd.py`.
---
## 2.4.0 - 2026-09-02 - MCP-Leseserver: zweiter Konsument auf demselben Kern
**Author:** Torben Nehmer
Letzter Schritt der Sequenz aus #36, inhaltlich Issue #19. `chemenu` bekommt einen zweiten
Konsumenten: `search`, `types`, `describe_type`, `lint` und `status` über MCP. Kein CLI mit
angeschraubter Netzwerkschnittstelle — CLI und Server sind zwei Adapter auf dem Kern, den 2.3.0
freigelegt hat.
**`tools/chemenu/mcp/`**, im Repo statt als eigenes Artefakt. Der Golden-Test, der die
Serverantworten gegen die `--json`-Formen der CLI hält, läuft nur mit beiden Seiten in einer
Testsuite; getrennt würde aus einem Contract eine Versionsabsprache. Der Test ruft `wikitool` als
Subprozess gegen denselben Baum auf, über `CHEMENU_ROOT` — womit er nebenbei die Root-Auflösung
von außen mitprüft.
**Zwei Transports.** `stdio` zum Entwickeln und Testen ohne Netz, `streamable-http` für die
Auslieferung — der einzige, vor den sich die Authentifizierungs-Middleware überhaupt setzen kann,
weil sie ein HTTP-Reverse-Proxy ist. `sse` ist über das SDK erreichbar und wird bewusst nicht
angeboten: der abgelöste Remote-Transport, jetzt darauf zu bauen verschiebt den Wechsel nur.
`--host`/`--port` gibt es, weil der Default auf Loopback bindet und ein Container hinter einem
Proxy eine Adresse braucht, die der Proxy erreicht — eine Eigenschaft der Software, nicht einer
Installation. Beide Transports sind gegen den echten Korpus gegengeprüft.
**Kein Schreibpfad, strukturell.** Weder der Server noch `chemenu.api` importiert irgendetwas
unter `chemenu.commands`, also existieren `new`, `touch`, `xref`, `cite`, `publish`, `migrate`
und `version bump` in dieser Reichweite gar nicht, statt aus einer Liste gefiltert zu werden. Ein
Test importiert das Servermodul in einem frischen Interpreter und sieht in `sys.modules` nach;
ein zweiter ruft alle fünf Tools auf und vergleicht den Dateibaum, `HEAD` und
`git status --porcelain` vorher/nachher.
**Jede Antwort trägt ihren Commit.** `commit` und `as_of` in jedem Payload; `null` heißt, der
bediente Baum hat uncommittete Änderungen und die Antwort entspricht keiner Revision. Der Stempel
ist die Revision, aus der die Seiten *tatsächlich* gelesen wurden — zwischen Laden und Stempeln
kann der Baum sich bewegen, deshalb reicht der Ladepfad seine Revision durch, statt noch einmal
zu fragen. Das war beim ersten Durchlauf falsch: `types`/`lint`/`status` lasen die zuletzt
*gecachte* Revision und stempelten `null`, obwohl der Baum sauber war.
**Telemetrie in den bedienten Baum wird beim Start verweigert**, nicht stillschweigend
umgeleitet. Tracing ist per Default an und schreibt nach `reports/telemetry/` im Repo — genau das
Verzeichnis, das der Sync per `git reset --hard` wegräumen darf. `WIKI_TRACE=0` oder
`WIKI_TRACE_DIR` außerhalb des Korpus. Heute schreibt auf diesem Pfad nichts (der Emitter hängt an
`cli.main()` und den Gates), die Sperre ist gegen später.
**Fehler an der Protokollgrenze.** Ein `ChemenuError` wird zum `ToolError` des SDK — eine
absichtliche Ablehnung, deren Text den Aufrufer erreicht. Alles andere bleibt ein Absturz, dessen
Text auf dem Server bleibt. Ein kaputtes Prädikat ist das Argument des Aufrufers, also muss die
Zeile mitreisen, die sagt, was stattdessen zu schreiben ist.
**Bewusst nicht enthalten:** Authentifizierung und Rate Limiting (Middleware vor dem Prozess),
Deployment (private Infrastruktur), der Iteration Budget Gate — er begrenzt eine Agenten-Session
und nicht einen Nutzer, weshalb Retrieval von ihm befreit ist; ihn hier als Rate Limiter zu
benutzen würde ihn dazu verwässern.
**Die Abhängigkeit ist optional** (`tools/requirements-mcp.txt`): eine Instanz, die nur die CLI
benutzt, soll dafür nicht pydantic, starlette, uvicorn und cryptography installieren müssen. CI
installiert sie, denn ein übersprungener Golden-Test ist genau der Weg, auf dem Server und CLI
unbemerkt auseinanderlaufen.
Betrieb und Sync-Mechanismus: [instructions/mcp-read-server.md](instructions/mcp-read-server.md).
Polling (`git fetch && git reset --hard`) statt Webhook — kein eingehender Endpunkt, keine
Signaturprüfung. `reset --hard` ist dort tragend und keine Bequemlichkeit: ein abgedrifteter Baum
antwortet zwar richtig, parst aber bei jeder Anfrage neu und stempelt jede Antwort mit `null`.
**Dateien:** `chemenu/mcp/` (neu: `server.py`, `__main__.py`), `chemenu/api.py`,
`tools/requirements-mcp.txt` (neu), `instructions/mcp-read-server.md` (neu), `tools/CONTRACT.md`,
`tools/README.md`, `.gitea/workflows/ci.yml`, `tests/test_mcp_server.py` (neu).
---
## 2.3.0 - 2026-09-02 - Bibliotheksgrenze: chemenu laesst sich auf einen Korpus zeigen
**Author:** Torben Nehmer
Dritter Schritt der Sequenz aus #36, inhaltlich Issue #31. Der Schritt, der `chemenu` von einem
Skript, das in seinem eigenen Verzeichnis lebt, zu einer Bibliothek macht, auf die man einen
Korpus *zeigen* kann. Rückwärtskompatibel: ohne gesetzte Variable verhält sich `tools/wikitool`
unverändert.
**Root-Auflösung nach Präzedenz statt nach Dateilage.** `config.resolve_root()`: expliziter
Parameter → `$CHEMENU_ROOT` → Walk-up wie bisher. Der Walk-up bleibt Default. Wichtiger als die
neue Fähigkeit ist die beseitigte Fehlerklasse: `ROOT` und alles darunter waren
Modulkonstanten, also **zur Importzeit gebunden**, und
`monkeypatch.setattr(config, "ROOT", ziel)` zeigte `ROOT` um, aber nicht `KB_DIR`/`RAW_DIR`. Wer
sich darauf verließ, baute etwas, das scheinbar auf einem Zielbaum arbeitete und in Wahrheit aus
dem Entwickler-Checkout antwortete. Die abgeleiteten Pfade werden jetzt bei jedem Zugriff
aufgelöst (PEP 562 `__getattr__`) und folgen `ROOT` — der halb-umgezeigte Zustand ist nicht mehr
konstruierbar. `CHEMENU_ROOT` ist in `_WIKITOOL_ENV` registriert, #23-konform.
**`config.reset()` und `config.rooted()`.** `reset()` nimmt Zuweisungen auf die verwalteten
Pfadnamen zurück; die Testsuite ruft es zwischen Tests, und das ist dort nicht optional:
`monkeypatch` merkt sich den alten Wert, indem es ihn *liest* — also auflöst — und schreibt ihn
beim Aufräumen als echtes Attribut zurück. Genau die stale Bindung, die der Umbau unmöglich
machen sollte, vom Cleanup wieder aufgebaut. `rooted(root)` setzt den Root für die Dauer eines
Blocks, prozessweit und damit nicht thread-sicher — der Aufrufer hält das Lock, dieselbe
Disziplin wie beim Korpus-Cache. Nötig, weil nicht alles einen Root als Argument nimmt: der
modulglobale `TypeResolver` muss `types/` finden, und ohne ihn läse ein fremder Korpus mit den
Type-Specs *dieses* Checkouts.
**Die Naht ist gezogen.** `run_search`/`run_lint`/`types` lagen in Modulen, die `typer` auf
Modulebene importieren und über `_util` auch `rich` — wer sie in-process aufrief, zog den
kompletten CLI-Kopf mit. Der reine Kern liegt jetzt in `search/service.py`, `lint_core.py` und
`types_core.py`; `commands/` sind die Terminal-Adapter darüber und re-exportieren die Namen, damit
kein bestehender Import bricht. Ein Test importiert `chemenu.api` in einem frischen Interpreter
und prüft, dass weder `chemenu.commands.*` noch `typer`/`rich`/`click` geladen werden.
**`chemenu.api.Corpus` als In-Process-Einstieg.** Nimmt einen Root, liefert exakt die
`--json`-Formen der CLI zurück — ein Wire-Contract statt zwei — und stempelt jede Antwort mit dem
Commit-SHA und einem Zeitstempel (`commit`, `as_of`), so dass aus einer stillen veralteten Antwort
eine sichtbare wird. `search`/`lint`/`types`/`describe_type`/`status`; `status` ist bewusst
serverseitig **komponiert** und kein Wrapper, weil es kein `wikitool status` gibt. Lesend
strukturell: nichts unter `chemenu.commands` wird importiert, die Schreibfunktionen existieren in
dieser Oberfläche also gar nicht, statt gefiltert zu werden. Das ist die Grenze, auf der #19
aufsetzt.
**Exceptions statt Exit-Codes an der Grenze.** `chemenu/errors.py`: `ChemenuError` mit
`ValidationError` (Eingabe abgelehnt) und `BackendError` (Abhängigkeit fehlt oder scheitert).
`PredicateError`, `FrontmatterError`, `UnknownBackend` und die beiden `Ripgrep*` hängen jetzt
darunter; `ValidationError` erbt zusätzlich von `ValueError`, weil `PredicateError` vorher eines
war und Aufrufer es so fangen. Das CLI-Verhalten ist unverändert: `fail()` → `ERROR`-Zeile,
Exit 1, Budget-Refund.
**`resolve()` reicht den Root an das Backend durch.** Vorher konnte ein Aufrufer `run_search` einen
Korpus übergeben, während `RipgrepBackend` weiter `config.KB_DIR` durchlief — die Anfrage aus dem
einen Baum beantwortet, die Seiten aus dem anderen gelesen, ohne dass irgendetwas das gesagt
hätte.
**Zwei Abhängigkeiten, die durch Zufall hielten, stehen jetzt da.** `TypeResolver.repo_root`
folgt `ROOT`, statt beim Import zu binden — womit Fixtures, die `ROOT` auf einen tmp-Baum zeigen,
die mitgelieferten Type-Specs ausdrücklich benennen müssen (`use_shipped_type_specs`). Dieselbe
Form wie das Loch, für das `raw_dir` geschrieben wurde, eine Ebene tiefer. Und
`types describe --json` trug `root:` nicht im Payload, obwohl `types list --json` es tut:
`types/instruction.md` deklariert `root: repo`, die Renderer-Zeile las es direkt aus dem
Frontmatter. Jetzt im Payload, in derselben Form wie bei `list`.
**Dateien:** `config.py`, `errors.py` (neu), `api.py` (neu), `search/service.py` (neu),
`lint_core.py` (neu), `types_core.py` (neu), `search/registry.py`, `type_resolver.py`,
`commands/search.py`, `commands/lint.py`, `commands/types_cmd.py`, `tools/CONTRACT.md`, dazu
`tests/conftest.py`, `tests/test_api.py` (neu), `tests/test_new_page.py`,
`tests/test_instructions_cmd.py`.
---
## 2.2.4 - 2026-09-02 - Haertung des Lesepfads: ReDoS, Subprozess-Timeout, YAML-Alias-Budget, Korpus-Cache
**Author:** Torben Nehmer
Zweiter Schritt der Sequenz aus #36, inhaltlich Issue #33. Limits vor der Exposition: der
Lesepfad bekommt mit dem MCP-Server (#19) einen Konsumenten, der nicht der Operator ist. Alle
sechs Befunde waren reproduziert, nicht vermutet; jeder hat jetzt eine Regression.
**ReDoS über `--regex` beseitigt.** `search/ripgrep.py:_contains` gab nutzergesteuerten Regex an
Pythons Backtracking-Engine: `(\w+\s?)+$` gegen 114 Zeichen gewöhnlichen Seiteninhalts terminiert
nicht in acht Sekunden, ein deterministisch scheiterndes Muster braucht 0,2 ms — der Unterschied
ist das Muster, nicht der Heuhaufen. `build_hit` ruft die Funktion je Treffer zweimal auf, und
`\w` matcht jede Seite: eine Anfrage kaufte zwei unbegrenzte Suchen pro Korpusseite. Der Zweig ist
**gelöscht**, nicht begrenzt. `rg` hat das Muster mit einer linearen Engine längst angewendet, wenn
die Funktion überhaupt läuft; verloren geht nur der zusätzliche Titel-/Summary-Bonus für
nicht-literale Muster, und Summary wie H1 sind selbst Zeilen in der Datei, die `rg` zählt.
**Subprozess-Timeout.** `rg` wird nach 30 s abgeräumt und über den vorhandenen
`RipgrepFailed`-Pfad gemeldet. Kein Performance-Budget — eine Fixed-String-Suche kostet hier 6 ms —
sondern ein Hänger-Abbruch, damit ein Aufruf als Fehler endet statt den Aufrufer offenzuhalten,
während seine Ausgabe in den Heap puffert.
**YAML-Anchors und -Aliases im Frontmatter werden verweigert, nicht budgetiert.** Gemessen:
267 Byte werden in 0,2 ms zu 672.603 Knoten beim Traversal, Wachstum 9ⁿ bei konstanter Parse-Zeit
— ein Größenlimit fasst das nicht an, weil die Eingabe klein bleibt. Die Prüfung läuft auf dem
*Event*-Strom (`yaml.parse`), der nichts auflöst, kostet also O(Text) und löst nie aus, wonach sie
fragt; `*` ist in jedem Alias-Knoten notwendig, seine Abwesenheit beweist Abwesenheit ohne jeden
Parse — der Weg, den jede echte Seite nimmt. Dazu ein Größenlimit von 64 KiB und ein Abfangen von
`RecursionError` (PyYAML komponiert rekursiv, tiefe Verschachtelung ist kein `YAMLError`). Heute
nicht erreichbar, weil `kb/` der Operator committet; erreichbar mit der Ingest-Queue (#32).
**`CSafeLoader` statt `SafeLoader`, mit Fallback.** Gemessen über diesen Korpus (176 Seiten,
best of 5): **265 ms → 54 ms**. Kein Mikro-Tuning — der Korpus-Parse war der größte Einzelposten
eines `search`-Aufrufs und skaliert linear mit der Korpusgröße. End-to-end fällt ein
`wikitool search` damit von 593 ms auf **347 ms**; die verbleibenden 262 ms sind Modulimport und
entfallen erst im residenten Prozess (#19).
**Stiller Frontmatter-Verlust wird gemeldet.** Entschieden: der Lesepfad *nennt* die Seite, statt
sie zu schlucken. Kaputtes YAML wird weiterhin zu `{}` — Massenoperationen dürfen an einer Seite
nicht scheitern —, aber der Grund wird mitgeführt (`Page.frontmatter_error`) und ausgegeben:
`search --json` trägt immer eine `unreadable`-Liste aus `{path, reason}`, die Tabellenform
schreibt dieselben Zeilen nach stderr. Das war nötig, weil so eine Seite weder `confidence` noch
`kind` hat und damit aus jedem positiven `--field`-Prädikat fällt — ausgerechnet aus der
Low-Confidence-Suche, die Seiten in genau diesem Zustand finden soll — und dabei aussieht wie eine
Seite, die nicht gematcht hat. Für Frontmatter, das diese Instanz nicht selbst geschrieben hat,
steht `read_page_strict()` bereit: die Quarantäne aus #32 muss strikt lesen, wo ein
unlesbares Dokument das Dokument stoppen und nicht leeren soll.
**Ein Parser statt zwei.** `read_page()` und `frontmatter_error()` liefen bisher getrennt durch
`safe_load` — so konnte der permissive Weg zu `{}` degradieren aus einem Grund, den der strikte
Weg anders beschrieb, und jeder Aufrufer, der beide Antworten wollte, las die Datei zweimal.
Beide gehen jetzt durch `_load_frontmatter()`; ein Test hält sie gegeneinander.
**Korpus-Cache am Commit-SHA** (`chemenu/corpus_cache.py`), als Objekt, das ein Aufrufer *hält* —
kein Modul-Dict, das sich hinter allen einschaltet. Die CLI hält keins und verhält sich unverändert
(ein Aufruf pro Prozess, nichts wiederzuverwenden); der residente Prozess aus #19 hält eins.
Entscheidend ist nicht die Geschwindigkeit, sondern dass nichts veraltet: **ein schmutziger
Arbeitsbaum wird nie gecacht**, sonst bekäme eine Sitzung, die eine Seite schreibt und danach
sucht, die Analyse von vor dem Schreiben — bei unverändertem SHA. Kann git nicht antworten, gilt
der Baum als schmutzig. Derselbe SHA ist der Antwort-Stempel aus #19, per Konstruktion also die
Revision, aus der die Antwort auch wirklich berechnet wurde.
**Erhalten geblieben** ist die Eigenschaft, die kein Befund war: kein `shell=True`,
`--fixed-strings` als Default, `--`-Terminator. Der Modul-Docstring führt sie jetzt als drei
tragende Zusagen statt zwei — die dritte ist, dass nutzergesteuerte Muster ausschließlich `rg`
sieht.
**Dateien:** `frontmatter_io.py`, `search/ripgrep.py`, `commands/search.py`, `page.py`,
`corpus_cache.py` (neu), `tools/CONTRACT.md`, dazu `tests/test_frontmatter_io.py`,
`tests/test_search.py`, `tests/test_corpus_cache.py` (neu).
---
## 2.2.3 - 2026-09-02 - Publish-Remote-Gate in diesem Checkout scharf, doctor benennt den Zustand
**Author:** Torben Nehmer
Erster Schritt der Sequenz aus Issue #36 (Weg zum MCP-Leseserver), inhaltlich Issue #34. Das
Gate war seit 2.2.0 implementiert und in diesem Checkout **inert**: `.wikitool-remotes.json`
fehlte, und eine fehlende Datei heißt unbeschränkt. `ENVIRONMENT.md` beschrieb `origin`
gleichzeitig als einziges Publish-Ziel, `AGENTS.md` führt das Gate unter den drei in Code
erzwungenen Grenzen. Eine dokumentierte Sicherung, die nicht greift, ist schlechter als eine
fehlende — sie erzeugt genau das Vertrauen, das sie nicht verdient.
**Die Datei ist angelegt** (gitignored, per Checkout, reist nicht mit) und gegengeprüft: ein
`publish --remote` auf ein nicht gelistetes Ziel verweigert mit Exit 42, bevor irgendetwas
gestaged wird, und der Arbeitsbaum bleibt unberührt. Damit steht die Sicherung **vor** dem Klonen
der privaten Instanz (#30) — nachträglich gesetzt ließe sie genau das Fenster offen, das sie
schließt.
**`doctor` sagt jetzt, ob das Gate scharf ist, nicht nur ob die Datei da ist.** Alle drei
Zustände beginnen mit `Gate armed:` bzw. `Gate not armed:`; der einzelne Remote ohne Allowlist
bleibt `OK` (er hat nichts zu schützen, und ein FAIL machte die Datei durch die Hintertür
verpflichtend), sagt aber ausdrücklich, dass jedes Push-Ziel durchkommt. Der Fall, der wirklich
beißt — mehrere Remotes ohne Allowlist — bleibt `WARN`. Der Check hatte bislang **keine Tests**;
drei sind dazugekommen, einer je Zustand.
**Dateien:** `.wikitool-remotes.json` (neu, nicht committet), `doctor.check_publish_remotes()`,
`tools/chemenu/tests/test_doctor.py`.
---
## 2.2.2 - 2026-09-01 - Doku-Verdrahtung: Publish-Remote Gate im Werkzeugvertrag, Projektseite auf oeffentlich
**Author:** Torben Nehmer
Nachziehen dessen, was 2.2.0 und die Veröffentlichung offen gelassen haben. Gefunden durch eine
Durchsicht auf lose Enden, nicht durch einen Fehlschlag — `docs verify` deckt den Fall nicht ab,
weil es Kommando-*Namen* gegeneinander prüft, nicht ob ein neuer Fehlerfall beschrieben ist.
**`tools/CONTRACT.md` kannte das Publish-Remote-Gate nicht.** Der Werkzeugvertrag ist die Stelle,
an der pro Kommando steht, was ein Fehlschlag bedeutet und ob ein Retry sicher ist — und
`publish` hatte seit 2.2.0 einen dritten Weg zu Exit 42, der dort nirgends stand. Ergänzt in
beiden Richtungen: in der Kommandozeile (URL statt Remote-Name, `pushurl` wird gelesen, fehlende
Datei heißt unbeschränkt, kaputte Datei ist ein Fehler) und im Fehlerkontrakt, wo der
entscheidende Unterschied zu den anderen beiden Gates steht — es gibt **keine** `--confirm`-Zeile,
die der Agent nachreichen könnte.
**`instructions/gates.md` verwies nicht auf die Prozedur, für die das Gate gebaut wurde.**
Jetzt verlinkt, mit dem Hinweis auf Schritt 4: Das Gate gehört vor den ersten `publish`, später
hinzugefügt schützt es das Fenster nicht, das es schließen soll.
**Die Projektseite beschrieb sich selbst falsch.** `kb/entities/projects/Chemenu.md` nannte
Chemenu ein „persönliches IT-Wissens-Wiki" mit dem Zweck „Persönliche IT-Wissensbasis" — seit
der Veröffentlichung schlicht unzutreffend, und es ist die Seite, die ein Fremder über das
Projekt liest. Neu gefasst: deterministischer Wissenskompiler, diese Instanz seit 2026-09-01
öffentlich als Testbett und Demo, Lizenz benannt.
Die historische Aussage über die monolithische `AGENTS.md` (~30 KB) **bleibt** — sie war zu ihrer
Zeit korrekt und ist belegt. Sie ist jetzt als Ausgangspunkt datiert statt als Gegenwart
formuliert, dieselbe Unterscheidung, die #29 für die Issue-Texte trifft: ein Pfad als Wegweiser
wird nachgezogen, ein Pfad als Beleg bleibt stehen und bekommt ein Datum.
**Dateien:** `tools/CONTRACT.md`, `instructions/gates.md`,
`kb/entities/projects/Chemenu.md`, `kb/entities/INDEX.md` (generiert).
---
## 2.2.1 - 2026-09-01 - private-instance: der Demo-Korpus wandert beim Merge doch mit - Prozedur korrigiert
**Author:** Torben Nehmer
`instructions/private-instance.md` behauptete in 2.2.0, ein `git merge upstream/main` löse
Änderungen am Demo-Korpus stillschweigend auf, weil die private Instanz ihn einmal gelöscht hat:
*deleted-in-ours, unmodified-in-theirs*. Das war **nicht gemessen, sondern angenommen** — und es
ist falsch. Ein Nachbau mit einem Upstream, der seinen Korpus bewegt, zeigt drei verschiedene
Verhalten:
| Upstream tut | `git merge upstream/main` tut |
|---|---|
| ändert eine Seite, die du gelöscht hast | `CONFLICT (modify/delete)` — und **lässt die Upstream-Fassung im Arbeitsbaum liegen**. Ein `git add -A` beim Auflösen holt die Demo-Seite zurück. |
| legt eine neue Seite an | staged sie **stillschweigend**. Kein Konflikt, keine Meldung. |
| löscht eine Seite, die du auch gelöscht hast | nichts. Der einzige harmlose Fall. |
Die mittlere Zeile ist die gefährliche, weil nichts sie ankündigt. Ein Upstream, der einen
Demo-Korpus ausliefert **und** ihn als Testbett benutzt, legt Seiten an — und jede einzelne
landet in der privaten Instanz und taucht dort in `lint`, `index`, `search` und
`confidence decay` auf. Genau diese Doppelnutzung beschreibt Issue #28.
**Korrigiert:** Die Update-Prozedur hält den Merge mit `--no-commit` offen, zwingt `kb/` und
`raw/` danach auf den eigenen Stand zurück (`git rm --cached`, `rm -rf`, `git checkout HEAD --`)
und schließt ihn erst dann. Solange der Merge offen ist, zeigt `HEAD` noch auf den Commit davor,
und genau das macht den Schritt sauber. Anschließend eine Kontrolle, die man nicht überlesen
kann:
```bash
git diff --name-only $BEFORE HEAD -- kb raw # muss leer sein
```
Das Rezept ist wörtlich so nachgespielt worden, wie es jetzt in der Datei steht — mit einem
Upstream, der gleichzeitig eine Seite ändert, eine anlegt, eine löscht und dasselbe unter
`raw/` tut. Ergebnis: Stack aktualisiert, nur eigener Inhalt übrig, Kontrolle leer,
Arbeitsbaum sauber.
**Auch die Decision Points korrigiert.** „Konflikt in `kb/` per Hand als *keep deleted*
auflösen" stand vorher da und ist der Rat, der in die Falle führt: `git add -A` committet die
Fassung, die git im Arbeitsbaum liegen gelassen hat.
**Dateien:** `instructions/private-instance.md`.
---
## 2.2.0 - 2026-09-01 - Publish-Remote Gate: publish schreibt nur an erklaerte Ziele
**Author:** Torben Nehmer
Der Stack bekommt sein drittes Gate. Die beiden bestehenden fragen, ob eine Änderung zu groß
ist und ob ein Rebase gefährlich ist. Dieses fragt, was darunter liegt: **ob das überhaupt das
richtige Repository ist.**
**Das Problem entsteht erst durch die private Instanz.** Ein Checkout mit eigenem Inhalt hat
typischerweise zwei Remotes — sein eigenes und das öffentliche Upstream, von dem er
Stack-Updates zieht. Git unterscheidet die beim Push nicht, also legt ein falsches `--remote`
einen privaten Korpus auf ein öffentliches Repository. Das ist nicht billig rückholbar, und
zwar nachweislich: Beim Veröffentlichen dieses Repos blieb die gesamte alte History nach dem
Force-Push per SHA abrufbar, bis auf dem Server die Reflogs verfielen und `git gc --prune=now`
lief. Ein Force-Push bewegt den Branch, nicht die Objekte.
**`.wikitool-remotes.json` nennt die erlaubten Push-URLs.** Nicht die Remote-*Namen*: Eine
Namensliste ließe ein `publish` durch, dessen `origin` umgebogen wurde, und genau das ist der
Fall, den das Gate fangen soll. Gelesen wird die `pushurl`, wenn der Remote eine setzt, denn
dorthin schreibt `git push` tatsächlich.
**Pro Checkout und gitignored**, aus demselben Grund wie `ENVIRONMENT.md`: Zwei Klone pushen an
zwei verschiedene Orte, eine committete Kopie würde einem privaten Klon also mitteilen, das
öffentliche Upstream sei ein legitimes Ziel für seinen eigenen Inhalt. **Fehlt die Datei, gilt
keine Beschränkung** — ein Checkout mit einem Remote und ohne Privates hat nichts zu schützen,
und eine Pflichtdatei würde aus einer Sicherung Papierkram machen. Eine *kaputte* Datei ist
dagegen ein Fehler und kein „keine Beschränkung": Eine beschädigte Sicherung darf sich nicht
wie eine abgeschaltete verhalten.
**Kein Token, keine Flagge.** Die anderen beiden Gates lösen sich mit einem `--confirm <token>`,
weil ihre Frage („ist diese Änderung richtig?") für genau ein Changeset beantwortbar ist. Dieses
fragt „gehört dieser Inhalt in jenes Repository?", und das ist eine stehende Eigenschaft des
Checkouts, kein Einzelfallurteil. Der Weg daran vorbei ist ein bewusster Edit des Nutzers.
Ein Agent, der die Datei anfasst, um an einer Verweigerung vorbeizukommen, öffnet ein Gate aus
eigenem Antrieb — Invariante 6.
**`doctor` meldet den Zustand** statt ihn zu erzwingen: OK mit Anzahl der Ziele, OK bei
Abwesenheit mit einem Remote, und WARN bei mehr als einem Remote ohne Allowlist — also genau in
der Form, die eine private Instanz annimmt, sobald sie das Upstream hinzufügt.
**Und die Prozedur, für die das Gate gebaut wurde.** `instructions/private-instance.md` (neu)
beschreibt die dritte Instanz-Form neben „frisch aufsetzen" und „Repo klonen": eine private
Arbeitsinstanz, die Stack-Updates von einem öffentlichen Upstream per `git merge` zieht und
deren eigener Inhalt nie zurückwandert. Der Grund, warum das dem Tarball-Weg vorzuziehen ist,
steht dort ausformuliert — `cp -r` hat keinen Drei-Wege-Merge und keine Konflikterkennung.
Schritt 4 der Anleitung ist das Gate, und zwar ausdrücklich **vor** dem ersten `publish`:
später hinzugefügt schützt es das Fenster nicht, das es schließen soll.
**Dateien:** `config.PUBLISH_REMOTES_FILENAME`, `git_publish.read_allowed_push_urls()`,
`push_url_for()`, `publish_remote_refusal()` und die Prüfung vor dem Reconcile-Schritt,
`doctor.check_publish_remotes()`, `.gitignore`, `instructions/gates.md`,
`instructions/private-instance.md` (neu), `AGENTS.md` (Gate-Liste und Bootstrap-Routing),
12 neue Tests in `test_git_publish.py`.
---
## 2.1.1 - 2026-09-01 - raw_dir-Fixture kappt config.ROOT; letzte private Fixture-Namen ersetzt
**Author:** Torben Nehmer
Der erste CI-Lauf nach der Korpus-Bereinigung wurde rot, während dieselbe Suite lokal grün
blieb: `test_legacy_source_pages_flags_url_and_directory` scheiterte mit
`KeyError: 'Source - DirBacked'`.
**Die Ursache ist keine Testschwäche, sondern eine Umgebungsabhängigkeit.** Der Test legt eine
Quellenseite mit `source: raw/documents` an und erwartet, dass `legacy_source_pages()` sie als
„directory, not a file" meldet. Die Prüfung dort ist `(config.ROOT / legacy).is_dir()` — sie
löst also gegen das *echte* Repo-Wurzelverzeichnis auf, während die `raw_dir`-Fixture ihren
Baum unter `tmp_path` baut. Der Test war nie hermetisch; er bestand, weil dieser Checkout
zufällig ein `raw/documents/` besaß.
Die Korpus-Bereinigung hat dieses Verzeichnis geleert. Git verfolgt keine leeren Verzeichnisse,
also verschwand es im CI-Checkout und blieb im lokalen Arbeitsbaum stehen — grün hier, rot
dort, ohne dass sich am Test etwas geändert hätte.
**Geschlossen in der Fixture, nicht im Test.** `raw_dir` setzt jetzt `config.ROOT` auf
`tmp_path`. Das ist dieselbe Bauart und dasselbe Argument wie bei `hermetic_environment`
(Gitea #8): Wenn eine Umgebungsabhängigkeit lokal unsichtbar ist, gehört sie an der Stelle
geschlossen, die alle Tests erreicht, statt bei dem einen, der zufällig darüber stolpert.
Nachgewiesen, indem `raw/documents/` lokal entfernt und die Suite erneut gefahren wurde — 38
Provenance-Tests grün ohne das Verzeichnis, vorher rot.
**Fixture-Namen.** Die Suite benutzte weiterhin reale Systemnamen der Ursprungsinstanz als
Fixture-Bezeichner. In einem
öffentlichen Repo beschreiben sie nichts, verraten aber die Namensgebung einer privaten
Umgebung. Ersetzt durch `aurora`, `almanac`, `Borealis`, `gateway`, `docstore`. `gdeploy`
bleibt: die Seite existiert im öffentlichen Korpus.
**Dateien:** `tools/chemenu/tests/conftest.py` (`raw_dir`), sämtliche Testdateien mit
Fixture-Namen.
---
## 2.1.0 - 2026-09-01 - Dual-Lizenz AGPL-3.0/CC-BY-4.0, und ein Leak-Canary fuer dist export
**Author:** Torben Nehmer
Das Repo hatte keine Lizenz. Für ein Repo, das privat auf der eigenen Gitea liegt, ist das
folgenlos; für eines, das veröffentlicht werden soll, hebt es den Zweck der Veröffentlichung
auf — ohne Lizenz gilt „alle Rechte vorbehalten", und niemand darf eine Instanz aufsetzen.
**Zwei Hälften, zwei Lizenzen.** `LICENSE` trägt die **AGPL-3.0** und gilt für den Stack
(`tools/`, `types/`); `LICENSE-CONTENT` trägt **CC-BY-4.0** und gilt für die Inhalte (`kb/`,
`raw/`, `instructions/`, die `CONTRACT.md`-Ebene, die Prosa im Wurzelverzeichnis). Die
Aufteilung folgt dem Vorbild der vendorten Commonplace-Basis, kehrt aber deren Dateinamen um:
Die Copyleft-Hälfte heißt `LICENSE`, weil das die Lizenz ist, die eine Forge für das Repository
meldet — und wer eine Copyleft-Pflicht übersieht, wird dadurch geschädigt, wer eine zu viel
sieht, nicht.
Die **Affero**-Variante statt der einfachen GPL, weil Issue #19 den Stack als MCP-Frontend
erreichbar machen will. Bei der GPL schuldet nichts, wer nur einen Dienst betreibt statt
Software zu verteilen; genau dieser Weg ist der, den dieses Projekt einschlägt.
**Die Grenze wird nicht aufgeschrieben.** Welche Datei zu welcher Hälfte gehört, ist der
Dateiplan, den `dist export` ohnehin berechnet. `NOTICE` verweist darauf, statt eine zweite
Pfadliste anzulegen, die drifted (Invariante 8). `NOTICE` trägt außerdem die Attribution für
`commonplace` (zby, CC-BY-4.0 Inhalte + MIT Code), die CC-BY verlangt.
**`dist export` liefert die Lizenzen mit — und weigert sich sonst.** Die drei Dateien stehen
jetzt in `ROOT_FILES`, aber nicht nur dort: `REQUIRED_ROOT_FILES` macht sie zur Bedingung.
Jeder andere `ROOT_FILES`-Eintrag wird `if source.is_file()` kopiert und sonst stillschweigend
übersprungen — richtig für `INSTALL.md`, das es lange nicht gab, und falsch für eine Lizenz:
Ein Export, der sie weglässt, übergibt einen AGPL-`tools/`-Baum ohne Lizenztext, und das ist
eine Verletzung, sobald die empfangende Instanz irgendwo öffentlich landet.
**Der Leak-Canary.** `find_leaks()` prüft den fertigen Plan gegen das, was er nie enthalten
darf: eine ausgefüllte `USER.md`/`SOUL.md`/`ENVIRONMENT.md`, etwas unter `instructions/dev/`,
eine `kb/`-Seite oder eine `raw/`-Quelle. Drei getrennte Allowlists entscheiden bisher, was
`build_plan` kopiert, und jede hält nur, solange sich beim Editieren jemand an die Regel
erinnert hat — nichts prüfte das Ergebnis nach. `export` bricht jetzt ab, wenn der Plan etwas
davon trägt, mit dem Hinweis, die Allowlist zu reparieren statt Dateien aus dem Ziel zu löschen.
Die Prüfung ist bewusst **strukturell** und nicht textbasiert. Ein Muster-Scan auf Hostnamen
oder IP-Literale wurde erwogen und verworfen: Der eigene Host steht legitim in `INSTALL.md` und
`version.py`, ein solcher Scan müsste also genau die Zeichenkette ausnehmen, nach der er sucht —
oder bei jedem Export Fehlalarm geben.
**Beispiele neutralisiert.** Hilfetexte, Docstrings und Contract-Beispiele nannten reale
Quellenseiten und Pfade der Ursprungsinstanz. In einer
Distribution ist das nicht nur unpassend, sondern verrät den Inhalt der Instanz, die sie gebaut
hat. Ersetzt durch Beispiele aus dem verbleibenden öffentlichen Korpus.
**Dateien:** `LICENSE`, `LICENSE-CONTENT`, `NOTICE` (neu), `config.LICENSE_FILES`,
`dist_cmd.REQUIRED_ROOT_FILES`, `dist_cmd.find_leaks()`, Lizenzabschnitt in `README.md`,
sechs neue Tests in `test_dist_cmd.py`; Beispiel-Neutralisierung in `cite_cmd.py`,
`page_ops.py`, `work_cmd.py`, `provenance.py`, `search/ripgrep.py`, `work/CONTRACT.md`,
`instructions/session-setup.md` und zwei Testdateien.
---
## 2.0.0 - 2026-09-01 - Rebranding zu Chemenu: Repo, Produktname und Python-Paket
**Author:** Torben Nehmer
**Migration:** none required - Der Bruch liegt im Update-Pfad und im Import-Namen, nicht im Korpus: kb/ behaelt Schema und Shape 1.0.0, keine Seite und kein Frontmatter-Feld aendert sich. Was eine bestehende Instanz tun muss, steht im Eintrag - Release-Feed umstellen, Paketimport nachziehen -, und beides ist Instanz-Konfiguration, keine Inhaltsmigration.
Der Stack hieß `llm-wiki-test1`, und das war nie ein Name, sondern ein Platzhalter mit einer
Ordnungszahl darin. Gitea-Issue #3 setzt **Chemenu** an seine Stelle - der altägyptische Name
von Hermopolis Magna, Hauptkultort des Thoth und „Stadt der Acht" der Ogdoade. Der Ort, nicht
der Gott: die Persona heißt weiterhin Thoth, und der Stack ist das, worin sie schreibt.
**Was den Namen trägt.** Repo (`torben/chemenu`), Produktname in `README.md`, `AGENTS.md`,
`.gitignore`, `types/type-spec.md` und `tools/CONTRACT.md`, das Release-Artefakt
(`chemenu-stack-<version>.tar.gz` statt `llm-wiki-stack-…`), der Release-Feed in
`version.DEFAULT_UPDATE_URL`, und die Download- und Issue-URLs in `INSTALL.md` und
`instructions/dev/issue-tracking.md`. Die eine Nennung von „LLM Wiki", die stehen bleibt, ist
die Attribution an Karpathys ursprüngliches Gist in `README.md` - das ist die Herkunft der
Idee, nicht der Name dieses Stacks.
**Das Python-Paket heißt jetzt `chemenu`**, nicht mehr `wiki_tools`. Der Import-Name eines
Pakets ist ein flacher globaler Namensraum ohne Kollisionsschutz, und `wiki_tools` ist genau
die Sorte generischer Name, die dort nichts verloren hat - unabhängig davon, ob je etwas
veröffentlicht wird, denn es reicht, dass ein zweites `wiki_tools` auf demselben `sys.path`
liegt. Betroffen sind 93 Dateien unter `tools/chemenu/` plus `pytest.ini`, `.coveragerc`, der
`tools/wikitool`-Shim und die Pfadangaben in `EVALS.md`, `instructions/dev/`, `types/*.md` und
`kb/CONTRACT.md`. `dist_cmd.py` brauchte nichts: es läuft `tools/` als Baum ab und nennt das
Paket nirgends beim Namen.
**Das Kommando bleibt `wikitool`**, und mit ihm `.wikitool-release.json`, `.wikitool-kb.json`
und die `WIKITOOL_*`-Variablen. Distributionsname, Import-Name und Kommandoname sind drei
unabhängige Dinge, und ein abweichender Kommandoname ist verbreitete Praxis (`httpie` →
`http`). Was der Stack nach außen anbietet, heißt weiter so, wie es tut, was es tut.
**Warum das trotzdem MAJOR ist.** Der erste Anlauf stand auf `1.9.0`, mit dem Argument, dass
kein Inhalt migriert werden muss. Das beantwortet die falsche Frage. Drei Dinge brechen:
1. **Die Update-Erkennung selbst.** Jede bereits exportierte Instanz trägt in
`.wikitool-release.json` ein `update_url` auf `.../repos/torben/llm-wiki-test1/releases/latest`.
Nach dem Repo-Rename antwortet Gitea dort bestenfalls per Redirect, schlimmstenfalls mit
`404` - und ein `404` ist von „kein Release vorhanden" nicht zu unterscheiden, wie
`INSTALL.md` § Konfiguration für den Token-Fall bereits festhält. Die Instanz kann sich
nicht selbst reparieren: der Stempel ist maschinengeschrieben, Invariante 1 verbietet den
Hand-Edit. Es bricht also ausgerechnet der Kanal, über den eine Instanz erführe, dass sie
etwas zu tun hat. Der Ausweg ist `WIKITOOL_UPDATE_URL` auf den neuen Feed zu setzen, oder
ein frischer `dist export`.
2. **Der Artefaktname.** `llm-wiki-stack-<version>.tar.gz` heißt jetzt
`chemenu-stack-<version>.tar.gz` - jedes Download-Skript und jeder Pin darauf bricht.
3. **Der Import-Name.** `from wiki_tools import …` außerhalb des ausgelieferten Baums bricht.
Keiner dieser drei Punkte verlangt, dass eine Seite in `kb/` angefasst wird - deshalb
`--no-migration` statt eines Migrationsdokuments. Aber rückwärtskompatibel ist das nicht, und
`1.9.0` hätte genau das behauptet.
**`SOUL.md.template` schlägt jetzt einen Persona-Namen vor.** Bisher stand dort ausdrücklich,
dass der Stack keinen vorschlägt; ab jetzt nennt er **Thoth** als Startpunkt, mit der
Begründung, die der neue Repo-Name mitbringt. Die Regel dahinter ändert sich nicht: gefragt
wird trotzdem, und ein anderer Name gewinnt. Weil das eine bestehende Regel verschiebt, steht
sie gleichlautend an allen drei Stellen, die sie führen - `SOUL.md.template`, Schritt 6 in
`instructions/setup-instance.md`, und `SOUL.md` dieser Instanz.
**Was den alten Namen behält:** `CHANGES.md`, `kb/log.md`, `kb/provenance.md`, die Prosa unter
`kb/sources/` und alles unter `raw/`. Das sind abgeschlossene Aufzeichnungen dessen, was
einmal galt - sie umzuschreiben hieße, die eigene Historie zu fälschen, und `raw/` ist
ohnehin unveränderlich.
---
## 1.8.1 - 2026-08-31 - Coverage-Reporting in CI: messen und als Artefakt ausweisen, ohne Schwelle
**Author:** Torben Nehmer
729 Tests liefen in CI, und wie viel vom Stack sie erreichen, wusste niemand - Coverage war nie
gemessen (Gitea #10). Der Verdacht war nicht theoretisch: Run 52 hatte zwei Tests gefunden, die
grün waren, weil die Umgebung zufällig passte. Was gar nicht ausgeführt wird, fällt leichter
durch.
**Gemessen wird jetzt, entschieden wird später.** Der CI-Schritt „Tests" läuft mit
`--cov --cov-report=term --cov-report=xml --cov-report=html`, ein neuer Schritt lädt XML und
HTML als Artefakt `coverage-<run id>` hoch (`upload-artifact@v3`, nicht v4 - v4 ist auf dieser
Gitea-Instanz eingeschränkt). Der Upload steht unter `if: always()`: eine rote Suite ist genau
der Moment, in dem die Zahlen pro Modul interessant sind.
**Kein `--cov-fail-under`, bewusst.** Eine Schwelle vor der Zahl hat zwei Ausgänge, beide
schlecht: sie liegt zu tief und wirkt nicht, oder sie liegt zu hoch und der nächste ehrliche
Commit wird rot - worauf jemand die Schwelle senkt statt Tests zu schreiben. Sie kommt in einem
eigenen, späteren Commit, mit der gemessenen Zahl als Begründung, und friert den erreichten
Stand ein, statt ihn schönzurechnen.
**Erste Messung: 86.9 % von 5105 Statements, 730 Tests** (2026-08-31, CI-Lauf 87). Festgehalten in EVALS.md
§ „How much of the stack the suite reaches", zusammen mit der Leseanleitung - denn der
Gesamtwert ist die uninteressanteste Größe. Der Bericht trennt drei Fälle, die verschieden zu
behandeln sind: dünne Typer-Wrapper (`eval_cmd.py` 36 %, `types_cmd.py` 52 %, `cli.py` 52 %),
bei denen niedrige Zahlen ein guter Schnitt sind und kein fehlender Test; Code mit einer
injizierbaren Außengrenze (`version.py`'s `fetch_latest()`), wo die echte Netzzeile absichtlich
ungetestet bleibt; und echte Lücken (`provenance_cmd.py` 44 %, `migrate_cmd.py` 71 %,
`type_resolver.py` 79 %). Nur die dritte Liste ist Arbeit.
`pytest-cov` steht **nicht** in `tools/requirements.txt`: die Datei beschreibt die
Laufzeitabhängigkeiten einer Instanz und wird von `dist export` mitgeliefert, und eine Instanz
misst diese Suite nicht. CI installiert es neben `pytest`. Die Konfiguration liegt in
**`tools/.coveragerc`** statt in `pytest.ini` - coverage.py liest `.coveragerc`, `setup.cfg`,
`tox.ini` und `pyproject.toml`, aber kein `pytest.ini`, wo ein `[coverage:*]`-Abschnitt still
ignoriert würde. Gemessen wird `wiki_tools/` ohne `wiki_tools/tests/`. Die Messung hängt
ausdrücklich nicht in `addopts`: das würde den nackten `pytest -q` überall dort brechen, wo
`pytest-cov` fehlt.
`.gitignore` und die Kanarienvögel in `docs verify` decken die Ausgabe mit ab
(`tools/.coverage`, `tools/coverage.xml`, `tools/htmlcov/`) - abgeleitet wie `reports/`, und
`publish` läuft mit `git add -A`, also hätte ein unignorierter `htmlcov/`-Baum sich beim
nächsten Content-Publish selbst mitcommittet.
**Dabei gefunden und mitbehoben: `dist export` hat die Coverage-Ausgabe mit ausgeliefert.** Der
erste Export nach der Messung trug 227 statt 162 Dateien, darunter den kompletten
`htmlcov/`-Baum - eine Messung *dieses* Repos, ausgeliefert in fremde Instanzen. Der Grund war
struktureller Natur: `TOOLS_EXCLUDE_DIRS` prunet Verzeichnisse, aber zwei Drittel der
Coverage-Ausgabe (`.coverage`, `coverage.xml`, unter Parallelläufen `.coverage.<host>.<pid>`)
liegen als Dateien neben dem Code. `_copy_tree` nimmt jetzt zusätzlich ein
Dateinamen-Prädikat; `htmlcov/` kam in die Verzeichnisliste. `.coveragerc` geht weiter mit -
Konfiguration ist Maschinerie, wie `pytest.ini`. Damit hat der Kopierplan drei
Ausschlussmechanismen statt zwei, für drei verschiedene Gründe: dev-only Inhalt (Marker),
dev-only Dateien (`instructions/dev/`), abgeleitete Ausgabe (Build-Artefakte).
---
## 1.8.0 - 2026-08-31 - ENVIRONMENT.md: optionale, checkout-spezifische Umgebungsnotiz
**Author:** Torben Nehmer
Welches Harness läuft hier, welche MCP-Server sind erreichbar, wohin veröffentlicht `publish`,
wo läuft CI? Werte, die sich über Wochen nicht ändern und die trotzdem jede Session neu
erfragt hat, weil nichts sie festhielt. `USER.md` beantwortet, *wer* die Instanz bedient,
`SOUL.md`, *wie* sie klingt - womit sie arbeitet, beantwortete bisher niemand (Gitea #24).
**`ENVIRONMENT.md`** schließt die Lücke, als drittes Root-Dokument der Session-Ebene. Es hält
Harness, publizierte Skills, MCP-Server, Connectoren, Git-Remotes und den CI-Ort fest;
`ENVIRONMENT.md.template` gibt die Abschnitte vor. Claude Code importiert es über `CLAUDE.md`,
`AGENTS.md` trägt es in der Namenstabelle und in einem eigenen Abschnitt für die übrigen
Harnesses.
Drei Eigenschaften unterscheiden es von der Personalization Plane, und alle drei sind
Absicht:
**Optional.** Fehlt die Datei, ist das ein gültiger Endzustand, kein Mangel - `doctor` meldet
`environment: absent (optional)` und niemals `FAIL`. Ein `FAIL` hätte die Datei durch die
Hintertür verpflichtend gemacht; der Preis ihres Fehlens sind ein paar Fragen, keine falsche
Ausgabe. Gemeldet wird genau ein Fall, und zwar als `WARN`: ein umbenanntes, nie ausgefülltes
Template, das in jeder Session mitgeladen wird und nichts beantwortet. Der Sentinel
`wikitool:template-unfilled` ist derselbe wie bei `USER.md`/`SOUL.md`.
**Gitignored statt committet.** Zwei Clones desselben Repos sind zwei verschiedene Umgebungen.
Eine committete Fassung würde dem zweiten Clone Antworten geben, die falsch sind statt zu
fehlen - und falsch ist hier schlimmer, weil die Datei geglaubt wird. `docs verify` prüft
beide Hälften des Musters: `ENVIRONMENT.md` muss ignoriert sein, `ENVIRONMENT.md.template`
darf es nicht sein. Das nachlässige `ENVIRONMENT.md*` würde beide schlucken, und genau das
fängt der Kanarienvogel ab.
**Kontext, keine Autorität.** Die Datei beschreibt, *was da ist*, nicht, was erlaubt ist. Ein
dort gelisteter Remote autorisiert kein `git push` - Invariante 5 führt weiter über
`wikitool publish` -, ein gelisteter MCP-Server öffnet kein Gate, und nichts darin ist eine
Quelle im Sinne von Invariante 3. Geheimnisse gehören nicht hinein: die Datei liegt im
Klartext im Arbeitsverzeichnis und in jedem Agenten-Kontext.
Warum ein Import in `CLAUDE.md` und kein Link: Was hier steht, wird nebenbei gebraucht - beim
Griff zum richtigen MCP-Server, mitten in einer Aufgabe -, nicht an einer Stelle, an der
jemand ein Dokument aufschlägt. Das ist derselbe Maßstab, den die Modellwahl-Instruktion
erfüllt, und er ist zugleich der Test: Eine Session, die erst nachschlagen müsste, fragt
stattdessen wieder den Nutzer - genau die Kosten, die die Datei beseitigt. Dass ein
unaufgelöster Import folgenlos bleibt, war schon vor der Personalization-Einführung so; hier
wird es zum Dauerzustand statt zum Übergang.
Angelegt wird die Datei in `bootstrap.md` (neuer Schritt 5, für einen Clone) und in
`setup-instance.md` (neuer Schritt 9, für eine frische Instanz). Beide Male gilt: eintragen,
was aus dem Checkout ablesbar ist, einmal nach dem Rest fragen, "weiß ich nicht" als Antwort
akzeptieren - und einen leeren Abschnitt löschen statt ihn mit Plausiblem zu füllen. Eine
geratene Zeile ist schlechter als eine fehlende.
---
## 1.7.2 - 2026-08-31 - lint liest Code nicht mehr als Wiki-Notation; Zitat-Limit zählt Zitate statt Zeilen
**Author:** Torben Nehmer
Two lint rules were measuring something other than what they claim to. Both are found the same
way - by a page that describes the stack rather than using it - and both are hard enough to be
worth a release of their own.
**`lint` read wiki notation inside code as the real thing** (Gitea #20). `CITE_REF_RE`,
`CITE_DEF_RE`, `LEGACY_CITE_RE` and `WIKILINK_RE` are plain text matches with no idea what a
Markdown code span is, so a page writing `` `[^cite-id]` `` in backticks, or showing a
definition line in a fenced block, produced `Undefined Footnote References` - and that is a
*hard* error, so `lint --fail-on-error` and CI went red. It happened at the 2026-08-31 ingest
(`c28f8ce`), on concept pages about the citation mechanism itself. The way out that session was
to write about the notation without writing it ("Zitatdefinitionszeile" instead of the marker),
which is invisible to whoever reads the page later. A knowledge base about a stack has to be
able to quote that stack's notation.
The new **`wiki_tools/markdown_code.py`** holds that rule once: `strip_code_spans()` replaces
fenced blocks and inline code spans with spaces of the same length, so offsets survive and a
caller may match the masked text and slice the original. Every scan now goes through it -
`provenance.iter_cite_refs()` for references, `split_cite_block()` for definitions,
`legacy_citation_markers()`, and `kb_scan.extract_wikilinks()`/`count_wikilinks()` for the link
graph, where the identical blindness made a fenced example link a broken one. Teaching each
regex its own context logic would have been six copies of one rule.
Two boundaries are drawn deliberately, and both are asserted in
`tests/test_markdown_code.py`. **Indented code blocks are not masked**: in this corpus a
four-space indent is a nested list continuation far more often than it is code, and masking by
indentation would have silently deleted real links from the graph. **Inline spans are matched
within one line**: an unclosed backtick is a common typo, and a matcher that crosses newlines
turns one typo into a masked paragraph - a failure that hides findings rather than inventing
them, which is the direction that goes unnoticed. For the same reason `split_cite_block()`
still locates the Footnotes heading on the *unmasked* body: one unclosed fence would otherwise
blank the heading and leave the page with no definitions at all.
**The corpus had the mirror-image habit.** 12 `[^s-…]` markers sat *inside* fenced blocks,
appended to a command or a YAML line, across four pages. They never rendered as footnotes -
GFM shows them literally, so copying the command copied the marker with it - and masking turned
two of them into orphan definitions. They now sit on a `Quelle: [^s-…]` line under their block:
vier Infrastruktur- und Konzeptseiten.
**The quote limit counted `>` lines, not quotes** (Gitea #22). The rule is about how much
borrowed wording a page carries; the line count measured the wrap width instead, so one
quotation counted 1 written long and 4 wrapped at the width the rest of the repo keeps. An
author who took the finding seriously made the page harder to read to quiet it - and it fired
on a page with exactly one quotation while verifying 1.7.1. `count_quote_blocks()` now counts
runs of consecutive quoted lines, with code masked out first so a `>` in a shell transcript is
a prompt rather than a quotation. `QUOTE_LIMIT` stays at 2 and finally means what its name
already claimed.
Alongside the release, `.gitea/workflows/nightly.yml` runs `doctor`, `docs verify`,
`instructions verify`, `lint --fail-on-error`, `sources coverage` and `migrate status` on a
schedule (Gitea #9). It closes the gap `ci.yml`'s content `paths-ignore` opened: since that
landed, structural lint no longer runs when the corpus changes. Workflows are outside the
version gate, so this needed no bump of its own.
---
## 1.7.1 - 2026-08-31 - Testsuite gegen stille Umgebungsabhängigkeiten gehärtet
**Author:** Claude Code
The suite was green for months on every developer machine while silently reading the machine it
ran on. `config.default_author()` shells out to `git config user.name` with `cwd=config.ROOT`;
the fixture root is not a repository, so the answer came from the *global* git configuration of
whoever started pytest. The first CI run that ever reached `pytest` (run 52) had none, and two
tests fell over with `ERROR No author configured for this instance.` Both were repaired in
`1.0.1` - and in `1.2.0` two *new* tests reintroduced exactly the same dependency, written by
someone who had read the issue first (Gitea #8). Patching cases one at a time loses to the rate
at which the suite acquires them.
**`tools/wiki_tools/tests/conftest.py` now carries an autouse `hermetic_environment` fixture**
next to `isolated_trace_dir`. Before every test it points `HOME` (and `XDG_CONFIG_HOME`) into
that test's own `tmp_path`, sets `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` to `/dev/null`, and
clears both git's identity/location variables (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_AUTHOR_*`,
`GIT_COMMITTER_*`, `EMAIL`) and the tool's own (`WIKI_AUTHOR`, `WIKI_TRACE`,
`WIKI_TRACE_CONTENT`, `WIKI_TRACE_MAX_CONTENT`, `WIKITOOL_SESSION_ID`, `WIKITOOL_UPDATE_URL`,
`WIKITOOL_UPDATE_TOKEN`). A test can no longer acquire the dependency at all: it either
establishes an identity itself or the tool refuses, on the author's machine and in CI alike.
`WIKI_TRACE_DIR` is deliberately the one variable left set - `isolated_trace_dir` redirects it
per test, and now declares `hermetic_environment` as a dependency so the ordering between the
two autouse fixtures is stated rather than inherited from declaration order. Tracing is never
disabled suite-wide; two telemetry tests assert that a trace is written.
**No identity is set for everyone.** A shared default in `conftest.py` would have been the
cheaper fix and the wrong one: `default_author()`'s `None` branch only exists on a machine that
knows nobody, so a suite-wide `WIKI_AUTHOR` would have made the fallback untestable while
looking green. Instead the new `test_hermetic_env.py` asserts the fixture itself - the cleared
variables, the empty `HOME`, that `git config user.name` answers nothing, that tracing stays on
and redirected - and covers all three branches of `default_author()`, including the one the
fixture makes writable for the first time. `test_new_source_fails_hard_without_any_author` keeps
patching `default_author` directly, so it stays a test of the CLI's error path rather than of
the environment.
Verified in three environments that now agree exactly - the developer shell, a deliberately
poisoned one (`WIKI_AUTHOR`, `WIKI_TRACE=0`, `WIKITOOL_*` and `GIT_*` all set to junk), and
`env -i` with an empty `HOME` and no git configuration: 702 passed in each. Before this change
the third of those was the one that failed.
**CI keeps running the suite once.** A second hardened `pytest` step was the issue's fallback
proposal; it is redundant now and would report the dependency only after a push. The Tests step
in `.gitea/workflows/ci.yml` says so, so nobody adds it back for the reason it was once needed.
Added [instructions/dev/testing-conventions.md](instructions/dev/testing-conventions.md) - what
the fixture neutralizes, how a test establishes an author when it needs one, and the `env -i`
counter-check to run before publishing a test change. Linked from the `stack-dev` skill's step
2, which until now pointed at "the existing test files' own patterns" for exactly this. Also
pins the fixture repo in `test_new_source_author_falls_back_to_git_config` to
`git init -q -b main`: without a global configuration git emits an `init.defaultBranch` advisory
that clutters unrelated failures.
No command changes behaviour, so this is a PATCH.
---
## 1.7.0 - 2026-08-31 - CLAUDE.md as a harness-scoped instruction reference; Claude-Code model-selection guidance
**Author:** Claude Code
`tools/wikitool instructions verify` only ever recognized `AGENTS.md` as an automatically-loaded
reference source for `instructions/*.md` - accurate for every other harness (Codex CLI, GitHub
Copilot CLI, Mistral Vibe read `AGENTS.md` natively), but not for Claude Code, which loads
`CLAUDE.md` instead and does not read `AGENTS.md` on its own. A Claude-Code-only instruction
therefore had no valid place to be linked from: linking it from `AGENTS.md` would load it into
every other harness's session too.
`CLAUDE.md` is now a second recognized reference/auto-load source alongside `AGENTS.md`,
scoped the same way: `instructions_cmd.AGENT_ROOT_FILES` and `automatic_load_paths()` both
include it, `manual: true` instructions are forbidden from being linked there for the same
reason they are forbidden from `AGENTS.md`, and `instructions/CONTRACT.md`'s reference-tier
table documents the split. `AGENTS.md` and `CLAUDE.md` are deliberately kept as two names, not
folded into one "automatically loaded" concept, because they are automatic for disjoint sets of
harnesses.
**A mention in `README.md` or `CHANGES.md` no longer counts as a reference.** Both were in the
old `REFERENCE_GLOBS`, so an instruction whose only mention was in one of them passed `verify`'s
"nothing references it - it deploys to no one" check while being unreachable by any agent -
`AGENTS.md`'s own file-naming table defines `README.md` as "never by an agent as instruction",
and `CHANGES.md` is not even shipped (`dist export` replaces it wholesale). The tuple is now
split by what the two callers actually ask: `AGENT_ROOT_FILES` for "can an agent reach this",
`AGENT_ROOT_FILES + SHIPPED_DOC_ROOT_FILES` for the `instructions/dev/` boundary check, which
still scans `README.md` because a dangling path in a shipped document is a defect regardless of
who reads it. Nothing in the tree relied on the old behaviour - `verify` stays green - so this
closes a latent false green rather than fixing a live break. It also means this entry's own
mention of the new instruction below no longer masks a broken `CLAUDE.md` link.
Added [instructions/claude-code-model-selection.md](instructions/claude-code-model-selection.md),
`@`-imported by `CLAUDE.md` as its fourth import: which model (Sonnet vs. Opus) and effort level
to run a session, a spawned subagent, or a `/code-review` pass at, keyed to the skill or task at
hand rather than defaulting everything to the most capable model at the highest effort.
**Imported, not linked** - the choice matters enough that `instructions/CONTRACT.md` now
documents both forms and when each applies. Model and effort are decided while spawning a
subagent or starting a review, not at a point where anyone stops to open a document, so a link
would be followed only when the user explicitly asked about models. The price is standing
context in every Claude Code session: an import is the strongest load in this layer, stronger
than a skill (which contributes only its `description`), and that is the bar each further
Claude-Code-only import has to clear. `verify` accepts either form - both put the filename in
`CLAUDE.md`.
**Deliberately out of scope, tracked as a follow-up issue instead:** equivalent model-selection
guidance for the non-Claude-Code harnesses this repo supports. Those harnesses have no
equivalent of Claude Code's model names, `/code-review` effort dial, or `Agent` tool `model:`
override, so the question of whether and how they should decide this differently is a separate
design decision, not answered here.
---
## 1.6.0 - 2026-08-31 - xref link-source schreibt beide Richtungen; xref add lehnt undeklarierte Ref-Felder ab
**Author:** Torben Nehmer
Schließt Gitea-Issue #18. Drei Defekte mit einer Ursache, und ein Loch in einer
Entscheidung aus `1.4.0` desselben Tages: die Sperrliste in `touch --set` lehnt
Page-Ref-Felder mit dem Verweis auf `xref` ab — für die `entities:`/`concepts:`
einer Source-Seite konnte `xref` das aber nicht. Der Verweis zeigte ins Leere.
### `xref link-source` schreibt jetzt beide Richtungen
Bisher schrieb es nur die Zielseiten (`sources:` + See-Also-Bullet) und rührte
die Arrays der Source-Seite nie an — es prüfte nur, dass sie existiert. Ein
Ingest legt die Source-Seite aber an, **bevor** die Concept-Seiten existieren,
weil deren Titel erst beim Extrahieren feststehen. Also blieb `concepts: []`,
und kein Kommando konnte es füllen.
Welches der beiden Felder ein Ziel bekommt, folgt seiner **Collection**:
`kb/entities/` → `entities:`, `kb/concepts/` → `concepts:`. Das Verzeichnis
*ist* der Feldname, also braucht eine neue Collection hier keine Code-Änderung —
sie braucht einen Typ, der das passende Feld deklariert. Ein Ziel, dessen
Collection zu keinem deklarierten Feld passt, wird einseitig verlinkt und in der
Ausgabe benannt statt stillschweigend übergangen.
### `xref add` lehnt ein nicht deklariertes `related:` ab
`add_related()` schrieb das Feld bedingungslos. Auf einer Source-Seite —
`page_ref_fields: [entities, concepts]` — erzeugte das Frontmatter, das das
Schema verwirft (`additionalProperties: false`). Genau so entstand der einzige
Schema-Fehler, der heute auf `main` lag.
Die Prüfung läuft für **beide** Seiten, bevor eine davon geschrieben wird: eine
Ablehnung darf keinen halben Link hinterlassen. Die Meldung nennt die Felder,
die der Typ kennt, und das Kommando, das sie füllt.
### `xref remove` räumt auch undeklarierte Reste
`strip_frontmatter_ref()` lief nur über die vom Typ deklarierten Felder. Ein
`related:` auf einer Source-Seite war damit **nicht entfernbar** — ein Kommando
erzeugte einen Zustand, den ein anderes nicht rückgängig machen konnte, obwohl
sein Contract-Eintrag ausdrücklich verspricht, hinterlassene Referenzen ohne
Handeditierung zu klären.
Gesweept wird jetzt zusätzlich jedes auf der Seite vorhandene Feld, das
*irgendein* Typ als Ref-Feld deklariert — die Namen kommen aus den Type-Specs,
nicht aus einer Konstante. Ein undeklariertes Feld, das dabei leer wird, fällt
ganz weg statt als `related: []` stehenzubleiben: der Schlüssel war für diesen
Typ nie gültig, und ein leeres Array hielte die Seite weiter schemawidrig.
Das gilt damit auch für `rename` und `rm`, die denselben Helfer benutzen.
### Bestandsfall repariert, mit dem Werkzeug
Die Seite auf `main` trug `related: [Write-Once Frontmatter Fields]` und ein
leeres `concepts:`. `xref remove` hat den Rest geräumt, `xref link-source` hat
beide Concepts in beide Richtungen eingetragen. `lint` meldet keinen
Schema-Fehler mehr. Kein `rm --yes`, keine Handeditierung.
### Sonst
Die Ablehnungsmeldungen der Sperrliste in `touch --set` nennen jetzt für
`entities:`/`concepts:`/`sources:` konkret `xref link-source` statt pauschal
`xref add`/`xref remove` — der bisherige Verweis war für genau diese Felder
falsch.
Sechs neue Tests in `test_xref.py`, darunter der, dass eine Ablehnung keine
Seite anfasst, und die Idempotenz der neuen Richtung.
---
## 1.5.1 - 2026-08-31 - cite add/sync und rename loeschen keinen Inhalt mehr hinter dem Fussnoten-Block
**Author:** Torben Nehmer
Schließt Gitea-Issue #17. **Der bisher einzige bekannte Weg, auf dem dieser
Stack Inhalt verliert statt ihn falsch zu schreiben.**
`split_cite_block()` nahm alles ab der Überschrift `## Fußnoten` bis zum
Dateiende als Zitatblock, behielt daraus nur die `[^id]:`-Zeilen, und jeder
Aufrufer setzt eine Seite als `Kopf + neu gerenderter Block` wieder zusammen.
Stand hinter dem Block noch ein Abschnitt, war er nach dem nächsten Aufruf weg —
ohne Warnung, mit Erfolgsmeldung. Betroffen waren `cite add`, `cite sync` **und
`rename`**, die alle denselben Pfad benutzen.
Systematisch wurde es durch einen Widerspruch zwischen zwei Kommandos:
`xref add` hängt `## Beziehungen` und `## Siehe auch` ans Dateiende, `cite add`
hielt den Fußnoten-Block *für* das Dateiende. Wer `cite add` zuerst laufen ließ,
war sicher; wer die Reihenfolge umdrehte, verlor beim nächsten Zitat alle
Querverweise. Keiner der Skills schreibt die Reihenfolge vor, es war also Zufall.
Gefunden beim Ingest vom 2026-08-31 (`8524bce`), wo
`kb/concepts/Detect-Repair Asymmetry.md` vier `## Beziehungen`- und fünf
`## Siehe auch`-Einträge verlor. Dass es auffiel, war Glück: der ausführende
Agent hatte nach `cite add` zurückgelesen.
### Der Fix
Der Block endet jetzt an der nächsten Überschrift statt am Dateiende. Alles
dahinter — und alles im Block, was keine Zitatdefinition ist — wird auf den Kopf
zurückgefaltet statt verworfen. Der Rückgabetyp bleibt gleich, alle sechs
Aufrufer profitieren ohne Änderung.
Zwei Eigenschaften, die dabei mehr wert sind als die reine Reparatur:
- **Die Seite heilt sich selbst.** Weil der gerenderte Block immer zuletzt
ausgegeben wird, bringt die erste Zitatoperation eine bereits verrutschte
Seite wieder in die richtige Form. `xref add` darf weiterhin ans Dateiende
anhängen, ohne Schaden anzurichten.
- **Loser Text im Block wird gerettet, nicht abgelehnt.** Ein Abbruch wäre die
naheliegende Härtung gewesen, ist hier aber falsch: derselbe Pfad läuft unter
`lint` und `corpus_diff`, wo eine Exception das Lesen einer Seite verweigern
würde, statt sie zu melden.
Nebenbei behoben: ein `[^id]`, das nur in einem Abschnitt *hinter* dem Block
referenziert wurde, galt für `extract_inline_cites` als nicht referenziert — und
`cite sync` hätte seine Definition als verwaist entfernt.
### Korpus repariert
Acht Seiten trugen zusammen 74 Zeilen in der gefährdeten Position. `cite sync
--all` hat sie normalisiert (elf Seiten insgesamt; drei brauchten nur eine
Neusortierung). Nachgemessen: 0 Seiten mit Inhalt hinter dem Block, und je Seite
unveränderte Zahl an Zitatdefinitionen und Bullets.
### Tests
Fünf neue in `test_provenance.py`, darunter der Fall, der zuerst rot sein muss
(Abschnitt hinter dem Block überlebt den Rundlauf), die Stabilität wiederholter
Rundläufe, und ein Zitat, das nur im geretteten Abschnitt referenziert wird.
Bemerkenswert: **vor diesen Tests liefen alle 678 grün.** Das alte, zerstörende
Verhalten war von keinem Test festgehalten — so hat es überlebt.
---
## 1.5.0 - 2026-08-31 - Mass-Update Gate zaehlt generierte Dateien nicht mehr; Kalibrierungsband gemessen statt geerbt
**Author:** Torben Nehmer
Zwei Kalibrierungen, beide aus derselben Beobachtung: **drei gewöhnliche
Ingests hintereinander blieben am Mass-Update Gate stehen.** Keiner war ein
Massen-Update. Das Gate feuerte auf Rauschen, und die Budget-Richtwerte
beschrieben eine Arbeit, die es so nicht gibt.
### Generierte Dateien zählen nicht mehr gegen die Schwelle
`kb/index.md`, `kb/log.md`, `kb/provenance.md` und jede `INDEX.md` werden
committet und gepusht wie bisher, aber vom Gate nicht mehr gezählt und nicht
mehr zur Freigabe vorgelegt. Der Grund ist derselbe, aus dem `work/` schon
ausgenommen war: sie tragen keine Entscheidung. Jede davon ist per
`index rebuild` / `sources rebuild-index` aus dem Baum reproduzierbar, also
entscheidet ihre Freigabe nichts — sie erzeugt nur Review-Müdigkeit, gegen die
die Schwelle eigentlich gerichtet ist.
Ein gewöhnlicher Ingest baut fünf bis sechs davon neu. Gemessen an den drei
Changesets vom 2026-08-31:
| Ingest | Dateien | vorher gezählt | jetzt gezählt |
|---|---|---|---|
| Comma Bug | 14 | 14 → Gate | **9 → läuft durch** |
| Issue Triage | 16 | 16 → Gate | **9 → läuft durch** |
| Auto Mode | 11 | 11 → Gate | **5 → läuft durch** |
Das Gate bleibt scharf: zehn echte Seiten lösen es weiterhin aus, egal wie
viel Index-Rauschen mitfährt. Ein Test hält genau das fest.
Die Ablehnungszeile weist beide Ausnahmegründe getrennt aus („3 under work/ and
5 generated by wikitool committed but not counted"), weil ein Prüfer, der „9
counted" an einem 14-Datei-Commit sieht, sonst die Differenz für einen Fehler
hält. Und weil Kratzzustand und abgeleitete Ausgabe nicht dasselbe sind.
Der `--confirm`-Token deckt jetzt genau das ab, was ein Mensch tatsächlich
gelesen hat: eine neu gebaute `INDEX.md` macht eine erteilte Freigabe nicht
mehr ungültig.
### Kalibrierungsband: 15-25 → 20-35 für komplexe Workflows
Das obere Band stammte aus einer Branchen-Faustregel. Vier reale Ingests
messen **24, 26, 29 und 30** Aufrufe — jeder einzelne am oberen Rand oder
darüber, ohne dass etwas Ungewöhnliches passiert wäre. Ein Richtwert, den der
Normalfall überschreitet, ist keiner; er bringt einem Agenten bei, dass die
Zahlen Dekoration sind.
Neu: ~5-15 für eine einfache Aufgabe (gemessen 5-9), **~20-35** für einen
komplexen Multi-Tool-Workflow. Nachgezogen in `run_budget.py`,
`instructions/gates.md` und den Skills `wiki-ingest` und `wiki-lint`. Die
Obergrenze von 60 bleibt unverändert — sie ist kein Ziel, sondern der Punkt, ab
dem eine Sitzung als festgefahren gilt.
`gates.md` sagt jetzt auch, **woher** die Zahl kommt und wie man sie neu misst:
`tools/.wikitool_session/budget.json` hält die Aufrufzahlen je Session. Ein
Richtwert ohne Messverfahren veraltet lautlos.
Die zitierte Branchen-Faustregel in `kb/concepts/Iteration and Cost Limits.md`
bleibt unangetastet — sie ist eine belegte Aussage über den Stand der Technik,
nicht über diese Instanz. Was diese Instanz misst, ist eine andere Behauptung
und gehört beim nächsten Ingest dieser Sitzung dort ergänzt.
---
## 1.4.0 - 2026-08-31 - touch --set/--add/--remove: Frontmatter-Felder nach dem Anlegen korrigierbar
**Author:** Torben Nehmer
Schließt Gitea-Issue #14. Ein Feld, das `new` einmal schrieb - `tags:`,
`raw_files:`, `source_url:` - war danach unerreichbar: `touch` kannte es nicht,
Frontmatter von Hand zu editieren ist das, was dieses Werkzeug verhindern soll,
und die Seite zu löschen und neu anzulegen bricht jede Referenz, die schon auf
sie zeigt. Da `new` nicht idempotent ist, war das Fenster, es richtig zu
treffen, **genau ein Kommando breit.**
Drei Fehlschläge in drei aufeinanderfolgenden Ingests am 2026-08-31, an zwei
Feldern, von drei verschiedenen Agenten - einer davon wegen eines Kommas am
Ende eines `--set tags=`-Werts. Das ist keine Ausrutscherquote, sondern die
Fehlerrate einer Schnittstelle, die einen Wert genau einmal entgegennimmt.
### Neu an `touch`
- **`--set field=value`** ersetzt den Wert auf der Platte. Wiederholtes `--set`
für dasselbe Arrayfeld hängt *innerhalb eines Aufrufs* an, `\,` ist ein
literales Komma - dieselben Regeln wie bei `new --set` (1.2.0).
- **`--add` / `--remove`** ändern einzelne Elemente eines Arrayfelds, ohne dass
man die bestehende Liste kennen muss. `--add` ist idempotent. `--remove` auf
ein nicht vorhandenes Element **gelingt und sagt es** - wie `xref remove`
idempotent, aber nie stillschweigend: ein stiller No-op sieht genauso aus wie
eine erfolgreiche Entfernung, und genau so verschwindet ein Tippfehler.
- `--summary`/`--provenance`/`--confidence-base` bleiben als Kurzformen.
### Die Sperrliste, und warum es eine ist
Gesperrt sind `type:` (ändert Schema *und* Ablageort - das ist
`page-lifecycle.md`), `confidence:` (abgeleitet; `--confidence-base` setzen und
`confidence decay --apply` laufen lassen) und die Page-Ref-Arrays `related:`,
`sources:`, `entities:`, `concepts:` (`xref` pflegt die Gegenrichtung und die
Body-Bullets mit; ein nackter Frontmatter-Schreibvorgang ließe die andere
Hälfte veralten). Jede Ablehnung nennt das Kommando, dem das Feld gehört.
Bewusst eine Deny- und keine Allowlist: eine Allowlist wäre eine zweite Kopie
des Schemas, und die Kopie ist die, die driftet - ein neu in einen Type-Spec
aufgenommenes Feld bliebe stumm unbeschreibbar, bis jemand daran denkt. So
gilt: was das Schema kennt, ist schreibbar, sofern es keinen Grund gibt.
Ein *unbekanntes* Feld wird anders abgelehnt als ein gesperrtes: nicht mit
einem Verweis auf ein anderes Kommando, sondern mit der Liste dessen, was die
Seite tatsächlich hat. Bei `--set tag=x` ist der Nutzen zu erfahren, dass
`tags` gemeint war.
### Sonst
- `raw_files:` bekommt beim Schreiben durch `touch` dieselbe Existenzprüfung
wie bei `new` - eine Dateisystemprüfung, die kein Schema ausdrücken kann.
- `_coerce_set_value`, `_parse_set_fields` und `_check_raw_files_exist` sind
aus `new_page.py` nach `commands/_util.py` gewandert und heißen jetzt ohne
Unterstrich. Zwei Kommandos, eine Implementierung - andernfalls hätte
`touch --set` das Komma-Problem aus #12 gleich mit geerbt.
- `tests/test_touch.py` ruft den Typer-Callback jetzt über einen Helfer mit
Vollbelegung auf. Ein direkt aufgerufener Callback bekommt für ausgelassene
Argumente `OptionInfo`-Objekte, und ohne den Helfer kostet jede neue Option
eine Änderung an jeder Aufrufstelle.
### Nicht enthalten
`raw rename` - der Fall, dass die *Datei* sich bewegt. Zweistufig ist er jetzt
möglich (`git mv`, dann `touch --set raw_files=…`), aber zwischen den beiden
Schritten zeigt die Referenz ins Leere, und bei mehreren referenzierenden
Seiten wächst das Fenster. Als eigenes Issue geführt.
---
## 1.3.0 - 2026-08-31 - capture-session: eine Session als raw/-Transkripte sichern und ingesten
**Author:** Torben Nehmer
Neue Instruction `instructions/capture-session.md`: eine beendete Claude-Code-
Session als `raw/notes/`-Transkripte sichern und einzeln ingesten. Das war
inzwischen dreimal von Hand gemacht worden, jedes Mal etwas anders - und die
Fehler, die dabei teuer sind, macht man genau einmal.
`manual: true`, also bewusst nicht aus `AGENTS.md` oder einem Skill verlinkt.
Jede Session zu sichern würde `raw/` mit Material füllen, das nie jemand
zitiert; die Einschätzung „war diese Session es wert" gehört dem Nutzer, nicht
dem Agenten. Aufruf nur namentlich auf Anforderung.
Was die Instruction festhält, statt es beim nächsten Mal wieder herzuleiten:
- **Drei Oberflächen, drei Aufgaben.** `raw/` hält die Evidenz, der
Issue-Tracker den offenen Arbeitsstand, `kb/` das Gelernte. Ein Transkript
ist keine To-do-Liste: was offen bleibt, wird vor dem Ingest zum Issue, und
das Transkript hält nur fest, dass es das wurde.
- **Ein Transkript je Thema.** Eine Rohdatei bekommt eine Source-Seite, und
deren `summary:`/`entities:`/`concepts:` beschreiben *eine* Sache. Später zu
schneiden heißt, eine Datei umzubenennen, auf die schon Zitate zeigen.
- **Fidelity wird bei der Erfassung festgelegt und steigt danach nie wieder.**
Kein Zitatformat und keine spätere Prüfung befördert eine Paraphrase zum
Zitat - nur ein erneuter Blick ins Original, den es bei einer beendeten
Session nicht mehr gibt. Was tragend werden könnte, wird sofort wörtlich
zitiert; Material aus zweiter Hand (etwa Subagent-Befunde) nennt seinen
Vermittler.
- **Ingests laufen streng nacheinander.** Parallele Ingests desselben Korpus
kollidieren an geteilten Entity-Seiten, an den generierten Dateien und an
`publish` - jedes Mal als stiller verlorener Schreibvorgang, nicht als
Fehler. Je Transkript eine eigene `WIKITOOL_SESSION_ID`.
- **Verworfene Alternativen gehören ins Transkript.** Was der Code tut, steht
im Code; was er bewusst nicht tut, steht sonst nirgends.
Die Punkte zu Fidelity und zur Trennung von Evidenz und aktivem Arbeitsstand
sind aus der vendorierten `commonplace/`-Wissensbasis übernommen
(`a-citation-cannot-assert-more-fidelity-than-its-capture-preserved`,
`active-work-state-is-not-retrospective-memory-or-chat-history`).
Mit diesem Commit landen zugleich die drei Transkripte dieser Session unter
`raw/notes/`; ihre Source-Seiten entstehen in den nachfolgenden Ingests.
---
## 1.2.1 - 2026-08-31 - Arbeitsplanung: Gitea-Issues mit prio/size-Labels statt TODO.md
**Author:** Torben Nehmer
`TODO.md` ist entfernt. Nach der Verlagerung der CI/CD-Punkte nach Gitea
(2026-08-30) und der Recherche-Notiz als Issue #15 stand dort nichts mehr, was
nicht ohnehin ein Issue war - die Datei war eine zweite Liste über denselben
Bestand, und die zweite Liste ist immer die, die driftet.
Offene Arbeit steht ab jetzt ausschließlich in den Gitea-Issues, ein Issue je
Arbeitspaket, mit genau zwei Labels:
- `prio/1` blockiert oder beschädigt laufende Arbeit, `prio/2` trägt bald
Zinsen, `prio/3` wartet auf einen benannten Auslöser.
- `size/XS` bis `size/L` schätzt den Aufwand, nicht die Wichtigkeit.
Beide Achsen sind Pflicht: eine Priorität ohne Kosten ist eine halbe
Entscheidung. Eine dritte Achse gibt es bewusst nicht - dort fängt eine
Taxonomie an, eigene Pflege zu brauchen, und dieses Board hat einen Betreuer.
Die Regel steht in `instructions/dev/issue-tracking.md` und ist aus dem
`stack-dev`-Skill verlinkt. Sie liegt unter `instructions/dev/`, weil sie nur
hier gilt: eine verteilte Instanz hat keine Issues unter dieser URL, und
`dist export` schließt das Verzeichnis vollständig aus. Aus demselben Grund ist
das ein **PATCH** und kein MINOR - für eine bestehende Instanz ändert sich
nichts.
---
## 1.2.0 - 2026-08-31 - --set-Arraywerte mit Komma, Lint-Reportpfad, Budget-Refund, Ceiling 60
**Author:** Torben Nehmer
Zwei Werkzeugdefekte aus dem Ingest vom 2026-08-30 (Issues #12 und #13). Beide
haben den Nutzer gegen eine Regel des Stacks gedrückt: einmal gegen die
Unveränderlichkeit von `raw/`, einmal gegen Invariante 6. Das ist der Grund,
warum sie zusammen vor allem anderen kommen.
### `--set` kann Kommas in Arraywerten ausdrücken (#12)
`parse_list` teilte hart an `,` ohne jedes Escape, und Shell-Quoting hilft
nicht - die Quotes sind lange weg, bevor der Wert dort ankommt. Ein
`raw_files:`-Pfad mit Komma im Dateinamen war damit nicht darstellbar. Im
Ingest vom 2026-08-30 wurde deshalb die Rohdatei umbenannt, obwohl
`raw/CONTRACT.md` die Rohschicht als unveränderlich führt.
- `\,` ist jetzt ein literales Komma und überlebt den Split (`parse_list`, also
auch `xref add --entities`).
- `--set` **hängt an**, wenn es für dasselbe Arrayfeld wiederholt wird:
`--set raw_files=a --set raw_files=b`. Das ist die Form ohne Trennzeichen und
damit die richtige, wenn ein Element ein Komma enthält. Skalare Felder
behalten "last one wins".
- Die Fehlermeldung von `_check_raw_files_exist` benennt den Split als Ursache
und den Ausweg - vorher nannte sie einen Pfad, den niemand getippt hatte.
**Dazu die zweite Hälfte desselben Defekts, die beim Testen auffiel:**
`dump_frontmatter` schreibt Listen in Flow-Syntax (`[a, b]`), quotete aber nach
Dokument-Kontext. Ein Element mit Komma kam deshalb als *zwei* Elemente zurück -
`--set` hätte den Wert korrekt geparst, und die Datei hätte ihn trotzdem
zerlegt. `_round_trips_as_string` prüft den Rundlauf jetzt im selben Kontext,
in dem geschrieben wird, und `_quote` fragt für beide Kontexte die Bibliothek
statt selbst zu quoten. Bestehende Ausgabe ändert sich dadurch nicht - nur
Werte, die vorher stillschweigend kaputtgingen, werden jetzt gequotet.
### Iteration Budget: Reibung statt Iteration wurde gezählt (#13)
- **Ceiling 30 → 60.** Das Band aus der Kalibrierung (5-15 einfach, 15-25
komplex) bleibt, wie es ist: es beschreibt die Arbeit. Die Decke beschrieb
bisher nichts - sie lag so dicht am Band, dass der Overhead eines echten
Ingests (Bericht nachlesen, ein korrigierter Retry, Baum vor dem Publish
prüfen) allein sie erreichte. Der Ingest vom 2026-08-30 lief inhaltlich im
Band und kam trotzdem nur mit `--override-budget` durch.
- **Ein Aufruf, der abgelehnt hat, wird zurückerstattet.** Wer über
`_util.fail()` mit Exit 1 endet - abgewiesenes Argument oder ein
lesender Check, der Befunde meldet - hat nichts getan; und der Tool-Error-
Contract beantwortet ein abgewiesenes Argument mit "einmal korrigiert
wiederholen", was sonst zwei Slots für eine Operation kostet. Der Aufruf
bleibt in der Historie des Loop-Breakers: dreimal dasselbe kaputte Kommando
bricht weiterhin ab. Ein Kommando, das seine Arbeit schon getan hat und dann
ungleich null endet (`lint --fail-on-error`), wirft `typer.Exit(1)` direkt
und bleibt gezählt.
### `lint` nennt seinen Reportpfad (#13)
`lint` ohne Flags kippte den vollen Bericht nach stdout und schrieb nichts. Wer
ihn abschnittsweise las, hatte keinen Weg zurück zum übersprungenen Teil außer
einem zweiten `lint` - zwei Slots für einen Blick auf einen unveränderten
Korpus.
- Der volle Bericht wird jetzt immer geschrieben, standardmäßig nach
`reports/Lint Report <date>.md`, und der Pfad wird ausgegeben. `--markdown`
überschreibt das Ziel wie bisher.
- Gedruckt werden nur die Abschnitte mit Befunden. Bei sauberem Korpus ist der
Vollbericht zu über 90 % "None found."; `--full` druckt ihn trotzdem.
- `--json` schreibt weiterhin nichts.
- `wiki-lint` und `wiki-status` sagen jetzt beide, dass die Datei gelesen wird
statt `lint` erneut aufgerufen. `wiki-status` Schritt 3 liest die
Hub-Statistik aus dem Bericht, weil sie kein Befund ist und deshalb nicht
mehr in der Zusammenfassung steht.
### Nicht enthalten
`lint` vom Budget zu befreien (dritter Vorschlag aus #13) - das ist eine
eigene Entscheidung über die Grenze zwischen lesend und schreibend, und `lint`
schreibt jetzt eine Datei.
---
## 1.1.1 - 2026-08-30 - CLAUDE.md: AGENTS.md/USER.md/SOUL.md erreichen Claude Code
**Author:** Torben Nehmer
Defekt, aufgefallen durch Nachfragen des Nutzers: **Claude Code hat `AGENTS.md`
noch nie geladen.** Das Repo hatte keine `CLAUDE.md`, und Claude Code liest
genau diese Datei automatisch - `AGENTS.md` nicht. Die anderen Harnesses
(Codex, Copilot, Vibe) lesen `AGENTS.md` nativ, weshalb es niemandem auffiel.
Praktisch heißt das: jede Claude-Code-Session lief bisher ohne Invarianten,
ohne Routing, ohne Gate-Regeln, sofern der Agent die Datei nicht zufällig
selbst aufmachte.
Mit 1.1.0 wurde der Defekt eine Stufe schlimmer: die File-naming-Tabelle
behauptet seitdem für `USER.md` und `SOUL.md` „Always, every session". Für
Claude Code war das schlicht unwahr.
Behoben durch eine `CLAUDE.md` im Root, die **keine eigenen Regeln trägt** -
nur drei Importe (`@AGENTS.md`, `@USER.md`, `@SOUL.md`) und die Begründung,
warum sie existiert. Eine Regel dort wäre die zweite Kopie, die Invariante 8
verbietet, und zwar die am ehesten driftende: sie stünde in der Datei, die nur
ein einziges Harness liest.
Sie wird von `dist export` mitgeliefert (`ROOT_FILES`), aus demselben Grund wie
`.claude/settings.json`: eine ausgelieferte Instanz unter Claude Code hätte
sonst exakt denselben Defekt. Ein Test hält fest, dass sie im Plan landet und
`@AGENTS.md` enthält.
Bewusst *kein* `doctor`-Check dafür: `CLAUDE.md` ist harness-spezifisch, und
eine Instanz, die nur unter Codex läuft, braucht sie nicht - ein FAIL wäre dort
schlicht falsch. Anders als bei `USER.md`/`SOUL.md`, die jedes Harness liest.
Während des Setups löst nur `@AGENTS.md` auf; `USER.md`/`SOUL.md` entstehen
erst in Schritt 6. Ab der ersten Session danach greifen alle drei.
---
## 1.1.0 - 2026-08-30 - Personalization Plane: USER.md/SOUL.md als Setup-Schritt
**Author:** Torben Nehmer
Zwei neue Dateien, die eine Instanz in jeder Session liest: `USER.md` (wer sie
bedient) und `SOUL.md` (wie sie klingt). Beide sind reine Kontext- und
Stildateien - sie bekommen **keine** neue Autorität, kein Gate und keinen
Zugang nach `kb/`: `SOUL.md` verliert gegen `AGENTS.md`, und eine Nutzeraussage
ist weiterhin keine Quelle im Sinne von Invariante 3.
Der eigentliche Entwurfspunkt liegt woanders. Beide Dateien sind
**Betriebsvoraussetzung** und zugleich **persönlicher Inhalt**, gehören also
nicht in jede exportierte Kopie. Sie entstehen deshalb während der
Installation, nicht vorab befüllt in der Distribution:
- `USER.md.template` und `SOUL.md.template` liegen im Root und werden von
`dist export` mitgeliefert. Dass die *ausgefüllten* Fassungen es nicht
werden, ist keine zusätzliche Regel, sondern Folge der bestehenden
Root-Allowlist - ein Name, der dort nicht steht, wird nicht kopiert.
- Beide Templates tragen eine Sentinel-Zeile (`wikitool:template-unfilled`),
die beim Ausfüllen entfernt wird.
- `instructions/setup-instance.md` hat einen neuen Entscheidungspunkt
(Schritt 6, Personalization); die Folgeschritte verschieben sich um eins.
Der Agent interviewt entlang der Template-Abschnitte und schreibt die
Antworten **wörtlich** mit. Zwei Fragen darf er nicht raten: den
Persona-Namen und die Themen, die bewusst draußen bleiben.
- `wikitool doctor` bekommt den Check `personalization`: `FAIL` bei fehlender
Datei **und** bei einer, die noch den Sentinel trägt - ein umbenanntes
Template ist kein ausgefülltes.
**Für bestehende Instanzen:** `doctor` meldet ab hier `personalization: FAIL`,
bis die beiden Dateien existieren. Das ist einmalig nachzuholen und **keine
Content-Migration** - `.wikitool-kb.json` bleibt unberührt, keine Seite ändert
sich. Nachzuholen ist ausschließlich Schritt 6 aus `setup-instance.md`, nicht
der ganze Ablauf; `instructions/bootstrap.md` und `INSTALL.md` verweisen an
der jeweils passenden Stelle darauf.
Der CI-Replay der Installation stubbt den neuen Entscheidungspunkt so wie die
Identität: mit einer festen Antwort (Template minus Sentinel-Zeile). Geprüft
wird damit, dass der Export die Templates trägt - nicht, was ein Mensch
hineinschreibt.
Umgesetzt aus Issue #2.
---
## 1.0.1 - 2026-08-30 - Tests ohne globale git-Identität; CI-Fixes
**Author:** Torben Nehmer
Der erste CI-Lauf, der überhaupt bis zu `pytest` kam, hat zwei Tests
umgeworfen, die auf jeder Entwicklermaschine grün sind: sie hingen an einer
**globalen git-Identität**. `default_author()` ruft `git config user.name` mit
`cwd=config.ROOT` auf, und die Fixture-Wurzel ist kein Repository - also
antwortete bisher die globale Konfiguration dessen, der die Suite laufen ließ.
Im Job-Container als `root` gibt es keine, und `new source` weigert sich zu
Recht, einen Platzhalter-Autor zu stempeln.
- `test_new_source_author_falls_back_to_git_config` macht die Fixture-Wurzel
jetzt selbst zu einem Repository mit *lokalem* `user.name`. Damit prüft der
Test den Fallback statt der Maschine - und prüft ihn schärfer: er behauptet
jetzt den konkreten Namen, nicht nur, dass irgendeiner dasteht.
- `test_new_source_with_multiple_raw_files` setzt `WIKI_AUTHOR`. Der Test geht
über `raw_files:`, Autorschaft ist dort nur Vorbedingung.
Nachprüfbar mit `GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
pytest`, was die CI-Bedingung lokal herstellt.
Dazu die Workflows unter `.gitea/` (ohne eigenen Bump, sie stehen außerhalb
des Version-Gates): `nodejs` wird jetzt vor dem Checkout installiert, weil
act_runner JavaScript-Actions mit `node` *im Job-Container* ausführt und
`debian:trixie-slim` keins mitbringt; `actions/checkout` auf `@v7`; und CI
läuft nicht mehr auf reinen Inhaltsänderungen.
---
## 1.0.0 - 2026-08-30 - Content migrations get a version, a chain, and a mechanical check
**Author:** Torben Nehmer
**Migration:** none required - no distributed instance exists yet; 1.0.0 is the migration baseline
`0.1.0` gave an instance a way to learn that it must migrate. This release gives it a way to
know *what shape its content is in*, which migrations it still owes, in what order - and a
mechanical check that a bulk rewrite did not quietly drop something.
**Two versions, not one.** `VERSION` describes the machinery. What shape the *content* is in is
a different number, now in `.wikitool-kb.json` and written only by `migrate done`. Without it
the state every upgrade passes through - machinery already replaced, content not yet migrated -
cannot be represented at all, and `migrate status` would have to infer the answer from the stack
version, which is wrong exactly when it matters. It is a separate file from
`.wikitool-release.json` because the two have opposite rules: that one is generated and must
never be hand-edited, this one is mutable instance state. `dist export` seeds it with the
exported version, since a fresh instance's content is empty and therefore trivially current;
only a tree predating the file has to answer by hand, via `migrate baseline`.
**Migrations run as a chain.** An instance rarely moves exactly one version. `migrate status`
builds the interval `(kb_version, VERSION]` from the documents under `instructions/migrations/`
and orders it: 1.3.1 to 2.0.0 runs 1.4.0, then 1.7.0, then 2.0.0. That no migration targets
1.3.x is not a special case - it simply is not in the interval. `migrate done` refuses any
version that is not the next link, so a step cannot be skipped and an interrupted multi-step
upgrade resumes rather than has to be reconstructed.
**`migrate verify --from <rev>` is the piece that was missing.** It compares every page against
a git revision on what a content migration must *not* change: wikilink and citation counts,
footnote definitions, H1, structural frontmatter. `lint` cannot answer this and never could - it
reads a single revision, so a reference that went missing leaves a corpus that is still
perfectly consistent. Everything it compares is a `Counter`, never a set: three of the four
defects found in the 248-page translation had unchanged link *sets* and only changed counts.
Verified both ways on the real corpus - a clean run over 248 pages produced zero findings in
2.2 s, and dropping one of two `[[Docker]]` occurrences from one page was reported as
`'Docker' 2->1` while `lint` passed all 21 checks in silence.
**A migration is a `manual: true` instruction**, at `instructions/migrations/<version>-<slug>.md`
with `migrates_to:` and `migration_kind:`. No new stage and no new type: the Manual tier exists
for procedures that must never run implicitly, and `dist export` already ships `instructions/`,
so a migration reaches every distributed instance with no second export path. (The contract's
claim that the tier had no example was already stale - `german-terminology.md` uses it too.)
**The methodology is now written down.** `instructions/migrate-corpus.md` holds what the
translation run learned and then deleted with its workshop: units sized by the iteration budget
(~21 pages against the 30-call ceiling), publishes sized by the Mass-Update Gate, the two being
different boundaries; `migrate verify` before the summaries and before `lint`; read the *whole*
lint report; carry the vocabulary between units. Preferring a backwards-compatible change over a
flag day is a decision point of its own, with `sections.py`'s canonical-plus-alias pattern as
the worked example.
**Enforcement, in two places.** `version bump --major` refuses without either a migration
document for the new version or `--no-migration "<reason>"`, and `docs verify` checks the same
thing about the newest changelog entry. An instance told to migrate with nothing telling it how
is the dead end both close - this entry's own `--no-migration` line is the mechanism's first
real use.
**Starting at 1.0.0.** `compat_key` is stated generally (leftmost non-zero component), so
nothing in the code special-cases a range; but the *guidance* derived from it under 0.x was
self-contradictory, assigning `--minor` to both "new capability" and "requires migration". From
1.0.0 the familiar reading holds throughout: PATCH fixes, MINOR adds compatibly, MAJOR migrates.
Pre-release suffixes stay unsupported - `x.y.z` is the whole grammar, and a second ordering rule
would have to be honoured by the release feed, the chain and the compatibility check alike.
**One bug found by running it for real.** The historical side of the diff listed every `.md`
under `kb/` while the working-tree side used `iter_kb_pages`, which skips `COLLECTION.md`,
`INDEX.md` and the kb-root meta files - so a clean run reported 13 phantom removed "pages". Both
sides now answer with one shared `kb_scan.is_page_path`, and the regression is pinned.
**Deliberately not built:** mechanical runner primitives (`migration_kind: mechanical` describes
the work today, it does not perform it - a DSL for zero migrations is not worth having), and
`dist upgrade`, still the manual procedure in `INSTALL.md`.
**Verification:** 630 tests pass (46 new), `docs verify`, `instructions verify` and
`lint --fail-on-error` clean, plus the two real-corpus runs described above.
---
## 0.1.0 - 2026-08-29 - The stack gets a version, a pipeline, and release files
**Author:** Claude Code
The repo had no CI and no way to say which stack an instance was running. Both are now in
place, and they are the same mechanism seen from two ends: a version that CI enforces, stamps
into every export, and publishes as a release an instance can compare itself against.
**`VERSION` versions the stack, never the content.** `tools/`, `types/`, `instructions/`,
`AGENTS.md` and the contracts - not the `kb/` pages sitting in the same repo. That split is why
the number is set explicitly instead of being derived from commit messages: `publish --message
"ingest: ..."` writes content commits here too, so any conventional-commit reading would turn
every ingest into a release.
**Compatibility is read off the leftmost non-zero component**, the rule Cargo's caret ranges
use: `0.1.3 -> 0.1.4` is safe, `0.1.3 -> 0.2.0` is not, and from `1.0.0` on the same rule reads
as the familiar "MAJOR means migration". Stating it this way is what lets the `0.x` era carry
the migration signal at all - under plain "MAJOR breaks" semantics every `0.x` release is
indistinguishable from every other, which is exactly the signal update detection needs. Nothing
about the mechanism changes at `1.0.0`.
**New: `wikitool version`** - `show` (offline: the version plus the release stamp it came
from), `bump --major|--minor|--patch --title` (writes `VERSION` *and* opens this entry's
heading, leaving the body to the author - the same structure-by-tool/prose-by-author split as
`new`), `notes` (prints one entry, so the release workflow never parses markdown in shell), and
`check`.
**`version check` is the one networked command in `wikitool`.** The research note in `TODO.md`
argues that a network call breaks all four properties every other command has - offline,
deterministic, free, testable - and it does. It is allowed here on narrow terms: its own
command that nothing else calls implicitly, no API key, a timeout, the fetch behind an
injectable function so the tests never touch a network, and a feed that cannot be reached is
reported as an error rather than silently answered as "up to date". The argument that ruled
out a Perplexity call was about a *paid, keyed* API; an unauthenticated GET on a release feed
is a different thing.
**`dist export` now ships `VERSION` and writes `.wikitool-release.json`.** The stamp holds the
version, the export date, the origin (repo, commit, release page, update feed - supplied by
flags, because `export` still never calls git), and a sha256 per exported file. Nothing reads
the digests today: they record what the machinery looked like when it was installed, which is
the only thing a later `dist upgrade` could use to tell a file the instance edited from one it
merely received. Writing them now is what keeps that upgrade from needing a format change.
**CI: `.gitea/workflows/ci.yml`** on push/PR to `main`, `runs-on: linux-docker`, one job that
stops at the first failure - the four steps `TODO.md` specified (tests, `lint`/`docs
verify`/`instructions verify` over the dev tree, `dist export`, then `instructions verify`
against the export) plus two the versioning makes possible:
- a **version gate**: if the pushed range touches stack paths, `VERSION` must have moved and
`CHANGES.md` must say why. It cannot block a push that already landed, but a release can
never contain a change nobody named.
- the export smoke test now **replays `setup-instance.md`** end to end (`git init`, identity,
venv, `instructions sync`, `index rebuild`, `sources rebuild-index`, `doctor`, `docs verify`,
`instructions verify`, `lint`) instead of running one verify against it. What that tests is
the artifact as an artifact: the documented path from tarball to working instance.
**Release: `.gitea/workflows/release.yml`**, triggered by a push to `main` in which `VERSION`
changed. It tags `v<version>` (**the CI tags, never an agent** - `AGENTS.md` invariant 5 is
untouched), exports with the origin flags filled in, and publishes
`llm-wiki-stack-<version>.tar.gz` plus a `.sha256` as Gitea release assets, with `wikitool
version notes` as the release body. The tarball is exactly a `dist export` tree, so
`INSTALL.md`'s Weg A now starts from a download instead of requiring a checkout of this repo.
**Both workflows were written against the wiki's own record of the runner** (the `Act Runner`
and `Gitea Actions` pages), which corrected two guesses. Authentication is
`${{ gitea.token }}`, the short-lived per-job token this instance issues - not
`secrets.GITEA_TOKEN`, and not a 1Password secret: nothing in these two workflows leaves the
instance, so the Zero-Trust secret path the container-build workflows use has nothing to carry.
And the job image is named explicitly (`container: image: debian:trixie-slim`, the base
Szenario B already proves works with `actions/checkout@v4`) rather than inherited from the
`linux-docker` label, because the label-to-image mapping is the one thing the wiki does *not*
record - and it decides whether `python3` exists at all. Trixie's python3 is 3.13, past the
3.11 floor `doctor` enforces.
**Deliberately not built yet: applying an update.** Detection tells an instance a newer stack
exists and whether it crosses a compatibility boundary; `INSTALL.md` documents the manual
procedure. `dist upgrade` is a command that writes into an instance that already has content,
which is a different risk class and wants the stamp's digests to exist first - they now do.
**Also:** `doctor` gained a `stack-version` check (missing `VERSION` is a WARN, since instances
exported before this release are still perfectly functional; malformed is a FAIL). `docs
verify` gained the `VERSION`/`CHANGES.md` agreement check. `version show`/`check`/`notes` are
exempt from the Iteration Budget Gate as read-only; `version bump` is not.
**Verification:** 585 tests pass (61 new), and the CI job's own sequence was replayed locally
end to end against a real export - `instructions sync`, `index rebuild`, `sources
rebuild-index`, `doctor`, `docs verify`, `instructions verify`, `lint --fail-on-error`,
`version show` - all clean in a tree with no git history and no content.
`version check` was also run against the live feed. It returned 404, which was first read as
"no release published yet" - wrongly. **This repository is private**, and Gitea answers an
anonymous caller with the same 404 for an invisible repository as for one that does not exist;
the repo endpoint returns it for a made-up repository name too. So `$WIKITOOL_UPDATE_TOKEN` is
not the exception here but the requirement, until the repository is made public. The mechanism
was unaffected - the variable exists and the 401/403 message already names it - but the two
documentation claims that said otherwise were wrong and are corrected.
That run also found a real bug, now fixed and regression-tested: `version notes`
terminated an entry at the next *versioned* heading, and every entry below `0.1.0` is headed by
a date - so the newest entry ran to the end of the file, and the first release would have
shipped the entire changelog as its notes.
What no local run can prove is the runner itself: whether `linux-docker` accepts a job that
names its own container image, and whether `${{ gitea.token }}` carries the rights to create a
release. The first run on the server answers both.
---
## 2026-08-29 - Human docs catch up, and a new instance is asked which language it wants
**Author:** Claude Code
Auditing README/INSTALL/EVALS/tools/README against the migration turned up one thing that was not
a documentation gap at all.
**A `dist export` instance inherited German silently.** The exported distribution ships
`kb/CONTRACT.md` § Language, `instructions/german-terminology.md` and the German section names in
`sections.py` - verified by exporting one and grepping it. `setup-instance.md` has decision points
for author identity and remote, both of which refuse to be guessed from the source repo on the
grounds that it is "a different person, a different project". The KB language is the same kind of
choice and was being passed along without being asked. It is now step 5, placed before the tool
environment so the answer lands *before the first ingest* - after that, changing it is a migration
of every existing page rather than an edit to one contract section.
The step names what actually has to change for another language (contract section, tone examples
and hedge words, four page templates, `sections.py` canonical names with the old ones kept as
aliases, relationship labels) and what does not: the prose/identifier split itself is not a German
rule. Titles, wikilink targets, cite-ids, enum values, tags, commands and paths follow no KB
language in any instance.
**Documentation brought current:** README gains the language paragraph it lacked entirely, the
title rule under Tips → Naming, `german-terminology.md` in the layout tree, and German relationship
labels where it still listed `depends-on, uses, implements`. INSTALL gains the language decision in
Weg A. `tools/README.md` gains `sections.py` and a design note on why heading names are a
vocabulary with aliases rather than literals.
**One correction worth its own line.** `tools/README.md` claimed tests "use `tmp_path` fixtures and
monkeypatch `config` paths rather than touching the real `kb/`". Two do not: `test_types_cmd.py`
and `test_index_build.py` resolve the real `types/` on purpose, since what they assert is that
behaviour comes from the type-specs. That sentence is why editing `layout: title:` failed six tests
that looked unrelated - and why the first fix attempt in this session anchored a test on a literal
heading that the next commit promptly translated. The README now says so, and says what such a test
may pin: structure, never wording.
---
## 2026-08-29 - The corpus is German; the glossary outlives the run
**Author:** Claude Code
All 248 `kb/` pages are translated, no English template heading remains anywhere, and
`source_language:` is set on all 38 source pages. `wikitool lint` reports clean on all 21 checks.
The `translate-kb-de` workshop is closed.
**What survived the closure, and why.** `work/CONTRACT.md` requires a run's durable conclusions to
be out of `work/` before the directory is deleted. Of the run's four files, three were bookkeeping -
unit plan, checklist, subagent brief - and died with it. The glossary did not: it is not a migration
artifact. Every future ingest of an English source produces German prose and meets the same
questions, so it is now [instructions/german-terminology.md](instructions/german-terminology.md),
linked from kb/CONTRACT.md § Language. The rule stays in the contract, the vocabulary moved to the
instruction layer - a word list is lookup material, and 80 lines of it do not belong in a file
loaded on every write.
**What the migration cost, and where.** The mechanical checks - frontmatter, H1, wikilink and
cite-id multisets compared against `HEAD` - found four defects in 248 pages: a dropped citation
that silently unsourced a claim, a dropped wikilink, an invented one, and one translated H1. Three
of those four would have passed a set-based comparison; only counting occurrences caught them. None
would have been reported by `lint`, which checks whether references resolve, not whether one went
missing.
The checks found nothing else, and that is the more useful result: **every remaining error was a
plausible, consistent, wrong word.** „Geheimnis" for `Secret` (96 occurrences), „Fähigkeit" for
`Skill` and `Capabilities`, „Anspruch" for *claim*, and 182 sentences in the polite „Sie" form the
English imperative invites. Structural verification cannot see any of these - each page is
internally perfect - and neither can a subagent that made the choice consistently.
**Where the errors actually came from.** Nine glossary revisions were needed, and the pattern held
throughout: the agents followed the instructions, and the instructions were ambiguous. A
counter-check for `capabilities` was written backwards and would have propagated through eleven
units. "Separation of Concerns" was listed as a choice between two options rather than as a fixed
phrase, and one agent took the other option and reported doing so. "Fehler melden, nicht
korrigieren" meant errors *in the source*, and an agent applied it to its own bad German. Cheap
parallel agents do not degrade an instruction gracefully - they execute both readings of it at once,
in the same batch, which makes every ambiguity immediately visible and immediately expensive.
---
## 2026-08-29 - Two frontmatter fields no command can write
**Author:** Claude Code
Both surfaced while closing the translation run, and both are recorded rather than fixed - the
right shape for either is a design question, not a patch.
**`source_language:` has no writer.** The field was added to `types/source.schema.yaml` this
morning, and `wikitool new source --set source_language=de` sets it at creation. On the 37 source
pages that predate it there is no path at all: `touch` writes exactly `modified`/`date`, `summary`,
`provenance` and `confidence_base`, and nothing else offers a generic frontmatter setter. Backfilling
meant editing the field directly. That is defensible for a plain data field - it is neither generated
nor a page-reference array, so AGENTS.md invariant 1 does not reach it - but "add a schema field"
and "be able to set it on the pages that already exist" should not be separate capabilities. Adding
a field to a schema is currently a half-migration by construction.
**`comparison` declares no modification date.** `touch --page "amd-pstate vs acpi-cpufreq"` fails
with *"Type types/comparison.md declares no modified/date field - pass --no-date to skip it"*,
because the schema has only `created`. So a comparison page can record when it was made but never
that it was revised - and `touch --summary` on one requires `--no-date` every time. Either
`comparison` should gain `modified:` like `entity` and `concept`, or the omission should be
documented as deliberate; right now it reads as an oversight that the error message papers over.
Neither blocked the run. Both are the kind of gap that only shows up when something walks the whole
corpus, which is exactly what a migration does and what routine per-page work never will.
---
## 2026-08-29 - Date fields hold dates, and the special case disappears
**Author:** Claude Code
Supersedes this morning's ISO-date exemption, which is now deleted. That fix worked by teaching
`_round_trips_as_string` to recognize a date-shaped *string* and leave it bare - a second place
that had to know what a date looks like, kept in sync by hand with the schemas and with
`validate_frontmatter`'s normalization.
The question that removed it: **why is there a date-shaped string at all?** `_format_scalar` has
always rendered a `datetime.date` bare, and `yaml.safe_load` yields exactly that for every page on
disk. Only two places ever put a *string* in a date field - `touch` and `new`, both via
`today_iso()` - and the guard existed solely to paper over that mismatch. Both now write
`datetime.date`, the exemption is gone, and no code outside `_format_scalar` needs an opinion about
date syntax.
Three things fell out of it:
- **`touch` no longer reports a change on every run.** It compared `frontmatter.get(field)` - a
`datetime.date` read from disk - against a string, which is never equal, so an unchanged date was
rewritten and logged as a change every time. With both sides dates, an unchanged page is now
correctly a no-op.
- **`--date` and `--set <date field>=` validate their input.** Previously any string was written
through; `29.08.2026` would have landed in frontmatter and surfaced later as a schema error on a
page nobody was looking at. Both now fail immediately, naming the expected shape.
- **`normalize_dates` moved to `frontmatter_io` and is shared.** There were two validators and only
one of them converted dates: `TypeResolver.validate_frontmatter` (behind `lint`) did, `touch`'s
`validate_fields` did not. Harmless while `touch` wrote strings, and a latent failure the moment
it stopped - found by writing the change rather than by the tests.
**On the question that prompted this** ("can the quoting be schema-driven - we know what belongs
there"): the instinct is right, but the schema does not distinguish the two cases. A `tags[]` entry
and `modified:` are both declared `type: string`; keying on the declared type gives dates the same
answer as `"1945"`, which is the wrong one. Only `format: date` separates them, and reaching it from
`frontmatter_io` would mean importing `type_resolver`, which already imports `frontmatter_io`.
Making the value's own type honest answers the same question without a lookup - and
`_build_frontmatter` was already schema-driven on `format == "date"`, which is where that knowledge
belongs.
---
## 2026-08-29 - The round-trip guard reached date fields it should not have
**Author:** Claude Code
Follow-up to the frontmatter fix earlier today, and a reminder that a correct-looking rule can
still be wrong in context. `_round_trips_as_string` quotes anything that would read back as a
different type - which is exactly right for a `"1945"` tag, and exactly wrong for `modified:`.
PyYAML reads a bare `2026-08-29` as a `datetime.date`, so the guard started quoting it, and 53
pages came out of one batch carrying `modified: '2026-08-29'` while the other ~195 still had it
bare.
Nothing broke: `TypeResolver.validate_frontmatter` already converts date objects to strings before
validating, precisely because the schemas declare `type: string` while YAML hands back a date. That
normalization *is* the corpus convention, and the guard had quietly voted against it. The result
was not corruption but something more corrosive - a field with two spellings that alternate
depending on whether `touch` happened to change the value, so every later diff carries noise
nobody can attribute.
Full ISO dates (`^\d{4}-\d{2}-\d{2}$`) are now exempt from the guard, and the 53 pages are
normalized back to bare. The exemption is deliberately narrow: `1945` is an int to YAML, not a
date, so a tag by that name still gets quoted.
Found by reading the Mass-Update Gate's per-file churn: two of 32 translated pages had asymmetric
line counts where every other page was symmetric. Chasing that asymmetry - it turned out to be
harmless line rewrapping - surfaced the `modified:` line in the same diff.
---
## 2026-08-29 - Frontmatter round-trip turned string values into other YAML types
**Author:** Claude Code
`_format_scalar` decided quoting from a hardcoded list: empty, leading special character,
surrounding whitespace, `": "`, or one of `true/false/null/~`. A string that merely *looks* like a
number was not on it, so the Python string `"1945"` was written bare and read back as `int` on the
next load. `wikitool touch` rewrites the whole frontmatter block, so translating a page was enough
to corrupt a tag it never mentioned: `Vannevar Bush` came out of a `--summary` update failing
schema validation on `Field 'tags[3]': 1945 is not of type 'string'`.
The list was also short in a second direction. `yaml.safe_load` follows YAML 1.1, where `yes`,
`no`, `on` and `off` are booleans, and it resolves `0x1F`, `1_000`, `1e5` and `+3` as numbers -
none of which the check knew about.
Rather than lengthening the list, `_round_trips_as_string(text)` now asks the loader whether
`yaml.safe_load(text) == text`, and quotes when it does not. Reader and writer agree by
construction, and a resolver rule this code has never heard of cannot drift out from under it.
Two pages in the corpus were one `touch` away from the same corruption - `Memex` (`1945`) and
`10.000 Dice Game` (`10000`) - which a translation run touching all 248 pages would have reached.
`Vannevar Bush` is repaired.
**How it was found is the reusable part.** The `wikitool lint` run at the end of a unit reported a
schema error on a field the unit had not edited. A page that changes in a way nobody asked for is
worth chasing even when the visible diff looks right, and the check that caught it - lint after
every unit, before publish - is now in the run's README rather than in anyone's memory.
---
## 2026-08-29 - `work new --key` for a run that is not an ingest
**Author:** Claude Code
The workshop layer assumed every task too big for one session was an ingest: `work new` required
`--input <raw path>` and derived the run key from it. A translation pass over the 248 existing
`kb/` pages has no raw input at all, so it could open no workshop - and the alternative was worse
than cosmetic. Without `plan.md` there are no written unit boundaries, and `instructions/gates.md`
allows a fresh `WIKITOOL_SESSION_ID` **only** at a boundary recorded there; a run with no plan
either exhausts one 30-call budget or takes new session ids on no authority but its own.
`work new --key <run key>` names the key outright. Exactly one of `--input`/`--key` is required,
and a `--key` may not start with `ingest-`, which stays reserved for derived keys so a directory
name keeps saying which kind of run made it. `plan.md` gets a second template for these runs -
units sized by the iteration budget rather than by `raw_files` count, since there is no tree to
cut.
Also worth recording, because it will happen again: adding a parameter broke seven tests that call
`new_command(...)` directly. Bypassing Typer means an omitted argument keeps its
`typer.Option(...)` sentinel rather than becoming `None`, so a mutual-exclusion check saw both
options as supplied. `test_git_publish.py` already documents this trap in `_publish`; the fix is to
pass every parameter explicitly, not to loosen the check.
---
## 2026-08-28 - Two bugs the translation sample run surfaced
**Author:** Claude Code
Both found by translating six pages by hand before turning subagents loose on 248. Neither is
about language; both were latent and would have been amplified by the bulk run.
**`touch` overwrote a source's publication date.** `DATE_FIELDS = ("modified", "date")` picks
whichever field the type declares, and a `source` declares `date:` - which the schema documents as
"Publication or creation date". So `touch --summary` on a source page silently rewrote the raw
material's own date to today, leaving the page contradicting the `**Datum:**` line printed in its
own body. Rewriting a summary is a statement about the page; the source's date is a fact about the
world, and today's date is simply wrong for it. `modified:` is still bumped automatically; `date:`
now moves only on an explicit `--date`. Caught on one page during the sample, it would have hit
all 38 source pages in the bulk pass.
**The Mass-Update Gate misreported any file with a non-ASCII name.** `_changed_files` reads
`git status --porcelain -z`, which emits raw paths; `_numstat` read `git diff --numstat` *without*
`-z`, and git renders such a path quoted (`"kb/sources/Source - 10.000 ausw\303\274rfeln…"`). The
lookup missed, the file fell through to `_untracked_stat`, and a rewrite was reported as a pure
insertion: `+54/-0` for a change git counted as `+28/-27`. This is the same failure mode as the
718-line deletion reported as `-174` on 2026-08-27, reached by a different route - the report
understating removals in the one direction a reviewer most needs it not to. `_numstat` now passes
`-z` and parses NUL-delimited records, including the empty-path-plus-two-records form git uses for
a rename. Regression tests for both are in `test_git_publish.py` and `test_touch.py`.
Worth noting how this one was found: the gate printed the wrong number in a clearance report that
was being read carefully because it was about to be approved. The numbers a gate prints are the
gate.
---
## 2026-08-28 - Section headings become a named vocabulary instead of hardcoded English literals
**Author:** Claude Code
The language policy earlier today froze all section headings as English, on the grounds that
`xref add` and `cite add` match three of them literally and a translated heading would silently
append a *second* section. That reasoning was sound but the conclusion was too cheap: it left
every page with an English skeleton around German prose, which is most of what "the wiki is in
German" is supposed to mean. Tracing the literals showed the constraint was smaller than it
looked - three constants in two files, with `page_ops.py` matching bullet *shapes* rather than
headings and not involved at all.
**`wiki_tools/sections.py`** now owns the vocabulary. Each heading has one canonical name - what
the tool writes - and aliases it still recognizes:
| Canonical | Recognized alias |
|---|---|
| `## Beziehungen` | `## Relationships` |
| `## Siehe auch` | `## See Also` |
| `## Fußnoten` | `## Footnotes` |
That asymmetry is the point: a 248-page corpus cannot be translated atomically, so a page still
carrying the English heading has to keep working. `xref add` finds and appends to it rather than
creating a duplicate, and only a page's own translation changes the name.
**A regression the tests caught, worth recording.** Making `render_page_body` write the canonical
heading turned `cite sync` into a silent migrator: every page with an English Footnotes block came
back `changed`, so one `cite sync --all` would have rewritten the whole wiki and buried the real
diff. `split_cite_block`'s counterpart `cite_block_heading(body)` now reports the heading the page
actually carries, and the three call sites pass it back into `render_page_body`. Renaming a
heading is the translation pass's job, never a side effect of another command. `cite sync` is a
no-op on an untranslated page again, and a test pins that.
Also here: the four page-type templates now scaffold German sections; `**Type:**` becomes
`**Typ:**` while the value stays the schema enum that `search --field` filters on; and
kb/CONTRACT.md's typed relationship labels (`depends on` → `hängt ab von`, …) are German, which is
safe because no code matches on them - an untranslated label is stale wording, not a broken
reference.
Not covered here: the 248 existing pages still carry English headings and prose. They are
recognized, not migrated.
---
## 2026-08-28 - Canonical KB language: German, with an identifier carve-out
**Author:** Claude Code
The wiki had no language rule anywhere - not in `AGENTS.md`, not in the contracts, not in the
skills, not in the schema. The 248 existing pages were English purely by LLM habit, and a German
raw file (`raw/documents/wuerfelspiel.md`) had already been
summarized into English with its German terms preserved in quotes. That is a defensible outcome
reached by accident, which means the next ingest could just as well have gone the other way.
**The rule** now lives in one place, `kb/CONTRACT.md` § Language: pages are written in German,
and **every line of a page is either prose or an identifier - only prose is translated.** The
carve-out is not a list of exceptions bolted onto a language rule; it is the rule's second half,
and it is what makes the policy mechanically safe:
- **Page titles and the H1 that repeats one** follow the subject's own established name, not the
KB language. `GitOps Ownership Model` and `Act Runner` stay. This keeps titles - and with them
`[[wikilinks]]`, the `[^cite-id]` ids derived from them, and every frontmatter reference array -
out of the language question entirely. `lint` already reports an H1 that stops matching its title.
- **Section headings stay English** because `xref add` and `cite add` locate them by literal name
(`commands/xref.py:75,79`, `commands/cite_cmd.py`). A translated `## Relationships` does not
error - it makes `xref add` append a *second* section, silently.
- **The generated `**Type:**` line** renders a schema enum value that `search --field` filters on.
- **`tags:`** are search keys; commands, paths, config keys and code are what they are; quotations
stay verbatim in the source's own language.
**Consequential edits in the same contract.** § Confidence prescribed the literal hedge words
"possibly"/"may"/"uncertain"/"unconfirmed", and § Tone's buzzword and AI-filler lists and its
good/bad example pair were English. Those are words meant to appear *in pages*, so under a German
policy the contract was instructing against itself. All now German.
**Sources in another language** are summarized in the KB language regardless of what the raw file
is in - a source page is evidence *about* a source, not a substitute for it. New optional
`source_language:` (ISO 639-1, `types/source.schema.yaml`) records what had to be translated, and
makes "which pages are translations" answerable with `search --field source_language=fr`. Optional
by design: the 38 existing source pages predate the rule and stay valid.
**The four page type-specs** (`entity`, `concept`, `source`, `comparison`) have German prose now -
they are the authoring surface that shapes `kb/`, and `types describe` dumps their body verbatim to
whoever is writing a page. Their ```markdown template fences are untouched, for the heading reason
above. Note that `extract_template` (`type_resolver.py:190`) takes the *first* markdown fence in the
body, not the one under `## Template`, so a translated section must never introduce a markdown
example block above it. The control plane - `AGENTS.md`, the stage contracts, `instructions/`, and
the type-specs for non-page artifacts - stays English.
Not covered here: the 248 existing pages, which are migrated separately, and `layout: title:` in
`types/entity.md` (the generated INDEX headings), which moves with that migration since it forces
an `index rebuild`.
---
## 2026-08-28 - Mass-Update Gate: `--yes` retired for an exit-42 clearance code and `--confirm <token>`
**Author:** Claude Code
The 2026-08-23 procedural fix - "run `publish` without `--yes` first, let the gate refuse, then
re-run with `--yes`" - failed a third time on 2026-08-27: an agent asked "want me to publish?",
got "Ok", ran `publish` (refused), and ran `publish --yes` in the *same turn*. The documented
order was followed - refusal before `--yes` - but no human turn happened in between, so the
approval the trace showed was the agent waving itself through. A procedure written down in
advance is a ritual an agent can perform without a human in the loop, and `eval score`'s
`gate-not-self-opened` could not see the difference because it only ever looked at single calls.
**What shipped, and the false start that shaped it.** The first attempt made the gate
un-self-openable: a ticket bound to the changeset, redeemable only after a `prompt.submitted`
trace event *and* a `wikitool approve <id>` typed in a second terminal. It worked and it was
unusable - on a harness driving `wikitool` through a shell tool the agent cannot produce the turn
signal at all, and the human is asked to leave the conversation to approve anything. It was built,
tested, and then deleted; a gate nobody can pass is not a safe gate, it is an abandoned one. What
replaced it splits the problem in two: cheap honest enforcement in-process, and the real check in
the eval layer.
- **A distinct exit code.** `EXIT_NEEDS_CLEARANCE = 42` (`commands/_util.py`, via
`needs_clearance()`) is now a third outcome beside success (0) and validation error (1),
meaning "a human has to see this output before anything proceeds". An agent, a hook, a CI job
and a trajectory scorer can each tell it apart from "your input was wrong, fix it and retry" -
which exit 1 could never express. `AGENTS.md`'s tool error contract lists four outcomes now,
not three.
- **The procedure moved into the tool's output.** A tripped gate prints what would change, the
full counted file list, and the exact copy-pasteable `--confirm <token>` line that publishes it
(`git_publish.clearance_message` / `rerun_command`, the latter reconstructing the command line
rather than echoing argv so it is correct however the original was spelled, and `shlex`-quoted).
`instructions/gates.md` now says only "exit 42 -> show the user the output, stop" and nothing
about the mechanism - deliberately, since a recipe in the instruction layer is one an agent can
complete alone.
- **`--confirm <token>` replaces `--yes`.** The token is a 12-hex digest of the counted file list
plus the publish target (`changeset_token`), so a clearance never carries to a changeset the
user did not see - touch one more file and the token changes and the gate asks again. This
closes a hole `--yes` always had: it published whatever was in the working tree when it ran.
A wrong, invented or superseded token exits 42 again with the current list. `--yes`/`-y` stay
registered only to fail with an explicit error instead of a Typer usage error.
- **Stateless.** No ticket file, no TTL, no session bookkeeping, no `approve`/`approval status`
commands, and nothing under `tools/.wikitool_session/` to get out of sync - the token is a pure
function of the working tree and can be recomputed at any time.
- **The real check is in the eval layer**, where a whole trajectory is visible instead of one
call (`evals/trajectory.py`): **`clearance-ended-the-turn`** (error) flags a `wikitool.call`
between an exit-42 call and the next `prompt.submitted` - precisely the "refused, then confirmed
in the same breath" shape all three incidents had; **`clearance-was-asked-for`** (error) flags a
`gate.cleared` whose token no `gate.refused` ever issued, catching both an invented token and
one reused from a different changeset. `gate-not-self-opened` still hard-flags `--yes`/`-y`.
Rules can now report a third state, **skipped** (`Rule.skipped`/`skip_reason`, rendered `skip`),
so `clearance-ended-the-turn` says "cannot say" on a harness with no prompt hook rather than
fabricating a pass - the degradation rule, applied to a rule that genuinely needs an optional
event.
- **Telemetry:** `gate.cleared` (token, counted, threshold, files) is new; `gate.refused` gained
`reason: needs-clearance|stale-token`, `token` and `presented_token`. The ticket-era
`gate.ticket_issued`/`gate.consent`/`gate.approved` are gone.
- **Harness adapters.** `.claude/settings.json` (new, tracked, and copied by `dist export` as a
single file via `dist_cmd.SINGLE_FILES` so the generated `.claude/skills/` is not swept in with
it) wires `UserPromptSubmit` to `trace_ingest.py`, which is what makes
`clearance-ended-the-turn` scorable here, and adds a `permissions.ask` rule on
`Bash(tools/wikitool publish --confirm:*)` so the clearing call prompts in-chat while ordinary
publishes do not. Verified live against this CLI: a `PreToolUse` hook returning
`permissionDecision: "ask"` does **not** override a matching `permissions.allow` rule
(permissions beat hooks), which is why the rule is a `permissions.ask` entry and not a hook -
and it is a prefix match, so `rerun_command` always emits `--confirm` first. Copilot CLI already
emits `userPromptSubmitted`, so it needed no change; its decision-document schema stays
unverified and therefore unwritten, per the rule the Vibe adapter established.
- **Honest limits, recorded rather than papered over.** None of this proves a human typed
anything: the token sits in the agent's own context. `prompt.submitted` proves a turn boundary,
not that the output was relayed or that the reply meant yes. Closing that needs the harness to
report what the agent actually said, which no adapter here does.
- **The clearance report is grouped and sized, not a flat list of paths.** A 30-line alphabetical
list tells a reviewer nothing about what kind of change it is. The refusal now opens with a
scale line (file count, `+added/-removed`, status breakdown), then only the attention notes that
actually apply - deletions named individually, how many files touch the agent control plane or
harness config, how many published pages change, the largest single file, any binary - and then
every counted path grouped by area (`FILE_GROUPS`), each with its status letter and churn.
Ordering is by consequence, not alphabet: published knowledge and the control plane first,
mechanically-regenerated files (`is_generated`) split into their own group at the end and
labelled as needing no review. Deliberately notes, not a risk score: every line is a checkable
statement a reader can disagree with by looking, where a number would invite trusting it. Only
applicable notes are emitted, since a wall of "0 deletions" reassurances is how a reviewer
learns to skim. Churn comes from `git diff --numstat HEAD` for tracked files and a line count
for untracked ones, with binaries marked rather than given a fabricated count; a repository with
no HEAD yet (the first-commit case in `setup-instance.md`) degrades to counts instead of failing.
- **The token now covers file contents, not just file names.** `changeset_token` digests each
counted path *with* a hash of its bytes, closing the same "approved A, published B" hole one
level down: previously a cleared list could be published after rewriting one of those files, so
the user's approval would have covered text they never saw. Editing a file after clearance now
invalidates the token exactly as adding one does.
- **Deletions report their size.** `collect_changes` originally short-circuited a deleted file to
`0/0` churn instead of asking `git diff --numstat`, which knows it. The first changeset to
exercise this deleted a 718-line file and went out headlined `-174` against git's own `-891` -
four fifths of the removals missing, in the one direction a reviewer least wants understated. A
deletion now carries its real removed-line count and renders as `-718 deleted`, so a one-line
stub and a long document stop reading identically.
- **The clearance message's wording, fixed after it failed on first contact.** It originally read
"SHOW THIS OUTPUT TO THE USER"; the first agent to receive it replied with a file count and a
pointer to "the output above" - which the user could not see, because on a harness that runs
`wikitool` through a shell tool a command's stdout lands in the agent's context, not on anyone's
screen. Printing and showing are different acts there, and an instruction that conflates them
reads as already satisfied the moment the text exists. The message now leads with "THE USER
CANNOT SEE THIS OUTPUT", asks for the file paths to be copied into the reply, and names the
near-misses that do not discharge it (a count, a summary, "the output above"). `gates.md` got
the same treatment. Worth generalising: an instruction to an agent should name the act, not the
intent.
- Also updated: `instructions/gates.md` (rewritten around the exit code, with an exit-code table),
`instructions/publish-cycle.md`, `tools/CONTRACT.md` (publish command + error-contract rows),
`AGENTS.md` (invariants 5-6, tool error contract), `README.md`, `INSTALL.md`,
`instructions/setup-instance.md`, the `wiki-ingest`/`wiki-lint` skills, `tools/README.md`,
`EVALS.md` (L2 rule table, the worked example rewritten around where the check moved to and why),
and - as wiki content - `kb/concepts/Mass-Update Gate.md` and
`kb/concepts/Iteration and Cost Limits.md`.
## 2026-08-26 - One-way dev/dist split: `instructions/dev/`, `stack-dev` skill; `dist enable-dev` retired
**Author:** Claude Code
Replaced the "dev instance" concept from the previous entry with a one-way model: a distributed
instance never becomes a dev instance again. Instead of restoring dev-only content into a
distributed instance, stack development happens in the origin repo (or a new dev instance
exported from it).
- **`tools/wikitool dist enable-dev` is gone**, along with
`tools/wiki_tools/dist_templates/agents_dev_extras.md`/`readme_dev_extras.md` and
`instructions/enable-dev-extras.md` (the `manual: true` instruction it backed). There is no
longer a command that reconstructs dev-only content in a distributed instance - `docs verify`'s
matching drift check (`check_dev_extras_in_sync`) is removed along with it.
- **New `instructions/dev/`** - a second, purpose-scoped location for material that is
exclusively about developing the tool stack itself (compiler/schema/instruction-layer
changes), as opposed to operating on wiki content. `tools/wikitool dist export` now prunes
this directory wholesale and unconditionally (`dist_cmd.INSTRUCTIONS_EXCLUDE_DIRS`) - a second,
independent exclusion mechanism alongside the existing `<!-- dist:strip-start/end -->` marker
convention, which only ever removed content inside an otherwise-shipped file. The commonplace/
vendored-knowledge-base guidance that used to live inline in AGENTS.md's marker block moved
into `instructions/dev/commonplace-kb.md`.
- **New `stack-dev` skill**, nested at `instructions/dev/stack-dev/SKILL.md` - switches a
session into tool-development mode: source-binding rules for wiki content do not apply to
code, and the test/review conventions under `instructions/dev/` apply instead. Nested rather
than a top-level sibling of the five content skills specifically so the one export exclusion
removes it together with the instructions it routes to, with no separate skill-exclusion list.
Linked from AGENTS.md via a routing line inside its own `dist:strip` block - normal (not
`manual: true`), since it is a recurring session type, not a rare explicit request.
- **`tools/wikitool instructions verify`** gained a boundary check: nothing outside
`instructions/dev/` may reference something inside it (`instructions_cmd.dev_only_forbidden_references`),
because such a reference would dangle the moment `dist export` prunes the directory. A mention
inside a `dist:strip` block is exempt - `dist_cmd.strip_markers()` removes it from the scan
before the check runs, since `dist export` removes the block and the directory together.
`instruction_files()`/`skill_dirs()` were extended to discover `instructions/dev/` as a second
scan location (previously non-recursive by design).
- Canonical rule: [instructions/CONTRACT.md](instructions/CONTRACT.md#instructionsdev). Also
updated: `AGENTS.md`, `README.md`, `INSTALL.md`, `instructions/setup-instance.md`,
`tools/CONTRACT.md`.
## 2026-08-25 - Citation syntax migrated to real GFM footnotes; `wikitool cite`
**Author:** Claude Code
Replaced the inline `^[[Source - X]]` (or `^[[Source - X|file.md]]`) citation marker - a
Pandoc-style parser reads it as an inline footnote wrapping a broken shortcut link, so it never
rendered correctly outside this repo's own tooling - with real GFM footnotes: an inline
`[^cite-id]` reference plus a `[^cite-id]: [[Source - X]]` definition in a tool-owned `##
Footnotes` block at the end of the page. The qualifier for a multi-file source now lives in the
wikilink's alias slot (`[[Source - X|storage-model.md]]`).
- **`tools/wikitool cite id/add/sync`** - new command group. `cite id` previews the
deterministic id `cite_id(title, qualifier)` derives (strip the `Source -` prefix, transliterate to
ASCII, slugify, `--`-join title and qualifier, `-2`/`-3` suffix on a per-page collision); `cite
add` upserts a page's Footnotes definition and its frontmatter `sources:` entry and prints the
marker to paste; `cite sync` prunes definitions nothing references any more, re-renders the
block in first-reference order, and reports any `[^id]` reference left undefined. `cite id` is
read-only and exempt from the Iteration Budget Gate.
- **`wikitool lint`** hard-errors on three new findings: `legacy_citation_markers` (a leftover
pre-migration `^[[...]]`), `undefined_footnote_refs` (a `[^id]` with no definition), and
`orphan_footnote_defs` (a definition nothing references) - the same class as `broken_links`,
not an advisory.
- **`wikitool rename`** now also refreshes a citation id derived from the old title (in both its
Footnotes definition and every reference to it) alongside the wikilink retargeting it already
did; a hand-picked or otherwise-derived id is left untouched.
- **Migration:** all 71 kb/ pages carrying the legacy marker were rewritten by a one-off script
(not committed - scratch tooling for a single run) that imports `cite_id()` from
`wiki_tools.provenance` so it can never mint an id the tool itself wouldn't, and refuses to
write any page where the multiset of (source title, qualifier) pairs cited before and after
migration doesn't match exactly. `raw/` and the historical entries in `CHANGES.md`/`kb/log.md`
were deliberately left untouched.
- Canonical rule: [kb/CONTRACT.md](kb/CONTRACT.md#provenance-and-citation). Also updated:
`types/entity.md`, `types/concept.md`, `types/source.md`, `kb/sources/COLLECTION.md`,
`instructions/page-lifecycle.md`, `instructions/wiki-ingest/SKILL.md`,
`instructions/wiki-manage/SKILL.md`, `AGENTS.md` invariants 1 and 2, `README.md`.
## 2026-08-25 - Distributable instances: `dist export`/`dist enable-dev`, `doctor`, per-instance author
**Author:** Claude Code
Added a reproducible way to turn this repo into a fresh, contentless wiki instance, instead of
manual copy-and-strip:
- **`tools/wikitool dist export <target>`** writes a contentless copy of the machinery
(`AGENTS.md`/`README.md`/`EVALS.md` with dev-instance-only content removed, `instructions/`,
`types/`, `tools/` minus venv/caches, the `.github/hooks/`+`.vibe/` session-tracing config,
every `kb/*/COLLECTION.md` with no pages or areas, empty `raw/{articles,documents,notes,assets}/`)
into an empty target. Never touches git.
- A generic `<!-- dist:strip-start -->`/`<!-- dist:strip-end -->` marker convention replaces the
one-off "strip the commonplace section" idea: `dist export` removes whatever sits in a marked
region, and **`tools/wikitool dist enable-dev`** is the exact inverse - it adds the vendored
`commonplace/` knowledge base back into an existing instance from
`tools/wiki_tools/dist_templates/`, on request. `docs verify` checks the templates never drift
from what this repo (itself a dev instance) actually has.
- **`tools/wikitool doctor [--json]`** is a single read-only health check (dependencies, author
resolution, git identity/branch/remote, published skills, structure, generated files, session
id) backing the new setup instruction instead of ten checks spelled out in prose.
- **Per-instance author.** `author:` on a new source page now resolves from `$WIKI_AUTHOR`, else
this instance's own `git config user.name` (`config.default_author()`) - never a hardcoded
name. Neither resolving is a hard failure (`ERROR`, exit 1), not a silent placeholder.
- **A third instruction-reference tier.** `instructions/*.md` frontmatter gained `manual: true`:
exempt from `instructions verify`'s "must be referenced" rule, and inverted - it must NOT be
linked from AGENTS.md or a skill (though a CONTRACT.md mentioning it as documentation is fine).
For a rare, deliberate procedure that must never run implicitly.
- New instructions: [instructions/setup-instance.md](instructions/setup-instance.md) (the normal
path for a new instance: git init, author/remote decision points, bootstrap, first commit) and
[instructions/enable-dev-extras.md](instructions/enable-dev-extras.md) (`manual: true`, adds
`commonplace/` back on explicit request only).
- New [INSTALL.md](INSTALL.md) for humans: prerequisites, the two paths (new instance vs.
cloning this repo), configuration, `doctor` as the verification step, troubleshooting.
## 2026-08-24 - README brought current; keeping the human docs current is now written down
**Author:** GitHub Copilot
`README.md` had drifted behind six stack changes. What was wrong:
- The architecture tree was missing `work/`, `EVALS.md`, `.github/hooks/` and
`.vibe/`, and still described `reports/` as holding only lint reports.
- It claimed to be "the only README in the repo", which stopped being true when
the naming rule changed to let a stage carry both a `README.md` and a
`CONTRACT.md`.
- Two links sent readers to `tools/README.md` for the command reference and the
maintenance schedule. Both moved to `tools/CONTRACT.md` when the command table
was consolidated - and `docs verify` now enforces that the README has no copy
of it.
- `wikitool skills sync` was renamed to `instructions sync`.
- Lint reports were said to land in `kb/reports/`.
- The Search section advised a third-party tool for wikis past 100 pages,
written before `wikitool search` existed.
Added: a Telemetry and evaluation section pointing at `EVALS.md`.
`AGENTS.md`'s Changelog section now says that a stack change is not finished
until the human docs describe it. It went there rather than into the Invariants
list on purpose: an invariant holds in every session regardless of task, and this
one only applies when the stack changes - a `wiki-ingest` or `wiki-query` session
never touches a README. The Changelog section is already the place where the
"record what changed" rules live, so it is one sentence in an existing home
rather than a new rule competing for the always-loaded budget.
---
## 2026-08-23 - L0: the pipeline against a wiki the tools built themselves
**Author:** GitHub Copilot
`tools/wiki_tools/tests/test_pipeline_l0.py` builds a wiki through the CLI -
`new` → write → `xref add` → `index rebuild` - against an empty tree, then
asserts that `lint` finds no hard errors, that the catalog covers exactly what
was created, and that rebuilding it again changes nothing on disk.
It closes the last gap the eval ladder rested on. Nothing in the suite chained
the commands before: `test_lint.py` runs lint over a hand-written fixture, and a
hand-written fixture cannot show that `new`, `xref`, `index` and `lint` agree
with one another.
**L0 belongs in pytest, not in a separate harness.** The plan had it as a case in
an `evals/` stage, but a fixture wiki has to be driven in-process - `config.ROOT`
is derived from `wiki_tools/config.py`'s own location, so `wikitool` cannot be
aimed at another tree from outside - and driving commands in-process against a
temporary tree is exactly what the test suite already does. A second mechanism
for it would have duplicated the suite. The `evals/` stage is therefore not
created: it exists to hold agent cases and fixtures, and there are none yet.
Two findings came out of writing it:
- **A scaffolded page does not lint clean.** `new` writes placeholder wikilinks
for the author to replace, so a page that was created but not yet written
reports broken links. That is the scaffold saying it is unfinished, and it is
now pinned by a test so it cannot be mistaken for a defect later.
- **The shared `kb_dir` fixture no longer validates against the type schemas.**
Five of its pages are missing `provenance`, `summary` or `raw_files`, and one
carries a `source` key the schema rejects. `test_clean_wiki_has_no_hard_errors`
passes anyway because it asserts only `broken_links` and `duplicate_titles`
rather than the whole hard-error set. Left as found: repairing the fixture
touches every test that reads those pages, and that is its own change.
`EVALS.md` records what an agent runner needs - a repo copy rather than a fixture
directory, an isolated HOME per run, a mandatory `--agent` so Vibe cannot fall
back to auto-approve, and a run manifest - and why it was not built: no provider
credentials are configured here, so a live run cannot be verified, and the Vibe
adapter is the standing lesson about writing against unverified documentation.
---
## 2026-08-23 - Let the gate refuse first, even when approval already exists
**Author:** GitHub Copilot
`instructions/gates.md` gains one rule: run `publish` without `--yes` first, let
the Mass-Update Gate refuse, then re-run. Approval that arrived earlier in the
conversation is not a licence to pass `--yes` on the first attempt.
This is the first change made *because a trace showed something*. `eval score`
flagged `gate-not-self-opened` on two consecutive sessions - the one that shipped
the telemetry layer and the one that shipped the scorer. Both times the file list
had been shown and approved; both times `--yes` went out on the first attempt, so
the gate never spoke. A trace records tool calls, not conversations, which makes
that indistinguishable from an agent waving a gate through on its own.
Two occurrences of one shape is a pattern, not an incident, so it warranted a
decision rather than an explanation. The options were to soften the rule, to give
the rule a way to see approval (an `--approved-by` flag - but a flag an agent can
set itself is a weaker check than the one it replaces), or to change the
procedure. The procedure changed: the outcome is identical, the record is not,
and the cost is one refused call.
The check that found it stays exactly as it was.
---
## 2026-08-23 - `wikitool eval`: scoring a session against what it left behind
**Author:** GitHub Copilot
`eval score` turns a trace into a scorecard, and `eval sessions` lists what there
is to score. Both are read-only and exempt from the Iteration Budget Gate, for
the same reason `search` is: reading back what a session did is not iteration on
the wiki, and charging for it would discourage checking one's own work.
Two levels, both hard-oracle:
- **L1, structure** re-runs `lint`'s checks in-process. The definition of a hard
error moved out of `lint_command` into `HARD_ERROR_KEYS`, so the scorecard and
`lint --fail-on-error` cannot drift apart - a run must never pass its score
while lint refuses it.
- **L2, trajectory** checks three rules over the trace. Each restates an
invariant the code cannot enforce in-process: `refusal-not-retried`,
`gate-not-self-opened`, `content-change-logged`. A gate can refuse a call, but
nothing stops an agent from calling again with the gate's own flag - that gap
is what a trajectory check closes.
The rules were not invented from the contract text. Each one corresponds to a
refusal an agent can talk its way around, which is why the gates exist at all.
Further rules wait for observed failures; a scorer built ahead of a failure
taxonomy improves its own number rather than the behaviour, which is the failure
mode `commonplace/kb/notes/evaluation-automation-is-phase-gated-by-comprehension.md`
describes.
Scoring the session that shipped the telemetry layer immediately reported a
violation of `gate-not-self-opened`: `publish --yes` went out on the first
attempt, so the gate never got to refuse anything, even though the file list had
been shown and approved in conversation. The finding is true and benign at once -
the trace records tool calls, not conversations. It stays an error rather than
being softened, because weakening a check to make a known case pass is how a
scorecard stops meaning anything. EVALS.md carries the worked example.
Two supporting changes:
- **Every trace now opens with a `session.start`** naming what its source can
report. Mistral Vibe has no session hook, so its traces would otherwise carry
no `completeness` at all and a scorer could not tell "never happened" from "not
observable here". The header is written under `x` mode, which elects a single
writer among the processes appending to one trace.
- **`telemetry/reader.py`** owns reading a trace back - sort order, torn last
line, the union of `completeness` across sources - because a consumer that
re-derived those would drift from the writer.
---
## 2026-08-23 - Mistral Vibe hooks, verified against the installed CLI
**Author:** GitHub Copilot
`.vibe/hooks.toml` declares the three hooks Vibe has - `pre_tool`, `post_tool`,
`post_agent` - and `.vibe/config.toml` puts the telemetry policy in the
repository instead of in someone's shell profile.
Vibe is installed here, and it is open source, so neither file was written
against the documentation: both were validated with the CLI's own loader
(`vibe.core.hooks.config._load_hooks_file`, `VibeConfigSchema`), and the
committed `post_tool` command was executed through Vibe's own
`HookExecutor` with a real `PostToolInvocation`. Four things that changed as a
result:
- **The config shape is an array of tables**, `[[hooks]]` with a unique `name`,
not a table per event type as the research summary suggested.
- **A failing hook cannot block anything.** With `strict = false` - the default -
a crash or timeout is a no-op warning. This is the mirror image of Copilot,
where a `preToolUse` hook is fail-closed and a missing interpreter would deny
every tool call. `strict` and `match` are rejected on `post_agent`.
- **`post_agent` carries no response text**, only the session context, so it maps
to `turn.end` rather than to `assistant.message`.
- **`enable_telemetry` defaults to `true`.** Setting it false is a real change,
not a restatement of the default. It also gates OTel: `vibe/core/tracing.py`
exports only when `enable_telemetry and enable_otel`. Both are off here,
because `enable_otel` ships prompts and tool arguments to Mistral Studio and
Mistral documents no way to point it at a collector you run yourself.
Also confirmed from the source: Vibe already reads `.agents/skills/` and
`AGENTS.md`, so the directory `wikitool instructions sync` publishes is a
project-scope skill source for it. This repository needs no adaptation to be
worked on with Vibe beyond trusting the folder.
A new test parses every committed hook config - JSON for Copilot, TOML for Vibe -
and asserts that each `--event` names an event that exists. A typo there would
otherwise produce a silent gap in a trace that nothing else would notice.
---
## 2026-08-23 - Copilot CLI hooks and the chronicle importer
**Author:** GitHub Copilot
The trace layer now has two sources besides `wikitool` itself.
**`.github/hooks/wiki-trace.json`** wires eleven Copilot CLI hook events into
`tools/trace_ingest.py`. It is committed, so a clone brings its own telemetry;
`disableAllHooks` opts out without deleting anything. Two details in it are
load-bearing:
- **Every command ends in `|| true`.** `preToolUse` hooks are fail-closed: a
non-zero exit denies the tool call. Without the guard a missing interpreter
would turn the observer into a blocker that refuses every tool call in the
session. Verified by running the committed command with `PATH=/nonexistent`.
- **The event name is passed explicitly.** Copilot serves two payload dialects -
camelCase event names give camelCase fields, PascalCase names give the VS
Code/Claude snake_case shape - so `--event` keeps the mapping independent of
which one a config picked. Both spellings are mapped either way.
**`tools/import_chronicle.py`** reconstructs a trace from a chronicle store
(`session-store.db`, shared schema between VS Code Chat and Copilot CLI) for the
surface that has no hooks at all. The store is opened read-only, the import is
idempotent unless `--force`, and the original timestamps are preserved rather
than replaced by import time - `write_event` gained a `ts` parameter for that.
Reconstruction is honest about its limits: those events carry
`reconstructed: true`, and the `completeness` list for `vscode-chat` names
`tool.post` but not `tool.pre`, because the store records that a file was
touched, not that a tool was about to run. Ordering follows the store's own
timestamps, which puts a touched file after the reply of its turn; reordering it
would invent a sequence nobody wrote down.
Two events joined the vocabulary. `turn.end` replaces the awkward use of
`assistant.message` for Copilot's `agentStop` and Vibe's `post_agent`, which mark
the end of a turn and do not always carry text. `session.error` covers Copilot's
`errorOccurred`, which can fire for a model call or the system rather than for a
tool.
---
## 2026-08-23 - Session traces: the telemetry layer under the eval harness
**Author:** GitHub Copilot
`wikitool` now records what a session did. Every command appends a
`wikitool.call` event to `reports/telemetry/<session>/trace.jsonl`, and both
gates record the moment they refuse. `tools/trace_ingest.py` accepts a hook
payload from Claude Code, Copilot CLI or Mistral Vibe and normalises it into the
same vocabulary.
The unit tests check the compiler; nothing checked the *agent*. Scoring a
trajectory - did `new` precede the write, was a refused gate simply retried -
needs a record of the trajectory, and that record has to survive a change of
harness. So the design puts the truth in the layer nobody can swap out: the
repo's own tool. Hooks enrich a trace, they do not constitute it.
Three design points worth knowing:
- **Hooks call `tools/trace_ingest.py`, never `wikitool`.** A hook fires on every
tool call, and every `wikitool` invocation is counted by the Iteration Budget
Gate - routing telemetry through the CLI would let the act of observing a
session end it. The script imports `wiki_tools.telemetry` as a library, which
is stdlib-only for the same reason: a hook must run without the venv.
- **The trace records what the budget exempts.** Read-only retrieval is not
charged, but it *is* traced: what an agent looked at before acting is exactly
what a trajectory scorer needs.
- **The core event set is what the weakest harness can produce.** Claude Code
has ~30 hooks, Mistral Vibe has three. Every `session.start` carries a
`completeness` list, so a scorer says "not measurable here" instead of
silently scoring zero.
Prompts and assistant replies are recorded in cleartext, locally, because a
failure taxonomy cannot be read out of hashes. Guards: secret scrubbing, a
60 KiB cap, `WIKI_TRACE_CONTENT=0`, and the rule that `raw/` file *contents*
never enter a trace at all. `reports/` stays gitignored and no exporter is
configured.
Also added: `/EVALS.md`, the human-facing hub for the whole thing, registered in
the `AGENTS.md` file-naming table. `reports/CONTRACT.md` gained a Traces
section. `wiki_tools/session.py` now holds the single definition of session
identity, which `run_budget` and the emitter share.
---
## 2026-08-23 - `lint` detects contested raw-file ownership
**Author:** GitHub Copilot
New hard check `duplicate_raw_file_owners`: a raw file named in more than one
source page's `raw_files:`.
`uncovered_raw_files` only ever asked whether a raw file is claimed **at all**,
so the opposite failure was invisible. It had already happened: one ingested manual
`10-bootstrap-manual/` subtree carried two incompatible cuts at once, leaving
eight raw files claimed by two pages each, undetected until someone counted by
hand.
The rule it enforces is the one recorded in `types/source.md`: **`raw_files:` is
a maintenance claim, `^[[Source - X]]` citation is free reuse.** Any number of
pages may cite a source; with two *owners* it is undefined which page must be
refreshed when the raw file changes, so both rot silently and neither is
identifiably the stale one.
Repeats inside a single page's own list are untidy, not contested - there is
still exactly one owner - so they are not reported.
---
## 2026-08-23 - Workshop layer `work/`; tree ingests; `## Not Extracted`
**Author:** GitHub Copilot
A tree like `raw/documents/handbook/` (71 files) does not fit in one ingest, and
the wiki had no place to keep a run that spans sessions. Four changes.
### `work/` - a tracked, transient workshop layer
New stage beside `kb/`, with [work/CONTRACT.md](work/CONTRACT.md). One directory
per run, holding the plan and the intermediate extracts on the way to a `kb/`
page, deleted when the run closes.
It is the mirror image of `reports/`: a report is recomputable and therefore
gitignored, a workshop is judgment in progress that nothing can re-derive and is
therefore **tracked**. A gitignored workshop would be a `reports/` directory
with extra steps - the whole point is that a multi-day run survives.
`work/` joins `raw/` and `kb/` in `CONTENT_DIRS` and in the `.gitignore`
backstop: an ignore rule reaching it is a data-loss bug, and `docs verify` now
carries a canary that says so.
### Run keys are derived, and a collision is an error
`wikitool work new --input raw/documents/handbook` creates
`work/ingest-documents-handbook/`. The key comes from the input path *below*
`raw/` with separators flattened - not the basename, because
`raw/documents/handbook` and `raw/articles/handbook` would collide.
**The directory name is the run key.** No second identifier, for the same
reason a page title is a page's only identifier (invariant 2). A collision is
refused rather than suffixed: it means the tree is already being ingested, which
is the condition the key exists to detect. A genuine re-ingest is `--again`,
which dates the key.
`wikitool work close --run-key <name> --yes` deletes a finished run, listing
what would be lost first.
### The Mass-Update Gate stops counting `work/`
`publish` still commits and pushes workshop files, but they no longer count
toward the threshold. The gate's justification is that a push publishes
knowledge irreversibly; a directory that is deleted on closure is not that, and
making a reviewer approve twelve scratch files is exactly the review fatigue the
threshold exists to avoid.
`GATE_EXEMPT_PREFIXES` is a constant, deliberately not a flag - a
`--gate-exempt` option would be a gate an agent could open on its own
initiative. The refusal message now reports both counts.
### Tree ingests, and recording what was *not* extracted
[instructions/ingest-large-tree.md](instructions/ingest-large-tree.md) (a flat
instruction, not a published skill - it runs rarely, so it earns a link rather
than a permanent slot in every session's context) cuts a tree into units before
anything is written, then reads, promotes and publishes one unit at a time
through a workshop. Triggers: more than ~20 raw files, or a source page that
would carry more than ~15 `raw_files:` entries. A third tier adds an audit pass
for units covering secrets, RBAC, ingress or disaster recovery.
Each unit takes its own `WIKITOOL_SESSION_ID`, derived from the run key
(`<runkey>/u<N>`), so the budget is scoped to the unit rather than to the whole
run. [instructions/gates.md](instructions/gates.md) now states the matching
rule: **a new session id may only be taken at a unit boundary written down in
`plan.md`, never in response to a gate refusal.** The plan is the human approval
the gate would otherwise have to ask for.
`types/source.md` gains a `## Not Extracted` section: what was read and
deliberately not promoted, with the reason. `sources coverage` can only answer
whether a raw file is *named* by some source page, never whether anyone decided
what to do with it - so without this, every later pass re-litigates the same
source.
### `README.md` and `CONTRACT.md` are both allowed, and kept apart
The naming rule said `README.md` was repo-root only. It is now explicit that a
stage may carry both: **`README.md` is technical documentation for humans and
for developing the thing in that directory, `CONTRACT.md` is the agent-facing
contract for using it.** What a stage may not carry is the same content twice.
`tools/README.md` returns under that rule, rewritten as developer
documentation - layout, how to add a command, design notes, how to run the
tests - with no command table. The untracked file previously sitting there was
a leftover copy of the document that became `tools/CONTRACT.md` in 35d5df8, and
had already drifted (no `search`, no `work`, old gate wording).
To stop that recurring, `docs verify`'s command-table guard was generalized
from the root README to every README (`check_readmes_have_no_command_table`).
The guard existed because this exact drift happened once; allowing stage
READMEs without widening it would have re-created the bug.
### Found while verifying
Running the new procedure against an already-ingested tree reproduced 5
of 7 existing units exactly and surfaced one defect: **8 raw files under
`10-bootstrap-manual/` are covered by two source pages each** - an 18-file
umbrella page and seven per-step pages, two incompatible cuts applied at
different times. Recorded as a `DECISION NEEDED` in
`work/ingest-documents-handbook/`; retiring either side is a page-lifecycle
operation, not part of this change.
---
## 2026-08-22 - Retrieval instead of catalog reading; one instruction layer; `CONTRACT.md` naming
**Author:** GitHub Copilot
Four changes, all aimed at the same problem: the routing layer had grown
redundant, and finding anything meant reading everything.
### `wikitool search`
New command. Text search runs through a pluggable backend (`rg` today, behind a
`SearchBackend` protocol with Reciprocal Rank Fusion already in place, so a
vector backend is a new module rather than a rewrite). Frontmatter predicates -
`--field entity_type=system`, `--field 'confidence>=0.8'`, `--field tags=k8s`,
`--field '!source_url'` - are evaluated in-process on the parsed YAML, and work
with or without a text query.
The query is passed as an argv element and `--fixed-strings` is the default, so
a search term is never a shell command and never an accidental regex. An unknown
field name fails with the list of fields that do exist, rather than returning an
empty result that reads as "no such pages".
`search` is **exempt from the Iteration Budget Gate**: retrieval is reading, not
iterating, and charging for it would tax the one habit that lowers token cost.
### The catalog is sharded
`kb/index.md` was one flat file listing all 243 pages with summaries - roughly
12-14k tokens to read, which `wiki-query` did on every question. It is now a
**map**: statistics, one row per collection and per area, and a link to the shard
that lists those pages (50 lines). The tables live in a generated
`kb/<collection>/INDEX.md`, and an area past 50 rows gets its own -
`kb/entities/tools/INDEX.md` split out immediately at 56. The threshold comes
from the wiki's own `Index Scaling` page, so growth is handled by arithmetic
rather than by a judgment call. Stale shards are swept on every rebuild.
### `instructions/`, and skills published by copy
New top-level directory: the single source for everything an agent is told to
do. Two forms, told apart **structurally** - a subdirectory with a `SKILL.md` is
published into the harness directories, a flat `.md` never is. That split is what
keeps a rarely-used procedure from occupying context for a whole session.
The five skills moved from `.agents/skills/` into `instructions/`. Four shared
fragments were extracted from the places that had been repeating them:
`gates.md` (was restated in 7 places), `page-lifecycle.md` (6),
`session-setup.md` (4), `publish-cycle.md` (4), plus `bootstrap.md`.
`wikitool skills sync|verify` became `wikitool instructions sync|verify|list`,
and publication now **copies** instead of symlinking. This reverses the earlier
decision recorded in that module: symlinks cannot go stale, but they are
unreliable on Windows checkouts and do not survive being archived or copied. The
price of a copy is drift, so `verify` compares every copy byte for byte against
its source. Both `.agents/skills/` and `.claude/skills/` are now **gitignored
build output**; a fresh clone publishes them once via `instructions/bootstrap.md`.
`verify` also reports an instruction that nothing references - an instruction
nothing loads is inert, and nothing else in the stack would ever say so.
New `instruction` type-spec, with a new optional placement field `root:` on
type-specs (`kb` by default, `repo` to opt out). Without it a type outside `kb/`
could only be contract-only; with it, `wikitool new instruction` scaffolds
correctly.
### `CONTRACT.md` for agents, `README.md` for humans
A file's name now says who it is for. `raw/README.md`, `reports/README.md` and
`tools/README.md` became `CONTRACT.md`; `kb/CONVENTIONS.md` became
`kb/CONTRACT.md` and absorbed `kb/README.md`, which is gone. `README.md` now
exists only at the repo root, for humans.
Twelve rules that had been stated in two to seven places each were reduced to one
canonical location apiece, with links elsewhere. This is now AGENTS.md invariant
8 ("one rule, one place"). It is deliberately **not** machine-checked: prose
duplication is a judgment call, so `wiki-lint` gained a review step for it
instead of `docs verify` gaining a validator.
Also fixed: `docs verify` said `COLLECTION.md` placement had two rules while
`kb/README.md` said three; `runs on` was recommended by the entities contract but
missing from the canonical label list; AGENTS.md implied `budget reset` was the
way past the Iteration Budget Gate, when `reset` is itself counted and refused at
the limit.
---
## 2026-08-21 - Collections under a single `kb/` root; the repo becomes a four-stage pipeline
**Author:** GitHub Copilot
The wiki layer was a single `wiki/` directory with one contract covering five
subdirectories. It is now `kb/`, a namespace whose children are first-class
collections, each with its own `COLLECTION.md`. The structural rule is now
enforced rather than described:
> A directory under `kb/` is a collection exactly when it contains a
> `COLLECTION.md`. A subdirectory inside one is an *area* and inherits it.
> `COLLECTION.md` appears nowhere outside `kb/`.
**Layout.** `wiki/` -> `kb/`, with `entities/`, `concepts/`, `sources/` and
`comparisons/` each gaining a contract. `wiki/COLLECTION.md` split into
`kb/CONVENTIONS.md` (the cross-collection half: naming, tone, linking,
provenance, confidence) and the four per-collection contracts (types offered,
what belongs, outbound linking, per-area emphasis). Added `kb/README.md` for
routing.
**`raw/` stayed at the repo root** rather than moving to `kb/raw/`. It has no
types, frontmatter, wikilinks, provenance or confidence, so none of
`kb/CONVENTIONS.md` applies to it; keeping it a sibling makes the trust boundary
structural instead of documented, and leaves every `raw_files:` reference in
every source page valid. `raw/COLLECTION.md` -> `raw/README.md`.
**`types/` stayed at the repo root** as the global type surface and lost its
`COLLECTION.md`; the content folded into `types/type-spec.md`, which now also
documents placement frontmatter, adding a type, and the template filters. A
type-spec is not a collection contract, and `docs verify` now fails if one
appears there.
**Lint reports left the wiki.** `types/lint-report.md` dropped its `base_dir:`
and is now contract-only, so `wikitool new lint-report` refuses to scaffold one.
`lint --markdown` writes to a new gitignored `reports/`. A report's structural
half is recomputable from the tree at any commit, so committing it created a
second copy that could drift; the semantic review, which is *not* recomputable,
now has to be carried into `kb/log.md` via `log append --op lint` before a pass
ends. `index.md` no longer has a Lint Reports section, and `lint` no longer
carries per-type exemptions for reports.
**Tooling.** `repo_scan.py` -> `kb_scan.py`; `iter_wiki_files`/`load_pages` ->
`iter_kb_pages`/`load_kb_pages`; `config.WIKI_DIR` -> `config.KB_DIR`, plus
`REPORTS_DIR` and `PROVENANCE_FILE`. The scanner now excludes `COLLECTION.md` at
any depth, since contracts sit one level below the kb root where the meta-file
guard does not reach. New `kb_collections.py` discovers collections from the
filesystem, so `mkdir kb/<name>` + a contract adds one with no code change.
`docs verify` replaced its hardcoded `("raw","wiki","types")` list with that
discovery and gained three checks: an unclaimed directory under `kb/`, a nested
contract, and a contract outside `kb/`. Its `.gitignore` canaries now run in
both directions - nothing ignored under `raw/`/`kb/`, everything ignored under
`reports/` except its README.
**AGENTS.md routing** was rewritten from a flat "by layer" table into the
pipeline diagram plus the two collection rules, then *by stage* -> *by
collection* -> *by task* -> *by question*, with an explicit statement that page
placement follows `base_dir:` and is never an agent's choice, and a two-line
"adding a collection" recipe.
**Verification:** 208 tests pass (13 new, covering collection discovery, scanner
exclusions and contract-only types). `lint` is identical to the pre-move
baseline: 243 pages, zero findings.
**Fixed while publishing the above:** `wikitool publish` pushed the *ref named*
`--branch` (default `main`) rather than the commit it had just made. Run from a
feature branch, `git push origin main` pushed an unrelated, unchanged ref, exited
0, and the command reported "Published changes to origin/main" while the new
commit stayed local - the same class of silent non-publication that the
`.gitignore` canaries exist to prevent. `publish` now refuses to push when the
checked-out branch differs from `--branch`, naming both and suggesting
`--branch <current>`, and it checks this *before* staging so a refusal leaves the
working tree untouched. Covered by two regression tests.
**Known gap, not fixed:** at exactly the iteration limit, `budget reset --yes` is
itself refused, because `reset` is deliberately excluded from the exemption list
(`SKIP_COMMAND_PATHS`) so an agent cannot step around the gate by resetting
first. The consequence is that the only way out is `--override-budget`, while
AGENTS.md's "Gates" section reads as though `reset` were available at that point.
The behaviour is intentional; the documentation is what misleads.
## Entries
| Date | Change | Author |
|------|--------|--------|
| 2026-08-13 | **Review remediation: ignore-rule and reference-integrity holes closed, page mutations given commands.** Two defects let the stack report itself healthier than it was. (1) `.gitignore` carried unanchored patterns (`*temp*`, `*scratch*`, `*.log`, `go.mod`, `bin/`, `core`, `tags`) that silently excluded content: `raw/notes/temperature-sensors.md` was gitignored, so `sources coverage` would call it covered while `publish`'s `git add -A` never committed it and a fresh clone got a broken `raw_files:` reference. Every repo-local pattern is now anchored to the root, the content-swallowing ones are gone, and a `!raw/**`/`!wiki/**` backstop closes the rest; `docs verify` gained `check_ignored_content()`, which reports both actually-ignored files under `raw/`/`wiki/` and ten latent canary paths via `git check-ignore --no-index`. (2) `lint`'s broken-link scan only walked page *bodies*, so `related:`/`sources:`/`entities:`/`concepts:` entries naming a non-existent page passed every check - 40 of them existed while lint reported zero findings, including a URL pasted into `sources:` and four pages citing a source title with a stray `.md` suffix the page does not carry. New `dangling_frontmatter_refs` hard-error check; which fields hold page titles is declared by each type-spec's new `page_ref_fields:` frontmatter (read via `TypeResolver.get_page_ref_fields`) rather than hardcoded, so a new type needs no code change. Both defects were reachable because renaming and deleting a page had no command: added `wikitool rename` (repoints body wikilinks, `^[[Title]]` citation markers with aliases/anchors preserved, the page's own H1, and every declared ref array; a `--from` that is not a page repoints references onto an existing `--to` instead of moving anything), `wikitool rm` (refuses while inbound references exist unless `--yes`, strips mechanical links, deliberately leaves prose citations and reports them), and `wikitool xref remove` (inverse of both `xref add` and `xref link-source`; `--b` need not exist, which is what lets a stale reference be cleared without hand-editing frontmatter). `log append` accepts `--op rename`. All 40 dangling references were then cleaned up: 19 repointed onto existing pages, 21 removed as retired or never-created. Also: the `lint_report` pseudo-type - written by `lint --markdown`, special-cased in three places, documented nowhere - became a real `types/lint-report.md` + schema with `base_dir: reports`, so reports live under `wiki/reports/` and are schema-validated like any other page; `budget reset` is no longer exempt from the Iteration Budget Gate and now requires `--yes` of its own (exempting the command that clears the counter made the gate a formality, while `budget status` stays exempt so the situation remains reportable); `skills sync` refuses to `rmtree` a `.claude/skills/` entry that is a real directory unless `--force`; `index_build` no longer hardcodes `types/entity.md`; the root README's duplicate command table, maintenance schedule, and entity-type directory table were replaced with pointers, with a new `docs verify` check enforcing that the command table stays absent there; `wiki/COLLECTION.md` dropped the directory column the type-specs own; and `wiki-lint` now writes its report *after* the fixes rather than before, so it records the wiki's final state. Test suite 139 -> 182. | GitHub Copilot |
| 2026-08-13 | **Tooling correctness + control-plane restructure.** Fixed `confidence decay` compounding on every run: `confidence` is now derived from a new undecayed `confidence_base` field (added to the entity/concept schemas, backfilled across 204 pages by the new `confidence init-base` command), so repeated runs are idempotent - the previous "never double-decays" claim in AGENTS.md/READMEs was false. Added `wikitool touch` (bump `modified:`/`summary:`/`provenance:`/`confidence_base` through the tool instead of by hand, schema-validating the fields it writes) and `wikitool docs verify` (checks every CLI command is documented in tools/README and vice versa, that each collection has a `COLLECTION.md`, and that no pre-migration `type: <enum>` block survives in the contracts). `lint` gained a hard-error check for unreadable/missing frontmatter - previously such pages silently read back as `{}` and escaped every other check - plus `most_linked`/`inbound_counts` output, which `wiki-status` had been documented to read but which the report never contained. `publish` now evaluates the Mass-Update Gate *before* staging, parses `git status --porcelain -z -uall` (fixing renames and paths with spaces), and takes `--remote`/`--branch`/`--path` (the last scoping gate count, staging and commit to a subtree, so a large change can be published in reviewable batches). Budget gate: refused calls are no longer counted, `--help` anywhere in argv is exempt, stale sessions are pruned from the state file, and `budget status` reports whether the session key came from `WIKITOOL_SESSION_ID` or the pid fallback. Restructured the 648-line AGENTS.md into a ~140-line control plane (invariants, routing, gates, error model) plus per-layer contracts `raw/COLLECTION.md`, `wiki/COLLECTION.md`, `types/COLLECTION.md`, moving the per-command error contracts and maintenance schedule into tools/README.md; deleted the stale `type: comparison` template, the schema-duplicating field tables, and the pseudo-command reference. All five skills now export `WIKITOOL_SESSION_ID`, call `touch` instead of hand-editing frontmatter, and treat `raw/` content as untrusted data. Added tests for decay idempotence, frontmatter errors, porcelain parsing, budget hardening, `touch`, `docs verify`, and `skills sync` (139 total). | GitHub Copilot |
| 2026-08-08 | Made `wikitool new`'s scaffolding fully schema-driven instead of hand-duplicating the type-specs: added `TypeResolver.get_enum()`/`get_schema()`, removed `config.py`'s hardcoded `ENTITY_TYPES`/`CONCEPT_TYPES`/`PROVENANCE_VALUES` (enum validity and CLI `--help` text now read from each type's `.schema.yaml`), replaced each `new_*` command's hand-built frontmatter dict with a generic schema-driven builder, and extended `_apply_template_variables` with `\|bullets`/`\|join`/`\|capitalize` filters so type-spec templates reference frontmatter fields directly instead of each command precomputing bespoke display variables. Also consolidated `lint`'s two overlapping missing-field checks into one: removed the hand-maintained `REQUIRED_FIELDS`/`schema_issues`, relying solely on the resolver-based `schema_validation_errors`. Directory placement (`ENTITY_SUBDIRS`) stays in `config.py` as wiki layout, not type structure. | GitHub Copilot |
| 2026-08-08 | Removed the now-unused legacy bare-enum `type:` compatibility shims left over from the type-system migration below (`Page.kind`'s `entity`/`concept`/`source`/`comparison` fallback entries, and `lint`'s duplicate `REQUIRED_FIELDS` keys) since all 238 wiki pages have used path-based `type: types/entity.md` references since that migration; only the current path-based form is recognized now. | GitHub Copilot |
| 2026-08-08 | **Major: Transformed to Commonplace-style externalized type system.** Created `/types/` directory with type-spec files (`entity.md`, `concept.md`, `source.md`, `comparison.md`, `type-spec.md`) and corresponding `.schema.yaml` files for structural validation. Rewrote `wikitool new` commands to use type-spec templates instead of hardcoded templates. Migrated all 238 wiki pages from enum-style type references (`type: entity`) to path-based references (`type: types/entity.md`). Created `type_resolver.py` module for path resolution, caching, and schema validation. Extended `lint` with type path validation and schema validation checks. Updated AGENTS.md Type System section, Architecture diagrams, and skill files. | Mistral Vibe |
| 2026-08-07 | Introduced this `CHANGES.md`. Removed the inline "Version History" sections from `AGENTS.md` and `README.md` (and confirmed no skill file under `.agents/skills/` had one); both now point here instead. | GitHub Copilot |
| 2026-08-07 | Added "Iteration and Cost Limits" section to `AGENTS.md` and a code-enforced `tools/wikitool` Iteration Budget Gate + Loop-Breaker (new `budget status`/`budget reset` commands, `--override-budget` flag): every `wikitool` call is checked against a 30-call/session ceiling and refuses 3x-identical repeats, mirroring the Mass-Update Gate pattern but scoped to iteration volume instead of publish size. | GitHub Copilot |
| 2026-08-06 | Added "Tool Error Contracts" section to `AGENTS.md`: a general 3-outcome model (success / expected validation error / unexpected error) plus a per-command table covering every `wikitool` command's exit-1 meaning, atomicity, retry policy, and escalation path. Added `--dry-run` to `xref add`/`xref link-source` and made `xref link-source` report partial failures instead of aborting silently mid-loop. | GitHub Copilot |
| 2026-08-05 | Documented the skills-based workflow split, the full `wikitool` command set, and a dedicated Maintenance section (schedule, confidence decay, provenance) in `README.md`. | GitHub Copilot |
| 2026-08-04 | Split the 5 step-by-step workflows into independently-discoverable skills under `.agents/skills/` (`wiki-ingest`, `wiki-query`, `wiki-lint`, `wiki-manage`, `wiki-status`), mirrored to `.claude/skills/` for Claude Code via new `tools/wikitool skills sync`/`skills verify` commands. Slimmed root `AGENTS.md` to schema/policy only. | GitHub Copilot |
| 2026-08-02 | Added "Provenance and Citation" section to `AGENTS.md`: `raw_files:` (replacing `source:`) on source pages, `provenance:` field on entity/concept pages, inline `^[[Source - X]]` citation markers, and the generated `wiki/provenance.md` reverse index. Added `tools/wikitool sources coverage\|trace\|rebuild-index` commands and 4 new `lint` checks. Updated all workflows and page templates accordingly, and added the "no confident answer without a source" rule. | GitHub Copilot |
| 2026-08-02 | Added `tools/wikitool` deterministic CLI and a new "Tooling" section to `AGENTS.md` mandating its use for scaffolding, cross-references, index rebuilds, log entries, confidence decay, and publishing. Rewrote INGEST/CREATE/UPDATE/LINT workflow steps and Git Automation to call it instead of describing manual edits. Added `summary:` frontmatter field to all page templates. | GitHub Copilot |
| 2026-08-02 | Added Git Automation: all workflows now automatically commit and push changes to `origin/main` without user confirmation. Added auto-commit/push step to INGEST and CREATE workflows. | Mistral Vibe |
| 2026-07-26 | Updated naming conventions: changed from kebab-case to human-readable titles with spaces (e.g., `Hybrid Search.md` instead of `hybrid-search.md`). Wikilinks now match file names exactly: `[[Hybrid Search]]` links to `Hybrid Search.md`. Updated all page templates to use spaced names in wikilinks. | Mistral Vibe |
| 2026-07-25 | Initial schema/scaffold created for the IT-focused LLM Wiki. | Mistral Vibe |