Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c400c670 | |||
| 9a1be6acde | |||
| 24cd221b21 | |||
| aa31d431fc | |||
| 4284f101c8 | |||
| e4e2332e01 | |||
| 536093f6c9 | |||
| 0c98080964 | |||
| 72d01beef8 | |||
| 0e09cf41ea | |||
| 504149c7c4 | |||
| f3c80747a5 | |||
| 5d26698cd0 | |||
| bb097f614b | |||
| 55f65c1ab1 | |||
| 6eb3f84256 | |||
| d49513bda6 | |||
| 90ce41964f | |||
| f350999053 | |||
| 05a75065ba | |||
| ef60e2984c | |||
| c64479fe02 | |||
| c0dc2129bb | |||
| f140e26a4c | |||
| 0fb8fd6122 | |||
| dc688e5726 |
@@ -99,6 +99,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
|||||||
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
||||||
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
||||||
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
||||||
|
| `types/<name>.guidance.md` | Agents + validator | Via `tools/wikitool types describe`, composed with the `types/<name>.md` it documents. Stack-owned regardless of the type-spec's own `root:` - it ships verbatim and is optional, present only where the type-spec declares `guidance:` |
|
||||||
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
|
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
|
||||||
| `INDEX.md` | Both | Generated - never hand-edited |
|
| `INDEX.md` | Both | Generated - never hand-edited |
|
||||||
|
|
||||||
@@ -107,20 +108,53 @@ documents. What it may not carry is the same content twice - a README that resta
|
|||||||
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
||||||
happened once: no README may hold a copy of the `wikitool` command table.
|
happened once: no README may hold a copy of the `wikitool` command table.
|
||||||
|
|
||||||
|
**Two languages, and which is which.** Which one a line is written in follows from the *For*
|
||||||
|
column above - who reads it - and from nothing else: not from who owns the file, and not from
|
||||||
|
whether it ever leaves this checkout.
|
||||||
|
|
||||||
|
1. **The control plane is written in English** - this file, `CLAUDE.md`, every `CONTRACT.md`,
|
||||||
|
everything under `instructions/`, and the type-specs for non-page artifacts. Quoted
|
||||||
|
vocabulary is not prose and stays as it is: a section name, a relationship label or a
|
||||||
|
translated term cited as evidence. What addresses the *page* goes the other way - `kb/` pages,
|
||||||
|
and inside a page type-spec the parts that become page text - and follows
|
||||||
|
`kb/CONVENTIONS.md`, which is also where the instance's own terminology material is reached
|
||||||
|
from.
|
||||||
|
|
||||||
|
This holds for a control-plane file an instance writes **only for itself** and never ships:
|
||||||
|
an instruction of its own, a page type it added (`types/` takes one without a code change),
|
||||||
|
a further stage contract. Such a file is instance-owned end to end, which settles who may
|
||||||
|
change it, not who reads it - and the reader is still an agent. There is deliberately no
|
||||||
|
second language value beside `kb/CONVENTIONS.md`'s `language:`, and no instance setting that
|
||||||
|
moves this rule; [docs/language-boundaries.md](docs/language-boundaries.md) has the reasoning.
|
||||||
|
2. **An agent speaks the instance's KB language**, whatever this file is written in. The value
|
||||||
|
lives in `kb/CONVENTIONS.md`'s `language:` and nowhere else; an instruction that models a
|
||||||
|
sentence for the user writes it in English like the rest of the control plane, and the agent
|
||||||
|
says it in that language.
|
||||||
|
|
||||||
|
Nothing checks either mechanically - a stop-word scan would flag the quoted vocabulary above
|
||||||
|
and miss a translated paragraph that reads cleanly. They are held up by whoever writes an
|
||||||
|
instruction, which is why [instructions/CONTRACT.md](instructions/CONTRACT.md) § "Writing an
|
||||||
|
instruction" names them at the step where that happens.
|
||||||
|
|
||||||
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
|
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
|
||||||
background consulted in passing, not a rule to follow; anything that would bind belongs in a
|
background consulted in passing, not a rule to follow; anything that would bind belongs in a
|
||||||
`CONTRACT.md` instead, which is what keeps invariant 8 intact here. It carries no frontmatter,
|
`CONTRACT.md` instead, which is what keeps invariant 8 intact here. It carries no frontmatter,
|
||||||
type, index, lint or provenance; `dist export` ships it verbatim and no other `tools/wikitool`
|
type, index, lint or provenance; `dist export` ships it verbatim and no other `tools/wikitool`
|
||||||
command touches it.
|
command touches it.
|
||||||
|
|
||||||
Four pages exist today, each read by link rather than automatically:
|
Five pages are reached from this file, each by link rather than automatically:
|
||||||
[docs/pipeline-rationale.md](docs/pipeline-rationale.md) (why the pipeline has four stages),
|
[docs/pipeline-rationale.md](docs/pipeline-rationale.md) (why the pipeline has four stages),
|
||||||
[docs/ownership-and-templates.md](docs/ownership-and-templates.md) (why a `.template` split
|
[docs/ownership-and-templates.md](docs/ownership-and-templates.md) (why a `.template` split
|
||||||
exists, and why silent overwrite is the failure it guards against),
|
exists, and why silent overwrite is the failure it guards against),
|
||||||
[docs/why-gates-are-code.md](docs/why-gates-are-code.md) (why the four gates in
|
[docs/language-boundaries.md](docs/language-boundaries.md) (why the control plane is English
|
||||||
|
everywhere and the KB language is a value, and why the axis is the reader rather than the
|
||||||
|
owner), [docs/why-gates-are-code.md](docs/why-gates-are-code.md) (why the four gates in
|
||||||
[Gates](#gates) are code rather than instruction), and
|
[Gates](#gates) are code rather than instruction), and
|
||||||
[docs/version-model.md](docs/version-model.md) (why a version number answers a compatibility
|
[docs/version-model.md](docs/version-model.md) (why a version number answers a compatibility
|
||||||
question and a migration question separately).
|
question and a migration question separately). A sixth,
|
||||||
|
`docs/model-and-effort-selection.md`, is deliberately not linked here but from `CLAUDE.md`: it
|
||||||
|
decides something only that harness has to decide, and a link here would load it into the other
|
||||||
|
three.
|
||||||
|
|
||||||
## Personalization
|
## Personalization
|
||||||
|
|
||||||
@@ -217,6 +251,13 @@ tools/wikitool search --field entity_type=system --field '!sources'
|
|||||||
|
|
||||||
`search` is read-only and exempt from the iteration budget.
|
`search` is read-only and exempt from the iteration budget.
|
||||||
|
|
||||||
|
**It is also exhaustive, so do not grep `kb/` yourself.** `search` *is* a `rg` run over `kb/`,
|
||||||
|
enriched with each hit's frontmatter and ranked; a grep of your own can therefore surface no
|
||||||
|
page it missed, only the generated files it deliberately excludes - `kb/index.md`, `kb/log.md`,
|
||||||
|
`kb/provenance.md`, every `INDEX.md` - which invariant 1 forbids acting on anyway. Each hit
|
||||||
|
carries the page's full path and full title, so it can be opened and passed to the commands
|
||||||
|
that take a title. A result cut short by `--limit` says so and names the total.
|
||||||
|
|
||||||
## Gates
|
## Gates
|
||||||
|
|
||||||
Four limits are enforced in code rather than by instruction, because a prompt-level limit is
|
Four limits are enforced in code rather than by instruction, because a prompt-level limit is
|
||||||
|
|||||||
+748
@@ -28,6 +28,13 @@ opening another: the heading's version/date/title move, and the bump's
|
|||||||
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
|
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
|
||||||
written and read by `wikitool version bump`, never by hand.
|
written and read by `wikitool version bump`, never by hand.
|
||||||
|
|
||||||
|
`**Breaking Change:**` accumulates, because one candidate can cross the
|
||||||
|
compatibility boundary more than once and each crossing is a separate thing an
|
||||||
|
operator has to act on: one reason stays on the marker line, a second and
|
||||||
|
further ones move to bullets beneath a bare marker. `**Migration:**` does not -
|
||||||
|
it answers one yes/no question about the candidate as a whole, so a later
|
||||||
|
answer replaces the earlier one.
|
||||||
|
|
||||||
That list is graded, not a flat chronological dump: each bump carries an
|
That list is graded, not a flat chronological dump: each bump carries an
|
||||||
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
||||||
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
||||||
@@ -52,6 +59,747 @@ 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
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- docs toc/verify erreichen die .template-Form einer Referenzdatei
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### docs toc/verify erreichen die .template-Form einer Referenzdatei
|
||||||
|
|
||||||
|
`kb/CONVENTIONS.md.template` war 105 Zeilen lang und trug keine TOC-Region. `toc.target_files()`
|
||||||
|
berechnete den Dateisatz ueber die *adoptierten* Namen, und eine Datei auf `.md.template` faellt
|
||||||
|
aus jedem dieser Walks heraus - also hat `docs toc --apply` das Template nie angefasst und
|
||||||
|
`docs verify` es nie gelesen. Eine Instanz, die es nach `instructions/setup-instance.md`
|
||||||
|
adoptiert, bekam damit eine `kb/CONVENTIONS.md` ohne Region und fiel am `docs verify` in
|
||||||
|
Schritt 13 derselben Anleitung um - dem Befehl, mit dem das Setup endet. Ausgeliefert war das in
|
||||||
|
`6.0.0`.
|
||||||
|
|
||||||
|
Eine in Scope stehende Datei nimmt ihr `<name>.template` jetzt mit hinein: das Template ist
|
||||||
|
dasselbe Dokument einen Schritt frueher in seinem Leben, und wer es auslaesst, laesst die
|
||||||
|
adoptierte Kopie den Fehler erben. `docs verify` prueft im Ursprungs-Repo damit 57 statt 56
|
||||||
|
Referenzdateien, in einer frisch exportierten Instanz 59.
|
||||||
|
|
||||||
|
Ausgeloest hat es ein Wachstum um sechs Zeilen: `f350999` hat das Template von 99 auf 105 Zeilen
|
||||||
|
gebracht und damit ueber die Schwelle von 100. Seither war `ci.yml` auf jedem Push rot (Laeufe
|
||||||
|
279 bis 289) - was als Flackern gelesen wurde, weil jeder Push zusaetzlich einen gruenen
|
||||||
|
`release.yml`-Lauf erzeugt und die Paare wie Lauf und Wiederholung aussehen. Sie sind zwei
|
||||||
|
verschiedene Workflows.
|
||||||
|
|
||||||
|
Grenzuebertritt-Frage geprueft und verneint, gegen den dokumentierten Update-Weg: das Template ist
|
||||||
|
stack-eigen (`ownership.is_stack_owned` - jede `.template` unter einer Content-Stage), steht nicht
|
||||||
|
in `UPGRADE_PRESERVED_PATHS`, und `dist upgrade` schreibt es damit mit. Eine Instanz bekommt das
|
||||||
|
reparierte Template also durch den Upgrade selbst, ohne Handarbeit; der Rueckweg funktioniert
|
||||||
|
ebenso, weil die alte Maschinerie das Template gar nicht erst prueft. Handarbeit faellt nur an, wo
|
||||||
|
eine Instanz ihr stack-eigenes Template lokal veraendert hat - `dist upgrade` meldet genau das als
|
||||||
|
`blocked` und verlangt `--keep-local`.
|
||||||
|
|
||||||
|
Verifiziert: `docs verify`/`instructions verify` gruen, 1275 Tests gruen (3 neu: das Template einer
|
||||||
|
in Scope stehenden Datei steht im Dateisatz, ein `.template` ohne solche Datei daneben nicht
|
||||||
|
(`USER.md.template`), und ein Template ueber der Schwelle ohne Region ist ein Befund - der letzte
|
||||||
|
waere am heutigen Stand rot gewesen). Dazu der vollstaendige `setup-instance.md`-Replay gegen einen
|
||||||
|
frischen `dist export`: `doctor`, `docs verify`, `instructions verify` und `lint` laufen in der
|
||||||
|
frischen Instanz durch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.0.0 - 2026-09-15 - search: Pfad und Titel vollstaendig, Trunkierung sichtbar
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
**Breaking Change:**
|
||||||
|
- docs verify loest ab dieser Version jeden relativen Markdown-Link in den Referenzdateien auf und meldet ein totes Ziel als Fehler - auch in kb/CONVENTIONS.md und kb/<collection>/COLLECTION.md, die eine Instanz selbst besitzt und die ein Drop-in-Copy der Maschinerie nicht ersetzt. Eine Instanz, deren eigene Konventions- oder Collection-Datei einen relativen Link mit falscher ../-Tiefe oder auf eine inzwischen geloeschte Datei traegt, sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief. Reparatur: den in der Meldung genannten Datei:Zeile-Link korrigieren - kein Werkzeuglauf, keine Inhaltsmigration.
|
||||||
|
- docs verify prueft die TOC-Region ab dieser Version auch auf types/*.md und docs/*.md. Eine Instanz, die die Page-Type-Spec-Templates adoptiert hat, traegt types/source.md und types/concept.md ohne Region und sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief; dasselbe gilt fuer eine selbst angelegte oder lokal geaenderte docs/-Seite ueber 100 Zeilen. Reparatur: tools/wikitool docs toc --apply - ein Werkzeuglauf, keine Inhaltsmigration.
|
||||||
|
|
||||||
|
**Migration:** none required - Keine kb/-Seite aendert ihre Form. Der Grenzuebertritt ist ein strengerer Check auf instanz-eigener Prosa, keine Schema- oder Frontmatteraenderung.
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||||
|
- docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||||
|
- TOC-Scope auf types/ und docs/ erweitert, Sprachregeln zentralisiert, --breaking akkumuliert
|
||||||
|
- types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert
|
||||||
|
|
||||||
|
**Medium impact**
|
||||||
|
- docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||||
|
- Control-Plane-Sprache universell: Achse ist das Publikum, kein Instanz-Schalter
|
||||||
|
- types/type-spec.schema.yaml enforced against real type-spec frontmatter
|
||||||
|
- search: Pfad und Titel vollstaendig, Trunkierung sichtbar
|
||||||
|
|
||||||
|
**Low impact**
|
||||||
|
- gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||||
|
- types/type-spec.md: Ownership und Sprache getrennt benannt (Nachzug zu #99)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Ausgangspunkt war ein realer Bruch: `instructions sync` kopiert jede `SKILL.md` in eine andere
|
||||||
|
Verzeichnistiefe, und 52 von 58 relativen Links darin zeigten in der publizierten Kopie ins Leere,
|
||||||
|
unbemerkt, weil kein Check je ein Linkziel gelesen hat. Die Reparatur - repo-root-relative Pfade
|
||||||
|
statt `../`-Links - zieht zwei neue mechanische Checks nach sich (`instructions verify` verbietet
|
||||||
|
relative Links in `SKILL.md`, `docs verify` loest Linkziele in allen Referenzdateien auf), und der
|
||||||
|
zweite Check erreicht auch instanz-eigene Prosa (`kb/CONVENTIONS.md`, `COLLECTION.md`), die ein
|
||||||
|
Drop-in-Copy nicht ersetzt - der Grenzuebertritt, der diesen Kandidaten auf `6.0.0` eskaliert hat.
|
||||||
|
Denselben Linkziel-Check bekommt die TOC-Pflicht gleich mit auf `types/` und `docs/` erweitert, und
|
||||||
|
die Sprachregeln fuer die Control-Plane sind zu einer einzigen, publikumsbasierten Regel in
|
||||||
|
`AGENTS.md` zentralisiert statt eines Instanz-Schalters. Daneben, unabhaengig vom Linkproblem: die
|
||||||
|
Seiten-Type-Spec-Anleitungsprosa ist in eine stackeigene Guidance-Datei ausgelagert,
|
||||||
|
`type-spec.schema.yaml` wird jetzt gegen echte Type-Spec-Frontmatter durchgesetzt, und `search`
|
||||||
|
zeigt Pfad und Titel eines Treffers vollstaendig statt trunkiert.
|
||||||
|
|
||||||
|
### gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||||
|
|
||||||
|
Doku-Nachzug zu `5.1.0`. Beide Dateien beschrieben die Budget-Ausnahme als feste Liste pro
|
||||||
|
Kommandoname ("fixed allowlist"); `version regrade` ist die erste Ausnahme, die nur in *einer*
|
||||||
|
Aufrufform liest - bar listet sie, mit Positionen schreibt sie `CHANGES.md`. Die Liste selbst
|
||||||
|
bleibt an ihrem einen Ort (`tools/CONTRACT.md`), beide Stellen benennen jetzt aber, dass dort ein
|
||||||
|
Eintrag pro Aufruf statt pro Namen gilt. Aufgefallen in der Schlussphase derselben Arbeit, deshalb
|
||||||
|
ein eigener Patch-Bump: der Pfad `instructions/` liegt im Version-Gate der CI.
|
||||||
|
|
||||||
|
### SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||||
|
|
||||||
|
`instructions sync` kopiert jede `SKILL.md` byteidentisch in `.agents/skills/` und
|
||||||
|
`.claude/skills/` - eine andere Verzeichnistiefe als die Quelle, ohne deren Nachbardateien. 52 von
|
||||||
|
58 relativen Markdown-Links in den sieben Skills zeigten deshalb in der publizierten Kopie ins
|
||||||
|
Leere, unbemerkt, weil kein Check je ein Linkziel gelesen hat (Gitea-Meldung: ein `session-setup.md`-Read
|
||||||
|
schlug in einer ausgelieferten Instanz fehl). Alle 58 Links sind jetzt repo-root-relative
|
||||||
|
Klartextpfade (`instructions/session-setup.md` statt `[session-setup.md](../session-setup.md)`) -
|
||||||
|
sie ueberleben die Kopie unveraendert, weil sie nicht von der Position der lesenden Datei abhaengen.
|
||||||
|
`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link" traegt die
|
||||||
|
Regel.
|
||||||
|
|
||||||
|
Zwei neue mechanische Checks verhindern das Wiederauftreten: `instructions verify` verbietet jeden
|
||||||
|
relativen Markdown-Link in einer `SKILL.md` (`check_skill_reference_paths`), `docs verify` loest
|
||||||
|
jeden relativen Link in den flachen Instructions und Contracts gegen den Arbeitsbaum auf
|
||||||
|
(`check_reference_targets`, ueber denselben Dateisatz wie `docs toc`). Nebenbei behoben:
|
||||||
|
`instructions/dev/doc-pull-through.md` hatte zwei Links mit falscher `../`-Tiefe, unabhaengig vom
|
||||||
|
Skill-Copy-Problem.
|
||||||
|
|
||||||
|
### docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||||
|
|
||||||
|
Nachtraegliche Neueinstufung des Bumps darueber, kein zusaetzlicher Code. `check_reference_targets`
|
||||||
|
laeuft ueber den Dateisatz von `docs toc`, und fuenf Dateien darin gehoeren der Instanz statt dem
|
||||||
|
Stack: `kb/CONVENTIONS.md` und die vier `kb/<collection>/COLLECTION.md`. Ein Drop-in-Copy von
|
||||||
|
`tools/`, `types/`, `instructions/` und `AGENTS.md` ersetzt sie nicht - ein toter relativer Link
|
||||||
|
darin laesst `docs verify` nach dem Update fehlschlagen, wo es vorher durchlief. Genau die Form,
|
||||||
|
die `tools/README.md` § Adding a command Schritt 5 als MAJOR-Zeile benennt ("a stricter check that
|
||||||
|
newly fails on shipped content an instance already had"), und `instructions/dev/version-parts.md`
|
||||||
|
entscheidet den Zweifelsfall zugunsten des Grenzuebertritts.
|
||||||
|
|
||||||
|
Gemessen bricht heute nichts: die zweite bekannte Instanz traegt 25 relative Links in diesen fuenf
|
||||||
|
Dateien, davon null tote; dieses Repo ebenso. Die Einstufung folgt der Reichweite des Checks, nicht
|
||||||
|
einem beobachteten Schaden - der Preis einer unnoetigen MAJOR ist eine Release-Notiz, der Preis
|
||||||
|
einer MINOR, die doch bricht, ist eine Instanz mit fehlschlagendem Update-Pfad unter einer
|
||||||
|
Versionsnummer, die Drop-in versprochen hat. Aufgefallen ist es in der Schlussphase beim Lesen der
|
||||||
|
eigenen Regel in `tools/README.md`, nicht durch einen Check - was `docs/version-model.md` ueber
|
||||||
|
genau diese Stelle sagt ("a person looking at the diff ... not a validator"), hat sich hier
|
||||||
|
wiederholt.
|
||||||
|
|
||||||
|
### docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||||
|
|
||||||
|
Defektbehebung am Check aus den beiden Bumps darueber, gefunden unmittelbar nach deren Publish.
|
||||||
|
`check_reference_targets` meldete auf einem frisch exportierten Baum 13 tote Links - `kb/CONTRACT.md`
|
||||||
|
neunmal, dazu `german-terminology.md`, `kb-profiles.md` und `link-taxonomy.md` - und zwar dafuer,
|
||||||
|
dass der Export tut, was er soll. `kb/CONVENTIONS.md` und die vier `kb/<name>/COLLECTION.md` sind
|
||||||
|
instanz-eigen: die Distribution traegt `<name>.template`, und die Instanz uebernimmt sie erst im
|
||||||
|
Personalisierungsschritt von `instructions/setup-instance.md` durch Umbenennen. Zwischen
|
||||||
|
`dist export` und diesem Schritt existiert die fertige Datei berechtigterweise nicht, waehrend die
|
||||||
|
stack-eigenen Dateien sie unter ihrem kuenftigen Namen verlinken - korrekt, denn so wird sie heissen.
|
||||||
|
|
||||||
|
Ein Linkziel gilt jetzt auch dann als aufgeloest, wenn daneben `<ziel>.template` liegt. Die
|
||||||
|
Ausnahme ist eng: fehlt beides, bleibt es ein Befund. Damit beschreibt der Check nicht laenger
|
||||||
|
"noch nicht personalisiert" als "kaputter Link" - diesen Zustand meldet `doctor` unter
|
||||||
|
`conventions` praezise und zustaendig.
|
||||||
|
|
||||||
|
CI war davon nie rot: der Replay in `.gitea/workflows/ci.yml` uebernimmt die Templates, bevor er
|
||||||
|
`docs verify` aufruft, und der dokumentierte Weg in `setup-instance.md` stellt die Personalisierung
|
||||||
|
(Schritt 5/6) ebenfalls vor die Verifikation (Schritt 13). Getroffen haette es jeden, der nach dem
|
||||||
|
Export einmal zur Kontrolle `docs verify` aufruft. Aufgefallen ist es, weil die Verifikation des
|
||||||
|
vorherigen Publishes den Arbeitsbaum geprueft hatte und nicht den exportierten - ausgerechnet bei
|
||||||
|
einer Aenderung, deren ganzer Gegenstand Kopien in anderer Verzeichnistiefe sind.
|
||||||
|
|
||||||
|
### TOC-Scope auf types/ und docs/ erweitert, Sprachregeln zentralisiert, --breaking akkumuliert
|
||||||
|
|
||||||
|
Drei Straenge, ausgeloest von einer Beobachtung: manche agentengeladene Referenzdatei trug keine
|
||||||
|
TOC, und manche Instruction war teilweise deutsch.
|
||||||
|
|
||||||
|
**TOC-Scope.** Die Pflicht aus `5.0.0` galt fuer `AGENTS.md`, die Stage-Contracts,
|
||||||
|
`kb/CONVENTIONS.md`, jede `COLLECTION.md` und die flache `instructions/**.md`-Form. `docs/` und die
|
||||||
|
Seiten-Type-Specs fielen ohne genannten Grund heraus - waehrend `SKILL.md` und die Menschendoku
|
||||||
|
ihren Ausschlussgrund im Docstring stehen hatten, was die beiden anderen Luecken wie Absicht
|
||||||
|
aussehen liess. `docs/` ist dabei genau der Fall, fuer den die Schwelle existiert:
|
||||||
|
`AGENTS.md` § File naming fuehrt es als agentengeladen per Link, also am zweiten Hop. Beide sind
|
||||||
|
jetzt drin; vier Dateien haben eine Region bekommen. `SKILL.md` bleibt die **eine** Ausnahme, und
|
||||||
|
zwar belegt statt behauptet: die vendorte Guidance setzt den SKILL.md-Body auf die Ladeebene, die
|
||||||
|
beim Triggern ganz gelesen wird, und richtet ihren eigenen TOC-Rat an die gebuendelten
|
||||||
|
Referenzdateien *daneben*. Ein Type-Spec wird zwar auch ganz geladen, aber eben auch als Datei
|
||||||
|
gelesen - deshalb traegt es eine Region, und `types describe` strippt sie aus seiner Ausgabe, weil
|
||||||
|
dort der ganze Body ohnehin mitkommt.
|
||||||
|
|
||||||
|
**Sprache.** Die Regel gab es schon ("the control plane stays English"), sie stand nur in
|
||||||
|
`kb/CONVENTIONS.md` - einer Datei, die der Instanz gehoert und die sie umschreiben darf, waehrend
|
||||||
|
die Regel stackeigene Dateien bindet. Sie ist nach `AGENTS.md` § File naming gezogen, zusammen mit
|
||||||
|
einer zweiten, die vorher gar nicht geschrieben stand: **ein Agent spricht die KB-Sprache der
|
||||||
|
Instanz**. Der Wert dafuer lebt weiter in `kb/CONVENTIONS.md`s `language:`; `SOUL.md`s eigene
|
||||||
|
`Sprache:`-Zeile war damit eine Dublette und ist weg. `instructions/setup-instance.md` - 297 Zeilen,
|
||||||
|
die einzige vollstaendig deutsche Instruction, verbatim an jede Instanz ausgeliefert - ist
|
||||||
|
uebersetzt, samt `description:`. Die zwei deutschen Blockquotes in den Dev-Skills sind es auch; sie
|
||||||
|
lesen sich jetzt als englisches *Modell* der Nachricht, die der Agent in der KB-Sprache ausspricht.
|
||||||
|
|
||||||
|
Dieselbe Regel gilt fuer **alles, was als Template ausgeliefert wird** - eine Instanz adoptiert es,
|
||||||
|
bevor sie ihre Sprache ueberhaupt gewaehlt hat. `USER.md.template`, `SOUL.md.template` und
|
||||||
|
`ENVIRONMENT.md.template` waren vollstaendig deutsch und sind uebersetzt; `kb/sources/` und
|
||||||
|
`kb/concepts/COLLECTION.md` waren es in Teilen und ziehen jetzt mit `kb/entities/` und
|
||||||
|
`kb/comparisons/` gleich, die es laengst waren. Bei den vier Seiten-Type-Specs laeuft der Schnitt
|
||||||
|
mitten durch die Datei, und zwar entlang derselben Prosa/Identifier-Grenze, die `kb/CONTRACT.md`
|
||||||
|
schon *innerhalb* einer Seite zieht: die Anleitungsprosa ist Anweisung an einen Agenten und damit
|
||||||
|
Control Plane, der `## Template`-Block und die `layout:`-Titel sind Seitentext und bleiben in der
|
||||||
|
KB-Sprache - `wikitool new entity` scaffoldet also weiter deutsche Ueberschriften.
|
||||||
|
`kb/CONVENTIONS.md` behauptete bis hierher, die Type-Specs folgten als Ganzes der KB-Sprache; der
|
||||||
|
Satz ist auf den tatsaechlichen Schnitt nachgezogen.
|
||||||
|
Mechanisch geprueft wird nichts davon: ein Stoppwort-Scan schluege auf dem zitierten Vokabular in
|
||||||
|
`kb-profiles.md` und `link-taxonomy.md` falsch an. Stattdessen nennen
|
||||||
|
`instructions/CONTRACT.md` § "Writing an instruction" und `stack-dev` die Regel an der Stelle, an
|
||||||
|
der sie befolgt oder verloren wird.
|
||||||
|
|
||||||
|
**`--breaking` akkumuliert.** Bis hierher ersetzte ein zweites `--breaking` die Zeile des
|
||||||
|
Kandidaten - der Eintrag versprach dann einen Bruch und lieferte zwei. Genau dieser Kandidat ist der
|
||||||
|
Fall: sein Linkziel-Uebertritt aus `beta.1` und der TOC-Uebertritt von hier sind zwei Dinge, auf die
|
||||||
|
ein Betreiber getrennt reagieren muss. Eine Begruendung bleibt flach auf der Markerzeile, ab der
|
||||||
|
zweiten werden es Bullets; eine vor dieser Aenderung geschriebene Einzelzeile liest sich unveraendert
|
||||||
|
als Ein-Element-Liste zurueck, also musste kein bestehender Eintrag angefasst werden. `--migration:`
|
||||||
|
bleibt bewusst eine Einzelzeile - sie beantwortet eine Ja/Nein-Frage ueber den Kandidaten als Ganzes,
|
||||||
|
und `--migration-required` ist ihr Ruecknahmepfad. Fuer eine falsche Breaking-Begruendung gibt es
|
||||||
|
keinen; der Kandidat ist bis zum Release dev-lokal.
|
||||||
|
|
||||||
|
Nebenbefund, den die Scope-Erweiterung sofort aufgedeckt hat: `docs/version-model.md` verlinkte nach
|
||||||
|
`instructions/dev/version-parts.md`, das `dist export` wegschneidet - im Ursprungs-Repo gruen, in
|
||||||
|
jeder ausgelieferten Instanz ein toter Link. Jetzt ein Klartextpfad mit dem Satz, warum er keiner
|
||||||
|
ist.
|
||||||
|
|
||||||
|
### types/type-spec.md: Ownership und Sprache getrennt benannt (Nachzug zu #99)
|
||||||
|
|
||||||
|
`types/type-spec.md` sagte weiterhin, Prosa, `## Template`-Body **und Sprache** eines
|
||||||
|
Seiten-Type-Specs gehoerten der Instanz, die bei anderer KB-Sprache "einfach die Datei
|
||||||
|
uebersetzt" - genau das Gegenteil des Schnitts, den der Bump davor ausgeliefert hat. Aufgefallen
|
||||||
|
in der Schlussphase, beim Nachdenken darueber, welche Sprachregel fuer einen *instanz-eigenen*
|
||||||
|
neuen Seitentyp gilt.
|
||||||
|
|
||||||
|
Der Abschnitt trennt die zwei Fragen jetzt: Ownership sagt, wer eine Zeile aendern darf, die
|
||||||
|
Sprache folgt davon unabhaengig dem Publikum der Zeile - Anleitungsprosa an einen Agenten ist
|
||||||
|
Control Plane und englisch, `## Template`-Body und `layout:`-Titel sind Seitentext in der
|
||||||
|
KB-Sprache, Feldnamen und Enum-Werte sind Identifier und werden nie uebersetzt. Als Tabelle, weil
|
||||||
|
der Schnitt mitten durch eine Datei laeuft und eine Aufzaehlung im Fliesstext ihn genau deshalb
|
||||||
|
nicht haelt. Der Satz bindet ausdruecklich auch einen Type-Spec, den eine Instanz sich selbst
|
||||||
|
schreibt: der ist zwar durchgaengig instanzeigen, aber seine Anleitungshaelfte hat trotzdem einen
|
||||||
|
Agenten als Leser.
|
||||||
|
|
||||||
|
### Control-Plane-Sprache universell: Achse ist das Publikum, kein Instanz-Schalter
|
||||||
|
|
||||||
|
Die Sprachregel in `AGENTS.md` ruhte auf einer Begruendung, die schmaler war als sie selbst:
|
||||||
|
"Every file in the table above belongs to the stack and **ships to instances** that share none of
|
||||||
|
this instance's language choices, so:". Das traegt nur fuer ausgeliefertes Material und laesst
|
||||||
|
offen, was fuer ein Control-Plane-Dokument gilt, das eine Instanz nur fuer sich selbst schreibt -
|
||||||
|
eine eigene Instruction, ein selbst angelegter Seitentyp (`types/` nimmt einen ohne Code-Aenderung
|
||||||
|
auf), ein weiterer Stage-Contract. Genau dort fallen Ownership und Publikum auseinander: die Datei
|
||||||
|
ist durchgaengig instanzeigen, ihre Anleitungshaelfte hat trotzdem einen Agenten als Leser.
|
||||||
|
|
||||||
|
Der Vorsatz nennt jetzt die tatsaechliche Achse - die *For*-Spalte der Tabelle darueber, also wer
|
||||||
|
die Zeile liest, und weder wem die Datei gehoert noch ob sie den Checkout je verlaesst. Regel 1
|
||||||
|
sagt ausdruecklich, dass sie auch fuer ein nie ausgeliefertes Control-Plane-Dokument gilt und dass
|
||||||
|
es neben `kb/CONVENTIONS.md`s `language:` bewusst keinen zweiten Sprachwert gibt.
|
||||||
|
`kb/CONVENTIONS.md` und ihr `.template` sagen dasselbe von ihrer Seite aus: die
|
||||||
|
Control-Plane-Sprache ist keine Einstellung, die diese Datei zurueckhaelt - es ist gar keine.
|
||||||
|
|
||||||
|
Die Begruendung dazu steht als neue `docs/`-Seite
|
||||||
|
([docs/language-boundaries.md](docs/language-boundaries.md)), weil sie sonst in einem Jahr neu
|
||||||
|
verhandelt wird: warum Englisch (der Stack redet fast nur ueber Identifier, und die sind
|
||||||
|
englisch), warum kein Parameter (die Kosten traegt jede Datei, den Nutzen haette ein Dokument, das
|
||||||
|
ohnehin nur ein Agent liest), und was die Entscheidung wieder aufmachen wuerde. Die Seite haelt
|
||||||
|
zugleich fest, welches Argument falsch war: "Sprache folgt der Ownership" hat funktioniert,
|
||||||
|
solange nur ausgeliefertes Material betrachtet wurde, und faellt am instanz-eigenen Typ.
|
||||||
|
|
||||||
|
Nebenbei zwei Befunde derselben Ecke behoben. Der Docstring von
|
||||||
|
`dist_cmd.instance_owned_type_stems()` behauptete weiter, "its prose, its template and its
|
||||||
|
**language** are the instance's business" - Stand vor dem TOC-/Sprach-Bump oben. Und die
|
||||||
|
Aufzaehlung der `docs/`-Seiten in `AGENTS.md` sagte "Four pages exist today", waehrend das
|
||||||
|
Verzeichnis fuenf trug: `docs/model-and-effort-selection.md` fehlte, und zwar absichtlich, weil
|
||||||
|
ein Link dorthin die Claude-Code-eigene Entscheidung in die anderen drei Harnesses laden wuerde.
|
||||||
|
Der Satz zaehlt jetzt, was von hier aus verlinkt ist, und benennt die sechste Seite samt Grund.
|
||||||
|
|
||||||
|
### types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert
|
||||||
|
|
||||||
|
Ein `root: kb` Type-Spec (`entity`, `concept`, `source`, `comparison`) hatte zwei Publika in
|
||||||
|
einer Datei: Anleitungsprosa fuer den Agenten (When to use/When NOT to use/Authoring guidance),
|
||||||
|
und Seitenmaterial (`## Template`-Block, `layout:`-Titel). Ownership gilt pro Datei, also wurde
|
||||||
|
die ganze Datei beim Setup als `.template` adoptiert und danach nie wieder angefasst - eine
|
||||||
|
Instanz, die ihre Type-Specs frueh adoptiert hat, las bis in alle Zukunft die Anleitung vom Tag
|
||||||
|
ihrer Erzeugung, weil `dist upgrade` das `.template` neben die adoptierte Datei schrieb, nie die
|
||||||
|
Datei selbst (`docs/ownership-and-templates.md` § "Where the file boundary strains").
|
||||||
|
|
||||||
|
Der urspruengliche Vorschlag drehte den Schnitt um (Type-Spec stackeigen, Seitenmaterial heraus)
|
||||||
|
und wurde beim Pruefen gegen `setup-instance.md` und `evolve-subtypes.md` verworfen: die
|
||||||
|
Frontmatter-Konfiguration (`layout:`, Enum-Werte, `base_dir`) ist instanzeigener Inhalt, keine
|
||||||
|
Stack-Maschinerie - beide Instructions weisen die Instanz an, Enum und `layout:`-Eintrag in
|
||||||
|
derselben Aenderung zu setzen. Stattdessen bleibt der Type-Spec instanzeigen, und nur die
|
||||||
|
maschinenabgeleitete Anleitungsprosa zieht in eine neue, stackeigene `types/<name>.guidance.md`,
|
||||||
|
verknuepft ueber ein optionales `guidance:`-Frontmatterfeld (neuer, nicht instanziierbarer Typ
|
||||||
|
`type-guidance`, wie `lint-report` ohne `base_dir:`). `tools/wikitool types describe <name>`
|
||||||
|
komponiert beide Haelften weiterhin zu einer Antwort - ein Agent muss nie wissen, dass ein Typ aus
|
||||||
|
zwei Dateien besteht. `type_resolver.extract_template()` liest das Template unveraendert allein
|
||||||
|
aus `types/<name>.md`; kein zweiter Ladepfad fuer `wikitool new`.
|
||||||
|
|
||||||
|
`dist_cmd._plan_types()`/`find_leaks()` teilten sich vorher `name.split(".", 1)[0]` als
|
||||||
|
Stamm-Berechnung - beides haette `entity.guidance.md` faelschlich als instanzeigenen Stamm
|
||||||
|
"entity" erkannt (die eine haette sie zum `.template` gemacht, die andere sie als Leak gemeldet).
|
||||||
|
Neuer gemeinsamer Prädikat `_owned_type_stem()` prueft die exakte Endung (`<stem>.md` oder
|
||||||
|
`<stem>.schema.yaml`), nicht den ersten Punkt.
|
||||||
|
|
||||||
|
Grenzuebertritt-Frage bewusst geprueft und verneint: Drop-in in beide Richtungen (ein Type-Spec
|
||||||
|
ohne `guidance:` verhaelt sich unveraendert, eine alte Maschinerie liest `types/<name>.md` wie
|
||||||
|
zuvor und die Guidance-Datei ist fuer sie inert), also `--minor` statt `--major`. Die einmalige
|
||||||
|
Adoption in einer bestehenden Instanz ist als `instructions/migrations/6.0.0-type-guidance-split.md`
|
||||||
|
dokumentiert - `obligation: offered`, der erste Gebrauch dieses seit 4.0.0 existierenden, bis jetzt
|
||||||
|
unbenutzten Mechanismus fuer ein instanzeigenes, upgradebares Machinery-File.
|
||||||
|
|
||||||
|
Verifiziert: `tools/wikitool docs verify`/`instructions verify` gruen, 1261 Tests gruen (8 neu:
|
||||||
|
`get_guidance`, das Template bleibt auf `types/<name>.md` allein geladen, die Guidance-Datei
|
||||||
|
schifft verbatim neben einem `.template`-adoptierten Type-Spec statt als weiteres `.template`,
|
||||||
|
ein `dist upgrade` schreibt verbesserte Guidance-Prosa in eine adoptierte Instanz obwohl deren
|
||||||
|
Type-Spec selbst nie im Stamp stand, `types describe` komponiert beide Haelften in JSON und
|
||||||
|
Textausgabe getrennt nachweisbar).
|
||||||
|
|
||||||
|
### types/type-spec.schema.yaml enforced against real type-spec frontmatter
|
||||||
|
|
||||||
|
Bei der Vorbereitung der Aenderung oben fiel auf: `types/type-spec.schema.yaml` traegt
|
||||||
|
`additionalProperties: false`, kannte aber `root:` und `capture_fields:` nicht, obwohl
|
||||||
|
`types/instruction.md` bzw. `types/source.md` beide Felder tragen und `type_resolver.get_root()`/
|
||||||
|
`get_capture_fields()` sie lesen. Gegen das Schema validiert waeren beide Type-Specs ungueltig
|
||||||
|
gewesen. Dass es niemandem auffiel, war der eigentliche Befund: Type-Spec-Frontmatter wurde
|
||||||
|
nirgends gegen sein eigenes Schema validiert - `resolver.validate_frontmatter()` lief nur ueber
|
||||||
|
`kb/`-Seiten, neu erzeugte Seiten und Instruktionsdateien, nie ueber die Type-Specs selbst.
|
||||||
|
`TypeResolver._validate_type_spec()`, der einzige Weg, den der Selbstbezug `type: types/type-spec.md`
|
||||||
|
nimmt, prueft ausschliesslich, ob `type`/`name`/`description` vorhanden sind.
|
||||||
|
|
||||||
|
Beide fehlenden Felder ergaenzt (`root:` als Enum `kb`/`repo`, `capture_fields:` als Liste wie
|
||||||
|
`page_ref_fields:`), dazu `guidance:` (seit der Aenderung oben real benutzt, aber noch nie im
|
||||||
|
Schema). `docs verify` bekommt eine neue Pruefung: jede Datei unter `types/` mit
|
||||||
|
`type: types/type-spec.md` validiert jetzt gegen `types/type-spec.schema.yaml`
|
||||||
|
(`check_type_spec_frontmatter()`, wiederverwendet `resolver.list_type_specs()` statt eines zweiten
|
||||||
|
Parse-Durchlaufs). `types/type-spec.md` § Validation Contract und die beiden `docs verify`-Zeilen
|
||||||
|
in `tools/CONTRACT.md` nennen das jetzt.
|
||||||
|
|
||||||
|
Daneben ein zweiter, unabhaengiger Befund derselben Aufraeumrunde behoben:
|
||||||
|
`instructions/dev/doc-pull-through.md` verwies fuer `docs/`-Seiten weiter auf "AGENTS.md § File
|
||||||
|
naming lists all four" - der Zaehler in AGENTS.md selbst war beim vorigen Bump schon auf fuenf
|
||||||
|
(plus eine sechste, nur von `CLAUDE.md` aus verlinkte) korrigiert worden, diese eine verbliebene
|
||||||
|
Stelle nicht.
|
||||||
|
|
||||||
|
Grenzuebertritt-Frage geprueft und verneint: additiv in beide Richtungen - eine bestehende Instanz
|
||||||
|
validiert bereits (0 Befunde gegen den realen Baum), und ein Type-Spec ohne die drei neuen Felder
|
||||||
|
bleibt unveraendert gueltig. `--patch`, kein `--breaking`, keine neue Migration noetig.
|
||||||
|
|
||||||
|
Verifiziert: `tools/wikitool docs verify`/`instructions verify` gruen, 1263 Tests gruen (2 neu:
|
||||||
|
alle Type-Specs dieses Repos validieren gegen ihr eigenes Schema; ein Type-Spec mit einem dem
|
||||||
|
Schema unbekannten Feld wird gemeldet, mit Dateiname und Feldname in der Meldung).
|
||||||
|
|
||||||
|
### search: Pfad und Titel vollstaendig, Trunkierung sichtbar
|
||||||
|
|
||||||
|
Gemeldet wurde eine Sitzung, die nach `wikitool search` zusaetzlich `grep -rl` ueber `kb/`
|
||||||
|
laufen liess. Der Grep war redundant - `search` *ist* ein `rg`-Lauf ueber `kb/` und kann keine
|
||||||
|
Seite verfehlen, die ein Grep findet -, aber die Ausgabe gab ihr drei Gruende dafuer, und die
|
||||||
|
sind der eigentliche Befund.
|
||||||
|
|
||||||
|
Die Tabelle nannte **keinen Pfad**, obwohl `wiki-query` verlangt, nur die Seiten zu lesen, auf
|
||||||
|
die die Suche zeigt. Sie kappte ausserdem den **Titel** auf 34 Zeichen - im gemeldeten
|
||||||
|
Transkript vier von fuenf Treffern -, und der Titel ist nach Invariante 2 der einzige
|
||||||
|
Identifier einer Seite und das Argument, das `touch`, `xref add` und `cite add` nehmen. Die
|
||||||
|
Sitzung hatte also weder etwas zum Oeffnen noch etwas zum Weiterreichen; `grep -rl` lieferte
|
||||||
|
genau beides.
|
||||||
|
|
||||||
|
Drittens war `N result(s).` die *gekappte* Zahl: `run_search` gab nur die beschnittene Liste
|
||||||
|
zurueck, also konnte kein Adapter die Gesamtzahl melden, und `20 result(s).` auf einer Anfrage
|
||||||
|
mit 182 Treffern war von einem vollstaendigen Ergebnis nicht zu unterscheiden. Eine
|
||||||
|
Vollstaendigkeitsaussage, zu der die Ausgabe nicht berechtigt war - der staerkste denkbare
|
||||||
|
Anlass, ihr zu misstrauen.
|
||||||
|
|
||||||
|
Die Zeile hat jetzt die Form `score | kind/subtype | titel | pfad | summary`, ohne
|
||||||
|
Spaltenauffuellung. Titel und Pfad werden nie gekappt; die Summary ist das einzige verlustige
|
||||||
|
Feld und steht deshalb am Ende, wo ein `|` in Prosa beim Trennen mit `maxsplit=4` folgenlos
|
||||||
|
bleibt (ein `|` im Titel schliesst die Wikilink-Syntax ohnehin aus). JSON als Default-Ausgabe
|
||||||
|
wurde erwogen und verworfen: ein Treffer ist flach, JSON kostet dafuer ein Vielfaches an Tokens,
|
||||||
|
und `search` existiert dafuer, Retrieval billig zu machen - der Fehler war ein fehlendes Feld,
|
||||||
|
kein Parse-Problem. Wer Struktur braucht, hat `--json`, `api.search` und MCP.
|
||||||
|
|
||||||
|
`run_search` gibt jetzt ein `SearchResult` mit Treffern, Gesamtzahl und Limit zurueck. Die
|
||||||
|
Tabelle schreibt `50 of 182 result(s) - raise --limit (0 for all) or narrow the query.`, das
|
||||||
|
JSON traegt `total`/`truncated`/`limit` neben `count`, dessen Bedeutung unveraendert bleibt
|
||||||
|
(`len(results)`), und `api.search` sowie der MCP-`search`-Tool tragen dieselben Felder. Das
|
||||||
|
Default-Limit steigt von 20 auf 50 und liegt als eine Konstante `DEFAULT_LIMIT` statt als drei
|
||||||
|
Literale in drei Adaptern: gekappt wurden bisher vor allem die *strukturellen* Sweeps
|
||||||
|
(`--field '!sources'`), die alphabetisch und nicht nach Relevanz sortiert sind, wo die Kappung
|
||||||
|
also eine beliebige Scheibe der Antwort wegwirft statt ihres schwaechsten Endes. Sichtbar zu
|
||||||
|
sein ist es, was ein endliches Default ueberhaupt erst unbedenklich macht.
|
||||||
|
|
||||||
|
`AGENTS.md` § Routing traegt die Regel an genau einer Stelle - `search` ist erschoepfend, ein
|
||||||
|
eigener Grep ueber `kb/` fuegt nur die generierten Dateien hinzu, die Invariante 1 ohnehin
|
||||||
|
verbietet; `tools/CONTRACT.md` traegt daneben nur den Mechanismus.
|
||||||
|
|
||||||
|
Grenzuebertritt-Frage geprueft und verneint: kein Flag entfernt oder umbenannt, keine
|
||||||
|
Umgebungsvariable, keine maschinengelesene Datei in ihrer Form veraendert, JSON rein additiv.
|
||||||
|
Die Tabelle liest ein Agent, kein Skript, und ihre Aenderung verlangt keiner Instanz Handarbeit
|
||||||
|
ab.
|
||||||
|
|
||||||
|
Verifiziert: `docs verify`/`instructions verify` gruen, 1272 Tests gruen (9 neu: Pfad vorhanden;
|
||||||
|
Titel und Pfad ungekappt bei langem Titel; eine Trefferzeile zerfaellt trotz `|` in der Prosa in
|
||||||
|
ihre fuenf Felder; ein gekapptes Ergebnis nennt die Gesamtzahl, ein ungekapptes behauptet
|
||||||
|
nichts; `--limit 0` gilt nie als gekappt; die Gesamtzahl ueberlebt das Limit in `run_search`;
|
||||||
|
`api.search` meldet dasselbe; alle drei Adapter teilen ein Default-Limit - der MCP-Golden-Test
|
||||||
|
haelt die neuen Felder zwischen CLI und Server zusammen).
|
||||||
|
|
||||||
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|||||||
+55
-58
@@ -1,86 +1,83 @@
|
|||||||
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
|
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
|
||||||
# ENVIRONMENT.md — <Instanz oder Rechnername>
|
# ENVIRONMENT.md — <instance or machine name>
|
||||||
|
|
||||||
Womit *dieser Checkout* arbeitet: Harness, veröffentlichte Skills, MCP-Server,
|
What *this checkout* works through: harness, published skills, MCP servers,
|
||||||
Connectoren und Git-Remotes. Konstante Werte, die ein Agent sonst in jeder
|
connectors and git remotes. Constant values an agent would otherwise ask about
|
||||||
Session neu erfragt oder errät.
|
or guess at in every session.
|
||||||
|
|
||||||
**Diese Datei ist optional.** Fehlt sie, ist das kein Fehler — es heißt nur,
|
**This file is optional.** Its absence is not an error — it only means the
|
||||||
dass die Umgebung wieder erfragt werden muss. `wikitool doctor` meldet sie als
|
environment has to be asked about again. `wikitool doctor` reports it as
|
||||||
`environment: absent (optional)` und niemals als `FAIL`.
|
`environment: absent (optional)` and never as a `FAIL`.
|
||||||
|
|
||||||
**Diese Datei ist Kontext, keine Autorität.** Sie beschreibt, *was da ist*, nicht,
|
**This file is context, not authority.** It describes *what is there*, not what
|
||||||
was erlaubt ist. Sie ändert keine Regel aus `AGENTS.md`, öffnet kein Gate und
|
is allowed. It changes no rule from `AGENTS.md`, opens no gate, and justifies no
|
||||||
begründet keinen Eintrag in `kb/` — was hier steht, ist keine Quelle im Sinne
|
entry in `kb/` — what it says is not a source in the sense of invariant 3. A
|
||||||
von Invariante 3. Ein hier aufgeführter Remote heißt nicht, dass ohne
|
remote listed here does not mean pushing without `wikitool publish` is allowed.
|
||||||
`wikitool publish` gepusht werden darf.
|
|
||||||
|
|
||||||
**Keine Geheimnisse.** Keine Tokens, Passwörter, API-Keys oder privaten
|
**No secrets.** No tokens, passwords, API keys or private endpoints that are not
|
||||||
Endpunkte, die nicht ohnehin in der Shell-Konfiguration stehen. Die Datei ist
|
already in the shell configuration anyway. The file is gitignored, but it sits
|
||||||
gitignored, aber sie liegt im Klartext im Arbeitsverzeichnis und landet in
|
in plaintext in the working directory and ends up in every agent's context.
|
||||||
jedem Agenten-Kontext.
|
|
||||||
|
|
||||||
**Ausfüllen:** frei Hand, sobald die Werte bekannt sind — es gibt kein
|
**Filling it in:** freehand, as soon as the values are known — there is no
|
||||||
Interview dafür. Ein Abschnitt, der nicht zutrifft, wird gelöscht, nicht mit
|
interview for it. A section that does not apply is deleted, not filled with
|
||||||
Plausiblem gefüllt. Wenn etwas hier nicht mehr stimmt, korrigieren statt
|
something plausible. When something here stops being true, correct it rather
|
||||||
umgehen: eine falsche Zeile ist schlimmer als eine fehlende, weil sie
|
than working around it: a wrong line is worse than a missing one, because it
|
||||||
geglaubt wird.
|
gets believed.
|
||||||
|
|
||||||
## Harness
|
## Harness
|
||||||
|
|
||||||
Welche Agenten-Harnesses auf diesem Checkout tatsächlich laufen, und welche
|
Which agent harnesses actually run on this checkout, and which do not. Relevant
|
||||||
nicht. Relevant, weil `.agents/skills/` und `.claude/skills/` unterschiedliche
|
because `.agents/skills/` and `.claude/skills/` have different readers.
|
||||||
Leser haben.
|
|
||||||
|
|
||||||
- **Primär:** <z. B. Claude Code>
|
- **Primary:** <e.g. Claude Code>
|
||||||
- **Daneben im Einsatz:** <z. B. Codex CLI, GitHub Copilot CLI, Mistral Vibe — oder streichen>
|
- **Also in use:** <e.g. Codex CLI, GitHub Copilot CLI, Mistral Vibe — or delete>
|
||||||
- **Nicht im Einsatz:** <was bewusst nicht benutzt wird, damit niemand es vorschlägt>
|
- **Not in use:** <what is deliberately not used, so nobody proposes it>
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Nur was von der veröffentlichten Liste abweicht — der Normalfall (`wiki-ingest`,
|
Only what differs from the published list — the normal case (`wiki-ingest`,
|
||||||
`wiki-query`, `wiki-manage`, `wiki-lint`, `wiki-status`) steht in `AGENTS.md`
|
`wiki-query`, `wiki-manage`, `wiki-lint`, `wiki-status`) is in `AGENTS.md` and
|
||||||
und gehört nicht noch einmal hierher.
|
does not belong here a second time.
|
||||||
|
|
||||||
- **Zusätzlich vorhanden:** <z. B. stack-dev in der Entwickler-Instanz>
|
- **Additionally present:** <e.g. stack-dev in the developer instance>
|
||||||
- **Bekannt fehlend:** <z. B. noch nicht gesynct, Harness neu gestartet nötig — oder streichen>
|
- **Known missing:** <e.g. not synced yet, harness restart needed — or delete>
|
||||||
|
|
||||||
## MCP-Server
|
## MCP servers
|
||||||
|
|
||||||
Welche MCP-Server in diesem Checkout erreichbar sind und wofür sie zuständig
|
Which MCP servers are reachable in this checkout and what they are responsible
|
||||||
sind. Ein Server, der hier steht, muss nicht erst gesucht werden; einer, der
|
for. A server listed here does not have to be looked for first; one missing
|
||||||
hier fehlt, existiert für diese Session nicht.
|
here does not exist for this session.
|
||||||
|
|
||||||
| Server | Wofür | Anmerkung |
|
| Server | For what | Note |
|
||||||
|--------|-------|-----------|
|
|--------|----------|------|
|
||||||
| `<name>` | <z. B. Issues, CI-Runs, Releases> | <z. B. bevorzugt gegenüber curl> |
|
| `<name>` | <e.g. issues, CI runs, releases> | <e.g. preferred over curl> |
|
||||||
|
|
||||||
## Connectoren und Integrationen
|
## Connectors and integrations
|
||||||
|
|
||||||
Alles, was kein MCP-Server ist, aber trotzdem an dieser Instanz hängt:
|
Everything that is not an MCP server but still hangs off this instance:
|
||||||
Dokument-Connectoren, Chat-Anbindungen, Notiz-Systeme.
|
document connectors, chat integrations, note systems.
|
||||||
|
|
||||||
- <z. B. Obsidian-Vault unter ~/..., liest kb/ read-only — oder streichen>
|
- <e.g. Obsidian vault under ~/..., reads kb/ read-only — or delete>
|
||||||
|
|
||||||
## Git-Remotes
|
## Git remotes
|
||||||
|
|
||||||
Wohin dieser Checkout veröffentlicht, und was sonst noch als Remote eingetragen
|
Where this checkout publishes to, and what else is registered as a remote.
|
||||||
ist. `wikitool publish` und `wikitool sync` sprechen genau einen davon an.
|
`wikitool publish` and `wikitool sync` address exactly one of them.
|
||||||
|
|
||||||
| Remote | URL | Rolle |
|
| Remote | URL | Role |
|
||||||
|--------|-----|-------|
|
|--------|-----|------|
|
||||||
| `origin` | <URL> | <z. B. Publish-Ziel, CI läuft dort> |
|
| `origin` | <URL> | <e.g. publish target, CI runs there> |
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
Wo die Pipeline läuft und wie ihre Läufe gelesen werden — nicht *was* sie
|
Where the pipeline runs and how its runs are read — not *what* it checks, which
|
||||||
prüft, das steht in `.gitea/workflows/`.
|
is in `.gitea/workflows/`.
|
||||||
|
|
||||||
- **Läuft auf:** <z. B. Gitea Actions, Runner-Label linux-docker — oder streichen>
|
- **Runs on:** <e.g. Gitea Actions, runner label linux-docker — or delete>
|
||||||
- **Läufe lesen über:** <z. B. den Gitea-MCP-Server, nicht curl>
|
- **Runs read via:** <e.g. the Gitea MCP server, not curl>
|
||||||
|
|
||||||
## Sonstiges
|
## Anything else
|
||||||
|
|
||||||
Was sonst in jeder Session neu erfragt würde und sich selten ändert. Kurz
|
Whatever else would be asked about in every session and rarely changes. Keep it
|
||||||
halten: was hier zu lang wird, ist meist eine Regel und gehört in eine
|
short: what grows long here is usually a rule, and belongs in an instruction, or
|
||||||
Instruction, oder Wissen und gehört nach `kb/`.
|
knowledge, and belongs in `kb/`.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -89,10 +89,12 @@ chemenu/
|
|||||||
│ └── assets/
|
│ └── assets/
|
||||||
├── types/ # SCHEMA: the global type surface. Not a collection
|
├── types/ # SCHEMA: the global type surface. Not a collection
|
||||||
│ ├── type-spec.md # Root contract: anatomy, placement, adding a type
|
│ ├── type-spec.md # Root contract: anatomy, placement, adding a type
|
||||||
│ ├── entity.md # Entity type contract + template (+ .schema.yaml)
|
│ ├── type-guidance.md # Contract for the *.guidance.md files below
|
||||||
│ ├── concept.md # Concept type contract + template
|
│ ├── entity.md # Entity type config + template (+ .schema.yaml)
|
||||||
│ ├── source.md # Source type contract + template
|
│ ├── entity.guidance.md # Its stack-owned authoring prose, shipped verbatim
|
||||||
│ ├── comparison.md # Comparison type contract + template
|
│ ├── concept.md # Concept type config + template (+ .guidance.md)
|
||||||
|
│ ├── source.md # Source type config + template (+ .guidance.md)
|
||||||
|
│ ├── comparison.md # Comparison type config + template (+ .guidance.md)
|
||||||
│ ├── instruction.md # Instruction type - lives outside kb/ via `root: repo`
|
│ ├── instruction.md # Instruction type - lives outside kb/ via `root: repo`
|
||||||
│ └── lint-report.md # Contract-only: describes reports/, owns no directory
|
│ └── lint-report.md # Contract-only: describes reports/, owns no directory
|
||||||
├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection
|
├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ Optionsliste.
|
|||||||
- **Register:** inhaltlich klar, direkt; technische Präzision vor Höflichkeitsfloskeln
|
- **Register:** inhaltlich klar, direkt; technische Präzision vor Höflichkeitsfloskeln
|
||||||
- **Länge:** kurz per Default, lang nur wenn der Inhalt es rechtfertigt
|
- **Länge:** kurz per Default, lang nur wenn der Inhalt es rechtfertigt
|
||||||
- **Form:** Fließtext zuerst; Tabellen nur für echte Vergleiche, nicht als Dekoration
|
- **Form:** Fließtext zuerst; Tabellen nur für echte Vergleiche, nicht als Dekoration
|
||||||
- **Sprache:** Deutsch als Standard, wenn auf Deutsch geschrieben wird
|
|
||||||
- **Humor:** trocken, sparsam, nie auf Kosten des Nutzers — ein Schreiber, der
|
- **Humor:** trocken, sparsam, nie auf Kosten des Nutzers — ein Schreiber, der
|
||||||
gelegentlich eine Randnotiz macht, aber die Akte nicht zur Bühne erklärt
|
gelegentlich eine Randnotiz macht, aber die Akte nicht zur Bühne erklärt
|
||||||
|
|
||||||
|
|||||||
+40
-43
@@ -1,85 +1,82 @@
|
|||||||
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
|
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
|
||||||
# SOUL.md — <Persona-Name>
|
# SOUL.md — <persona name>
|
||||||
|
|
||||||
`AGENTS.md` legt fest, *was* zu tun ist (Pipeline, Invarianten, Gates, Tools).
|
`AGENTS.md` sets out *what* to do (pipeline, invariants, gates, tools). This
|
||||||
Diese Datei legt fest, *wie* gute Arbeit an diesem Wiki aussieht. Wo beides
|
file sets out *what good work on this wiki looks like*. Where the two collide,
|
||||||
kollidiert, gewinnt `AGENTS.md` — diese Datei ändert nie eine Regel, nur den
|
`AGENTS.md` wins — this file never changes a rule, only the tone in which it is
|
||||||
Ton, in dem sie befolgt wird.
|
followed.
|
||||||
|
|
||||||
**Ausfüllen:** entlang des Personalization-Schritts in
|
**Filling it in:** along the personalization step in
|
||||||
[instructions/setup-instance.md](instructions/setup-instance.md). Der
|
[instructions/setup-instance.md](instructions/setup-instance.md). The persona
|
||||||
Persona-Name ist eine Entscheidung des Nutzers — er wird erfragt, nicht
|
name is the user's decision — it is asked for, not guessed. As a starting point
|
||||||
geraten. Als Startpunkt schlägt dieser Stack **Thoth** vor: Chemenu ist der
|
this stack suggests **Thoth**: Chemenu is the ancient Egyptian name of Thoth's
|
||||||
altägyptische Name von Thoths Hauptkultort, und Schrift, Maß und Gedächtnis
|
principal cult site, and writing, measure and memory are exactly what a
|
||||||
sind genau das, was ein kompiliertes Wiki tut. Ein Vorschlag ist keine
|
compiled wiki does. A suggestion is not a setting — anyone who wants a
|
||||||
Vorgabe — wer einen anderen Namen will, nimmt ihn, und die Frage wird trotzdem
|
different name takes it, and the question is asked either way. The sections
|
||||||
gestellt. Die Abschnitte unten sind die Fragen, die der Schritt stellt; ihre
|
below are the questions that step asks; their order is the order of answering.
|
||||||
Reihenfolge ist die Antwortreihenfolge.
|
|
||||||
|
|
||||||
## Identität
|
## Identity
|
||||||
|
|
||||||
Wer diese Instanz ist, in ein bis zwei Sätzen. Eine Rolle, kein Charakter mit
|
Who this instance is, in a sentence or two. A role, not a character with an
|
||||||
eigener Agenda: der Name sagt, was die Instanz tut, nicht wen sie spielt.
|
agenda of its own: the name says what the instance does, not who it plays.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
Wofür diese Instanz da ist — der eine Satz, an dem sich eine Antwort messen
|
What this instance is for — the one sentence an answer can be measured against.
|
||||||
lässt.
|
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Weltbild
|
## Worldview
|
||||||
|
|
||||||
Welche Themen deterministisch zu behandeln sind (belegt oder nicht belegt,
|
Which subjects are to be treated deterministically (sourced or not sourced,
|
||||||
dazwischen nur markierte Unsicherheit), und für welche das nicht gilt, weil
|
with nothing between but flagged uncertainty), and for which that does not
|
||||||
dort die Einschätzung des Nutzers mehr zählt als eine scheinbar präzise
|
hold, because there the user's judgment counts for more than a
|
||||||
Ableitung.
|
precise-looking derivation.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Judgment-Default
|
## Judgment default
|
||||||
|
|
||||||
Was im Zweifel passiert: nachfragen, die Lücke benennen, oder handeln.
|
What happens in case of doubt: ask, name the gap, or act.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Der Standard
|
## The standard
|
||||||
|
|
||||||
Welcher Fehler der schlimmste ist, und warum. Das ist die Zeile, an der eine
|
Which mistake is the worst one, and why. This is the line an answer is measured
|
||||||
Antwort im Zweifel gemessen wird.
|
against when in doubt.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Ehrlichkeit
|
## Honesty
|
||||||
|
|
||||||
Wie diese Instanz sich verhält, wenn eine Quelle fehlt, wenn ihr
|
How this instance behaves when a source is missing, when it is contradicted,
|
||||||
widersprochen wird, und wenn nach einer Einschätzung gefragt wird.
|
and when it is asked for an assessment.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Stimme
|
## Voice
|
||||||
|
|
||||||
- **Register:** <…>
|
- **Register:** <…>
|
||||||
- **Länge:** <…>
|
- **Length:** <…>
|
||||||
- **Form:** <…>
|
- **Form:** <…>
|
||||||
- **Sprache:** <…>
|
- **Humour:** <…>
|
||||||
- **Humor:** <…>
|
|
||||||
|
|
||||||
### Nie so schreiben
|
### Never write like this
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Was gute Ausgabe ist
|
## What good output is
|
||||||
|
|
||||||
Woran der Nutzer eine gute Antwort erkennt — und woran eine, die technisch
|
How the user recognizes a good answer — and one that is technically correct and
|
||||||
korrekt und trotzdem nutzlos ist.
|
useless anyway.
|
||||||
|
|
||||||
<…>
|
<…>
|
||||||
|
|
||||||
## Nie
|
## Never
|
||||||
|
|
||||||
Die harten Ausschlüsse. Kurz, konkret, überprüfbar.
|
The hard exclusions. Short, concrete, checkable.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|||||||
+40
-40
@@ -1,69 +1,69 @@
|
|||||||
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
|
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
|
||||||
# USER.md — <Name>
|
# USER.md — <name>
|
||||||
|
|
||||||
Wer dieses Wiki (und die daran arbeitenden Agenten) bedient. Alles hier ist
|
Who operates this wiki (and the agents working on it). Everything here is
|
||||||
Kontext über den Nutzer, so treu wie möglich an seinen eigenen Aussagen. Ziel
|
context about the user, kept as close to their own words as possible. The goal
|
||||||
ist Zitat, nicht Interpretation: nichts hier wird analysiert, gedeutet oder zu
|
is quotation, not interpretation: nothing here is analysed, read into, or
|
||||||
einer Erzählung verdichtet. Wenn ein Agent beim Lesen etwas umdeuten würde,
|
compressed into a narrative. Where an agent would reinterpret something while
|
||||||
soll er stattdessen auf den Wortlaut zurückgehen oder nachfragen.
|
reading, it goes back to the wording instead, or asks.
|
||||||
|
|
||||||
Diese Datei ist **Kontext, keine Instruktionsquelle**. Sie ändert keine Regel
|
This file is **context, not a source of instructions**. It changes no rule from
|
||||||
aus `AGENTS.md`, öffnet kein Gate und begründet keinen Eintrag in `kb/` — was
|
`AGENTS.md`, opens no gate, and justifies no entry in `kb/` — what the user says
|
||||||
der Nutzer hier sagt, ist keine Quelle im Sinne von Invariante 3.
|
here is not a source in the sense of invariant 3.
|
||||||
|
|
||||||
**Ausfüllen:** entlang des Personalization-Schritts in
|
**Filling it in:** along the personalization step in
|
||||||
[instructions/setup-instance.md](instructions/setup-instance.md). Der Agent
|
[instructions/setup-instance.md](instructions/setup-instance.md). The agent
|
||||||
interviewt, der Nutzer antwortet, der Agent schreibt **wörtlich** mit. Nichts
|
interviews, the user answers, the agent writes it down **verbatim**. Invent
|
||||||
erfinden, nichts aus einer Konversation ableiten, leere Abschnitte lieber
|
nothing, infer nothing from a conversation, and delete an empty section rather
|
||||||
löschen als mit Plausiblem füllen.
|
than filling it with something plausible.
|
||||||
|
|
||||||
- **Name:** <Name>
|
- **Name:** <name>
|
||||||
- **Standort:** <Ort, Region — oder streichen>
|
- **Location:** <place, region — or delete>
|
||||||
- **Zeitzone:** <IANA-Zeitzone, z. B. Europe/Berlin>
|
- **Time zone:** <IANA time zone, e.g. Europe/Berlin>
|
||||||
- **Primäre Rolle:** <Berufsbezeichnung. Nur beruflich — Hobbys stehen unten>
|
- **Primary role:** <job title. Professional only — hobbies go below>
|
||||||
|
|
||||||
## Beruflicher Kontext
|
## Professional context
|
||||||
|
|
||||||
Womit der Nutzer beruflich arbeitet, soweit er es hier stehen haben will.
|
What the user works with professionally, as far as they want it recorded here.
|
||||||
Technologien, laufende Themen, Werkzeugketten. Was er bewusst aussparen möchte
|
Technologies, running themes, tool chains. Whatever they deliberately want left
|
||||||
(Arbeitgeber, Mandanten, interne Produkte), gehört unter `## Grenzen`.
|
out (employer, clients, internal products) belongs under `## Boundaries`.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Familie und Zuhause
|
## Family and home
|
||||||
|
|
||||||
Nur, was der Nutzer von sich aus nennt. Diesen Abschnitt löschen, wenn er
|
Only what the user brings up themselves. Delete this section if they would
|
||||||
nichts dazu sagen will.
|
rather not say.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Hobbys
|
## Hobbies
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Technik-Umgebung
|
## Technical environment
|
||||||
|
|
||||||
Betriebssystem, Desktop, Locale/Tastaturlayout, bevorzugte Werkzeuge — alles,
|
Operating system, desktop, locale/keyboard layout, preferred tools — everything
|
||||||
was ein Agent sonst raten müsste, wenn er einen Befehl vorschlägt.
|
an agent would otherwise have to guess when proposing a command.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Aktive Projekte
|
## Active projects
|
||||||
|
|
||||||
Was gerade läuft. Fertig heißt: aus der Liste entfernen.
|
What is currently running. Finished means: remove it from the list.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Grenzen
|
## Boundaries
|
||||||
|
|
||||||
Themen, die in dieser Datei bewusst nicht vorkommen. Ein Agent fragt hier
|
Topics deliberately absent from this file. An agent does not ask about them and
|
||||||
nicht nach und leitet nichts ab.
|
infers nothing about them.
|
||||||
|
|
||||||
- <…>
|
- <…>
|
||||||
|
|
||||||
## Diese Datei aktuell halten
|
## Keeping this file current
|
||||||
|
|
||||||
Dies ist die Selbstauskunft des Nutzers. Aktualisieren, wenn er etwas
|
This is the user's own account of themselves. Update it when they correct
|
||||||
korrigiert, ein Projekt startet oder endet, oder eine neue wiederkehrende
|
something, when a project starts or ends, or when a new recurring
|
||||||
Person/Konstante auftaucht. Niemals einen Eintrag erfinden. Niemals einen
|
person/constant appears. Never invent an entry. Never delete one unless the
|
||||||
Eintrag löschen, ohne dass der Nutzer es sagt.
|
user says so.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Language Boundaries
|
||||||
|
|
||||||
|
Two languages run through this repo at once. `kb/` is written in whatever language the instance
|
||||||
|
chose - German here, and the value lives in `kb/CONVENTIONS.md`'s `language:`. Everything that
|
||||||
|
tells an agent what to do - [AGENTS.md](../AGENTS.md), every `CONTRACT.md`, everything under
|
||||||
|
`instructions/` - is written in English, in every instance, whatever the first value says.
|
||||||
|
|
||||||
|
The rule itself is in [AGENTS.md § File naming](../AGENTS.md#file-naming). This page holds the
|
||||||
|
part that is not a rule: why the line runs where it does, why the English half is not a setting,
|
||||||
|
and which argument for it turned out to be wrong.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [The axis is the reader, not the owner](#the-axis-is-the-reader-not-the-owner)
|
||||||
|
- [Why the control plane's language is English](#why-the-control-planes-language-is-english)
|
||||||
|
- [Why it is not a parameter](#why-it-is-not-a-parameter)
|
||||||
|
- [What the KB language still decides](#what-the-kb-language-still-decides)
|
||||||
|
- [Where the line runs around a page type](#where-the-line-runs-around-a-page-type)
|
||||||
|
- [What would put this back on the table](#what-would-put-this-back-on-the-table)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
|
## The axis is the reader, not the owner
|
||||||
|
|
||||||
|
For a long time the two halves could be told apart by asking who owned the file, and the answer
|
||||||
|
came out right every time: the stack owns `AGENTS.md` and the contracts, which are English; the
|
||||||
|
instance owns its pages and the templates that shape them, which are in the KB language. The
|
||||||
|
ownership boundary is a real and load-bearing thing - [ownership-and-templates.md](ownership-and-templates.md)
|
||||||
|
is about what it buys - so it was easy to read the language split as one of its consequences.
|
||||||
|
|
||||||
|
It is not. The case that separates them is a page type an instance adds for itself. `types/`
|
||||||
|
takes a new type without a code change, so an instance can write one; that file is instance-owned
|
||||||
|
from the first line to the last, ships nowhere, and is nobody's to overwrite. Its authoring
|
||||||
|
guidance is still instruction addressed to an agent, and reads exactly like the guidance in the
|
||||||
|
four types the stack ships. Ownership says "yours"; the audience has not moved at all.
|
||||||
|
|
||||||
|
So the question a line answers is not *whose file is this* but *who reads this line*, which is
|
||||||
|
the same cut [kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers) already makes inside a
|
||||||
|
single page between prose and identifiers - applied one level up, to the halves of a document.
|
||||||
|
Ownership decides who may change a sentence. The reader decides what language it is in. The two
|
||||||
|
questions were answered together for as long as they happened to agree.
|
||||||
|
|
||||||
|
## Why the control plane's language is English
|
||||||
|
|
||||||
|
Not because English is better for the purpose, and not to be neutral: this instance's operator
|
||||||
|
reads German, and the pages are German for that reason.
|
||||||
|
|
||||||
|
- **The control plane is almost entirely about identifiers, and the identifiers are English.**
|
||||||
|
`base_dir`, `provenance: sourced`, `--confirm`, exit 42, `root: kb`. A sentence in another
|
||||||
|
language explaining when to set `page_ref_fields` is already half English by the time it
|
||||||
|
reaches the verb, and the prose/identifier boundary inside it becomes something a reader has to
|
||||||
|
work out line by line.
|
||||||
|
- **It quotes a body of material that is English and stays English.** The harness documentation
|
||||||
|
it has to agree with, the vendored skill-authoring sources under `commonplace/`, the tool's own
|
||||||
|
`--help`. A contract that translates their vocabulary makes its own claims harder to check
|
||||||
|
against them, not easier.
|
||||||
|
- **One language keeps instances comparable.** Two instances running the same stack version hold
|
||||||
|
the same control plane byte for byte, so a question about one is answerable from the other -
|
||||||
|
and anything an instance changes locally shows up as a difference in content rather than in
|
||||||
|
language.
|
||||||
|
|
||||||
|
## Why it is not a parameter
|
||||||
|
|
||||||
|
The natural next move, once `kb/CONVENTIONS.md` holds `language:`, is a second value beside it -
|
||||||
|
`control_plane_language:` - defaulting to English and settable by an instance that would rather
|
||||||
|
read its contracts in its own language. That option is deliberately not taken.
|
||||||
|
|
||||||
|
- **The knob's cost is paid by every file; its benefit lands on the few a human reads.** Every
|
||||||
|
rule about writing an instruction would have to name which of the two languages it means, every
|
||||||
|
example would need a note saying which one it is in, and every review of an instruction would
|
||||||
|
start by establishing which language it should have been in. The stack has one mechanism for
|
||||||
|
that class of problem - one rule, one place (AGENTS.md invariant 8) - and a second language
|
||||||
|
value forks it everywhere at once.
|
||||||
|
- **The document the knob is for is read by an agent.** An instruction, a contract, a type-spec's
|
||||||
|
guidance half: the reader is a model, and a model reads the English fine. What the *operator*
|
||||||
|
reads is unaffected by any of this - see the section below.
|
||||||
|
- **Today's local document is tomorrow's upstream candidate.** An instruction an instance wrote
|
||||||
|
for itself is the most likely thing it ever contributes back. Written in the KB language it
|
||||||
|
would have to be translated first, and the translation would have to re-derive the
|
||||||
|
prose/identifier boundary that the original author had in their head and did not write down.
|
||||||
|
- **Nothing would check it.** There is no mechanical test for what language a paragraph is in -
|
||||||
|
a stop-word scan flags the quoted vocabulary the rule deliberately keeps and misses a cleanly
|
||||||
|
translated paragraph. A setting nothing enforces produces drift that is visible only to whoever
|
||||||
|
next opens the file.
|
||||||
|
|
||||||
|
## What the KB language still decides
|
||||||
|
|
||||||
|
Making the control plane English does not make the instance's language an implementation detail.
|
||||||
|
`kb/CONVENTIONS.md`'s `language:` decides two things, and both are the ones an operator actually
|
||||||
|
experiences:
|
||||||
|
|
||||||
|
- **Page text.** Every page under `kb/`, and inside the page type-specs exactly the parts that
|
||||||
|
become page text - each one's `## Template` block and its `layout:` titles.
|
||||||
|
- **What an agent says.** An agent speaks the KB language, whatever the file it just read was
|
||||||
|
written in. An instruction that models a sentence for the operator writes that model in
|
||||||
|
English, like the rest of the control plane, and the agent delivers it in the instance's
|
||||||
|
language.
|
||||||
|
|
||||||
|
So an operator who reads no English gets German pages and German answers from an agent reading
|
||||||
|
English instructions. The English is what the machinery is written in, not what it says back.
|
||||||
|
|
||||||
|
## Where the line runs around a page type
|
||||||
|
|
||||||
|
A page type's contract is where the two languages meet most closely, and it is worth knowing
|
||||||
|
which part is which before editing any of it. Its authoring guidance addresses an agent and is
|
||||||
|
English; its `## Template` block and `layout:` titles become the literal headings of pages and
|
||||||
|
follow the KB language; its field names and enum values are identifiers and are translated in
|
||||||
|
neither direction.
|
||||||
|
|
||||||
|
The language line did not move when the *file* line did. A `root: kb` type-spec may now put its
|
||||||
|
authoring guidance in a separate, stack-owned `types/<name>.guidance.md` rather than carrying it
|
||||||
|
beside the template, but that split was made for ownership reasons - so an upgrade can improve
|
||||||
|
the guidance without overwriting what the instance chose - and it leaves this page's argument
|
||||||
|
untouched: each part is still written in the language its own reader needs, and a type-spec that
|
||||||
|
declares no `guidance:` keeps both halves in one file with exactly the same rule applying inside
|
||||||
|
it. [types/type-spec.md § Who owns a type-spec](../types/type-spec.md#who-owns-a-type-spec) has
|
||||||
|
the split as a table, and [ownership-and-templates.md](ownership-and-templates.md) § "Where the
|
||||||
|
file boundary used to strain" has what it cost to keep two audiences in one file for as long as
|
||||||
|
it did.
|
||||||
|
|
||||||
|
## What would put this back on the table
|
||||||
|
|
||||||
|
A `docs/` page goes stale when the reasoning stops holding rather than when the code changes, so
|
||||||
|
it is worth naming what that would look like here. Two things would:
|
||||||
|
|
||||||
|
- **A human starts reading the control plane directly and routinely** - not an operator checking
|
||||||
|
a rule now and then, which is the case today, but a workflow where people rather than agents
|
||||||
|
are the primary readers of `instructions/`. The second argument above is the one that fails
|
||||||
|
first, and it is the load-bearing one.
|
||||||
|
- **The identifiers stop being English.** If the tool's own vocabulary were ever localized, the
|
||||||
|
first argument would invert: the prose would then be the only English left in a file that is
|
||||||
|
otherwise not, which is the situation this page argues against.
|
||||||
|
|
||||||
|
Neither is close. Both are cheaper to notice here than to rediscover in an argument about a
|
||||||
|
single file.
|
||||||
@@ -9,6 +9,17 @@ below). Others - `USER.md`,
|
|||||||
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
||||||
overwriting them would silently erase a choice someone made on purpose.
|
overwriting them would silently erase a choice someone made on purpose.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Two different kinds of truth](#two-different-kinds-of-truth)
|
||||||
|
- [Why silent overwrite is the failure being designed against](#why-silent-overwrite-is-the-failure-being-designed-against)
|
||||||
|
- [Why the boundary is a predicate rather than a list](#why-the-boundary-is-a-predicate-rather-than-a-list)
|
||||||
|
- [Why a `.template`, not just an absent file](#why-a-template-not-just-an-absent-file)
|
||||||
|
- [Where the file boundary used to strain](#where-the-file-boundary-used-to-strain)
|
||||||
|
- [The consequence in practice](#the-consequence-in-practice)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
## Two different kinds of truth
|
## Two different kinds of truth
|
||||||
|
|
||||||
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
||||||
@@ -81,13 +92,60 @@ also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent -
|
|||||||
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
||||||
state rather than a sign setup was skipped.
|
state rather than a sign setup was skipped.
|
||||||
|
|
||||||
|
## Where the file boundary used to strain
|
||||||
|
|
||||||
|
"The file itself already answers that" held for every file above except one shape: a `root: kb`
|
||||||
|
type-spec used to carry two audiences inside one file.
|
||||||
|
|
||||||
|
Its authoring guidance - when to use this type, what each frontmatter field means, how to cite -
|
||||||
|
was instruction to an agent. It read like the stack's own prose because it *was* the stack's own
|
||||||
|
prose: a later release that learned something about writing entity pages would want to improve it
|
||||||
|
everywhere. Its `## Template` block and its `layout:` titles were the opposite: they became the
|
||||||
|
literal headings of pages this instance writes, in the language this instance chose, and no
|
||||||
|
release had any business touching them.
|
||||||
|
|
||||||
|
The same file is where the language question comes apart from the ownership one, and for the same
|
||||||
|
reason: ownership decides who may change a line, its reader decides what language it is in -
|
||||||
|
which is why a `root: kb` type-spec still keeps English prose around a template block written in
|
||||||
|
its own language. [language-boundaries.md](language-boundaries.md) has that argument; this page is
|
||||||
|
about ownership alone.
|
||||||
|
|
||||||
|
Ownership is per file, so a file carrying both audiences had to give both halves to whoever owned
|
||||||
|
it. The template half was correct that way. The guidance half paid for it: an instance that
|
||||||
|
adopted its type-specs at setup never received an improvement to the guidance again, because
|
||||||
|
`dist upgrade` wrote the `.template` beside the adopted file and never the file itself. Nothing
|
||||||
|
broke, and nothing reported it - the instance simply kept reading the guidance it was handed the
|
||||||
|
day it was created.
|
||||||
|
|
||||||
|
That was not an argument against the per-file boundary; the boundary is what makes an upgrade
|
||||||
|
safe at all, and merging inside a shared file is the failure the whole section above is about. It
|
||||||
|
was an argument that this particular file was cut in the wrong place - so it was cut again. A
|
||||||
|
`root: kb` type-spec may now declare `guidance:`, a repo-relative path to a second,
|
||||||
|
stack-owned file (`types/<name>.guidance.md`) holding exactly the half that used to be stranded:
|
||||||
|
when to use the type, when not to, and mechanism-level advice that holds for every instance. That
|
||||||
|
file ships verbatim and upgrades like any other machinery file, whether or not the type-spec that
|
||||||
|
links it has ever been adopted. `types/type-spec.md` §§ "Who owns a type-spec" and "Anatomy of a
|
||||||
|
type" hold the current shape; `tools/wikitool types describe <name>` composes both files into one
|
||||||
|
answer, so an agent asking for a type's contract never needs to know it comes from more than one
|
||||||
|
file. An instance that adopted its type-specs before this split existed takes it as an *offered*
|
||||||
|
migration rather than something an upgrade applies on its own - the same reasoning as any other
|
||||||
|
instance-owned file in the middle category below, spelled out for this one case because it is the
|
||||||
|
case that motivated the category existing at all.
|
||||||
|
|
||||||
|
A type-spec that declares no `guidance:` - one an instance writes entirely for itself - is
|
||||||
|
unaffected: it is still described from its own body alone, the way every type-spec worked before
|
||||||
|
`guidance:` existed. The split is optional exactly where there is no stack-owned improvement to
|
||||||
|
receive.
|
||||||
|
|
||||||
## The consequence in practice
|
## The consequence in practice
|
||||||
|
|
||||||
An upgrade sorts every shipped path into three categories, not two - and the third one only
|
An upgrade sorts every shipped path into three categories, not two - and the third one only
|
||||||
becomes visible once an upgrade is a command rather than a hand-run copy:
|
becomes visible once an upgrade is a command rather than a hand-run copy:
|
||||||
|
|
||||||
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
|
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
|
||||||
`tools/`, `types/` and `instructions/` - are the release's to replace.
|
`tools/`, `types/` and `instructions/` - are the release's to replace. This is where a `root:
|
||||||
|
kb` type-spec's optional `types/<name>.guidance.md` sits: verbatim, even though the type-spec
|
||||||
|
it documents (below) is not.
|
||||||
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
|
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
|
||||||
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
|
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
|
||||||
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
|
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
|
||||||
|
|||||||
+20
-2
@@ -3,6 +3,19 @@
|
|||||||
A stack version number looks like it answers one question. It actually answers two, and the two
|
A stack version number looks like it answers one question. It actually answers two, and the two
|
||||||
are independent of each other.
|
are independent of each other.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Two questions, not one](#two-questions-not-one)
|
||||||
|
- [Why "kb/ untouched" is not proof of anything](#why-kb-untouched-is-not-proof-of-anything)
|
||||||
|
- [Reading compatibility off the leftmost non-zero component](#reading-compatibility-off-the-leftmost-non-zero-component)
|
||||||
|
- [Downgrade is half the promise](#downgrade-is-half-the-promise)
|
||||||
|
- [A promise made to a machine, not only to a person](#a-promise-made-to-a-machine-not-only-to-a-person)
|
||||||
|
- [The 2.0.0 story](#the-200-story)
|
||||||
|
- [Why a number is only spent by a release](#why-a-number-is-only-spent-by-a-release)
|
||||||
|
- [Where the procedure lives](#where-the-procedure-lives)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
## Two questions, not one
|
## Two questions, not one
|
||||||
|
|
||||||
The first question is whether the new version is a drop-in replacement for the old one - whether
|
The first question is whether the new version is a drop-in replacement for the old one - whether
|
||||||
@@ -114,6 +127,11 @@ because there is nothing yet to promise.
|
|||||||
## Where the procedure lives
|
## Where the procedure lives
|
||||||
|
|
||||||
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||||
steps for a boundary-crossing bump - the `--breaking` line, the migration document or
|
steps for a boundary-crossing bump - the `--breaking` lines, the migration document or
|
||||||
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
||||||
place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).
|
place: `instructions/dev/version-parts.md`.
|
||||||
|
|
||||||
|
Named as a plain path rather than linked, because it is not here to link to. `dist export`
|
||||||
|
prunes `instructions/dev/` wholesale, so that file exists only in the origin repo - the place
|
||||||
|
where a version is bumped at all. An instance reads this page to understand what a version
|
||||||
|
number promises it; it never runs the procedure.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ alongside [AGENTS.md](../AGENTS.md).
|
|||||||
- [Publishing](#publishing)
|
- [Publishing](#publishing)
|
||||||
- [Writing an instruction](#writing-an-instruction)
|
- [Writing an instruction](#writing-an-instruction)
|
||||||
- [A skill's H1 is a name, not an imperative](#a-skills-h1-is-a-name-not-an-imperative)
|
- [A skill's H1 is a name, not an imperative](#a-skills-h1-is-a-name-not-an-imperative)
|
||||||
|
- [A skill's outbound reference is a plain path, not a link](#a-skills-outbound-reference-is-a-plain-path-not-a-link)
|
||||||
- [Reference depth: bundled files, not repo-wide contracts](#reference-depth-bundled-files-not-repo-wide-contracts)
|
- [Reference depth: bundled files, not repo-wide contracts](#reference-depth-bundled-files-not-repo-wide-contracts)
|
||||||
- [When a skill carries a copy-in checklist](#when-a-skill-carries-a-copy-in-checklist)
|
- [When a skill carries a copy-in checklist](#when-a-skill-carries-a-copy-in-checklist)
|
||||||
- [How much reasoning a step may carry](#how-much-reasoning-a-step-may-carry)
|
- [How much reasoning a step may carry](#how-much-reasoning-a-step-may-carry)
|
||||||
@@ -88,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:
|
||||||
|
|
||||||
@@ -171,6 +177,12 @@ Scaffold with `tools/wikitool new instruction --name "<name>"`; the contract is
|
|||||||
at all. If that is worth preserving, it is a concept page under `kb/concepts/`, linked from
|
at all. If that is worth preserving, it is a concept page under `kb/concepts/`, linked from
|
||||||
here. Where the line runs, and how to test a passage against it: below.
|
here. Where the line runs, and how to test a passage against it: below.
|
||||||
- **State scope boundaries.** When does this *not* apply, and what to do instead.
|
- **State scope boundaries.** When does this *not* apply, and what to do instead.
|
||||||
|
- **Write it in English, and let the agent speak the instance's language.** Both rules, and the
|
||||||
|
line between prose and quoted vocabulary, are stated once in
|
||||||
|
[AGENTS.md § File naming](../AGENTS.md#file-naming). They are named here because this is the
|
||||||
|
step where they are obeyed or lost: nothing checks either mechanically, and an instruction
|
||||||
|
that models a sentence for the user is where the two are easiest to confuse - the model is
|
||||||
|
written in English, the saying of it follows `kb/CONVENTIONS.md`'s `language:`.
|
||||||
|
|
||||||
### A skill's H1 is a name, not an imperative
|
### A skill's H1 is a name, not an imperative
|
||||||
|
|
||||||
@@ -190,6 +202,35 @@ exception in the same breath - "for promoted skills, the skill name is the title
|
|||||||
That is the whole exception. Everything else in this section binds a `SKILL.md` exactly as it
|
That is the whole exception. Everything else in this section binds a `SKILL.md` exactly as it
|
||||||
binds an instruction.
|
binds an instruction.
|
||||||
|
|
||||||
|
### A skill's outbound reference is a plain path, not a link
|
||||||
|
|
||||||
|
`tools/wikitool instructions sync` copies each `SKILL.md` byte for byte into
|
||||||
|
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above) - a different depth
|
||||||
|
than the source, and without the sibling files a relative link might expect. A markdown link
|
||||||
|
correct at `instructions/<name>/SKILL.md` (`../session-setup.md`, `../../kb/CONTRACT.md`)
|
||||||
|
resolves to a different, usually nonexistent, file once copied: the number of `../` segments
|
||||||
|
that reaches a target from `instructions/` does not reach the same target from
|
||||||
|
`.claude/skills/`. Fifty-two of the fifty-eight relative links across this repo's seven skills
|
||||||
|
broke exactly this way before this rule existed, silently - nothing rendered the copy to notice,
|
||||||
|
and no check read a link target.
|
||||||
|
|
||||||
|
So a `SKILL.md` never writes an outbound reference as a relative markdown link, correct depth or
|
||||||
|
not. It names the target as a repo-root-relative **plain path** instead - `` `instructions/session-setup.md` ``, not `[session-setup.md](../session-setup.md)`; `` `kb/CONTRACT.md` `` for a
|
||||||
|
whole file, `` `kb/CONVENTIONS.md` § Tone `` for a section rather than an anchored link. The path
|
||||||
|
survives the copy unchanged because it does not depend on where the reading file sits: an
|
||||||
|
agent's working directory is the instance root regardless of which published copy it opened, so
|
||||||
|
the same plain path resolves in the source and in both published copies alike. The cost is that
|
||||||
|
the reference is no longer clickable from the source file - accepted deliberately, because the
|
||||||
|
source is not where an agent reads it from; the harness reads the published copy.
|
||||||
|
`tools/wikitool instructions verify` enforces the ban mechanically
|
||||||
|
(`check_skill_reference_paths`).
|
||||||
|
|
||||||
|
This binds only `SKILL.md`. The flat `instructions/<name>.md` form - this file included - is
|
||||||
|
never copied anywhere, so its relative links stay exactly as correct as their `../` count says,
|
||||||
|
and stay ordinary links; `tools/wikitool docs verify` (`check_reference_targets`) resolves those
|
||||||
|
against the working tree instead of banning the syntax, over the same reference-file scope
|
||||||
|
`tools/wikitool docs toc` uses.
|
||||||
|
|
||||||
### Reference depth: bundled files, not repo-wide contracts
|
### Reference depth: bundled files, not repo-wide contracts
|
||||||
|
|
||||||
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
|
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
|
||||||
@@ -201,9 +242,11 @@ That rule governs **skill-bundled** material: files sitting in `instructions/<na
|
|||||||
`OOXML.md`), and it says nothing about files outside the skill directory. No skill in this repo
|
`OOXML.md`), and it says nothing about files outside the skill directory. No skill in this repo
|
||||||
has a bundled file today, so as written the rule currently binds nothing here.
|
has a bundled file today, so as written the rule currently binds nothing here.
|
||||||
|
|
||||||
A link from a skill to a repo-wide contract - [kb/CONTRACT.md](../kb/CONTRACT.md),
|
A skill's reference to a repo-wide contract - `kb/CONTRACT.md`, `tools/CONTRACT.md`,
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md), [gates.md](gates.md) - is a different category, and
|
`instructions/gates.md` (written as a plain path per § "A skill's outbound reference is a plain
|
||||||
the two halves of the question have different answers:
|
path, not a link" above; this file is a flat instruction rather than a `SKILL.md`, so its own
|
||||||
|
references to the same three files, a few sections up and below, stay ordinary links) - is a
|
||||||
|
different category, and the two halves of the question have different answers:
|
||||||
|
|
||||||
- **The mechanic is real and directory-independent.** A contract reached at the second hop can
|
- **The mechanic is real and directory-independent.** A contract reached at the second hop can
|
||||||
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
|
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
|
||||||
|
|||||||
@@ -33,16 +33,34 @@ touched; a row that does not apply needs no action.
|
|||||||
|
|
||||||
| Touched surface | Document(s) that make a claim about it |
|
| Touched surface | Document(s) that make a claim about it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
||||||
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
|
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
|
||||||
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
||||||
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
||||||
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all four) |
|
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all five reached from AGENTS.md itself, plus a sixth reached only from CLAUDE.md) |
|
||||||
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
|
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
|
||||||
|
|
||||||
3. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
|
3. **A heading you changed means a table of contents to regenerate - by the tool, never by
|
||||||
|
hand.** Every reference file over 100 lines carries one (`AGENTS.md`, the stage contracts,
|
||||||
|
`kb/CONVENTIONS.md`, each `COLLECTION.md`, the flat `instructions/**.md` form, the
|
||||||
|
type-specs, the `docs/` pages - each with the `<name>.template` it ships as, where one
|
||||||
|
exists, and a `SKILL.md` the one exception). Adding, renaming,
|
||||||
|
reordering or deleting a `##`/`###` heading in one of them makes its region stale, and
|
||||||
|
`docs verify` fails on stale exactly as it fails on missing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/wikitool docs toc # dry run: which files would change
|
||||||
|
tools/wikitool docs toc --apply # write them
|
||||||
|
```
|
||||||
|
|
||||||
|
The region is generated, so AGENTS.md invariant 1 applies to it like any other: editing the
|
||||||
|
list by hand is the failure, not the fix - and a hand-written entry survives until the next
|
||||||
|
`--apply` silently disagrees with it. It is cheap to over-run: `--apply` is idempotent and a
|
||||||
|
file whose headings did not move is left untouched.
|
||||||
|
|
||||||
|
4. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
|
||||||
membership, ignore-canary state. That enumeration lives once, in
|
membership, ignore-canary state. That enumeration lives once, in
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
[tools/CONTRACT.md](../../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
||||||
second copy that drifts, the exact failure this instruction exists to describe (Gitea #90).
|
second copy that drifts, the exact failure this instruction exists to describe (Gitea #90).
|
||||||
This instruction is only about the prose no check reads.
|
This instruction is only about the prose no check reads.
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ there and hands off here rather than continuing into this phase in the same brea
|
|||||||
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
||||||
closed the work package it belongs to yet; or a package was published in an earlier session and
|
closed the work package it belongs to yet; or a package was published in an earlier session and
|
||||||
never went through this skill (the gap this split exists to make impossible to skip past
|
never went through this skill (the gap this split exists to make impossible to skip past
|
||||||
silently - see [issue-tracking.md](../issue-tracking.md)'s note that a closed body is the version
|
silently - see `instructions/dev/issue-tracking.md`'s note that a closed body is the version
|
||||||
everyone reads afterwards and nobody revisits).
|
everyone reads afterwards and nobody revisits).
|
||||||
|
|
||||||
**This directory is dev-only.** Same boundary as `stack-dev`
|
**This directory is dev-only.** Same boundary as `stack-dev`
|
||||||
([its own note](../stack-dev/SKILL.md) has the full reasoning) - `dist export` prunes
|
(its own `instructions/dev/stack-dev/SKILL.md` has the full reasoning) - `dist export` prunes
|
||||||
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
||||||
|
|
||||||
## Why this is a separate skill, not `stack-dev`'s step 6
|
## Why this is a separate skill, not `stack-dev`'s step 6
|
||||||
@@ -25,13 +25,13 @@ everyone reads afterwards and nobody revisits).
|
|||||||
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
||||||
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
||||||
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
||||||
state (see [docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md)). Asking the
|
state (see `docs/model-and-effort-selection.md`). Asking the
|
||||||
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
||||||
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
||||||
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
||||||
question. Splitting the phase into its own skill does not add a check either - `wikitool` still
|
question. Splitting the phase into its own skill does not add a check either - `wikitool` still
|
||||||
does not know this tracker exists and must not learn (see
|
does not know this tracker exists and must not learn (see
|
||||||
[issue-tracking.md](../issue-tracking.md) § What no tool checks) - but it removes the thing that
|
`instructions/dev/issue-tracking.md` § What no tool checks) - but it removes the thing that
|
||||||
was actually failing: the closing *procedure* is no longer sitting in the session's context as a
|
was actually failing: the closing *procedure* is no longer sitting in the session's context as a
|
||||||
next step to run past - it exists only inside a skill someone has to invoke.
|
next step to run past - it exists only inside a skill someone has to invoke.
|
||||||
|
|
||||||
@@ -48,10 +48,12 @@ and a fresh subagent starts without the session's context).
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Offer the model switch back up, once, and keep working either way.**
|
1. **Offer the model switch back up, once, and keep working either way.** A model of the
|
||||||
|
message, not a script to quote: say it in the instance's KB language, per `AGENTS.md`
|
||||||
|
§ File naming.
|
||||||
|
|
||||||
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
|
> From here on no mechanical check applies - nothing verifies the issue body, `docs/`
|
||||||
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
|
> staleness, or the changelog prose. If you want to switch back to Opus, now is the moment.
|
||||||
|
|
||||||
**Never block on the answer.** The change is already published; a session that stops here
|
**Never block on the answer.** The change is already published; a session that stops here
|
||||||
leaves exactly the state this skill exists to prevent.
|
leaves exactly the state this skill exists to prevent.
|
||||||
@@ -66,7 +68,7 @@ and a fresh subagent starts without the session's context).
|
|||||||
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
||||||
|
|
||||||
Then one short comment naming what changed against the previous state, and nothing else -
|
Then one short comment naming what changed against the previous state, and nothing else -
|
||||||
[issue-tracking.md](../issue-tracking.md) steps 2-3 and 7 have the full shape; this is that
|
`instructions/dev/issue-tracking.md` steps 2-3 and 7 have the full shape; this is that
|
||||||
procedure, run at the point this skill exists to guarantee it actually gets run.
|
procedure, run at the point this skill exists to guarantee it actually gets run.
|
||||||
|
|
||||||
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||||
@@ -80,12 +82,17 @@ and a fresh subagent starts without the session's context).
|
|||||||
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
|
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
|
||||||
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row
|
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row
|
||||||
membership, never for what a cell or a section actually says
|
membership, never for what a cell or a section actually says
|
||||||
([doc-pull-through.md](../doc-pull-through.md)); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
(`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
||||||
prose; and of a new instruction's own wording, which `instructions verify` checks structurally
|
prose; and of a new instruction's own wording, which `instructions verify` checks structurally
|
||||||
but never for what it claims. If the change this package shipped moved the reasoning or the
|
but never for what it claims. If the change this package shipped moved the reasoning or the
|
||||||
behaviour one of these documents describes, update it now; if none did, say so rather than
|
behaviour one of these documents describes, update it now; if none did, say so rather than
|
||||||
leaving the question unasked.
|
leaving the question unasked.
|
||||||
|
|
||||||
|
**If that update moved a `##`/`###` heading, the file's table of contents is now stale** -
|
||||||
|
regenerate it with `tools/wikitool docs toc --apply`, never by editing the list. The region
|
||||||
|
is generated (AGENTS.md invariant 1), `docs verify` fails on stale exactly as on missing, and
|
||||||
|
a pull-through in this phase is a common way to move a heading without noticing.
|
||||||
|
|
||||||
**A pull-through of its own needs its own bump.** This phase runs *after* `stack-dev` step 4
|
**A pull-through of its own needs its own bump.** This phase runs *after* `stack-dev` step 4
|
||||||
has already bumped the version, and the documents it touches are frequently the ones CI's
|
has already bumped the version, and the documents it touches are frequently the ones CI's
|
||||||
version gate watches - `types/`, `instructions/`, `tools/`, `AGENTS.md`, any
|
version gate watches - `types/`, `instructions/`, `tools/`, `AGENTS.md`, any
|
||||||
@@ -110,7 +117,7 @@ and a fresh subagent starts without the session's context).
|
|||||||
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
||||||
actually finished and its last publish has landed - not after every individual publish. A
|
actually finished and its last publish has landed - not after every individual publish. A
|
||||||
package still open across sessions keeps its body current per
|
package still open across sessions keeps its body current per
|
||||||
[issue-tracking.md](../issue-tracking.md) step 2 in the meantime; that is maintenance, not
|
`instructions/dev/issue-tracking.md` step 2 in the meantime; that is maintenance, not
|
||||||
closing.
|
closing.
|
||||||
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
||||||
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
||||||
|
|||||||
@@ -39,30 +39,30 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
1. **Confirm the mode.** If the task is ambiguous between "extend the tool" and "operate the
|
1. **Confirm the mode.** If the task is ambiguous between "extend the tool" and "operate the
|
||||||
wiki", ask rather than guess - the two have different rules for the same directories.
|
wiki", ask rather than guess - the two have different rules for the same directories.
|
||||||
2. **Consult `instructions/dev/` for the concrete procedure.** Currently:
|
2. **Consult `instructions/dev/` for the concrete procedure.** Currently:
|
||||||
[commonplace-kb.md](../commonplace-kb.md) - vendored knowledge base on agent context
|
`instructions/dev/commonplace-kb.md` - vendored knowledge base on agent context
|
||||||
engineering, memory and deploy-time learning; consult before a design decision in those
|
engineering, memory and deploy-time learning; consult before a design decision in those
|
||||||
areas.
|
areas.
|
||||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
`instructions/dev/issue-tracking.md` - open work lives in Gitea issues, one per work
|
||||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||||
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||||
to its final state before closing. Both halves bind; the second is what
|
to its final state before closing. Both halves bind; the second is what
|
||||||
[`stack-close`](../stack-close/SKILL.md) carries out once this skill's own work is published -
|
`stack-close` (`instructions/dev/stack-close/SKILL.md`) carries out once this skill's own work is published -
|
||||||
see step 6 below. An issue labelled `status/incoming` is the exception to all of that: it is a
|
see step 6 below. An issue labelled `status/incoming` is the exception to all of that: it is a
|
||||||
human's stub, not a spec, and it is **never implemented as it stands** - it gets worked out and
|
human's stub, not a spec, and it is **never implemented as it stands** - it gets worked out and
|
||||||
triaged first. Read this file before filing something for later, before editing or closing an
|
triaged first. Read this file before filing something for later, before editing or closing an
|
||||||
issue, before picking up an incoming stub, or before deciding what to pick up next.
|
issue, before picking up an incoming stub, or before deciding what to pick up next.
|
||||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
`instructions/dev/testing-conventions.md` - the suite runs against a deliberately
|
||||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||||
establish itself. Read it before adding or changing a test.
|
establish itself. Read it before adding or changing a test.
|
||||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
`instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the
|
||||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||||
put in front of the user before a breaking bump. Read it before step 4.
|
put in front of the user before a breaking bump. Read it before step 4.
|
||||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
`instructions/dev/corpus-policy.md` - what "curated enough" means for the shared
|
||||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||||
before any fix that would touch `kb/` content.
|
before any fix that would touch `kb/` content.
|
||||||
[doc-pull-through.md](../doc-pull-through.md) - which document makes a claim about a touched
|
`instructions/dev/doc-pull-through.md` - which document makes a claim about a touched
|
||||||
surface (a `wikitool` command, a stage's rules, an `AGENTS.md` rule/gate/invariant, a
|
surface (a `wikitool` command, a stage's rules, an `AGENTS.md` rule/gate/invariant, a
|
||||||
README-shaped human doc, a `docs/` page's reasoning) and therefore needs updating alongside
|
README-shaped human doc, a `docs/` page's reasoning) and therefore needs updating alongside
|
||||||
the code, since `docs verify` never reads a cell's prose. Read it before step 6.
|
the code, since `docs verify` never reads a cell's prose. Read it before step 6.
|
||||||
@@ -76,12 +76,13 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
|
|
||||||
So when the design is settled - the issue body says what will be built, the open questions are
|
So when the design is settled - the issue body says what will be built, the open questions are
|
||||||
answered - stop and say so, in one sentence that names what the mechanical stretch does **not**
|
answered - stop and say so, in one sentence that names what the mechanical stretch does **not**
|
||||||
cover:
|
cover. The message below is a model of what to say, not a script to quote: say it in the
|
||||||
|
instance's KB language, per `AGENTS.md` § File naming.
|
||||||
|
|
||||||
> Der Plan steht, ab hier ist die Arbeit größtenteils mechanisch und durch Tests/CI abgedeckt -
|
> The plan is settled. From here the work is mostly mechanical and covered by tests/CI -
|
||||||
> mit Ausnahme der Changelog-Prosa (Schritt 4), einer berührten `docs/`-Seite, neuer
|
> except the changelog prose (step 4), any `docs/` page you touch, new human-facing
|
||||||
> Menschendoku oder des Prosa-Anteils einer Instruction. Wenn du auf Opus bist, ist jetzt der
|
> documentation, and the prose half of an instruction. If you are on Opus, now is the moment
|
||||||
> Moment für `/model sonnet` bei Effort `high`.
|
> for `/model sonnet` at effort `high`.
|
||||||
|
|
||||||
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
||||||
setting an agent applies. Offer it once and keep working either way; a session that argues
|
setting an agent applies. Offer it once and keep working either way; a session that argues
|
||||||
@@ -100,7 +101,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
|
|
||||||
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||||
than one file or a contract. Full table and reasoning:
|
than one file or a contract. Full table and reasoning:
|
||||||
[docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md).
|
`docs/model-and-effort-selection.md`.
|
||||||
|
|
||||||
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||||
@@ -113,7 +114,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
`--impact high|medium|low` (default `medium`) grades this bump in the changelog entry's own
|
`--impact high|medium|low` (default `medium`) grades this bump in the changelog entry's own
|
||||||
list - `tools/wikitool version regrade` corrects it later if the candidate's overall shape
|
list - `tools/wikitool version regrade` corrects it later if the candidate's overall shape
|
||||||
changes the read on an earlier one; see
|
changes the read on an earlier one; see
|
||||||
[instructions/dev/version-parts.md](../version-parts.md) § The candidate model.
|
`instructions/dev/version-parts.md` § The candidate model.
|
||||||
|
|
||||||
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
||||||
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
|
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
|
||||||
@@ -128,12 +129,12 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Content migration is one way to land in the last row, not the definition of it: a rename of
|
Content migration is one way to land in the last row, not the definition of it: a rename of
|
||||||
the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
|
the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
|
||||||
entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
|
entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
|
||||||
the user first are in [version-parts.md](../version-parts.md) - **read it before choosing
|
the user first are in `instructions/dev/version-parts.md` - **read it before choosing
|
||||||
`--major`.**
|
`--major`.**
|
||||||
|
|
||||||
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
|
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
|
||||||
is required on every boundary-crossing bump; on top of it, a migration document for the new
|
is required on every boundary-crossing bump; on top of it, a migration document for the new
|
||||||
version - written per [migrate-corpus.md](../../migrate-corpus.md) - or
|
version - written per `instructions/migrate-corpus.md` - or
|
||||||
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
|
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
|
||||||
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
|
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
|
||||||
telling it how, is a dead end.
|
telling it how, is a dead end.
|
||||||
@@ -145,8 +146,14 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||||
|
|
||||||
5. **Pull through every document that makes a claim about the surface you touched - `docs verify`
|
5. **Pull through every document that makes a claim about the surface you touched - `docs verify`
|
||||||
checks a cell's presence, never its prose.** [doc-pull-through.md](../doc-pull-through.md) has
|
checks a cell's presence, never its prose.** `instructions/dev/doc-pull-through.md` has
|
||||||
the table of which document that is, per surface.
|
the table of which document that is, per surface, and its step 3 for the one part of the
|
||||||
|
pull-through that is *not* prose: a reference file whose headings moved needs
|
||||||
|
`tools/wikitool docs toc --apply`, never a hand-written list.
|
||||||
|
|
||||||
|
Prose you write here is English, whatever language the session is being held in -
|
||||||
|
`AGENTS.md` § File naming has both language rules and the line between prose and quoted
|
||||||
|
vocabulary.
|
||||||
|
|
||||||
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
||||||
and the relevant `pytest` run in `tools/` - the same checks any stack change must pass, run
|
and the relevant `pytest` run in `tools/` - the same checks any stack change must pass, run
|
||||||
@@ -162,7 +169,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
|
|
||||||
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
||||||
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
||||||
in [`stack-close`](../stack-close/SKILL.md), not in a further step of this one. Invoke it now;
|
in `stack-close` (`instructions/dev/stack-close/SKILL.md`), not in a further step of this one. Invoke it now;
|
||||||
do not fold its work into this session under this skill's rules, and do not treat "the change
|
do not fold its work into this session under this skill's rules, and do not treat "the change
|
||||||
is published" as this work package being done.
|
is published" as this work package being done.
|
||||||
|
|
||||||
@@ -176,7 +183,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||||
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
`instructions/dev/version-parts.md` step 4 has the full shape. A surfacing boundary crossing
|
||||||
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||||
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||||
part was chosen correctly.
|
part was chosen correctly.
|
||||||
@@ -186,4 +193,4 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
||||||
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
||||||
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
||||||
its publish has landed - that is [`stack-close`](../stack-close/SKILL.md).
|
its publish has landed - that is `stack-close` (`instructions/dev/stack-close/SKILL.md`).
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ a new one, and only `version release` turns it into something the release workfl
|
|||||||
|
|
||||||
1. **Heading, author, breaking/migration lines** - written by `version bump`, anchored right
|
1. **Heading, author, breaking/migration lines** - written by `version bump`, anchored right
|
||||||
above the bump list so the line an operator most needs to act on never sits beneath a list
|
above the bump list so the line an operator most needs to act on never sits beneath a list
|
||||||
that can run long.
|
that can run long. The breaking line **accumulates** across a candidate's crossings - one
|
||||||
|
reason on the marker line, bullets under a bare marker from the second onward - because a
|
||||||
|
long-running candidate can break compatibility more than once and each break is its own
|
||||||
|
thing to act on. The migration line does not: it answers one yes/no about the candidate as
|
||||||
|
a whole, and `--migration-required` is its retraction path. Nothing retracts a breaking
|
||||||
|
reason; a wrong one is rare enough, and the candidate is dev-local until release.
|
||||||
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
|
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
|
||||||
`version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`.
|
`version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`.
|
||||||
Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the
|
Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the
|
||||||
|
|||||||
@@ -193,10 +193,17 @@ command you actually need to run, and only with the user's approval.
|
|||||||
|
|
||||||
A dozen commands are exempt from this budget entirely - `search` and `doctor` because retrieval
|
A dozen commands are exempt from this budget entirely - `search` and `doctor` because retrieval
|
||||||
and diagnosis are reading, not iterating, plus the read-only forms of `links`, `cite`, `budget`,
|
and diagnosis are reading, not iterating, plus the read-only forms of `links`, `cite`, `budget`,
|
||||||
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that fixed allowlist in
|
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that allowlist in
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md), not a "does not change the wiki" rule of thumb: `lint`
|
[tools/CONTRACT.md](../tools/CONTRACT.md), not a "does not change the wiki" rule of thumb: `lint`
|
||||||
only writes to gitignored `reports/` and still counts, because it is not on the list.
|
only writes to gitignored `reports/` and still counts, because it is not on the list.
|
||||||
|
|
||||||
|
**One entry is read-only only in one of its two forms.** `version regrade` lists the running
|
||||||
|
candidate's graded bump titles when called bare, and writes `CHANGES.md` when called with
|
||||||
|
positions to regrade - so the exemption is per *invocation* there, not per command name. It is
|
||||||
|
the only such case; every other row on the list is exempt however it is called. Its
|
||||||
|
`tools/CONTRACT.md` row says which form is which, which is still the single place that list
|
||||||
|
lives.
|
||||||
|
|
||||||
### Taking a new session id
|
### Taking a new session id
|
||||||
|
|
||||||
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
|
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
type: types/instruction.md
|
||||||
|
name: 6.0.0-type-guidance-split
|
||||||
|
description: "Add a guidance: field to an adopted root:kb type-spec so it starts receiving the stack's authoring-prose improvements again, without touching the type-spec's own frontmatter or template."
|
||||||
|
manual: true
|
||||||
|
migrates_to: 6.0.0
|
||||||
|
migration_kind: assisted
|
||||||
|
obligation: offered
|
||||||
|
---
|
||||||
|
# Link an adopted type-spec to its stack-owned guidance file (6.0.0)
|
||||||
|
|
||||||
|
Before 6.0.0, a `root: kb` type-spec (`entity`, `concept`, `source`, `comparison`, or one this
|
||||||
|
instance added itself) carried its generic authoring prose - when to use the type, when not to,
|
||||||
|
mechanism-level advice such as citation and provenance rules - in the same file as its frontmatter
|
||||||
|
configuration and its `## Template` block. Adopting the type-spec at setup meant adopting all of
|
||||||
|
it at once, and an upgrade never touched the adopted file again: the prose an instance received
|
||||||
|
was frozen at the day it ran `setup-instance.md`, while every later improvement shipped only in
|
||||||
|
the `.template` beside it (`docs/ownership-and-templates.md` § "Where the file boundary used to
|
||||||
|
strain").
|
||||||
|
|
||||||
|
6.0.0 splits that prose into a separate, stack-owned `types/<name>.guidance.md`, linked from the
|
||||||
|
type-spec via an optional `guidance:` frontmatter field. The new file ships verbatim and upgrades
|
||||||
|
like any other machinery file from here on - but only once a type-spec actually points at it.
|
||||||
|
Taking this offer is exactly that: adding one frontmatter line per adopted type-spec. It is
|
||||||
|
`assisted`, not `mechanical`, because whether this instance's own copy of the prose has diverged
|
||||||
|
from the shipped default is a judgment call a script cannot make.
|
||||||
|
|
||||||
|
This migration is **offered, not required**. A type-spec with no `guidance:` keeps working
|
||||||
|
exactly as it did before 6.0.0 - it is described from its own body alone. Declining costs nothing
|
||||||
|
except future improvements to the prose half; nothing about the machinery stops fitting.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [When to run](#when-to-run)
|
||||||
|
- [Steps](#steps)
|
||||||
|
- [How to tell a migrated type-spec from an unmigrated one](#how-to-tell-a-migrated-type-spec-from-an-unmigrated-one)
|
||||||
|
- [Decision points](#decision-points)
|
||||||
|
- [Scope](#scope)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
|
## When to run
|
||||||
|
|
||||||
|
Any time after installing 6.0.0 machinery over an instance that adopted at least one `root: kb`
|
||||||
|
type-spec before this migration existed. `tools/wikitool migrate status` lists it under "optional
|
||||||
|
upgrade(s) available"; taking it is not gated on anything else being current.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Confirm the new guidance files actually arrived.** `dist upgrade` writes `types/<name>.guidance.md`
|
||||||
|
as an ordinary new/unchanged file - it does not depend on this migration at all. If
|
||||||
|
`ls types/*.guidance.md` shows nothing, the machinery upgrade has not landed yet; run that
|
||||||
|
first.
|
||||||
|
|
||||||
|
2. **For each adopted `root: kb` type-spec, decide whether its authoring prose still matches the
|
||||||
|
shipped default.** Compare the type-spec's current prose (everything outside `## Frontmatter`
|
||||||
|
and `## Template`) against the corresponding `types/<name>.guidance.md`:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
it into a local copy of the guidance file this instance then owns for itself (any path is
|
||||||
|
valid for `guidance:`, not only the shipped one), or keep it in the type-spec's own body
|
||||||
|
instead of adding `guidance:` at all - both are legitimate; declining the stack default for
|
||||||
|
one type is not an error.
|
||||||
|
|
||||||
|
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/`
|
||||||
|
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.
|
||||||
|
|
||||||
|
5. **Delete the now-duplicated prose from the type-spec**, keeping the H1, a short pointer to the
|
||||||
|
guidance file, `## Frontmatter` and `## Template`. Where step 2 found a local edit worth
|
||||||
|
keeping and it lives in the type-spec's own body rather than a private guidance file, leave
|
||||||
|
that part exactly where it is.
|
||||||
|
|
||||||
|
**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
|
||||||
|
tools/wikitool types describe <name> > /tmp/<name>-after.txt
|
||||||
|
diff /tmp/<name>-before.txt /tmp/<name>-after.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The two must read the same - the guidance prose composed ahead of the type-spec's own body,
|
||||||
|
in one answer. Wording differences are expected only where step 2 found something to drop or
|
||||||
|
fold in; the structure (frontmatter fields, template block) must be byte-identical, and a
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
tools/wikitool migrate done 6.0.0 --pages 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`--pages 0` because no `kb/` page changes - this migration touches machinery under `types/`
|
||||||
|
only. This does **not** advance `kb_version`, per `obligation: offered` above; it only marks
|
||||||
|
the offer as taken so `migrate status` stops listing it.
|
||||||
|
|
||||||
|
## How to tell a migrated type-spec from an unmigrated one
|
||||||
|
|
||||||
|
`grep -L '^guidance:' types/*.md` (excluding `.guidance.md` files themselves, which never carry
|
||||||
|
the field) lists every `root: kb` type-spec that has not taken the offer yet.
|
||||||
|
|
||||||
|
## Decision points
|
||||||
|
|
||||||
|
- **A type this instance wrote entirely for itself?** No `types/<name>.guidance.md` exists for
|
||||||
|
it and none should be authored to match this migration artificially - `guidance:` is for
|
||||||
|
receiving a *stack* default, and a self-written type has none to receive. Leave it as it is.
|
||||||
|
- **Local prose worth keeping, but no interest in maintaining a private guidance file?** Skip
|
||||||
|
`guidance:` for that one type-spec. Nothing forces uniformity across an instance's own types.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
For `types/` machinery, not `kb/` content - the one migration document in this directory that
|
||||||
|
is. No page's frontmatter or body changes, `sources coverage`/`lint`/`kb_version` are all
|
||||||
|
unaffected, and `migrate done`'s `--pages` is `0` for exactly that reason.
|
||||||
@@ -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
|
||||||
@@ -57,11 +84,12 @@ refusal. See [gates.md](gates.md).
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
**The exemption is a fixed allowlist, not "read-only" or "does not change the wiki."** A command
|
**The exemption is an allowlist, not "read-only" or "does not change the wiki."** A command
|
||||||
needs this setup unless it is one of the dozen `tools/CONTRACT.md` marks exempt in its command
|
needs this setup unless it is one of the dozen `tools/CONTRACT.md` marks exempt in its command
|
||||||
table (`search`, `doctor`, `links show`, `cite id`, `budget status`, the read-only forms of
|
table (`search`, `doctor`, `links show`, `cite id`, `budget status`, the read-only forms of
|
||||||
`eval`, `version`, `migrate` and `upstream verify`) - that table, not a rule of thumb here, is
|
`eval`, `version`, `migrate` and `upstream verify`) - that table, not a rule of thumb here, is
|
||||||
the single list.
|
the single list. One entry on it, `version regrade`, is exempt only in its bare listing form and
|
||||||
|
counted when it is given positions to regrade; every other entry is exempt however it is called.
|
||||||
|
|
||||||
`lint` is the case that breaks the "changes the wiki" reading: it only writes to `reports/`,
|
`lint` is the case that breaks the "changes the wiki" reading: it only writes to `reports/`,
|
||||||
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
|
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
|
||||||
|
|||||||
+180
-180
@@ -1,83 +1,83 @@
|
|||||||
---
|
---
|
||||||
type: types/instruction.md
|
type: types/instruction.md
|
||||||
name: setup-instance
|
name: setup-instance
|
||||||
description: Eine frische Distribution (aus `dist export`) in eine funktionsfähige, eigenständige Wiki-Instanz verwandeln - Git-Repo, Identität/Autor, optionaler Remote, Bootstrap, erster Commit.
|
description: Turn a fresh distribution (from `dist export`) into a working, self-contained wiki instance - git repo, identity/author, optional remote, bootstrap, first commit.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Neue Wiki-Instanz einrichten
|
# Set up a new wiki instance
|
||||||
|
|
||||||
Diese Anweisung führt eine leere, per `tools/wikitool dist export <ziel>` erzeugte Distribution
|
This instruction takes an empty distribution produced by `tools/wikitool dist export <target>`
|
||||||
zu einer funktionsfähigen, eigenständigen Wiki-Instanz - mit eigenem Git-Repo, eigener Autor-
|
and turns it into a working, self-contained wiki instance - with its own git repo, its own
|
||||||
Identität und (optional) eigenem Remote. Am Ende ist die Instanz committet, verifiziert und
|
author identity and (optionally) its own remote. At the end the instance is committed, verified
|
||||||
bereit für den ersten `Ingest`.
|
and ready for its first ingest.
|
||||||
|
|
||||||
<!-- wikitool:toc -->
|
<!-- wikitool:toc -->
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Wann anwenden](#wann-anwenden)
|
- [When to run](#when-to-run)
|
||||||
- [Schritte](#schritte)
|
- [Steps](#steps)
|
||||||
- [Scope](#scope)
|
- [Scope](#scope)
|
||||||
<!-- /wikitool:toc -->
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
## Wann anwenden
|
## When to run
|
||||||
|
|
||||||
- Der Nutzer möchte eine neue, leere Wiki-Instanz aufsetzen (eigenes Thema, anderer Nutzer).
|
- The user wants to set up a new, empty wiki instance (their own subject, a different person).
|
||||||
- Nicht für einen bestehenden Clone dieses (Quell-)Repos - siehe [bootstrap.md](bootstrap.md).
|
- Not for an existing clone of this (source) repo - see [bootstrap.md](bootstrap.md).
|
||||||
- Es gibt keinen Weg zurück: `dist export` lässt `instructions/dev/` (die Stack-Entwicklung
|
- There is no way back: `dist export` deliberately and permanently leaves out
|
||||||
selbst, inkl. der vendorten `commonplace/`-Wissensbasis) bewusst und dauerhaft weg. Wer den
|
`instructions/dev/` (stack development itself, including the vendored `commonplace/` knowledge
|
||||||
entstehenden Instanz-Stack weiterentwickeln will, tut das im Ursprungs-Repo (oder einer neuen
|
base). Anyone who wants to develop the resulting instance's stack further does that in the
|
||||||
Dev-Instanz daraus) - nicht durch Nachrüsten in dieser Instanz.
|
origin repo (or a new dev instance made from it) - not by retrofitting it into this instance.
|
||||||
|
|
||||||
## Schritte
|
## Steps
|
||||||
|
|
||||||
1. **Distribution exportieren**, im Quell-Repo:
|
1. **Export the distribution**, in the source repo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool dist export <ziel>
|
tools/wikitool dist export <target>
|
||||||
```
|
```
|
||||||
|
|
||||||
`<ziel>` muss nicht existieren oder leer sein; der Befehl bricht sonst mit `ERROR` ab. Danach
|
`<target>` must not exist, or must be empty; otherwise the command aborts with `ERROR`. Work
|
||||||
für alle folgenden Schritte in `<ziel>` arbeiten.
|
inside `<target>` for every step that follows.
|
||||||
|
|
||||||
2. **Git-Repo initialisieren:**
|
2. **Initialize the git repo:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git init -b main
|
git init -b main
|
||||||
```
|
```
|
||||||
|
|
||||||
`-b main` ist Pflicht: `tools/wikitool publish` prüft beim tatsächlichen Push, ob der
|
`-b main` is mandatory: on the actual push, `tools/wikitool publish` checks that the
|
||||||
ausgecheckte Branch dem Ziel-Branch entspricht (Default `main`), und lehnt sonst ab, um
|
checked-out branch matches the target branch (default `main`) and refuses otherwise, so that
|
||||||
nicht den falschen Branch zu veröffentlichen.
|
the wrong branch is never published.
|
||||||
|
|
||||||
3. **Entscheidungspunkt - Identität.** Frage den Nutzer nach Namen und E-Mail-Adresse; rate sie
|
3. **Decision point - identity.** Ask the user for their name and email address; never guess
|
||||||
nie, und übernimm sie nie stillschweigend aus dem Quell-Repo (das ist eine andere Person, ein
|
them, and never quietly carry them over from the source repo (that is a different person and
|
||||||
anderes Projekt):
|
a different project):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git config user.name "<Name>"
|
git config user.name "<name>"
|
||||||
git config user.email "<E-Mail>"
|
git config user.email "<email>"
|
||||||
```
|
```
|
||||||
|
|
||||||
Das setzt zugleich den Autor jeder künftig angelegten Wiki-Seite: `tools/wikitool new`
|
This also sets the author of every wiki page created from now on: `tools/wikitool new`
|
||||||
löst `author:` über `$WIKI_AUTHOR` (Override) oder sonst `git config user.name` auf und
|
resolves `author:` from `$WIKI_AUTHOR` (an override) or else from `git config user.name`, and
|
||||||
bricht mit `ERROR` ab, wenn beides fehlt - es gibt keinen stillen Platzhalter.
|
aborts with `ERROR` when both are missing - there is no silent placeholder.
|
||||||
|
|
||||||
4. **Entscheidungspunkt - Remote.** Frage den Nutzer nach einer Remote-URL; ein rein lokales
|
4. **Decision point - remote.** Ask the user for a remote URL; a purely local repo is a valid
|
||||||
Repo ist ein gültiger Endzustand:
|
end state:
|
||||||
- Genannt: `git remote add origin <url>`
|
- Given: `git remote add origin <url>`
|
||||||
- Nicht genannt: lokal bleiben - dann braucht **jeder** spätere `tools/wikitool publish`
|
- Not given: stay local - then **every** later `tools/wikitool publish` needs a `--no-push`
|
||||||
ein `--no-push` (dessen Branch-Prüfung dabei ohnehin entfällt, siehe Schritt 2).
|
(which also drops its branch check, see step 2).
|
||||||
|
|
||||||
5. **Entscheidungspunkt - Autorenkonventionen.** Die Distribution bringt keine ausgefüllten
|
5. **Decision point - authoring conventions.** The distribution ships no filled-in conventions,
|
||||||
Konventionen mit, sondern `kb/CONVENTIONS.md.template` und je Collection ein
|
only `kb/CONVENTIONS.md.template` and one `kb/<name>/COLLECTION.md.template` per collection.
|
||||||
`kb/<name>/COLLECTION.md.template`. Beide **binden**, sobald sie übernommen sind, und beide
|
Both **bind** once adopted, and both belong to this instance - which is why the stack ships
|
||||||
gehören dieser Instanz - deshalb liefert der Stack nur die Vorlage. Die eine Entscheidung
|
the template alone. The one decision behind them is: **in which language and in what tone
|
||||||
dahinter ist: **in welcher Sprache und in welchem Ton schreibt diese Instanz ihre Seiten?**
|
does this instance write its pages?**
|
||||||
|
|
||||||
Ablauf:
|
Procedure:
|
||||||
|
|
||||||
1. Die Collection-Contracts **und die Page-Type-Specs** übernehmen - Kopien, keine Frage an
|
1. Adopt the collection contracts **and the page type-specs** - copies, no question to the
|
||||||
den Nutzer, denn was dort steht ist als Ausgangspunkt unabhängig von der Sprache brauchbar:
|
user, because what they say is usable as a starting point regardless of language:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for template in kb/*/COLLECTION.md.template types/*.template; do
|
for template in kb/*/COLLECTION.md.template types/*.template; do
|
||||||
@@ -85,110 +85,114 @@ bereit für den ersten `Ingest`.
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
Die `.template`-Dateien bleiben liegen; sie sind die Vorlage für den nächsten Export.
|
The `.template` files stay where they are; they are the source for the next export.
|
||||||
|
|
||||||
Unter `types/` betrifft das genau die Type-Specs mit `root: kb` - `entity`, `concept`,
|
Under `types/` this covers exactly the type-specs with `root: kb` - `entity`, `concept`,
|
||||||
`source`, `comparison` - samt ihrer `.schema.yaml`. Sie beschreiben Seiten, die *diese*
|
`source`, `comparison` - along with their `.schema.yaml`. They describe pages *this*
|
||||||
Instanz schreibt, also gehören sie ihr: Prosa, Template und Sprache dürfen umgeschrieben
|
instance writes, so they belong to it: frontmatter, template and language may all be
|
||||||
werden. `instruction`, `lint-report` und `type-spec` beschreiben Stack-Artefakte und
|
rewritten. `instruction`, `lint-report`, `type-spec` and `type-guidance` describe stack
|
||||||
kommen unverändert.
|
artifacts and arrive unchanged - the glob above never matches them because none of them
|
||||||
|
ships as a `.template` in the first place.
|
||||||
|
|
||||||
2. Den Nutzer nach der KB-Sprache fragen. `kb/CONVENTIONS.md.template` ist auf **Englisch**
|
A `root: kb` type-spec's generic authoring guidance (when to use the type, when not to)
|
||||||
voreingestellt; [kb-profiles.md](kb-profiles.md) hält daneben ein vollständiges
|
is not part of this adoption at all: it lives in a sibling `types/<name>.guidance.md`
|
||||||
deutsches Profil bereit, und dessen Volltext ist die `kb/CONVENTIONS.md` des Quell-Repos.
|
this instance never renames, the same as `instruction.md` - it ships verbatim and a later
|
||||||
Der Profilkatalog ist eine **Palette, kein Enum**: übernommen wird der Text *in* die
|
`dist upgrade` improves it directly, without the type-spec that links it needing to be
|
||||||
Instanzdatei, nicht ein Verweis auf den Katalog.
|
touched. `types/type-spec.md` § "Anatomy of a type" has the shape.
|
||||||
|
|
||||||
3. `kb/CONVENTIONS.md.template` nach `kb/CONVENTIONS.md` kopieren, entlang des gewählten
|
2. Ask the user for the KB language. `kb/CONVENTIONS.md.template` defaults to **English**;
|
||||||
Profils ausfüllen - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels,
|
[kb-profiles.md](kb-profiles.md) additionally holds a complete German profile, whose full
|
||||||
Hedging-Regel - und dabei die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen.
|
text is the source repo's own `kb/CONVENTIONS.md`. The profile catalogue is a **palette,
|
||||||
Die Platzhalter in geschweiften Klammern **sind** der Fragenkatalog.
|
not an enum**: what gets adopted is the text *into* the instance file, not a reference to
|
||||||
|
the catalogue.
|
||||||
|
|
||||||
4. Bei einer anderen Sprache als der des Quell-Repos: `german-terminology.md` löschen oder
|
3. Copy `kb/CONVENTIONS.md.template` to `kb/CONVENTIONS.md`, fill it in along the chosen
|
||||||
durch das eigene Vokabular ersetzen - sie ist Material des deutschen Profils, nicht des
|
profile - language, section names, naming forms, tone, relationship labels, hedging rule -
|
||||||
Stacks.
|
and remove the sentinel line (`wikitool:template-unfilled`) while doing so. The
|
||||||
|
placeholders in curly braces **are** the list of questions.
|
||||||
|
|
||||||
5. Den Nutzer nach dem Anwendungsgebiet fragen und daraus einen `source_type`-Vorschlag
|
4. For a language other than the source repo's: delete `german-terminology.md` or replace it
|
||||||
ableiten. [kb-profiles.md](kb-profiles.md) hält dafür zwei ausformulierte Domänenprofile
|
with your own vocabulary - it is material belonging to the German profile, not to the
|
||||||
als Anschauung bereit, neben dem Wert, den dieses Repo selbst nutzt. Der Vorschlag ist ein
|
stack.
|
||||||
**Startpunkt, keine Festlegung** - zum Setup-Zeitpunkt hat der Betreiber null Quellen und
|
|
||||||
rät seine Taxonomie, bevor er auch nur eine Datei gesehen hat, und das ist der
|
|
||||||
schlechteste Moment, ein Enum festzuzurren. Vorschlag umgesetzt heißt: das Enum in
|
|
||||||
`types/source.schema.yaml` **und** die passende `layout:`-Zeile je Wert in
|
|
||||||
`types/source.md` in derselben Bearbeitung setzen - eine ohne die andere lässt einen Wert
|
|
||||||
ohne Zielverzeichnis zurück. Der sichtbare Auffangwert (`unclassified`) bleibt in jedem
|
|
||||||
Vorschlag erhalten; er ist kein Sammelbecken, sondern das Fach für eine Quelle, deren
|
|
||||||
Kategorie noch nicht feststeht. Die Liste später erweitern oder das Fach leeren:
|
|
||||||
[evolve-subtypes.md](evolve-subtypes.md) - nicht Teil dieses Schritts, aber der Weg dahin,
|
|
||||||
sobald echtes Material vorliegt.
|
|
||||||
|
|
||||||
**Unverändert lassen:** `fidelity` und `authority` auf `source`-Seiten. Die sind
|
5. Ask the user about the subject area and derive a `source_type` proposal from it.
|
||||||
Stack-Vokabular, keine Instanzentscheidung - [kb-profiles.md](kb-profiles.md) sagt das im
|
[kb-profiles.md](kb-profiles.md) holds two worked domain profiles as illustration, beside
|
||||||
selben Abschnitt.
|
the value this repo uses itself. The proposal is a **starting point, not a commitment** -
|
||||||
|
at setup time the operator has zero sources and is guessing a taxonomy before having seen
|
||||||
|
a single file, which is the worst possible moment to pin an enum down. Carrying out the
|
||||||
|
proposal means setting the enum in `types/source.schema.yaml` **and** the matching
|
||||||
|
`layout:` line per value in `types/source.md` in the same edit - one without the other
|
||||||
|
leaves a value with no target directory. The visible catch-all (`unclassified`) survives
|
||||||
|
every proposal; it is not a dumping ground but the slot for a source whose category is not
|
||||||
|
settled yet. Extending the list later, or emptying that slot:
|
||||||
|
[evolve-subtypes.md](evolve-subtypes.md) - not part of this step, but the way there once
|
||||||
|
real material exists.
|
||||||
|
|
||||||
**Vor dem ersten Ingest entscheiden.** Die `sections:`-Namen in `kb/CONVENTIONS.md` sind die
|
**Leave unchanged:** `fidelity` and `authority` on `source` pages. Those are stack
|
||||||
Überschriften, die `xref` und `cite` in jede Seite schreiben; sie danach zu ändern ist eine
|
vocabulary, not an instance decision - [kb-profiles.md](kb-profiles.md) says so in the
|
||||||
Migration jeder vorhandenen Seite (`section_aliases:` trägt die alten Namen, siehe
|
same section.
|
||||||
|
|
||||||
|
**Decide before the first ingest.** The `sections:` names in `kb/CONVENTIONS.md` are the
|
||||||
|
headings `xref` and `cite` write into every page; changing them afterwards is a migration of
|
||||||
|
every existing page (`section_aliases:` carries the old names, see
|
||||||
[migrate-corpus.md](migrate-corpus.md)).
|
[migrate-corpus.md](migrate-corpus.md)).
|
||||||
|
|
||||||
**Nichts davon liegt in einer Stack-Datei.** Der Compiler liest die Abschnittsnamen aus
|
**None of this lives in a stack file.** The compiler reads the section names from
|
||||||
`kb/CONVENTIONS.md`; die vier Page-Type-Specs gehören ab Schritt 1 dieser Instanz. Eine
|
`kb/CONVENTIONS.md`; the four page type-specs have belonged to this instance since step 1. An
|
||||||
anderssprachige Instanz übersetzt sie einfach - das ist kein lokaler Patch an etwas
|
instance in another language simply translates them - that is no longer a local patch to
|
||||||
Ausgeliefertem mehr, sondern Arbeit an den eigenen Dateien, und ein Upgrade nimmt sie ihr
|
something shipped, but work on its own files, and an upgrade does not take it away again.
|
||||||
nicht wieder weg.
|
|
||||||
|
|
||||||
Was der Stack von `types/` überhaupt noch verlangt, ist eine Zeile: es muss einen Type-Spec
|
What the stack still requires of `types/` is one line: there must be a type-spec with
|
||||||
mit `name: source` geben, dessen Schema `raw_files` fordert. Daran hängt der gesamte
|
`name: source` whose schema requires `raw_files`. The entire `raw/`→`kb/` provenance path
|
||||||
`raw/`→`kb/`-Provenance-Pfad (`sources coverage`, `[^cite-id]`-Auflösung, `kb/provenance.md`),
|
hangs on it (`sources coverage`, `[^cite-id]` resolution, `kb/provenance.md`), and
|
||||||
und `docs verify` prüft genau das - nicht mehr.
|
`docs verify` checks exactly that - no more.
|
||||||
|
|
||||||
Unverändert bleibt in jedem Fall die Regel, die dem Stack gehört: **jede Zeile einer Seite
|
What stays untouched in every case is the rule the stack owns: **every line of a page is
|
||||||
ist Prosa oder Identifier, und nur Prosa wird übersetzt** ([kb/CONTRACT.md § Language and
|
either prose or an identifier, and only prose is translated** ([kb/CONTRACT.md § Language and
|
||||||
identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titel, Wikilink-Ziele, Cite-IDs,
|
identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titles, wikilink targets, cite ids,
|
||||||
Enum-Werte, Tags, Befehle und Pfade folgen keiner KB-Sprache.
|
enum values, tags, commands and paths follow no KB language.
|
||||||
|
|
||||||
`tools/wikitool doctor` prüft das Ergebnis in Schritt 13 (`conventions`): eine fehlende
|
`tools/wikitool doctor` checks the result in step 13 (`conventions`): a missing file is a
|
||||||
Datei ist ein `FAIL`, eine mit Sentinel oder ohne vollständigen `sections:`-Block ebenso.
|
`FAIL`, and so is one carrying the sentinel or lacking a complete `sections:` block.
|
||||||
`docs verify` prüft zusätzlich `profile:` und `required_by_stack:` auf jedem
|
`docs verify` additionally checks `profile:` and `required_by_stack:` on every
|
||||||
`COLLECTION.md`.
|
`COLLECTION.md`.
|
||||||
|
|
||||||
6. **Entscheidungspunkt - Personalization.** Die Distribution bringt
|
6. **Decision point - personalization.** The distribution ships `USER.md.template` and
|
||||||
`USER.md.template` und `SOUL.md.template` mit, aber keine ausgefüllten Fassungen: wer diese
|
`SOUL.md.template`, but no filled-in versions: who operates this instance and how it sounds
|
||||||
Instanz bedient und wie sie klingt, ist Eigentum genau dieser Instanz und wird nie aus dem
|
is the property of this instance alone and is never carried over from the source repo. Both
|
||||||
Quell-Repo übernommen. Beide Dateien werden ab jetzt in **jeder** Session gelesen, also
|
files are read in **every** session from now on, so they come into being here - not later,
|
||||||
entstehen sie hier - nicht später bei Gelegenheit.
|
when the occasion arises.
|
||||||
|
|
||||||
Ablauf, für `USER.md` und `SOUL.md` je einmal:
|
Procedure, once each for `USER.md` and `SOUL.md`:
|
||||||
|
|
||||||
1. Das Template lesen. Seine Abschnitte **sind** der Fragenkatalog, in der Reihenfolge, in
|
1. Read the template. Its sections **are** the list of questions, in the order they appear.
|
||||||
der sie dort stehen.
|
2. Interview the user along those sections - `USER.md`: name, location, time zone, primary
|
||||||
2. Den Nutzer entlang dieser Abschnitte befragen - `USER.md`: Name, Standort, Zeitzone,
|
role (professional only), professional context, family/home, hobbies, technical
|
||||||
primäre Rolle (rein beruflich), beruflicher Kontext, Familie/Zuhause, Hobbys,
|
environment, active projects, deliberate boundaries. `SOUL.md`: persona name, identity,
|
||||||
Technik-Umgebung, aktive Projekte, bewusste Grenzen. `SOUL.md`: Persona-Name, Identität,
|
mission, worldview, judgment default, standard, honesty, voice, exclusions.
|
||||||
Mission, Weltbild, Judgment-Default, Standard, Ehrlichkeit, Stimme, Ausschlüsse.
|
3. Take the answers **verbatim**. Do not interpret, do not compress into a narrative, do not
|
||||||
3. Die Antworten **wörtlich** übernehmen. Nicht deuten, nicht zu einer Erzählung
|
infer from the course of the conversation. What the user does not say does not go in:
|
||||||
verdichten, nicht aus dem Gesprächsverlauf ableiten. Was der Nutzer nicht sagt, steht
|
better to delete a section than to fill it with something plausible.
|
||||||
nicht drin: einen Abschnitt lieber löschen als mit Plausiblem füllen.
|
4. Write the result as `USER.md` and `SOUL.md` respectively, removing the sentinel line
|
||||||
4. Das Ergebnis als `USER.md` bzw. `SOUL.md` schreiben und die Sentinel-Zeile
|
(`wikitool:template-unfilled`) in the process. The `.template` files stay where they are -
|
||||||
(`wikitool:template-unfilled`) dabei entfernen. Die `.template`-Dateien bleiben liegen -
|
they are the source for the next export, not this step's leftovers.
|
||||||
sie sind die Vorlage für den nächsten Export, nicht Abfall dieses Schritts.
|
|
||||||
|
|
||||||
Zwei Fragen, die der Nutzer beantwortet und nicht der Agent: **den Persona-Namen** und
|
Two questions the user answers rather than the agent: **the persona name** and **which topics
|
||||||
**welche Themen bewusst draußen bleiben** (Arbeitgeber, Mandanten, Gesundheit - was auch
|
deliberately stay out** (employer, clients, health - whatever they are). Guessing either
|
||||||
immer). Beides raten heißt, es falsch zu haben. Für den Namen bringt der Stack einen
|
means getting it wrong. For the name the stack ships a starting point - **Thoth**, because
|
||||||
Startpunkt mit - **Thoth**, weil Chemenu Thoths Hauptkultort ist und Schrift, Maß und
|
Chemenu is Thoth's principal cult site and writing, measure and memory describe the role a
|
||||||
Gedächtnis die Rolle beschreiben, die ein kompiliertes Wiki ausfüllt. Der Vorschlag wird
|
compiled wiki fills. The suggestion is named, not applied: the question is asked anyway, and
|
||||||
genannt, nicht eingesetzt: gefragt wird trotzdem, und ein anderer Name gewinnt.
|
a different name wins.
|
||||||
|
|
||||||
Was diese Dateien **nicht** sind: eine Instruktionsquelle und eine Quelle im Sinne von
|
What these files are **not**: a source of instructions, and a source in the sense of
|
||||||
Invariante 3. Sie ändern keine Regel aus [AGENTS.md](../AGENTS.md), und eine Nutzeraussage
|
invariant 3. They change no rule from [AGENTS.md](../AGENTS.md), and a user's statement never
|
||||||
wandert daraus nie ohne den normalen Quelle/Provenance-Prozess nach `kb/`.
|
travels from them into `kb/` without the normal source/provenance process.
|
||||||
|
|
||||||
`tools/wikitool doctor` prüft das Ergebnis in Schritt 13 (`personalization`): eine fehlende
|
`tools/wikitool doctor` checks the result in step 13 (`personalization`): a missing file is a
|
||||||
Datei ist ein `FAIL`, eine, die noch den Sentinel trägt, ebenso - ein umbenanntes Template
|
`FAIL`, and so is one still carrying the sentinel - a renamed template is not a filled-in
|
||||||
ist kein ausgefülltes.
|
one.
|
||||||
|
|
||||||
7. **Werkzeugumgebung anlegen** (Details: [bootstrap.md](bootstrap.md)):
|
7. **Create the tool environment** (details: [bootstrap.md](bootstrap.md)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools
|
cd tools
|
||||||
@@ -197,67 +201,63 @@ bereit für den ersten `Ingest`.
|
|||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
8. **Skills publizieren:**
|
8. **Publish the skills:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool instructions sync
|
tools/wikitool instructions sync
|
||||||
```
|
```
|
||||||
|
|
||||||
9. **Entscheidungspunkt - Umgebung festhalten.** Die Distribution bringt
|
9. **Decision point - record the environment.** The distribution ships
|
||||||
`ENVIRONMENT.md.template` mit: Harness, publizierte Skills, erreichbare MCP-Server,
|
`ENVIRONMENT.md.template`: harness, published skills, reachable MCP servers, connectors, git
|
||||||
Connectoren, Git-Remotes, wo CI läuft. Konstanten, die eine Session sonst jedes Mal neu
|
remotes, where CI runs. Constants a session would otherwise ask about every time.
|
||||||
erfragt.
|
|
||||||
|
|
||||||
Anders als Schritt 6 ist dieser Schritt **optional** und kein Interview. Was aus dem
|
Unlike step 6, this step is **optional** and not an interview. Whatever can be read off the
|
||||||
Checkout selbst ablesbar ist (`git remote -v`, das laufende Harness, die eben publizierten
|
checkout itself (`git remote -v`, the running harness, the skills just published) the agent
|
||||||
Skills), trägt der Agent ein; nach dem Rest fragt er einmal und akzeptiert "weiß ich nicht"
|
fills in; for the rest it asks once and accepts "I don't know" as an answer - an empty
|
||||||
als Antwort - ein leerer Abschnitt wird gelöscht, nicht mit Plausiblem gefüllt. Beim
|
section is deleted, not filled with something plausible. Remove the sentinel line
|
||||||
Schreiben die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen; das `.template`
|
(`wikitool:template-unfilled`) when writing; the `.template` stays where it is.
|
||||||
bleibt liegen.
|
|
||||||
|
|
||||||
Wird der Schritt übersprungen, läuft alles weiter: `doctor` meldet in Schritt 13
|
If the step is skipped, everything still works: `doctor` reports
|
||||||
`environment: absent (optional)`, kein `FAIL`. Die Datei ist gitignored und geht in keinen
|
`environment: absent (optional)` in step 13, not a `FAIL`. The file is gitignored and enters
|
||||||
Commit ein - sie beschreibt diesen Checkout, nicht das Repo.
|
no commit - it describes this checkout, not the repo.
|
||||||
|
|
||||||
10. **Entscheidungspunkt - Telemetrie.** Der Default hängt am Installationsweg, nicht an
|
10. **Decision point - telemetry.** The default follows the installation path, not this step: an
|
||||||
diesem Schritt: eine per `dist export` ausgelieferte Instanz - jede, die hier ankommt, ohne
|
instance delivered via `dist export` - every instance that arrives here without having taken
|
||||||
Weg C (direkter Klon des Ursprungs-Repos) genommen zu haben - trägt eine
|
route C (a direct clone of the origin repo) - carries a `.wikitool-release.json` and starts
|
||||||
`.wikitool-release.json` und startet mit Telemetrie **aus**; niemand hat sie bestellt, und
|
with telemetry **off**; nobody asked for it, and nobody reads `EVALS.md` before the first
|
||||||
`EVALS.md` liest ohnehin niemand, bevor die erste Datei geschrieben ist. Dieser Schritt
|
file is written anyway. This step only asks whether the operator wants to reverse that.
|
||||||
fragt nur, ob der Betreiber das umdrehen will.
|
|
||||||
|
|
||||||
Den Nutzer einmal fragen: Telemetrie an? Falls ja, `.wikitool-telemetry.json` im
|
Ask the user once: telemetry on? If yes, create `.wikitool-telemetry.json` in the repo root
|
||||||
Repo-Root anlegen (pro Checkout, gitignored, kein `.template` - wie
|
(per checkout, gitignored, no `.template` - like `.wikitool-remotes.json`):
|
||||||
`.wikitool-remotes.json`):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "enabled": true }
|
{ "enabled": true }
|
||||||
```
|
```
|
||||||
|
|
||||||
`max_session_bytes` (Default 5 MiB) und `keep_sessions` (Default 250) sind optional in
|
`max_session_bytes` (default 5 MiB) and `keep_sessions` (default 250) are optional in the
|
||||||
derselben Datei; die meisten Instanzen brauchen sie nicht anzufassen. Falls nein, nichts
|
same file; most instances need not touch them. If no, do nothing - the default is already
|
||||||
tun - der Default steht bereits auf aus, und keine Datei entsteht. `WIKI_TRACE`
|
off, and no file is created. `WIKI_TRACE` still overrides in both directions, should a
|
||||||
überschreibt beide Richtungen weiterhin, falls eine einzelne Session abweichen soll.
|
single session need to differ.
|
||||||
|
|
||||||
`tools/wikitool doctor` meldet das Ergebnis in Schritt 13 (`telemetry`): an/aus, warum
|
`tools/wikitool doctor` reports the result in step 13 (`telemetry`): on/off, why
|
||||||
(Installationsform, diese Datei, oder `WIKI_TRACE`), und die aktuelle Menge gegen beide
|
(installation form, this file, or `WIKI_TRACE`), and the current volume against both caps -
|
||||||
Deckel - nie ein `FAIL`, in beide Richtungen ist das ein gültiger Zustand. Mehr dazu:
|
never a `FAIL`, since both directions are a valid state. More on this:
|
||||||
[EVALS.md](../EVALS.md) § "Whether it runs at all".
|
[EVALS.md](../EVALS.md) § "Whether it runs at all".
|
||||||
|
|
||||||
11. **Session-Budget scopen** (Details: [session-setup.md](session-setup.md)):
|
11. **Scope the session budget** (details: [session-setup.md](session-setup.md)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export WIKITOOL_SESSION_ID="wiki-$(date +%s)"
|
export WIKITOOL_SESSION_ID="wiki-$(date +%s)"
|
||||||
```
|
```
|
||||||
|
|
||||||
12. **Generierte Indizes erzeugen** - `dist export` liefert sie bewusst nicht mit:
|
12. **Build the generated indexes** - `dist export` deliberately does not ship them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool index rebuild
|
tools/wikitool index rebuild
|
||||||
tools/wikitool sources rebuild-index
|
tools/wikitool sources rebuild-index
|
||||||
```
|
```
|
||||||
|
|
||||||
13. **Verifizieren**, in dieser Reihenfolge:
|
13. **Verify**, in this order:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool doctor
|
tools/wikitool doctor
|
||||||
@@ -266,32 +266,32 @@ bereit für den ersten `Ingest`.
|
|||||||
tools/wikitool lint
|
tools/wikitool lint
|
||||||
```
|
```
|
||||||
|
|
||||||
`doctor` muss ohne `FAIL` durchlaufen, bevor es weitergeht - ein `WARN` (z. B. kein Remote,
|
`doctor` must run through without a `FAIL` before anything continues - a `WARN` (no remote,
|
||||||
keine `WIKITOOL_SESSION_ID`) ist kein Blocker. Ein `FAIL` benennt sein eigenes Fix-Kommando;
|
no `WIKITOOL_SESSION_ID`, say) is not a blocker. A `FAIL` names its own fix command; run it
|
||||||
das ausführen und `doctor` erneut aufrufen.
|
and call `doctor` again.
|
||||||
|
|
||||||
14. **Ersten Commit anstoßen:**
|
14. **Make the first commit:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool publish --message "chore: initial instance setup"
|
tools/wikitool publish --message "chore: initial instance setup"
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Mass-Update-Gate greift hier erwartungsgemäß: eine frische Distribution besteht aus weit
|
The Mass-Update Gate fires here as expected: a fresh distribution consists of far more than
|
||||||
mehr als den zehn gezählten Dateien, die den Schwellwert auslösen, also endet der Aufruf mit
|
the ten counted files that trip the threshold, so the call ends with exit code 42. Show the
|
||||||
Exit-Code 42. Die Ausgabe dem Nutzer **vollständig zeigen** und warten; sie enthält die
|
output to the user **in full** and wait; it contains the file list and the exact
|
||||||
Dateiliste und die exakte `--confirm <token>`-Zeile, die nach seiner Freigabe
|
`--confirm <token>` line that publishes once they approve. Details on the gate:
|
||||||
veröffentlicht. Details zum Gate: [gates.md](gates.md).
|
[gates.md](gates.md).
|
||||||
|
|
||||||
15. **Agent-Session neu starten.** Harnesses lesen die Skill-Verzeichnisse beim Start; erst
|
15. **Restart the agent session.** Harnesses read the skill directories at startup; only
|
||||||
danach sind `wiki-ingest`, `wiki-query`, `wiki-manage`, `wiki-lint` und `wiki-status`
|
afterwards are `wiki-ingest`, `wiki-query`, `wiki-manage`, `wiki-lint` and `wiki-status`
|
||||||
verfügbar.
|
available.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Gilt nur für eine per `dist export` erzeugte, leere Distribution. Für einen bestehenden Clone
|
Applies only to an empty distribution produced by `dist export`. For an existing clone of this
|
||||||
dieses Quell-Repos siehe [bootstrap.md](bootstrap.md) - dort existieren Git-Repo, Autor und
|
source repo see [bootstrap.md](bootstrap.md) - there the git repo, author and content already
|
||||||
Inhalt bereits, und nur die Werkzeugumgebung (Schritt 7) plus die Skills (Schritt 8) fehlen.
|
exist, and only the tool environment (step 7) plus the skills (step 8) are missing.
|
||||||
|
|
||||||
Eine Ausnahme: Schritt 6 (Personalization) gilt auch für einen bestehenden Clone, der noch
|
One exception: step 6 (personalization) also applies to an existing clone that has no
|
||||||
kein `USER.md`/`SOUL.md` hat - dort als einzelner nachgeholter Schritt, nicht als ganzer
|
`USER.md`/`SOUL.md` yet - there as a single catch-up step, not as a whole procedure.
|
||||||
Ablauf. `bootstrap.md` verweist dafür hierher.
|
`bootstrap.md` points here for it.
|
||||||
|
|||||||
@@ -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".
|
||||||
@@ -10,7 +10,7 @@ description: Process a new source file into the LLM wiki - extract entities and
|
|||||||
**Trigger:** User drops a file into `incoming/` (the normal path - see step 1) or directly into
|
**Trigger:** User drops a file into `incoming/` (the normal path - see step 1) or directly into
|
||||||
`raw/`, or explicitly requests ingestion.
|
`raw/`, or explicitly requests ingestion.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
Contracts are read **when the step needs them**, not upfront: a source that produces no concept
|
Contracts are read **when the step needs them**, not upfront: a source that produces no concept
|
||||||
pages should never have cost the concept contract. Field-level requirements always come from
|
pages should never have cost the concept contract. Field-level requirements always come from
|
||||||
@@ -40,7 +40,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Promote from `incoming/` if that is where the file sits.** Read
|
1. **Promote from `incoming/` if that is where the file sits.** Read
|
||||||
[raw/CONTRACT.md](../../raw/CONTRACT.md) "Getting a file in" and "Capture fields" if you have
|
`raw/CONTRACT.md` "Getting a file in" and "Capture fields" if you have
|
||||||
not this session - the directory and any bundling are computed, never chosen by hand, but the
|
not this session - the directory and any bundling are computed, never chosen by hand, but the
|
||||||
two capture flags are not: `raw accept` refuses without them.
|
two capture flags are not: `raw accept` refuses without them.
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
**A file that arrived through the MCP `submit` tool is not yet in `incoming/`** - it sits in
|
**A file that arrived through the MCP `submit` tool is not yet in `incoming/`** - it sits in
|
||||||
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
|
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
|
||||||
with `wikitool upload accept <id> --confirm <token>`, per
|
with `wikitool upload accept <id> --confirm <token>`, per
|
||||||
[instructions/ingest-queue.md](../ingest-queue.md); once accepted it is an ordinary file in
|
`instructions/ingest-queue.md`; once accepted it is an ordinary file in
|
||||||
`incoming/` and this step applies to it exactly as to anything dropped there by hand.
|
`incoming/` and this step applies to it exactly as to anything dropped there by hand.
|
||||||
|
|
||||||
**If this refuses because the name is already claimed** (a file stem or a bundle directory
|
**If this refuses because the name is already claimed** (a file stem or a bundle directory
|
||||||
@@ -79,7 +79,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
|
|
||||||
**Check the size first, on both axes.** *Volume* - how many raw files this ingest covers -
|
**Check the size first, on both axes.** *Volume* - how many raw files this ingest covers -
|
||||||
and *breadth* - how many entities and concepts this one source would produce or update.
|
and *breadth* - how many entities and concepts this one source would produce or update.
|
||||||
Either one past the thresholds in [ingest-large-tree.md](../ingest-large-tree.md) § When to
|
Either one past the thresholds in `instructions/ingest-large-tree.md` § When to
|
||||||
run is that procedure, not this one: stop and follow it. There, volume is cut into units;
|
run is that procedure, not this one: stop and follow it. There, volume is cut into units;
|
||||||
breadth cannot be cut at all (`raw/` keeps a file whole, and one raw file has one owning
|
breadth cannot be cut at all (`raw/` keeps a file whole, and one raw file has one owning
|
||||||
source page) and buys an extract pass instead, before any page is written. Skipping either
|
source page) and buys an extract pass instead, before any page is written. Skipping either
|
||||||
@@ -107,7 +107,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
which entities/concepts to create or update, any specific emphasis.
|
which entities/concepts to create or update, any specific emphasis.
|
||||||
|
|
||||||
6. **Create the source page.** Read
|
6. **Create the source page.** Read
|
||||||
[kb/sources/COLLECTION.md](../../kb/sources/COLLECTION.md) first - it holds what this
|
`kb/sources/COLLECTION.md` first - it holds what this
|
||||||
instance expects of a source page's sections and how it names one.
|
instance expects of a source page's sections and how it names one.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -140,18 +140,18 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
||||||
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 5 - in the
|
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 5 - in the
|
||||||
KB language, whatever the source's own language is, quoting verbatim passages in the
|
KB language, whatever the source's own language is, quoting verbatim passages in the
|
||||||
original. Which language that is: [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#language).
|
original. Which language that is: `kb/CONVENTIONS.md` § Language.
|
||||||
What is exempt from it, in any language:
|
What is exempt from it, in any language:
|
||||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers).
|
`kb/CONTRACT.md` § Language and identifiers.
|
||||||
|
|
||||||
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
||||||
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
||||||
same source gets re-litigated on the next pass.
|
same source gets re-litigated on the next pass.
|
||||||
|
|
||||||
7. **Create or update entity pages.** Read
|
7. **Create or update entity pages.** Read
|
||||||
[kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and
|
`kb/entities/COLLECTION.md` and
|
||||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) plus
|
`kb/CONTRACT.md` plus
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and
|
`kb/CONVENTIONS.md` first - the second is where provenance and
|
||||||
citation are defined, the third where this instance's tone and naming forms are.
|
citation are defined, the third where this instance's tone and naming forms are.
|
||||||
|
|
||||||
**A subject earns a page when the source carries material for one.** A name the source
|
**A subject earns a page when the source carries material for one.** A name the source
|
||||||
@@ -186,7 +186,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
|
|
||||||
8. **Create or update concept pages** - only if the source produced any. Same pattern, including
|
8. **Create or update concept pages** - only if the source produced any. Same pattern, including
|
||||||
step 7's rule about which subjects earn a page at all, reading
|
step 7's rule about which subjects earn a page at all, reading
|
||||||
[kb/concepts/COLLECTION.md](../../kb/concepts/COLLECTION.md) first:
|
`kb/concepts/COLLECTION.md` first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool new concept --name "<Name>" \
|
tools/wikitool new concept --name "<Name>" \
|
||||||
@@ -211,7 +211,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
The new raw file(s) must no longer be listed as uncovered, and no `raw_files:` entry may be
|
The new raw file(s) must no longer be listed as uncovered, and no `raw_files:` entry may be
|
||||||
broken.
|
broken.
|
||||||
|
|
||||||
11. **Close out.** Follow [publish-cycle.md](../publish-cycle.md) with `--op ingest` and a
|
11. **Close out.** Follow `instructions/publish-cycle.md` with `--op ingest` and a
|
||||||
message of the form `ingest: <raw path>`.
|
message of the form `ingest: <raw path>`.
|
||||||
|
|
||||||
12. **Check the lint cadence.**
|
12. **Check the lint cadence.**
|
||||||
@@ -230,16 +230,16 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
Two pages on one subject is the failure this step exists to prevent.
|
Two pages on one subject is the failure this step exists to prevent.
|
||||||
- **One source names far more subjects than usual?** That is breadth, not volume. It is not
|
- **One source names far more subjects than usual?** That is breadth, not volume. It is not
|
||||||
split into several sources - it cannot be - and it does not get a page per name either:
|
split into several sources - it cannot be - and it does not get a page per name either:
|
||||||
[ingest-large-tree.md](../ingest-large-tree.md) § A broad source is not cut.
|
`instructions/ingest-large-tree.md` § A broad source is not cut.
|
||||||
- **No raw file backs a claim you want to write?** Leave it out, or mark the page
|
- **No raw file backs a claim you want to write?** Leave it out, or mark the page
|
||||||
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
|
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
|
||||||
- **`publish` exited 42?** A single ingest is normally well under the Mass-Update Gate
|
- **`publish` exited 42?** A single ingest is normally well under the Mass-Update Gate
|
||||||
threshold. If it trips - a source touching many entities - show the user the output and stop;
|
threshold. If it trips - a source touching many entities - show the user the output and stop;
|
||||||
see [gates.md](../gates.md).
|
see `instructions/gates.md`.
|
||||||
- **A gate or the loop-breaker refuses anything?** Stop and follow [gates.md](../gates.md).
|
- **A gate or the loop-breaker refuses anything?** Stop and follow `instructions/gates.md`.
|
||||||
A multi-tool ingest should land in roughly 20-35 `wikitool` calls; needing far more is a sign
|
A multi-tool ingest should land in roughly 20-35 `wikitool` calls; needing far more is a sign
|
||||||
the source should be split into several ingests - which is
|
the source should be split into several ingests - which is
|
||||||
[ingest-large-tree.md](../ingest-large-tree.md), not a bigger budget.
|
`instructions/ingest-large-tree.md`, not a bigger budget.
|
||||||
|
|
||||||
## wikitool commands used
|
## wikitool commands used
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ description: Health-check the LLM wiki - broken links, orphan pages, uncovered r
|
|||||||
threshold reached - `wiki-ingest`'s last step checks it after every publish, so the count is
|
threshold reached - `wiki-ingest`'s last step checks it after every publish, so the count is
|
||||||
never something an agent has to remember.
|
never something an agent has to remember.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
## Run checklist
|
## Run checklist
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
||||||
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
||||||
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
||||||
clears the reference in *both* directions (see [tools/CONTRACT.md](../../tools/CONTRACT.md)),
|
clears the reference in *both* directions (see `tools/CONTRACT.md`),
|
||||||
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
||||||
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
||||||
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
||||||
@@ -90,7 +90,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
7. **Repair what is mechanical.** A dangling frontmatter reference is either a page that should
|
7. **Repair what is mechanical.** A dangling frontmatter reference is either a page that should
|
||||||
exist (`tools/wikitool new ...`) or a reference that should not
|
exist (`tools/wikitool new ...`) or a reference that should not
|
||||||
(`tools/wikitool xref remove --a "<Page>" --b "<Missing>"`). A title that changed is
|
(`tools/wikitool xref remove --a "<Page>" --b "<Missing>"`). A title that changed is
|
||||||
`tools/wikitool rename` - see [page-lifecycle.md](../page-lifecycle.md). Never hand-edit a
|
`tools/wikitool rename` - see `instructions/page-lifecycle.md`. Never hand-edit a
|
||||||
frontmatter array to clear one.
|
frontmatter array to clear one.
|
||||||
|
|
||||||
8. **Verify the stack.**
|
8. **Verify the stack.**
|
||||||
@@ -129,7 +129,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
|
|
||||||
- **Publish?** Lint does not auto-publish. Run `tools/wikitool publish` only if asked.
|
- **Publish?** Lint does not auto-publish. Run `tools/wikitool publish` only if asked.
|
||||||
- **Bulk fixes touched 10+ files?** Expected for a lint pass: `publish` exits 42. Show the
|
- **Bulk fixes touched 10+ files?** Expected for a lint pass: `publish` exits 42. Show the
|
||||||
user its output and stop; see [gates.md](../gates.md). Consider `--path` batches instead.
|
user its output and stop; see `instructions/gates.md`. Consider `--path` batches instead.
|
||||||
- **The gate or loop-breaker keeps tripping?** That is a signal to stop and re-plan with the
|
- **The gate or loop-breaker keeps tripping?** That is a signal to stop and re-plan with the
|
||||||
user, not to pass `--override-budget`. A full pass should land in roughly 20-35 calls.
|
user, not to pass `--override-budget`. A full pass should land in roughly 20-35 calls.
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
`publish` (only if asked)
|
`publish` (only if asked)
|
||||||
|
|
||||||
**Deliberately absent:** `rm` - a lint pass never deletes a page, and
|
**Deliberately absent:** `rm` - a lint pass never deletes a page, and
|
||||||
[page-lifecycle.md](../page-lifecycle.md) is where a deletion belongs. `log status` - it decides
|
`instructions/page-lifecycle.md` is where a deletion belongs. `log status` - it decides
|
||||||
this skill's *trigger*, but `wiki-ingest`'s last step is what runs it.
|
this skill's *trigger*, but `wiki-ingest`'s last step is what runs it.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ catalog and the audit log in sync.
|
|||||||
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
|
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
|
||||||
integrating into an existing one.
|
integrating into an existing one.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - linking and provenance,
|
**Read before drafting:** `kb/CONTRACT.md` - linking and provenance,
|
||||||
both of which the tool enforces - and
|
both of which the tool enforces - and
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md), which is where this instance's language, naming
|
`kb/CONVENTIONS.md`, which is where this instance's language, naming
|
||||||
forms, tone and relationship labels are, together with the target collection's own
|
forms, tone and relationship labels are, together with the target collection's own
|
||||||
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
||||||
requirements come from `tools/wikitool types describe <type>`.
|
requirements come from `tools/wikitool types describe <type>`.
|
||||||
@@ -48,7 +48,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
subjects - so the prose connects to existing pages instead of restating them.
|
subjects - so the prose connects to existing pages instead of restating them.
|
||||||
|
|
||||||
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#tone). If `provenance:` is `sourced` or `mixed`, cite
|
`kb/CONVENTIONS.md` § Tone. If `provenance:` is `sourced` or `mixed`, cite
|
||||||
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
||||||
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
|
|
||||||
One per relationship. Never hand-edit `related:`.
|
One per relationship. Never hand-edit `related:`.
|
||||||
|
|
||||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op create`.
|
7. **Close out.** `instructions/publish-cycle.md`, `--op create`.
|
||||||
|
|
||||||
## Updating a page
|
## Updating a page
|
||||||
|
|
||||||
@@ -84,11 +84,11 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
|
|
||||||
Never hand-edit `modified:`, `summary:` or `provenance:`.
|
Never hand-edit `modified:`, `summary:` or `provenance:`.
|
||||||
|
|
||||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op update`.
|
7. **Close out.** `instructions/publish-cycle.md`, `--op update`.
|
||||||
|
|
||||||
## Renaming, deleting, or unlinking
|
## Renaming, deleting, or unlinking
|
||||||
|
|
||||||
That is [page-lifecycle.md](../page-lifecycle.md). A title is the wiki's only identifier for a
|
That is `instructions/page-lifecycle.md`. A title is the wiki's only identifier for a
|
||||||
page, so none of it is a file operation.
|
page, so none of it is a file operation.
|
||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
@@ -98,7 +98,7 @@ page, so none of it is a file operation.
|
|||||||
- **Entity or concept?** A thing you can point at is an entity; a *why* or *how* is a concept.
|
- **Entity or concept?** A thing you can point at is an entity; a *why* or *how* is a concept.
|
||||||
The collection contracts draw the line.
|
The collection contracts draw the line.
|
||||||
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
|
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
|
||||||
[gates.md](../gates.md).
|
`instructions/gates.md`.
|
||||||
|
|
||||||
## wikitool commands used
|
## wikitool commands used
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ page, so none of it is a file operation.
|
|||||||
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
|
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
|
||||||
|
|
||||||
`xref remove` belongs to the unlinking case, which this skill delegates whole to
|
`xref remove` belongs to the unlinking case, which this skill delegates whole to
|
||||||
[page-lifecycle.md](../page-lifecycle.md) rather than describing in a step of its own.
|
`instructions/page-lifecycle.md` rather than describing in a step of its own.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: Answer a question using the LLM wiki's compiled knowledge - read-on
|
|||||||
|
|
||||||
**Trigger:** User asks a question.
|
**Trigger:** User asks a question.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
**Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a
|
**Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a
|
||||||
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
|
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
|
||||||
@@ -34,19 +34,22 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
|||||||
```bash
|
```bash
|
||||||
tools/wikitool search "backup" --kind entity --subtype system
|
tools/wikitool search "backup" --kind entity --subtype system
|
||||||
tools/wikitool search --field entity_type=system --field '!sources' --sort -modified
|
tools/wikitool search --field entity_type=system --field '!sources' --sort -modified
|
||||||
tools/wikitool search --field tags=k8s --limit 30
|
tools/wikitool search --field tags=k8s --limit 0 # a sweep: every match, not the first 50
|
||||||
tools/wikitool search "Longhorn" --matches # show the matching lines
|
tools/wikitool search "Longhorn" --matches # show the matching lines
|
||||||
```
|
```
|
||||||
|
|
||||||
`search` is read-only and exempt from the iteration budget, so searching again is always
|
`search` is read-only and exempt from the iteration budget, so searching again is always
|
||||||
cheaper than reading more.
|
cheaper than reading more. A result that hit `--limit` says so and names the total, so read
|
||||||
|
the last line before treating a list as the whole answer - and do not grep `kb/` yourself,
|
||||||
|
per AGENTS.md § Routing.
|
||||||
|
|
||||||
3. **Read only the pages the search points at**, then follow their `related:` and `sources:`
|
3. **Read only the pages the search points at** - each hit carries its full path - then follow
|
||||||
entries. Check `kb/sources/` when the question is about what a specific source said.
|
their `related:` and `sources:` entries. Check `kb/sources/` when the question is about what
|
||||||
|
a specific source said.
|
||||||
|
|
||||||
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
|
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
|
||||||
Hedge to what those sources carry, not to a number - see
|
Hedge to what those sources carry, not to a number - see
|
||||||
[kb/CONVENTIONS.md § Hedging](../../kb/CONVENTIONS.md#hedging).
|
`kb/CONVENTIONS.md` § Hedging.
|
||||||
|
|
||||||
5. **Decide what earns a page - before the first `new`.** Name every page you are considering,
|
5. **Decide what earns a page - before the first `new`.** Name every page you are considering,
|
||||||
then hold each one on its own against all three criteria: the answer required synthesis
|
then hold each one on its own against all three criteria: the answer required synthesis
|
||||||
@@ -75,9 +78,9 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
|||||||
exist under different words. Then say the wiki has no confident source, and offer to ingest
|
exist under different words. Then say the wiki has no confident source, and offer to ingest
|
||||||
one.
|
one.
|
||||||
- **Filed a page?** Query does **not** auto-publish. Run `tools/wikitool publish` only if asked;
|
- **Filed a page?** Query does **not** auto-publish. Run `tools/wikitool publish` only if asked;
|
||||||
the sequence is in [publish-cycle.md](../publish-cycle.md).
|
the sequence is in `instructions/publish-cycle.md`.
|
||||||
- **Several answers filed at once?** That can trip the Mass-Update Gate - see
|
- **Several answers filed at once?** That can trip the Mass-Update Gate - see
|
||||||
[gates.md](../gates.md). The gate is a brake, not the check: it counts files and knows nothing
|
`instructions/gates.md`. The gate is a brake, not the check: it counts files and knows nothing
|
||||||
about whether any of them earned a page. Step 5 is what decides that, and a batch small enough
|
about whether any of them earned a page. Step 5 is what decides that, and a batch small enough
|
||||||
to pass the gate has not been cleared by it.
|
to pass the gate has not been cleared by it.
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ semantic review a lint pass does.
|
|||||||
|
|
||||||
**Trigger:** User asks for wiki statistics, "what's new", or a quick health snapshot.
|
**Trigger:** User asks for wiki statistics, "what's new", or a quick health snapshot.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md) - step 2's `lint` is
|
**Before the first `wikitool` call:** `instructions/session-setup.md` - step 2's `lint` is
|
||||||
not on the budget's exemption allowlist and is counted like any other call, gitignored report or
|
not on the budget's exemption allowlist and is counted like any other call, gitignored report or
|
||||||
not (§ Scope there).
|
not (§ Scope there).
|
||||||
|
|
||||||
**Hard rule:** read-only with respect to wiki *content*. Never create, modify, or scaffold a
|
**Hard rule:** read-only with respect to wiki *content*. Never create, modify, or scaffold a
|
||||||
page, never repair a finding, never publish. One file does get written: the report `lint`
|
page, never repair a finding, never publish. One file does get written: the report `lint`
|
||||||
produces in step 2. That is not an exception being stretched - `reports/` is gitignored and holds
|
produces in step 2. That is not an exception being stretched - `reports/` is gitignored and holds
|
||||||
no wiki page ([reports/CONTRACT.md](../../reports/CONTRACT.md)), so the write leaves nothing
|
no wiki page (`reports/CONTRACT.md`), so the write leaves nothing
|
||||||
behind that the wiki ships. If something looks wrong, point the user at `wiki-lint` or
|
behind that the wiki ships. If something looks wrong, point the user at `wiki-lint` or
|
||||||
`wiki-manage` instead of fixing it here.
|
`wiki-manage` instead of fixing it here.
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -40,11 +40,24 @@ those regions and nothing else. Nothing matches on this text.
|
|||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it -
|
Pages are written in **German** - the `language:` in this file's own frontmatter, and the one
|
||||||
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
|
place that value is written down. This binds `kb/`, and inside the page type-specs
|
||||||
`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the
|
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`) exactly the
|
||||||
control plane stays English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and
|
parts that become page text: each one's `## Template` block, and the `layout:` titles that head a
|
||||||
the type-specs for non-page artifacts.
|
catalog section. Their authoring guidance around those is instruction to an agent, so it follows
|
||||||
|
the control plane and stays English - the same prose/identifier cut
|
||||||
|
[kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a page, applied one level up.
|
||||||
|
`raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)).
|
||||||
|
|
||||||
|
Two things follow from that value rather than being decided here, both stated once in
|
||||||
|
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
|
||||||
|
instance writes its pages in, and an agent *speaks* the language named above.
|
||||||
|
|
||||||
|
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
|
||||||
|
above is the only language value in the tree, and what it binds is page text; a control-plane
|
||||||
|
document is English even when this instance wrote it for itself and never ships it. Why that is
|
||||||
|
an architecture decision rather than an unset parameter:
|
||||||
|
[docs/language-boundaries.md](../docs/language-boundaries.md).
|
||||||
|
|
||||||
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
||||||
the contract's rule, not this file's: see
|
the contract's rule, not this file's: see
|
||||||
|
|||||||
@@ -25,14 +25,40 @@ The frontmatter above is the one machine-read part. `sections:` names the headin
|
|||||||
generated regions render under. Safe to change at any time - each region is located by its
|
generated regions render under. Safe to change at any time - each region is located by its
|
||||||
marker pair, so a rename re-renders words and nothing else.
|
marker pair, so a rename re-renders words and nothing else.
|
||||||
|
|
||||||
|
<!-- wikitool:toc -->
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Language](#language)
|
||||||
|
- [Section headings](#section-headings)
|
||||||
|
- [Naming](#naming)
|
||||||
|
- [Tone](#tone)
|
||||||
|
- [Relationship labels](#relationship-labels)
|
||||||
|
- [Hedging](#hedging)
|
||||||
|
- [Keeping this file honest](#keeping-this-file-honest)
|
||||||
|
<!-- /wikitool:toc -->
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
Pages are written in **{language}**. This binds `kb/` and the authoring surface that shapes it -
|
Pages are written in **{language}** - the `language:` in this file's own frontmatter, and the
|
||||||
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
|
one place that value is written down. This binds `kb/`, and inside the page type-specs
|
||||||
`types/comparison.md`, whose `## Template` blocks are the body skeleton every new page starts
|
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`) exactly the
|
||||||
from. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the control plane stays
|
parts that become page text: each one's `## Template` block - the body skeleton every new page
|
||||||
English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and the type-specs for
|
starts from - and the `layout:` titles that head a catalog section. Their authoring guidance
|
||||||
non-page artifacts.
|
around those is instruction to an agent, so it follows the control plane and stays English - the
|
||||||
|
same prose/identifier cut [kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a
|
||||||
|
page, applied one level up. Adopting this template into a non-English instance therefore means
|
||||||
|
translating those blocks, not the whole file. `raw/` is untouched
|
||||||
|
([raw/CONTRACT.md](../raw/CONTRACT.md)).
|
||||||
|
|
||||||
|
Two things follow from that value rather than being decided here, both stated once in
|
||||||
|
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
|
||||||
|
instance writes its pages in, and an agent *speaks* the language named above.
|
||||||
|
|
||||||
|
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
|
||||||
|
above is the only language value in the tree, and what it binds is page text; a control-plane
|
||||||
|
document is English even when this instance wrote it for itself and never ships it. Why that is
|
||||||
|
an architecture decision rather than an unset parameter:
|
||||||
|
[docs/language-boundaries.md](../docs/language-boundaries.md).
|
||||||
|
|
||||||
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
Which line is prose and which is an identifier - and therefore what is translated at all - is
|
||||||
the contract's rule, not this file's: see
|
the contract's rule, not this file's: see
|
||||||
|
|||||||
+21
-22
@@ -35,32 +35,31 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
|
|||||||
|
|
||||||
## Types offered
|
## Types offered
|
||||||
|
|
||||||
`concept` (`tools/wikitool types describe concept`). Das Feld `concept_type:`
|
`concept` (`tools/wikitool types describe concept`). The `concept_type:` field
|
||||||
wählt die Area:
|
picks the area:
|
||||||
|
|
||||||
| Area | Hält |
|
| Area | Holds |
|
||||||
|------|------|
|
|------|-------|
|
||||||
| `architectures/` | Aufbau und Struktur: wie ein System geschnitten ist und warum die Schnitte dort liegen |
|
| `architectures/` | Shape and structure: how a system is cut up, and why the cuts fall where they do |
|
||||||
| `patterns/` | Wiederverwendbare Lösungsformen, die über mehr als einen Gegenstand hinweg gelten |
|
| `patterns/` | Reusable solution shapes that hold across more than one subject |
|
||||||
| `protocols/` | Kommunikationsprotokolle und Standards, in ihrer üblichen Schreibweise benannt |
|
| `protocols/` | Communication protocols and standards, named in their usual spelling |
|
||||||
| `workflows/` | Abläufe und Prozesse, die projektübergreifend wiederkehren |
|
| `workflows/` | Procedures and processes that recur across projects |
|
||||||
| `decisions/` | Architektur- und Entwurfsentscheidungen (siehe unten) |
|
| `decisions/` | Architectural and design decisions (see below) |
|
||||||
| `problems/` | Wiederkehrende Problemstellungen und ihre Lösungsansätze |
|
| `problems/` | Recurring problems and the approaches taken to them |
|
||||||
|
|
||||||
Das sind Areas, keine Collections: sie erben diesen Contract und tragen keine
|
These are areas, not collections: they inherit this contract and carry no
|
||||||
eigene `COLLECTION.md`.
|
`COLLECTION.md` of their own.
|
||||||
|
|
||||||
Die Zuordnung trifft niemand von Hand — sie steht als `layout:` in
|
Nobody assigns them by hand — the mapping is the `layout:` in
|
||||||
`types/concept.md`, und `wikitool new` legt eine neue Seite direkt dort ab.
|
`types/concept.md`, and `wikitool new` puts a new page straight there. A page
|
||||||
Eine Seite, die anderswo liegt, meldet `wikitool lint` als *misplaced*;
|
sitting anywhere else is reported by `wikitool lint` as *misplaced*;
|
||||||
`wikitool move --page "<Titel>"` bringt sie an ihren berechneten Ort.
|
`wikitool move --page "<title>"` moves it to its computed location.
|
||||||
|
|
||||||
Die Aufteilung ist keine Geschmacksfrage, sondern das, was die Shard-Schwelle
|
The split is not a matter of taste but what makes the catalog's shard threshold
|
||||||
des Katalogs überhaupt wirksam macht: `index rebuild` teilt **pro Area**, und
|
effective at all: `index rebuild` splits **per area**, and a collection without
|
||||||
eine Collection ohne Areas teilt sich nie — mit 80 Seiten in einer einzigen
|
areas never splits — with 80 pages in a single table the threshold was a dead
|
||||||
Tabelle war die Schwelle hier ein toter Wert. Keine der sechs
|
value here. None of the six areas is currently above it, so none gets a shard of
|
||||||
Areas liegt derzeit über der Schwelle, also bekommt auch keine einen eigenen
|
its own; when one grows into it, that happens without anyone acting.
|
||||||
Shard; wächst eine hinein, passiert das ohne Zutun.
|
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+26
-28
@@ -26,38 +26,36 @@ renamed or dropped - its authoring rules below are the instance's, its existence
|
|||||||
## Types offered
|
## Types offered
|
||||||
|
|
||||||
`source` (`tools/wikitool types describe source`). Page titles carry the `Source - ` prefix,
|
`source` (`tools/wikitool types describe source`). Page titles carry the `Source - ` prefix,
|
||||||
applied automatically by `wikitool new source`. Das Feld `source_type:` wählt die Area - **ohne
|
applied automatically by `wikitool new source`. The `source_type:` field picks the area - **with
|
||||||
Default**: `wikitool new source` verweigert ohne einen expliziten Wert.
|
no default**: `wikitool new source` refuses without an explicit value.
|
||||||
|
|
||||||
| Area | Hält |
|
| Area | Holds |
|
||||||
|------|------|
|
|------|-------|
|
||||||
| `transcripts/` | Session-Transkripte: mitgeschriebener Dialog zwischen Mensch und Agent, oder zwischen Menschen |
|
| `transcripts/` | Session transcripts: recorded dialogue between a human and an agent, or between humans |
|
||||||
| `analyses/` | Analyse-Output eines Modells über einen Gegenstand - kein Dialog, kein Protokoll, sondern eine eigenständige Einschätzung |
|
| `analyses/` | A model's analytical output about a subject - not dialogue, not a record, but an assessment in its own right |
|
||||||
| `articles/` | Externe Artikel und Blogposts, mit `source_url:` |
|
| `articles/` | External articles and blog posts, with `source_url:` |
|
||||||
| `documents/` | Eingelesene Dokumente, Handbücher, Spezifikationen |
|
| `documents/` | Ingested documents, manuals, specifications |
|
||||||
| `notes/` | Echte eigene Notizen ohne Dialogform - Cheat Sheets, Merkzettel |
|
| `notes/` | Genuinely own notes in no dialogue form - cheat sheets, reminders |
|
||||||
| `trackers/` | Exporte aus einem Issue-Tracker oder vergleichbaren System |
|
| `trackers/` | Exports from an issue tracker or comparable system |
|
||||||
| `unclassified/` | Sichtbares Fach für eine Quelle, deren Kategorie noch nicht feststeht - beratender `lint`-Befund, kein Sammelbecken. Es wieder zu leeren, oder das Enum um einen neuen Wert zu erweitern: [instructions/evolve-subtypes.md](../../instructions/evolve-subtypes.md) |
|
| `unclassified/` | The visible slot for a source whose category is not settled yet - an advisory `lint` finding, not a dumping ground. Emptying it again, or extending the enum by a new value: [instructions/evolve-subtypes.md](../../instructions/evolve-subtypes.md) |
|
||||||
|
|
||||||
Das sind Areas, keine Collections: sie erben diesen Contract und tragen keine eigene
|
These are areas, not collections: they inherit this contract and carry no `COLLECTION.md` of
|
||||||
`COLLECTION.md`. Die Zuordnung trifft niemand von Hand - sie steht als `layout:` in
|
their own. Nobody assigns them by hand - the mapping is the `layout:` in `types/source.md`, and
|
||||||
`types/source.md`, und `wikitool new` legt eine neue Seite direkt dort ab. Eine Seite, die
|
`wikitool new` puts a new page straight there. A page sitting anywhere else is reported by
|
||||||
anderswo liegt, meldet `wikitool lint` als *misplaced*; `wikitool move --page "<Titel>"` bringt
|
`wikitool lint` as *misplaced*; `wikitool move --page "<title>"` moves it to its computed
|
||||||
sie an ihren berechneten Ort.
|
location.
|
||||||
|
|
||||||
**`analysis` gegen `document`:** die Unterscheidung läuft über die Autorschaft, nicht über den
|
**`analysis` versus `document`:** the distinction runs on authorship, not on content. A model
|
||||||
Inhalt. Ein Modell, das über einen Gegenstand urteilt oder ihn zusammenfasst, ohne dass ein
|
judging or summarizing a subject with no human or organization answering for it is `analysis` -
|
||||||
Mensch oder eine Organisation dafür geradesteht, ist `analysis` - unabhängig davon, wie
|
however article-shaped the text looks. A manual, a specification, a vendor document is
|
||||||
artikelförmig der Text wirkt. Ein Handbuch, eine Spezifikation, eine Herstellerdoku ist
|
`document`, even where a tool generated it, as long as an organization is accountable for what
|
||||||
`document`, auch wenn ein Werkzeug sie generiert hat, solange eine Organisation die Aussage
|
it says. The question is "who is liable for the claim", not "how does the text read".
|
||||||
verantwortet. Die Frage ist also "wer haftet für die Behauptung", nicht "wie liest sich der
|
|
||||||
Text".
|
|
||||||
|
|
||||||
Solange es diesen Default noch gab, fiel fast alles hierher in `notes/`, weil
|
While that default still existed, nearly everything landed here in `notes/`, because
|
||||||
`types/source.schema.yaml` `notes` als `default:` gesetzt hatte - der Compiler wählte das
|
`types/source.schema.yaml` had `notes` as its `default:` - the compiler picked the dumping
|
||||||
Sammelbecken, sobald niemand widersprach.
|
ground whenever nobody objected. 22 of the 29 pages there at the time were in fact transcripts,
|
||||||
22 der 29 damaligen Seiten waren tatsächlich Transkripte, Analysen oder Tracker-Exporte und
|
analyses or tracker exports, and were reclassified with `wikitool touch --set source_type=…`
|
||||||
wurden per `wikitool touch --set source_type=…` umklassifiziert, bevor die Areas entstanden.
|
before the areas existed.
|
||||||
|
|
||||||
## Provenance rules
|
## Provenance rules
|
||||||
|
|
||||||
|
|||||||
+21
-27
@@ -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) |
|
||||||
@@ -124,7 +124,7 @@ tools/wikitool <command> --help
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||||
|
|
||||||
### Provenance
|
### Provenance
|
||||||
|
|
||||||
@@ -166,12 +166,12 @@ tools/wikitool <command> --help
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
||||||
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
|
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body - composed with the stack-owned `types/<name>.guidance.md` where the type-spec declares `guidance:` (`--json` reports it separately as `guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract reads as one answer even though it may live in two files. A type-spec (or its guidance file) over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
|
||||||
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
||||||
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
||||||
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
||||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), and every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent. The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
|
|
||||||
@@ -185,11 +185,11 @@ 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 lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. 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
|
||||||
@@ -379,9 +382,9 @@ is atomic, and whether a retry is safe.
|
|||||||
| `types list` | Never fails | Read-only | Safe to retry freely |
|
| `types list` | Never fails | Read-only | Safe to retry freely |
|
||||||
| `types describe` | Unknown type name | Read-only | Fix the name and retry |
|
| `types describe` | Unknown type name | Read-only | Fix the name and retry |
|
||||||
| `instructions sync` | No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed | No - one directory copy per skill per target (`.agents/skills/`, `.claude/skills/`); each copy is idempotent, so a re-run converges even after a partial failure | Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry |
|
| `instructions sync` | No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed | No - one directory copy per skill per target (`.agents/skills/`, `.claude/skills/`); each copy is idempotent, so a re-run converges even after a partial failure | Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry |
|
||||||
| `instructions verify` | Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
| `instructions verify` | Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
||||||
| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely |
|
| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely |
|
||||||
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, or a reference file's table-of-contents region is missing or stale | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region |
|
| `docs verify` | A command, contract, or type-form mismatch was found, a type-spec's own frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file | Read-only | Fix the documentation it names, then re-run. For a type-spec's own frontmatter: fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name |
|
||||||
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
|
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
@@ -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.
|
|
||||||
|
|||||||
+10
-5
@@ -38,7 +38,7 @@ from chemenu.lint_core import run_lint
|
|||||||
from chemenu.search import filters
|
from chemenu.search import filters
|
||||||
from chemenu.search.registry import resolve
|
from chemenu.search.registry import resolve
|
||||||
from chemenu.search.service import run_search, unreadable_pages
|
from chemenu.search.service import run_search, unreadable_pages
|
||||||
from chemenu.search.types import Predicate, SearchQuery
|
from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery
|
||||||
from chemenu.types_core import describe_type, list_types
|
from chemenu.types_core import describe_type, list_types
|
||||||
|
|
||||||
# Distinguishes "the caller did not pass a revision" from "the caller passed
|
# Distinguishes "the caller did not pass a revision" from "the caller passed
|
||||||
@@ -120,7 +120,7 @@ class Corpus:
|
|||||||
text: Optional[str] = None,
|
text: Optional[str] = None,
|
||||||
predicates: Iterable[str] = (),
|
predicates: Iterable[str] = (),
|
||||||
regex: bool = False,
|
regex: bool = False,
|
||||||
limit: int = 20,
|
limit: int = DEFAULT_LIMIT,
|
||||||
sort: Optional[str] = None,
|
sort: Optional[str] = None,
|
||||||
backend: Optional[str] = None,
|
backend: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -142,13 +142,18 @@ class Corpus:
|
|||||||
|
|
||||||
with self._rooted():
|
with self._rooted():
|
||||||
pages, revision = self._cache.load()
|
pages, revision = self._cache.load()
|
||||||
hits = run_search(query, pages, backends, self.kb_dir)
|
result = run_search(query, pages, backends, self.kb_dir)
|
||||||
return self._stamp({
|
return self._stamp({
|
||||||
"query": text,
|
"query": text,
|
||||||
"predicates": [p.render() for p in parsed],
|
"predicates": [p.render() for p in parsed],
|
||||||
"backend": ",".join(b.name for b in backends),
|
"backend": ",".join(b.name for b in backends),
|
||||||
"count": len(hits),
|
# Same shape the CLI's `--json` prints: `count` is what came back,
|
||||||
"results": [hit.as_dict() for hit in hits],
|
# `total` is how many matched before `limit` cut it.
|
||||||
|
"count": len(result.hits),
|
||||||
|
"total": result.total,
|
||||||
|
"truncated": result.truncated,
|
||||||
|
"limit": result.limit,
|
||||||
|
"results": [hit.as_dict() for hit in result.hits],
|
||||||
"unreadable": unreadable_pages(pages),
|
"unreadable": unreadable_pages(pages),
|
||||||
}, revision)
|
}, revision)
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
@@ -308,9 +308,17 @@ def instance_owned_type_stems() -> set[str]:
|
|||||||
|
|
||||||
The line is `root:`, and it was already in the frontmatter before anyone
|
The line is `root:`, and it was already in the frontmatter before anyone
|
||||||
drew it: `root: kb` means the type describes a page the instance writes, so
|
drew it: `root: kb` means the type describes a page the instance writes, so
|
||||||
its prose, its template and its language are the instance's business.
|
the file is the instance's to change. Anything else - `instruction`
|
||||||
Anything else - `instruction` (`root: repo`), `lint-report` (no `base_dir`
|
(`root: repo`), `lint-report` (no `base_dir` at all), `type-spec` itself -
|
||||||
at all), `type-spec` itself - describes a stack artifact and ships verbatim.
|
describes a stack artifact and ships verbatim.
|
||||||
|
|
||||||
|
Ownership, not language. What such a file is *written in* is decided by who
|
||||||
|
reads each half, not by who owns the file: its `## Template` block and its
|
||||||
|
`layout:` titles become page text and follow `kb/CONVENTIONS.md`, while the
|
||||||
|
authoring guidance around them addresses an agent and stays English like the
|
||||||
|
rest of the control plane (AGENTS.md § File naming, types/type-spec.md
|
||||||
|
§ Who owns a type-spec). That the two halves share one file, and what it
|
||||||
|
costs, is docs/ownership-and-templates.md § Where the file boundary strains.
|
||||||
|
|
||||||
Read from `types/` rather than listed, so an instance adding its own page
|
Read from `types/` rather than listed, so an instance adding its own page
|
||||||
type gets the same treatment without a code change.
|
type gets the same treatment without a code change.
|
||||||
@@ -330,6 +338,32 @@ def instance_owned_type_stems() -> set[str]:
|
|||||||
return stems
|
return stems
|
||||||
|
|
||||||
|
|
||||||
|
# The suffix a type-spec's own two files carry - `<stem>.md` and
|
||||||
|
# `<stem>.schema.yaml` - as opposed to a sibling file that merely starts with
|
||||||
|
# the same stem, such as `<stem>.guidance.md` (Gitea #104). Checked as an
|
||||||
|
# exact suffix rather than by splitting on the first `.`, which is what let
|
||||||
|
# `entity.guidance.md` be mistaken for the `entity` type-spec's own file
|
||||||
|
# before this existed - a stack-owned file re-keyed as though it were the
|
||||||
|
# instance's `.template` to adopt, and flagged as a leak by the other call
|
||||||
|
# site for not being one.
|
||||||
|
_TYPE_SCHEMA_SUFFIX = ".schema.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_type_stem(relative: str) -> Optional[str]:
|
||||||
|
"""The type stem `relative` (a path under `types/`, no `.template`
|
||||||
|
suffix) names, if it is exactly that type-spec's own `<stem>.md` or
|
||||||
|
`<stem>.schema.yaml` - `None` for anything else under `types/`,
|
||||||
|
including a `<stem>.guidance.md` file. `_plan_types()` and `find_leaks()`
|
||||||
|
both ask this instead of computing their own stem, so the two answer the
|
||||||
|
same question about the same path (AGENTS.md invariant 8)."""
|
||||||
|
name = relative.rsplit("/", 1)[-1]
|
||||||
|
if name.endswith(_TYPE_SCHEMA_SUFFIX):
|
||||||
|
return name[: -len(_TYPE_SCHEMA_SUFFIX)]
|
||||||
|
if name.endswith(".md") and not name.endswith(".guidance.md"):
|
||||||
|
return name[: -len(".md")]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _plan_types() -> dict[str, PlannedFile]:
|
def _plan_types() -> dict[str, PlannedFile]:
|
||||||
"""`types/`, with the page type-specs re-keyed as templates.
|
"""`types/`, with the page type-specs re-keyed as templates.
|
||||||
|
|
||||||
@@ -339,6 +373,11 @@ def _plan_types() -> dict[str, PlannedFile]:
|
|||||||
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
|
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
|
||||||
it, because the two are one type (see types/type-spec.md § Anatomy) and
|
it, because the two are one type (see types/type-spec.md § Anatomy) and
|
||||||
adopting half of it would leave a spec validated by a file it does not own.
|
adopting half of it would leave a spec validated by a file it does not own.
|
||||||
|
|
||||||
|
A type-spec's optional `<name>.guidance.md` (Gitea #104) is the opposite:
|
||||||
|
stack-owned even where the type-spec itself is instance-owned, and ships
|
||||||
|
verbatim beside the `.template` - `_owned_type_stem` is what keeps it out
|
||||||
|
of this re-keying despite sharing the type-spec's own stem.
|
||||||
"""
|
"""
|
||||||
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
|
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
|
||||||
stems = instance_owned_type_stems()
|
stems = instance_owned_type_stems()
|
||||||
@@ -347,9 +386,8 @@ def _plan_types() -> dict[str, PlannedFile]:
|
|||||||
|
|
||||||
rekeyed: dict[str, PlannedFile] = {}
|
rekeyed: dict[str, PlannedFile] = {}
|
||||||
for relative, planned in plan.items():
|
for relative, planned in plan.items():
|
||||||
name = relative.rsplit("/", 1)[-1]
|
stem = _owned_type_stem(relative)
|
||||||
stem = name.split(".", 1)[0]
|
if stem is not None and stem in stems:
|
||||||
if stem in stems:
|
|
||||||
rekeyed[f"{relative}.template"] = planned
|
rekeyed[f"{relative}.template"] = planned
|
||||||
else:
|
else:
|
||||||
rekeyed[relative] = planned
|
rekeyed[relative] = planned
|
||||||
@@ -504,7 +542,8 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
|
|||||||
elif (
|
elif (
|
||||||
relative.startswith("types/")
|
relative.startswith("types/")
|
||||||
and not relative.endswith(".template")
|
and not relative.endswith(".template")
|
||||||
and name.split(".", 1)[0] in owned_types
|
and (owned_stem := _owned_type_stem(relative)) is not None
|
||||||
|
and owned_stem in owned_types
|
||||||
):
|
):
|
||||||
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
|
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
|
||||||
elif relative.startswith("instructions/dev/"):
|
elif relative.startswith("instructions/dev/"):
|
||||||
@@ -620,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)
|
||||||
@@ -763,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:
|
||||||
@@ -782,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:
|
||||||
@@ -816,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",
|
||||||
@@ -833,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -847,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:
|
||||||
@@ -954,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
|
||||||
@@ -995,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 "")
|
||||||
+ "."
|
+ "."
|
||||||
@@ -1006,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)
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ A sixth checks a *reference* rather than a copy: no document `dist export`
|
|||||||
ships may cite an issue number, because the board those numbers live on
|
ships may cite an issue number, because the board those numbers live on
|
||||||
exists only in the origin repo.
|
exists only in the origin repo.
|
||||||
|
|
||||||
|
A seventh checks the other half of the same reference problem: every relative
|
||||||
|
markdown link in a reference file - `toc.target_files()`'s scope, the same one
|
||||||
|
the table-of-contents check uses - must resolve to a file that actually
|
||||||
|
exists. A link with the wrong `../` count is invisible to every check above:
|
||||||
|
it is present, it names an existing command or contract by title, and nothing
|
||||||
|
renders it to notice the target is unreachable. The complementary half - that
|
||||||
|
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
|
||||||
|
because `instructions sync` copies it to a different depth than its links
|
||||||
|
assume - is `instructions verify`'s job, not this one, since that module
|
||||||
|
already owns the Skill/Instruction split (`skill_dirs()` vs
|
||||||
|
`instruction_files()`).
|
||||||
|
|
||||||
|
An eighth checks the type layer against its own schema: every file under
|
||||||
|
`types/` declaring `type: types/type-spec.md` must validate against
|
||||||
|
`types/type-spec.schema.yaml`. Before this check existed the schema had
|
||||||
|
already drifted behind two fields real type-specs carry (`root:`,
|
||||||
|
`capture_fields:`) while `additionalProperties: false` sat there describing a
|
||||||
|
contract nothing enforced - the exact "checked or absent" failure this file's
|
||||||
|
opening paragraph names, just one level up, for the schema that describes the
|
||||||
|
type layer instead of a copy the type layer's code produces (Gitea #105).
|
||||||
|
|
||||||
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
||||||
Content quality of the contracts themselves stays with the LLM.
|
Content quality of the contracts themselves stays with the LLM.
|
||||||
"""
|
"""
|
||||||
@@ -44,7 +65,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import config, conventions, kb_collections, toc, version as version_mod
|
from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||||
from chemenu.commands import dist_cmd
|
from chemenu.commands import dist_cmd
|
||||||
from chemenu.commands._util import fail, rel_path, success
|
from chemenu.commands._util import fail, rel_path, success
|
||||||
|
|
||||||
@@ -410,6 +431,37 @@ def check_stack_required_types() -> list[str]:
|
|||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def check_type_spec_frontmatter() -> list[str]:
|
||||||
|
"""Every type-spec's own frontmatter must validate against
|
||||||
|
`types/type-spec.schema.yaml` - the schema that describes the type layer
|
||||||
|
gets the same enforcement any other type's schema gets (Gitea #105).
|
||||||
|
|
||||||
|
Before this check nothing ever called `validate_frontmatter` against a
|
||||||
|
type-spec's own frontmatter, so the schema had quietly drifted behind two
|
||||||
|
fields real type-specs actually carry (`root:`, `capture_fields:`)
|
||||||
|
without anything failing - `additionalProperties: false` described a
|
||||||
|
contract that bound nothing. `resolver.list_type_specs()` already reads
|
||||||
|
every file's frontmatter once for `wikitool types list`; reusing it here
|
||||||
|
means this check costs no second parse pass.
|
||||||
|
"""
|
||||||
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
|
issues: list[str] = []
|
||||||
|
for type_path, frontmatter in resolver.list_type_specs():
|
||||||
|
try:
|
||||||
|
resolver.validate_frontmatter(
|
||||||
|
frontmatter, "types/type-spec.md", source_file=config.ROOT / type_path
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
# `validate_frontmatter`'s own message names the type path it
|
||||||
|
# validated *against* (always `types/type-spec.md` here, since
|
||||||
|
# every type-spec is validated against the same schema) rather
|
||||||
|
# than the specific file that failed - prefix that file's own
|
||||||
|
# path so two failures in one run stay distinguishable.
|
||||||
|
issues.append(f"{type_path}: {exc}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def check_legacy_type_blocks() -> list[str]:
|
def check_legacy_type_blocks() -> list[str]:
|
||||||
issues = []
|
issues = []
|
||||||
guarded = [
|
guarded = [
|
||||||
@@ -452,6 +504,85 @@ def check_toc_regions() -> list[str]:
|
|||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
# A markdown link, `[text](target)`. The target excludes `)` and whitespace -
|
||||||
|
# the same restriction every link in this repo's own instructions already
|
||||||
|
# follows; a target needing either would need CommonMark's <angle-bracket>
|
||||||
|
# escaping, which nothing here uses.
|
||||||
|
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||||
|
|
||||||
|
# The suffix `dist export` re-keys an instance-owned file to, and the one
|
||||||
|
# `setup-instance.md` renames away again. Imported from `toc` rather than
|
||||||
|
# spelled again here: that module already decides which files are reference
|
||||||
|
# material in both their forms, and this check runs over its scope. Not from
|
||||||
|
# `ownership`, whose own `.template` handling answers a different question
|
||||||
|
# (which side an upstream merge keeps) over a narrower scope (paths under a
|
||||||
|
# content stage).
|
||||||
|
TEMPLATE_SUFFIX = toc.TEMPLATE_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
def is_external_or_anchor(target: str) -> bool:
|
||||||
|
"""A link this check does not resolve as a filesystem path: an absolute
|
||||||
|
URL, a `mailto:`, or a pure in-page `#anchor`.
|
||||||
|
|
||||||
|
Public (not `_`-prefixed): `instructions_cmd.check_skill_reference_paths`
|
||||||
|
imports this alongside `MARKDOWN_LINK_RE` rather than keeping a second
|
||||||
|
copy - the two checks classify the same link shape, just over different
|
||||||
|
file sets (AGENTS.md invariant 8)."""
|
||||||
|
return target.startswith(("http://", "https://", "mailto:", "#"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_reference_targets() -> list[str]:
|
||||||
|
"""Every relative markdown link in a reference file resolves to a real file.
|
||||||
|
|
||||||
|
Scoped to `toc.target_files()` - AGENTS.md, the stage and collection
|
||||||
|
contracts, and every flat `instructions/**.md` file - the same scope the
|
||||||
|
table-of-contents check uses. That scope already excludes `SKILL.md`
|
||||||
|
(banned from carrying a markdown link at all - `instructions verify`'s
|
||||||
|
`check_skill_reference_paths`), `commonplace/` (vendored, not stack
|
||||||
|
material) and `raw/`/`kb/` page content (data, not documentation) beyond
|
||||||
|
the two files that are themselves reference material.
|
||||||
|
|
||||||
|
A target's `#anchor` suffix is stripped before resolving - CommonMark
|
||||||
|
anchors are not filesystem paths, and nothing here renders one to notice
|
||||||
|
a stale one anyway. Code fences and inline code spans are masked first
|
||||||
|
(`markdown_code.strip_code_spans`), so a passage that shows link syntax
|
||||||
|
as an example is not mistaken for a real reference.
|
||||||
|
|
||||||
|
**A target the stack ships only as a `.template` counts as resolving.**
|
||||||
|
`kb/CONVENTIONS.md` and every `kb/<name>/COLLECTION.md` are instance-owned:
|
||||||
|
a distribution carries `<name>.template` and the instance adopts it by
|
||||||
|
renaming, during `instructions/setup-instance.md`'s personalization step.
|
||||||
|
Between `dist export` and that step the real file legitimately does not
|
||||||
|
exist yet - while `kb/CONTRACT.md` and three flat instructions link to it
|
||||||
|
by its adopted name, correctly, because that is the name it will have.
|
||||||
|
Reporting those as dead links would fail a fresh export for doing exactly
|
||||||
|
what it is supposed to do, and would describe "not personalized yet" as a
|
||||||
|
broken link when `doctor`'s `conventions` check already says it precisely.
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
for path in toc.target_files():
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
masked = markdown_code.strip_code_spans(text)
|
||||||
|
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||||
|
for match in MARKDOWN_LINK_RE.finditer(masked_line):
|
||||||
|
target = match.group(1)
|
||||||
|
if is_external_or_anchor(target):
|
||||||
|
continue
|
||||||
|
target_path = target.split("#", 1)[0]
|
||||||
|
if not target_path:
|
||||||
|
continue
|
||||||
|
resolved = (path.parent / target_path).resolve()
|
||||||
|
if resolved.exists():
|
||||||
|
continue
|
||||||
|
if resolved.with_name(resolved.name + TEMPLATE_SUFFIX).exists():
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
f"{rel_path(path)}:{line_number} links to `{target}`, which does not "
|
||||||
|
"resolve to an existing file"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def command_table_free_readmes() -> list[Path]:
|
def command_table_free_readmes() -> list[Path]:
|
||||||
"""Every README that must not carry a copy of the command table.
|
"""Every README that must not carry a copy of the command table.
|
||||||
|
|
||||||
@@ -768,11 +899,12 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
@app.command("verify")
|
@app.command("verify")
|
||||||
def verify():
|
def verify():
|
||||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
|
"""Check the CLI/README command tables, contract presence, type-form drift, every type-spec's frontmatter against its own schema, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
|
||||||
issues = (
|
issues = (
|
||||||
check_cli_readme()
|
check_cli_readme()
|
||||||
+ check_readmes_have_no_command_table()
|
+ check_readmes_have_no_command_table()
|
||||||
+ check_collection_contracts()
|
+ check_collection_contracts()
|
||||||
|
+ check_type_spec_frontmatter()
|
||||||
+ check_legacy_type_blocks()
|
+ check_legacy_type_blocks()
|
||||||
+ check_ignored_content()
|
+ check_ignored_content()
|
||||||
+ check_version_changelog()
|
+ check_version_changelog()
|
||||||
@@ -780,18 +912,23 @@ def verify():
|
|||||||
+ check_breaking_change_for_boundary()
|
+ check_breaking_change_for_boundary()
|
||||||
+ check_no_issue_references()
|
+ check_no_issue_references()
|
||||||
+ check_toc_regions()
|
+ check_toc_regions()
|
||||||
|
+ check_reference_targets()
|
||||||
)
|
)
|
||||||
|
|
||||||
if issues:
|
if issues:
|
||||||
fail("Documentation issues found:\n" + "\n".join(f"- {i}" for i in issues))
|
fail("Documentation issues found:\n" + "\n".join(f"- {i}" for i in issues))
|
||||||
|
|
||||||
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
success(
|
success(
|
||||||
f"Docs verified: {len(registered_commands())} command(s) documented, "
|
f"Docs verified: {len(registered_commands())} command(s) documented, "
|
||||||
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
|
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
|
||||||
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
||||||
|
f"{len(resolver.list_type_specs())} type-spec(s) validating against their own schema, "
|
||||||
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
||||||
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
||||||
f"tables of contents current on {len(toc.target_files())} reference file(s), "
|
f"tables of contents current and every link resolving on "
|
||||||
|
f"{len(toc.target_files())} reference file(s), "
|
||||||
f"{version_mod.CHANGES_FILENAME} documents version "
|
f"{version_mod.CHANGES_FILENAME} documents version "
|
||||||
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ Both target directories are gitignored. A fresh clone has no skills until `sync`
|
|||||||
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
||||||
than reporting an error when *every* copy is missing, because that is the
|
than reporting an error when *every* copy is missing, because that is the
|
||||||
expected state of a clean checkout rather than a fault.
|
expected state of a clean checkout rather than a fault.
|
||||||
|
|
||||||
|
The copy is also a different depth than the source, and without the sibling
|
||||||
|
files a relative link might expect - a plain `shutil.copytree` per skill
|
||||||
|
directory, not a mirror of the whole `instructions/` tree. A relative markdown
|
||||||
|
link correct at `instructions/<name>/SKILL.md` therefore resolves to a
|
||||||
|
different, usually nonexistent, file in the published copy the harness
|
||||||
|
actually reads. `verify` forbids the shape outright
|
||||||
|
(`check_skill_reference_paths`) rather than checking depth arithmetic, and a
|
||||||
|
`SKILL.md` writes an outbound reference as a repo-root-relative plain path
|
||||||
|
instead - see instructions/CONTRACT.md § "A skill's outbound reference is a
|
||||||
|
plain path, not a link". `docs_verify.check_reference_targets` is the
|
||||||
|
complementary check, over the flat instructions and contracts that are still
|
||||||
|
allowed to link normally because nothing ever copies them elsewhere.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,8 +44,8 @@ from pathlib import Path
|
|||||||
import typer
|
import typer
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import config, markdown_code
|
||||||
from chemenu.commands import dist_cmd
|
from chemenu.commands import dist_cmd, docs_verify
|
||||||
from chemenu.commands._util import fail, rel_path, success
|
from chemenu.commands._util import fail, rel_path, success
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
@@ -313,6 +326,45 @@ def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[s
|
|||||||
return referenced
|
return referenced
|
||||||
|
|
||||||
|
|
||||||
|
def check_skill_reference_paths() -> list[str]:
|
||||||
|
"""No `SKILL.md` may carry a relative markdown link.
|
||||||
|
|
||||||
|
`sync` copies each skill directory verbatim into `.agents/skills/<name>/`
|
||||||
|
and `.claude/skills/<name>/` - a different depth than
|
||||||
|
`instructions/<name>/SKILL.md` itself, and without the sibling files a
|
||||||
|
relative link might expect. A markdown link that resolves correctly at
|
||||||
|
the source (`../session-setup.md`, `../../kb/CONTRACT.md`) resolves to a
|
||||||
|
different, usually nonexistent, file once copied: the number of `../`
|
||||||
|
segments that reaches a target from `instructions/<name>/` does not reach
|
||||||
|
the same target from `.claude/skills/<name>/`.
|
||||||
|
|
||||||
|
So a `SKILL.md` never writes an outbound reference as a relative markdown
|
||||||
|
link - it names the target as a repo-root-relative plain path instead
|
||||||
|
(`` `instructions/session-setup.md` ``, not
|
||||||
|
`[session-setup.md](../session-setup.md)`). See instructions/CONTRACT.md
|
||||||
|
§ "A skill's outbound reference is a plain path, not a link" for why that
|
||||||
|
form survives the copy unchanged.
|
||||||
|
`docs_verify.check_reference_targets` is the complementary check, over the
|
||||||
|
flat instructions and contracts that are still allowed to link normally
|
||||||
|
because nothing ever copies them elsewhere."""
|
||||||
|
issues: list[str] = []
|
||||||
|
for source in skill_dirs():
|
||||||
|
path = source / SKILL_FILE
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
masked = markdown_code.strip_code_spans(text)
|
||||||
|
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||||
|
for match in docs_verify.MARKDOWN_LINK_RE.finditer(masked_line):
|
||||||
|
target = match.group(1)
|
||||||
|
if docs_verify.is_external_or_anchor(target):
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
f"{rel_path(path)}:{line_number} carries a relative markdown link to "
|
||||||
|
f"`{target}` - `instructions sync` copies this file to a different depth, "
|
||||||
|
"so write the target as a plain repo-root-relative path instead"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@app.command("sync")
|
@app.command("sync")
|
||||||
def sync(
|
def sync(
|
||||||
force: bool = typer.Option(
|
force: bool = typer.Option(
|
||||||
@@ -351,7 +403,7 @@ def sync(
|
|||||||
|
|
||||||
@app.command("verify")
|
@app.command("verify")
|
||||||
def verify():
|
def verify():
|
||||||
"""Check instructions/ against its type, and every published copy against its source."""
|
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
|
||||||
sources = skill_dirs()
|
sources = skill_dirs()
|
||||||
instructions = instruction_files()
|
instructions = instruction_files()
|
||||||
if not sources and not instructions:
|
if not sources and not instructions:
|
||||||
@@ -398,7 +450,11 @@ def verify():
|
|||||||
if not frontmatter.get("description"):
|
if not frontmatter.get("description"):
|
||||||
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
||||||
|
|
||||||
# 3. Published copies match their sources. Missing *everywhere* is a clean
|
# 3. No skill carries a relative markdown link - see
|
||||||
|
# check_skill_reference_paths's own docstring for why the copy breaks it.
|
||||||
|
issues.extend(check_skill_reference_paths())
|
||||||
|
|
||||||
|
# 4. Published copies match their sources. Missing *everywhere* is a clean
|
||||||
# checkout, not a fault - say what to run instead of reporting drift.
|
# checkout, not a fault - say what to run instead of reporting drift.
|
||||||
expected = len(sources) * len(target_dirs())
|
expected = len(sources) * len(target_dirs())
|
||||||
missing = 0
|
missing = 0
|
||||||
@@ -419,7 +475,7 @@ def verify():
|
|||||||
if missing and not bootstrap_needed:
|
if missing and not bootstrap_needed:
|
||||||
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
||||||
|
|
||||||
# 4. An instruction nothing loads is inert. Nothing else would report it -
|
# 5. An instruction nothing loads is inert. Nothing else would report it -
|
||||||
# unless it is `manual: true`, which inverts the rule over a narrower
|
# unless it is `manual: true`, which inverts the rule over a narrower
|
||||||
# haystack: that instruction must not be linked from AGENTS.md or a
|
# haystack: that instruction must not be linked from AGENTS.md or a
|
||||||
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
||||||
@@ -441,7 +497,7 @@ def verify():
|
|||||||
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
# 6. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||||
# so nothing outside it may depend on something inside it staying
|
# so nothing outside it may depend on something inside it staying
|
||||||
# around in a distributed instance. See dev_only_forbidden_references's
|
# around in a distributed instance. See dev_only_forbidden_references's
|
||||||
# docstring for the dist:strip exemption.
|
# docstring for the dist:strip exemption.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from chemenu.search.service import (
|
|||||||
sort_hits,
|
sort_hits,
|
||||||
unreadable_pages,
|
unreadable_pages,
|
||||||
)
|
)
|
||||||
from chemenu.search.types import Predicate, SearchHit, SearchQuery
|
from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery, SearchResult
|
||||||
|
|
||||||
# Re-exported so `from chemenu.commands.search import run_search` keeps
|
# Re-exported so `from chemenu.commands.search import run_search` keeps
|
||||||
# resolving. The core lives in `chemenu/search/service.py`, which imports no
|
# resolving. The core lives in `chemenu/search/service.py`, which imports no
|
||||||
@@ -49,32 +49,76 @@ __all__ = [
|
|||||||
"search_command",
|
"search_command",
|
||||||
]
|
]
|
||||||
|
|
||||||
TITLE_WIDTH = 34
|
|
||||||
SUMMARY_WIDTH = 84
|
SUMMARY_WIDTH = 84
|
||||||
|
|
||||||
|
# One hit per line, ` | `-separated, in the order score, kind, title, path,
|
||||||
|
# summary. Three properties are load-bearing and should survive any edit here:
|
||||||
|
#
|
||||||
|
# 1. **The path is present.** It was not, and the instructions that drive this
|
||||||
|
# command tell an agent to "read only the pages the search points at" - which
|
||||||
|
# it could not do, because nothing here pointed anywhere. What a session did
|
||||||
|
# instead was run `grep -rl` over `kb/` for the filenames, a second search
|
||||||
|
# that can find no page this one missed (the backend *is* `rg` over `kb/`).
|
||||||
|
# 2. **Title and path are never truncated.** The title is the wiki's only
|
||||||
|
# identifier for a page (AGENTS.md invariant 2) and the argument `xref add`,
|
||||||
|
# `cite add` and `touch` all take; a title clipped to a column width is not
|
||||||
|
# one. The old fixed 34-char field clipped four of five hits in the report
|
||||||
|
# that prompted this. Only the summary is lossy, which is why it goes last.
|
||||||
|
# 3. **The separator is unambiguous.** A `|` cannot occur in a title - the
|
||||||
|
# wikilink syntax reserves it, so a page carrying one could not be linked at
|
||||||
|
# all - and a `|` in the summary is harmless, because the summary is the
|
||||||
|
# final field: split on " | " with maxsplit=4 and prose cannot shift a
|
||||||
|
# column.
|
||||||
|
#
|
||||||
|
# Column padding is gone with the widths: it aligned the table for an eye, and
|
||||||
|
# the reader here is an agent that pays for the spaces by the token.
|
||||||
|
SEPARATOR = " | "
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, width: int) -> str:
|
def _truncate(text: str, width: int) -> str:
|
||||||
text = " ".join(text.split())
|
text = " ".join(text.split())
|
||||||
return text if len(text) <= width else text[: width - 1] + "\u2026"
|
return text if len(text) <= width else text[: width - 1] + "\u2026"
|
||||||
|
|
||||||
|
|
||||||
def render_table(hits: list[SearchHit], show_matches: bool) -> str:
|
def _count_line(result: SearchResult) -> str:
|
||||||
if not hits:
|
"""The last line: how many hits, and whether that is all of them.
|
||||||
|
|
||||||
|
A bare `N result(s).` reads as the whole answer, so it is only used when it
|
||||||
|
is one. A capped search says what it capped, which is the number the caller
|
||||||
|
would otherwise have to run a second, unlimited search to learn.
|
||||||
|
"""
|
||||||
|
if not result.truncated:
|
||||||
|
return f"{len(result.hits)} result(s)."
|
||||||
|
return (
|
||||||
|
f"{len(result.hits)} of {result.total} result(s) - "
|
||||||
|
f"raise --limit (0 for all) or narrow the query."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_table(result: SearchResult, show_matches: bool) -> str:
|
||||||
|
if not result.hits:
|
||||||
return "No matches."
|
return "No matches."
|
||||||
lines = []
|
lines = []
|
||||||
for hit in hits:
|
for hit in result.hits:
|
||||||
kind = hit.kind or "?"
|
kind = hit.kind or "?"
|
||||||
if hit.subtype:
|
if hit.subtype:
|
||||||
kind = f"{kind}/{hit.subtype}"
|
kind = f"{kind}/{hit.subtype}"
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{hit.score:6.1f} {_truncate(hit.title, TITLE_WIDTH):<{TITLE_WIDTH}} "
|
SEPARATOR.join(
|
||||||
f"{kind:<18} {_truncate(hit.summary, SUMMARY_WIDTH)}"
|
(
|
||||||
|
f"{hit.score:.1f}",
|
||||||
|
kind,
|
||||||
|
hit.title,
|
||||||
|
hit.path,
|
||||||
|
_truncate(hit.summary, SUMMARY_WIDTH),
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if show_matches:
|
if show_matches:
|
||||||
for match in hit.matches:
|
for match in hit.matches:
|
||||||
lines.append(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}")
|
lines.append(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"{len(hits)} result(s).")
|
lines.append(_count_line(result))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +143,11 @@ def search_command(
|
|||||||
regex: bool = typer.Option(
|
regex: bool = typer.Option(
|
||||||
False, "--regex", help="Treat the query as a regex. Off by default: terms are literal."
|
False, "--regex", help="Treat the query as a regex. Off by default: terms are literal."
|
||||||
),
|
),
|
||||||
limit: int = typer.Option(20, "--limit", help="Maximum number of results. 0 for no limit."),
|
limit: int = typer.Option(
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
"--limit",
|
||||||
|
help="Maximum number of results. 0 for no limit. A capped result says so.",
|
||||||
|
),
|
||||||
sort: str = typer.Option(
|
sort: str = typer.Option(
|
||||||
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -modified."
|
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -modified."
|
||||||
),
|
),
|
||||||
@@ -146,7 +194,7 @@ def search_command(
|
|||||||
|
|
||||||
pages = load_pages_by_path()
|
pages = load_pages_by_path()
|
||||||
try:
|
try:
|
||||||
hits = run_search(query, pages, backends)
|
result = run_search(query, pages, backends)
|
||||||
except PredicateError as exc:
|
except PredicateError as exc:
|
||||||
fail(str(exc))
|
fail(str(exc))
|
||||||
except RipgrepMissing as exc:
|
except RipgrepMissing as exc:
|
||||||
@@ -162,8 +210,15 @@ def search_command(
|
|||||||
"query": text,
|
"query": text,
|
||||||
"predicates": [p.render() for p in predicates],
|
"predicates": [p.render() for p in predicates],
|
||||||
"backend": ",".join(b.name for b in backends),
|
"backend": ",".join(b.name for b in backends),
|
||||||
"count": len(hits),
|
# `count` keeps its meaning - how many results are in this payload -
|
||||||
"results": [hit.as_dict() for hit in hits],
|
# so a consumer written against the old shape reads the same number
|
||||||
|
# it always did. `total`/`truncated`/`limit` are what it could not
|
||||||
|
# ask before.
|
||||||
|
"count": len(result.hits),
|
||||||
|
"total": result.total,
|
||||||
|
"truncated": result.truncated,
|
||||||
|
"limit": result.limit,
|
||||||
|
"results": [hit.as_dict() for hit in result.hits],
|
||||||
# Always present, usually empty. A caller that has to look for the
|
# Always present, usually empty. A caller that has to look for the
|
||||||
# key to learn whether it should worry will not look.
|
# key to learn whether it should worry will not look.
|
||||||
"unreadable": unreadable,
|
"unreadable": unreadable,
|
||||||
@@ -171,7 +226,7 @@ def search_command(
|
|||||||
typer.echo(json.dumps(payload, indent=2))
|
typer.echo(json.dumps(payload, indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
typer.echo(render_table(hits, show_matches))
|
typer.echo(render_table(result, show_matches))
|
||||||
for entry in unreadable:
|
for entry in unreadable:
|
||||||
typer.echo(
|
typer.echo(
|
||||||
f"WARN unreadable frontmatter: {entry['path']} ({entry['reason']}) - "
|
f"WARN unreadable frontmatter: {entry['path']} ({entry['reason']}) - "
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import json
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from chemenu import toc
|
||||||
from chemenu.commands._util import fail
|
from chemenu.commands._util import fail
|
||||||
from chemenu.types_core import UnknownType, describe_type, list_types
|
from chemenu.types_core import UnknownType, describe_type, list_types
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ def describe_type_command(
|
|||||||
"""Print one type's full contract: frontmatter fields (required/optional,
|
"""Print one type's full contract: frontmatter fields (required/optional,
|
||||||
with enums where declared), its subtype field if any, and its authoring
|
with enums where declared), its subtype field if any, and its authoring
|
||||||
body - the same information an LLM would otherwise gather by reading the
|
body - the same information an LLM would otherwise gather by reading the
|
||||||
raw type-spec and `.schema.yaml` files directly."""
|
raw type-spec and `.schema.yaml` files directly. Where the type-spec
|
||||||
|
declares `guidance:`, that stack-owned file's prose is composed in ahead
|
||||||
|
of the type-spec's own body, so a `root: kb` type's contract still reads
|
||||||
|
as one answer even though it lives in two files (Gitea #104)."""
|
||||||
try:
|
try:
|
||||||
described = describe_type(name)
|
described = describe_type(name)
|
||||||
except UnknownType as exc:
|
except UnknownType as exc:
|
||||||
@@ -88,4 +92,13 @@ def describe_type_command(
|
|||||||
typer.echo("")
|
typer.echo("")
|
||||||
|
|
||||||
typer.echo("## Authoring guidance")
|
typer.echo("## Authoring guidance")
|
||||||
typer.echo(described["body"])
|
# A type-spec (and its guidance file) over 100 lines carries a generated
|
||||||
|
# table-of-contents region (`chemenu/toc.py`), which serves whoever opens
|
||||||
|
# the file directly. Here it would be noise: this command already hands
|
||||||
|
# over the whole body, so there is nothing left for a navigation aid to
|
||||||
|
# navigate - only markers and a list of headings the reader is about to
|
||||||
|
# see anyway.
|
||||||
|
if described["guidance"]:
|
||||||
|
typer.echo(toc.strip_region(described["guidance"]))
|
||||||
|
typer.echo("")
|
||||||
|
typer.echo(toc.strip_region(described["body"]))
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -204,7 +305,7 @@ def bump_command(
|
|||||||
breaking: Optional[str] = typer.Option(
|
breaking: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--breaking",
|
"--breaking",
|
||||||
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
|
help="What stops working (recorded in CHANGES.md). Required on the bump that first escalates to a boundary crossing, optional on a later bump of the same crossing candidate - where it joins the reasons already recorded rather than replacing them. Refused on a bump that crosses nothing",
|
||||||
),
|
),
|
||||||
no_migration: Optional[str] = typer.Option(
|
no_migration: Optional[str] = typer.Option(
|
||||||
None,
|
None,
|
||||||
@@ -241,9 +342,16 @@ def bump_command(
|
|||||||
version is not a drop-in replacement, whether or not any content moves -
|
version is not a drop-in replacement, whether or not any content moves -
|
||||||
requires `--breaking "<what stops working>"`, and on top of that either a
|
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||||
migration document for the new base or `--no-migration "<reason>"`. Both
|
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||||
lines are written into the entry once and then persist across every later
|
are written into the entry and persist across every later bump at the same
|
||||||
bump at the same stage: a follow-up bump need not repeat them, and passing
|
stage, so a follow-up bump need not repeat them, and passing either on a
|
||||||
either on a bump that crosses nothing at all is refused.
|
bump that crosses nothing at all is refused.
|
||||||
|
|
||||||
|
A candidate can cross the boundary more than once, and the two flags part
|
||||||
|
ways there. A further `--breaking` **joins** the reasons already recorded -
|
||||||
|
each crossing is its own thing an operator has to act on, and replacing
|
||||||
|
meant the second one silently deleted the first. A further
|
||||||
|
`--no-migration` **replaces**: whether content has to change is one
|
||||||
|
question about the candidate as a whole, not one per crossing.
|
||||||
|
|
||||||
A later bump of the same candidate that finds out `--no-migration` was
|
A later bump of the same candidate that finds out `--no-migration` was
|
||||||
wrong after all retracts it with `--migration-required` - write the
|
wrong after all retracts it with `--migration-required` - write the
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from mcp.server.mcpserver.exceptions import ToolError
|
|||||||
from chemenu import config, upload
|
from chemenu import config, upload
|
||||||
from chemenu.api import Corpus
|
from chemenu.api import Corpus
|
||||||
from chemenu.errors import ChemenuError
|
from chemenu.errors import ChemenuError
|
||||||
|
from chemenu.search.types import DEFAULT_LIMIT
|
||||||
from chemenu.telemetry import policy
|
from chemenu.telemetry import policy
|
||||||
|
|
||||||
SERVER_NAME = "chemenu"
|
SERVER_NAME = "chemenu"
|
||||||
@@ -159,13 +160,15 @@ def build_server(
|
|||||||
"title, path, kind and summary per hit, so a result can be judged "
|
"title, path, kind and summary per hit, so a result can be judged "
|
||||||
"without fetching the page. Prefer this over listing files: the "
|
"without fetching the page. Prefer this over listing files: the "
|
||||||
"answer is a few hundred tokens instead of a whole index. "
|
"answer is a few hundred tokens instead of a whole index. "
|
||||||
|
"'count' is how many hits came back and 'total' how many matched; "
|
||||||
|
"when 'truncated' is true, raise 'limit' (0 for all) to see the rest."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def search(
|
def search(
|
||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
predicates: list[str] | None = None,
|
predicates: list[str] | None = None,
|
||||||
regex: bool = False,
|
regex: bool = False,
|
||||||
limit: int = 20,
|
limit: int = DEFAULT_LIMIT,
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Search the wiki.
|
"""Search the wiki.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from chemenu.search import filters
|
|||||||
from chemenu.search.base import page_key
|
from chemenu.search.base import page_key
|
||||||
from chemenu.search.fuse import reciprocal_rank_fusion
|
from chemenu.search.fuse import reciprocal_rank_fusion
|
||||||
from chemenu.search.ripgrep import build_hit
|
from chemenu.search.ripgrep import build_hit
|
||||||
from chemenu.search.types import SearchHit, SearchQuery
|
from chemenu.search.types import SearchHit, SearchQuery, SearchResult
|
||||||
|
|
||||||
|
|
||||||
def load_pages_by_path(kb_dir: Path | None = None, root: Path | None = None) -> dict[str, Page]:
|
def load_pages_by_path(kb_dir: Path | None = None, root: Path | None = None) -> dict[str, Page]:
|
||||||
@@ -92,8 +92,13 @@ def run_search(
|
|||||||
pages: dict[str, Page],
|
pages: dict[str, Page],
|
||||||
backends: list,
|
backends: list,
|
||||||
kb_dir: Path | None = None,
|
kb_dir: Path | None = None,
|
||||||
) -> list[SearchHit]:
|
) -> SearchResult:
|
||||||
"""Answer a query. Pure: no I/O beyond whatever a backend does."""
|
"""Answer a query. Pure: no I/O beyond whatever a backend does.
|
||||||
|
|
||||||
|
Returns the truncated hits *and* the number there were before the limit,
|
||||||
|
because the caller cannot recover the second from the first - see
|
||||||
|
`SearchResult`.
|
||||||
|
"""
|
||||||
filters.validate_fields(query.predicates, pages)
|
filters.validate_fields(query.predicates, pages)
|
||||||
|
|
||||||
if query.text:
|
if query.text:
|
||||||
@@ -110,4 +115,9 @@ def run_search(
|
|||||||
hits.sort(key=lambda h: h.title.lower())
|
hits.sort(key=lambda h: h.title.lower())
|
||||||
|
|
||||||
hits = sort_hits(hits, query.sort)
|
hits = sort_hits(hits, query.sort)
|
||||||
return hits[: query.limit] if query.limit else hits
|
total = len(hits)
|
||||||
|
return SearchResult(
|
||||||
|
hits=hits[: query.limit] if query.limit else hits,
|
||||||
|
total=total,
|
||||||
|
limit=query.limit,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# How many hits a caller gets when it asks for no particular number. Defined
|
||||||
|
# once, here, because three adapters offer the same knob - the CLI's `--limit`,
|
||||||
|
# `api.search(limit=...)` and the MCP `search` tool - and three literals is how
|
||||||
|
# they start disagreeing about what "the default search" returns.
|
||||||
|
#
|
||||||
|
# 50 rather than a smaller number because the queries that actually hit the cap
|
||||||
|
# are the *structured* sweeps (`--field '!sources'`), which are ordered
|
||||||
|
# alphabetically rather than by relevance: truncating those throws away an
|
||||||
|
# arbitrary slice of the answer rather than its weakest tail. A capped result
|
||||||
|
# is only safe at all because it now says so - see `SearchResult.truncated`.
|
||||||
|
DEFAULT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Predicate:
|
class Predicate:
|
||||||
@@ -30,7 +42,7 @@ class SearchQuery:
|
|||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
predicates: tuple[Predicate, ...] = ()
|
predicates: tuple[Predicate, ...] = ()
|
||||||
regex: bool = False
|
regex: bool = False
|
||||||
limit: int = 20
|
limit: int = DEFAULT_LIMIT
|
||||||
sort: Optional[str] = None
|
sort: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -77,3 +89,34 @@ class SearchHit:
|
|||||||
"backend": self.backend,
|
"backend": self.backend,
|
||||||
"matches": [m.as_dict() for m in self.matches],
|
"matches": [m.as_dict() for m in self.matches],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchResult:
|
||||||
|
"""The hits a caller gets back, plus how many there were before the limit.
|
||||||
|
|
||||||
|
`run_search` used to return the truncated list alone, which made the
|
||||||
|
truncation impossible to report: every adapter counted `len(hits)` and
|
||||||
|
printed it as the answer, so `20 result(s).` on a query matching 182 pages
|
||||||
|
was indistinguishable from a query that really matched twenty. That is a
|
||||||
|
completeness claim none of them were in a position to make, and the only
|
||||||
|
way to find out was to ask again with `--limit 0` - a second full search to
|
||||||
|
learn a number the first one already knew.
|
||||||
|
|
||||||
|
So the total travels with the hits. Nothing here decides how to say it;
|
||||||
|
that belongs to each adapter (`render_table`, the `--json` payload,
|
||||||
|
`api.search`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
hits: list[SearchHit]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def truncated(self) -> bool:
|
||||||
|
"""Whether the limit actually cut something off.
|
||||||
|
|
||||||
|
`limit=0` means "no limit", so it never truncates however large the
|
||||||
|
corpus is.
|
||||||
|
"""
|
||||||
|
return bool(self.limit) and self.total > len(self.hits)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ def test_a_corpus_can_be_named_and_is_the_one_that_answers(foreign_corpus):
|
|||||||
assert result["results"][0]["path"] == "kb/entities/Peregrine.md"
|
assert result["results"][0]["path"] == "kb/entities/Peregrine.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_capped_answer_says_how_much_it_left_out(foreign_corpus):
|
||||||
|
"""`count` keeps meaning "what is in this payload", so a consumer written
|
||||||
|
against the old shape reads the number it always did; `total` is what it
|
||||||
|
could not ask before, and without it a truncated answer is indistinguishable
|
||||||
|
from a complete one."""
|
||||||
|
corpus = Corpus(foreign_corpus)
|
||||||
|
|
||||||
|
whole = corpus.search("Peregrine")
|
||||||
|
assert (whole["count"], whole["total"], whole["truncated"]) == (1, 1, False)
|
||||||
|
assert whole["count"] == len(whole["results"])
|
||||||
|
|
||||||
|
capped = corpus.search("Peregrine", limit=0)
|
||||||
|
assert capped["truncated"] is False, "limit 0 means no limit, so it caps nothing"
|
||||||
|
|
||||||
|
|
||||||
def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_corpus):
|
def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_corpus):
|
||||||
"""The acceptance criterion, asserted rather than argued.
|
"""The acceptance criterion, asserted rather than argued.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -81,7 +81,16 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
# the instance's; `instruction` writes into the repo and is the stack's.
|
# the instance's; `instruction` writes into the repo and is the stack's.
|
||||||
(types_dir / "entity.md").write_text(
|
(types_dir / "entity.md").write_text(
|
||||||
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
|
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
|
||||||
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
|
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
|
||||||
|
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# entity's guidance: stack-owned even though entity.md itself is
|
||||||
|
# instance-owned - the file `_owned_type_stem` must not mistake for
|
||||||
|
# entity's own `.md`/`.schema.yaml` despite sharing its stem (Gitea #104).
|
||||||
|
(types_dir / "entity.guidance.md").write_text(
|
||||||
|
"---\ntype: types/type-guidance.md\nname: entity\ndescription: When to use entity.\n"
|
||||||
|
"---\n\n# Entity Guidance\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
(types_dir / "instruction.md").write_text(
|
(types_dir / "instruction.md").write_text(
|
||||||
@@ -394,6 +403,20 @@ def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypa
|
|||||||
assert "types/instruction.md.template" not in plan
|
assert "types/instruction.md.template" not in plan
|
||||||
|
|
||||||
|
|
||||||
|
def test_guidance_file_ships_verbatim_beside_a_templated_type_spec(repo, monkeypatch):
|
||||||
|
"""Gitea #104: `types/entity.guidance.md` is stack-owned even though
|
||||||
|
`types/entity.md` (same stem) is instance-owned - it must cross like
|
||||||
|
`types/instruction.md` above, never re-keyed as though it were the
|
||||||
|
type-spec's own `.template`."""
|
||||||
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
|
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
|
||||||
|
plan = dist_cmd.build_plan()
|
||||||
|
|
||||||
|
assert "types/entity.guidance.md" in plan
|
||||||
|
assert "types/entity.guidance.md.template" not in plan
|
||||||
|
|
||||||
|
|
||||||
def test_plan_creates_empty_raw_and_incoming_not_real_content(repo):
|
def test_plan_creates_empty_raw_and_incoming_not_real_content(repo):
|
||||||
"""Both flat since Gitea #67: `raw/` addresses a file by its accept date,
|
"""Both flat since Gitea #67: `raw/` addresses a file by its accept date,
|
||||||
never by a hand-picked type, so there is nothing left to seed per type."""
|
never by a hand-picked type, so there is nothing left to seed per type."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -223,6 +375,56 @@ def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_the
|
|||||||
assert not (instance / preserved).exists()
|
assert not (instance / preserved).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# --- a root:kb type-spec's guidance half upgrades like any other file -------
|
||||||
|
#
|
||||||
|
# Gitea #104: before the split, `types/<name>.md` carried both the
|
||||||
|
# instance-owned template and the stack-owned authoring prose in one file, so
|
||||||
|
# an instance that had adopted it (renamed the `.template`) never received a
|
||||||
|
# prose improvement again - `dist upgrade` only ever wrote the `.template`
|
||||||
|
# beside the adopted file, never the file itself. Splitting the prose into a
|
||||||
|
# sibling `.guidance.md` that ships verbatim (never `.template`-sourced) means
|
||||||
|
# it upgrades through the ordinary unchanged/new path below, even though the
|
||||||
|
# type-spec it documents is never in the stamp at all and therefore never
|
||||||
|
# touched.
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_writes_improved_guidance_prose_over_an_adopted_type_spec(instance, tmp_path):
|
||||||
|
(instance / "types").mkdir()
|
||||||
|
(instance / "types" / "entity.md").write_text(
|
||||||
|
# Adopted from `types/entity.md.template` at some earlier setup - this
|
||||||
|
# file was never part of any release stamp and `dist upgrade` must
|
||||||
|
# never touch it.
|
||||||
|
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
|
||||||
|
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
|
||||||
|
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(instance / "types" / "entity.guidance.md").write_text("old guidance\n", encoding="utf-8")
|
||||||
|
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
|
||||||
|
stamp["files"]["types/entity.guidance.md"] = _digest("old guidance\n")
|
||||||
|
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(stamp), encoding="utf-8")
|
||||||
|
|
||||||
|
release = _release(
|
||||||
|
tmp_path, "release", "1.1.0",
|
||||||
|
{
|
||||||
|
"AGENTS.md": "core\n",
|
||||||
|
"tools/wikitool": "#!/bin/sh\n",
|
||||||
|
"types/entity.guidance.md": "improved guidance\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
dist_cmd.run_upgrade(release)
|
||||||
|
|
||||||
|
assert (instance / "types" / "entity.guidance.md").read_text(encoding="utf-8") == (
|
||||||
|
"improved guidance\n"
|
||||||
|
)
|
||||||
|
# The adopted type-spec itself was never in either stamp, so it is
|
||||||
|
# completely untouched by the upgrade.
|
||||||
|
assert "guidance: types/entity.guidance.md" in (
|
||||||
|
instance / "types" / "entity.md"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# --- migration chain: reported, never run -----------------------------------
|
# --- migration chain: reported, never run -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import config, type_resolver
|
||||||
from chemenu.commands import dist_cmd, docs_verify
|
from chemenu.commands import dist_cmd, docs_verify
|
||||||
|
from chemenu.type_resolver import TypeResolver
|
||||||
|
|
||||||
|
|
||||||
def test_every_registered_command_is_documented():
|
def test_every_registered_command_is_documented():
|
||||||
@@ -289,6 +290,51 @@ def test_an_absent_listed_doc_is_skipped_not_reported(tmp_path, monkeypatch):
|
|||||||
assert docs_verify.check_readmes_have_no_command_table() == []
|
assert docs_verify.check_readmes_have_no_command_table() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_this_repos_type_specs_validate_against_their_own_schema():
|
||||||
|
"""Regression guard for Gitea #105: types/type-spec.schema.yaml declared
|
||||||
|
`additionalProperties: false` while real type-specs already carried
|
||||||
|
`root:` and `capture_fields:`, and nothing ever validated a type-spec's
|
||||||
|
own frontmatter against it - so the mismatch shipped silently."""
|
||||||
|
assert docs_verify.check_type_spec_frontmatter() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_type_spec_field_is_reported(tmp_path, monkeypatch):
|
||||||
|
"""Once the schema is enforced, a type-spec frontmatter field its own
|
||||||
|
schema does not know about must fail loudly rather than validating
|
||||||
|
silently - the other direction of the #105 regression guard above."""
|
||||||
|
types_dir = tmp_path / "types"
|
||||||
|
types_dir.mkdir()
|
||||||
|
(types_dir / "type-spec.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"type: types/type-spec.md\n"
|
||||||
|
"name: type-spec\n"
|
||||||
|
"description: Authoring and validation contract for type specs\n"
|
||||||
|
"schema: types/type-spec.schema.yaml\n"
|
||||||
|
"---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(types_dir / "type-spec.schema.yaml").write_text(
|
||||||
|
(config.ROOT / "types" / "type-spec.schema.yaml").read_text(encoding="utf-8"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(types_dir / "widget.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"type: types/type-spec.md\n"
|
||||||
|
"name: widget\n"
|
||||||
|
"description: A type-spec with a field its own schema does not know\n"
|
||||||
|
"schema: null\n"
|
||||||
|
"not_a_real_field: true\n"
|
||||||
|
"---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
|
||||||
|
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
|
||||||
|
|
||||||
|
issues = docs_verify.check_type_spec_frontmatter()
|
||||||
|
assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_type_blocks_are_absent():
|
def test_legacy_type_blocks_are_absent():
|
||||||
assert docs_verify.check_legacy_type_blocks() == []
|
assert docs_verify.check_legacy_type_blocks() == []
|
||||||
|
|
||||||
@@ -525,6 +571,33 @@ def test_every_reference_files_toc_is_current():
|
|||||||
assert docs_verify.check_toc_regions() == []
|
assert docs_verify.check_toc_regions() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_shipped_template_over_the_threshold_without_a_region_is_reported(tmp_path, monkeypatch):
|
||||||
|
"""Regression guard for the defect this scope extension fixes: the template
|
||||||
|
an instance adopts was maintained by nothing and checked by nothing, so
|
||||||
|
`kb/CONVENTIONS.md.template` grew past the threshold carrying no region -
|
||||||
|
and every instance that adopted it got a `kb/CONVENTIONS.md` that fails
|
||||||
|
`docs verify` at the end of `setup-instance.md`, the one command that step
|
||||||
|
ends with. Before the template entered `toc.target_files()`, this check
|
||||||
|
returned nothing here."""
|
||||||
|
from chemenu import config, toc as toc_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
long_body = "# Conventions\n\nIntro.\n" + "".join(
|
||||||
|
f"\n## Section {i}\n\n" + "Body line.\n" * 12 for i in range(8)
|
||||||
|
)
|
||||||
|
assert toc_mod.needs_toc(long_body)
|
||||||
|
# The adopted file is current; only the template it was adopted from is not.
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md").write_text(toc_mod.upsert(long_body), encoding="utf-8")
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text(long_body, encoding="utf-8")
|
||||||
|
|
||||||
|
issues = docs_verify.check_toc_regions()
|
||||||
|
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "kb/CONVENTIONS.md.template" in issues[0]
|
||||||
|
assert "docs toc --apply" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
def test_no_shipped_document_cites_an_issue():
|
def test_no_shipped_document_cites_an_issue():
|
||||||
"""Forward direction, against the real tree: a `#42` in a file `dist export`
|
"""Forward direction, against the real tree: a `#42` in a file `dist export`
|
||||||
ships points at a board only the origin repo has, and the reader of a
|
ships points at a board only the origin repo has, and the reader of a
|
||||||
@@ -533,6 +606,100 @@ def test_no_shipped_document_cites_an_issue():
|
|||||||
assert docs_verify.check_no_issue_references() == []
|
assert docs_verify.check_no_issue_references() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dead_relative_link_is_reported(tmp_path, monkeypatch):
|
||||||
|
"""Regression guard for the bug this check exists to catch: a `../` count
|
||||||
|
wrong for the file's own depth is invisible to every other check - the
|
||||||
|
name it links to is real, the text renders, and nothing resolves the
|
||||||
|
target to notice it lands nowhere."""
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [tools/CONTRACT.md](../tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
issues = docs_verify.check_reference_targets()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "example.md:1" in issues[0]
|
||||||
|
assert "../tools/CONTRACT.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_resolving_relative_link_is_not_reported(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "tools").mkdir()
|
||||||
|
(tmp_path / "tools" / "CONTRACT.md").write_text("# Contract\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [tools/CONTRACT.md](tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absolute_url_is_not_resolved_as_a_path(tmp_path, monkeypatch):
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [Anthropic](https://www.anthropic.com).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_section_anchor_is_stripped_before_resolving(tmp_path, monkeypatch):
|
||||||
|
"""CommonMark anchors are not filesystem paths - only the path part of
|
||||||
|
`target#anchor` is checked for existence."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md").write_text("## Tone\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [kb/CONVENTIONS.md § Tone](kb/CONVENTIONS.md#tone).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(tmp_path, monkeypatch):
|
||||||
|
"""A passage documenting bad link syntax must not be mistaken for a real
|
||||||
|
reference - code fences are masked before scanning, mirroring `toc.py`."""
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text(
|
||||||
|
"Do not write it like this:\n\n```markdown\n[gates.md](../nonexistent.md)\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_target_shipped_only_as_a_template_is_not_dead(tmp_path, monkeypatch):
|
||||||
|
"""Regression guard for a defect this check shipped with. A fresh
|
||||||
|
`dist export` carries `kb/CONVENTIONS.md.template`, not
|
||||||
|
`kb/CONVENTIONS.md` - the instance adopts it by renaming, during
|
||||||
|
`setup-instance.md`'s personalization step. `kb/CONTRACT.md` and three
|
||||||
|
flat instructions link to the adopted name, correctly. Before this
|
||||||
|
exemption the check reported 13 dead links on a just-exported tree, for
|
||||||
|
doing exactly what a fresh export is supposed to do."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text("# Conventions\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||||
|
fake.write_text("What this instance decided: [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_target_with_neither_the_file_nor_a_template_is_still_dead(tmp_path, monkeypatch):
|
||||||
|
"""The exemption is narrow: it covers a file the stack ships as a
|
||||||
|
template, not any missing target."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||||
|
fake.write_text("See [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
issues = docs_verify.check_reference_targets()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "CONVENTIONS.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_reference_files_link_targets_resolve():
|
||||||
|
"""Forward direction, against the real tree: every relative link in a file
|
||||||
|
`toc.target_files()` covers must resolve - this is what a `../` count
|
||||||
|
wrong for the file's own depth is caught by."""
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_raises_when_a_reference_target_is_dead(monkeypatch):
|
||||||
|
monkeypatch.setattr(docs_verify, "check_reference_targets", lambda: ["dangling"])
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
docs_verify.verify()
|
||||||
|
|
||||||
|
|
||||||
def test_a_cited_issue_number_is_reported(monkeypatch):
|
def test_a_cited_issue_number_is_reported(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
docs_verify,
|
docs_verify,
|
||||||
|
|||||||
@@ -341,6 +341,123 @@ def test_a_self_mention_does_not_count_as_a_reference(layer):
|
|||||||
assert "gates.md" not in instructions_cmd.referenced_names()
|
assert "gates.md" not in instructions_cmd.referenced_names()
|
||||||
|
|
||||||
|
|
||||||
|
# --- skill reference paths ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fixture_skill_carries_no_relative_link(layer):
|
||||||
|
"""Forward direction: the fixture's own `wiki-demo/SKILL.md` mentions
|
||||||
|
`gates.md` as a bare word (`referenced_names()` relies on exactly that
|
||||||
|
substring match), never as a markdown link - so it must not trip the ban."""
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_relative_markdown_link_in_a_skill_is_reported(layer):
|
||||||
|
"""`sync` copies `wiki-demo/SKILL.md` to `.claude/skills/wiki-demo/SKILL.md`
|
||||||
|
and `.agents/skills/wiki-demo/SKILL.md` - a different depth than the
|
||||||
|
source - so a link written `../gates.md` here would resolve to a
|
||||||
|
different, usually nonexistent, file once published."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
issues = instructions_cmd.check_skill_reference_paths()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "wiki-demo" in issues[0] and "../gates.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_root_relative_path_is_not_a_reported_link(layer):
|
||||||
|
"""The decided fix: a `SKILL.md` names its target as a repo-root-relative
|
||||||
|
plain path, not a link - even one written correctly with brackets and
|
||||||
|
parens. `instructions/gates.md` on its own, with no `[...]`, must pass."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee `instructions/gates.md`.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absolute_url_in_a_skill_is_not_reported(layer):
|
||||||
|
"""The ban is about relative paths breaking under the copy - an external
|
||||||
|
link is not affected by where the file sits, so it stays a normal link."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [the spec](https://example.com/spec).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pure_anchor_link_in_a_skill_is_not_reported(layer):
|
||||||
|
"""A same-page `#anchor` link is not a filesystem reference and does not
|
||||||
|
move when the file is copied."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\n## Steps\n\nSee [Steps](#steps) above.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(layer):
|
||||||
|
"""A skill documenting the banned syntax as an example (rather than using
|
||||||
|
it) must not trip its own ban - code fences are masked before scanning,
|
||||||
|
the same way `toc.py` masks them before hunting for headings."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nDo not write it like this:\n\n"
|
||||||
|
"```markdown\n[gates.md](../gates.md)\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_skill_with_a_relative_markdown_link(layer):
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
instructions_cmd.sync(force=False)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
instructions_cmd.verify()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_path_resolves_the_same_regardless_of_which_copy_reads_it(layer):
|
||||||
|
"""The property the whole fix rests on. A repo-root-relative plain path
|
||||||
|
(`instructions/gates.md`) names the same file whether it is read from the
|
||||||
|
source (`instructions/wiki-demo/SKILL.md`) or from either published copy
|
||||||
|
(`.claude/skills/wiki-demo/SKILL.md`, `.agents/skills/wiki-demo/SKILL.md`),
|
||||||
|
because it is resolved against the instance root, never against the
|
||||||
|
reading file's own directory.
|
||||||
|
|
||||||
|
A relative link has no such property, which is the defect this whole
|
||||||
|
check exists to prevent: the same `../gates.md` means
|
||||||
|
`instructions/gates.md` from the source but a nonexistent
|
||||||
|
`.claude/skills/gates.md` / `.agents/skills/gates.md` from either
|
||||||
|
published copy - one directory short of the real file, because `sync`
|
||||||
|
copies the skill one level shallower than `instructions/<name>/` sits."""
|
||||||
|
instructions_cmd.sync(force=False)
|
||||||
|
target = layer / "instructions" / "gates.md"
|
||||||
|
assert target.is_file()
|
||||||
|
|
||||||
|
source_dir = layer / "instructions" / "wiki-demo"
|
||||||
|
claude_copy_dir = _skill_copy(layer, ".claude")
|
||||||
|
agents_copy_dir = _skill_copy(layer, ".agents")
|
||||||
|
|
||||||
|
# The plain path resolves against the instance root, regardless of which
|
||||||
|
# of the three directories above is doing the reading.
|
||||||
|
for _ in (source_dir, claude_copy_dir, agents_copy_dir):
|
||||||
|
assert (layer / "instructions" / "gates.md").is_file()
|
||||||
|
|
||||||
|
# The equivalent relative link would not: correct from the source, wrong
|
||||||
|
# from both copies.
|
||||||
|
assert (source_dir / ".." / "gates.md").resolve() == target.resolve()
|
||||||
|
assert not (claude_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||||
|
assert not (agents_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||||
|
|
||||||
|
|
||||||
# --- instructions/dev/ boundary ----------------------------------------------
|
# --- instructions/dev/ boundary ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ def test_the_four_tools_are_there_and_nothing_that_writes(corpus):
|
|||||||
assert names.isdisjoint({"new", "touch", "xref", "cite", "publish", "migrate", "rm"})
|
assert names.isdisjoint({"new", "touch", "xref", "cite", "publish", "migrate", "rm"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_three_adapters_offer_the_same_default_limit(corpus):
|
||||||
|
"""The CLI's `--limit`, `api.search(limit=...)` and this tool are three
|
||||||
|
knobs on one search, and three literals is how they start disagreeing about
|
||||||
|
what "the default search" returns. They read one constant."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from chemenu.api import Corpus
|
||||||
|
from chemenu.commands.search import search_command
|
||||||
|
from chemenu.search.types import DEFAULT_LIMIT
|
||||||
|
|
||||||
|
server = build_server(corpus, check_trace=False)
|
||||||
|
tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "search")
|
||||||
|
|
||||||
|
assert tool.input_schema["properties"]["limit"]["default"] == DEFAULT_LIMIT
|
||||||
|
assert inspect.signature(Corpus.search).parameters["limit"].default == DEFAULT_LIMIT
|
||||||
|
assert inspect.signature(search_command).parameters["limit"].default.default == DEFAULT_LIMIT
|
||||||
|
|
||||||
|
|
||||||
def test_no_tool_writes_anything_into_the_corpus_or_git(corpus):
|
def test_no_tool_writes_anything_into_the_corpus_or_git(corpus):
|
||||||
server = build_server(corpus, check_trace=False)
|
server = build_server(corpus, check_trace=False)
|
||||||
before = _tree(corpus)
|
before = _tree(corpus)
|
||||||
@@ -176,7 +194,8 @@ def test_the_wire_format_is_the_clis_json_form(corpus):
|
|||||||
# `generated` is the CLI's date stamp and `commit`/`as_of` are the server's
|
# `generated` is the CLI's date stamp and `commit`/`as_of` are the server's
|
||||||
# revision stamp - two answers to "when", neither of them a finding. What
|
# revision stamp - two answers to "when", neither of them a finding. What
|
||||||
# has to match is everything that describes the *corpus*.
|
# has to match is everything that describes the *corpus*.
|
||||||
shared = ("query", "predicates", "backend", "count", "results", "unreadable")
|
shared = ("query", "predicates", "backend", "count", "total", "truncated", "limit",
|
||||||
|
"results", "unreadable")
|
||||||
assert {key: served[key] for key in shared} == {key: from_cli[key] for key in shared}
|
assert {key: served[key] for key in shared} == {key: from_cli[key] for key in shared}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ from chemenu.search.filters import PredicateError, parse_predicate
|
|||||||
from chemenu.search.fuse import reciprocal_rank_fusion
|
from chemenu.search.fuse import reciprocal_rank_fusion
|
||||||
from chemenu.search.registry import UnknownBackend, resolve
|
from chemenu.search.registry import UnknownBackend, resolve
|
||||||
from chemenu.search.ripgrep import RipgrepBackend, build_argv
|
from chemenu.search.ripgrep import RipgrepBackend, build_argv
|
||||||
from chemenu.search.types import Match, Predicate, SearchHit, SearchQuery
|
from chemenu.search.types import (
|
||||||
|
DEFAULT_LIMIT,
|
||||||
|
Match,
|
||||||
|
Predicate,
|
||||||
|
SearchHit,
|
||||||
|
SearchQuery,
|
||||||
|
SearchResult,
|
||||||
|
)
|
||||||
from chemenu.frontmatter_io import write_page
|
from chemenu.frontmatter_io import write_page
|
||||||
|
|
||||||
|
|
||||||
@@ -32,8 +39,12 @@ def backend(kb_dir: Path, tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def search(kb_dir: Path, pages):
|
def search_result(kb_dir: Path, pages):
|
||||||
"""Run a query against the fixture kb rather than the real one."""
|
"""Run a query against the fixture kb and return the whole `SearchResult`.
|
||||||
|
|
||||||
|
For the tests that care about the limit and the total; most only want the
|
||||||
|
hits, and use `search` below.
|
||||||
|
"""
|
||||||
|
|
||||||
def _search(query: SearchQuery, backends=()):
|
def _search(query: SearchQuery, backends=()):
|
||||||
return run_search(query, pages, list(backends), kb_dir)
|
return run_search(query, pages, list(backends), kb_dir)
|
||||||
@@ -41,6 +52,16 @@ def search(kb_dir: Path, pages):
|
|||||||
return _search
|
return _search
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def search(search_result):
|
||||||
|
"""The hits alone, for tests whose subject is ranking or filtering."""
|
||||||
|
|
||||||
|
def _search(query: SearchQuery, backends=()):
|
||||||
|
return search_result(query, backends).hits
|
||||||
|
|
||||||
|
return _search
|
||||||
|
|
||||||
|
|
||||||
def _titles(hits):
|
def _titles(hits):
|
||||||
return [hit.title for hit in hits]
|
return [hit.title for hit in hits]
|
||||||
|
|
||||||
@@ -211,6 +232,20 @@ def test_limit_and_sort(search):
|
|||||||
assert len(search(_q("kind=entity", limit=2))) == 2
|
assert len(search(_q("kind=entity", limit=2))) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_total_survives_the_limit_so_no_second_search_is_needed(search_result):
|
||||||
|
"""The caller cannot recover the total from a truncated list, and asking
|
||||||
|
again with `--limit 0` is a second full search to learn a number the first
|
||||||
|
one already had."""
|
||||||
|
capped = search_result(_q("kind=entity", limit=2))
|
||||||
|
|
||||||
|
assert len(capped.hits) == 2
|
||||||
|
assert capped.total == 3
|
||||||
|
assert capped.truncated is True
|
||||||
|
|
||||||
|
whole = search_result(_q("kind=entity"))
|
||||||
|
assert (whole.total, whole.truncated) == (3, False)
|
||||||
|
|
||||||
|
|
||||||
def test_sort_puts_missing_values_last():
|
def test_sort_puts_missing_values_last():
|
||||||
hits = [
|
hits = [
|
||||||
SearchHit(title="b", path="b", modified=None),
|
SearchHit(title="b", path="b", modified=None),
|
||||||
@@ -240,18 +275,79 @@ def test_resolve_defaults_to_rg_and_rejects_unknown():
|
|||||||
# --- output -----------------------------------------------------------------
|
# --- output -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _result(hits, total=None, limit=DEFAULT_LIMIT):
|
||||||
|
return SearchResult(hits=hits, total=total if total is not None else len(hits), limit=limit)
|
||||||
|
|
||||||
|
|
||||||
def test_render_table_is_compact_and_reports_the_count():
|
def test_render_table_is_compact_and_reports_the_count():
|
||||||
hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system",
|
hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system",
|
||||||
summary="Server hosting DocStore", score=8.0, matches=[Match(3, "DocStore")])
|
summary="Server hosting DocStore", score=8.0, matches=[Match(3, "DocStore")])
|
||||||
out = render_table([hit], show_matches=False)
|
out = render_table(_result([hit]), show_matches=False)
|
||||||
assert "aurora" in out and "entity/system" in out
|
assert "aurora" in out and "entity/system" in out
|
||||||
assert "kb/x.md:3" not in out
|
assert "kb/x.md:3" not in out
|
||||||
assert "1 result(s)." in out
|
assert "1 result(s)." in out
|
||||||
assert "kb/x.md:3" in render_table([hit], show_matches=True)
|
assert "kb/x.md:3" in render_table(_result([hit]), show_matches=True)
|
||||||
|
|
||||||
|
|
||||||
def test_render_table_says_so_when_nothing_matched():
|
def test_render_table_says_so_when_nothing_matched():
|
||||||
assert render_table([], show_matches=False) == "No matches."
|
assert render_table(_result([]), show_matches=False) == "No matches."
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_hit_carries_the_path_so_the_page_can_be_opened():
|
||||||
|
"""The reason this format changed: the instructions tell an agent to read
|
||||||
|
the pages the search points at, and the table pointed nowhere. A session
|
||||||
|
that needed filenames ran `grep -rl` over kb/ for them - a second search
|
||||||
|
that can find no page this one missed."""
|
||||||
|
hit = SearchHit(title="aurora", path="kb/entities/systems/aurora.md", kind="entity")
|
||||||
|
assert "kb/entities/systems/aurora.md" in render_table(_result([hit]), show_matches=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_and_path_are_never_truncated_only_the_summary_is():
|
||||||
|
"""A clipped title is not an identifier. It is what `xref add`, `cite add`
|
||||||
|
and `touch` take as an argument, and the old fixed 34-char column cut four
|
||||||
|
of five hits in the report that prompted this."""
|
||||||
|
title = "Source - Pelletofenkondensator und Verkabelung Recherche"
|
||||||
|
path = f"kb/sources/llm-sessions/{title}.md"
|
||||||
|
hit = SearchHit(title=title, path=path, kind="source", summary="x" * 400)
|
||||||
|
line = render_table(_result([hit]), show_matches=False).splitlines()[0]
|
||||||
|
|
||||||
|
assert title in line
|
||||||
|
assert path in line
|
||||||
|
assert "…" in line, "the summary is still the one lossy field"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hit_line_parses_into_its_five_fields_even_with_prose_pipes():
|
||||||
|
"""The separator has to survive a summary that contains one. It does,
|
||||||
|
because the summary is last: a `|` there cannot shift a column. A `|` in a
|
||||||
|
title is impossible - the wikilink syntax reserves it."""
|
||||||
|
hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system",
|
||||||
|
summary="Runs `a | b` nightly", score=8.0)
|
||||||
|
line = render_table(_result([hit]), show_matches=False).splitlines()[0]
|
||||||
|
score, kind, title, path, summary = line.split(" | ", 4)
|
||||||
|
|
||||||
|
assert (score, kind, title, path) == ("8.0", "entity/system", "aurora", "kb/x.md")
|
||||||
|
assert summary == "Runs `a | b` nightly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_capped_result_says_what_it_capped():
|
||||||
|
"""`20 result(s).` on a query matching 182 pages is a completeness claim
|
||||||
|
the output was in no position to make, and the only way to find the real
|
||||||
|
number was a second, unlimited search."""
|
||||||
|
hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(20)]
|
||||||
|
out = render_table(_result(hits, total=182, limit=20), show_matches=False)
|
||||||
|
|
||||||
|
assert "20 of 182 result(s)" in out
|
||||||
|
assert "--limit" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_uncapped_result_claims_nothing_about_a_limit():
|
||||||
|
hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(3)]
|
||||||
|
assert "3 result(s)." in render_table(_result(hits), show_matches=False)
|
||||||
|
assert " of " not in render_table(_result(hits), show_matches=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_zero_never_counts_as_truncated_however_large_the_corpus():
|
||||||
|
assert _result([SearchHit(title="a", path="a")] * 182, total=182, limit=0).truncated is False
|
||||||
|
|
||||||
|
|
||||||
def test_hit_serialises_for_json():
|
def test_hit_serialises_for_json():
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -150,9 +150,10 @@ def test_stale_regions_is_false_right_after_upsert():
|
|||||||
|
|
||||||
|
|
||||||
def test_target_files_matches_the_documented_scope():
|
def test_target_files_matches_the_documented_scope():
|
||||||
"""Integration check against the real repo: the scope is AGENTS.md, every
|
"""Integration check against the real repo: every agent-loaded category
|
||||||
stage contract, kb/CONVENTIONS.md, every COLLECTION.md, and the flat
|
AGENTS.md § File naming names - AGENTS.md itself, every stage contract,
|
||||||
`instructions/**.md` form - never a `SKILL.md`."""
|
kb/CONVENTIONS.md, every COLLECTION.md, the flat `instructions/**.md` form,
|
||||||
|
every type-spec, every `docs/` page - with `SKILL.md` the one exception."""
|
||||||
from chemenu import config
|
from chemenu import config
|
||||||
|
|
||||||
files = toc.target_files()
|
files = toc.target_files()
|
||||||
@@ -164,5 +165,67 @@ def test_target_files_matches_the_documented_scope():
|
|||||||
assert "types/type-spec.md" in relatives
|
assert "types/type-spec.md" in relatives
|
||||||
assert "instructions/CONTRACT.md" in relatives
|
assert "instructions/CONTRACT.md" in relatives
|
||||||
assert "instructions/dev/version-parts.md" in relatives # flat, still instructions/**.md
|
assert "instructions/dev/version-parts.md" in relatives # flat, still instructions/**.md
|
||||||
|
assert "types/source.md" in relatives # a page type-spec, not only the stage contract
|
||||||
|
assert "docs/version-model.md" in relatives
|
||||||
assert not any(rel.endswith("SKILL.md") for rel in relatives)
|
assert not any(rel.endswith("SKILL.md") for rel in relatives)
|
||||||
assert not any(rel.startswith("types/") and rel != "types/type-spec.md" for rel in relatives)
|
|
||||||
|
|
||||||
|
def test_target_files_takes_the_shipped_template_of_a_file_in_scope():
|
||||||
|
"""Integration check against the real repo: `kb/CONVENTIONS.md` is
|
||||||
|
instance-owned and crosses the distribution boundary as
|
||||||
|
`kb/CONVENTIONS.md.template`, so the template is the same document one step
|
||||||
|
earlier in its life and is maintained here too. Left out, nothing wrote the
|
||||||
|
region into it and nothing checked it - and the adopted copy inherited the
|
||||||
|
gap, which is how a fresh instance came to fail `docs verify` at the end of
|
||||||
|
its own setup."""
|
||||||
|
from chemenu import config
|
||||||
|
|
||||||
|
relatives = {f.relative_to(config.ROOT).as_posix() for f in toc.target_files()}
|
||||||
|
|
||||||
|
assert "kb/CONVENTIONS.md.template" in relatives
|
||||||
|
assert "kb/CONVENTIONS.md" in relatives # both forms, not one replacing the other
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_files_takes_a_template_only_for_a_file_already_in_scope(tmp_path, monkeypatch):
|
||||||
|
"""The rule is "a file in scope carries its `.template` in with it", not
|
||||||
|
"every `.template` in the tree" - `USER.md.template` has no business here,
|
||||||
|
because `USER.md` itself is not reference material this region applies to."""
|
||||||
|
from chemenu import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
for name in ("kb/CONVENTIONS.md", "kb/CONVENTIONS.md.template", "USER.md.template"):
|
||||||
|
(tmp_path / name).write_text("# A file\n", encoding="utf-8")
|
||||||
|
|
||||||
|
relatives = {f.relative_to(tmp_path).as_posix() for f in toc.target_files()}
|
||||||
|
|
||||||
|
assert relatives == {"kb/CONVENTIONS.md", "kb/CONVENTIONS.md.template"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_files_lists_type_spec_once_despite_two_sources():
|
||||||
|
"""`types/type-spec.md` is both a stage contract and a `types/*.md` file;
|
||||||
|
the set in `target_files` is what keeps that from being a special case."""
|
||||||
|
from chemenu import config
|
||||||
|
|
||||||
|
relatives = [f.relative_to(config.ROOT).as_posix() for f in toc.target_files()]
|
||||||
|
assert relatives.count("types/type-spec.md") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_region_removes_what_types_describe_would_otherwise_echo():
|
||||||
|
body = (
|
||||||
|
"# A type\n\n"
|
||||||
|
f"{blocks.open_marker(toc.REGION_NAME)}\n## Contents\n\n- [X](#x)\n"
|
||||||
|
f"{blocks.close_marker(toc.REGION_NAME)}\n\n## X\n\nProse.\n"
|
||||||
|
)
|
||||||
|
stripped = toc.strip_region(body)
|
||||||
|
assert toc.REGION_NAME not in stripped
|
||||||
|
assert "## Contents" not in stripped
|
||||||
|
assert "## X" in stripped and "Prose." in stripped
|
||||||
|
# The heading keeps the blank line the region used to sit in, rather than
|
||||||
|
# being welded to the line above it.
|
||||||
|
assert "# A type\n\n## X" in stripped
|
||||||
|
|
||||||
|
|
||||||
|
def test_strip_region_leaves_a_body_that_never_had_one_alone():
|
||||||
|
body = "# A type\n\n## X\n\nProse.\n"
|
||||||
|
assert toc.strip_region(body) == body
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,54 @@ def test_get_page_ref_fields_defaults_to_empty():
|
|||||||
assert resolver.get_page_ref_fields("types/type-spec.md") == []
|
assert resolver.get_page_ref_fields("types/type-spec.md") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_guidance_reads_the_linked_type_guidance_file():
|
||||||
|
"""Gitea #104: a root:kb type-spec's generic authoring prose lives in a
|
||||||
|
separate, stack-owned `<name>.guidance.md`, linked via `guidance:` -
|
||||||
|
unlike `schema:`, this one's absence is the common case (an instance
|
||||||
|
typed for itself), not an error."""
|
||||||
|
guidance = resolver.get_guidance("types/entity.md")
|
||||||
|
assert guidance is not None
|
||||||
|
assert "When to use" in guidance or "When NOT to use" in guidance
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_guidance_is_none_when_the_type_spec_declares_none():
|
||||||
|
"""`instruction` and `lint-report` describe stack artifacts and have
|
||||||
|
never carried a `guidance:` field - this is the type with no linked
|
||||||
|
guidance at all, not a broken link."""
|
||||||
|
assert resolver.get_guidance("types/instruction.md") is None
|
||||||
|
assert resolver.get_guidance("types/lint-report.md") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_template_reads_only_the_type_spec_never_the_guidance_file(tmp_path):
|
||||||
|
"""`wikitool new` must keep exactly one load path for its scaffold - the
|
||||||
|
first ```markdown block of `types/<name>.md` itself - even though the
|
||||||
|
type-spec now optionally links a second file. A ```markdown block placed
|
||||||
|
in the guidance file instead must never be picked up."""
|
||||||
|
from chemenu.type_resolver import TypeResolver
|
||||||
|
|
||||||
|
types_dir = tmp_path / "types"
|
||||||
|
types_dir.mkdir()
|
||||||
|
(types_dir / "widget.md").write_text(
|
||||||
|
# Self-referential `type:` (like the badtype fixture above), purely so
|
||||||
|
# this narrow fixture needs no real `types/type-spec.md` on disk -
|
||||||
|
# `extract_template` never reads the `type:` field at all.
|
||||||
|
"---\ntype: types/widget.md\nname: widget\ndescription: A widget type.\n"
|
||||||
|
"schema: null\nbase_dir: widgets\nguidance: types/widget.guidance.md\n---\n\n"
|
||||||
|
"# Widget\n\n## Template\n\n```markdown\n# {name}\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(types_dir / "widget.guidance.md").write_text(
|
||||||
|
"---\ntype: types/type-guidance.md\nname: widget\ndescription: Guidance for widget.\n"
|
||||||
|
"---\n\n# Widget Guidance\n\n```markdown\nTHIS MUST NEVER BE THE SCAFFOLD\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
bad_resolver = TypeResolver(repo_root=tmp_path)
|
||||||
|
type_spec = bad_resolver.load_type_spec("types/widget.md")
|
||||||
|
template = bad_resolver.extract_template(type_spec)
|
||||||
|
assert template == "# {name}"
|
||||||
|
assert "THIS MUST NEVER BE THE SCAFFOLD" not in template
|
||||||
|
|
||||||
|
|
||||||
def test_get_capture_fields_reads_the_type_spec():
|
def test_get_capture_fields_reads_the_type_spec():
|
||||||
"""`fidelity`/`authority` are fixed once, at capture time (Gitea #67) -
|
"""`fidelity`/`authority` are fixed once, at capture time (Gitea #67) -
|
||||||
`raw accept`, `new source` and `touch` all read the field list from here
|
`raw accept`, `new source` and `touch` all read the field list from here
|
||||||
@@ -101,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"]
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +269,7 @@ def test_list_type_specs_finds_every_type_spec():
|
|||||||
names = {fm.get("name") for _, fm in resolver.list_type_specs()}
|
names = {fm.get("name") for _, fm in resolver.list_type_specs()}
|
||||||
assert names == {
|
assert names == {
|
||||||
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
|
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
|
||||||
|
"type-guidance",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ def test_types_list_finds_all_current_type_specs():
|
|||||||
names = {row["name"] for row in rows}
|
names = {row["name"] for row in rows}
|
||||||
assert names == {
|
assert names == {
|
||||||
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
|
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
|
||||||
|
"type-guidance",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +53,42 @@ def test_types_describe_entity_reports_schema_and_body():
|
|||||||
assert "wikitool:footnotes" not in data["body"]
|
assert "wikitool:footnotes" not in data["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_types_describe_entity_composes_guidance_and_body_separately():
|
||||||
|
"""Gitea #104: the generic authoring prose (When to use / When NOT to
|
||||||
|
use) now lives in the stack-owned `entity.guidance.md`, reported under
|
||||||
|
its own JSON keys, while `body` stays exactly what it was - the
|
||||||
|
instance-owned type-spec's own text (frontmatter table + template)."""
|
||||||
|
result = runner.invoke(app, ["types", "describe", "entity", "--json"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
import json
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["guidance_path"] == "types/entity.guidance.md"
|
||||||
|
assert data["guidance"] is not None
|
||||||
|
assert "## When to use" in data["guidance"]
|
||||||
|
assert "## When to use" not in data["body"]
|
||||||
|
assert "## Kerndaten" in data["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_types_describe_composes_guidance_ahead_of_body_in_text_output():
|
||||||
|
result = runner.invoke(app, ["types", "describe", "entity"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
guidance_at = result.output.index("## When to use")
|
||||||
|
template_at = result.output.index("## Kerndaten")
|
||||||
|
assert guidance_at < template_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_types_describe_a_type_with_no_guidance_omits_it_cleanly():
|
||||||
|
"""`instruction` describes a stack artifact and has never carried a
|
||||||
|
`guidance:` field - this must read exactly as it did before the split
|
||||||
|
existed, not print an empty section."""
|
||||||
|
result = runner.invoke(app, ["types", "describe", "instruction", "--json"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
import json
|
||||||
|
data = json.loads(result.output)
|
||||||
|
assert data["guidance"] is None
|
||||||
|
assert data["guidance_path"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_types_describe_unknown_name_fails_cleanly():
|
def test_types_describe_unknown_name_fails_cleanly():
|
||||||
result = runner.invoke(app, ["types", "describe", "bogus"])
|
result = runner.invoke(app, ["types", "describe", "bogus"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|||||||
@@ -444,6 +444,107 @@ def test_breaking_line_sits_above_the_bumps_region(tree):
|
|||||||
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
# --- version bump: accumulating --breaking ----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_breaking_reason_stays_on_the_marker_line(tree):
|
||||||
|
"""The shape every entry written before accumulation existed carries."""
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_breaking_reason_joins_the_first_instead_of_replacing_it(tree):
|
||||||
|
"""The defect this exists for: the second --breaking used to delete the
|
||||||
|
first, leaving the entry promising one break while shipping two."""
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="First crossing",
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="Second crossing",
|
||||||
|
breaking="the import name changed", no_migration=None, migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
|
||||||
|
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
|
||||||
|
assert "- the feed moved" in changes and "- the import name changed" in changes
|
||||||
|
# Still the line an operator acts on first, above a list that can run long.
|
||||||
|
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeating_a_breaking_reason_verbatim_is_a_no_op(tree):
|
||||||
|
"""So a re-run after an interrupted bump converges instead of writing the
|
||||||
|
same sentence twice."""
|
||||||
|
for title in ("First", "Second"):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title=title,
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
|
||||||
|
assert version_mod.breaking_reasons(section) == ["the feed moved"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_breaking_reasons_reads_a_pre_accumulation_single_line_back(tree):
|
||||||
|
"""An entry written by a build that predates accumulation round-trips: it
|
||||||
|
parses as one reason, and a later bump grows it to a list rather than
|
||||||
|
rewriting what was already there."""
|
||||||
|
section = "## 2.0.0-beta.1 - 2026-09-01 - T\n\n**Breaking Change:** the feed moved\n\n"
|
||||||
|
assert version_mod.breaking_reasons(section) == ["the feed moved"]
|
||||||
|
grown = version_mod._add_breaking_reason(section, "the import name changed")
|
||||||
|
assert version_mod.breaking_reasons(grown) == ["the feed moved", "the import name changed"]
|
||||||
|
assert f"{version_mod.BREAKING_CHANGE_MARKER}\n- the feed moved\n" in grown
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_migration_still_replaces_rather_than_accumulating(tree):
|
||||||
|
"""It answers one yes/no question about the candidate as a whole, so a
|
||||||
|
second answer supersedes the first - unlike --breaking beside it."""
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="First",
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="Second",
|
||||||
|
breaking=None, no_migration="still nothing to change", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert changes.count(version_mod.MIGRATION_NONE_MARKER) == 1
|
||||||
|
assert "kb/ untouched" not in changes
|
||||||
|
assert "still nothing to change" in changes
|
||||||
|
|
||||||
|
|
||||||
|
def test_retracting_no_migration_leaves_an_accumulated_breaking_list_intact(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="First",
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="Second",
|
||||||
|
breaking="the import name changed", no_migration=None, migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
_migration_document(tree, "2.0.0")
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Turns out it migrates",
|
||||||
|
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.3"))
|
||||||
|
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
||||||
|
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
|
||||||
|
|
||||||
|
|
||||||
# --- version regrade --------------------------------------------------------
|
# --- version regrade --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -894,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
|
||||||
|
|
||||||
|
|
||||||
@@ -907,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
|
||||||
@@ -915,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 ---------------------------------------------------------
|
||||||
|
|||||||
+112
-11
@@ -20,16 +20,50 @@ the stage/collection contracts, the flat `instructions/**.md` files) is a
|
|||||||
precedent first.
|
precedent first.
|
||||||
|
|
||||||
**Scope is computed, never a hand-picked list** - the same principle that
|
**Scope is computed, never a hand-picked list** - the same principle that
|
||||||
governs `wikitool` itself. `target_files()` walks the file-naming categories
|
governs `wikitool` itself. `target_files()` walks every file-naming category
|
||||||
AGENTS.md's own table calls agent-loaded reference material: `AGENTS.md`,
|
AGENTS.md's own table calls agent-loaded: `AGENTS.md`, every stage contract,
|
||||||
every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, and
|
`kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md`
|
||||||
every flat `instructions/**.md` file (a `SKILL.md` is excluded - it is loaded
|
file, every `types/*.md` type-spec, and every `docs/` page - each together with
|
||||||
whole by the harness, not previewed at a second hop; `instructions/CONTRACT.md`
|
the `<name>.template` it ships as, where one exists. One rule, and
|
||||||
§ "How much reasoning a step may carry" already treats a checklist read once
|
exactly one exception below it - which is the whole point, because a scope
|
||||||
as the table of contents it replaced). Human docs (`README.md`, `CHANGES.md`,
|
carrying several unexplained absences reads as an accident rather than a
|
||||||
`EVALS.md`, `INSTALL.md`, `tools/README.md`) are excluded too: the file-naming
|
decision, and did: `docs/` and the page type-specs sat outside it for no
|
||||||
table says they are "Never loaded by an agent as instruction," so the preview
|
recorded reason at all.
|
||||||
mechanic this exists to mitigate does not apply to them.
|
|
||||||
|
`docs/` belongs in for the reason the threshold exists. AGENTS.md § File naming
|
||||||
|
calls it "Agents and humans | By link, or on explicit request" - agent-loaded,
|
||||||
|
at a second hop, which is precisely the preview mechanic
|
||||||
|
`instructions/CONTRACT.md` § "Reference depth" treats as real. The exclusion of
|
||||||
|
the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`,
|
||||||
|
`tools/README.md`) rests on a sentence that does not stretch to cover it: those
|
||||||
|
are "Never loaded by an agent as instruction," while a `docs/` page is not
|
||||||
|
loaded *as instruction* but is very much loaded.
|
||||||
|
|
||||||
|
**The one exception is `SKILL.md`**, and the vendored skill-authoring guidance
|
||||||
|
is what puts it there rather than a judgment of ours. `commonplace/kb/work/
|
||||||
|
skill-creator-distillation/sources/claude-code-skill-creator/SKILL.md:88-99`
|
||||||
|
sets out three loading levels and places the SKILL.md body on the second - "In
|
||||||
|
context whenever skill triggers (<500 lines ideal)" - while aiming its own TOC
|
||||||
|
advice at the third, "large reference files (>300 lines)", i.e. bundled
|
||||||
|
resources. The Codex source agrees by placement: its TOC bullet sits directly
|
||||||
|
under "Keep references one level deep from SKILL.md. All reference files should
|
||||||
|
link directly from SKILL.md" (`.../codex-skill-creator/SKILL.md:221-222`).
|
||||||
|
Neither asks a skill body to carry a table of contents, because neither expects
|
||||||
|
one to be previewed. `instructions/CONTRACT.md` § "How much reasoning a step
|
||||||
|
may carry" arrives at the same place from the other side, treating a checklist
|
||||||
|
read once as the table of contents it replaced. (Our longest skill is 254
|
||||||
|
lines, so the <500 guidance costs us nothing either.)
|
||||||
|
|
||||||
|
A type-spec is loaded whole too - `tools/wikitool types describe` prints its
|
||||||
|
entire authoring body - but it is *also* read as a file, by whoever edits it,
|
||||||
|
and that is the reading the threshold is about. So it carries a region like any
|
||||||
|
other reference file, and `types_cmd` strips the region back out of what
|
||||||
|
`describe` prints: the command already hands over the whole body, so a
|
||||||
|
navigation aid into it would be noise in the output and nothing else.
|
||||||
|
|
||||||
|
The threshold's own provenance is worth recording, because the two sources
|
||||||
|
disagree: Codex says 100 lines, Claude Code says 300. This stack took the
|
||||||
|
stricter number.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -42,6 +76,14 @@ from chemenu import blocks, config, kb_collections, markdown_code
|
|||||||
REGION_NAME = "toc"
|
REGION_NAME = "toc"
|
||||||
HEADING_TEXT = "Contents"
|
HEADING_TEXT = "Contents"
|
||||||
|
|
||||||
|
# The suffix `dist export` re-keys an instance-owned file to, and the one
|
||||||
|
# `setup-instance.md` adopts away again. Lives here because this module is what
|
||||||
|
# decides which files are reference material *in both their forms*;
|
||||||
|
# `docs_verify` imports it rather than keeping a second spelling. `ownership.py`
|
||||||
|
# keeps its own literal deliberately - it answers a different question (which
|
||||||
|
# side an upstream merge keeps) over a narrower scope.
|
||||||
|
TEMPLATE_SUFFIX = ".template"
|
||||||
|
|
||||||
# The line threshold Anthropic's own guidance names. Measured on the body
|
# The line threshold Anthropic's own guidance names. Measured on the body
|
||||||
# with any existing TOC region stripped out first, so inserting or updating
|
# with any existing TOC region stripped out first, so inserting or updating
|
||||||
# the region can never be what pushes a file over the line.
|
# the region can never be what pushes a file over the line.
|
||||||
@@ -80,7 +122,25 @@ def target_files() -> list[Path]:
|
|||||||
`instructions/<name>.md` form (`instructions/CONTRACT.md` says the `dev/`
|
`instructions/<name>.md` form (`instructions/CONTRACT.md` says the `dev/`
|
||||||
split is orthogonal to Linked/Manual, not a different file shape), so
|
split is orthogonal to Linked/Manual, not a different file shape), so
|
||||||
there is no separate rule for them to fall out of - and no hand-picking
|
there is no separate rule for them to fall out of - and no hand-picking
|
||||||
for a future file under either to be missed.
|
for a future file under either to be missed. `types/` and `docs/` are
|
||||||
|
walked for the same reason: a type or a rationale page added later is in
|
||||||
|
scope by construction, not by someone remembering this function.
|
||||||
|
|
||||||
|
`types/type-spec.md` arrives twice - once as a stage contract, once from
|
||||||
|
the `types/` walk - and the set at the bottom is what makes that a
|
||||||
|
non-issue rather than something to special-case.
|
||||||
|
|
||||||
|
**A file in scope carries its `.template` in with it.** An instance-owned
|
||||||
|
reference file crosses the distribution boundary as `<name>.template` and
|
||||||
|
is adopted by copying it back (`instructions/setup-instance.md`), so the
|
||||||
|
template is the same document one step earlier in its life - the shipped
|
||||||
|
form of a file this scope already covers. Leaving it out meant nothing
|
||||||
|
maintained it and nothing checked it: `kb/CONVENTIONS.md.template` grew
|
||||||
|
past the threshold carrying no region at all, and the first thing to notice
|
||||||
|
was a fresh instance failing `docs verify` at the end of its own setup,
|
||||||
|
because the adopted copy inherited the gap. `docs toc --apply` writes the
|
||||||
|
region into the template as into anything else here; the instance's own
|
||||||
|
later edits stay its business, and its own `docs toc` run answers for them.
|
||||||
"""
|
"""
|
||||||
files: list[Path] = [config.ROOT / "AGENTS.md"]
|
files: list[Path] = [config.ROOT / "AGENTS.md"]
|
||||||
files += [config.ROOT / rel for rel in _STAGE_CONTRACTS]
|
files += [config.ROOT / rel for rel in _STAGE_CONTRACTS]
|
||||||
@@ -96,6 +156,17 @@ def target_files() -> list[Path]:
|
|||||||
for path in instructions_dir.rglob("*.md")
|
for path in instructions_dir.rglob("*.md")
|
||||||
if path.name != "SKILL.md"
|
if path.name != "SKILL.md"
|
||||||
)
|
)
|
||||||
|
for subdir in ("types", "docs"):
|
||||||
|
directory = config.ROOT / subdir
|
||||||
|
if directory.is_dir():
|
||||||
|
files += sorted(directory.rglob("*.md"))
|
||||||
|
files += [
|
||||||
|
template
|
||||||
|
for template in (
|
||||||
|
path.with_name(path.name + TEMPLATE_SUFFIX) for path in tuple(files)
|
||||||
|
)
|
||||||
|
if template.is_file()
|
||||||
|
]
|
||||||
return sorted({f for f in files if f.is_file()})
|
return sorted({f for f in files if f.is_file()})
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +187,36 @@ def body_without_region(text: str) -> str:
|
|||||||
return stripped.rstrip("\n") + "\n" if stripped else stripped
|
return stripped.rstrip("\n") + "\n" if stripped else stripped
|
||||||
|
|
||||||
|
|
||||||
|
_REGION_WITH_PADDING_RE = re.compile(
|
||||||
|
r"\n*"
|
||||||
|
+ re.escape(blocks.open_marker(REGION_NAME))
|
||||||
|
+ r".*?"
|
||||||
|
+ re.escape(blocks.close_marker(REGION_NAME))
|
||||||
|
+ r"\n*",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_region(text: str) -> str:
|
||||||
|
"""`text` with the generated TOC region removed, for a caller that is
|
||||||
|
handing the whole body over anyway.
|
||||||
|
|
||||||
|
`types describe` is the one such caller: it prints a type-spec's entire
|
||||||
|
authoring body, so the region's markers and heading list would be noise in
|
||||||
|
its output rather than a way into anything. Distinct from
|
||||||
|
`body_without_region`, which exists to *measure* a body against the line
|
||||||
|
threshold and therefore also canonicalizes the trailing newline.
|
||||||
|
|
||||||
|
Takes the surrounding blank lines with it and puts one back, rather than
|
||||||
|
calling `blocks.strip`: that collapses the padding to a single newline,
|
||||||
|
which is the right answer when the result is about to be rebuilt from
|
||||||
|
scratch (`upsert` does exactly that) and the wrong one here, where the
|
||||||
|
output is printed as-is - it would leave the following `##` heading welded
|
||||||
|
to the paragraph above it.
|
||||||
|
"""
|
||||||
|
return _REGION_WITH_PADDING_RE.sub("\n\n", text)
|
||||||
|
|
||||||
|
|
||||||
def needs_toc(text: str) -> bool:
|
def needs_toc(text: str) -> bool:
|
||||||
return len(body_without_region(text).splitlines()) > THRESHOLD
|
return len(body_without_region(text).splitlines()) > THRESHOLD
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -436,6 +436,33 @@ class TypeResolver:
|
|||||||
type_spec = self.load_type_spec(type_path, source_file)
|
type_spec = self.load_type_spec(type_path, source_file)
|
||||||
return type_spec['frontmatter'].get('title_prefix') or ""
|
return type_spec['frontmatter'].get('title_prefix') or ""
|
||||||
|
|
||||||
|
def get_guidance(self, type_path: str, source_file: Path = None) -> Optional[str]:
|
||||||
|
"""Return the stack-owned guidance body a type-spec's `guidance:`
|
||||||
|
field points to, or None if it declares none - the common case for a
|
||||||
|
type an instance writes entirely for itself (Gitea #104).
|
||||||
|
|
||||||
|
The linked file is loaded as a type-spec-shaped document (`type:
|
||||||
|
types/type-guidance.md`, `name:`, `description:`) the same way
|
||||||
|
`schema:` is resolved, so a broken link or a malformed guidance file
|
||||||
|
fails the same way a broken `schema:` would rather than silently
|
||||||
|
returning nothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type_path: The type path to resolve, e.g. 'types/entity.md'
|
||||||
|
source_file: The source file path (for relative type resolution)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the type path cannot be resolved, or `guidance:`
|
||||||
|
names a path that cannot be resolved or does not validate as
|
||||||
|
a type-guidance document.
|
||||||
|
"""
|
||||||
|
type_spec = self.load_type_spec(type_path, source_file)
|
||||||
|
guidance_path = type_spec['frontmatter'].get('guidance')
|
||||||
|
if not guidance_path:
|
||||||
|
return None
|
||||||
|
guidance_spec = self.load_type_spec(guidance_path, type_spec['path'])
|
||||||
|
return guidance_spec['body']
|
||||||
|
|
||||||
def get_page_ref_fields(self, type_path: str, source_file: Path = None) -> list:
|
def get_page_ref_fields(self, type_path: str, source_file: Path = None) -> list:
|
||||||
"""Return the frontmatter fields whose entries are wiki page titles
|
"""Return the frontmatter fields whose entries are wiki page titles
|
||||||
(e.g. `['related', 'sources']` for an entity), as declared by the
|
(e.g. `['related', 'sources']` for an entity), as declared by the
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ def describe_type(name: str) -> Dict[str, Any]:
|
|||||||
type_spec = resolver.load_type_spec(type_path)
|
type_spec = resolver.load_type_spec(type_path)
|
||||||
frontmatter = type_spec["frontmatter"]
|
frontmatter = type_spec["frontmatter"]
|
||||||
schema = resolver.get_schema(type_path)
|
schema = resolver.get_schema(type_path)
|
||||||
|
guidance_path = frontmatter.get("guidance")
|
||||||
|
guidance_body = resolver.get_guidance(type_path) if guidance_path else None
|
||||||
|
|
||||||
fields: list[Dict[str, Any]] = []
|
fields: list[Dict[str, Any]] = []
|
||||||
if schema is not None:
|
if schema is not None:
|
||||||
@@ -86,5 +88,10 @@ def describe_type(name: str) -> Dict[str, Any]:
|
|||||||
"base_dir": frontmatter.get("base_dir"),
|
"base_dir": frontmatter.get("base_dir"),
|
||||||
"title_prefix": frontmatter.get("title_prefix"),
|
"title_prefix": frontmatter.get("title_prefix"),
|
||||||
"fields": fields,
|
"fields": fields,
|
||||||
|
# `body` stays the type-spec's own body, unchanged - additive fields
|
||||||
|
# below it keep the MCP wire contract readable for an older client
|
||||||
|
# that has never heard of the guidance split (Gitea #104).
|
||||||
"body": type_spec["body"].strip(),
|
"body": type_spec["body"].strip(),
|
||||||
|
"guidance_path": guidance_path,
|
||||||
|
"guidance": guidance_body.strip() if guidance_body is not None else None,
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-16
@@ -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
|
||||||
@@ -583,9 +640,11 @@ def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
|
|||||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||||
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
||||||
|
|
||||||
Used for the breaking-change and no-migration lines, which - unlike the
|
Used for the no-migration line, which - unlike the bumps list and unlike
|
||||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
the breaking-change paragraph below - is **not** accumulated: it answers
|
||||||
restates it rather than growing a list nobody would read as history.
|
one yes/no question about the candidate as a whole ("does content have to
|
||||||
|
change?"), so a second answer replaces the first rather than joining it,
|
||||||
|
and `_clear_marker_line` is its retraction path.
|
||||||
Anchored just above the bumps region (not below it, as before Gitea #95):
|
Anchored just above the bumps region (not below it, as before Gitea #95):
|
||||||
with a graded, potentially 30-line list, the line an operator most needs
|
with a graded, potentially 30-line list, the line an operator most needs
|
||||||
to act on stayed the deepest thing in the entry otherwise.
|
to act on stayed the deepest thing in the entry otherwise.
|
||||||
@@ -599,6 +658,73 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
|
|||||||
return section.rstrip() + f"\n\n{line}\n"
|
return section.rstrip() + f"\n\n{line}\n"
|
||||||
|
|
||||||
|
|
||||||
|
# The breaking-change paragraph, matched whole: the marker line plus any `- `
|
||||||
|
# bullets under it. `_set_marker_line`'s `^marker.*$` reaches the first line
|
||||||
|
# only, which is exactly wrong for a form that can carry bullets beneath it.
|
||||||
|
_BREAKING_PARAGRAPH_RE = re.compile(
|
||||||
|
rf"^{re.escape(BREAKING_CHANGE_MARKER)}.*(?:\n-[ \t].*)*$", re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def breaking_reasons(section: str) -> list[str]:
|
||||||
|
"""The breaking-change paragraph parsed back into one reason per crossing,
|
||||||
|
in written order.
|
||||||
|
|
||||||
|
Two shapes read the same way, which is what lets an entry written before
|
||||||
|
accumulation existed round-trip untouched: `**Breaking Change:** <reason>`
|
||||||
|
is one reason, and a bare `**Breaking Change:**` followed by `- ` bullets
|
||||||
|
is one reason per bullet. Same "flat while there is only one of them"
|
||||||
|
trick `_bumps_block` plays with its impact groups, and for the same
|
||||||
|
reason - the common case keeps the shape it always had.
|
||||||
|
"""
|
||||||
|
match = _BREAKING_PARAGRAPH_RE.search(section)
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
lines = match.group(0).splitlines()
|
||||||
|
head = lines[0][len(BREAKING_CHANGE_MARKER):].strip()
|
||||||
|
reasons = [head] if head else []
|
||||||
|
reasons += [line.strip()[2:].strip() for line in lines[1:]]
|
||||||
|
return [reason for reason in reasons if reason]
|
||||||
|
|
||||||
|
|
||||||
|
def _breaking_paragraph(reasons: list[str]) -> str:
|
||||||
|
"""Render the breaking-change paragraph from one reason per crossing.
|
||||||
|
|
||||||
|
One reason stays on the marker line - byte-identical to what every entry
|
||||||
|
written before accumulation carries. Two or more move to bullets under a
|
||||||
|
bare marker, because a single line holding two unrelated breakages reads
|
||||||
|
as one run-on sentence and an operator has to act on each separately.
|
||||||
|
"""
|
||||||
|
if len(reasons) == 1:
|
||||||
|
return f"{BREAKING_CHANGE_MARKER} {reasons[0]}"
|
||||||
|
bullets = "\n".join(f"- {reason}" for reason in reasons)
|
||||||
|
return f"{BREAKING_CHANGE_MARKER}\n{bullets}"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_breaking_reason(section: str, reason: str) -> str:
|
||||||
|
"""Append `reason` to the breaking-change paragraph, or start one.
|
||||||
|
|
||||||
|
Accumulates rather than replaces: a candidate can cross the compatibility
|
||||||
|
boundary more than once (this is the normal shape of a long-running one),
|
||||||
|
and each crossing is a separate thing the operator of an existing instance
|
||||||
|
has to act on. Replacing meant the second `--breaking` silently deleted
|
||||||
|
the first - the entry then promised a single break while shipping two.
|
||||||
|
|
||||||
|
Repeating a reason verbatim is a no-op, so a re-run after an interrupted
|
||||||
|
bump converges instead of writing the same sentence twice.
|
||||||
|
"""
|
||||||
|
existing = breaking_reasons(section)
|
||||||
|
if reason in existing:
|
||||||
|
return section
|
||||||
|
paragraph = _breaking_paragraph(existing + [reason])
|
||||||
|
if existing:
|
||||||
|
return _BREAKING_PARAGRAPH_RE.sub(lambda _m: paragraph, section, count=1)
|
||||||
|
anchor = section.find(_BUMPS_OPEN)
|
||||||
|
if anchor != -1:
|
||||||
|
return section[:anchor] + f"{paragraph}\n\n" + section[anchor:]
|
||||||
|
return section.rstrip() + f"\n\n{paragraph}\n"
|
||||||
|
|
||||||
|
|
||||||
def _clear_marker_line(section: str, marker: str) -> str:
|
def _clear_marker_line(section: str, marker: str) -> str:
|
||||||
"""Remove the one-line `marker ...` paragraph from `section`, if present.
|
"""Remove the one-line `marker ...` paragraph from `section`, if present.
|
||||||
|
|
||||||
@@ -633,10 +759,15 @@ def _update_open_candidate(
|
|||||||
impact: str = DEFAULT_IMPACT,
|
impact: str = DEFAULT_IMPACT,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||||
`(impact, title)` to its machine-managed bump list, and set the
|
`(impact, title)` to its machine-managed bump list, and record the
|
||||||
breaking/no-migration lines only where this call supplies them - see
|
breaking/no-migration lines only where this call supplies them - see
|
||||||
`insert_changes_entry`.
|
`insert_changes_entry`.
|
||||||
|
|
||||||
|
The two are recorded differently on purpose: a `breaking_reason` **joins**
|
||||||
|
whatever crossings the candidate already recorded (`_add_breaking_reason`),
|
||||||
|
a `no_migration_reason` **replaces** the single line that answers whether
|
||||||
|
content has to change (`_set_marker_line`).
|
||||||
|
|
||||||
`migration_required` retracts an earlier `--no-migration` line instead of
|
`migration_required` retracts an earlier `--no-migration` line instead of
|
||||||
setting one - the two are mutually exclusive on a single bump, enforced by
|
setting one - the two are mutually exclusive on a single bump, enforced by
|
||||||
the caller (`version_cmd.bump_command`), not here."""
|
the caller (`version_cmd.bump_command`), not here."""
|
||||||
@@ -653,7 +784,7 @@ def _update_open_candidate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if breaking_reason:
|
if breaking_reason:
|
||||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
section = _add_breaking_reason(section, breaking_reason)
|
||||||
if no_migration_reason:
|
if no_migration_reason:
|
||||||
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||||
elif migration_required:
|
elif migration_required:
|
||||||
@@ -684,7 +815,9 @@ def insert_changes_entry(
|
|||||||
candidate still open" here.
|
candidate still open" here.
|
||||||
|
|
||||||
A fresh entry gets the skeleton only: heading, date, author, - when a
|
A fresh entry gets the skeleton only: heading, date, author, - when a
|
||||||
compatibility boundary is crossed - the line saying what breaks, plus the
|
compatibility boundary is crossed - the line saying what breaks (one
|
||||||
|
crossing, so the flat one-line form; a candidate that crosses again
|
||||||
|
accumulates bullets there, see `_add_breaking_reason`), plus the
|
||||||
line saying no content has to change where that applies, and then the
|
line saying no content has to change where that applies, and then the
|
||||||
machine-managed bump list (started with this one `(impact, title)` pair,
|
machine-managed bump list (started with this one `(impact, title)` pair,
|
||||||
for a candidate). The break comes first, above the bump list rather than
|
for a candidate). The break comes first, above the bump list rather than
|
||||||
|
|||||||
+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}"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
type: types/type-guidance.md
|
||||||
|
name: comparison
|
||||||
|
description: When to write a comparison page instead of a neighboring type, and how to write a conforming one - structure and analysis rules that hold regardless of what this instance is comparing
|
||||||
|
---
|
||||||
|
|
||||||
|
# Comparison Guidance
|
||||||
|
|
||||||
|
`comparison` is the type for pages that set several entities, tools, technologies or approaches
|
||||||
|
systematically against one another along fixed criteria. Comparison pages support decisions and
|
||||||
|
make the trade-offs between alternatives visible.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Comparing several technologies or frameworks for a concrete purpose
|
||||||
|
- Analysing the advantages and disadvantages of different tools or libraries
|
||||||
|
- Evaluating alternative ways of solving a problem
|
||||||
|
- Documenting a decision process with several options examined
|
||||||
|
|
||||||
|
## When NOT to use
|
||||||
|
|
||||||
|
- For documenting a single entity or concept (use `entity` or `concept` respectively)
|
||||||
|
- For summaries of source material (use `source`)
|
||||||
|
- For pages covering a single subject rather than comparing several
|
||||||
|
|
||||||
|
## Authoring guidance
|
||||||
|
|
||||||
|
- A title that names the comparison (e.g. "Go vs Rust", "Kubernetes vs Docker Swarm"); it follows the established names of the subjects compared, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
|
||||||
|
- State clearly what is being compared and why
|
||||||
|
- Use a comparison table with the criteria as rows
|
||||||
|
- An analysis that evaluates the table rather than repeating it
|
||||||
|
- A recommendation wherever one can be justified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Relevant Notes:
|
||||||
|
|
||||||
|
- Comparison pages support technology selection and architecture decisions
|
||||||
|
- The entities compared should all exist as wiki pages
|
||||||
|
- The criteria should fit the decision at hand
|
||||||
|
- A comparison stays focused on one concrete purpose or decision context
|
||||||
+21
-43
@@ -1,50 +1,37 @@
|
|||||||
---
|
---
|
||||||
type: types/type-spec.md
|
type: types/type-spec.md
|
||||||
name: comparison
|
name: comparison
|
||||||
description: Strukturierter Typ für Vergleichsseiten, die mehrere Entities oder Ansätze gegenüberstellen
|
description: Structured type for comparison pages that set several entities or approaches against one another
|
||||||
schema: types/comparison.schema.yaml
|
schema: types/comparison.schema.yaml
|
||||||
base_dir: comparisons
|
base_dir: comparisons
|
||||||
page_ref_fields: [entities, related]
|
page_ref_fields: [entities, related]
|
||||||
|
guidance: types/comparison.guidance.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Comparison
|
# Comparison
|
||||||
|
|
||||||
`comparison` ist der Typ für Seiten, die mehrere Entities, Werkzeuge, Technologien oder Ansätze systematisch anhand festgelegter Kriterien gegenüberstellen. Vergleichsseiten stützen Entscheidungen und machen Zielkonflikte zwischen Alternativen sichtbar.
|
This instance's configuration for the `comparison` type: its frontmatter fields as this schema
|
||||||
|
requires them, and the page skeleton `wikitool new comparison` scaffolds. When to use this type
|
||||||
## Wann zu verwenden
|
and how to write a conforming page is [types/comparison.guidance.md](comparison.guidance.md) -
|
||||||
|
`tools/wikitool types describe comparison` composes both into one answer.
|
||||||
- Vergleich mehrerer Technologien oder Frameworks für einen konkreten Einsatzzweck
|
|
||||||
- Analyse der Vor- und Nachteile verschiedener Werkzeuge oder Bibliotheken
|
|
||||||
- Bewertung alternativer Lösungswege für ein Problem
|
|
||||||
- Dokumentation eines Entscheidungsprozesses mit mehreren geprüften Optionen
|
|
||||||
|
|
||||||
## Wann NICHT zu verwenden
|
|
||||||
|
|
||||||
- Für die Dokumentation einer einzelnen Entity oder eines Concepts (dann `entity` bzw. `concept`)
|
|
||||||
- Für Zusammenfassungen von Quellmaterial (dann `source`)
|
|
||||||
- Für Seiten, die einen einzelnen Gegenstand behandeln statt mehrere zu vergleichen
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
| Feld | Pflicht | Verwendung |
|
| Field | Required | Use |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `type` | Ja | `types/comparison.md` |
|
| `type` | Yes | `types/comparison.md` |
|
||||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
| `tags` | No | Navigation tags for categorization |
|
||||||
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
| `created` | Yes | Creation date (YYYY-MM-DD) |
|
||||||
| `entities` | Ja | Titel der verglichenen Entities |
|
| `entities` | Yes | Titles of the entities compared |
|
||||||
| `related` | Nein | Deklarierte ausgehende Kanten - je Subjekt eine `compares-with`-Kante, geschrieben von `wikitool xref add` |
|
| `related` | No | Declared outbound edges - one `compares-with` edge per subject, written by `wikitool xref add` |
|
||||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Autorenanweisungen
|
|
||||||
|
|
||||||
- Ein Titel, der den Vergleich benennt (z. B. "Go vs Rust", "Kubernetes vs Docker Swarm"); er folgt den etablierten Namen der verglichenen Gegenstände, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
|
||||||
- Klar darlegen, was verglichen wird und warum
|
|
||||||
- Eine Vergleichstabelle mit den Kriterien als Zeilen verwenden
|
|
||||||
- Eine Analyse, die die Tabelle auswertet statt sie zu wiederholen
|
|
||||||
- Eine Empfehlung, wo eine begründbar ist
|
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|
||||||
|
The block below is page material, so it is written in this instance's KB language
|
||||||
|
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
|
||||||
|
become the headings of every page `wikitool new comparison` scaffolds.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Comparison: {name}
|
# Comparison: {name}
|
||||||
|
|
||||||
@@ -67,16 +54,7 @@ TODO: Auswertung des Vergleichs. Welche Muster zeigen sich? Was sind die entsche
|
|||||||
TODO: Falls möglich - was wann und für wen zu verwenden ist. Unter welchen Umständen ist welche Option die beste?
|
TODO: Falls möglich - was wann und für wen zu verwenden ist. Unter welchen Umständen ist welche Option die beste?
|
||||||
```
|
```
|
||||||
|
|
||||||
`# Comparison:` bleibt als Präfix stehen - anders als bei `source` ist es kein `title_prefix`,
|
`# Comparison:` stays as a prefix - unlike `source` it is no `title_prefix` but a pure template
|
||||||
sondern reine Template-Konvention, und der Seitentitel selbst (`Go vs Rust`) trägt es nicht.
|
convention, and the page title itself (`Go vs Rust`) does not carry it. When `wikitool xref` adds
|
||||||
Fügt `wikitool xref` eine Beziehung hinzu, entsteht der toolgeführte Querverweis-Abschnitt; wie
|
a relationship, the tool-managed cross-reference section appears; what it is called is the
|
||||||
er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
|
instance's decision in `kb/CONVENTIONS.md` (`sections:`).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Ergänzende Hinweise:
|
|
||||||
|
|
||||||
- Vergleichsseiten stützen Technologieauswahl und Architekturentscheidungen
|
|
||||||
- Die verglichenen Entities sollten alle als Wiki-Seiten existieren
|
|
||||||
- Die Kriterien sollten zur anstehenden Entscheidung passen
|
|
||||||
- Ein Vergleich bleibt auf einen konkreten Einsatzzweck oder Entscheidungskontext fokussiert
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
type: types/type-guidance.md
|
||||||
|
name: concept
|
||||||
|
description: When to write a concept page instead of a neighboring type, and how to write a conforming one - citation, linking and register rules that hold regardless of which concept_type values this instance chose
|
||||||
|
---
|
||||||
|
|
||||||
|
# Concept Guidance
|
||||||
|
|
||||||
|
`concept` is the type for abstract ideas, methodologies, patterns and architectural decisions.
|
||||||
|
Concepts provide the theoretical and methodological ground that entities implement or follow.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Documenting an architectural pattern or design approach
|
||||||
|
- Explaining a design pattern or a programming methodology
|
||||||
|
- Describing a communication protocol or standard
|
||||||
|
- Recording a workflow or process that recurs across projects
|
||||||
|
- Documenting an architectural decision (ADR) or design decision
|
||||||
|
- Analysing a recurring problem and its solutions
|
||||||
|
|
||||||
|
## When NOT to use
|
||||||
|
|
||||||
|
- For concrete things such as projects, systems or tools (use `entity`)
|
||||||
|
- For source documents or ingested material (use `source`)
|
||||||
|
- For comparative analyses of concrete subjects (use `comparison`)
|
||||||
|
|
||||||
|
## Authoring guidance
|
||||||
|
|
||||||
|
- The title is the concept's canonical name and follows the established term of art, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
|
||||||
|
- Open with a clear definition: what the concept is
|
||||||
|
- Give examples wherever they carry the understanding
|
||||||
|
- Link to the entities that implement or use the concept
|
||||||
|
- Under `provenance: sourced` or `mixed`, back hard facts inline with a `[^cite-id]` footnote -
|
||||||
|
`tools/wikitool cite add --page "<Title>" --source "Source - X"` mints the id and its definition
|
||||||
|
- Wikipedia register: factual, neutral, no marketing language
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Relevant Notes:
|
||||||
|
|
||||||
|
- Concept pages carry the wiki's theoretical underpinning
|
||||||
|
- Each concept type stands for a different category of abstract knowledge
|
||||||
|
- Concepts should be linked to from the entities that implement or use them
|
||||||
+23
-48
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
type: types/type-spec.md
|
type: types/type-spec.md
|
||||||
name: concept
|
name: concept
|
||||||
description: Basistyp für Concept-Seiten - Architekturen, Muster, Protokolle, Abläufe, Entscheidungen oder Problemstellungen
|
description: Base type for concept pages - architectures, patterns, protocols, workflows, decisions or problems
|
||||||
schema: types/concept.schema.yaml
|
schema: types/concept.schema.yaml
|
||||||
subtype_field: concept_type
|
subtype_field: concept_type
|
||||||
base_dir: concepts
|
base_dir: concepts
|
||||||
page_ref_fields: [related, sources]
|
page_ref_fields: [related, sources]
|
||||||
|
guidance: types/concept.guidance.md
|
||||||
layout:
|
layout:
|
||||||
architecture: {dir: architectures, title: Architekturen}
|
architecture: {dir: architectures, title: Architekturen}
|
||||||
pattern: {dir: patterns, title: Muster}
|
pattern: {dir: patterns, title: Muster}
|
||||||
@@ -17,49 +18,31 @@ layout:
|
|||||||
|
|
||||||
# Concept
|
# Concept
|
||||||
|
|
||||||
`concept` ist der Typ für abstrakte Ideen, Methodiken, Muster und Architekturentscheidungen. Concepts liefern die theoretische und methodische Grundlage, die Entities umsetzen oder befolgen.
|
This instance's configuration for the `concept` type: its frontmatter fields as this schema
|
||||||
|
requires them, and the page skeleton `wikitool new concept` scaffolds. When to use this type and
|
||||||
## Wann zu verwenden
|
how to write a conforming page is [types/concept.guidance.md](concept.guidance.md) -
|
||||||
|
`tools/wikitool types describe concept` composes both into one answer.
|
||||||
- Dokumentation eines Architekturmusters oder Entwurfsansatzes
|
|
||||||
- Erläuterung eines Entwurfsmusters oder einer Programmiermethodik
|
|
||||||
- Beschreibung eines Kommunikationsprotokolls oder Standards
|
|
||||||
- Festhalten eines Ablaufs oder Prozesses, der über Projekte hinweg wiederkehrt
|
|
||||||
- Dokumentation einer Architekturentscheidung (ADR) oder Entwurfsentscheidung
|
|
||||||
- Analyse eines wiederkehrenden Problems und seiner Lösungen
|
|
||||||
|
|
||||||
## Wann NICHT zu verwenden
|
|
||||||
|
|
||||||
- Für konkrete Dinge wie Projekte, Systeme oder Werkzeuge (dann `entity`)
|
|
||||||
- Für Quelldokumente oder eingelesenes Material (dann `source`)
|
|
||||||
- Für vergleichende Analysen konkreter Gegenstände (dann `comparison`)
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
| Feld | Pflicht | Verwendung |
|
| Field | Required | Use |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `type` | Ja | `types/concept.md` |
|
| `type` | Yes | `types/concept.md` |
|
||||||
| `concept_type` | Ja | Eines von: architecture, pattern, protocol, workflow, decision, problem |
|
| `concept_type` | Yes | One of: architecture, pattern, protocol, workflow, decision, problem |
|
||||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
| `tags` | No | Navigation tags for categorization |
|
||||||
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
| `created` | Yes | Creation date (YYYY-MM-DD) |
|
||||||
| `modified` | Ja | Datum der letzten Änderung (YYYY-MM-DD) |
|
| `modified` | Yes | Date last changed (YYYY-MM-DD) |
|
||||||
| `related` | Nein | Titel verwandter Concepts und Entities |
|
| `related` | No | Titles of related concepts and entities |
|
||||||
| `sources` | Nein | Titel der Source-Seiten, die die Aussagen dieser Seite stützen |
|
| `sources` | No | Titles of the source pages backing this page's claims |
|
||||||
| `provenance` | Ja | sourced, general oder mixed |
|
| `provenance` | Yes | sourced, general or mixed |
|
||||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Autorenanweisungen
|
|
||||||
|
|
||||||
- Der Titel ist der kanonische Name des Concepts und folgt der etablierten Fachbezeichnung, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
|
||||||
- Mit einer klaren Definition beginnen: was das Concept ist
|
|
||||||
- Beispiele geben, wo sie das Verständnis tragen
|
|
||||||
- Auf Entities verlinken, die das Concept umsetzen oder verwenden
|
|
||||||
- Bei `provenance: sourced` oder `mixed` harte Fakten inline mit einer `[^cite-id]`-Fußnote belegen -
|
|
||||||
`tools/wikitool cite add --page "<Title>" --source "Source - X"` erzeugt Id und Definition
|
|
||||||
- Wikipedia-Tonfall: sachlich, neutral, keine Werbesprache
|
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|
||||||
|
The block below is page material, so it is written in this instance's KB language
|
||||||
|
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
|
||||||
|
become the headings of every page `wikitool new concept` scaffolds.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# {name}
|
# {name}
|
||||||
|
|
||||||
@@ -94,15 +77,7 @@ TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist
|
|||||||
- [[Related Concept 2]]
|
- [[Related Concept 2]]
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Wert hinter `**Typ:**` bleibt der englische Enum-Wert - danach filtert `search --field`.
|
The value behind `**Typ:**` stays the English enum value - that is what `search --field` filters
|
||||||
Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich die beiden toolgeführten
|
on. When `wikitool xref` adds a relationship, the two tool-managed sections for relationships and
|
||||||
Abschnitte für Beziehungen und Querverweise; wie sie heißen, entscheidet die Instanz in
|
cross-references appear as well; what they are called is the instance's decision in
|
||||||
`kb/CONVENTIONS.md` (`sections:`).
|
`kb/CONVENTIONS.md` (`sections:`).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Ergänzende Hinweise:
|
|
||||||
|
|
||||||
- Concept-Seiten tragen den theoretischen Unterbau des Wikis
|
|
||||||
- Jeder Concept-Typ steht für eine andere Kategorie abstrakten Wissens
|
|
||||||
- Concepts sollten von den Entities aus verlinkt sein, die sie umsetzen oder verwenden
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
type: types/type-guidance.md
|
||||||
|
name: entity
|
||||||
|
description: When to write an entity page instead of a neighboring type, and how to write a conforming one - citation, linking and register rules that hold regardless of which entity_type values this instance chose
|
||||||
|
---
|
||||||
|
|
||||||
|
# Entity Guidance
|
||||||
|
|
||||||
|
`entity` is the type for concrete things: projects, systems, tools, technologies or people.
|
||||||
|
Entities are the primary building blocks of the knowledge graph.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Representing a software project, an initiative or a piece of work
|
||||||
|
- Documenting a running system, service or infrastructure component
|
||||||
|
- Describing a CLI tool, utility or software library
|
||||||
|
- Recording information about a language, a framework or a protocol
|
||||||
|
- Documenting a person, a team or an organization
|
||||||
|
|
||||||
|
## When NOT to use
|
||||||
|
|
||||||
|
- For abstract concepts, patterns or methodologies (use `concept`)
|
||||||
|
- For source documents or ingested material (use `source`)
|
||||||
|
- For comparative analyses (use `comparison`)
|
||||||
|
|
||||||
|
## Authoring guidance
|
||||||
|
|
||||||
|
- The title is the entity's canonical name and follows the subject's established designation, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
|
||||||
|
- The main description goes near the top
|
||||||
|
- Link to related entities and concepts wherever relationships exist
|
||||||
|
- Under `provenance: sourced` or `mixed`, back hard facts inline with a `[^cite-id]` footnote -
|
||||||
|
`tools/wikitool cite add --page "<Title>" --source "Source - X"` mints the id and its definition
|
||||||
|
- Wikipedia register: factual, neutral, no marketing language
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Relevant Notes:
|
||||||
|
|
||||||
|
- Entity pages form the concrete inventory of the knowledge base
|
||||||
|
- Every entity type (project, system, tool and so on) is a variant of this base type
|
||||||
|
- The relationships between entities carry the knowledge graph's interconnection
|
||||||
+24
-47
@@ -1,11 +1,12 @@
|
|||||||
---
|
---
|
||||||
type: types/type-spec.md
|
type: types/type-spec.md
|
||||||
name: entity
|
name: entity
|
||||||
description: Basistyp für Entity-Seiten - Projekte, Systeme, Werkzeuge, Technologien oder Personen
|
description: Base type for entity pages - projects, systems, tools, technologies or people
|
||||||
schema: types/entity.schema.yaml
|
schema: types/entity.schema.yaml
|
||||||
subtype_field: entity_type
|
subtype_field: entity_type
|
||||||
base_dir: entities
|
base_dir: entities
|
||||||
page_ref_fields: [related, sources]
|
page_ref_fields: [related, sources]
|
||||||
|
guidance: types/entity.guidance.md
|
||||||
layout:
|
layout:
|
||||||
project: {dir: projects, title: Projekte}
|
project: {dir: projects, title: Projekte}
|
||||||
system: {dir: systems, title: Systeme}
|
system: {dir: systems, title: Systeme}
|
||||||
@@ -16,47 +17,31 @@ layout:
|
|||||||
|
|
||||||
# Entity
|
# Entity
|
||||||
|
|
||||||
`entity` ist der Typ für konkrete Dinge: Projekte, Systeme, Werkzeuge, Technologien oder Personen. Entities sind die primären Bausteine des Wissensgraphen.
|
This instance's configuration for the `entity` type: its frontmatter fields as this schema
|
||||||
|
requires them, and the page skeleton `wikitool new entity` scaffolds. When to use this type and
|
||||||
## Wann zu verwenden
|
how to write a conforming page is [types/entity.guidance.md](entity.guidance.md) -
|
||||||
|
`tools/wikitool types describe entity` composes both into one answer.
|
||||||
- Darstellung eines Softwareprojekts, einer Initiative oder eines Arbeitsvorhabens
|
|
||||||
- Dokumentation eines betriebenen Systems, Dienstes oder einer Infrastrukturkomponente
|
|
||||||
- Beschreibung eines CLI-Werkzeugs, Hilfsprogramms oder einer Programmbibliothek
|
|
||||||
- Festhalten von Informationen zu einer Sprache, einem Framework oder einem Protokoll
|
|
||||||
- Dokumentation einer Person, eines Teams oder einer Organisation
|
|
||||||
|
|
||||||
## Wann NICHT zu verwenden
|
|
||||||
|
|
||||||
- Für abstrakte Konzepte, Muster oder Methodiken (dann `concept`)
|
|
||||||
- Für Quelldokumente oder eingelesenes Material (dann `source`)
|
|
||||||
- Für vergleichende Analysen (dann `comparison`)
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
| Feld | Pflicht | Verwendung |
|
| Field | Required | Use |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `type` | Ja | `types/entity.md` |
|
| `type` | Yes | `types/entity.md` |
|
||||||
| `entity_type` | Ja | Eines von: project, system, tool, technology, person |
|
| `entity_type` | Yes | One of: project, system, tool, technology, person |
|
||||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
| `tags` | No | Navigation tags for categorization |
|
||||||
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
| `created` | Yes | Creation date (YYYY-MM-DD) |
|
||||||
| `modified` | Ja | Datum der letzten Änderung (YYYY-MM-DD) |
|
| `modified` | Yes | Date last changed (YYYY-MM-DD) |
|
||||||
| `related` | Nein | Titel verwandter Seiten (Entities, Concepts, Sources) |
|
| `related` | No | Titles of related pages (entities, concepts, sources) |
|
||||||
| `sources` | Nein | Titel der Source-Seiten, die die Aussagen dieser Seite stützen |
|
| `sources` | No | Titles of the source pages backing this page's claims |
|
||||||
| `provenance` | Ja | sourced, general oder mixed |
|
| `provenance` | Yes | sourced, general or mixed |
|
||||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Autorenanweisungen
|
|
||||||
|
|
||||||
- Der Titel ist der kanonische Name der Entity und folgt der etablierten Bezeichnung des Gegenstands, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
|
|
||||||
- Die Hauptbeschreibung steht weit oben
|
|
||||||
- Auf verwandte Entities und Concepts verlinken, wo Beziehungen bestehen
|
|
||||||
- Bei `provenance: sourced` oder `mixed` harte Fakten inline mit einer `[^cite-id]`-Fußnote belegen -
|
|
||||||
`tools/wikitool cite add --page "<Title>" --source "Source - X"` erzeugt Id und Definition
|
|
||||||
- Wikipedia-Tonfall: sachlich, neutral, keine Werbesprache
|
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|
||||||
|
The block below is page material, so it is written in this instance's KB language
|
||||||
|
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
|
||||||
|
become the headings of every page `wikitool new entity` scaffolds.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# {name}
|
# {name}
|
||||||
|
|
||||||
@@ -84,15 +69,7 @@ TODO: Ausführliche Informationen, nach sinnvollen Abschnitten gegliedert
|
|||||||
- [{today}] - Page created via wikitool
|
- [{today}] - Page created via wikitool
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Beziehungsabschnitt steht bewusst **nicht** im Template: er ist eine generierte Region, die
|
The relationships section is deliberately **not** in the template: it is a generated region that
|
||||||
`wikitool xref` beim ersten Kanteneintrag zwischen Markern anlegt und aus `related:` neu
|
`wikitool xref` creates between markers on the first edge entry and re-renders from `related:`.
|
||||||
rendert. Ein Autor schreibt dort nie hinein. Der Wert hinter `**Typ:**` bleibt der englische
|
An author never writes into it. The value behind `**Typ:**` stays the English enum value - that is
|
||||||
Enum-Wert - danach filtert `search --field`.
|
what `search --field` filters on.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Ergänzende Hinweise:
|
|
||||||
|
|
||||||
- Entity-Seiten bilden den konkreten Bestand der Wissensbasis
|
|
||||||
- Jeder Entity-Typ (project, system, tool usw.) ist eine Ausprägung dieses Basistyps
|
|
||||||
- Die Beziehungen zwischen Entities tragen die Vernetzung des Wissensgraphen
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
type: types/type-guidance.md
|
||||||
|
name: source
|
||||||
|
description: When to write a source page instead of a neighboring type, and how to write a conforming one - capture-field, provenance and not-extracted rules that hold regardless of which source_type values this instance chose
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source Guidance
|
||||||
|
|
||||||
|
`source` is the type for pages that summarize and catalogue ingested raw material. Source pages
|
||||||
|
are the bridge between the `raw/` layer (immutable source files) and the `kb/` layer (compiled
|
||||||
|
knowledge). One source page stands for **one logical source**, which may span several raw files.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Summarizing a single external article, document or specification
|
||||||
|
- Recording several related notes or meeting records as one source
|
||||||
|
- Documenting an ingested PDF, manual or other document
|
||||||
|
- Capturing information about an image or a diagram
|
||||||
|
|
||||||
|
## When NOT to use
|
||||||
|
|
||||||
|
- For compiled knowledge (use `entity` or `concept`)
|
||||||
|
- For comparative analyses (use `comparison`)
|
||||||
|
- For original wiki content not derived from raw material
|
||||||
|
|
||||||
|
## Authoring guidance
|
||||||
|
|
||||||
|
- `source_type` has **no default** - `wikitool new source` refuses without an explicit value. Where the category is unclear, set `unclassified` rather than guessing; that is a visible catalog slot with an advisory `lint` finding, not a dumping ground. What separates `analysis` from `document`, and which area holds which value, is a call this instance's own collection contract makes - see `kb/sources/COLLECTION.md`
|
||||||
|
- `raw_files` lists every raw file this source covers (one source page per logical source, not per file)
|
||||||
|
- `fidelity` and `authority` are **capture fields** (`capture_fields:` on this type): recorded at the drop point and not freely changeable afterwards. `wikitool raw accept --fidelity <value> --authority <value>` refuses without both; without `--page` it instead prints the ready-made `new source --set fidelity=... --set authority=...` line, which `new source` in turn refuses without both values. `wikitool touch --set fidelity=<value>` only writes while the field is absent - where a value already stands, it refuses and points at `raw accept --replaces` as the one correction path (a corrected capture is a new edition, not an edit). `unknown` is backfill-only: only `wikitool touch` may write it, never `raw accept` or `new source` - the same construction as `source_language` uses for pages that predate this rule. What the values mean and how they differ: [raw/CONTRACT.md](../raw/CONTRACT.md#getting-a-file-in-incoming)
|
||||||
|
- For external articles, always set `source_url` to the origin URL
|
||||||
|
- Set `source_language` to the raw material's language, not the page's
|
||||||
|
- The page is written in the KB language, whatever language the source is in; verbatim passages are quoted in the original (`kb/CONVENTIONS.md` § "Language")
|
||||||
|
- Summarize the key claims in the summary section
|
||||||
|
- Put anything actionable in the action items section
|
||||||
|
- Put deliberate omissions in the not-extracted section - see below
|
||||||
|
- Link the entities and concepts mentioned under related entities/concepts
|
||||||
|
|
||||||
|
## Not Extracted
|
||||||
|
|
||||||
|
The decision that material should *not* be taken over cannot be reconstructed: nothing in the
|
||||||
|
repository can re-derive it, and `sources coverage` only knows whether a raw file is claimed by
|
||||||
|
some source page - never whether anyone decided about its contents. Left unwritten, the same
|
||||||
|
source is renegotiated on every later pass.
|
||||||
|
|
||||||
|
- Record every deliberate omission with a **reason**, not just a filename.
|
||||||
|
- Mandatory where the ingest ran through `instructions/ingest-large-tree.md` - on both axes: for
|
||||||
|
a tree ingest the section records what was not taken from the tree; for a thematically broad
|
||||||
|
single source, which named subjects got no page of their own, and why. Optional for a single
|
||||||
|
small file - but an empty section still beats a missing one.
|
||||||
|
- Belongs on the source page, not in `kb/log.md`: it is a statement about *this* source, and the
|
||||||
|
log is chronological rather than per-source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Relevant Notes:
|
||||||
|
|
||||||
|
- Source pages are the authoritative catalogue of what raw material has been ingested
|
||||||
|
- **One raw file, one owner.** A raw file appears in exactly one `raw_files:` - that page is responsible for keeping it summarized. Any number of pages may **cite** it via `[^cite-id]`; a citation is reuse, `raw_files:` is a maintenance responsibility. With two claimants it is undefined which page has to be brought up to date when the raw file changes - and then both rot quietly
|
||||||
|
- Source pages make knowledge traceable back to the original raw material
|
||||||
|
- `raw_files:` holds concrete existing file paths, never directories
|
||||||
|
- A `raw_files:` list beyond roughly 15 entries indicates the cut was too coarse - the source should have been split into several source pages via `instructions/ingest-large-tree.md`
|
||||||
|
- `entities:` plus `concepts:` beyond roughly 20 entries is the counterpart on the other axis: not cut too little, but compiled too much at once. Such a source is not split - a raw file has one owner - it needed the extract pass from `instructions/ingest-large-tree.md` § "A broad source is not cut", so that not every named subject gets a page
|
||||||
+28
-80
@@ -1,13 +1,14 @@
|
|||||||
---
|
---
|
||||||
type: types/type-spec.md
|
type: types/type-spec.md
|
||||||
name: source
|
name: source
|
||||||
description: Strukturierter Typ für Source-Seiten, die eingelesenes Rohmaterial erfassen und zusammenfassen
|
description: Structured type for source pages that record and summarize ingested raw material
|
||||||
schema: types/source.schema.yaml
|
schema: types/source.schema.yaml
|
||||||
subtype_field: source_type
|
subtype_field: source_type
|
||||||
base_dir: sources
|
base_dir: sources
|
||||||
title_prefix: "Source - "
|
title_prefix: "Source - "
|
||||||
page_ref_fields: [entities, concepts]
|
page_ref_fields: [entities, concepts]
|
||||||
capture_fields: [fidelity, authority]
|
capture_fields: [fidelity, authority]
|
||||||
|
guidance: types/source.guidance.md
|
||||||
layout:
|
layout:
|
||||||
transcript: {dir: transcripts, title: Transkripte}
|
transcript: {dir: transcripts, title: Transkripte}
|
||||||
analysis: {dir: analyses, title: Analysen}
|
analysis: {dir: analyses, title: Analysen}
|
||||||
@@ -20,71 +21,35 @@ layout:
|
|||||||
|
|
||||||
# Source
|
# Source
|
||||||
|
|
||||||
`source` ist der Typ für Seiten, die eingelesenes Rohmaterial zusammenfassen und katalogisieren. Source-Seiten sind die Brücke zwischen der `raw/`-Schicht (unveränderliche Quelldateien) und der `kb/`-Schicht (kompiliertes Wissen). Eine Source-Seite steht für **eine logische Quelle**, die mehrere Raw-Dateien umfassen kann.
|
This instance's configuration for the `source` type: its frontmatter fields as this schema
|
||||||
|
requires them, and the page skeleton `wikitool new source` scaffolds. When to use this type and
|
||||||
## Wann zu verwenden
|
how to write a conforming page is [types/source.guidance.md](source.guidance.md) -
|
||||||
|
`tools/wikitool types describe source` composes both into one answer.
|
||||||
- Zusammenfassung eines einzelnen externen Artikels, Dokuments oder einer Spezifikation
|
|
||||||
- Erfassung mehrerer zusammengehöriger Notizen oder Gesprächsprotokolle als eine Quelle
|
|
||||||
- Dokumentation eines eingelesenen PDFs, Handbuchs oder sonstigen Dokuments
|
|
||||||
- Festhalten von Informationen zu einem Bild oder Diagramm
|
|
||||||
|
|
||||||
## Wann NICHT zu verwenden
|
|
||||||
|
|
||||||
- Für kompiliertes Wissen (dann `entity` oder `concept`)
|
|
||||||
- Für vergleichende Analysen (dann `comparison`)
|
|
||||||
- Für originären Wiki-Inhalt, der nicht aus Rohmaterial abgeleitet ist
|
|
||||||
|
|
||||||
## Frontmatter
|
## Frontmatter
|
||||||
|
|
||||||
| Feld | Pflicht | Verwendung |
|
| Field | Required | Use |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `type` | Ja | `types/source.md` |
|
| `type` | Yes | `types/source.md` |
|
||||||
| `source_type` | Ja | Eines von: transcript, analysis, article, document, notes, tracker, unclassified - kein Default, siehe unten |
|
| `source_type` | Yes | One of: transcript, analysis, article, document, notes, tracker, unclassified - no default, see below |
|
||||||
| `author` | Ja | Urheber des Quellmaterials |
|
| `author` | Yes | Originator of the source material |
|
||||||
| `raw_files` | Ja | Raw-Dateipfade, die diese Quelle **besitzt** - siehe "Eine Raw-Datei, ein Besitzer" unten |
|
| `raw_files` | Yes | Raw file paths this source **owns** - see "One raw file, one owner" below |
|
||||||
| `fidelity` | Ja (im Werkzeug, nicht im Schema) | Wie treu die *Erfassung* ist: `verbatim`, `published`, `secondhand`, `nontextual` - Capture-Feld, siehe unten |
|
| `fidelity` | Yes (in the tool, not in the schema) | How faithful the *capture* is: `verbatim`, `published`, `secondhand`, `nontextual` - a capture field, see below |
|
||||||
| `authority` | Ja (im Werkzeug, nicht im Schema) | Was das Material über seinen *Gegenstand* behaupten darf: `normative`, `reporting`, `opinion` - Capture-Feld, siehe unten |
|
| `authority` | Yes (in the tool, not in the schema) | What the material may claim about its *subject*: `normative`, `reporting`, `opinion` - a capture field, see below |
|
||||||
| `source_url` | Nein | Ursprungs-URL bei externen Quellen |
|
| `source_url` | No | Origin URL for external sources |
|
||||||
| `source_language` | Nein | ISO-639-1-Code der Sprache des Rohmaterials, z. B. `de`, `en`, `fr` |
|
| `source_language` | No | ISO 639-1 code of the raw material's language, e.g. `de`, `en`, `fr` |
|
||||||
| `date` | Ja | Veröffentlichungs- oder Erstellungsdatum (YYYY-MM-DD) |
|
| `date` | Yes | Publication or creation date (YYYY-MM-DD) |
|
||||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
| `tags` | No | Navigation tags for categorization |
|
||||||
| `entities` | Nein | Titel der in dieser Quelle erwähnten Entities |
|
| `entities` | No | Titles of the entities mentioned in this source |
|
||||||
| `concepts` | Nein | Titel der in dieser Quelle erwähnten Concepts |
|
| `concepts` | No | Titles of the concepts mentioned in this source |
|
||||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
| `summary` | Yes | One-liner for `kb/index.md` |
|
||||||
|
|
||||||
## Autorenanweisungen
|
|
||||||
|
|
||||||
- Der Titel beginnt mit "Source - ", gefolgt vom Namen der Quelle
|
|
||||||
- `source_type` hat **keinen Default** - `wikitool new source` verweigert ohne einen expliziten Wert. Ist die Kategorie unklar, `unclassified` setzen statt zu raten; das ist ein sichtbares Katalogfach mit beratendem `lint`-Befund, kein Sammelbecken. Was `analysis` von `document` trennt und welche Area welchen Wert hält: [kb/sources/COLLECTION.md](../kb/sources/COLLECTION.md)
|
|
||||||
- `raw_files` listet jede Raw-Datei, die diese Quelle abdeckt (eine Source-Seite pro logischer Quelle, nicht pro Datei)
|
|
||||||
- `fidelity` und `authority` sind **Capture-Felder** (`capture_fields:` oben): am Drop-Punkt erhoben, danach nicht mehr frei änderbar. `wikitool raw accept --fidelity <wert> --authority <wert>` verweigert ohne beide; ohne `--page` druckt es stattdessen die fertige `new source --set fidelity=... --set authority=...`-Zeile, die `new source` seinerseits ohne beide Werte verweigert. `wikitool touch --set fidelity=<wert>` schreibt nur, solange das Feld fehlt - steht bereits ein Wert, verweigert es und verweist auf `raw accept --replaces` als einzigen Korrekturweg (eine korrigierte Erfassung ist eine neue Edition, keine Bearbeitung). `unknown` ist backfill-only: nur `wikitool touch` darf es schreiben, nie `raw accept` oder `new source` - dieselbe Konstruktion wie bei `source_language` für Seiten, die vor dieser Regel entstanden sind. Was die Werte bedeuten und wie sie sich unterscheiden: [raw/CONTRACT.md](../raw/CONTRACT.md#getting-a-file-in-incoming)
|
|
||||||
- Bei externen Artikeln immer `source_url` auf die Ursprungs-URL setzen
|
|
||||||
- `source_language` auf die Sprache des Rohmaterials setzen, nicht auf die der Seite
|
|
||||||
- Die Seite wird in der KB-Sprache geschrieben, unabhängig von der Sprache der Quelle; wörtliche Passagen werden im Original zitiert (`kb/CONVENTIONS.md` § "Language")
|
|
||||||
- Kernaussagen im Abschnitt Summary zusammenfassen
|
|
||||||
- Handlungsbedarf in den Abschnitt Action Items
|
|
||||||
- Bewusst Weggelassenes in den Abschnitt Not Extracted - siehe unten
|
|
||||||
- Erwähnte Entities und Concepts unter Related Entities/Concepts verlinken
|
|
||||||
|
|
||||||
## Not Extracted
|
|
||||||
|
|
||||||
Die Entscheidung, dass Material *nicht* übernommen werden soll, ist nicht rekonstruierbar: nichts
|
|
||||||
im Repository kann sie neu herleiten, und `sources coverage` weiß nur, ob eine Raw-Datei von
|
|
||||||
irgendeiner Source-Seite beansprucht wird - nie, ob jemand über ihren Inhalt entschieden hat.
|
|
||||||
Bleibt das unaufgeschrieben, wird dieselbe Quelle bei jedem späteren Durchgang neu verhandelt.
|
|
||||||
|
|
||||||
- Jede bewusste Auslassung mit **Begründung** festhalten, nicht nur mit Dateinamen.
|
|
||||||
- Pflicht, wenn der Ingest über `instructions/ingest-large-tree.md` lief - auf beiden Achsen:
|
|
||||||
beim Tree-Ingest hält der Abschnitt fest, was aus dem Baum nicht übernommen wurde, bei einer
|
|
||||||
thematisch breiten Einzelquelle, welche genannten Gegenstände keine eigene Seite bekommen
|
|
||||||
haben und warum. Optional bei einer einzelnen kleinen Datei - aber ein leerer Abschnitt ist
|
|
||||||
immer noch besser als ein fehlender.
|
|
||||||
- Gehört auf die Source-Seite, nicht in `kb/log.md`: es ist eine Aussage über *diese* Quelle,
|
|
||||||
und das Log ist chronologisch, nicht quellenbezogen.
|
|
||||||
|
|
||||||
## Template
|
## Template
|
||||||
|
|
||||||
|
The block below is page material, so it is written in this instance's KB language
|
||||||
|
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
|
||||||
|
become the headings of every page `wikitool new source` scaffolds.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Source: {name}
|
# Source: {name}
|
||||||
|
|
||||||
@@ -121,24 +86,7 @@ TODO: 2-3 Absätze zu den Kernaussagen des Quellmaterials.
|
|||||||
{concepts|bullets}
|
{concepts|bullets}
|
||||||
```
|
```
|
||||||
|
|
||||||
`# Source:` bleibt als Präfix stehen - es spiegelt den `title_prefix` und damit den Titel, unter
|
`# Source:` stays as a prefix - it mirrors the `title_prefix` and with it the title the page is
|
||||||
dem die Seite verlinkt und zitiert wird. Der Wert hinter `**Typ:**` bleibt der englische
|
linked and cited under. The value behind `**Typ:**` stays the English enum value. When
|
||||||
Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende der toolgeführte
|
`wikitool cite` adds a citation, the tool-managed footnote block appears at the end of the page;
|
||||||
Fußnoten-Block; wie er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
|
what it is called is the instance's decision in `kb/CONVENTIONS.md` (`sections:`).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Ergänzende Hinweise:
|
|
||||||
|
|
||||||
- Source-Seiten sind der maßgebliche Katalog dessen, was an Rohmaterial eingelesen wurde
|
|
||||||
- **Eine Raw-Datei, ein Besitzer.** Eine Raw-Datei steht in genau einem `raw_files:` - diese Seite ist dafür verantwortlich, sie zusammengefasst zu halten. Beliebig viele Seiten dürfen sie per `[^cite-id]` **zitieren**; ein Zitat ist Wiederverwendung, `raw_files:` ist eine Wartungszuständigkeit. Bei zwei Anspruchstellern ist undefiniert, welche Seite bei einer Änderung der Raw-Datei nachgezogen werden muss - dann verrotten beide still
|
|
||||||
- Source-Seiten machen Wissen bis zum ursprünglichen Rohmaterial rückverfolgbar
|
|
||||||
- `raw_files:` enthält konkrete existierende Dateipfade, nie Verzeichnisse
|
|
||||||
- Eine `raw_files:`-Liste jenseits von etwa 15 Einträgen zeigt an, dass der Schnitt zu grob war -
|
|
||||||
die Quelle hätte per `instructions/ingest-large-tree.md` in mehrere Source-Seiten geteilt
|
|
||||||
werden müssen
|
|
||||||
- `entities:` plus `concepts:` jenseits von etwa 20 Einträgen ist das Gegenstück auf der anderen
|
|
||||||
Achse: nicht zu wenig geschnitten, sondern zu viel auf einmal kompiliert. Geteilt wird eine
|
|
||||||
solche Quelle nicht - eine Raw-Datei hat einen Besitzer -, sie hätte den Extract-Pass aus
|
|
||||||
`instructions/ingest-large-tree.md` § "A broad source is not cut" gebraucht, damit nicht jeder
|
|
||||||
genannte Gegenstand eine Seite bekommt
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
type: types/type-spec.md
|
||||||
|
name: type-guidance
|
||||||
|
description: "Stack-owned authoring prose for a root:kb page type - the machine-independent half a type-spec's optional guidance: field links to"
|
||||||
|
schema: types/type-guidance.schema.yaml
|
||||||
|
---
|
||||||
|
|
||||||
|
# Type Guidance
|
||||||
|
|
||||||
|
`type-guidance` describes the shape of one `types/<name>.guidance.md` file: the stack-owned half
|
||||||
|
of a `root: kb` page type's contract. A page type-spec ([types/type-spec.md](type-spec.md) §
|
||||||
|
"Who owns a type-spec") splits at the audience line - what an agent needs to know to write a
|
||||||
|
conforming page, versus what this instance's schema and template concretely are - and a guidance
|
||||||
|
file is the machine-independent half of that split, linked from the type-spec it documents via
|
||||||
|
that file's own `guidance:` frontmatter field.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Writing or improving the generic authoring rules for a `root: kb` type: when to choose it, when
|
||||||
|
not to, and mechanism-level advice that holds regardless of which enum values, directory names
|
||||||
|
or template text this instance chose - citation mechanics, provenance rules, what a conforming
|
||||||
|
page does or does not claim
|
||||||
|
- Anything a later stack release would want every adopting instance to receive automatically,
|
||||||
|
the way a fix to `tools/` already does
|
||||||
|
|
||||||
|
## When NOT to Use
|
||||||
|
|
||||||
|
- Enum values, `layout:` titles, directory names or template text. Those name *this* instance's
|
||||||
|
choices and stay in the type-spec itself - the file `guidance:` points *from*, never the one it
|
||||||
|
points *to*
|
||||||
|
- A type an instance writes entirely for itself. `guidance:` is optional; a type-spec that omits
|
||||||
|
it is described from its own body alone, exactly as every type-spec worked before this type
|
||||||
|
existed
|
||||||
|
|
||||||
|
## Frontmatter
|
||||||
|
|
||||||
|
| Field | Required | Use |
|
||||||
|
|---|---:|---|
|
||||||
|
| `type` | Yes | `types/type-guidance.md` |
|
||||||
|
| `name` | Yes | The page type this documents, e.g. `entity` - matches that type-spec's own `name:` |
|
||||||
|
| `description` | Yes | Retrieval description for this guidance document |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Linked from the type-spec it documents via that file's `guidance:` frontmatter field - a
|
||||||
|
repo-relative path, resolved the same way `schema:` is
|
||||||
|
- `tools/wikitool types describe <name>` composes both halves into one answer; an agent asking
|
||||||
|
for a type's contract never needs to know it comes from two files
|
||||||
|
- Written in the control plane's English ([AGENTS.md](../AGENTS.md) § File naming), like a
|
||||||
|
type-spec's own authoring prose - only the type-spec's `## Template` block and its `layout:`
|
||||||
|
titles are page material
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Relevant Notes:
|
||||||
|
|
||||||
|
- Ships verbatim with every distribution; the type-spec it documents ships only as a `.template`
|
||||||
|
where `root: kb` - see `docs/ownership-and-templates.md` § "Where the file boundary used to
|
||||||
|
strain"
|
||||||
|
- No `## Template` section here: a guidance file's body is prose an author reads, not a body
|
||||||
|
structure `wikitool new` would scaffold. `type-guidance` declares no `base_dir:` for the same
|
||||||
|
reason `lint-report` does, and `wikitool new type-guidance` refuses for the same reason
|
||||||
|
- An instance that adopted its type-specs before this type existed sees nothing change until it
|
||||||
|
takes the offered migration that adds `guidance:` to each one - see
|
||||||
|
`instructions/migrations/` for the document
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# YAML Schema for type-guidance documents
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
const: "types/type-guidance.md"
|
||||||
|
description: Must reference the type-guidance type-spec
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
The page type this guidance documents, e.g. 'entity' - matches that
|
||||||
|
type-spec's own `name:` field
|
||||||
|
minLength: 1
|
||||||
|
pattern: "^[a-z][a-z0-9-]*$"
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Retrieval description for this guidance document
|
||||||
|
minLength: 10
|
||||||
|
required:
|
||||||
|
- type
|
||||||
|
- name
|
||||||
|
- description
|
||||||
|
additionalProperties: false
|
||||||
+54
-11
@@ -33,6 +33,7 @@ A type-spec doc is the authoring and validation contract for one Chemenu artifac
|
|||||||
- `description`: retrieval description for the type contract.
|
- `description`: retrieval description for the type contract.
|
||||||
- `schema`: repo-relative path to the `.schema.yaml` file that validates artifacts of this type, or `null` when the type has no schema.
|
- `schema`: repo-relative path to the `.schema.yaml` file that validates artifacts of this type, or `null` when the type has no schema.
|
||||||
- `subtype_field` (optional): the frontmatter field name on instances of this type that carries a subtype/category (e.g. `entity_type`), or omitted if this type has no such field.
|
- `subtype_field` (optional): the frontmatter field name on instances of this type that carries a subtype/category (e.g. `entity_type`), or omitted if this type has no such field.
|
||||||
|
- `guidance` (optional): repo-relative path to a `types/type-guidance.md`-shaped document holding this type's stack-owned authoring prose - see § "Anatomy of a type" below. Meaningful only on a `root: kb` type-spec; omit for a type an instance writes entirely for itself.
|
||||||
|
|
||||||
## Validation Contract
|
## Validation Contract
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ Two systems check conformance to a type:
|
|||||||
|
|
||||||
Do not restate a schema rule in body text. The validator already enforces it, so the restatement adds no protection — it only spends the reviewer's judgment re-confirming something already guaranteed, instead of on the properties only a reviewer can check.
|
Do not restate a schema rule in body text. The validator already enforces it, so the restatement adds no protection — it only spends the reviewer's judgment re-confirming something already guaranteed, instead of on the properties only a reviewer can check.
|
||||||
|
|
||||||
|
A type-spec's own frontmatter is checked the same way a page's is: `docs verify` validates every file under `types/` declaring `type: types/type-spec.md` against `types/type-spec.schema.yaml`. A frontmatter field used here needs a matching line in that schema, or `docs verify` fails.
|
||||||
|
|
||||||
## Writing Shape
|
## Writing Shape
|
||||||
|
|
||||||
- Write the body as a **content contract**: checkable properties of a conforming instance, in a form the type-conformance gate can apply directly. Prefer "what a conforming instance contains or claims" over "how to produce one" — production process is invisible to the reviewer, so it belongs in a skill or instruction, linked rather than embedded.
|
- Write the body as a **content contract**: checkable properties of a conforming instance, in a form the type-conformance gate can apply directly. Prefer "what a conforming instance contains or claims" over "how to produce one" — production process is invisible to the reviewer, so it belongs in a skill or instruction, linked rather than embedded.
|
||||||
@@ -65,13 +68,41 @@ frontmatter before anyone drew it:
|
|||||||
| Type-spec | Describes | Owned by | Ships as |
|
| Type-spec | Describes | Owned by | Ships as |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `root: kb` (`entity`, `concept`, `source`, `comparison`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
|
| `root: kb` (`entity`, `concept`, `source`, `comparison`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
|
||||||
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec` itself | A stack artifact | The stack | Verbatim |
|
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec`/`type-guidance` themselves | A stack artifact | The stack | Verbatim |
|
||||||
|
|
||||||
A page type-spec's prose, its `## Template` body and its language are therefore the instance's
|
A page type-spec's frontmatter configuration and its `## Template` body are therefore the
|
||||||
to rewrite — an instance writing its pages in another language simply translates the file, and
|
instance's to rewrite, and an upgrade does not take that back. Its generic authoring prose is the
|
||||||
an upgrade does not take that back. Improvements to a shipped default reach it as an *offered*
|
opposite: where the type-spec declares `guidance:`, that prose lives in a separate, stack-owned
|
||||||
migration ([instructions/CONTRACT.md](../instructions/CONTRACT.md#instructionsmigrations)),
|
`types/<name>.guidance.md` (§ "Anatomy of a type" below) that ships verbatim and is overwritten
|
||||||
never by overwriting.
|
by an upgrade like any other machinery file - the type-spec it documents does not have to be
|
||||||
|
re-adopted, or even touched, for that improvement to arrive. A type-spec that declares no
|
||||||
|
`guidance:` (a type an instance writes entirely for itself) has no stack half to receive; it is
|
||||||
|
described from its own body alone, exactly as every type-spec worked before this split existed.
|
||||||
|
An *existing* instance that adopted its type-specs before `guidance:` existed takes this as an
|
||||||
|
*offered* migration ([instructions/CONTRACT.md](../instructions/CONTRACT.md#instructionsmigrations))
|
||||||
|
rather than something an upgrade applies on its own - see `instructions/migrations/` for the
|
||||||
|
document.
|
||||||
|
|
||||||
|
**Ownership and language still do not line up with each other**, and that stays true even though
|
||||||
|
the authoring prose now has its own file rather than sharing one with the template. Owning the
|
||||||
|
type-spec means the instance may rewrite its frontmatter and its `## Template` body; a guidance
|
||||||
|
file it links is never the instance's to rewrite at all - it is the stack's, the same way
|
||||||
|
`tools/CONTRACT.md` is. What each part is *written in* still follows what the line is *for*, per
|
||||||
|
[AGENTS.md § File naming](../AGENTS.md#file-naming), independent of which file it lives in or who
|
||||||
|
owns that file:
|
||||||
|
|
||||||
|
| Part of a `root: kb` type's contract | Lives in | Audience | Language |
|
||||||
|
|---|---|---|---|
|
||||||
|
| When to use / not to use, authoring guidance | The `guidance:` file, where declared | An agent writing a page | The control plane's — English |
|
||||||
|
| The frontmatter table documenting this instance's own fields | The type-spec itself | An agent writing a page | The control plane's — English |
|
||||||
|
| The `## Template` body, and the `layout:` titles that head a catalog section | The type-spec itself | The page itself | The instance's KB language (`kb/CONVENTIONS.md` `language:`) |
|
||||||
|
| Field names, enum values, `dir:` values, `type:` paths | Either file's frontmatter | The machine | Neither — identifiers, never translated |
|
||||||
|
|
||||||
|
That is the same prose/identifier cut [kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers)
|
||||||
|
makes *inside* a page, applied one level up. It binds a type-spec an instance writes for itself
|
||||||
|
exactly as it binds the four shipped ones: a new page type is instance-owned end to end, which
|
||||||
|
settles who may change it, not which language each half is written in - and it may declare its
|
||||||
|
own `guidance:` file if it wants the same shape, though nothing requires it to.
|
||||||
|
|
||||||
**What the stack still requires of the type layer is one line.** There must be a type-spec
|
**What the stack still requires of the type layer is one line.** There must be a type-spec
|
||||||
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/` → `kb/`
|
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/` → `kb/`
|
||||||
@@ -87,14 +118,26 @@ Python. Adding a type must require no code change.
|
|||||||
|
|
||||||
### Anatomy of a type
|
### Anatomy of a type
|
||||||
|
|
||||||
Each type is two files:
|
Each type is at least two files, and a `root: kb` type may be three:
|
||||||
|
|
||||||
| File | Owns |
|
| File | Owns |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `types/<name>.md` | The authoring contract: when to use the type, when not to, guidance, and the `## Template` block used to scaffold new pages |
|
| `types/<name>.md` | This instance's configuration: its frontmatter fields as this schema requires them, and the `## Template` block used to scaffold new pages. For a type with no `guidance:` (below), its own prose also carries the authoring contract - when to use the type, when not to |
|
||||||
| `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 |
|
||||||
|
|
||||||
This file is the self-referential root contract that both are validated against.
|
**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
|
||||||
|
`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
|
||||||
|
type's files into one answer regardless of how many there are; an agent asking for a type's
|
||||||
|
contract never needs to know it came from more than one file.
|
||||||
|
|
||||||
### Placement frontmatter
|
### Placement frontmatter
|
||||||
|
|
||||||
@@ -135,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.
|
||||||
@@ -175,7 +218,7 @@ replace anyway. See `tools/chemenu/blocks.py`.
|
|||||||
| Frontmatter fields, enums, defaults, required-ness | Provenance and citation mechanics | Quality goal and tone |
|
| Frontmatter fields, enums, defaults, required-ness | Provenance and citation mechanics | Quality goal and tone |
|
||||||
| Directory placement and title prefix | Linking mechanics and the orphan check | Naming conventions |
|
| Directory placement and title prefix | Linking mechanics and the orphan check | Naming conventions |
|
||||||
| Body skeleton (template) | The prose/identifier rule | Relationship vocabulary |
|
| Body skeleton (template) | The prose/identifier rule | Relationship vocabulary |
|
||||||
| When to use / not use this type | — | The KB language and its section-heading names |
|
| When to use / not use this type | — | The KB language value, and its section-heading names |
|
||||||
|
|
||||||
If a rule would be identical for every type *and* every instance, it belongs in
|
If a rule would be identical for every type *and* every instance, it belongs in
|
||||||
`kb/CONTRACT.md`. If every instance would answer it differently, it belongs in
|
`kb/CONTRACT.md`. If every instance would answer it differently, it belongs in
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -69,6 +69,33 @@ properties:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
pattern: "^[a-z][a-z0-9_]*$"
|
pattern: "^[a-z][a-z0-9_]*$"
|
||||||
|
capture_fields:
|
||||||
|
type: array
|
||||||
|
description: >-
|
||||||
|
Frontmatter fields on instances of this type that are fixed at capture
|
||||||
|
time and never correctable afterwards except by re-capturing the source
|
||||||
|
(e.g. `fidelity`, `authority` for a source page). `raw accept`,
|
||||||
|
`new source` and `touch --set` read this instead of a hardcoded field
|
||||||
|
list. Omit for types with no capture-time fields.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
pattern: "^[a-z][a-z0-9_]*$"
|
||||||
|
root:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Which root a `base_dir:` resolves against - `kb` (the default, so
|
||||||
|
omitting this field is equivalent to `kb`) for a page this instance
|
||||||
|
writes, or `repo` for a type whose artifacts are legitimately not
|
||||||
|
knowledge (e.g. `instruction`).
|
||||||
|
enum: [kb, repo]
|
||||||
|
guidance:
|
||||||
|
type: string
|
||||||
|
description: >-
|
||||||
|
Repo-relative path to a `types/type-guidance.md`-shaped document
|
||||||
|
holding this type's stack-owned authoring prose. Meaningful only on a
|
||||||
|
`root: kb` type-spec; omit for a type an instance writes entirely for
|
||||||
|
itself.
|
||||||
|
pattern: "^types/.*\\.md$"
|
||||||
required:
|
required:
|
||||||
- type
|
- type
|
||||||
- name
|
- name
|
||||||
|
|||||||
Reference in New Issue
Block a user