Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c400c670 | |||
| 9a1be6acde | |||
| 24cd221b21 | |||
| aa31d431fc | |||
| 4284f101c8 | |||
| e4e2332e01 | |||
| 536093f6c9 | |||
| 0c98080964 | |||
| 72d01beef8 | |||
| 0e09cf41ea | |||
| 504149c7c4 |
+342
@@ -59,6 +59,348 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6.1.0 - 2026-09-17 - Upgrade-Pfad und Iteration-Budget-Gate gehaertet, wiki/-Pfadliterale bereinigt
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate
|
||||||
|
|
||||||
|
**Medium impact**
|
||||||
|
- Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md
|
||||||
|
- Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form
|
||||||
|
- dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung
|
||||||
|
- version notes antwortet auf einer ausgelieferten Instanz aus dem Release-Feed
|
||||||
|
- new: scaffold materializes a schema default only for a required field
|
||||||
|
|
||||||
|
**Low impact**
|
||||||
|
- Stale `wiki/` path literals swept out of tools/ and types/, with a test guarding against the next rename
|
||||||
|
- CHANGES.md/Guard-Docstring: die Zahl der nachgezogenen Pfadliterale korrigiert (33, nicht 27)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Dieser Kandidat sammelt, was ein getraceter 5.0.0-auf-6.0.0-Upgrade-Lauf auf einer echten Instanz
|
||||||
|
offengelegt hat: ein fehlender Upgrade-Leitfaden, zwei falsche Verweise im Migrationsdokument,
|
||||||
|
eine fehlende dritte Antwort in `dist upgrade` fuer eine lokal geaenderte Datei, und
|
||||||
|
`version notes`, das auf einer ausgelieferten Instanz nie antworten konnte. Im selben Lauf zerfiel
|
||||||
|
die Sitzung durch einen PID-basierten Session-Id-Fallback in 21 Telemetrie-Buckets, wodurch das
|
||||||
|
Iteration-Budget-Gate strukturell unerreichbar blieb - behoben durch eine Registry bekannter
|
||||||
|
Harness-Session-Variablen, mit einem SIGPIPE-Nebenbefund im CLI-Emitter gleich mit. Dazu,
|
||||||
|
unabhaengig vom getraceten Lauf: ein Scaffold-Fix, der `obligation: required` nicht mehr in jede
|
||||||
|
neue Instruktion schreibt, und eine Bereinigung von 33 stehengebliebenen `wiki/`-Pfadliteralen aus
|
||||||
|
der `wiki/`-nach-`kb/`-Umbenennung, mit einem Test-Guard gegen die naechste Umbenennung.
|
||||||
|
|
||||||
|
Kein Grenzuebertritt: jede Aenderung ist in beide Richtungen ein Drop-in, additiv gegenueber
|
||||||
|
`6.0.1`.
|
||||||
|
|
||||||
|
### Upgrade-Prozedur als eigene Instruktion statt als Prosa in INSTALL.md
|
||||||
|
|
||||||
|
Der Upgrade-Pfad einer ausgelieferten Instanz stand nur in INSTALL.md § "Eine Instanz
|
||||||
|
aktualisieren" - einem Dokument fuer Menschen, das `AGENTS.md` § File naming ausdruecklich als
|
||||||
|
*"never by an agent as instruction"* fuehrt. Ausgefuehrt wird er aber von einer Agent-Sitzung,
|
||||||
|
jedes Mal. Der getracete 5.0.0-auf-6.0.0-Lauf auf einer echten Instanz zeigt, was daraus folgt:
|
||||||
|
der erste Tool-Call listete `instructions/` mit, fand keine passende Instruktion, oeffnete die
|
||||||
|
naechstliegende (`private-instance.md`, der falsche der beiden Wege) und fiel dann auf INSTALL.md
|
||||||
|
zurueck. `migrate verify --from <commit vor dem Tausch>` - INSTALL.md Schritt 6, erster
|
||||||
|
Pruefschritt - lief in 33 Werkzeugaufrufen kein einziges Mal, und die Agent-Sitzung wurde nie neu
|
||||||
|
gestartet, obwohl `AGENTS.md` im selben Commit +44/-3 bekommen hatte. Die anschliessende Migration
|
||||||
|
lief damit unter dem alten Kontrollplan.
|
||||||
|
|
||||||
|
Dahinter lagen drei Reihenfolgen nebeneinander: die in INSTALL.md, die im Abschlussbericht von
|
||||||
|
`dist upgrade`, und die tatsaechlich gelaufene. Genau der Zustand, den Invariante 8 verbietet.
|
||||||
|
|
||||||
|
`instructions/upgrade-instance.md` ist jetzt die eine Fassung: dreizehn Schritte von der
|
||||||
|
Sitzungs-Id bis zum zweiten Publish, mit dem Sitzungsneustart an der Stelle, an der der neue
|
||||||
|
Kontrollplan zu gelten anfaengt - nach dem Publish der Maschinerie, vor der Migrationskette, und
|
||||||
|
mit `migrate status` als Wiedereinstiegspunkt fuer die neue Sitzung. `manual: true`, weil die
|
||||||
|
Prozedur einmal pro Release laeuft und nie implizit aufgegriffen werden darf; ein Skill wuerde
|
||||||
|
seine `description` dafuer in jede Sitzung legen. Auffindbar ist sie ueber den Abschlussbericht
|
||||||
|
von `dist upgrade`, der statt einer eigenen Schrittliste jetzt die Datei nennt und das Kommando,
|
||||||
|
bei dem der Lauf weitergeht (`instructions sync`). INSTALL.md behaelt, was ein Mensch *vorher*
|
||||||
|
entscheidet, und den einen Sonderfall, den die Instruktion nicht abdecken kann, weil es sie dort
|
||||||
|
noch nicht gibt: den ersten Sprung auf `4.5.0`.
|
||||||
|
|
||||||
|
Zwei Schritte der Instruktion sagen ausdruecklich, dass sie eine Luecke umgehen, und was sie
|
||||||
|
ueberfluessig macht. Schritt 2 liest die Release-Notes von der Release-Seite statt mit
|
||||||
|
`version notes`, weil eine Instanz ihre `CHANGES.md` als Stub bekommt und `dist upgrade` sie nie
|
||||||
|
ueberschreibt - der Befehl kann dort nicht heute und nicht spaeter antworten. Schritt 6 nimmt fuer
|
||||||
|
eine lokal veraenderte stackeigene Datei die Release-Fassung von Hand, weil es zu `--keep-local`
|
||||||
|
kein Gegenstueck gibt; dabei geht der noetige Commit ueber `publish --no-push`, nicht ueber
|
||||||
|
`git commit` - Invariante 5 kennt keine Ausnahme fuer "ist ja nur eine Vorbedingung", und genau
|
||||||
|
diese Ausnahme hat sich der beobachtete Lauf genommen.
|
||||||
|
|
||||||
|
`instructions/session-setup.md` sagt jetzt, dass ein `export` nur traegt, solange die Shell
|
||||||
|
traegt. Mehrere Harnesses starten pro Tool-Call eine frische Shell - das Arbeitsverzeichnis
|
||||||
|
ueberlebt, Shell-State nicht - und dann faellt jeder Aufruf auf seine eigene Parent-PID zurueck.
|
||||||
|
Im gemessenen Lauf wurde eine Sitzung so zu 21 Telemetrie-Buckets mit hoechstens drei Aufrufen
|
||||||
|
pro Bucket: das Iteration-Budget-Gate (60) und der Loop-Breaker (3 identische in Folge) konnten
|
||||||
|
strukturell nicht ausloesen. Die Anleitung nennt deshalb die Inline-Form pro Aufruf und den
|
||||||
|
Einzeiler, mit dem sich beantworten laesst, welcher Fall vorliegt.
|
||||||
|
|
||||||
|
Verifiziert: `docs verify` (73 ausgelieferte Dokumente, 58 Referenzdateien),
|
||||||
|
`instructions verify` (23 Instruktionen, 7 Skills) und 1276 Tests gruen - einer davon neu und auf
|
||||||
|
genau die Stelle gerichtet, an der die Doppelung wieder entstehen wuerde: der Abschlussbericht
|
||||||
|
von `dist upgrade` muss die Instruktion und ihr Wiedereinstiegskommando nennen, nicht eine zweite
|
||||||
|
Kopie der Liste.
|
||||||
|
|
||||||
|
Kein Grenzuebertritt: eine neue Instruktionsdatei und ein geaenderter Meldungstext sind in beide
|
||||||
|
Richtungen ein Drop-in. Eine Instanz, die zurueckgeht, behaelt die Datei als ueberzaehlige Datei,
|
||||||
|
und nichts liest sie automatisch - `manual: true` heisst genau das.
|
||||||
|
|
||||||
|
### Migrationsdokument prueft gegen eine festgehaltene Vorher-Ausgabe, Beispielverweis auf die .template-Form
|
||||||
|
|
||||||
|
`instructions/migrations/6.0.0-type-guidance-split.md` verlangte in seinem Verifikationsschritt,
|
||||||
|
die Ausgabe von `types describe <name>` muesse *"read the same as it did before this migration"* -
|
||||||
|
ohne dass ein Schritt davor dieses Vorher festhielt. Eine Pruefung gegen einen Zustand, den
|
||||||
|
niemand aufgeschrieben hat, faellt auf das Gedaechtnis des Ausfuehrenden zurueck, und bei ueber
|
||||||
|
150 Zeilen Ausgabe je Typ ist das keins. Der getracete 6.0.0-Lauf hat entsprechend durch
|
||||||
|
`| head -250` und `| tail -80` geprueft und *"structurally identical to before"* geurteilt; was
|
||||||
|
das uebersah, lag in der Mitte der `source`-Ausgabe. Das Dokument schreibt die Ausgabe jetzt in
|
||||||
|
einem eigenen Schritt **vor** der Aenderung in eine Datei und diffed hinterher, mit
|
||||||
|
`grep -c '^## Authoring guidance'` als Ein-Zahl-Probe: zwei Koepfe sind richtig - einen setzt
|
||||||
|
`types describe` selbst, einen bringt die Guidance-Datei mit.
|
||||||
|
|
||||||
|
Als generisches Muster steht dasselbe jetzt in `instructions/migrate-corpus.md` § "Writing the
|
||||||
|
migration document", weil es nicht an diesem einen Dokument haengt: `migrate verify` traegt seine
|
||||||
|
Baseline im letzten Commit, ob jemand daran denkt oder nicht - eine Migration an der Maschinerie
|
||||||
|
statt an `kb/` hat gar keine, und genau dort entsteht die Behauptung, die sich nicht widerlegen
|
||||||
|
laesst.
|
||||||
|
|
||||||
|
Zweiter Fehler im selben Dokument: der Beispielverweis auf `types/entity.md` zeigt in einer
|
||||||
|
ausgelieferten Instanz auf die beim Setup adoptierte Kopie - also auf genau den Vorher-Zustand,
|
||||||
|
den der Schritt entfernen laesst. Der Nachher-Zustand liegt dort unter
|
||||||
|
`types/entity.md.template`, und im Ursprungs-Repo existiert diese Datei ueberhaupt nicht:
|
||||||
|
`dist export` re-keyt `types/<name>.md` erst beim Export. Der Satz konnte in einer Instanz also
|
||||||
|
nicht bloss unguenstig sein, er konnte dort nie stimmen. Dazu sagt der Schritt jetzt die Sprache
|
||||||
|
des Pointer-Absatzes - englisch, weil Anleitungsprosa an einen Agenten Control Plane ist,
|
||||||
|
unabhaengig davon, wem die Datei gehoert - und dass das auch fuer behaltene lokale Prosa gilt:
|
||||||
|
die wird uebersetzt, nicht umbenannt. Die Tabelle dazu wird verlinkt statt kopiert
|
||||||
|
(`types/type-spec.md` § "Who owns a type-spec"), und ein behaltener Abschnitt bekommt einen
|
||||||
|
eigenen Namen statt der Ueberschrift, die `types describe` schon selbst setzt.
|
||||||
|
|
||||||
|
Derselbe Defekt eine Ebene hoeher, gefunden beim Nachmessen: `types/source.md` trug hier im
|
||||||
|
Ursprungs-Repo noch einen Rest-Abschnitt `## Authoring guidance` mit einem einzigen Bullet, der
|
||||||
|
die `title_prefix`-Frontmatter wiederholte - `types describe source` gab drei Koepfe aus, die
|
||||||
|
anderen drei Typen zwei. Die Sprachzentralisierung hat den Abschnitt uebersetzt, der
|
||||||
|
Guidance-Split den Rest der Prosa ausgelagert und diesen Bullet stehenlassen. Die Datei wird beim
|
||||||
|
Export zu `types/source.md.template`, also haette ihn jede neu aufgesetzte Instanz mit adoptiert.
|
||||||
|
Entfernt, geprueft mit genau dem Muster, das der Schritt oben jetzt vorschreibt: Vorher-Datei,
|
||||||
|
Diff, vier entfernte Zeilen und sonst nichts, alle vier Typen komponieren jetzt mit zwei Koepfen.
|
||||||
|
|
||||||
|
Verifiziert: `docs verify` (73 ausgelieferte Dokumente, 58 Referenzdateien),
|
||||||
|
`instructions verify` (23 Instruktionen, 7 Skills) und 1276 Tests gruen. Kein neuer Test: die
|
||||||
|
Aenderung ist Prosa in zwei Instruktionen und ein entfernter Abschnitt aus einem Type-Spec -
|
||||||
|
was hier mechanisch pruefbar waere, prueft `docs verify` bereits als Type-Spec gegen sein Schema.
|
||||||
|
|
||||||
|
Kein Grenzuebertritt: in beide Richtungen ein Drop-in. Die Korrektur gilt denen, die noch
|
||||||
|
upgraden - eine Instanz, die das Angebot bereits genommen hat, liest das Dokument nicht noch
|
||||||
|
einmal. Fuer sie lohnt der eine Befehl, mit dem der Schaden hier gefunden wurde:
|
||||||
|
`grep -c '^## Authoring guidance'` ueber `types describe <name>` fuer alle vier Typen, drei
|
||||||
|
bedeutet einen Rest-Abschnitt im eigenen Type-Spec.
|
||||||
|
|
||||||
|
### dist upgrade: --take-release nimmt fuer einen lokal geaenderten Pfad die Release-Fassung
|
||||||
|
|
||||||
|
`dist upgrade` kannte zwei Antworten auf eine lokal geaenderte Datei und die dritte, die man
|
||||||
|
eigentlich will, war keine davon. `--keep-local` *behaelt* die Aenderung - und weil der neue Stamp
|
||||||
|
die Release-Digest trotzdem schreibt, wird dieselbe Datei bei jedem kuenftigen Upgrade erneut
|
||||||
|
gemeldet. Fuer eine Datei, die der Instanz gar nicht gehoert, ist das der dauerhaft falsche
|
||||||
|
Zustand. Der andere angebotene Weg, "reconcile them by hand first", hatte kein Werkzeug: im
|
||||||
|
getraceten 5.0.0-auf-6.0.0-Lauf war eine `kb/CONTRACT.md` durch ein Format-on-Save um
|
||||||
|
Tabellen-Whitespace verschoben, und das kostete eine Handkopie aus dem entpackten Tarball, einen
|
||||||
|
Commit nur zur Herstellung der Clean-Tree-Vorbedingung des naechsten Kommandos - und damit einen
|
||||||
|
rohen `git commit`, an `AGENTS.md` Invariante 5 vorbei, die fuer "ist ja nur eine Vorbedingung"
|
||||||
|
keine Ausnahme kennt.
|
||||||
|
|
||||||
|
`--take-release <pfad>` ist die fehlende Antwort: schreibe fuer diesen Pfad die Release-Fassung,
|
||||||
|
statt abzubrechen. Wiederholbar, weil der Pfad die Entscheidung *benennt* - `--keep-local` verliert
|
||||||
|
nichts, `--take-release` verwirft eine lokale Aenderung, und die zwei sind darum nicht symmetrisch
|
||||||
|
genug fuer ein pauschales Flag. Beide gelten pro Pfad und komponieren auf einem Aufruf, was der
|
||||||
|
gemischte Fall braucht: eine Datei zuruecksetzen, eine andere behalten. Ohne `--keep-local` bricht
|
||||||
|
ein blockierter Pfad, zu dem nichts gesagt wurde, weiter ab; ein `--take-release`-Pfad, der gar
|
||||||
|
nicht blockiert ist, wird abgelehnt - auch im `--dry-run`, denn das ist ein Fehler im *Argument*
|
||||||
|
und nicht ein Zustand des Baums, und ein still ignorierter Tippfehler haette ein erfolgreiches
|
||||||
|
Upgrade gemeldet und die Aenderung behalten, die verworfen werden sollte.
|
||||||
|
|
||||||
|
Anders als bei `--keep-local` ist die Drift danach **weg** und nicht bloss uebergangen: die Datei
|
||||||
|
stimmt wieder mit der Digest ueberein, die der Stamp fuehrt, und verschwindet aus der Meldung.
|
||||||
|
|
||||||
|
Dazu die Abbruchmeldung selbst, die den Fehlgriff mitverursacht hat. Sie nannte `--keep-local` und
|
||||||
|
"reconcile by hand", sagte aber nicht, dass es zu `--keep-local` kein Gegenstueck gibt - der Lauf
|
||||||
|
kuendigte woertlich an, *"I'll let the upgrade take the release's version"*, und rief das Kommando
|
||||||
|
ohne Flag auf. Jetzt nennt sie alle drei Antworten mit fertig eingesetzter Kommandozeile, im Muster
|
||||||
|
des Mass-Update-Gates, und sagt ausdruecklich, dass keine davon der Default ist.
|
||||||
|
|
||||||
|
`instructions/upgrade-instance.md` Schritt 6 traegt entsprechend nicht mehr die Drei-Schritt-Handreparatur, sondern die Entscheidung und den Dry-Run, mit dem man sie vorher sieht.
|
||||||
|
|
||||||
|
### version notes antwortet auf einer ausgelieferten Instanz aus dem Release-Feed
|
||||||
|
|
||||||
|
`version notes` liest die lokale `CHANGES.md`. Eine ausgelieferte Instanz bekommt die aber als
|
||||||
|
neunzeiligen Stub ohne einen einzigen Versionseintrag, und `CHANGES.md` steht in
|
||||||
|
`chemenu.ownership.is_upgrade_preserved` - `dist upgrade` ueberschreibt sie also nie. Der Stub
|
||||||
|
bleibt der Stub, dauerhaft. Der Befehl konnte dort nicht nur heute nicht antworten, sondern nie,
|
||||||
|
und das an genau der Stelle, an der die Antwort am meisten zaehlt: dem Grenzuebertritt, vor dem
|
||||||
|
**Breaking Change:** und **Migration:** gelesen werden muessen. Der getracete
|
||||||
|
5.0.0-auf-6.0.0-Lauf kam nur weiter, weil er die Release-Notes ueber einen MCP-Server holte - ein
|
||||||
|
Weg, den die Anleitung nicht nannte und den eine Instanz ohne erreichbaren Server gar nicht hat.
|
||||||
|
|
||||||
|
Fehlt der Eintrag lokal, fragt der Befehl jetzt den Feed aus `update_url` - denselben, den
|
||||||
|
`version check` benutzt - und druckt den `body` des Release, den `release.yml` im Ursprungs-Repo
|
||||||
|
ohnehin aus `version notes` baut. Drei Praezisierungen halten das von einem stillen Netzaufruf
|
||||||
|
auseinander:
|
||||||
|
|
||||||
|
- **Nur mit Release-Stamp.** Ein Baum ohne `.wikitool-release.json` ist ein Dev-Checkout und
|
||||||
|
behaelt die alte Fehlermeldung. Damit kann der neue Pfad im Ursprungs-Repo und in CI nicht
|
||||||
|
betreten werden - auch nicht von `release.yml`s eigenem `version notes`.
|
||||||
|
- **stdout traegt nur die Notes.** Die Zeile, welcher Feed gefragt wird, und die, welche Version
|
||||||
|
geantwortet hat, gehen nach stderr. `release.yml` leitet stdout in die Datei um, die es als
|
||||||
|
Release-Body postet; alles andere dort waere Inhalt im Release.
|
||||||
|
- **`--offline`** verweigert den Aufruf und scheitert mit der `release_url` aus dem Stamp. Dieselbe
|
||||||
|
Seite nennt auch jeder Fehlerfall des Feeds, damit ein Lauf, der die Notes nicht lesen kann,
|
||||||
|
wenigstens weiss, wo sie stehen. Ein leerer `body` ist ebenfalls ein Fehler: eine leere Antwort
|
||||||
|
darf nicht als "dieses Release hat nichts zu melden" durchgehen.
|
||||||
|
|
||||||
|
Gefragt werden kann nur das **neueste** Release: `update_url` ist die einzige URL, die der Stamp
|
||||||
|
fuehrt, und eine `/releases/tags/<tag>`-URL daraus zusammenzusetzen waere eine geratene
|
||||||
|
API-Form statt einer gelesenen (Invariante 7). Antwortet der Feed eine andere Version als die
|
||||||
|
gefragte, wird das auf stderr benannt und die Notes werden trotzdem gedruckt - das ist nicht der
|
||||||
|
Randfall, sondern der Hauptfall, weil die Notes *vor* dem Tausch gelesen werden, wenn `VERSION`
|
||||||
|
noch das Release nennt, das verlassen wird.
|
||||||
|
|
||||||
|
`instructions/upgrade-instance.md` Schritt 2 und INSTALL.md § "Version und Updates" tragen
|
||||||
|
entsprechend nicht mehr den Hinweis, dass der Befehl auf einer Instanz nicht antwortet; damit ist
|
||||||
|
auch die letzte der beiden Werkzeugluecken aus dieser Instruktion heraus, und ihr Vorwort nennt
|
||||||
|
keine mehr.
|
||||||
|
|
||||||
|
Bei der Gelegenheit zwei Eintraege aus `tools/CONTRACT.md` § "Future considerations (not
|
||||||
|
implemented)" entfernt, die dort seit ihrer Umsetzung falsch standen: der MCP-Server-Wrapper und
|
||||||
|
`dist upgrade` selbst. Beide sind im selben Dokument weiter oben als existierend beschrieben.
|
||||||
|
|
||||||
|
### Session-Id-Fallback: Harness-Variable schliesst die Luecke zwischen Telemetrie-Join und Iteration-Budget-Gate
|
||||||
|
|
||||||
|
Gemessen an einem getracten Lauf (33 `wikitool`-Aufrufe, eine Sitzung): unter Claude Code, dessen
|
||||||
|
Bash-Tool jeden Aufruf in einer frisch initialisierten Shell ausfuehrt, fiel `chemenu.session`
|
||||||
|
ohne gesetztes `WIKITOOL_SESSION_ID` auf `os.getppid()` zurueck - eine neue "Sitzung" pro Aufruf.
|
||||||
|
Der Lauf zerfiel so in 21 Telemetrie-Buckets (hoechster Bucket: 3 von 33 Aufrufen), und das
|
||||||
|
Iteration-Budget-Gate (60 Aufrufe, Loop-Breaker bei 3 identischen in Folge) sah nie mehr als 3 von
|
||||||
|
60 - strukturell unerreichbar, obwohl `AGENTS.md` es als eine der vier code-durchgesetzten
|
||||||
|
Sicherungen fuehrt. Derselbe Bruch traf den Telemetrie-Join: Hook-Events (`prompt.submitted`)
|
||||||
|
trugen die Harness-UUID, `wikitool.call`-Events die wechselnde PID - kein gemeinsamer Schluessel,
|
||||||
|
und `eval score` bewertete 1-3 Aufrufe statt 33.
|
||||||
|
|
||||||
|
`chemenu.session` bekommt eine dritte Stufe zwischen der expliziten Variable und dem
|
||||||
|
PID-Fallback: eine kleine Registry bekannter Harness-Session-Variablen (`HARNESS_ENV_VARS`),
|
||||||
|
heute mit einem verifizierten Eintrag, `CLAUDE_CODE_SESSION_ID`. Verifiziert heisst: gegen eine
|
||||||
|
echte Sitzung gemessen, dass die Variable ueber Tool-Aufrufe hinweg stabil bleibt (anders als die
|
||||||
|
Shell-PID) und exakt der Wert ist, den der `UserPromptSubmit`-Hook in die Trace schreibt - der
|
||||||
|
Wert wird unveraendert als Schluessel uebernommen, kein Praefix, keine Umschreibung, sonst waere
|
||||||
|
der Join wieder zerstoert. Ein Eintrag wird nur nach genau dieser Verifikation aufgenommen: ein
|
||||||
|
Variablenname, der zufaellig existiert und etwas anderes bedeutet, waere ein stillerer Fehler als
|
||||||
|
der PID-Fallback, den er ersetzt.
|
||||||
|
|
||||||
|
`run_budget`s Zustandsdatei (`budget.json`) traegt je Eintrag jetzt die Herkunft seiner Id; faellt
|
||||||
|
dieselbe Id-Zeichenkette unter eine andere Herkunft als die gespeicherte, beginnt ein neuer
|
||||||
|
Zaehler statt einen fremden zu erben - ein Eintrag ohne das Feld (vor dieser Aenderung
|
||||||
|
geschrieben) behaelt seinen Count unveraendert. `doctor` ist jetzt dreiwertig (`OK` fuer eine
|
||||||
|
explizite Variable oder eine erkannte Harness-Variable, `WARN` nur noch fuer den reinen
|
||||||
|
PID-Fallback), und sowohl `budget status` als auch der `session.start`-Event der `wikitool`-
|
||||||
|
Telemetriequelle nennen die Herkunft der Id.
|
||||||
|
|
||||||
|
Im selben Lauf gemessener Nebenbefund auf der Emitter-Seite: ein durch eine geschlossene Pipe
|
||||||
|
abgebrochener, ansonsten erfolgreicher Aufruf (`... | head`) stand mit `exit_code: 1` in der
|
||||||
|
Trace - Click faengt `BrokenPipeError` selbst ab und erzwingt `sys.exit(1)`, ununterscheidbar von
|
||||||
|
einem echten Fehler. `cli.py` installiert jetzt vor jedem Dispatch einen Wrapper um
|
||||||
|
`stdout`/`stderr`, der einen EPIPE-Schreibfehler schluckt, bevor Click ihn sieht, und markiert den
|
||||||
|
Trace-Eintrag stattdessen mit `stdout_truncated: true` bei unveraendertem, dem tatsaechlichen
|
||||||
|
Kommandoerfolg entsprechendem `exit_code`.
|
||||||
|
|
||||||
|
Reproduziert mit Tests, die echte Subprozesse statt In-Process-Aufrufe verwenden - `os.getppid()`
|
||||||
|
ist sonst ueber die Testlaufzeit hinweg konstant: 61 Aufrufe aus je eigenem Prozess mit nur der
|
||||||
|
Harness-Variablen loesen das Gate jetzt aus, drei identische ebenso den Loop-Breaker; vor dieser
|
||||||
|
Aenderung waeren beide Tests gruen und blind gewesen.
|
||||||
|
|
||||||
|
`--minor`: additiv (ein neues optionales `source`-Feld in `budget.json`, die Id faellt weiterhin
|
||||||
|
auf `getppid()` zurueck, wo keine Variable greift), keine der beiden Drop-in-Richtungen verletzt.
|
||||||
|
|
||||||
|
### new: scaffold materializes a schema default only for a required field
|
||||||
|
|
||||||
|
`tools/wikitool new instruction --name "x"` schrieb bislang `obligation: required` in jede neue
|
||||||
|
Instruktion. `obligation:` ist ein Migrationsfeld (`instructions/CONTRACT.md`
|
||||||
|
§ `instructions/migrations/`) - eine gewoehnliche Instruktion ist keine Migration und hat nichts,
|
||||||
|
was laufen muesste. Ursache: `new_page._build_frontmatter()` materialisierte jedes
|
||||||
|
Schema-`default:` unbesehen; ueber alle acht `types/*.schema.yaml` gibt es genau zwei
|
||||||
|
(`entity`/`concept`s `provenance`, in `required:`; `instruction`s `obligation:`, nicht).
|
||||||
|
|
||||||
|
Die Regel jetzt: ein Schema-`default:` wird nur fuer ein Feld materialisiert, das das Schema auch
|
||||||
|
in `required:` fuehrt. Auf einem optionalen Feld ist ein `default:` eine Lese-Annahme (was ein
|
||||||
|
fehlendes Feld bedeutet), keine Schreib-Vorgabe - sie hinzuschreiben macht aus der stillen
|
||||||
|
Annahme eine ausgesprochene Behauptung. `instruction.obligation`s eigene Lese-Annahme steht
|
||||||
|
unveraendert und unabhaengig in `kb_state.py` (`frontmatter.get("obligation") or REQUIRED`).
|
||||||
|
Der `array`-Zweig direkt daneben (leere Liste fuer ein unbesetztes Array-Feld wie `tags:`) ist
|
||||||
|
davon ausdruecklich nicht betroffen - er bleibt fuer optionale wie Pflichtfelder gleich, weil ein
|
||||||
|
fehlender Schluessel sonst den Template-Filter-Suffix woertlich in den Body schreiben wuerde
|
||||||
|
(`{related|bullets}` -> das Wort "bullets").
|
||||||
|
|
||||||
|
`--patch`: kein Bestandsdokument aendert sich (`obligation:` stand bislang nur explizit oder auf
|
||||||
|
den beiden Migrationsdokumenten), keine Migration noetig, und ein zurueckgerolltes Werkzeug
|
||||||
|
schriebe das Feld nur wieder mit.
|
||||||
|
|
||||||
|
### Stale `wiki/` path literals swept out of tools/ and types/, with a test guarding against the next rename
|
||||||
|
|
||||||
|
Die Wissensschicht wurde am 2026-08-21 von `wiki/` nach `kb/` umbenannt. Das Verzeichnis zog um,
|
||||||
|
die Zeichenkette nicht: 33 Stellen nannten weiter einen Pfad, den es nicht mehr gibt. Gemeldet
|
||||||
|
war davon eine - die Kopfzeile des Lint-Reports (``Scanned N pages under `wiki/` ``) - als
|
||||||
|
kosmetischer Einzelfall. Der Scan selbst war immer korrekt: `run_lint(kb_dir)` laeuft ueber
|
||||||
|
`kb/`, gezaehlt wird, was dort liegt. Falsch waren ausschliesslich die Beschriftungen.
|
||||||
|
|
||||||
|
Dreizehn davon sind nutzersichtbar. Die Fehlermeldungen von `xref`, `cite`, `touch`,
|
||||||
|
`move`, `rm`, `rename`, `raw accept` und `log status` nannten `wiki/`, ebenso die `--help`-Texte
|
||||||
|
von `cite sync --all`, `provenance rebuild-index --dry-run` und `move --reconcile`. Dazu die
|
||||||
|
`description:`-Felder in `types/type-spec.schema.yaml`, die ueber `types describe` und ueber jede
|
||||||
|
Schema-Validierungsmeldung bei einem Agenten landen. Zwei Stellen waren doppelt falsch:
|
||||||
|
`git_publish.py` und `run_budget.py` verwiesen auf `wiki/concepts/Mass-Update Gate.md`, waehrend
|
||||||
|
die Seite unter `kb/concepts/workflows/Mass-Update Gate.md` liegt - dort war auch die
|
||||||
|
Collection-Ebene veraltet.
|
||||||
|
|
||||||
|
Nicht angefasst: `raw/` (unveraenderlich, was immer dort steht) und die Alteintraege dieser
|
||||||
|
Datei. Beide sind Aufzeichnungen dessen, was zu ihrer Zeit galt, keine Wegweiser - dieselbe
|
||||||
|
Unterscheidung, die `instructions/dev/issue-tracking.md` fuer den Tracker trifft.
|
||||||
|
|
||||||
|
Dass es vier Wochen unbemerkt blieb, ist der eigentliche Befund: kein Check liest ein Pfadliteral
|
||||||
|
in Quelltext. `docs verify` kam dafuer nicht in Frage, weil es `shipped_prose()` liest, also
|
||||||
|
Markdown - der Grossteil des Defekts sass in `.py`-Zeichenketten. Der Guard ist deshalb ein Test:
|
||||||
|
`tools/chemenu/tests/test_source_hygiene.py` scannt jede `.py`-Datei unter `tools/chemenu/` sowie
|
||||||
|
`tools/wikitool` gegen eine Tabelle stillgelegter Stufenpfade. Die naechste Umbenennung traegt
|
||||||
|
dort eine Zeile nach und bekommt jede vergessene Stelle als Testfehler, statt als Zeichenkette,
|
||||||
|
die ein Jahr lang niemand liest. Die zwei Ausnahmen stehen bewusst als Liste mit Begruendung und
|
||||||
|
nicht als geschickteres Muster: eine Fixture-URL, in der `wiki` ein Repository-Name ist, und die
|
||||||
|
Guard-Datei selbst, die die stillgelegten Pfade ja gerade deklariert.
|
||||||
|
|
||||||
|
`kb/entities/projects/Chemenu.md` trug denselben Fehler in einer Kerndaten-Zeile und wurde ueber
|
||||||
|
`touch` nachgezogen. "Dreilagig" blieb dort stehen: das deckt sich mit der Concept-Seite
|
||||||
|
`Three-Layer Architecture`, die `reports/` ausdruecklich als vierte *Phase* neben den drei
|
||||||
|
Schichten fuehrt.
|
||||||
|
|
||||||
|
`--patch`: keine Schnittstelle aendert sich, kein Verhalten, keine Migration. Ein
|
||||||
|
zurueckgerolltes Werkzeug gibt nur wieder die alten Beschriftungen aus.
|
||||||
|
|
||||||
|
### CHANGES.md/Guard-Docstring: die Zahl der nachgezogenen Pfadliterale korrigiert (33, nicht 27)
|
||||||
|
|
||||||
|
Der Eintrag darueber nannte 27 nachgezogene Stellen und "rund die Haelfte davon nutzersichtbar".
|
||||||
|
Beides war falsch. Die 27 stammten aus einem `wc -l`, das nur `tools/**/*.py` gezaehlt hatte -
|
||||||
|
`tools/wikitool`, `types/type-spec.md` und die vier `description:`-Felder in
|
||||||
|
`types/type-spec.schema.yaml` fehlten darin. Nachgezaehlt am Commit selbst
|
||||||
|
(`git show <sha> | grep -c '^-.*wiki/'`): 33, davon 32 im Stack und eine auf der Seite
|
||||||
|
`Chemenu`. Nutzersichtbar sind davon dreizehn, also gut ein Drittel und nicht die Haelfte.
|
||||||
|
|
||||||
|
Derselbe Zahlendreher stand im Docstring von `tools/chemenu/tests/test_source_hygiene.py`, wo er
|
||||||
|
kuenftigen Lesern erklaert, wogegen der Guard schuetzt - dort ebenfalls korrigiert. Dass diese
|
||||||
|
Korrektur einen eigenen Bump braucht, ist kein Formalismus: der Docstring liegt unter `tools/`,
|
||||||
|
und das Version-Gate in `.gitea/workflows/ci.yml` ist nach Pfad geschnitten, nicht nach Absicht.
|
||||||
|
|
||||||
|
`--patch`: reine Prosakorrektur, kein Verhalten, keine Schnittstelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei
|
## 6.0.1 - 2026-09-16 - docs toc/verify erreichen die .template-Form einer Referenzdatei
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|||||||
@@ -57,7 +57,21 @@ flowchart TD
|
|||||||
- **Hooks enrich.** They add the tool calls the repo layer cannot see: file reads, greps,
|
- **Hooks enrich.** They add the tool calls the repo layer cannot see: file reads, greps,
|
||||||
shell commands, prompts.
|
shell commands, prompts.
|
||||||
|
|
||||||
Everything joins on `WIKITOOL_SESSION_ID`.
|
Everything joins on one session id, resolved the same way by every source that has to pick
|
||||||
|
one - see `chemenu.session`. The chain is `WIKITOOL_SESSION_ID`, then a harness's own session
|
||||||
|
variable where one is registered (`chemenu.session.HARNESS_ENV_VARS` - Claude Code's
|
||||||
|
`CLAUDE_CODE_SESSION_ID` today), then the parent process id. The middle step exists because
|
||||||
|
the last one does not survive a harness that runs every tool call in its own freshly
|
||||||
|
initialised shell: `os.getppid()` is then a new "session" per call, and neither the join nor
|
||||||
|
the Iteration Budget Gate below can see more than one or two calls of a real run. A harness
|
||||||
|
only earns an entry in that chain once a live session has been observed setting the variable,
|
||||||
|
confirmed to be the exact id its own hooks write elsewhere in a trace - a name that merely
|
||||||
|
looks plausible would mis-key a session more quietly than the pid fallback it replaced.
|
||||||
|
|
||||||
|
A trace hook that only *observes* tool calls (a `PreToolUse`/`PostToolUse`-style wiring) does
|
||||||
|
not by itself fix a harness whose events carry a different id than `wikitool`'s own emitter -
|
||||||
|
the two still would not join. Wiring such a hook is only worth doing once this fallback chain
|
||||||
|
already keys both sides on the same id.
|
||||||
|
|
||||||
## The trace
|
## The trace
|
||||||
|
|
||||||
@@ -68,7 +82,7 @@ is [tools/chemenu/telemetry/schema.py](tools/chemenu/telemetry/schema.py).
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `v` | Schema version |
|
| `v` | Schema version |
|
||||||
| `ts` | ISO-8601 UTC, microsecond precision |
|
| `ts` | ISO-8601 UTC, microsecond precision |
|
||||||
| `session_id` | The join key. `WIKITOOL_SESSION_ID`, else the parent process id |
|
| `session_id` | The join key - `chemenu.session`'s fallback chain: `WIKITOOL_SESSION_ID`, else a registered harness variable, else the parent process id |
|
||||||
| `pid`, `seq` | `seq` counts **within one process**. Sort a trace by `(ts, pid, seq)` |
|
| `pid`, `seq` | `seq` counts **within one process**. Sort a trace by `(ts, pid, seq)` |
|
||||||
| `source` | `wikitool`, `runner`, or a harness name |
|
| `source` | `wikitool`, `runner`, or a harness name |
|
||||||
| `event` | See below |
|
| `event` | See below |
|
||||||
@@ -202,8 +216,8 @@ same question the same way:
|
|||||||
| Git clone of this repo (dev checkout) | **on** (opt-out) | No `.wikitool-release.json` |
|
| Git clone of this repo (dev checkout) | **on** (opt-out) | No `.wikitool-release.json` |
|
||||||
| `dist export` tarball (a distributed instance) | **off** (opt-in) | `.wikitool-release.json` present |
|
| `dist export` tarball (a distributed instance) | **off** (opt-in) | `.wikitool-release.json` present |
|
||||||
|
|
||||||
The form is read off `.wikitool-release.json`, the same stamp `version check` and `dist upgrade`
|
The form is read off `.wikitool-release.json`, the same stamp `version check`, `dist upgrade` and
|
||||||
already use to tell a distribution from the repo it came from - present means an operator never
|
`version notes` already use to tell a distribution from the repo it came from - present means an operator never
|
||||||
asked for telemetry, absent means this is the dev checkout the stack ships from, where the traces
|
asked for telemetry, absent means this is the dev checkout the stack ships from, where the traces
|
||||||
are its own measuring instrument (the rest of this file). A private instance
|
are its own measuring instrument (the rest of this file). A private instance
|
||||||
(`instructions/private-instance.md`) is a git clone of an *export*, so it carries the stamp and
|
(`instructions/private-instance.md`) is a git clone of an *export*, so it carries the stamp and
|
||||||
|
|||||||
+34
-64
@@ -152,9 +152,12 @@ tools/wikitool version # was läuft hier, und woher kommt es
|
|||||||
tools/wikitool version check # gibt es ein neueres Release?
|
tools/wikitool version check # gibt es ein neueres Release?
|
||||||
```
|
```
|
||||||
|
|
||||||
`version check` ist der einzige Befehl, der ins Netz geht. Er fragt den Release-Feed der
|
`version check` und `version notes` sind die einzigen Befehle, die ins Netz gehen, und beide
|
||||||
Ursprungs-Instanz (`$WIKITOOL_UPDATE_URL` überschreibt; sonst der Wert aus dem Stamp). Ein
|
fragen denselben Release-Feed der Ursprungs-Instanz (`$WIKITOOL_UPDATE_URL` überschreibt; sonst
|
||||||
nicht erreichbarer Feed wird als Fehler gemeldet - **nie** als „aktuell".
|
der Wert aus dem Stamp). `version check` ist dafür da; `version notes` greift nur dann darauf
|
||||||
|
zurück, wenn die lokale `CHANGES.md` den Eintrag nicht hat - auf einer Instanz also immer, siehe
|
||||||
|
unten - und sagt vorher auf stderr, welche URL es fragt. Ein nicht erreichbarer Feed wird als
|
||||||
|
Fehler gemeldet - **nie** als „aktuell" und nie als „keine Notes".
|
||||||
|
|
||||||
**Was die Versionsnummer aussagt:** kompatibel ist, was in der *linkesten von Null
|
**Was die Versionsnummer aussagt:** kompatibel ist, was in der *linkesten von Null
|
||||||
verschiedenen Stelle* übereinstimmt. `0.1.3 → 0.1.4` ist ein sicheres Update, `0.1.3 → 0.2.0`
|
verschiedenen Stelle* übereinstimmt. `0.1.3 → 0.1.4` ist ein sicheres Update, `0.1.3 → 0.2.0`
|
||||||
@@ -172,7 +175,13 @@ Update von 1.x auf 2.0.0" unten ist genau dieser Fall.
|
|||||||
Deshalb stehen in den Release-Notes eines MAJOR zwei getrennte Zeilen, und beide sind vor dem
|
Deshalb stehen in den Release-Notes eines MAJOR zwei getrennte Zeilen, und beide sind vor dem
|
||||||
Update zu lesen: **Breaking Change:** sagt, was aufhört zu funktionieren und was diese Instanz
|
Update zu lesen: **Breaking Change:** sagt, was aufhört zu funktionieren und was diese Instanz
|
||||||
dagegen tun muss; **Migration:** sagt, ob und wie der Korpus umgeschrieben wird (`none required`,
|
dagegen tun muss; **Migration:** sagt, ob und wie der Korpus umgeschrieben wird (`none required`,
|
||||||
wenn nicht). `tools/wikitool version notes` druckt den Eintrag.
|
wenn nicht). `tools/wikitool version notes` druckt beide Zeilen - im Ursprungs-Repo aus der dort
|
||||||
|
gefüllten `CHANGES.md`, auf einer ausgelieferten Instanz aus dem Release-Feed, weil die Instanz
|
||||||
|
die Datei nur als Stub bekommt und ein Update sie nie überschreibt. Der Befehl fragt dabei immer
|
||||||
|
das **neueste** Release: solange `VERSION` noch die alte Fassung nennt, antwortet er also mit
|
||||||
|
einer anderen Version als der eigenen und sagt das auf stderr dazu. Ist der Feed nicht
|
||||||
|
erreichbar, nennt die Fehlermeldung die Release-Seite, die `.wikitool-release.json` als
|
||||||
|
`release_url` führt; `--offline` verlangt diesen Weg von vornherein.
|
||||||
|
|
||||||
### Eine Instanz aktualisieren
|
### Eine Instanz aktualisieren
|
||||||
|
|
||||||
@@ -180,33 +189,27 @@ Zwei Wege, je nachdem, wie diese Instanz entstanden ist. Ein **Clone mit gemeins
|
|||||||
Git-History** (`upstream`-Remote auf das Ursprungs-Repo, siehe
|
Git-History** (`upstream`-Remote auf das Ursprungs-Repo, siehe
|
||||||
[instructions/private-instance.md](instructions/private-instance.md)) nimmt Stack-Updates per
|
[instructions/private-instance.md](instructions/private-instance.md)) nimmt Stack-Updates per
|
||||||
echtem Drei-Wege-Merge: `tools/wikitool upstream merge`. Alles Folgende gilt für eine **Instanz
|
echtem Drei-Wege-Merge: `tools/wikitool upstream merge`. Alles Folgende gilt für eine **Instanz
|
||||||
aus einem Tarball**, ohne gemeinsame History - der Weg unten unter „Eine Instanz aktualisieren"
|
aus einem Tarball**, ohne gemeinsame History.
|
||||||
nutzt sie.
|
|
||||||
|
|
||||||
Das Anwenden eines Updates schreibt in eine Instanz, die bereits Inhalt hat. Der Inhalt hat dabei
|
Das Anwenden eines Updates schreibt in eine Instanz, die bereits Inhalt hat. Der Inhalt hat dabei
|
||||||
eine **eigene Version**: `.wikitool-kb.json` sagt, in welcher Form die Seiten vorliegen,
|
eine **eigene Version**: `.wikitool-kb.json` sagt, in welcher Form die Seiten vorliegen,
|
||||||
unabhängig davon, welche Maschinerie danebensteht. Genau dieser Unterschied ist der Zustand, in
|
unabhängig davon, welche Maschinerie danebensteht. Genau dieser Unterschied ist der Zustand, in
|
||||||
dem sich jede Instanz mitten im Upgrade befindet.
|
dem sich jede Instanz mitten im Upgrade befindet.
|
||||||
|
|
||||||
1. **Vor dem Tausch** prüfen, was ansteht - solange `VERSION` noch die alte ist:
|
**Die Durchführung selbst steht in `instructions/upgrade-instance.md`** - die Reihenfolge, was
|
||||||
|
jeder Schritt entscheidet, wo die Agent-Sitzung neu gestartet werden muss, und die beiden Stellen,
|
||||||
|
an denen heute Handarbeit nötig ist. Sie steht dort und nicht hier, weil sie von einer
|
||||||
|
Agent-Sitzung ausgeführt wird; eine zweite Fassung derselben Schrittfolge an dieser Stelle wäre
|
||||||
|
genau die Kopie, die irgendwann auseinanderläuft. Wer den Lauf selbst fahren will, liest dieselbe
|
||||||
|
Datei.
|
||||||
|
|
||||||
```bash
|
Was dieses Dokument beiträgt, ist die Entscheidung *davor* - welches Release, ob überhaupt, woher
|
||||||
tools/wikitool migrate status
|
der Tarball kommt (§ „Version und Updates" und Weg A oben) - und der eine Sonderfall, den die
|
||||||
```
|
Instruktion nicht abdecken kann, weil es sie dort noch nicht gibt:
|
||||||
|
|
||||||
Steht hier etwas aus, erst diese Migrationskette abschließen (Schritt 5 unten) - `dist upgrade`
|
**Beim ersten Sprung auf `4.5.0` oder höher gibt es `dist upgrade` in der Instanz noch nicht** -
|
||||||
verweigert den Tausch sonst von selbst.
|
es kam erst mit `4.5.0`. Dann das Werkzeug aus dem entpackten *neuen* Tarball verwenden, gegen die
|
||||||
|
alte Instanz gerichtet:
|
||||||
2. Release-Tarball herunterladen und die Release-Notes lesen (Weg A oben).
|
|
||||||
3. **Maschinerie tauschen:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tools/wikitool dist upgrade <tarball-oder-verzeichnis> --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
**Beim ersten Sprung auf `4.5.0` oder höher gibt es dieses Kommando in der Instanz noch
|
|
||||||
nicht** - es kam erst mit `4.5.0`. Dann das Werkzeug aus dem entpackten *neuen* Tarball
|
|
||||||
verwenden, gegen die alte Instanz gerichtet:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf chemenu-stack-<version>.tar.gz
|
tar -xzf chemenu-stack-<version>.tar.gz
|
||||||
@@ -214,47 +217,14 @@ dem sich jede Instanz mitten im Upgrade befindet.
|
|||||||
dist upgrade chemenu-stack-<version>.tar.gz --dry-run
|
dist upgrade chemenu-stack-<version>.tar.gz --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
`CHEMENU_ROOT` sagt dem Paket, auf welchen Korpus es zeigen soll (siehe § Konfiguration);
|
`CHEMENU_ROOT` sagt dem Paket, auf welchen Korpus es zeigen soll (siehe § Konfiguration); ohne die
|
||||||
ohne die Variable würde es den entpackten Tarball selbst für die Instanz halten. Ab dem
|
Variable würde es den entpackten Tarball selbst für die Instanz halten. Ab dem zweiten Upgrade
|
||||||
zweiten Upgrade trägt die Instanz das Kommando selbst und die kurze Form oben genügt.
|
trägt die Instanz Kommando und Instruktion selbst, und der normale Weg greift.
|
||||||
|
|
||||||
Klassifiziert jede Datei aus dem `files`-Block der neuen `.wikitool-release.json`:
|
Was `dist upgrade` dabei genau tut, klassifiziert und verweigert, steht in
|
||||||
unverändert seit der Installation, lokal verändert oder gelöscht, neu im Release, oder aus dem
|
[tools/CONTRACT.md](tools/CONTRACT.md) - einschließlich des vollständigen Fehlerkontrakts. Eine
|
||||||
Release entfallen - und druckt die Migrationskette, die nach dem Tausch aussteht, ohne sie
|
lokal veränderte Stack-Datei ist damit sichtbar, statt von Hand gegen die sha256-Summen im
|
||||||
auszuführen. Ohne `--dry-run` schreibt der Befehl; eine lokal veränderte oder gelöschte Datei
|
`files`-Block geprüft werden zu müssen - genau der Schritt, der vor `4.5.0` hier stand.
|
||||||
wird dabei **nie** stillschweigend überschrieben - der Lauf bricht mit der vollständigen Liste
|
|
||||||
ab, es sei denn `--keep-local` ist gesetzt (dann bleibt jede davon unangetastet, erneut
|
|
||||||
gemeldet). `--prune` entfernt zusätzlich Dateien, die der neue Release nicht mehr ausliefert
|
|
||||||
und die seit der Installation unverändert sind. Voraussetzungen: ein sauberer Arbeitsbaum
|
|
||||||
(kein Git-Repo ist ein WARN, keine Sperre), eine lokale `.wikitool-release.json` mit
|
|
||||||
`files`-Block (fehlt sie, siehe „Fallstricke" unten), und `.wikitool-kb.json` vorhanden.
|
|
||||||
Committet und pusht nichts (Invariante 5). Vollständiger Fehlerkontrakt:
|
|
||||||
[tools/CONTRACT.md](tools/CONTRACT.md).
|
|
||||||
|
|
||||||
Eine lokal veränderte Stack-Datei ist damit sichtbar, statt von Hand gegen die sha256-Summen
|
|
||||||
im `files`-Block geprüft werden zu müssen - genau der Schritt, der vor `4.5.0` hier stand.
|
|
||||||
4. Bei einer Kompatibilitätsgrenze (`dist upgrade` meldet sie laut) die Release-Notes vor dem
|
|
||||||
nächsten Schritt lesen: **Breaking Change:** und **Migration:** im Eintrag von
|
|
||||||
`tools/wikitool version notes` sagen, was aufhört zu funktionieren und ob der Korpus
|
|
||||||
umgeschrieben werden muss.
|
|
||||||
5. **Die Migrationskette abarbeiten.** `tools/wikitool migrate status` listet jetzt alle
|
|
||||||
offenen Migrationen in der Reihenfolge, in der sie laufen müssen - bei einem Sprung über
|
|
||||||
mehrere Versionen sind das mehrere. Für jede: das genannte Dokument unter
|
|
||||||
`instructions/migrations/` ausführen lassen (die Prozedur dazu ist
|
|
||||||
`instructions/migrate-corpus.md`), dann
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tools/wikitool migrate done <version>
|
|
||||||
```
|
|
||||||
|
|
||||||
`done` verweigert jede Version, die nicht das nächste Glied ist - eine übersprungene
|
|
||||||
Migration hinterlässt einen Korpus in einer Form, die keine Version beschreibt. Ein
|
|
||||||
abgebrochenes Upgrade wird durch erneutes `migrate status` fortgesetzt.
|
|
||||||
6. Prüfen: `tools/wikitool migrate verify --from <commit vor dem Tausch>`, dann `doctor`,
|
|
||||||
`docs verify`, `instructions verify` und `lint`. Zum Schluss
|
|
||||||
`tools/wikitool instructions sync` (die Skills sind Kopien) und die Agent-Session neu
|
|
||||||
starten. `dist upgrade` nennt diese Reihenfolge im eigenen Abschlussbericht, führt aber keinen
|
|
||||||
der Schritte selbst aus.
|
|
||||||
|
|
||||||
`doctor` warnt, solange `kb_version` hinter `VERSION` zurückliegt und noch Migrationen offen
|
`doctor` warnt, solange `kb_version` hinter `VERSION` zurückliegt und noch Migrationen offen
|
||||||
sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei ganz - dann einmalig
|
sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei ganz - dann einmalig
|
||||||
@@ -279,7 +249,7 @@ Ausnahmen (`kb/CONVENTIONS.md`, `kb/*/COLLECTION.md`, `.wikitool-kb.json`) in
|
|||||||
| Variable | Zweck | Fallback |
|
| Variable | Zweck | Fallback |
|
||||||
|----------|-------|----------|
|
|----------|-------|----------|
|
||||||
| `WIKI_AUTHOR` | Override für den Autornamen neuer Source-Seiten | `git config user.name` - fehlt beides, bricht `new` mit `ERROR` ab |
|
| `WIKI_AUTHOR` | Override für den Autornamen neuer Source-Seiten | `git config user.name` - fehlt beides, bricht `new` mit `ERROR` ab |
|
||||||
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
| `WIKITOOL_SESSION_ID` | Scopt das Iteration-Budget-Gate auf eine Aufgabe statt auf ein Terminal | Eine vom Harness selbst gesetzte Sitzungs-Variable, wo eine bekannt ist (z. B. `CLAUDE_CODE_SESSION_ID`), sonst die Parent-Process-ID (siehe [instructions/session-setup.md](instructions/session-setup.md)) |
|
||||||
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
| `WIKITOOL_UPDATE_URL` | Release-Feed, den `version check` abfragt | Wert aus `.wikitool-release.json`, sonst der Feed der Ursprungs-Instanz |
|
||||||
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
| `WIKITOOL_UPDATE_TOKEN` | Gitea-Token für den Release-Feed | keiner - gegen `torben/chemenu` nicht nötig, nur für einen privaten Fork (siehe unten) |
|
||||||
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
| `CHEMENU_ROOT` | Auf welchen Korpus das Paket zeigt - für einen Aufrufer, der nicht im Checkout selbst liegt | der Checkout, in dem das Paket liegt (`tools/wikitool` verhält sich ohne die Variable unverändert) |
|
||||||
|
|||||||
@@ -51,6 +51,28 @@ The Iteration Budget Gate asks a fourth kind of question - not "is this instance
|
|||||||
itself (call count, repeated identical calls), not from anything about the content of any one
|
itself (call count, repeated identical calls), not from anything about the content of any one
|
||||||
call.
|
call.
|
||||||
|
|
||||||
|
## A gate in code still has to be reachable
|
||||||
|
|
||||||
|
Code beats prose for the reason above, but on its own it buys less than it looks like: a check
|
||||||
|
that runs on every call is only as good as the thing it counts under. The Iteration Budget Gate
|
||||||
|
scopes its counter to a session, and "session" was approximated by the parent process id whenever
|
||||||
|
nothing set an explicit one. On a harness that runs every tool call in a freshly initialised
|
||||||
|
shell, that approximation hands out a new session per call - so a traced run of thirty-three calls
|
||||||
|
arrived as twenty-one sessions of one to three calls each, the ceiling of sixty was never
|
||||||
|
approached, and the loop-breaker's window never held three calls at once to compare. The gate ran
|
||||||
|
on every one of those calls, exactly as written, and refused nothing.
|
||||||
|
|
||||||
|
That failure has no symptom of its own. A gate that fires announces that it exists; a gate that
|
||||||
|
*cannot* fire looks identical to a gate nobody happened to need - the same clean runs, the same
|
||||||
|
silence - and what finally told the two apart was reading a trace for an unrelated reason. So
|
||||||
|
there is a third property to keep alongside living in code and carrying measured numbers: each
|
||||||
|
gate has to leave evidence that it can still fire. The three that clear by token or by a
|
||||||
|
deliberate edit have it by construction, because clearing one is a visible event in somebody's
|
||||||
|
terminal. The budget gate, whose ordinary outcome is silence, is the one that had to be given
|
||||||
|
it - which is why its session id now carries where it came from, into both the trace and
|
||||||
|
`budget status`, so a session's own record answers the question instead of an investigation
|
||||||
|
having to.
|
||||||
|
|
||||||
## Numbers that come from measurement, not intuition
|
## Numbers that come from measurement, not intuition
|
||||||
|
|
||||||
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ produces; `migration_kind:` (`mechanical` | `assisted`); and `obligation:`
|
|||||||
(`required` | `offered`, default `required`). It lives at
|
(`required` | `offered`, default `required`). It lives at
|
||||||
`instructions/migrations/<version>-<slug>.md`.
|
`instructions/migrations/<version>-<slug>.md`.
|
||||||
|
|
||||||
|
`wikitool new instruction` scaffolds none of the three: `migrates_to:` and `migration_kind:`
|
||||||
|
have no schema `default:` at all, and an ordinary instruction's scaffold no longer materializes
|
||||||
|
`obligation:`'s default either - all three are added by hand when a migration document is
|
||||||
|
written, per [migrate-corpus.md](migrate-corpus.md).
|
||||||
|
|
||||||
`migration_kind:` and `obligation:` are **two axes, not one**. The first says how the work is
|
`migration_kind:` and `obligation:` are **two axes, not one**. The first says how the work is
|
||||||
carried out, the second whether it has to happen at all:
|
carried out, the second whether it has to happen at all:
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,16 @@ Write it for a reader who has the new machinery and the old content, and who is
|
|||||||
changed, which pages are affected, how to tell a migrated page from an unmigrated one, and what
|
changed, which pages are affected, how to tell a migrated page from an unmigrated one, and what
|
||||||
`migrate verify` should report when it is done.
|
`migrate verify` should report when it is done.
|
||||||
|
|
||||||
|
**A verification step names its own baseline, and does it in an earlier step.** Where the
|
||||||
|
document asks that something "read the same as before" - a composed `types describe` answer, a
|
||||||
|
rendered index, any command's output - it says what to capture, where to put it, and at which
|
||||||
|
point, so the check is a `diff` rather than a memory. Step 4's `migrate verify` needs none of
|
||||||
|
that: its baseline is the last commit, which git holds whether or not anyone thought to keep it.
|
||||||
|
A migration that changes machinery rather than `kb/` pages has no such baseline, and that is
|
||||||
|
exactly where the unfalsifiable version has already slipped through - the 6.0.0 type-guidance
|
||||||
|
split asked for output that "must read the same", named nothing to compare it against, and a
|
||||||
|
stray section in the middle of one type-spec survived a check made in good faith.
|
||||||
|
|
||||||
**Baseline: 1.0.0.** Migrations that predate it - the type-system move, the `confidence_base`
|
**Baseline: 1.0.0.** Migrations that predate it - the type-system move, the `confidence_base`
|
||||||
backfill, the German section headings, the translation itself - have no documents and will not
|
backfill, the German section headings, the translation itself - have no documents and will not
|
||||||
get any. An instance older than that is re-exported, not migrated.
|
get any. An instance older than that is re-exported, not migrated.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ upgrade(s) available"; taking it is not gated on anything else being current.
|
|||||||
shipped default.** Compare the type-spec's current prose (everything outside `## Frontmatter`
|
shipped default.** Compare the type-spec's current prose (everything outside `## Frontmatter`
|
||||||
and `## Template`) against the corresponding `types/<name>.guidance.md`:
|
and `## Template`) against the corresponding `types/<name>.guidance.md`:
|
||||||
|
|
||||||
- **Unchanged, or changed only in ways this instance is happy to lose:** proceed to step 3
|
- **Unchanged, or changed only in ways this instance is happy to lose:** proceed to step 4
|
||||||
directly - the new guidance file already carries the improved version.
|
directly - the new guidance file already carries the improved version.
|
||||||
- **Locally edited in a way worth keeping** (a house style note, an extra rule specific to
|
- **Locally edited in a way worth keeping** (a house style note, an extra rule specific to
|
||||||
this corpus): that edit has to move somewhere before the old prose is dropped. Either fold
|
this corpus): that edit has to move somewhere before the old prose is dropped. Either fold
|
||||||
@@ -65,28 +65,69 @@ upgrade(s) available"; taking it is not gated on anything else being current.
|
|||||||
instead of adding `guidance:` at all - both are legitimate; declining the stack default for
|
instead of adding `guidance:` at all - both are legitimate; declining the stack default for
|
||||||
one type is not an error.
|
one type is not an error.
|
||||||
|
|
||||||
3. **Add `guidance: types/<name>.guidance.md` to the type-spec's frontmatter** - by hand, the same
|
3. **Write down what `types describe` answers today, before changing anything.** Step 6 checks
|
||||||
|
that the composed answer still reads the same, and that is only a check if the "before" was
|
||||||
|
recorded somewhere other than your memory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool types describe <name> > /tmp/<name>-before.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The whole output, per type-spec you are about to touch. Reading it through `head` or `tail`
|
||||||
|
instead is how a difference in the middle of a 150-line answer survives the check - and a
|
||||||
|
stray section in the middle of one type-spec is exactly what this step exists to catch.
|
||||||
|
|
||||||
|
4. **Add `guidance: types/<name>.guidance.md` to the type-spec's frontmatter** - by hand, the same
|
||||||
way any other type-spec frontmatter field is written (a type-spec is machinery, not a `kb/`
|
way any other type-spec frontmatter field is written (a type-spec is machinery, not a `kb/`
|
||||||
page, so this is not a `wikitool touch` call). Do not remove `## Frontmatter` or `## Template`;
|
page, so this is not a `wikitool touch` call). Do not remove `## Frontmatter` or `## Template`;
|
||||||
only the generic prose around them is what the guidance file now carries.
|
only the generic prose around them is what the guidance file now carries.
|
||||||
|
|
||||||
4. **Delete the now-duplicated prose from the type-spec**, keeping the H1, a short pointer to the
|
5. **Delete the now-duplicated prose from the type-spec**, keeping the H1, a short pointer to the
|
||||||
guidance file (`types/entity.md`'s own current text is the worked example), `## Frontmatter`
|
guidance file, `## Frontmatter` and `## Template`. Where step 2 found a local edit worth
|
||||||
and `## Template`. Where step 2 found a local edit worth keeping and it lives in the
|
keeping and it lives in the type-spec's own body rather than a private guidance file, leave
|
||||||
type-spec's own body rather than a private guidance file, leave that part exactly where it is.
|
that part exactly where it is.
|
||||||
|
|
||||||
5. **Verify:**
|
**The worked example is `types/<name>.md.template`, not `types/<name>.md`.** The latter is the
|
||||||
|
copy this instance adopted at setup - it is the file you are editing, so it still shows the
|
||||||
|
before-state. The `.template` beside it ships verbatim with every release and already carries
|
||||||
|
the after-state: H1, pointer paragraph, and `guidance:` in the frontmatter. Read it for the
|
||||||
|
shape; do not copy it wholesale, because its `## Frontmatter` and `## Template` are the
|
||||||
|
stack's defaults and yours are yours.
|
||||||
|
|
||||||
|
**The pointer paragraph is written in English**, like the H1 above it. It is authoring prose
|
||||||
|
addressed to an agent, so it belongs to the control plane whether or not this instance owns
|
||||||
|
the file it sits in - and so does any prose you keep beside it. A local note written in this
|
||||||
|
instance's KB language before that rule existed is therefore translated, not relabelled:
|
||||||
|
an English heading over a body in another language is the half-done version of this step.
|
||||||
|
[types/type-spec.md](../../types/type-spec.md#who-owns-a-type-spec) has the part-by-part
|
||||||
|
table; `## Frontmatter` and `## Template` are untouched by this migration either way.
|
||||||
|
|
||||||
|
**Do not head a kept note `## Authoring guidance`.** `types describe` sets that heading itself
|
||||||
|
and inlines the guidance file beneath it, which brings its own - so a third one out of the
|
||||||
|
type-spec's body reads as a duplicated section in the composed answer. Give a local note a
|
||||||
|
name of its own.
|
||||||
|
|
||||||
|
6. **Verify against the file from step 3:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool types describe <name>
|
tools/wikitool types describe <name> > /tmp/<name>-after.txt
|
||||||
|
diff /tmp/<name>-before.txt /tmp/<name>-after.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
The output must read the same as it did before this migration - the guidance prose composed
|
The two must read the same - the guidance prose composed ahead of the type-spec's own body,
|
||||||
ahead of the type-spec's own body, in one answer. A diff against the pre-migration output of
|
in one answer. Wording differences are expected only where step 2 found something to drop or
|
||||||
the same command, restricted to wording, is expected only where step 2 found something to
|
fold in; the structure (frontmatter fields, template block) must be byte-identical, and a
|
||||||
drop or fold in; the structure (frontmatter fields, template block) must be byte-identical.
|
heading that stands in the "after" but not in the "before" means prose was renamed where it
|
||||||
|
should have been removed. One number catches the most likely version of that:
|
||||||
|
|
||||||
6. **Record it:**
|
```bash
|
||||||
|
grep -c '^## Authoring guidance' /tmp/<name>-after.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Two is correct - the one `types describe` sets, and the one the guidance file brings. Three
|
||||||
|
means the type-spec's own body still carries a section of that name (step 5).
|
||||||
|
|
||||||
|
7. **Record it:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool migrate done 6.0.0 --pages 0
|
tools/wikitool migrate done 6.0.0 --pages 0
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ description: Scope the wikitool iteration budget to the task by exporting a stab
|
|||||||
# Scope the session budget
|
# Scope the session budget
|
||||||
|
|
||||||
Every `wikitool` call is counted against a per-session iteration budget. A "session" is keyed
|
Every `wikitool` call is counted against a per-session iteration budget. A "session" is keyed
|
||||||
by `WIKITOOL_SESSION_ID`, falling back to the parent process id when that variable is unset.
|
by a fallback chain (`chemenu.session`): `WIKITOOL_SESSION_ID` first, then a harness's own
|
||||||
|
session variable where one is registered (`CLAUDE_CODE_SESSION_ID` today), then the parent
|
||||||
|
process id.
|
||||||
|
|
||||||
Without an explicit id, the budget is scoped to whichever shell happened to run the command,
|
Without an explicit id, and on a harness with no registered variable, the budget is scoped to
|
||||||
so a task spanning several terminals is counted as several sessions - and one that reuses a
|
whichever shell happened to run the command, so a task spanning several terminals is counted as
|
||||||
shell inherits an unrelated count.
|
several sessions - and one that reuses a shell inherits an unrelated count.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
@@ -23,8 +25,33 @@ export WIKITOOL_SESSION_ID="wiki-$(date +%s)"
|
|||||||
tools/wikitool sync
|
tools/wikitool sync
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**An `export` only carries if the shell carries.** Several agent harnesses run every tool call in
|
||||||
|
a freshly initialised shell: the working directory survives, shell state - environment variables,
|
||||||
|
functions - does not, so the variable is gone by the next call and each call falls back to whatever
|
||||||
|
the chain's next step resolves to.
|
||||||
|
|
||||||
|
On a harness with a registered variable in that chain (Claude Code, via `CLAUDE_CODE_SESSION_ID`),
|
||||||
|
the fallback already keeps every call in one bucket without this step - but it scopes to the
|
||||||
|
*whole* harness session, not to this one task, so a long-running session can carry unrelated prior
|
||||||
|
work into the same count. Setting `WIKITOOL_SESSION_ID` explicitly still narrows the bucket to the
|
||||||
|
task at hand, and remains the only way to scope it at all on a harness with no registered
|
||||||
|
variable - each call falls back to its own parent pid there, and neither the 60-call ceiling nor
|
||||||
|
the loop-breaker can ever trip (measured directly on a real upgrade run: 33 `wikitool` calls in
|
||||||
|
one task split into 21 telemetry buckets under the pid fallback alone). On such a harness, pass
|
||||||
|
the id **inline on every call** instead of `export`, keeping the same value for the whole task:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool sync
|
||||||
|
WIKITOOL_SESSION_ID="wiki-1234" tools/wikitool new entity --name "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Which of the three applies is answerable in one call: run `tools/wikitool budget status` twice in
|
||||||
|
separate calls, and see whether it names the same id both times, and where that id came from -
|
||||||
|
`budget status` prints both.
|
||||||
|
|
||||||
Check the current state at any time with `tools/wikitool budget status`, which is never
|
Check the current state at any time with `tools/wikitool budget status`, which is never
|
||||||
counted against the budget itself and prints the id it is counting under.
|
counted against the budget itself and prints the id it is counting under, and its origin
|
||||||
|
(`WIKITOOL_SESSION_ID`, a named harness variable, or the parent-pid fallback).
|
||||||
|
|
||||||
**Why `sync` here, not just at publish time.** `publish` already pulls before it pushes, but a
|
**Why `sync` here, not just at publish time.** `publish` already pulls before it pushes, but a
|
||||||
session that runs many `wikitool` calls before its first `publish` (an ingest, a multi-page
|
session that runs many `wikitool` calls before its first `publish` (an ingest, a multi-page
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
type: types/instruction.md
|
||||||
|
name: upgrade-instance
|
||||||
|
description: Carry out a stack release upgrade on an instance built from a tarball - read this release's notes, swap the machinery with dist upgrade, work the migration chain, verify, publish, and restart the session at the point where the new control plane starts to matter.
|
||||||
|
manual: true
|
||||||
|
---
|
||||||
|
# Upgrade this instance to a new stack release
|
||||||
|
|
||||||
|
An instance built from a `dist export` tarball takes stack updates by copying a newer release
|
||||||
|
over its machinery. This is the order in which that happens, what each step decides, and where
|
||||||
|
the two known rough edges are. It ends with the instance on the new `VERSION`, its content
|
||||||
|
version recorded, every check green, and the change published.
|
||||||
|
|
||||||
|
**This is the tarball path.** An instance that is a *clone* of the origin repo, sharing git
|
||||||
|
history, takes updates by three-way merge (`tools/wikitool upstream merge`) and follows
|
||||||
|
[private-instance.md](private-instance.md) instead. `git remote -v` answers which one this is:
|
||||||
|
a clone carries an `upstream` remote pointing at the origin.
|
||||||
|
|
||||||
|
**One thing this file deliberately does not know.** The copy you are reading shipped with the
|
||||||
|
release this instance is *leaving*, not the one it is going to - so nothing specific to a
|
||||||
|
particular jump is written here. That belongs to the release notes (step 2) and to the migration
|
||||||
|
documents that arrive inside the tarball.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [When to run](#when-to-run)
|
||||||
|
- [Steps](#steps)
|
||||||
|
- [Decision points](#decision-points)
|
||||||
|
- [Scope](#scope)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
|
## When to run
|
||||||
|
|
||||||
|
- `tools/wikitool version check` reports `state: update` or `state: migration`, and the operator
|
||||||
|
wants the new release installed.
|
||||||
|
- An operator asks for the stack, the tooling or "the wiki software" to be brought up to date.
|
||||||
|
- An interrupted upgrade is being resumed. Do not restart from step 1: `migrate status` and
|
||||||
|
`dist upgrade --dry-run` both report the true state, and the step that matches what they say
|
||||||
|
is where this run continues.
|
||||||
|
|
||||||
|
Not for setting up a new instance ([setup-instance.md](setup-instance.md)), not for preparing a
|
||||||
|
fresh clone ([bootstrap.md](bootstrap.md)), and not for the clone-with-upstream path above.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Take a session id and pass it on every call for the whole upgrade** - the form and the
|
||||||
|
reason are in [session-setup.md](session-setup.md). An upgrade is one of the longest runs
|
||||||
|
this stack has, and the iteration budget only sees it as one run if every call carries the
|
||||||
|
same id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WIKITOOL_SESSION_ID=upgrade-<target-version> tools/wikitool version check
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Read this release's notes before touching anything.** Two lines decide the rest of the run:
|
||||||
|
**Breaking Change:** says what stops working and what this instance must do about it, and
|
||||||
|
**Migration:** says whether the corpus has to be rewritten (`none required` when it does not).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool version notes
|
||||||
|
```
|
||||||
|
|
||||||
|
On an instance this answers out of the release feed, not out of the local `CHANGES.md` - that
|
||||||
|
file arrives as a stub with no version entries and `dist upgrade` never overwrites it, so the
|
||||||
|
command reads the notes off the release the feed publishes instead. Two things follow that are
|
||||||
|
worth knowing before reading the output. It can only ask for the feed's *latest* release, so
|
||||||
|
while `VERSION` still names the release being left, the version it answers with is **not** the
|
||||||
|
one this tree declares - it says so on stderr, and that is the normal shape here rather than a
|
||||||
|
fault. And if the feed cannot be reached, the error names the release page from
|
||||||
|
`.wikitool-release.json`'s `release_url`; read it there and continue.
|
||||||
|
|
||||||
|
3. **Ask what is already outstanding, while `VERSION` is still the old one:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool migrate status
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything in the outstanding chain is finished **before** the swap - `dist upgrade` refuses
|
||||||
|
otherwise, and a chain that was already owed is not this release's business. The procedure is
|
||||||
|
step 12's, run against the migration documents this instance already has. An `offered` upgrade
|
||||||
|
listed separately blocks nothing and is decided later, in step 12.
|
||||||
|
|
||||||
|
4. **Fetch the tarball and verify it.** `dist upgrade` downloads nothing; the file has to be
|
||||||
|
there already. Take the `.tar.gz` and its `.sha256` from the release page found in step 2 and
|
||||||
|
check them before unpacking. A tarball must unpack to exactly one top-level directory.
|
||||||
|
|
||||||
|
5. **Dry-run the swap and read all four counts:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool dist upgrade <tarball> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
`unchanged` / `new` / `locally changed` / `removed from the release`. The first two need no
|
||||||
|
decision. `locally changed` is step 6. `removed` matters only if `--prune` is wanted, which is
|
||||||
|
optional and never required.
|
||||||
|
|
||||||
|
6. **Only if a file is reported as locally changed: decide whose file it is, then reconcile it.**
|
||||||
|
The classification is against the sha256 the *installed* release recorded, so "locally
|
||||||
|
changed" means the working tree differs from what this instance was given - deliberately or
|
||||||
|
by a stray editor save.
|
||||||
|
|
||||||
|
| Whose file | What to do |
|
||||||
|
|---|---|
|
||||||
|
| The instance's own | Cannot appear here, which is worth knowing so a report that looks like it is read again rather than acted on: a file the instance owns either ships only as `<name>.template` (`kb/CONVENTIONS.md`, each `COLLECTION.md`, `USER.md`/`SOUL.md`/`ENVIRONMENT.md`) and is never classified at all, or is seeded once and then kept out of the write set (`.wikitool-kb.json`, `CHANGES.md`) |
|
||||||
|
| Machinery (a `CONTRACT.md`, anything under `tools/`, `types/`, `instructions/`, `AGENTS.md`, and every `<name>.template` beside an owned file) | It should not have local changes at all. Take the release's version: `--take-release <path>`, one per file |
|
||||||
|
| Machinery this instance changed **on purpose** | `--keep-local` keeps every listed file untouched - but the new stamp records the release digest anyway, so the same file is reported again at every future upgrade. That is the right answer only for a difference the instance intends to carry indefinitely |
|
||||||
|
|
||||||
|
The decision is per path, and the two flags compose - which is what a mixed report needs, one
|
||||||
|
file reset and another kept. Preview it before it writes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool dist upgrade <tarball> --dry-run --take-release <path> [--take-release <path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview marks every named path as one it would overwrite from the release, and a path that
|
||||||
|
is not actually in the locally-changed list is refused *here* rather than in the writing run.
|
||||||
|
Nothing else is needed: no copy out of the unpacked tarball by hand, and no commit made only
|
||||||
|
to satisfy the next command's clean-tree precondition. Carry the flags you settled on into
|
||||||
|
step 7.
|
||||||
|
|
||||||
|
**Where `--keep-local` answers for some paths and `--take-release` for others, both go on the
|
||||||
|
same call.** Without `--keep-local`, a locally changed path that no `--take-release` names
|
||||||
|
still aborts the run: every one of them has to be answered for, and the abort's own text
|
||||||
|
names the three answers with the command line already filled in.
|
||||||
|
|
||||||
|
7. **Swap the machinery**, with whatever step 6 settled on. Note the commit the instance is on
|
||||||
|
first - step 13 compares against it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse --short HEAD # the pre-swap commit; keep it
|
||||||
|
tools/wikitool dist upgrade <tarball> [--take-release <path>] [--keep-local]
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes, and commits nothing.
|
||||||
|
|
||||||
|
8. **Republish the skills.** `tools/wikitool instructions sync` - the published skill directories
|
||||||
|
are copies, so until this runs the harness is still offering the previous release's skills.
|
||||||
|
|
||||||
|
9. **Verify the machinery, and fix what the release said would need fixing:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool doctor
|
||||||
|
tools/wikitool docs verify
|
||||||
|
tools/wikitool instructions verify
|
||||||
|
tools/wikitool lint
|
||||||
|
```
|
||||||
|
|
||||||
|
A `docs verify` failure naming a missing or stale table of contents is repaired with
|
||||||
|
`tools/wikitool docs toc --apply`, never by hand - a release that widened the set of files
|
||||||
|
carrying a region will produce exactly that on files this instance adopted before the
|
||||||
|
widening. Any other failure is read against step 2's **Breaking Change:** line: if the release
|
||||||
|
predicted it, the notes also say what fixes it; if it did not, stop and report it rather than
|
||||||
|
improvising.
|
||||||
|
|
||||||
|
10. **Publish the machinery swap.** A release swap is far above the Mass-Update Gate's threshold,
|
||||||
|
so expect exit 42. That is not an error and not yours to clear: reproduce the file breakdown
|
||||||
|
it prints for the operator, stop, and publish with the token it named once they have
|
||||||
|
approved it. See [gates.md](gates.md).
|
||||||
|
|
||||||
|
Publishing here, before the content migrations, is deliberate. The intermediate state -
|
||||||
|
new machinery, content still at the old shape - is a state the stack names rather than
|
||||||
|
avoids (`.wikitool-kb.json` records it), and it keeps a 200-file swap out of the same commit
|
||||||
|
as a content rewrite.
|
||||||
|
|
||||||
|
11. **Restart the agent session.** Everything the previous steps replaced - `AGENTS.md`, the
|
||||||
|
contracts, the type-specs, the skills - is still in the running session's context in its
|
||||||
|
*old* form. A migration document written against a rule that arrived in this release will
|
||||||
|
otherwise be carried out against the rule it replaced, and nothing checks that.
|
||||||
|
|
||||||
|
The new session resumes at step 12. `tools/wikitool migrate status` is the resume point:
|
||||||
|
it is stateful, so it says what is left without being told what already happened.
|
||||||
|
|
||||||
|
12. **Work the migration chain.** `tools/wikitool migrate status` lists what is outstanding, in
|
||||||
|
the order it has to run - a jump across several releases lists several. For each one, run
|
||||||
|
the named document under `instructions/migrations/` following
|
||||||
|
[migrate-corpus.md](migrate-corpus.md), then record it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool migrate done <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
An `offered` migration is a separate decision, not part of the chain: it changes a file this
|
||||||
|
instance owns, blocks nothing, and recording it does not move `kb_version`. Take it or
|
||||||
|
decline it deliberately; both are correct answers.
|
||||||
|
|
||||||
|
**Whatever the migration changes, capture the before.** Where a document asks that some
|
||||||
|
command's output "read the same as before", that is only checkable if the before was written
|
||||||
|
down - redirect it to a file first and `diff` afterwards, rather than reading two long
|
||||||
|
outputs from memory. Reading either one through `head` or `tail` is how a difference in the
|
||||||
|
middle survives the check.
|
||||||
|
|
||||||
|
13. **Verify the content, then publish.** Only after the chain has run, and against the commit
|
||||||
|
noted in step 7:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool migrate verify --from <pre-swap commit>
|
||||||
|
tools/wikitool lint
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate verify` is the only check that sees a page which lost a citation, a wikilink or a
|
||||||
|
generated-region marker in the rewrite - `lint` reports a corpus that is internally
|
||||||
|
consistent, which a corpus that quietly lost something still is. Then publish, the same way
|
||||||
|
as in step 10.
|
||||||
|
|
||||||
|
## Decision points
|
||||||
|
|
||||||
|
- **`version check` reports `state: migration` (a compatibility boundary)?** That is a statement
|
||||||
|
about the machinery being a drop-in replacement, not about the corpus. A boundary crossing with
|
||||||
|
an empty migration chain is normal and means the hand-work is elsewhere - which is precisely
|
||||||
|
what step 2's **Breaking Change:** line names.
|
||||||
|
- **`dist upgrade` refuses because the tree is not clean?** Commit or stash what is there first,
|
||||||
|
and look at what it is: work in progress is committed through `publish`, an editor's stray
|
||||||
|
reformatting of machinery is step 6's case.
|
||||||
|
- **A required migration cannot be completed now?** Stop after step 10 and leave it. The
|
||||||
|
intermediate state is legitimate and `migrate status` resumes it; what is not legitimate is
|
||||||
|
recording a migration with `migrate done` that was not carried out - the version then describes
|
||||||
|
a shape the corpus is not in.
|
||||||
|
- **`doctor` reports `kb-version` behind `VERSION` after everything is done?** Correct when the
|
||||||
|
release's chain was empty or carried only `offered` entries: an offer changes a file the
|
||||||
|
instance owns, not the shape of its content, so the content version stays where it was.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
For an instance that receives releases as tarballs. Not the origin repo, which has no upgrade
|
||||||
|
path of its own, and not a clone with shared history - see the second paragraph. Anything about
|
||||||
|
*writing* a migration document rather than running one is
|
||||||
|
[migrate-corpus.md](migrate-corpus.md) § "Writing the migration document".
|
||||||
|
|
||||||
|
What a human decides before any of this starts - which release, whether to take it at all, where
|
||||||
|
the tarball comes from - is [INSTALL.md](../INSTALL.md) § "Version und Updates".
|
||||||
@@ -3,7 +3,7 @@ type: types/entity.md
|
|||||||
entity_type: project
|
entity_type: project
|
||||||
tags: [wiki, llm, knowledge-base]
|
tags: [wiki, llm, knowledge-base]
|
||||||
created: 2026-08-04
|
created: 2026-08-04
|
||||||
modified: 2026-09-02
|
modified: 2026-09-17
|
||||||
related:
|
related:
|
||||||
- implements: Personalization Plane
|
- implements: Personalization Plane
|
||||||
- implements: Issue Label Scheme
|
- implements: Issue Label Scheme
|
||||||
@@ -56,7 +56,7 @@ Das Repository hat bereits ein deterministisches CLI, `tools/wikitool` (Python,
|
|||||||
- **Verantwortlich:** Torben
|
- **Verantwortlich:** Torben
|
||||||
- **Lizenz:** AGPL-3.0 (Stack: `tools/`, `types/`), CC-BY-4.0 (Inhalte)
|
- **Lizenz:** AGPL-3.0 (Stack: `tools/`, `types/`), CC-BY-4.0 (Inhalte)
|
||||||
- **Repository:** `torben/chemenu` auf gitea.nehmer.net; bis 2026-09-01 `torben/llm-wiki-test1`
|
- **Repository:** `torben/chemenu` auf gitea.nehmer.net; bis 2026-09-01 `torben/llm-wiki-test1`
|
||||||
- **Architektur:** Dreilagig: raw/ (Quelle), wiki/ (Wissen), tools/ (deterministisches CLI)
|
- **Architektur:** Dreilagig: raw/ (Quelle), kb/ (Wissen), tools/ (deterministisches CLI)
|
||||||
|
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
|
|||||||
@@ -209,3 +209,9 @@ Alle Checklistenpunkte erledigt: 29 Seiten getouched, `migrate verify` 0 finding
|
|||||||
Korpusmigration zu #86/#60: confidence/confidence_base aus allen 152 betroffenen Entity-/Concept-Seiten entfernt (types/entity.schema.yaml, types/concept.schema.yaml deklarieren additionalProperties: false seit dem Stack-Teil von #60). Vier Einheiten entlang bestehender Area-Verzeichnisse (u1 kb/concepts/architectures+decisions+protocols+problems: 35, u2 kb/concepts/patterns+workflows: 45, u3 kb/entities/tools+people: 35, u4 kb/entities/technologies+projects+systems: 37), je per Skript work/confidence-removal/strip_confidence.py ueber chemenu.frontmatter_io.read_page/write_page (nie von Hand). migrate verify --from HEAD --fail-on-error zeigt fuer alle vier Einheiten 0 Befunde - modified:, Body, Referenzarrays und Feldreihenfolge unveraendert. lint --fail-on-error: 0 schema_validation_errors (voller Report unter reports/Lint Report 2026-09-10.md; die dort gemeldeten redundant_see_also-Funde sind vorbestehend, advisory und unabhaengig von dieser Migration). Ein Body-Treffer bleibt bewusst bestehen: kb/concepts/patterns/Confidence Scoring.md zitiert 'confidence: 0.XX' als YAML-Beispiel innerhalb eines Code-Blocks - das ist Content ueber das Pattern selbst, kein Frontmatter-Feld dieser Seite, und liegt ausserhalb des Body-unberuehrt-Scopes von #86. migrate done 5.0.0 --pages 152 gesetzt, kb_version steht auf 5.0.0. Workshop work/confidence-removal/ nach work/CONTRACT.md geschlossen und geloescht; die dauerhafte Ausgabe ist der bereinigte Korpus selbst. Naechster Schritt: ein gemeinsamer publish mit den Stack-Aenderungen aus #60 (kein eigener Publish fuer diese Einheit, siehe #60 Sequencing).
|
Korpusmigration zu #86/#60: confidence/confidence_base aus allen 152 betroffenen Entity-/Concept-Seiten entfernt (types/entity.schema.yaml, types/concept.schema.yaml deklarieren additionalProperties: false seit dem Stack-Teil von #60). Vier Einheiten entlang bestehender Area-Verzeichnisse (u1 kb/concepts/architectures+decisions+protocols+problems: 35, u2 kb/concepts/patterns+workflows: 45, u3 kb/entities/tools+people: 35, u4 kb/entities/technologies+projects+systems: 37), je per Skript work/confidence-removal/strip_confidence.py ueber chemenu.frontmatter_io.read_page/write_page (nie von Hand). migrate verify --from HEAD --fail-on-error zeigt fuer alle vier Einheiten 0 Befunde - modified:, Body, Referenzarrays und Feldreihenfolge unveraendert. lint --fail-on-error: 0 schema_validation_errors (voller Report unter reports/Lint Report 2026-09-10.md; die dort gemeldeten redundant_see_also-Funde sind vorbestehend, advisory und unabhaengig von dieser Migration). Ein Body-Treffer bleibt bewusst bestehen: kb/concepts/patterns/Confidence Scoring.md zitiert 'confidence: 0.XX' als YAML-Beispiel innerhalb eines Code-Blocks - das ist Content ueber das Pattern selbst, kein Frontmatter-Feld dieser Seite, und liegt ausserhalb des Body-unberuehrt-Scopes von #86. migrate done 5.0.0 --pages 152 gesetzt, kb_version steht auf 5.0.0. Workshop work/confidence-removal/ nach work/CONTRACT.md geschlossen und geloescht; die dauerhafte Ausgabe ist der bereinigte Korpus selbst. Naechster Schritt: ein gemeinsamer publish mit den Stack-Aenderungen aus #60 (kein eigener Publish fuer diese Einheit, siehe #60 Sequencing).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2026-09-17] update | Chemenu - Pfad kb/ in den Kerndaten korrigiert
|
||||||
|
|
||||||
|
Die Kerndaten-Zeile "Architektur" nannte noch `wiki/` als Wissensschicht; das Verzeichnis heisst seit der Umbenennung am 2026-08-21 `kb/`. Nur der Pfad wurde nachgezogen - "Dreilagig" bleibt stehen, weil es sich mit [[Three-Layer Architecture]] deckt, wo `reports/` als vierte Phase neben den drei Schichten gefuehrt wird. Teil eines Stack-Durchgangs, der dieselbe veraltete Zeichenkette an 27 Stellen unter tools/ und types/ beseitigt hat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+13
-19
@@ -89,7 +89,7 @@ tools/wikitool <command> --help
|
|||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, defaults, directory (`base_dir`/`layout`), title prefix, and template - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
|
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, directory (`base_dir`/`layout`), title prefix, and template - a schema `default:` is materialized only for a field the schema also lists in `required:` (an optional field's default is a reader-side assumption, not a scaffold-time value) - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
|
||||||
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
|
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
|
||||||
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
||||||
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
||||||
@@ -185,10 +185,10 @@ tools/wikitool <command> --help
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors `raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), the two flat anchors `raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step re-creating it), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
||||||
| `dist upgrade <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). See `INSTALL.md` § "Eine Instanz aktualisieren" |
|
| `dist upgrade <source> [--dry-run] [--keep-local] [--take-release <path>]... [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list, and its text names the three answers with the command line already filled in, so that no reader takes any of them for the default. `--keep-local` proceeds and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the release's version over the named path, discarding the local change, and re-creates it if it was locally deleted. The two are decided per path and compose on one call: without `--keep-local`, a locally changed path that no `--take-release` names still aborts the run. A `--take-release` path that this run does not report as locally changed is refused, in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a state of the tree, and a path that silently did nothing would report a successful upgrade while keeping the change it was asked to discard. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path taken with `--take-release` is the opposite case and the reason the flag exists: it was written, so it matches the digest the stamp records and stops being reported at all. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), it is a pre-release (`-beta.N`) without `--pre`, or `--take-release` names a path this run does not classify as locally changed. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" |
|
||||||
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
| `version show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
|
||||||
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). One of the **two** commands in `wikitool` that make a network call, and the only one whose whole job it is - `version notes` is the other, and only on a distributed instance. Never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
|
||||||
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
|
| `version notes [--version X.Y.Z] [--offline] [--url U] [--timeout S]` | Print one version's release notes (default: this tree's `VERSION`): the `CHANGES.md` entry where there is one, and where there is not, the feed's latest release notes. The fallback exists because an instance's `CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_preserved`), so the local file can never carry the entry - not today and not after any future release, which made the command permanently unanswerable exactly where the release notes are most needed. It is reached **only with a release stamp present**, i.e. only from a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed being asked, and the one naming the release that answered, go to stderr - `release.yml` redirects stdout into the file it posts as the release body. Only the feed's *latest* release can be asked for (`update_url` is the one URL a stamp records, and composing a by-tag URL out of it would be guessing at an API shape), so a returned version other than the one asked for is named on stderr and printed anyway - the expected shape before an upgrade, where `VERSION` still names the release being left. `--offline` refuses the call and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate |
|
||||||
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||||
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
||||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||||
@@ -214,7 +214,7 @@ tools/wikitool <command> --help
|
|||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), `WIKITOOL_SESSION_ID`, and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
@@ -268,9 +268,12 @@ tools/wikitool <command> --help
|
|||||||
section): every invocation is recorded and checked in `main()` (`cli.py`)
|
section): every invocation is recorded and checked in `main()` (`cli.py`)
|
||||||
before Typer dispatches to any subcommand, so it applies uniformly without
|
before Typer dispatches to any subcommand, so it applies uniformly without
|
||||||
each command needing its own opt-in. State lives in the gitignored
|
each command needing its own opt-in. State lives in the gitignored
|
||||||
`tools/.wikitool_session/budget.json`, keyed by `WIKITOOL_SESSION_ID` (or
|
`tools/.wikitool_session/budget.json`, keyed by `chemenu.session`'s fallback
|
||||||
the caller's parent process id as a fallback), so a new terminal/session
|
chain (`WIKITOOL_SESSION_ID`, else a registered harness session variable,
|
||||||
starts with a clean budget. Default ceiling: 60 calls/session, or 3
|
else the caller's parent process id), so a new terminal/session starts with
|
||||||
|
a clean budget - and a bucket whose recorded origin no longer matches the
|
||||||
|
current one starts a fresh count rather than inheriting a stranger's.
|
||||||
|
Default ceiling: 60 calls/session, or 3
|
||||||
identical calls in a row (whichever trips first). A call that left through
|
identical calls in a row (whichever trips first). A call that left through
|
||||||
`_util.fail()` - a rejected argument, or a read-only check reporting
|
`_util.fail()` - a rejected argument, or a read-only check reporting
|
||||||
findings - is refunded: it declined instead of acting, and the contract's own
|
findings - is refunded: it declined instead of acting, and the contract's own
|
||||||
@@ -396,10 +399,10 @@ is atomic, and whether a retry is safe.
|
|||||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||||
|---------|--------------|---------|--------------|
|
|---------|--------------|---------|--------------|
|
||||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||||
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, a `--take-release` path that is not classified as locally changed (the one refusal a `--dry-run` also raises), or one or more locally changed files that neither `--keep-local` nor a `--take-release` answers for | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For a rejected `--take-release` path: correct it against the locally-changed list the refusal prints. For locally changed files, the refusal names all three answers with the re-run line filled in - `--take-release <path>` to write the release's version over it (which ends the divergence), `--keep-local` to leave them untouched (repeatable, and it reports the same files again on every subsequent run until they stop diverging), or reconcile by hand and retry. An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
|
||||||
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
|
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
|
||||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||||
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
|
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, or a missing `CHANGES.md`. No entry for the requested version is an error only where the feed cannot answer either: in a tree with no release stamp (a dev checkout - write the entry, or `version bump`), with `--offline`, or when the feed could not be reached or returned a release with an empty `body`. Every one of those failures names the stamp's `release_url` where it has one, so a run that cannot read the notes is still told where they are | Read-only | Fix the named argument or file, then retry. A feed failure is transient - retry once, then read the release page the error names. Safe to retry |
|
||||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||||
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
|
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
|
||||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||||
@@ -451,16 +454,7 @@ Run by the LLM through the skills, on this cadence:
|
|||||||
|
|
||||||
## Future considerations (not implemented)
|
## Future considerations (not implemented)
|
||||||
|
|
||||||
- MCP server wrapper exposing these same commands as native tool calls for
|
|
||||||
MCP-capable agents, instead of shell invocation.
|
|
||||||
- A pre-commit hook running `wikitool lint --fail-on-error` before every
|
- A pre-commit hook running `wikitool lint --fail-on-error` before every
|
||||||
`wikitool publish`. CI already runs it on every push
|
`wikitool publish`. CI already runs it on every push
|
||||||
(`.gitea/workflows/ci.yml`), which catches it after the fact rather than
|
(`.gitea/workflows/ci.yml`), which catches it after the fact rather than
|
||||||
before.
|
before.
|
||||||
- `dist upgrade`: apply a newer release to an instance that already has
|
|
||||||
content. `version check` detects that one exists and says whether it crosses
|
|
||||||
a compatibility boundary; applying it is the manual procedure in
|
|
||||||
[INSTALL.md](../INSTALL.md) § "Eine Instanz aktualisieren". The `files` block
|
|
||||||
of `.wikitool-release.json` is the groundwork - it records what the machinery
|
|
||||||
looked like at install time, which is the only way to tell a file the
|
|
||||||
instance edited from one it merely received.
|
|
||||||
|
|||||||
+92
-6
@@ -3,6 +3,8 @@
|
|||||||
The root AGENTS.md holds the invariants that say when these commands are
|
The root AGENTS.md holds the invariants that say when these commands are
|
||||||
mandatory; tools/CONTRACT.md is the full per-command reference.
|
mandatory; tools/CONTRACT.md is the full per-command reference.
|
||||||
"""
|
"""
|
||||||
|
import errno
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -50,6 +52,82 @@ except ModuleNotFoundError as exc:
|
|||||||
|
|
||||||
from chemenu.telemetry import emit # noqa: E402 - after the dependency check
|
from chemenu.telemetry import emit # noqa: E402 - after the dependency check
|
||||||
|
|
||||||
|
|
||||||
|
class _BrokenPipeSwallow:
|
||||||
|
"""Wraps a stream so a write into a closed pipe is dropped instead of
|
||||||
|
raised - installed on `sys.stdout`/`sys.stderr` before Typer/Click ever
|
||||||
|
run, so Click's own broken-pipe handling (`click.core.BaseCommand.main`)
|
||||||
|
never gets the chance to fire.
|
||||||
|
|
||||||
|
Why not just read Click's outcome afterwards: Click already catches this
|
||||||
|
exact case (`OSError` with `errno.EPIPE`) and turns it into `sys.exit(1)`
|
||||||
|
to avoid a traceback - a clean-looking exit, but indistinguishable from a
|
||||||
|
real failure to whatever reads that exit code next. `cli._run_traced`
|
||||||
|
does exactly that: it is the trace, which recorded a truncated-but-
|
||||||
|
otherwise-successful `types describe source | head -1` as a tool error
|
||||||
|
(Gitea #110, measured against a real trace: `exit_code: 1` for a call the
|
||||||
|
very next, unpiped, retry of which showed `exit_code: 0`).
|
||||||
|
|
||||||
|
Swallowing the write here instead means Click's own handler never
|
||||||
|
triggers, so the command finishes through its normal exit path - `0` for
|
||||||
|
an otherwise-successful run - and `sigpipe` on this wrapper is the signal
|
||||||
|
`_run_traced` reads to note the truncation without miscasting it as an
|
||||||
|
error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, wrapped):
|
||||||
|
self._wrapped = wrapped
|
||||||
|
self.sigpipe = False
|
||||||
|
|
||||||
|
def _is_epipe(self, exc: OSError) -> bool:
|
||||||
|
return exc.errno == errno.EPIPE
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
try:
|
||||||
|
return self._wrapped.write(data)
|
||||||
|
except OSError as exc:
|
||||||
|
if not self._is_epipe(exc):
|
||||||
|
raise
|
||||||
|
self.sigpipe = True
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
try:
|
||||||
|
self._wrapped.flush()
|
||||||
|
except OSError as exc:
|
||||||
|
if not self._is_epipe(exc):
|
||||||
|
raise
|
||||||
|
self.sigpipe = True
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self._wrapped, attr)
|
||||||
|
|
||||||
|
|
||||||
|
def _pacify_real_fd(stream) -> None:
|
||||||
|
"""Redirect a broken stream's real file descriptor to `os.devnull`.
|
||||||
|
|
||||||
|
Swallowing the write in `_BrokenPipeSwallow` is not enough on its own:
|
||||||
|
CPython still flushes the *real* underlying stream automatically at
|
||||||
|
interpreter shutdown, by code this module does not control, and that
|
||||||
|
flush hits the same closed pipe - printing "Exception ignored while
|
||||||
|
flushing sys.stdout" (the well-known CPython caveat; see the standard
|
||||||
|
library docs' "Note on SIGPIPE"). Once a pipe is known broken there is
|
||||||
|
nothing left worth writing to it, so pointing the fd at `/dev/null`
|
||||||
|
makes every later flush - ours or the interpreter's own - a normal
|
||||||
|
write that always succeeds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull, stream.fileno())
|
||||||
|
finally:
|
||||||
|
os.close(devnull)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
# AttributeError: a stream with no real fd at all (a test double, or
|
||||||
|
# a harness that already replaced sys.stdout with something that
|
||||||
|
# isn't a file) - nothing to redirect, same as the OSError case.
|
||||||
|
pass
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
@@ -127,6 +205,10 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
|||||||
"""
|
"""
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
|
real_stdout, real_stderr = sys.stdout, sys.stderr
|
||||||
|
stdout_wrap = _BrokenPipeSwallow(real_stdout)
|
||||||
|
stderr_wrap = _BrokenPipeSwallow(real_stderr)
|
||||||
|
sys.stdout, sys.stderr = stdout_wrap, stderr_wrap
|
||||||
try:
|
try:
|
||||||
app()
|
app()
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
@@ -137,18 +219,22 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None:
|
|||||||
exit_code = 1
|
exit_code = 1
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
if stdout_wrap.sigpipe:
|
||||||
|
_pacify_real_fd(real_stdout)
|
||||||
|
if stderr_wrap.sigpipe:
|
||||||
|
_pacify_real_fd(real_stderr)
|
||||||
|
sys.stdout, sys.stderr = real_stdout, real_stderr
|
||||||
if charged and _util.declined():
|
if charged and _util.declined():
|
||||||
run_budget.refund()
|
run_budget.refund()
|
||||||
emit(
|
attrs = {
|
||||||
"wikitool",
|
|
||||||
"wikitool.call",
|
|
||||||
{
|
|
||||||
"command": command,
|
"command": command,
|
||||||
"args": args,
|
"args": args,
|
||||||
"exit_code": exit_code,
|
"exit_code": exit_code,
|
||||||
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
"duration_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
},
|
}
|
||||||
)
|
if stdout_wrap.sigpipe or stderr_wrap.sigpipe:
|
||||||
|
attrs["stdout_truncated"] = True
|
||||||
|
emit("wikitool", "wikitool.call", attrs)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ def rel_path(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def check_collision(name: str) -> None:
|
def check_collision(name: str) -> None:
|
||||||
"""Fail if any page under wiki/ already has `name` as its filename stem.
|
"""Fail if any page under kb/ already has `name` as its filename stem.
|
||||||
|
|
||||||
The stem *is* the page title and wikilinks resolve by title alone, so two
|
The stem *is* the page title and wikilinks resolve by title alone, so two
|
||||||
files sharing a stem in different directories are indistinguishable to
|
files sharing a stem in different directories are indistinguishable to
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ app = typer.Typer(help="Manage [^cite-id] footnote citations and their Footnotes
|
|||||||
|
|
||||||
def _find_page(pages: dict[str, Page], title: str) -> Page:
|
def _find_page(pages: dict[str, Page], title: str) -> Page:
|
||||||
if title not in pages:
|
if title not in pages:
|
||||||
fail(f"No page titled '{title}' found under wiki/.")
|
fail(f"No page titled '{title}' found under kb/.")
|
||||||
return pages[title]
|
return pages[title]
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ def cite_add(
|
|||||||
pages = load_kb_pages(config.KB_DIR)
|
pages = load_kb_pages(config.KB_DIR)
|
||||||
page = _find_page(pages, page_title)
|
page = _find_page(pages, page_title)
|
||||||
if source not in pages:
|
if source not in pages:
|
||||||
fail(f"No page titled '{source}' found under wiki/ - citing a page that doesn't exist would be a dangling reference.")
|
fail(f"No page titled '{source}' found under kb/ - citing a page that doesn't exist would be a dangling reference.")
|
||||||
|
|
||||||
marker_id, new_body, changed = upsert_citation(page, source, file)
|
marker_id, new_body, changed = upsert_citation(page, source, file)
|
||||||
marker = f"[^{marker_id}]"
|
marker = f"[^{marker_id}]"
|
||||||
@@ -159,7 +159,7 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]:
|
|||||||
@app.command("sync")
|
@app.command("sync")
|
||||||
def cite_sync(
|
def cite_sync(
|
||||||
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
|
page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"),
|
||||||
all_pages: bool = typer.Option(False, "--all", help="Sync every page under wiki/"),
|
all_pages: bool = typer.Option(False, "--all", help="Sync every page under kb/"),
|
||||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
|
dry_run: bool = typer.Option(False, "--dry-run", help="Report what would change instead of writing"),
|
||||||
):
|
):
|
||||||
"""Prune orphan Footnotes definitions and re-render each page's block in
|
"""Prune orphan Footnotes definitions and re-render each page's block in
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import tempfile
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, NamedTuple, Optional, Union
|
from typing import Callable, NamedTuple, Optional, Sequence, Union
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -659,8 +659,11 @@ def run_export(target: Path, dry_run: bool = False, origin: Optional[Origin] = N
|
|||||||
# (`ownership.is_export_stub`, `ownership.is_upgrade_preserved`), plus the
|
# (`ownership.is_export_stub`, `ownership.is_upgrade_preserved`), plus the
|
||||||
# stamp itself. Every candidate path is classified against the *old* stamp's
|
# stamp itself. Every candidate path is classified against the *old* stamp's
|
||||||
# recorded digest - unchanged, locally modified, or locally deleted - and a
|
# recorded digest - unchanged, locally modified, or locally deleted - and a
|
||||||
# modified/deleted file is never silently overwritten. This never calls a
|
# modified/deleted file is never silently overwritten: the run aborts unless
|
||||||
# release feed; the caller supplies an already-downloaded tree or archive.
|
# `--keep-local` keeps it or `--take-release <path>` names it, which is the
|
||||||
|
# difference between a file the instance means to carry and one that drifted.
|
||||||
|
# This never calls a release feed; the caller supplies an already-downloaded
|
||||||
|
# tree or archive.
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -802,12 +805,68 @@ def _git_working_tree_status() -> Optional[str]:
|
|||||||
return result.stdout if result.returncode == 0 else None
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_take_release(
|
||||||
|
take_release: Optional[Sequence[str]], classification: FileClassification
|
||||||
|
) -> set[str]:
|
||||||
|
"""The blocked paths `--take-release` names, refusing any that is not
|
||||||
|
actually blocked.
|
||||||
|
|
||||||
|
A path that silently does nothing is the worse answer: the operator asked
|
||||||
|
for a local change to be discarded and would be told the upgrade went
|
||||||
|
fine, having kept it. Checked before `--dry-run` returns, so a typo
|
||||||
|
surfaces in the preview rather than in the writing run."""
|
||||||
|
if not take_release:
|
||||||
|
return set()
|
||||||
|
blocked = set(classification.blocked)
|
||||||
|
wanted = {path.strip() for path in take_release if path.strip()}
|
||||||
|
unknown = sorted(wanted - blocked)
|
||||||
|
if unknown:
|
||||||
|
listed = "\n".join(f" - {path}" for path in classification.blocked) or " (none)"
|
||||||
|
fail(
|
||||||
|
f"--take-release names {len(unknown)} path(s) that are not locally changed: "
|
||||||
|
f"{', '.join(unknown)}. Only a path this run reports as locally modified or "
|
||||||
|
f"locally deleted can be taken from the release. Reported as locally changed:\n"
|
||||||
|
f"{listed}"
|
||||||
|
)
|
||||||
|
return set() # unreachable: fail() raises typer.Exit
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def _refusal_for_blocked(
|
||||||
|
source: Path, undecided: list[str], classification: FileClassification
|
||||||
|
) -> str:
|
||||||
|
"""The abort text for blocked paths no flag has answered for.
|
||||||
|
|
||||||
|
It spells all three answers out with a ready-to-paste command line -
|
||||||
|
the same shape the Mass-Update Gate uses for its `--confirm` line -
|
||||||
|
because the one thing a reader must not take away is that any of them is
|
||||||
|
the default. A run on a real instance read the old wording, which named
|
||||||
|
only `--keep-local` and "reconcile by hand", as "the default takes the
|
||||||
|
release's version" and called the command with no flag at all."""
|
||||||
|
paths = " ".join(undecided)
|
||||||
|
kept_again = (
|
||||||
|
"they are reported again on every future upgrade"
|
||||||
|
if len(classification.blocked) > 1
|
||||||
|
else "it is reported again on every future upgrade"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{len(undecided)} locally changed file(s) (listed above) would be silently "
|
||||||
|
f"overwritten. Nothing was written, and none of these three is the default:\n"
|
||||||
|
f" - take the release's version and discard the local change:\n"
|
||||||
|
f" dist upgrade {rel_path(source)} --take-release {paths}\n"
|
||||||
|
f" - keep every local change and upgrade around them ({kept_again}):\n"
|
||||||
|
f" dist upgrade {rel_path(source)} --keep-local\n"
|
||||||
|
f" - reconcile them by hand first, then re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _report_plan(
|
def _report_plan(
|
||||||
classification: FileClassification,
|
classification: FileClassification,
|
||||||
migration_chain: list["kb_state.Migration"],
|
migration_chain: list["kb_state.Migration"],
|
||||||
boundary_crossing: bool,
|
boundary_crossing: bool,
|
||||||
local_version: "version_mod.Version",
|
local_version: "version_mod.Version",
|
||||||
new_version: "version_mod.Version",
|
new_version: "version_mod.Version",
|
||||||
|
taken: set[str] = frozenset(),
|
||||||
) -> None:
|
) -> None:
|
||||||
console.print(f"{local_version} -> {new_version}")
|
console.print(f"{local_version} -> {new_version}")
|
||||||
if boundary_crossing:
|
if boundary_crossing:
|
||||||
@@ -821,14 +880,18 @@ def _report_plan(
|
|||||||
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
|
f"{len(classification.blocked)} locally changed, {len(classification.removed)} removed "
|
||||||
"from the release."
|
"from the release."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _mark(relative: str) -> str:
|
||||||
|
return " [cyan](--take-release: overwritten from the release)[/cyan]" if relative in taken else ""
|
||||||
|
|
||||||
if classification.modified:
|
if classification.modified:
|
||||||
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
|
console.print(f"[bold]Locally modified ({len(classification.modified)}):[/bold]")
|
||||||
for relative in classification.modified:
|
for relative in classification.modified:
|
||||||
console.print(f" - {relative}")
|
console.print(f" - {relative}{_mark(relative)}")
|
||||||
if classification.deleted:
|
if classification.deleted:
|
||||||
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
|
console.print(f"[bold]Locally deleted ({len(classification.deleted)}):[/bold]")
|
||||||
for relative in classification.deleted:
|
for relative in classification.deleted:
|
||||||
console.print(f" - {relative}")
|
console.print(f" - {relative}{_mark(relative)}")
|
||||||
if classification.removed:
|
if classification.removed:
|
||||||
console.print("[dim]No longer part of the release, not written or removed by default:[/dim]")
|
console.print("[dim]No longer part of the release, not written or removed by default:[/dim]")
|
||||||
for relative in classification.removed:
|
for relative in classification.removed:
|
||||||
@@ -855,6 +918,13 @@ def upgrade_command(
|
|||||||
False, "--keep-local",
|
False, "--keep-local",
|
||||||
help="Proceed even with locally changed files - leave each one untouched rather than aborting",
|
help="Proceed even with locally changed files - leave each one untouched rather than aborting",
|
||||||
),
|
),
|
||||||
|
take_release: list[str] = typer.Option(
|
||||||
|
None, "--take-release",
|
||||||
|
help="Overwrite this locally changed path with the release's version, discarding the local "
|
||||||
|
"change. Repeatable, and each path must be one this run reports as locally changed. The "
|
||||||
|
"counterpart to --keep-local, which keeps the change and reports it again on every future "
|
||||||
|
"upgrade",
|
||||||
|
),
|
||||||
prune: bool = typer.Option(
|
prune: bool = typer.Option(
|
||||||
False, "--prune",
|
False, "--prune",
|
||||||
help="Also delete files the new release no longer ships, if they are unchanged since install",
|
help="Also delete files the new release no longer ships, if they are unchanged since install",
|
||||||
@@ -872,13 +942,20 @@ def upgrade_command(
|
|||||||
against the *old* stamp's recorded digest: unchanged files are
|
against the *old* stamp's recorded digest: unchanged files are
|
||||||
overwritten silently, new files are created, and a locally modified or
|
overwritten silently, new files are created, and a locally modified or
|
||||||
deleted file is never silently overwritten - `dist upgrade` aborts unless
|
deleted file is never silently overwritten - `dist upgrade` aborts unless
|
||||||
`--keep-local` says to leave it alone. Reports the migration chain the new
|
`--keep-local` says to leave it alone or `--take-release <path>` names it
|
||||||
|
as one to overwrite from the release. Reports the migration chain the new
|
||||||
machinery would owe without running any of it (there is no `migrate run`).
|
machinery would owe without running any of it (there is no `migrate run`).
|
||||||
Refuses on a missing local release stamp, a downgrade, a pre-release
|
Refuses on a missing local release stamp, a downgrade, a pre-release
|
||||||
source without `--pre`, or a dirty working tree. Never touches git.
|
source without `--pre`, a dirty working tree, or a `--take-release` path
|
||||||
|
that is not locally changed. Never touches git.
|
||||||
See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren"."""
|
See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren"."""
|
||||||
run_upgrade(
|
run_upgrade(
|
||||||
source, dry_run=dry_run, keep_local=keep_local, prune=prune, allow_pre=allow_pre
|
source,
|
||||||
|
dry_run=dry_run,
|
||||||
|
keep_local=keep_local,
|
||||||
|
take_release=take_release,
|
||||||
|
prune=prune,
|
||||||
|
allow_pre=allow_pre,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -886,6 +963,7 @@ def run_upgrade(
|
|||||||
source: Path,
|
source: Path,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
keep_local: bool = False,
|
keep_local: bool = False,
|
||||||
|
take_release: Optional[Sequence[str]] = None,
|
||||||
prune: bool = False,
|
prune: bool = False,
|
||||||
allow_pre: bool = False,
|
allow_pre: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -993,26 +1071,29 @@ def run_upgrade(
|
|||||||
)
|
)
|
||||||
boundary_crossing = local_version.compat_key != new_version.compat_key
|
boundary_crossing = local_version.compat_key != new_version.compat_key
|
||||||
|
|
||||||
_report_plan(classification, migration_chain, boundary_crossing, local_version, new_version)
|
taken = _resolve_take_release(take_release, classification)
|
||||||
|
_report_plan(
|
||||||
|
classification, migration_chain, boundary_crossing, local_version, new_version, taken
|
||||||
|
)
|
||||||
|
|
||||||
# Dry-run's whole purpose is to preview this classification - including
|
# Dry-run's whole purpose is to preview this classification - including
|
||||||
# the blocked list - without raising, so it must be checked before the
|
# the blocked list - without raising, so it must be checked before the
|
||||||
# abort below rather than after: a blocked file must never turn
|
# abort below rather than after: a blocked file must never turn
|
||||||
# `--dry-run` into a non-zero exit, or the flag stops being safe to run
|
# `--dry-run` into a non-zero exit, or the flag stops being safe to run
|
||||||
# freely.
|
# freely. A bad `--take-release` path is the other way round: it is a
|
||||||
|
# mistake in the *argument*, not a state of the tree, so it is resolved
|
||||||
|
# above this line and does exit non-zero here - catching a typo in the
|
||||||
|
# preview is the whole point of previewing.
|
||||||
if dry_run:
|
if dry_run:
|
||||||
success(f"Dry run: would upgrade {local_version} -> {new_version}. Nothing written.")
|
success(f"Dry run: would upgrade {local_version} -> {new_version}. Nothing written.")
|
||||||
return
|
return
|
||||||
|
|
||||||
if classification.blocked and not keep_local:
|
undecided = [path for path in classification.blocked if path not in taken]
|
||||||
fail(
|
if undecided and not keep_local:
|
||||||
f"{len(classification.blocked)} locally changed file(s) (listed above) would be "
|
fail(_refusal_for_blocked(source, undecided, classification))
|
||||||
"silently overwritten. Pass --keep-local to upgrade anyway and leave every one of "
|
|
||||||
"them untouched, or reconcile them by hand first. Nothing was written."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
to_write = sorted(classification.unchanged + classification.new)
|
to_write = sorted(classification.unchanged + classification.new + sorted(taken))
|
||||||
for relative in to_write:
|
for relative in to_write:
|
||||||
src = new_root / relative
|
src = new_root / relative
|
||||||
dst = config.ROOT / relative
|
dst = config.ROOT / relative
|
||||||
@@ -1034,9 +1115,10 @@ def run_upgrade(
|
|||||||
target.unlink()
|
target.unlink()
|
||||||
pruned.append(relative)
|
pruned.append(relative)
|
||||||
|
|
||||||
skipped = classification.blocked if keep_local else []
|
skipped = undecided if keep_local else []
|
||||||
summary = (
|
summary = (
|
||||||
f"Upgraded {local_version} -> {new_version}: {len(to_write)} file(s) written"
|
f"Upgraded {local_version} -> {new_version}: {len(to_write)} file(s) written"
|
||||||
|
+ (f", {len(taken)} taken from the release (--take-release)" if taken else "")
|
||||||
+ (f", {len(skipped)} left untouched (--keep-local)" if skipped else "")
|
+ (f", {len(skipped)} left untouched (--keep-local)" if skipped else "")
|
||||||
+ (f", {len(pruned)} pruned" if pruned else "")
|
+ (f", {len(pruned)} pruned" if pruned else "")
|
||||||
+ "."
|
+ "."
|
||||||
@@ -1045,8 +1127,13 @@ def run_upgrade(
|
|||||||
summary += (
|
summary += (
|
||||||
f" {len(migration_chain)} migration(s) now outstanding - run `wikitool migrate status`."
|
f" {len(migration_chain)} migration(s) now outstanding - run `wikitool migrate status`."
|
||||||
)
|
)
|
||||||
|
# One pointer rather than a second copy of the order: the steps after the
|
||||||
|
# swap live in instructions/upgrade-instance.md, which ships with every
|
||||||
|
# instance. Naming the resume *command* rather than a step number keeps this
|
||||||
|
# line correct when that file's numbering moves.
|
||||||
summary += (
|
summary += (
|
||||||
" Nothing was committed. Now run, in order: `wikitool instructions sync`, `doctor`, "
|
" Nothing was committed and nothing is verified yet."
|
||||||
"`docs verify`, `instructions verify`, `lint` - then restart the agent session."
|
" `instructions/upgrade-instance.md` carries the order for everything that follows"
|
||||||
|
" and resumes at `wikitool instructions sync`."
|
||||||
)
|
)
|
||||||
success(summary)
|
success(summary)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from chemenu import config, conventions, kb_collections, version as version_mod
|
|||||||
from chemenu.commands import git_publish, instructions_cmd
|
from chemenu.commands import git_publish, instructions_cmd
|
||||||
from chemenu.commands._util import rel_path
|
from chemenu.commands._util import rel_path
|
||||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||||
|
from chemenu.session import session_id_source as _session_id_source
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -414,12 +415,23 @@ def check_upload_intake() -> Check:
|
|||||||
|
|
||||||
|
|
||||||
def check_session_id() -> Check:
|
def check_session_id() -> Check:
|
||||||
|
"""Three-valued, not two: an explicit `WIKITOOL_SESSION_ID` and a
|
||||||
|
recognised harness variable (see `chemenu.session.HARNESS_ENV_VARS`) both
|
||||||
|
keep a session's calls in one telemetry/budget bucket, so both are `OK`.
|
||||||
|
Only the `getppid()` fallback - a fresh "session" on every call, on a
|
||||||
|
harness that runs each tool call in its own shell - is a `WARN` (see
|
||||||
|
Gitea #110)."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
if os.environ.get(SESSION_ENV_VAR, "").strip():
|
if os.environ.get(SESSION_ENV_VAR, "").strip():
|
||||||
return Check("session-id", "OK", f"{SESSION_ENV_VAR}={os.environ[SESSION_ENV_VAR]}")
|
return Check("session-id", "OK", f"{SESSION_ENV_VAR}={os.environ[SESSION_ENV_VAR]}")
|
||||||
|
source = _session_id_source()
|
||||||
|
if source != "getppid() fallback":
|
||||||
|
return Check("session-id", "OK", f"scoped by harness variable {source}")
|
||||||
return Check(
|
return Check(
|
||||||
"session-id", "WARN", f"{SESSION_ENV_VAR} is not set - budget falls back to the parent PID",
|
"session-id", "WARN",
|
||||||
|
f"{SESSION_ENV_VAR} is not set and no harness session variable was found - "
|
||||||
|
"budget falls back to the parent PID",
|
||||||
"See instructions/session-setup.md",
|
"See instructions/session-setup.md",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ whose push failed leaves a real, unpushed commit sitting on the branch, and
|
|||||||
the next `publish` now pushes it instead of reporting "Nothing to commit"
|
the next `publish` now pushes it instead of reporting "Nothing to commit"
|
||||||
forever.
|
forever.
|
||||||
|
|
||||||
Also implements the Mass-Update Gate (wiki/concepts/Mass-Update Gate.md):
|
Also implements the Mass-Update Gate (kb/concepts/workflows/Mass-Update Gate.md):
|
||||||
a push to origin/main is the one action in this system with a real,
|
a push to origin/main is the one action in this system with a real,
|
||||||
irreversible external effect (publicly visible commit history, possible CI
|
irreversible external effect (publicly visible commit history, possible CI
|
||||||
triggers, other clients pulling). Small/normal publishes (< threshold
|
triggers, other clients pulling). Small/normal publishes (< threshold
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Append correctly-formatted entries to wiki/log.md."""
|
"""Append correctly-formatted entries to kb/log.md."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -10,7 +10,7 @@ import typer
|
|||||||
from chemenu import config
|
from chemenu import config
|
||||||
from chemenu.commands._util import fail, rel_path, success, today_iso
|
from chemenu.commands._util import fail, rel_path, success, today_iso
|
||||||
|
|
||||||
app = typer.Typer(help="Manage wiki/log.md.")
|
app = typer.Typer(help="Manage kb/log.md.")
|
||||||
|
|
||||||
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
|
VALID_OPS = ["ingest", "query", "lint", "create", "update", "delete", "rename", "move"]
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def log_status():
|
|||||||
`lint` - the deterministic trigger for the Maintenance Schedule's "every
|
`lint` - the deterministic trigger for the Maintenance Schedule's "every
|
||||||
10 sources" full-lint cadence. Read-only."""
|
10 sources" full-lint cadence. Read-only."""
|
||||||
if not config.LOG_FILE.exists():
|
if not config.LOG_FILE.exists():
|
||||||
success("No wiki/log.md yet; nothing logged.")
|
success("No kb/log.md yet; nothing logged.")
|
||||||
return
|
return
|
||||||
entries = parse_log_entries(config.LOG_FILE.read_text(encoding="utf-8"))
|
entries = parse_log_entries(config.LOG_FILE.read_text(encoding="utf-8"))
|
||||||
count = ingests_since_last_lint(entries)
|
count = ingests_since_last_lint(entries)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ deterministic and stored in /types/; the content is judgment and provided by the
|
|||||||
|
|
||||||
Frontmatter defaults, enum validity, and required-ness all come from the
|
Frontmatter defaults, enum validity, and required-ness all come from the
|
||||||
type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them.
|
type's `.schema.yaml` (via `TypeResolver`) - nothing here re-declares them.
|
||||||
|
A schema `default:` is materialized only for a field the schema also lists
|
||||||
|
in `required:` - an optional field's default is a reader-side assumption
|
||||||
|
(what a missing field means), and writing it into every scaffolded page
|
||||||
|
would turn that assumption into a stated claim instead (Gitea #109).
|
||||||
Directory placement for subtype-driven types (currently just entities) also
|
Directory placement for subtype-driven types (currently just entities) also
|
||||||
comes from the type-spec, via its `layout:` frontmatter (see
|
comes from the type-spec, via its `layout:` frontmatter (see
|
||||||
`TypeResolver.get_layout`) - not a hand-maintained Python dict.
|
`TypeResolver.get_layout`) - not a hand-maintained Python dict.
|
||||||
@@ -74,12 +78,22 @@ def _build_frontmatter(
|
|||||||
`explicit` supplies every CLI-derived value the caller already has;
|
`explicit` supplies every CLI-derived value the caller already has;
|
||||||
fields not in `explicit` get a type-appropriate default (today's date for
|
fields not in `explicit` get a type-appropriate default (today's date for
|
||||||
date-formatted fields, the scaffold placeholder for `summary`, the
|
date-formatted fields, the scaffold placeholder for `summary`, the
|
||||||
schema's own `default:` where declared, an empty list for arrays), or are
|
schema's own `default:` where declared *and the field is required*, an
|
||||||
omitted entirely if optional with no sensible default (e.g.
|
empty list for arrays), or are omitted entirely if optional with no
|
||||||
`source_url`). This is what lets frontmatter shape - and scaffold-time
|
sensible default (e.g. `source_url`). This is what lets frontmatter
|
||||||
defaults like `provenance: general` - follow the schema instead of being
|
shape - and scaffold-time defaults like `provenance: general` - follow
|
||||||
hand-declared per CLI command.
|
the schema instead of being hand-declared per CLI command.
|
||||||
|
|
||||||
|
A `default:` on an *optional* field (e.g. `instruction.obligation`) is
|
||||||
|
deliberately not materialized here: it documents what a reader should
|
||||||
|
assume when the field is absent, not what the scaffold should write.
|
||||||
|
Writing it anyway turned every scaffolded instruction into one that
|
||||||
|
falsely claims `obligation: required` - a migration-only field - and
|
||||||
|
the same read/write distinction is what the schema's own `default:`
|
||||||
|
doc-comment (`types/instruction.schema.yaml`) already draws (Gitea
|
||||||
|
#109).
|
||||||
"""
|
"""
|
||||||
|
required = set((schema or {}).get("required") or [])
|
||||||
frontmatter: Dict[str, Any] = {"type": type_path}
|
frontmatter: Dict[str, Any] = {"type": type_path}
|
||||||
for field_name, field_schema in (schema or {}).get("properties", {}).items():
|
for field_name, field_schema in (schema or {}).get("properties", {}).items():
|
||||||
if field_name == "type":
|
if field_name == "type":
|
||||||
@@ -105,7 +119,7 @@ def _build_frontmatter(
|
|||||||
frontmatter[field_name] = resolved_author
|
frontmatter[field_name] = resolved_author
|
||||||
elif field_schema.get("format") == "date":
|
elif field_schema.get("format") == "date":
|
||||||
frontmatter[field_name] = today
|
frontmatter[field_name] = today
|
||||||
elif "default" in field_schema:
|
elif "default" in field_schema and field_name in required:
|
||||||
frontmatter[field_name] = field_schema["default"]
|
frontmatter[field_name] = field_schema["default"]
|
||||||
elif field_schema.get("type") == "array":
|
elif field_schema.get("type") == "array":
|
||||||
frontmatter[field_name] = []
|
frontmatter[field_name] = []
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ def rename_command(
|
|||||||
if references_only:
|
if references_only:
|
||||||
if new not in pages:
|
if new not in pages:
|
||||||
fail(
|
fail(
|
||||||
f"Neither '{old}' nor '{new}' is a page under wiki/. Repointing references "
|
f"Neither '{old}' nor '{new}' is a page under kb/. Repointing references "
|
||||||
f"to '{new}' would just move the dangling reference; create the page first "
|
f"to '{new}' would just move the dangling reference; create the page first "
|
||||||
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
|
"with `wikitool new ...`, or drop the reference with `wikitool xref remove`."
|
||||||
)
|
)
|
||||||
@@ -316,7 +316,7 @@ def rm_command(
|
|||||||
pages = load_kb_pages(config.KB_DIR)
|
pages = load_kb_pages(config.KB_DIR)
|
||||||
target = pages.get(page_title)
|
target = pages.get(page_title)
|
||||||
if target is None:
|
if target is None:
|
||||||
fail(f"No page titled '{page_title}' found under wiki/.")
|
fail(f"No page titled '{page_title}' found under kb/.")
|
||||||
|
|
||||||
inbound = inbound_pages(pages, page_title)
|
inbound = inbound_pages(pages, page_title)
|
||||||
if inbound and not yes:
|
if inbound and not yes:
|
||||||
@@ -404,7 +404,7 @@ def move_command(
|
|||||||
None, "--page", help="Exact title of the page to move to its computed location"
|
None, "--page", help="Exact title of the page to move to its computed location"
|
||||||
),
|
),
|
||||||
reconcile: bool = typer.Option(
|
reconcile: bool = typer.Option(
|
||||||
False, "--reconcile", help="Move every page under wiki/ that is not at its computed location"
|
False, "--reconcile", help="Move every page under kb/ that is not at its computed location"
|
||||||
),
|
),
|
||||||
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
|
dry_run: bool = typer.Option(False, "--dry-run", help="List what would move without writing"),
|
||||||
):
|
):
|
||||||
@@ -475,7 +475,7 @@ def move_command(
|
|||||||
|
|
||||||
target = pages.get(page_title)
|
target = pages.get(page_title)
|
||||||
if target is None:
|
if target is None:
|
||||||
fail(f"No page titled '{page_title}' found under wiki/.")
|
fail(f"No page titled '{page_title}' found under kb/.")
|
||||||
|
|
||||||
type_path = target.frontmatter.get("type")
|
type_path = target.frontmatter.get("type")
|
||||||
if not type_path:
|
if not type_path:
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ def build_provenance_index(kb_dir: Path, raw_dir: Path) -> str:
|
|||||||
|
|
||||||
@app.command("rebuild-index")
|
@app.command("rebuild-index")
|
||||||
def rebuild_index(
|
def rebuild_index(
|
||||||
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing wiki/provenance.md"),
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing kb/provenance.md"),
|
||||||
):
|
):
|
||||||
content = build_provenance_index(config.KB_DIR, config.RAW_DIR)
|
content = build_provenance_index(config.KB_DIR, config.RAW_DIR)
|
||||||
provenance_file = config.KB_DIR / "provenance.md"
|
provenance_file = config.KB_DIR / "provenance.md"
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ def raw_accept_command(
|
|||||||
pages = load_kb_pages(config.KB_DIR)
|
pages = load_kb_pages(config.KB_DIR)
|
||||||
target_page = pages.get(page)
|
target_page = pages.get(page)
|
||||||
if target_page is None:
|
if target_page is None:
|
||||||
fail(f"No page titled '{page}' found under wiki/. Create it first, or omit --page.")
|
fail(f"No page titled '{page}' found under kb/. Create it first, or omit --page.")
|
||||||
existing_rel = source_raw_files(target_page)
|
existing_rel = source_raw_files(target_page)
|
||||||
if not existing_rel:
|
if not existing_rel:
|
||||||
fail(
|
fail(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This closes the gap documented in AGENTS.md's "Gates" section: unlike a
|
|||||||
prompt instruction ("stop after N steps"), this check runs
|
prompt instruction ("stop after N steps"), this check runs
|
||||||
in-process on every `wikitool` invocation and cannot be skipped by the
|
in-process on every `wikitool` invocation and cannot be skipped by the
|
||||||
calling agent "politely trying again". It mirrors the Mass-Update Gate
|
calling agent "politely trying again". It mirrors the Mass-Update Gate
|
||||||
pattern (see git_publish.py / wiki/concepts/Mass-Update Gate.md), but that
|
pattern (see git_publish.py / kb/concepts/workflows/Mass-Update Gate.md), but that
|
||||||
gate is scoped to the *size* of a single publish, while this one is scoped to
|
gate is scoped to the *size* of a single publish, while this one is scoped to
|
||||||
*iteration volume* across a whole session (e.g. a wiki-ingest or wiki-lint run
|
*iteration volume* across a whole session (e.g. a wiki-ingest or wiki-lint run
|
||||||
that could otherwise loop unbounded over many entity/concept pages).
|
that could otherwise loop unbounded over many entity/concept pages).
|
||||||
@@ -144,6 +144,27 @@ def _session_id_source() -> str:
|
|||||||
return _shared_session_id_source()
|
return _shared_session_id_source()
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_for(state: dict, session_id: str) -> dict:
|
||||||
|
"""The state entry for this session id, starting a fresh counter if the
|
||||||
|
same id string now carries a different origin than the one that wrote it.
|
||||||
|
|
||||||
|
Two different id spaces (a `getppid()` integer, a harness UUID, an
|
||||||
|
explicit `WIKITOOL_SESSION_ID`) are vanishingly unlikely to collide as
|
||||||
|
strings - but "unlikely" is not "impossible", and inheriting a stranger's
|
||||||
|
count on collision is exactly the silent mis-key #110 exists to close.
|
||||||
|
An entry written before this field existed carries no `source` at all and
|
||||||
|
is treated as compatible: it keeps its count rather than being reset the
|
||||||
|
first time this ships, which would throw away real, in-flight state.
|
||||||
|
"""
|
||||||
|
source = _session_id_source()
|
||||||
|
entry = state.get(session_id)
|
||||||
|
if entry is None or (entry.get("source") is not None and entry["source"] != source):
|
||||||
|
entry = {"count": 0, "recent": []}
|
||||||
|
state[session_id] = entry
|
||||||
|
entry.setdefault("source", source)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _load_state() -> dict:
|
def _load_state() -> dict:
|
||||||
if not STATE_FILE.exists():
|
if not STATE_FILE.exists():
|
||||||
return {}
|
return {}
|
||||||
@@ -247,7 +268,7 @@ def record_and_check(
|
|||||||
with _state_lock():
|
with _state_lock():
|
||||||
session_id = _session_id()
|
session_id = _session_id()
|
||||||
state = _load_state()
|
state = _load_state()
|
||||||
entry = state.setdefault(session_id, {"count": 0, "recent": []})
|
entry = _entry_for(state, session_id)
|
||||||
recent = entry["recent"]
|
recent = entry["recent"]
|
||||||
|
|
||||||
call_signature = f"{command} {' '.join(args)}".strip()
|
call_signature = f"{command} {' '.join(args)}".strip()
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ def touch_command(
|
|||||||
pages = load_kb_pages(config.KB_DIR)
|
pages = load_kb_pages(config.KB_DIR)
|
||||||
page = pages.get(page_title)
|
page = pages.get(page_title)
|
||||||
if page is None:
|
if page is None:
|
||||||
fail(f"No page titled '{page_title}' found under wiki/. Create it first with `wikitool new ...`.")
|
fail(f"No page titled '{page_title}' found under kb/. Create it first with `wikitool new ...`.")
|
||||||
|
|
||||||
type_path = page.frontmatter.get("type")
|
type_path = page.frontmatter.get("type")
|
||||||
if not type_path:
|
if not type_path:
|
||||||
|
|||||||
@@ -19,10 +19,16 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
|||||||
rendered list - the correction path for the judgment `version bump
|
rendered list - the correction path for the judgment `version bump
|
||||||
--impact` made at the time, per Gitea #95's fix for an unreadably long,
|
--impact` made at the time, per Gitea #95's fix for an unreadably long,
|
||||||
ungraded bump list.
|
ungraded bump list.
|
||||||
- `version check` is the one command in `wikitool` that makes a network call.
|
- `version notes` prints one version's release notes. In a tree that writes
|
||||||
It is deliberately its own command: nothing else reaches for it implicitly,
|
its own `CHANGES.md` that is a mechanical extraction from it; on a
|
||||||
it needs no key, it times out, and a feed that cannot be reached is reported
|
*distributed* instance, whose `CHANGES.md` is a stub `dist upgrade` never
|
||||||
as an error rather than silently answered as "up to date".
|
overwrites, it falls back to the release feed, because otherwise the command
|
||||||
|
can never answer there - not today and not after any future release.
|
||||||
|
- `version check` and that fallback are the only two network calls in
|
||||||
|
`wikitool`, and neither is implicit: `check` exists for the call, `notes`
|
||||||
|
announces the URL on stderr before asking and takes `--offline`. Both need
|
||||||
|
no key, both time out, and a feed that cannot be reached is reported as an
|
||||||
|
error rather than silently answered as "up to date" or "no notes".
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -32,10 +38,18 @@ from typing import Optional
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
from chemenu import config, version as version_mod
|
from chemenu import config, version as version_mod
|
||||||
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
from chemenu.commands._util import console, fail, rel_path, success, today_iso
|
||||||
from chemenu.version import Version, VersionError
|
from chemenu.version import Version, VersionError
|
||||||
|
|
||||||
|
# `version notes` is the one command whose stdout is consumed by a machine -
|
||||||
|
# `release.yml` redirects it into the file it posts as the release body - so
|
||||||
|
# everything it says *about* the notes goes here instead of onto the same
|
||||||
|
# stream as the notes themselves.
|
||||||
|
err = Console(stderr=True)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
help="Report, bump, and check the stack version (see tools/CONTRACT.md).",
|
help="Report, bump, and check the stack version (see tools/CONTRACT.md).",
|
||||||
invoke_without_command=True,
|
invoke_without_command=True,
|
||||||
@@ -169,13 +183,48 @@ def notes_command(
|
|||||||
version: Optional[str] = typer.Option(
|
version: Optional[str] = typer.Option(
|
||||||
None, "--version", help="Which entry to print (default: this tree's VERSION)"
|
None, "--version", help="Which entry to print (default: this tree's VERSION)"
|
||||||
),
|
),
|
||||||
|
offline: bool = typer.Option(
|
||||||
|
False, "--offline",
|
||||||
|
help="Never ask the release feed: on a distributed instance, whose CHANGES.md carries no "
|
||||||
|
"entry to print, fail with the release page instead of fetching the notes",
|
||||||
|
),
|
||||||
|
url: Optional[str] = typer.Option(
|
||||||
|
None, "--url", help="Release feed to ask for the fallback (default: the stamp's, as `version check`)"
|
||||||
|
),
|
||||||
|
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for the feed"),
|
||||||
):
|
):
|
||||||
"""Print one version's `CHANGES.md` entry, for use as release notes.
|
"""Print one version's release notes: the `CHANGES.md` entry where there is
|
||||||
|
one, the installed release's notes from the feed on a distributed instance,
|
||||||
|
where there never is.
|
||||||
|
|
||||||
Mechanical extraction, so the release workflow never has to parse markdown
|
Mechanical extraction, so the release workflow never has to parse markdown
|
||||||
in shell."""
|
in shell - which is also why **stdout carries nothing but the notes** and
|
||||||
|
every line about where they came from goes to stderr. `release.yml` does
|
||||||
|
`version notes > /tmp/release-notes.md`.
|
||||||
|
|
||||||
|
The fallback is reached only with a release stamp present, i.e. only from a
|
||||||
|
tree that came out of `dist export`. A dev checkout keeps the plain error,
|
||||||
|
so this command cannot make a network call in the origin repository or in
|
||||||
|
CI. See `version_mod.fetch_latest_notes` for why only the feed's *latest*
|
||||||
|
release can be asked for."""
|
||||||
|
run_notes(version=version, offline=offline, url=url, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_notes(
|
||||||
|
version: Optional[str] = None,
|
||||||
|
offline: bool = False,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
fetcher: Optional[version_mod.Fetcher] = None,
|
||||||
|
) -> None:
|
||||||
|
"""`version notes` itself, free of Typer's option objects - the same split
|
||||||
|
`dist_cmd.run_export` makes, and for the same reason. `fetcher` is the
|
||||||
|
network seam: a test passes one, nothing else does."""
|
||||||
|
import os
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wanted = Version.parse(version) if version else version_mod.read_version()
|
wanted = Version.parse(version) if version else version_mod.read_version()
|
||||||
|
stamp = version_mod.read_stamp()
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
fail(str(exc))
|
fail(str(exc))
|
||||||
return
|
return
|
||||||
@@ -186,13 +235,65 @@ def notes_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
|
section = version_mod.changes_section(changes.read_text(encoding="utf-8"), wanted)
|
||||||
if section is None:
|
if section is not None:
|
||||||
|
typer.echo(section, nl=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
release_url = str((stamp or {}).get("release_url") or "").strip()
|
||||||
|
if stamp is None or offline:
|
||||||
|
fail(_no_entry_message(wanted, stamp is not None, release_url))
|
||||||
|
return
|
||||||
|
|
||||||
|
feed = url or version_mod.update_url(stamp)
|
||||||
|
token = os.environ.get(version_mod.UPDATE_TOKEN_ENV, "").strip() or None
|
||||||
|
err.print(
|
||||||
|
f"[dim]{version_mod.CHANGES_FILENAME} has no entry for {wanted} - a distributed instance "
|
||||||
|
f"receives it as a stub. Asking {feed}[/dim]"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
latest, body, page = version_mod.fetch_latest_notes(feed, token, timeout, fetcher)
|
||||||
|
except VersionError as exc:
|
||||||
fail(
|
fail(
|
||||||
|
f"{exc}. The notes for {wanted} are on the release page instead: "
|
||||||
|
f"{release_url or '(no release_url in the release stamp)'}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if latest == wanted:
|
||||||
|
err.print(f"[dim]These are {latest}'s notes, from {page or feed}[/dim]")
|
||||||
|
else:
|
||||||
|
err.print(
|
||||||
|
f"[yellow]These are {latest}'s notes, not {wanted}'s[/yellow] - the feed publishes only "
|
||||||
|
f"its latest release, and this tree declares {wanted}. That is the expected shape "
|
||||||
|
f"before an upgrade, where VERSION still names the release being left. From "
|
||||||
|
f"{page or feed}"
|
||||||
|
)
|
||||||
|
typer.echo(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str:
|
||||||
|
"""Why there is no entry, and where the notes are instead.
|
||||||
|
|
||||||
|
Two trees land here and they are not the same mistake: a dev checkout that
|
||||||
|
has not written its entry yet, and an instance that was told not to go
|
||||||
|
online (the only way an instance reaches this at all). Naming the wrong one
|
||||||
|
sends the reader to the wrong fix."""
|
||||||
|
if not has_stamp:
|
||||||
|
return (
|
||||||
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted} - "
|
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted} - "
|
||||||
f"run `wikitool version bump` before releasing, or write the entry"
|
f"run `wikitool version bump` before releasing, or write the entry"
|
||||||
)
|
)
|
||||||
return
|
where = (
|
||||||
typer.echo(section, nl=False)
|
f"Read them on the release page instead: {release_url}"
|
||||||
|
if release_url
|
||||||
|
else f"The release stamp records no `release_url` to point at - `wikitool version check` "
|
||||||
|
f"names the feed this instance asks."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{version_mod.CHANGES_FILENAME} has no entry for {wanted}, and a distributed instance "
|
||||||
|
f"never has one: it receives the file as a stub and `dist upgrade` never overwrites it. "
|
||||||
|
f"--offline was passed, so the feed was not asked. {where}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command("bump")
|
@app.command("bump")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ app = typer.Typer(help="Manage bidirectional cross-references between wiki pages
|
|||||||
|
|
||||||
def _find_page(pages: dict[str, Page], name: str) -> Page:
|
def _find_page(pages: dict[str, Page], name: str) -> Page:
|
||||||
if name not in pages:
|
if name not in pages:
|
||||||
fail(f"No page titled '{name}' found under wiki/. Create it first with `wikitool new ...`.")
|
fail(f"No page titled '{name}' found under kb/. Create it first with `wikitool new ...`.")
|
||||||
return pages[name]
|
return pages[name]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Read/write markdown files with YAML frontmatter, matching the formatting
|
"""Read/write markdown files with YAML frontmatter, matching the formatting
|
||||||
conventions already used across wiki/ (inline flow-style lists, unquoted
|
conventions already used across kb/ (inline flow-style lists, unquoted
|
||||||
dates).
|
dates).
|
||||||
|
|
||||||
We deliberately avoid a generic yaml.dump() for the frontmatter block because
|
We deliberately avoid a generic yaml.dump() for the frontmatter block because
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ def _section(lines: list[str], title: str, items: list, formatter) -> None:
|
|||||||
|
|
||||||
def render_markdown(report: dict) -> str:
|
def render_markdown(report: dict) -> str:
|
||||||
lines = [f"# Structural Lint Report ({report['generated']})", ""]
|
lines = [f"# Structural Lint Report ({report['generated']})", ""]
|
||||||
lines.append(f"Scanned {report['page_count']} pages under `wiki/`. This report covers only")
|
lines.append(f"Scanned {report['page_count']} pages under `kb/`. This report covers only")
|
||||||
lines.append("mechanically-verifiable structural issues; see the Semantic Review section")
|
lines.append("mechanically-verifiable structural issues; see the Semantic Review section")
|
||||||
lines.append("below for judgment calls the LLM should complete.")
|
lines.append("below for judgment calls the LLM should complete.")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
@@ -4,10 +4,35 @@ One definition, because the two must agree: if telemetry grouped events
|
|||||||
differently from the way the budget counts calls, a trace could not be read
|
differently from the way the budget counts calls, a trace could not be read
|
||||||
against the gate that refused it.
|
against the gate that refused it.
|
||||||
|
|
||||||
A "session" is approximated by the parent process of this CLI invocation - the
|
Three-step fallback chain, in order:
|
||||||
agent's shell - unless the caller sets `WIKITOOL_SESSION_ID`. Skills set it
|
|
||||||
explicitly so a session is scoped to a task rather than to a terminal window
|
1. `WIKITOOL_SESSION_ID`, if the caller set one explicitly. Skills set it so a
|
||||||
(see instructions/session-setup.md).
|
session is scoped to a task rather than to a terminal window (see
|
||||||
|
instructions/session-setup.md).
|
||||||
|
2. A harness's own session variable, from `HARNESS_ENV_VARS` below - checked
|
||||||
|
only when nothing set the variable above.
|
||||||
|
3. `os.getppid()` - the parent process of this CLI invocation. On a harness
|
||||||
|
that runs every tool call in a freshly initialised shell (Claude Code's
|
||||||
|
Bash tool does), this is a new "session" per call and neither the
|
||||||
|
iteration-budget gate's ceiling nor its loop-breaker can ever trip - see
|
||||||
|
Gitea #110, which measured a 33-call run splitting into 21 telemetry
|
||||||
|
buckets under this fallback alone.
|
||||||
|
|
||||||
|
Step 2 is what closes that gap without asking every skill to `export` a
|
||||||
|
variable a harness already re-derives per call: `CLAUDE_CODE_SESSION_ID` is
|
||||||
|
stable across a Claude Code session's tool calls (verified 2026-09-16,
|
||||||
|
against a live session, across separate Bash invocations - the shell's own
|
||||||
|
PID changed on every call, this variable did not) and is **exactly** the id
|
||||||
|
the `UserPromptSubmit` hook writes into a trace's `session.start` and
|
||||||
|
`prompt.submitted` events. Using it unmodified as the budget/telemetry key -
|
||||||
|
no prefix, no rewriting - is what lets the hook's events and this module's
|
||||||
|
events land in the same bucket.
|
||||||
|
|
||||||
|
`HARNESS_ENV_VARS` only ever grows by a verified entry: a variable a real
|
||||||
|
session was observed setting, confirmed to be the same id a harness's own
|
||||||
|
hooks use elsewhere in a trace. A guessed name that happens to exist and
|
||||||
|
means something else would be worse than the `getppid()` fallback it would
|
||||||
|
replace - it would look like a fix and quietly mis-key a session instead.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,15 +41,46 @@ import re
|
|||||||
|
|
||||||
ENV_VAR = "WIKITOOL_SESSION_ID"
|
ENV_VAR = "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
HARNESS_ENV_VARS: tuple[tuple[str, str], ...] = (
|
||||||
|
("CLAUDE_CODE_SESSION_ID", "claude-code"),
|
||||||
|
)
|
||||||
|
|
||||||
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _harness_session() -> tuple[str, str] | None:
|
||||||
|
"""The first harness variable that is actually set, as `(value, harness)`."""
|
||||||
|
for var, harness in HARNESS_ENV_VARS:
|
||||||
|
value = os.environ.get(var)
|
||||||
|
if value:
|
||||||
|
return value, harness
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def session_id() -> str:
|
def session_id() -> str:
|
||||||
return os.environ.get(ENV_VAR) or str(os.getppid())
|
explicit = os.environ.get(ENV_VAR)
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
harness = _harness_session()
|
||||||
|
if harness:
|
||||||
|
return harness[0]
|
||||||
|
return str(os.getppid())
|
||||||
|
|
||||||
|
|
||||||
def session_id_source() -> str:
|
def session_id_source() -> str:
|
||||||
return ENV_VAR if os.environ.get(ENV_VAR) else "getppid() fallback"
|
"""Where the id in `session_id()` came from - `ENV_VAR`, a harness
|
||||||
|
variable name (with the harness named alongside it), or the `getppid()`
|
||||||
|
fallback. `doctor`, `budget status` and the `wikitool` source's
|
||||||
|
`session.start` event all read this so a session - or a trace - can say
|
||||||
|
what it was keyed on, not just what the id happened to be."""
|
||||||
|
if os.environ.get(ENV_VAR):
|
||||||
|
return ENV_VAR
|
||||||
|
harness = _harness_session()
|
||||||
|
if harness:
|
||||||
|
_, name = harness
|
||||||
|
var = next(v for v, h in HARNESS_ENV_VARS if h == name)
|
||||||
|
return f"{var} ({name})"
|
||||||
|
return "getppid() fallback"
|
||||||
|
|
||||||
|
|
||||||
def session_slug(value: str | None = None) -> str:
|
def session_slug(value: str | None = None) -> str:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from chemenu import config
|
from chemenu import config
|
||||||
from chemenu.session import session_id as current_session_id
|
from chemenu.session import session_id as current_session_id
|
||||||
|
from chemenu.session import session_id_source as current_session_id_source
|
||||||
from chemenu.session import session_slug
|
from chemenu.session import session_slug
|
||||||
from chemenu.telemetry import policy, schema, scrub
|
from chemenu.telemetry import policy, schema, scrub
|
||||||
|
|
||||||
@@ -155,14 +156,22 @@ def _seed_session_header(target: Path, source: str, session: str) -> None:
|
|||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
return
|
return
|
||||||
with handle:
|
with handle:
|
||||||
header = schema.make_event(
|
attrs = {
|
||||||
source,
|
|
||||||
"session.start",
|
|
||||||
{
|
|
||||||
"harness": source,
|
"harness": source,
|
||||||
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
|
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
|
||||||
"synthesized": True,
|
"synthesized": True,
|
||||||
},
|
}
|
||||||
|
# Only the `wikitool` source resolves its own session id through
|
||||||
|
# chemenu.session's fallback chain - every other source hands `emit()`
|
||||||
|
# an id its own hook payload already carried. Naming the chain's
|
||||||
|
# outcome here is what lets a trace say what it was keyed on, not just
|
||||||
|
# what the id happened to be (Gitea #110).
|
||||||
|
if source == "wikitool":
|
||||||
|
attrs["session_origin"] = current_session_id_source()
|
||||||
|
header = schema.make_event(
|
||||||
|
source,
|
||||||
|
"session.start",
|
||||||
|
attrs,
|
||||||
session_id=session,
|
session_id=session,
|
||||||
seq=_next_seq(),
|
seq=_next_seq(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from chemenu import config, conventions
|
from chemenu import config, conventions
|
||||||
from chemenu.frontmatter_io import write_page
|
from chemenu.frontmatter_io import write_page
|
||||||
|
from chemenu.session import HARNESS_ENV_VARS
|
||||||
from chemenu.telemetry import policy as telemetry_policy
|
from chemenu.telemetry import policy as telemetry_policy
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
@@ -13,6 +14,13 @@ from chemenu.type_resolver import resolver
|
|||||||
# that a test which needs one sets it itself and the rest run against the
|
# that a test which needs one sets it itself and the rest run against the
|
||||||
# tool's own defaults. `WIKI_TRACE_DIR` is deliberately absent: it is not a
|
# tool's own defaults. `WIKI_TRACE_DIR` is deliberately absent: it is not a
|
||||||
# leak but the redirect `isolated_trace_dir` installs one fixture below.
|
# leak but the redirect `isolated_trace_dir` installs one fixture below.
|
||||||
|
#
|
||||||
|
# The harness variables from `chemenu.session.HARNESS_ENV_VARS` are pulled in
|
||||||
|
# here rather than duplicated: this suite runs *inside* Claude Code, so
|
||||||
|
# `CLAUDE_CODE_SESSION_ID` is genuinely set in the real environment - without
|
||||||
|
# clearing it, every session-fallback test would silently pick up this
|
||||||
|
# session's real id instead of exercising the fallback it means to test
|
||||||
|
# (Gitea #110).
|
||||||
_WIKITOOL_ENV = (
|
_WIKITOOL_ENV = (
|
||||||
"WIKI_AUTHOR",
|
"WIKI_AUTHOR",
|
||||||
"WIKI_TRACE",
|
"WIKI_TRACE",
|
||||||
@@ -24,7 +32,7 @@ _WIKITOOL_ENV = (
|
|||||||
"WIKITOOL_UPDATE_URL",
|
"WIKITOOL_UPDATE_URL",
|
||||||
"WIKITOOL_UPDATE_TOKEN",
|
"WIKITOOL_UPDATE_TOKEN",
|
||||||
"CHEMENU_ROOT",
|
"CHEMENU_ROOT",
|
||||||
)
|
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
|
||||||
|
|
||||||
# Environment git reads for identity or for where its repo lives. A stray
|
# Environment git reads for identity or for where its repo lives. A stray
|
||||||
# `GIT_DIR` would point every fixture repo at the developer's checkout; the
|
# `GIT_DIR` would point every fixture repo at the developer's checkout; the
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""The CLI dispatch wrapper: the budget/trace hook every command runs
|
||||||
|
through (`cli.main`/`cli._run_traced`), and the broken-pipe handling that
|
||||||
|
sits alongside it.
|
||||||
|
|
||||||
|
Gitea #110's SIGPIPE side finding: a successful call whose output is cut off
|
||||||
|
by a closed pipe (`wikitool types describe source | head -1`) used to record
|
||||||
|
`exit_code: 1` in the trace - indistinguishable from a real tool failure, and
|
||||||
|
reproduced verbatim by the very next, unpiped retry of the same command
|
||||||
|
showing `exit_code: 0`. `cli._BrokenPipeSwallow` and `cli._pacify_real_fd`
|
||||||
|
exist to close that gap; these tests exercise them without depending on a
|
||||||
|
real OS pipe or Click's own internal handling, which is exactly the moving
|
||||||
|
part being routed around.
|
||||||
|
"""
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chemenu import cli
|
||||||
|
|
||||||
|
|
||||||
|
def read_lines(path):
|
||||||
|
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingStream:
|
||||||
|
"""Raises EPIPE on the `fail_on`-th write - a fake stream standing in for
|
||||||
|
a real pipe whose reader has already closed."""
|
||||||
|
|
||||||
|
def __init__(self, fail_on=1):
|
||||||
|
self.fail_on = fail_on
|
||||||
|
self.calls = 0
|
||||||
|
self.written = []
|
||||||
|
self.flushed = False
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls >= self.fail_on:
|
||||||
|
raise OSError(errno.EPIPE, "Broken pipe")
|
||||||
|
self.written.append(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.flushed = True
|
||||||
|
|
||||||
|
def isatty(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- _BrokenPipeSwallow ---
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_absorbs_epipe_on_write():
|
||||||
|
swallow = cli._BrokenPipeSwallow(_FailingStream(fail_on=1))
|
||||||
|
n = swallow.write("hello")
|
||||||
|
assert n == len("hello")
|
||||||
|
assert swallow.sigpipe is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_absorbs_epipe_on_flush():
|
||||||
|
class _FlushFails:
|
||||||
|
def flush(self):
|
||||||
|
raise OSError(errno.EPIPE, "Broken pipe")
|
||||||
|
|
||||||
|
swallow = cli._BrokenPipeSwallow(_FlushFails())
|
||||||
|
swallow.flush() # does not raise
|
||||||
|
assert swallow.sigpipe is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_reraises_unrelated_oserrors():
|
||||||
|
class _Explodes:
|
||||||
|
def write(self, data):
|
||||||
|
raise OSError(errno.ENOSPC, "No space left on device")
|
||||||
|
|
||||||
|
swallow = cli._BrokenPipeSwallow(_Explodes())
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
swallow.write("x")
|
||||||
|
assert swallow.sigpipe is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_passes_through_normal_writes():
|
||||||
|
wrapped = _FailingStream(fail_on=99)
|
||||||
|
swallow = cli._BrokenPipeSwallow(wrapped)
|
||||||
|
swallow.write("hello")
|
||||||
|
assert wrapped.written == ["hello"]
|
||||||
|
assert swallow.sigpipe is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pipe_swallow_proxies_unknown_attributes():
|
||||||
|
wrapped = _FailingStream()
|
||||||
|
swallow = cli._BrokenPipeSwallow(wrapped)
|
||||||
|
assert swallow.isatty() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- _pacify_real_fd ---
|
||||||
|
|
||||||
|
def test_pacify_real_fd_is_a_best_effort_noop_without_a_real_descriptor():
|
||||||
|
class _RaisesOSError:
|
||||||
|
def fileno(self):
|
||||||
|
raise OSError("not a real fd in this test")
|
||||||
|
|
||||||
|
class _HasNoFilenoAtAll:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cli._pacify_real_fd(_RaisesOSError()) # must not raise
|
||||||
|
cli._pacify_real_fd(_HasNoFilenoAtAll()) # must not raise either
|
||||||
|
|
||||||
|
|
||||||
|
# --- _run_traced: the trace records what actually happened ---
|
||||||
|
|
||||||
|
def test_a_write_cut_off_by_a_closed_pipe_is_not_recorded_as_an_error(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "sigpipe-unit")
|
||||||
|
stub = _FailingStream(fail_on=2) # first write succeeds, second hits EPIPE
|
||||||
|
monkeypatch.setattr(sys, "stdout", stub)
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
sys.stdout.write("line one\n")
|
||||||
|
sys.stdout.write("line two\n") # truncated here, like `| head -1`
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli._run_traced("types", ["describe", "source"])
|
||||||
|
assert exc.value.code == 0
|
||||||
|
|
||||||
|
records = read_lines(tmp_path / "sigpipe-unit" / "trace.jsonl")
|
||||||
|
call = next(r for r in records if r["event"] == "wikitool.call")
|
||||||
|
assert call["attrs"]["exit_code"] == 0
|
||||||
|
assert call["attrs"]["stdout_truncated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_failure_is_still_recorded_as_one(monkeypatch, tmp_path):
|
||||||
|
"""The unrelated-error path stays exactly as before: an actual failure
|
||||||
|
keeps its exit code and carries no truncation flag."""
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "real-failure-unit")
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
cli._run_traced("new", ["entity", "--name", ""])
|
||||||
|
assert exc.value.code == 1
|
||||||
|
|
||||||
|
records = read_lines(tmp_path / "real-failure-unit" / "trace.jsonl")
|
||||||
|
call = next(r for r in records if r["event"] == "wikitool.call")
|
||||||
|
assert call["attrs"]["exit_code"] == 1
|
||||||
|
assert "stdout_truncated" not in call["attrs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit")
|
||||||
|
real_stdout, real_stderr = sys.stdout, sys.stderr
|
||||||
|
|
||||||
|
def fake_app():
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "app", fake_app)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli._run_traced("lint", [])
|
||||||
|
assert sys.stdout is real_stdout
|
||||||
|
assert sys.stderr is real_stderr
|
||||||
@@ -176,6 +176,140 @@ def test_keep_local_proceeds_and_leaves_the_changed_file_untouched(instance, tmp
|
|||||||
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_names_all_three_answers_with_a_pasteable_take_release_line(
|
||||||
|
instance, tmp_path, capsys
|
||||||
|
):
|
||||||
|
"""The abort a run actually reads has to rule out "the default takes the
|
||||||
|
release's version" - a real 5.0.0 -> 6.0.0 run on an instance announced
|
||||||
|
exactly that belief and then called `dist upgrade` with no flag. So the
|
||||||
|
text names all three answers, says none of them is the default, and carries
|
||||||
|
the `--take-release` line with the blocked paths already filled in."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
||||||
|
assert "none of these three is the default" in out
|
||||||
|
assert "--take-release AGENTS.md" in out
|
||||||
|
assert "--keep-local" in out
|
||||||
|
assert "reconcile them by hand" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- --take-release: the other answer to a locally changed file ---------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_release_overwrites_the_named_path_and_clears_the_drift(instance, tmp_path):
|
||||||
|
"""The point of the flag, in one run rather than three hand steps: the
|
||||||
|
local change is gone, and because the new stamp records the release digest
|
||||||
|
for a file that now *matches* it, the path is no longer divergent - unlike
|
||||||
|
`--keep-local`, which reports it again on every future upgrade."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
|
||||||
|
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
|
||||||
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||||
|
assert stamp["version"] == "1.1.0"
|
||||||
|
# The recorded digest and the file on disk agree again, so a second run
|
||||||
|
# classifies it as unchanged rather than blocked.
|
||||||
|
assert kb_state.compare_against_stamp({"AGENTS.md": stamp["files"]["AGENTS.md"]}) == {
|
||||||
|
"AGENTS.md": kb_state.UNCHANGED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_release_recreates_a_locally_deleted_file(instance, tmp_path):
|
||||||
|
(instance / "tools" / "wikitool").unlink()
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, take_release=["tools/wikitool"])
|
||||||
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/sh v2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_release_and_keep_local_compose_per_path(instance, tmp_path):
|
||||||
|
"""The mixed case is the one a blanket flag could not express: two changed
|
||||||
|
files, one to reset and one to keep."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"], keep_local=True)
|
||||||
|
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "core v2\n"
|
||||||
|
assert (instance / "tools" / "wikitool").read_text(encoding="utf-8") == "#!/bin/zsh\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blocked_path_not_named_by_take_release_still_aborts(instance, tmp_path):
|
||||||
|
"""Without `--keep-local` the run must say something about every blocked
|
||||||
|
path, not just the ones it happened to name."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
(instance / "tools" / "wikitool").write_text("#!/bin/zsh\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh v2\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
dist_cmd.run_upgrade(release, take_release=["AGENTS.md"])
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
# Nothing was written, including the path that *was* named.
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
assert json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())["version"] == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_take_release_refuses_a_path_that_is_not_locally_changed(instance, tmp_path, capsys):
|
||||||
|
"""A typo that silently did nothing would report a successful upgrade while
|
||||||
|
keeping the change the operator asked to discard."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
dist_cmd.run_upgrade(release, take_release=["AGENT.md"])
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
out = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "AGENT.md" in out # the typo, named back
|
||||||
|
assert "AGENTS.md" in out # and the list of what *is* blocked
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_take_release_path_fails_in_the_dry_run_too(instance, tmp_path):
|
||||||
|
"""The one thing that turns `--dry-run` non-zero: not a state of the tree
|
||||||
|
(a blocked file must never do that), but a mistake in the argument, which
|
||||||
|
is exactly what a preview is for."""
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
dist_cmd.run_upgrade(release, dry_run=True, take_release=["types/entity.md"])
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_marks_the_paths_take_release_would_overwrite(instance, tmp_path, capsys):
|
||||||
|
(instance / "AGENTS.md").write_text("locally edited\n", encoding="utf-8")
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core v2\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
dist_cmd.run_upgrade(release, dry_run=True, take_release=["AGENTS.md"])
|
||||||
|
out = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "--take-release" in out
|
||||||
|
assert (instance / "AGENTS.md").read_text(encoding="utf-8") == "locally edited\n"
|
||||||
|
|
||||||
|
|
||||||
# --- the write set -----------------------------------------------------------
|
# --- the write set -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +326,24 @@ def test_unchanged_and_new_files_are_written_silently(instance, tmp_path):
|
|||||||
assert stamp["files"]["types/entity.md"] == _digest("new\n")
|
assert stamp["files"]["types/entity.md"] == _digest("new\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, capsys):
|
||||||
|
"""Everything after the swap has exactly one written order, and it is not
|
||||||
|
this line: the report names the instruction that carries it and the command
|
||||||
|
the run resumes at, rather than a second copy of the list that drifts
|
||||||
|
(AGENTS.md invariant 8). A run that reads only this output must still be
|
||||||
|
able to find the rest."""
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{"AGENTS.md": "core\n", "tools/wikitool": "#!/bin/sh\n"},
|
||||||
|
)
|
||||||
|
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
|
||||||
|
assert "instructions/upgrade-instance.md" in out
|
||||||
|
assert "instructions sync" in out
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
|
||||||
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
|
def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_them(
|
||||||
instance, tmp_path, preserved
|
instance, tmp_path, preserved
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def test_log_status_reports_zero_with_no_log_file(tmp_path, monkeypatch, capsys)
|
|||||||
|
|
||||||
monkeypatch.setattr(config, "LOG_FILE", tmp_path / "log.md")
|
monkeypatch.setattr(config, "LOG_FILE", tmp_path / "log.md")
|
||||||
log_status()
|
log_status()
|
||||||
assert "No wiki/log.md yet" in capsys.readouterr().out
|
assert "No kb/log.md yet" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
def test_log_status_warns_at_the_ten_ingest_threshold(tmp_path, monkeypatch, capsys):
|
def test_log_status_warns_at_the_ten_ingest_threshold(tmp_path, monkeypatch, capsys):
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir):
|
|||||||
assert "# gateway.example.net" in body
|
assert "# gateway.example.net" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_entity_still_materializes_empty_arrays_for_unset_optional_fields(monkeypatch, kb_dir):
|
||||||
|
"""Gitea #109 stops materializing an optional field's schema `default:`,
|
||||||
|
but `tags`/`related`/`sources` are optional arrays with no `default:` at
|
||||||
|
all - they must keep landing as `[]`, not disappear. Their absence would
|
||||||
|
make `_apply_template_variables` fall back to the filter suffix rendered
|
||||||
|
literally (`{related|bullets}` -> the word "bullets" left in the body)."""
|
||||||
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
|
"new", "entity", "--name", "Bare", "--set", "entity_type=tool",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
fm, body = read_page(kb_dir / "entities/tools/Bare.md")
|
||||||
|
assert fm["tags"] == []
|
||||||
|
assert fm["related"] == []
|
||||||
|
assert fm["sources"] == []
|
||||||
|
assert "bullets" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
|
def test_a_scaffolded_body_carries_no_tool_owned_region(monkeypatch, kb_dir):
|
||||||
"""A template must not scaffold the links or footnotes regions. They are
|
"""A template must not scaffold the links or footnotes regions. They are
|
||||||
generated between markers from frontmatter and re-rendered on every write,
|
generated between markers from frontmatter and re-rendered on every write,
|
||||||
@@ -403,6 +420,48 @@ def test_raw_files_error_points_at_the_comma_split(monkeypatch, kb_dir, raw_dir)
|
|||||||
assert "never rename the raw file" in result.output
|
assert "never rename the raw file" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_new_instruction(monkeypatch, tmp_path, args):
|
||||||
|
"""Invoke `new` for a `root: repo` type. `instruction` resolves its
|
||||||
|
`base_dir:` against `config.ROOT`, not `config.KB_DIR` - unlike
|
||||||
|
`_invoke_new`'s callers, patching `KB_DIR` alone would leave the scaffold
|
||||||
|
writing into this checkout's real `instructions/` (Gitea #109's fixture
|
||||||
|
note). Repointing `ROOT` pulls `TYPES_DIR` along with it, so
|
||||||
|
`use_shipped_type_specs` restores the real, shipped type-specs."""
|
||||||
|
import chemenu.config as config
|
||||||
|
from chemenu.cli import app
|
||||||
|
from chemenu.tests.conftest import use_shipped_type_specs
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
use_shipped_type_specs(monkeypatch)
|
||||||
|
(tmp_path / "instructions").mkdir(parents=True, exist_ok=True)
|
||||||
|
return runner.invoke(app, args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_instruction_omits_migration_only_default(monkeypatch, tmp_path):
|
||||||
|
"""Gitea #109: `obligation:` is a migration-only field (`instructions/
|
||||||
|
migrations/*`) with a schema `default:` but no `required:` entry. The
|
||||||
|
scaffold must not materialize it into an ordinary instruction."""
|
||||||
|
result = _invoke_new_instruction(monkeypatch, tmp_path, [
|
||||||
|
"new", "instruction", "--name", "probe",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
fm, _body = read_page(tmp_path / "instructions/probe.md")
|
||||||
|
assert "obligation" not in fm
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_instruction_explicit_obligation_is_still_written(monkeypatch, tmp_path):
|
||||||
|
"""The rule only suppresses the *implicit* default - an explicit
|
||||||
|
`--set obligation=offered` (as when hand-scaffolding a migration
|
||||||
|
document) must still land in the frontmatter."""
|
||||||
|
result = _invoke_new_instruction(monkeypatch, tmp_path, [
|
||||||
|
"new", "instruction", "--name", "probe-migration",
|
||||||
|
"--set", "obligation=offered",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
fm, _body = read_page(tmp_path / "instructions/probe-migration.md")
|
||||||
|
assert fm["obligation"] == "offered"
|
||||||
|
|
||||||
|
|
||||||
def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_dir, raw_dir):
|
def test_source_page_accepts_a_raw_file_whose_name_has_a_comma(monkeypatch, kb_dir, raw_dir):
|
||||||
import chemenu.config as config
|
import chemenu.config as config
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from chemenu import config
|
||||||
from chemenu.commands import run_budget
|
from chemenu.commands import run_budget
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +141,39 @@ def test_override_bypasses_both_gates():
|
|||||||
assert state["test-session"]["count"] == 40
|
assert state["test-session"]["count"] == 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_without_a_recorded_origin_keeps_its_count():
|
||||||
|
"""Grandfathering (#110 decision 1): an entry written before this field
|
||||||
|
existed must not be reset the moment this ships - that would throw away
|
||||||
|
real, in-flight state on every existing instance's first call after
|
||||||
|
upgrading."""
|
||||||
|
run_budget._save_state({"test-session": {"count": 5, "recent": ["lint"]}})
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
entry = run_budget._load_state()["test-session"]
|
||||||
|
assert entry["count"] == 6
|
||||||
|
assert entry["source"] == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_with_a_different_recorded_origin_starts_over():
|
||||||
|
"""The same id string, stamped by a different origin than the one
|
||||||
|
recorded, is treated as a stranger's bucket rather than inherited - the
|
||||||
|
mechanism behind #110's 'no bucket is silently reinterpreted' criterion."""
|
||||||
|
run_budget._save_state(
|
||||||
|
{"test-session": {"count": 40, "recent": ["lint"], "source": "getppid() fallback"}}
|
||||||
|
)
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
entry = run_budget._load_state()["test-session"]
|
||||||
|
assert entry["count"] == 1
|
||||||
|
assert entry["source"] == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bucket_with_the_same_recorded_origin_keeps_counting():
|
||||||
|
run_budget._save_state(
|
||||||
|
{"test-session": {"count": 7, "recent": ["lint"], "source": "WIKITOOL_SESSION_ID"}}
|
||||||
|
)
|
||||||
|
run_budget.record_and_check("lint", [], override=False)
|
||||||
|
assert run_budget._load_state()["test-session"]["count"] == 8
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_writes_atomically_and_leaves_no_tmp_file(isolated_state):
|
def test_save_state_writes_atomically_and_leaves_no_tmp_file(isolated_state):
|
||||||
run_budget._save_state({"test-session": {"count": 1, "recent": []}})
|
run_budget._save_state({"test-session": {"count": 1, "recent": []}})
|
||||||
assert isolated_state.exists()
|
assert isolated_state.exists()
|
||||||
@@ -228,3 +265,51 @@ def test_status_command_reports_count(capsys):
|
|||||||
run_budget.status_command()
|
run_budget.status_command()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Calls so far: 1" in out
|
assert "Calls so far: 1" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- gate reproduced across separate processes (Gitea #110) ---
|
||||||
|
#
|
||||||
|
# `isolated_state`'s in-process monkeypatching cannot exercise the actual bug:
|
||||||
|
# `os.getppid()` is constant within one test process. These spawn a fresh
|
||||||
|
# Python subprocess per call - the same shape as Claude Code's Bash tool,
|
||||||
|
# which runs every `wikitool` invocation in a freshly initialised shell - so
|
||||||
|
# the parent pid really does differ call to call, and only a harness variable
|
||||||
|
# (standing in for `CLAUDE_CODE_SESSION_ID`) can hold the run together.
|
||||||
|
# Before the fallback chain existed, both tests below would be green *and*
|
||||||
|
# blind: every call landed in its own one-or-two-call bucket, and neither
|
||||||
|
# gate could ever see enough of one session to trip.
|
||||||
|
|
||||||
|
def _spawn_call(tmp_path, monkeypatch, *, command="lint", args=(), override=False):
|
||||||
|
monkeypatch.setenv("CHEMENU_ROOT", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "harness-run")
|
||||||
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
||||||
|
code = (
|
||||||
|
"from chemenu.commands import run_budget\n"
|
||||||
|
f"run_budget.record_and_check({command!r}, {list(args)!r}, override={override!r})\n"
|
||||||
|
)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", code],
|
||||||
|
cwd=config._PACKAGE_ROOT / "tools",
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_iteration_budget_gate_trips_across_separate_shells(tmp_path, monkeypatch):
|
||||||
|
for i in range(run_budget.DEFAULT_CALL_LIMIT):
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, args=[f"--pass-{i}"])
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, args=["--one-too-many"])
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "Iteration Budget Gate" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_loop_breaker_trips_across_separate_shells(tmp_path, monkeypatch):
|
||||||
|
args = ["add", "--a", "X", "--b", "Y"]
|
||||||
|
for _ in range(run_budget.DEFAULT_LOOP_WINDOW):
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, command="xref", args=args)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
|
||||||
|
result = _spawn_call(tmp_path, monkeypatch, command="xref", args=args)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "Loop-Breaker" in result.stdout
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Guard against a renamed stage leaving its old path standing in source.
|
||||||
|
|
||||||
|
`wiki/` was renamed to `kb/` on 2026-08-21. The directory moved; the string did
|
||||||
|
not, in 33 places - error messages, `--help` text, docstrings and the lint
|
||||||
|
report's own header, all naming a directory that no longer exists. Nothing
|
||||||
|
caught it, because no check reads a path literal in source.
|
||||||
|
|
||||||
|
This is that check. It is deliberately a plain substring scan over the source
|
||||||
|
tree rather than a `docs verify` check: `docs verify` reads `shipped_prose()`,
|
||||||
|
which is markdown only, and the bulk of the defect sat in `.py` strings.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Retired stage path -> what replaced it. A future rename adds a row here in the
|
||||||
|
# same change that does the renaming, which is what makes the next occurrence a
|
||||||
|
# test failure instead of a string nobody reads for a year.
|
||||||
|
RETIRED_STAGE_PATHS = {
|
||||||
|
"wiki/": "kb/",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Occurrences that are not stage paths at all. Kept as an explicit list with a
|
||||||
|
# reason rather than dodged by a cleverer regex: an exception a reader can see
|
||||||
|
# is worth more than one a pattern hides.
|
||||||
|
ALLOWED = {
|
||||||
|
# A fixture release URL, where `wiki` is a repository name in `owner/repo`.
|
||||||
|
("tests/test_dist_cmd.py", "https://example/torben/wiki/releases/tag/v0.3.1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SELF = Path(__file__).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _scanned_files() -> list[Path]:
|
||||||
|
"""Every source file in `tools/` a stale path literal could hide in.
|
||||||
|
|
||||||
|
This module is excluded, and has to be: it is the one file whose job is to
|
||||||
|
name the retired paths, so scanning it would make the guard fail on its own
|
||||||
|
declaration.
|
||||||
|
"""
|
||||||
|
candidates = [*(TOOLS_DIR / "chemenu").rglob("*.py"), TOOLS_DIR / "wikitool"]
|
||||||
|
return sorted(path for path in candidates if path.resolve() != SELF)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retired_stage_path_survives_in_source():
|
||||||
|
findings = []
|
||||||
|
for path in _scanned_files():
|
||||||
|
relative = path.relative_to(TOOLS_DIR / "chemenu" if path.suffix == ".py" else TOOLS_DIR)
|
||||||
|
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||||
|
for retired, replacement in RETIRED_STAGE_PATHS.items():
|
||||||
|
if retired not in line:
|
||||||
|
continue
|
||||||
|
if any(key == str(relative) and excerpt in line for key, excerpt in ALLOWED):
|
||||||
|
continue
|
||||||
|
findings.append(
|
||||||
|
f"{relative}:{number} names the retired path `{retired}` "
|
||||||
|
f"(now `{replacement}`): {line.strip()}"
|
||||||
|
)
|
||||||
|
assert findings == [], "Retired stage paths still in source:\n" + "\n".join(findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_actually_scans_something():
|
||||||
|
"""A scan that silently matches no file passes for the wrong reason."""
|
||||||
|
scanned = _scanned_files()
|
||||||
|
assert len(scanned) > 40
|
||||||
|
assert TOOLS_DIR / "wikitool" in scanned
|
||||||
|
assert SELF not in scanned
|
||||||
@@ -145,6 +145,30 @@ def test_session_id_falls_back_to_the_parent_process(monkeypatch):
|
|||||||
assert "getppid" in session.session_id_source()
|
assert "getppid" in session.session_id_source()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_id_prefers_a_harness_variable_over_getppid(monkeypatch):
|
||||||
|
"""The middle link of the chain (Gitea #110): a harness that sets its own
|
||||||
|
session variable, but not WIKITOOL_SESSION_ID, still gets a stable id
|
||||||
|
rather than falling all the way to the per-call parent pid."""
|
||||||
|
monkeypatch.delenv("WIKITOOL_SESSION_ID", raising=False)
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "30d734d5-live-session")
|
||||||
|
assert session.session_id() == "30d734d5-live-session"
|
||||||
|
assert session.session_id_source() == "CLAUDE_CODE_SESSION_ID (claude-code)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_session_id_still_wins_over_a_harness_variable(monkeypatch):
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "ingest-handbook/u2")
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "30d734d5-live-session")
|
||||||
|
assert session.session_id() == "ingest-handbook/u2"
|
||||||
|
assert session.session_id_source() == "WIKITOOL_SESSION_ID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_harness_id_is_used_unmodified_as_the_key():
|
||||||
|
"""Decision 1 in #110: no prefix, no rewriting - the value has to be
|
||||||
|
exactly what a harness's own hook writes into a trace, or the two would
|
||||||
|
stop joining on the same string."""
|
||||||
|
assert session.HARNESS_ENV_VARS == (("CLAUDE_CODE_SESSION_ID", "claude-code"),)
|
||||||
|
|
||||||
|
|
||||||
def test_the_core_event_set_is_what_every_harness_can_produce():
|
def test_the_core_event_set_is_what_every_harness_can_produce():
|
||||||
"""Guards the degradation rule: if a core event stops being available on one
|
"""Guards the degradation rule: if a core event stops being available on one
|
||||||
harness, this fails rather than the scorer silently reporting zero."""
|
harness, this fails rather than the scorer silently reporting zero."""
|
||||||
@@ -185,6 +209,25 @@ def test_a_reported_session_start_is_not_shadowed_by_a_synthetic_one(monkeypatch
|
|||||||
assert "synthesized" not in records[0]["attrs"]
|
assert "synthesized" not in records[0]["attrs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wikitool_s_own_header_names_where_its_session_id_came_from(monkeypatch, tmp_path):
|
||||||
|
"""Only the `wikitool` source resolves its own id through the fallback
|
||||||
|
chain - naming that origin in its own header is what lets a trace say
|
||||||
|
what it was keyed on, not just what the id happened to be (Gitea #110)."""
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "harness-session")
|
||||||
|
emit_mod.write_event("wikitool", "wikitool.call", {"command": "lint"},
|
||||||
|
session="harness-session")
|
||||||
|
header = read_lines(tmp_path / "harness-session" / "trace.jsonl")[0]
|
||||||
|
assert header["attrs"]["session_origin"] == "CLAUDE_CODE_SESSION_ID (claude-code)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_source_s_header_carries_no_session_origin(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
|
emit_mod.write_event("copilot-cli", "tool.post", {"tool_name": "bash"}, session="v4")
|
||||||
|
header = read_lines(tmp_path / "v4" / "trace.jsonl")[0]
|
||||||
|
assert "session_origin" not in header["attrs"]
|
||||||
|
|
||||||
|
|
||||||
# --- reading back ---
|
# --- reading back ---
|
||||||
|
|
||||||
def test_a_trace_reads_back_in_time_order(monkeypatch, tmp_path):
|
def test_a_trace_reads_back_in_time_order(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def _touch(**overrides):
|
|||||||
def test_touch_bumps_modified(touch_wiki):
|
def test_touch_bumps_modified(touch_wiki):
|
||||||
_touch(page_title="aurora")
|
_touch(page_title="aurora")
|
||||||
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
frontmatter, _ = read_page(touch_wiki / "entities/systems/aurora.md")
|
||||||
# Unquoted YAML dates round-trip as date objects, matching the rest of wiki/.
|
# Unquoted YAML dates round-trip as date objects, matching the rest of kb/.
|
||||||
assert str(frontmatter["modified"]) == datetime.date.today().isoformat()
|
assert str(frontmatter["modified"]) == datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ def test_get_layout_reads_entity_type_specs_own_layout_field():
|
|||||||
"person": "people",
|
"person": "people",
|
||||||
}
|
}
|
||||||
assert all(spec.get("title") for spec in layout.values())
|
assert all(spec.get("title") for spec in layout.values())
|
||||||
# Order drives wiki/index.md section order.
|
# Order drives kb/index.md section order.
|
||||||
assert list(layout) == ["project", "system", "tool", "technology", "person"]
|
assert list(layout) == ["project", "system", "tool", "technology", "person"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -995,7 +995,7 @@ def test_release_refuses_when_version_and_changelog_disagree(tree):
|
|||||||
|
|
||||||
|
|
||||||
def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
||||||
version_cmd.notes_command(version=None)
|
version_cmd.run_notes()
|
||||||
assert "## 1.0.0" in capsys.readouterr().out
|
assert "## 1.0.0" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
@@ -1008,7 +1008,7 @@ def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
|||||||
major=False, minor=False, patch=True, title="Second bump",
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.notes_command(version=None)
|
version_cmd.run_notes()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "## 1.1.0-beta.2" in out
|
assert "## 1.1.0-beta.2" in out
|
||||||
assert "First bump" in out and "Second bump" in out
|
assert "First bump" in out and "Second bump" in out
|
||||||
@@ -1016,7 +1016,115 @@ def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
|||||||
|
|
||||||
def test_notes_fails_for_a_version_with_no_entry(tree):
|
def test_notes_fails_for_a_version_with_no_entry(tree):
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.notes_command(version="9.9.9")
|
version_cmd.run_notes(version="9.9.9")
|
||||||
|
|
||||||
|
|
||||||
|
# --- version notes on a distributed instance --------------------------------
|
||||||
|
#
|
||||||
|
# Such an instance receives CHANGES.md as a nine-line stub with no version
|
||||||
|
# entries, and `dist upgrade` never overwrites it, so the local file it would
|
||||||
|
# read can never carry the entry - not today and not after any future release.
|
||||||
|
# The run that found this (a traced 5.0.0 -> 6.0.0 upgrade) only got past the
|
||||||
|
# step because it read the release page through an MCP server, which is not a
|
||||||
|
# path INSTALL.md named and not one every instance has.
|
||||||
|
|
||||||
|
|
||||||
|
def _stamped(tree: Path, release_url: str = "https://example.invalid/releases/tag/v2.0.0") -> None:
|
||||||
|
"""Make `tree` read as a tree that came out of `dist export`. The stamp's
|
||||||
|
presence is what gates the feed fallback: a dev checkout has none."""
|
||||||
|
(tree / version_mod.RELEASE_STAMP_FILENAME).write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema": version_mod.STAMP_SCHEMA,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"release_url": release_url,
|
||||||
|
"update_url": "https://example.invalid/api/v1/repos/x/y/releases/latest",
|
||||||
|
"files": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_falls_back_to_the_feed_when_the_instance_has_no_entry(tree, capsys):
|
||||||
|
_stamped(tree)
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub, no entries.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
version_cmd.run_notes(
|
||||||
|
fetcher=_feed({"tag_name": "1.0.0", "body": "## 1.0.0\n\n**Migration:** none required\n"})
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "**Migration:** none required" in captured.out
|
||||||
|
# stdout is consumed by `release.yml`'s redirect, so the provenance lines
|
||||||
|
# must not be on it.
|
||||||
|
assert "Asking" not in captured.out
|
||||||
|
assert "Asking" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_names_the_version_the_feed_answered_when_it_differs(tree, capsys):
|
||||||
|
"""The main case, not an edge one: the notes are read *before* the swap,
|
||||||
|
while VERSION still names the release being left."""
|
||||||
|
_stamped(tree)
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
version_cmd.run_notes(fetcher=_feed({"tag_name": "2.0.0", "body": "## 2.0.0\n\nnotes\n"}))
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "## 2.0.0" in captured.out
|
||||||
|
assert "not 1.0.0's" in " ".join(captured.err.split())
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_offline_refuses_the_feed_and_names_the_release_page(tree, capsys):
|
||||||
|
_stamped(tree)
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
version_cmd.run_notes(offline=True, fetcher=_feed({"tag_name": "2.0.0", "body": "x"}))
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
out = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "https://example.invalid/releases/tag/v2.0.0" in out
|
||||||
|
assert "--offline was passed" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_on_an_unreachable_feed_still_hands_over_the_release_page(tree, capsys):
|
||||||
|
"""An instance that cannot reach the feed must not be left with only a
|
||||||
|
network error: the page is the answer it was after."""
|
||||||
|
_stamped(tree)
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def refusing(url: str, token, timeout: float) -> bytes:
|
||||||
|
raise OSError("no route to host")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.run_notes(fetcher=refusing)
|
||||||
|
out = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "https://example.invalid/releases/tag/v2.0.0" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_treats_an_empty_release_body_as_an_error(tree):
|
||||||
|
"""An empty answer must never read as "this release has nothing to
|
||||||
|
report" - the two lines an operator needs are **Breaking Change:** and
|
||||||
|
**Migration:**, and their absence is not the same as their being empty."""
|
||||||
|
_stamped(tree)
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nA stub.\n", encoding="utf-8")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.run_notes(fetcher=_feed({"tag_name": "1.0.0", "body": " "}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_never_asks_a_feed_without_a_release_stamp(tree, capsys):
|
||||||
|
"""The guard that keeps the origin repo and CI offline: `release.yml` runs
|
||||||
|
`version notes > /tmp/release-notes.md` in a tree that has no stamp, so it
|
||||||
|
can never reach the fallback however its CHANGES.md looks."""
|
||||||
|
(tree / "CHANGES.md").write_text("# Changelog\n\nno entry here\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def exploding(url: str, token, timeout: float) -> bytes:
|
||||||
|
raise AssertionError("a tree with no release stamp must not ask a feed")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.run_notes(fetcher=exploding)
|
||||||
|
out = " ".join(capsys.readouterr().out.split())
|
||||||
|
assert "version bump" in out # the dev-checkout fix, not the instance one
|
||||||
|
|
||||||
|
|
||||||
# --- version check ---------------------------------------------------------
|
# --- version check ---------------------------------------------------------
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class TypeResolver:
|
|||||||
if not type_path.endswith('.md'):
|
if not type_path.endswith('.md'):
|
||||||
raise ValueError(f"Type path must end with .md: {type_path}")
|
raise ValueError(f"Type path must end with .md: {type_path}")
|
||||||
|
|
||||||
# Try as repo-relative from /types/ or /wiki/**/types/
|
# Try as repo-relative from /types/ or /kb/**/types/
|
||||||
if type_path.startswith('types/'):
|
if type_path.startswith('types/'):
|
||||||
candidate = (self.repo_root / type_path).resolve()
|
candidate = (self.repo_root / type_path).resolve()
|
||||||
# Ensure it's within the repo - resolve() first so `..` segments
|
# Ensure it's within the repo - resolve() first so `..` segments
|
||||||
@@ -77,7 +77,7 @@ class TypeResolver:
|
|||||||
# Ensure it's within the repo and starts with types/ or has types/ in path
|
# Ensure it's within the repo and starts with types/ or has types/ in path
|
||||||
try:
|
try:
|
||||||
candidate.relative_to(self.repo_root)
|
candidate.relative_to(self.repo_root)
|
||||||
# Allow relative paths that resolve to types/ or wiki/**/types/
|
# Allow relative paths that resolve to types/ or kb/**/types/
|
||||||
if 'types' in str(candidate.relative_to(self.repo_root).parts):
|
if 'types' in str(candidate.relative_to(self.repo_root).parts):
|
||||||
if candidate.exists() and candidate.is_file():
|
if candidate.exists() and candidate.is_file():
|
||||||
return candidate
|
return candidate
|
||||||
@@ -300,8 +300,8 @@ class TypeResolver:
|
|||||||
def get_layout(self, type_path: str, source_file: Path = None) -> Optional[Dict[str, Dict[str, str]]]:
|
def get_layout(self, type_path: str, source_file: Path = None) -> Optional[Dict[str, Dict[str, str]]]:
|
||||||
"""Return a type-spec's `layout:` frontmatter - a map of subtype value
|
"""Return a type-spec's `layout:` frontmatter - a map of subtype value
|
||||||
to `{dir, title}`, declaring where instances of each subtype are
|
to `{dir, title}`, declaring where instances of each subtype are
|
||||||
written under wiki/ and what section title/order to use in
|
written under kb/ and what section title/order to use in
|
||||||
wiki/index.md. This is the single source of truth for directory
|
kb/index.md. This is the single source of truth for directory
|
||||||
placement, so callers (e.g. `new_page.py`, `index_build.py`) never
|
placement, so callers (e.g. `new_page.py`, `index_build.py`) never
|
||||||
need a hand-maintained `entity_type -> subdirectory` Python dict.
|
need a hand-maintained `entity_type -> subdirectory` Python dict.
|
||||||
|
|
||||||
|
|||||||
+67
-10
@@ -349,8 +349,12 @@ def fetch_latest(
|
|||||||
|
|
||||||
The network call sits behind `fetcher` so every caller above this line -
|
The network call sits behind `fetcher` so every caller above this line -
|
||||||
and every test - can run without a network. This is the one place in
|
and every test - can run without a network. This is the one place in
|
||||||
`wikitool` that talks to a remote host, and it is reached only from
|
`wikitool` that talks to a remote host, and only two commands reach it:
|
||||||
`version check`, never implicitly from another command.
|
`version check`, whose whole job it is, and `version notes` on a
|
||||||
|
*distributed* instance, whose local `CHANGES.md` is a stub with no entry to
|
||||||
|
print (see `fetch_latest_notes`). Neither is implicit - `check` exists for
|
||||||
|
the call, and `notes` announces the URL it is asking before it asks, on
|
||||||
|
stderr, and takes `--offline` for a caller that wants none of it.
|
||||||
"""
|
"""
|
||||||
fetch = fetcher or _urlopen_fetch
|
fetch = fetcher or _urlopen_fetch
|
||||||
try:
|
try:
|
||||||
@@ -378,14 +382,18 @@ def fetch_latest(
|
|||||||
return Version.parse(tag)
|
return Version.parse(tag)
|
||||||
|
|
||||||
|
|
||||||
def fetch_latest_release(
|
def _fetch_latest_object(
|
||||||
url: str,
|
url: str,
|
||||||
token: Optional[str] = None,
|
token: Optional[str],
|
||||||
timeout: float = 10.0,
|
timeout: float,
|
||||||
fetcher: Optional[Fetcher] = None,
|
fetcher: Optional[Fetcher],
|
||||||
) -> tuple[Version, Optional[str], Optional[str]]:
|
) -> tuple[Version, dict]:
|
||||||
"""`fetch_latest` plus the two display fields a report wants: the release's
|
"""`fetch_latest`'s version plus the whole release object it came out of.
|
||||||
own page and its publication date."""
|
|
||||||
|
`fetch_latest` deliberately answers one question and validates only the
|
||||||
|
field that answers it. The two callers below want further fields off the
|
||||||
|
same response, and neither may make a second request for them - so the
|
||||||
|
payload is captured on the way through rather than re-fetched."""
|
||||||
fetch = fetcher or _urlopen_fetch
|
fetch = fetcher or _urlopen_fetch
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|
||||||
@@ -399,12 +407,61 @@ def fetch_latest_release(
|
|||||||
pass
|
pass
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
version = fetch_latest(url, token, timeout, capturing)
|
return fetch_latest(url, token, timeout, capturing), captured
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_release(
|
||||||
|
url: str,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
fetcher: Optional[Fetcher] = None,
|
||||||
|
) -> tuple[Version, Optional[str], Optional[str]]:
|
||||||
|
"""`fetch_latest` plus the two display fields a report wants: the release's
|
||||||
|
own page and its publication date."""
|
||||||
|
version, captured = _fetch_latest_object(url, token, timeout, fetcher)
|
||||||
html_url = captured.get("html_url") or captured.get("url")
|
html_url = captured.get("html_url") or captured.get("url")
|
||||||
published = captured.get("published_at") or captured.get("created_at")
|
published = captured.get("published_at") or captured.get("created_at")
|
||||||
return version, (str(html_url) if html_url else None), (str(published) if published else None)
|
return version, (str(html_url) if html_url else None), (str(published) if published else None)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_latest_notes(
|
||||||
|
url: str,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
fetcher: Optional[Fetcher] = None,
|
||||||
|
) -> tuple[Version, str, Optional[str]]:
|
||||||
|
"""The latest release's notes text, its version, and its own page.
|
||||||
|
|
||||||
|
This is what makes `version notes` answer on a distributed instance at
|
||||||
|
all. Such an instance receives `CHANGES.md` as a nine-line stub with no
|
||||||
|
version entries, and `dist upgrade` never overwrites it
|
||||||
|
(`ownership.is_upgrade_preserved`), so the local file it would read can
|
||||||
|
never carry the entry - not today and not after any future release. The
|
||||||
|
release the feed publishes carries the same text in its `body`, because
|
||||||
|
`release.yml` builds that body out of `version notes` in the origin repo.
|
||||||
|
|
||||||
|
Only the feed's *latest* release can be asked for: `update_url` is the one
|
||||||
|
URL a release stamp records, and composing a `/releases/tags/<tag>` URL out
|
||||||
|
of it would be guessing at an API shape rather than reading a recorded one
|
||||||
|
(AGENTS.md invariant 7). The caller therefore compares the returned version
|
||||||
|
against what it asked for and says so - which is not the edge case but the
|
||||||
|
main one: an operator reads the notes *before* the swap, while `VERSION`
|
||||||
|
still names the release being left.
|
||||||
|
|
||||||
|
Raises `VersionError` for an unreachable feed, a non-release answer, or a
|
||||||
|
release with an empty body - an empty answer must never read as "this
|
||||||
|
release has no breaking change to report"."""
|
||||||
|
version, captured = _fetch_latest_object(url, token, timeout, fetcher)
|
||||||
|
body = str(captured.get("body") or "").strip()
|
||||||
|
if not body:
|
||||||
|
raise VersionError(
|
||||||
|
f"{url} answered with release {version} but no notes text (`body` is empty) - "
|
||||||
|
"nothing to print, and an empty answer must not read as 'nothing to report'"
|
||||||
|
)
|
||||||
|
html_url = captured.get("html_url") or captured.get("url")
|
||||||
|
return version, body, (str(html_url) if html_url else None)
|
||||||
|
|
||||||
|
|
||||||
# --- CHANGES.md ------------------------------------------------------------
|
# --- CHANGES.md ------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The changelog is prose and stays the author's job. What is mechanical is the
|
# The changelog is prose and stays the author's job. What is mechanical is the
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ if [ ! -x "$DIR/.venv/bin/python" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Do NOT cd into $DIR: that would resolve relative CLI arguments (e.g.
|
# Do NOT cd into $DIR: that would resolve relative CLI arguments (e.g.
|
||||||
# --markdown "wiki/Lint Report.md") against tools/ instead of the caller's cwd.
|
# --markdown "kb/Lint Report.md") against tools/ instead of the caller's cwd.
|
||||||
# Add $DIR to PYTHONPATH instead so `chemenu` is importable regardless of
|
# Add $DIR to PYTHONPATH instead so `chemenu` is importable regardless of
|
||||||
# where this script is invoked from.
|
# where this script is invoked from.
|
||||||
export PYTHONPATH="$DIR${PYTHONPATH:+:$PYTHONPATH}"
|
export PYTHONPATH="$DIR${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ how to write a conforming page is [types/source.guidance.md](source.guidance.md)
|
|||||||
| `concepts` | No | Titles of the concepts mentioned in this source |
|
| `concepts` | No | Titles of the concepts mentioned in this source |
|
||||||
| `summary` | Yes | One-liner for `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Authoring guidance
|
|
||||||
|
|
||||||
- The title starts with "Source - ", followed by the name of the source
|
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|
||||||
The block below is page material, so it is written in this instance's KB language
|
The block below is page material, so it is written in this instance's KB language
|
||||||
|
|||||||
+8
-1
@@ -126,6 +126,13 @@ Each type is at least two files, and a `root: kb` type may be three:
|
|||||||
| `types/<name>.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` |
|
| `types/<name>.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` |
|
||||||
| `types/<name>.guidance.md` (optional, `root: kb` only) | The stack-owned authoring contract: when to use the type, when not to, and mechanism-level advice that holds regardless of this instance's own enum values or template text - linked from the type-spec's own `guidance:` field. `types/type-guidance.md` is its contract |
|
| `types/<name>.guidance.md` (optional, `root: kb` only) | The stack-owned authoring contract: when to use the type, when not to, and mechanism-level advice that holds regardless of this instance's own enum values or template text - linked from the type-spec's own `guidance:` field. `types/type-guidance.md` is its contract |
|
||||||
|
|
||||||
|
**A `default:` is materialized by `wikitool new` only for a field the schema also lists in
|
||||||
|
`required:`.** On an optional field, `default:` documents what a reader should assume when the
|
||||||
|
field is absent - writing it into every scaffolded page turns that silent assumption into a
|
||||||
|
stated claim instead. `instruction.obligation` is the field this was fixed for: a migration-only
|
||||||
|
axis, unrelated to an ordinary instruction, whose `default: required` used to land in every
|
||||||
|
newly scaffolded instruction regardless.
|
||||||
|
|
||||||
This file is the self-referential root contract every type-spec is validated against, and
|
This file is the self-referential root contract every type-spec is validated against, and
|
||||||
`type-guidance.md` is validated against it the same way `lint-report.md` is - itself a
|
`type-guidance.md` is validated against it the same way `lint-report.md` is - itself a
|
||||||
non-instantiable, contract-only type. `tools/wikitool types describe <name>` composes all of a
|
non-instantiable, contract-only type. `tools/wikitool types describe <name>` composes all of a
|
||||||
@@ -171,7 +178,7 @@ genuinely that uniform. Once the default was removed and the pages reclassified
|
|||||||
actually were, the field split into six areas with none of them small. Declare `layout` for
|
actually were, the field split into six areas with none of them small. Declare `layout` for
|
||||||
*every* enum value once it does: a value missing from it still gets a directory (`subtype_dir`
|
*every* enum value once it does: a value missing from it still gets a directory (`subtype_dir`
|
||||||
falls back to naively pluralizing the value's own name), but that directory carries no title in
|
falls back to naively pluralizing the value's own name), but that directory carries no title in
|
||||||
`wiki/index.md`, which is a silent gap rather than a deliberate flat collection. Leaving a whole
|
`kb/index.md`, which is a silent gap rather than a deliberate flat collection. Leaving a whole
|
||||||
type's `layout` off is for the case in the paragraph above - no subtype field to key on at all
|
type's `layout` off is for the case in the paragraph above - no subtype field to key on at all
|
||||||
- not for a subtype field one has simply not gotten around to mapping. A `dir:` must be a single
|
- not for a subtype field one has simply not gotten around to mapping. A `dir:` must be a single
|
||||||
path segment, because an area is as deep as a page goes.
|
path segment, because an area is as deep as a page goes.
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ properties:
|
|||||||
type: object
|
type: object
|
||||||
description: >-
|
description: >-
|
||||||
Optional map of subtype value -> {dir, title}, declaring where instances
|
Optional map of subtype value -> {dir, title}, declaring where instances
|
||||||
of each subtype are written under wiki/ and what section title/order to
|
of each subtype are written under kb/ and what section title/order to
|
||||||
use in wiki/index.md. Only types with subtype-driven directory
|
use in kb/index.md. Only types with subtype-driven directory
|
||||||
placement (e.g. entity) need this; omit for types with a single flat
|
placement (e.g. entity) need this; omit for types with a single flat
|
||||||
directory.
|
directory.
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
@@ -46,11 +46,11 @@ properties:
|
|||||||
base_dir:
|
base_dir:
|
||||||
type: string
|
type: string
|
||||||
description: >-
|
description: >-
|
||||||
Directory, relative to the wiki root (wiki/), where instances of this
|
Directory, relative to the KB root (kb/), where instances of this
|
||||||
type are written - e.g. `entities`. Required on every instantiable
|
type are written - e.g. `entities`. Required on every instantiable
|
||||||
type; omit only for types that are never instantiated as pages (e.g.
|
type; omit only for types that are never instantiated as pages (e.g.
|
||||||
type-spec itself). Combined with `layout:` for subtype-driven
|
type-spec itself). Combined with `layout:` for subtype-driven
|
||||||
placement, giving wiki/<base_dir>/<layout[subtype].dir>/<Title>.md.
|
placement, giving kb/<base_dir>/<layout[subtype].dir>/<Title>.md.
|
||||||
pattern: "^[a-z][a-z0-9/_-]*$"
|
pattern: "^[a-z][a-z0-9/_-]*$"
|
||||||
title_prefix:
|
title_prefix:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user