Files
chemenu/CHANGES.md
T
torben b1883befc7
CI / verify (push) Successful in 49s
Release / release (push) Successful in 36s
docs: Modellwahl nach Pruefbarkeit; stack-dev bricht an den Phasenwechseln fuer den Model-Switch (4.3.3)
Files changed:
- CHANGES.md
- VERSION
- instructions/claude-code-model-selection.md
- instructions/dev/stack-dev/SKILL.md
2026-09-03 21:30:54 +02:00

215 KiB

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.


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 verifyparity 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. lints 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.


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 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 (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.


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, xrefs 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.mds 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. 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:

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 (httpiehttp). 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 - 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, @-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.


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.

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.

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, 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 onhä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. 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. 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 (the normal path for a new instance: git init, author/remote decision points, bootstrap, first commit) and instructions/enable-dev-extras.md (manual: true, adds commonplace/ back on explicit request only).
  • New 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 addindex 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. 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 (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 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.

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