From 502971d14734ef2e4ef9b9d181d4bec2c01d3efb Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Wed, 2 Sep 2026 15:02:10 +0200 Subject: [PATCH] feat: Autorenkonventionen nach Eigentum geschnitten - kb/CONVENTIONS.md, deklarierte Collections (3.0.0) Files changed: - .gitea/workflows/ci.yml - .wikitool-kb.json - AGENTS.md - CHANGES.md - INSTALL.md - README.md - VERSION - instructions/CONTRACT.md - instructions/dev/testing-conventions.md - instructions/german-terminology.md - instructions/kb-profiles.md - instructions/migrations/3.0.0-authoring-conventions.md - instructions/private-instance.md - instructions/setup-instance.md - instructions/wiki-ingest/SKILL.md - instructions/wiki-manage/SKILL.md - kb/CONTRACT.md - kb/CONVENTIONS.md - kb/CONVENTIONS.md.template - kb/comparisons/COLLECTION.md - kb/concepts/COLLECTION.md - kb/entities/COLLECTION.md - kb/sources/COLLECTION.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/new_page.py - tools/chemenu/conventions.py - tools/chemenu/kb_collections.py - tools/chemenu/kb_scan.py - tools/chemenu/provenance.py - tools/chemenu/sections.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_conventions.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_types_cmd.py - types/comparison.md - types/concept.md - types/entity.md - types/source.md - types/type-spec.md --- .gitea/workflows/ci.yml | 10 + .wikitool-kb.json | 10 +- AGENTS.md | 21 +- CHANGES.md | 79 ++++++ INSTALL.md | 31 ++- README.md | 13 +- VERSION | 2 +- instructions/CONTRACT.md | 9 +- instructions/dev/testing-conventions.md | 5 + instructions/german-terminology.md | 16 +- instructions/kb-profiles.md | 191 +++++++++++++++ .../migrations/3.0.0-authoring-conventions.md | 150 ++++++++++++ instructions/private-instance.md | 54 ++++- instructions/setup-instance.md | 66 ++++-- instructions/wiki-ingest/SKILL.md | 10 +- instructions/wiki-manage/SKILL.md | 12 +- kb/CONTRACT.md | 146 ++++++------ kb/CONVENTIONS.md | 126 ++++++++++ kb/CONVENTIONS.md.template | 104 ++++++++ kb/comparisons/COLLECTION.md | 16 +- kb/concepts/COLLECTION.md | 15 +- kb/entities/COLLECTION.md | 11 +- kb/sources/COLLECTION.md | 15 +- tools/CONTRACT.md | 6 +- tools/README.md | 29 ++- tools/chemenu/commands/dist_cmd.py | 56 ++++- tools/chemenu/commands/docs_verify.py | 22 +- tools/chemenu/commands/doctor.py | 50 +++- tools/chemenu/commands/new_page.py | 16 +- tools/chemenu/conventions.py | 224 ++++++++++++++++++ tools/chemenu/kb_collections.py | 98 ++++++++ tools/chemenu/kb_scan.py | 6 +- tools/chemenu/provenance.py | 32 ++- tools/chemenu/sections.py | 63 ++++- tools/chemenu/tests/conftest.py | 10 +- tools/chemenu/tests/test_conventions.py | 167 +++++++++++++ tools/chemenu/tests/test_dist_cmd.py | 34 ++- tools/chemenu/tests/test_doctor.py | 34 ++- tools/chemenu/tests/test_new_page.py | 25 ++ tools/chemenu/tests/test_types_cmd.py | 9 +- types/comparison.md | 6 +- types/concept.md | 7 +- types/entity.md | 14 +- types/source.md | 6 +- types/type-spec.md | 23 +- 45 files changed, 1817 insertions(+), 232 deletions(-) create mode 100644 instructions/kb-profiles.md create mode 100644 instructions/migrations/3.0.0-authoring-conventions.md create mode 100644 kb/CONVENTIONS.md create mode 100644 kb/CONVENTIONS.md.template create mode 100644 tools/chemenu/conventions.py create mode 100644 tools/chemenu/tests/test_conventions.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7507ee0..88ca9a4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -221,6 +221,16 @@ jobs: for personal in USER SOUL; do grep -v 'wikitool:template-unfilled' "$personal.md.template" > "$personal.md" done + # The authoring conventions ride the same split one directory down, + # and are stubbed the same way: what is under test is that the export + # carries the templates and that `doctor`/`docs verify` accept an + # adopted one, not what a person would write into them. The collection + # contracts are adopted verbatim - the shipped text is a working + # default, unlike a personalization file. + grep -v 'wikitool:template-unfilled' kb/CONVENTIONS.md.template > kb/CONVENTIONS.md + for template in kb/*/COLLECTION.md.template; do + cp "$template" "${template%.template}" + done python3 -m venv tools/.venv tools/.venv/bin/pip install --quiet -r tools/requirements.txt tools/wikitool instructions sync diff --git a/.wikitool-kb.json b/.wikitool-kb.json index 95c2591..6d40af5 100644 --- a/.wikitool-kb.json +++ b/.wikitool-kb.json @@ -1,5 +1,11 @@ { "schema": 1, - "kb_version": "1.0.0", - "applied": [] + "kb_version": "3.0.0", + "applied": [ + { + "migration": "3.0.0-authoring-conventions", + "at": "2026-09-02", + "pages": 0 + } + ] } diff --git a/AGENTS.md b/AGENTS.md index 77b7490..fd9f4fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,8 @@ What a file is called says who it is for and how it is loaded. This is a rule, n | `SOUL.md` | Agents | Always, every session | | `ENVIRONMENT.md` | Agents | Every session, **if it exists** - the one optional file in this table. Not committed: it describes one checkout, not the repo | | `/CONTRACT.md` | Agents | When writing in that stage | -| `kb//COLLECTION.md` | Agents | When writing in that collection | +| `kb/CONVENTIONS.md` | Agents | When writing any page - it holds what *this* instance decided about authoring (language, section headings, naming, tone, relationship labels, confidence rubric), where `kb/CONTRACT.md` holds what the stack enforces. Instance-owned: a distribution ships only the `.template` | +| `kb//COLLECTION.md` | Agents | When writing in that collection. Instance-owned in the same way, and declares in frontmatter which profile it adopted | | `instructions/.md` | Agents | By link, or on explicit request | | `instructions//SKILL.md` | Agents | By the harness, once published | | `types/.md` | Agents + validator | Via `tools/wikitool types describe` | @@ -104,6 +105,16 @@ and `SOUL.md.template`; the Personalization step of writes the real files. `tools/wikitool doctor` FAILs on a missing one, and on one still carrying the template's sentinel. +The same `.template` split runs one directory down, for authoring rather than for voice. +`kb/CONVENTIONS.md` and each `kb//COLLECTION.md` bind every page and belong to the +instance, so a distribution ships them as templates and the KB-language step of +[instructions/setup-instance.md](instructions/setup-instance.md) fills them in, out of a +catalogue of ready-made profiles it routes to; `doctor` FAILs on a missing or unfilled +`kb/CONVENTIONS.md` the same way. + +Unlike `USER.md`, these two *are* a source of rules: they are as binding as `kb/CONTRACT.md`. +What differs is ownership, not authority. + ## Environment `ENVIRONMENT.md` records what *this checkout* works through - harness, published skills, @@ -142,15 +153,17 @@ Alongside it, not part of it: `instructions/` (what agents are told to do) and t |-------|----------|--------| | `raw/` | [raw/CONTRACT.md](raw/CONTRACT.md) | Immutability, directory routing, untrusted-content rule | | `types/` | [types/type-spec.md](types/type-spec.md) | Type-spec anatomy, placement, adding a type, template variables | -| `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) | Collections, naming, tone, linking, provenance, confidence | +| `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) + `kb/CONVENTIONS.md` | What the stack enforces about a page (collections, linking, provenance, confidence machinery), and beside it what this instance decided (language, naming, tone, labels, rubric) | | `reports/` | [reports/CONTRACT.md](reports/CONTRACT.md) | Why reports and traces are generated, gitignored, and carried into `kb/log.md` | | `work/` | [work/CONTRACT.md](work/CONTRACT.md) | Workshop runs: run keys, required files, why they are tracked, how a run closes | | `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Full command reference, per-command error contracts, maintenance schedule | | `instructions/` | [instructions/CONTRACT.md](instructions/CONTRACT.md) | Instruction vs. skill, publishing, writing standard | **By collection** - then read the contract for the collection you are writing in. -[kb/CONTRACT.md](kb/CONTRACT.md) routes between `kb/entities/`, `kb/concepts/`, `kb/sources/` -and `kb/comparisons/`, and holds the rules they share. +[kb/CONTRACT.md](kb/CONTRACT.md) routes between this instance's collections and holds the rules +the stack enforces across all of them; `kb/CONVENTIONS.md` holds the ones this instance chose. +Both bind. The difference is who may change the sentence - which is also why a distribution +ships the first verbatim and the second only as a `.template`. **By task** - skills hold the step-by-step procedures. Sources live in `instructions//`: diff --git a/CHANGES.md b/CHANGES.md index 83cec3a..14e7c93 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,85 @@ their date-only headings. --- +## 3.0.0 - 2026-09-02 - Autorenkonventionen nach Eigentum geschnitten: kb/CONVENTIONS.md, deklarierte Collections + +**Author:** Torben Nehmer + +**Breaking Change:** kb/CONTRACT.md ist um alles gekuerzt, was eine Instanz selbst entscheidet; das steht jetzt in einer neuen, instanzeigenen kb/CONVENTIONS.md, aus der der Compiler die drei toolgefuehrten Abschnittsnamen liest. Eine bestehende Instanz muss diese Datei anlegen, auf jedem kb/*/COLLECTION.md profile: und required_by_stack: deklarieren und kb/CONTRACT.md aus dem Release nachziehen - sonst FAILt doctor und docs verify bricht. Ablauf: instructions/migrations/3.0.0-authoring-conventions.md + +`kb/CONTRACT.md` war eine Datei mit zwei Autoritäten. Der eine Teil ist code-erzwungen und in +jeder Instanz gleich; der andere - **§ Language komplett**, das Beziehungslabel-Vokabular, die +Tonfall-Beispiele samt deutscher Buzzword-Liste, die Confidence-Rubrik, das ADR-Präfix - ist +Konvention, die jede Instanz für sich entscheidet, und wurde trotzdem als bindender Contract +verbatim ausgeliefert. Wer bei Schritt 5 von `setup-instance.md` "Englisch" antwortete, hatte +danach `kb/CONTRACT.md`, vier Type-Specs **und `tools/chemenu/sections.py`** lokal geändert - +und `private-instance.md`s Decision Point sagt für so einen Merge-Konflikt: Upstream-Seite +nehmen. Für diese Instanz hieß das: KB-Sprache zurück auf Deutsch. + +**Der Schnitt läuft jetzt danach, wer den Satz ändern darf.** `kb/CONTRACT.md` behält, was +`wikitool` erzwingt; neu daneben liegt `kb/CONVENTIONS.md`, die **genauso bindet** und der +Instanz gehört. Unterschied ist Eigentum, nicht Autorität - deshalb liefert die Distribution nur +`kb/CONVENTIONS.md.template`, exakt der `USER.md`/`SOUL.md`-Split ein Verzeichnis tiefer. Dazu +`instructions/kb-profiles.md`: der Katalog erprobter Profile, ausdrücklich **Palette und kein +Enum**. Übernommen wird der *Text* in die Instanzdatei, nie ein Verweis auf den Katalog - ein +Verweis wäre wieder genau die Konstruktion, die dieser Release beendet. + +**`sections.py` hält keine Überschrift mehr.** `RELATIONSHIPS = "Beziehungen"` war die Stelle, +an der die Konvention in Code übergelaufen war: solange sie dort stand, konnte kein Template die +Sprache umstellen. Neu ist `tools/chemenu/conventions.py`, das die drei Namen aus +`kb/CONVENTIONS.md` liest; `sections.py` löst sie per PEP 562 bei jedem Zugriff auf, wie +`config` seine Pfade - ein Modulkonstante hätte den Wert an den Baum gebunden, in dem der Prozess +gestartet ist. Aus demselben Grund ist `provenance.CITE_BLOCK_HEADING` ein `__getattr__` und +`render_cite_block(heading=None)` löst innerhalb des Aufrufs auf. Der Alias-Mechanismus, den das +Modul schon hatte, **ist** der Migrationspfad: erkannt wird die kanonische Form plus die +deklarierten `section_aliases:` plus das, was dieser Stack vor der Konventionsdatei geschrieben +hat. Ohne Datei antwortet dieser Fallback - richtig für jeden Korpus, der ihn erreichen kann, +denn der wurde unter genau diesen Namen geschrieben; `doctor` ist die laute Hälfte davon. + +**Die vier Page-Type-Specs schreiben `## {section.relationships}`** statt einer Überschrift. +Neue Template-Variablen `{section.relationships}` / `{section.see_also}` / `{section.footnotes}`, +gefüllt aus der Instanzdeklaration. Damit ändert eine anderssprachige Instanz **keine Datei unter +`tools/` oder `types/`** mehr - was Schritt 5 von `setup-instance.md` von fünf Editierstellen +über drei Schichten auf eine Entscheidung reduziert. + +**`COLLECTION.md` bekommt Frontmatter.** Bisher wurde eine Collection rein an der Dateipräsenz +erkannt; die Deklaration brauchte einen Träger, sonst wäre der Ortsschnitt nur durch einen +Prosaschnitt ersetzt worden. `profile:` nennt den übernommenen Katalogeintrag (Freitext - eine +selbst angelegte Collection hat dort keinen), `required_by_stack:` sagt, ob `wikitool` die +Collection *namentlich* auflöst. Das zweite ist **nicht** die Wahl der Instanz: `docs verify` +prüft es beidseitig gegen `kb_collections.STACK_REQUIRED_COLLECTIONS`. Heute steht dort genau +`sources` - `sources coverage`, die `[^cite-id]`-Auflösung und `kb/provenance.md` hängen an dem +Namen, `entities` an keinem. + +**Das zweite Leck der Merge-Prozedur ist zu.** `git checkout HEAD -- kb raw` holte *alles* unter +beiden Stages auf den Vor-Merge-Stand - auch `kb/CONTRACT.md` und `raw/CONTRACT.md`. Änderte der +Upstream einen davon, warf die Prozedur das Update still weg, und die Kontrollzeile meldete dabei +*leer*, bestätigte den Fehler also, statt ihn zu fangen. `private-instance.md` nimmt die +Upstream-Seite jetzt für die drei Maschinerie-Pfade unter den Content-Stages zurück +(`kb/CONTRACT.md`, `kb/CONVENTIONS.md.template`, `raw/CONTRACT.md`) und schließt sie aus der +Kontrollzeile aus. Dieselbe Altlast in der Tarball-Richtung: `INSTALL.md` Schritt 3 fasste `kb/` +gar nicht an und zog `kb/CONTRACT.md` damit nie nach - jetzt ausdrücklich benannt. + +**Verworfen, gemessen: `sources/` aus `kb/` herausziehen.** Der Graph ist einwurzelig +(`kb_scan.iter_kb_pages` macht ein `rglob` über `kb/`, darauf sitzen Link-Graph, Orphan-Check, +`index rebuild` und `search`), und Source-Seiten sind darin der dichteste Knotentyp. Ein Hoist +machte jede Graph-Operation dauerhaft zweiwurzelig, um ein Verzeichnis umzubenennen. Vor allem +aber kann der *Ort* Eigentum ohnehin nicht kodieren, sobald Collections offen sind: eine selbst +angelegte liegt im selben `kb/` wie die Defaults. Eigentum ist eine deklarierte Eigenschaft - +daher das Frontmatter oben. Gitea #39 trägt die Ablehnung im Volltext. + +**Warum das MAJOR ist.** Die Rückwärtshälfte des Drop-in-Tests hält - 2.5.0 ignoriert beide neuen +Deklarationen folgenlos. Die Vorwärtshälfte nicht: nach dem Kopieren der Maschinerie FAILt +`doctor` auf der fehlenden `kb/CONVENTIONS.md`, `docs verify` bricht auf den undeklarierten +Collections, und `kb/CONTRACT.md` muss aus dem Release nachgezogen werden. Ein Shim war die +Alternative (`doctor` nur WARN, Pflichtfelder tolerant) und wurde verworfen: er hätte genau den +Zustand normalisiert, in dem eine Instanz glaubt, sie habe entschieden, während in Wahrheit der +Fallback antwortet - für eine englische Instanz hieße das `## Beziehungen` in englischen Seiten. +Die Handarbeit ist eine Datei und zwei Frontmatter-Zeilen je Collection; keine einzige `kb/`-Seite +ändert sich, weshalb `migrate done 3.0.0 --pages 0` ehrlich und kein Platzhalter ist. + +--- + ## 2.5.0 - 2026-09-02 - Versionsstelle: Kompatibilitaet statt Inhaltsmigration, Breaking-Change-Vermerk erzwungen **Author:** Torben Nehmer diff --git a/INSTALL.md b/INSTALL.md index f2a2737..aa9c9f9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -68,11 +68,14 @@ Zwei Schritte, von denen nur der erste rein menschlich ist: Wiki-Seite (`$WIKI_AUTHOR` überschreibt dies bei Bedarf). - **Remote** (optional) - eine URL, wenn du das Repo auf einen Server pushen willst; sonst bleibt die Instanz lokal, und jedes `publish` läuft mit `--no-push`. - - **KB-Sprache** - die exportierte Distribution bringt **Deutsch** mit: die Regel in - `kb/CONTRACT.md`, das Vokabular in `instructions/german-terminology.md` und deutsche - Abschnittsnamen in den Seitenvorlagen. Das ist eine Entscheidung dieser Ursprungsinstanz, - keine Eigenschaft des Musters. Willst du eine andere Sprache, sag es **vor dem ersten - Ingest** - danach ist es eine Migration jeder bereits angelegten Seite. + - **Autorenkonventionen** - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels + und Confidence-Rubrik stehen in `kb/CONVENTIONS.md`, dazu je Collection die Regeln in + `kb//COLLECTION.md`. Die Distribution bringt davon nur die `.template`-Dateien mit: + das sind Entscheidungen *dieser* Instanz, keine Eigenschaft des Musters, und nichts davon + liegt unter `tools/` oder `types/`. Fertige Profile - darunter ein vollständiges deutsches - + hält `instructions/kb-profiles.md` bereit; es ist eine Palette, kein Enum. Sag die Sprache + **vor dem ersten Ingest** - danach ist ein Wechsel der Abschnittsnamen eine Migration jeder + bereits angelegten Seite. - **Personalization** - wer diese Instanz bedient (`USER.md`) und wie sie klingt (`SOUL.md`). Die Distribution bringt nur `USER.md.template` und `SOUL.md.template` mit: persönlicher Inhalt gehört nicht in jede exportierte Kopie, aber beide Dateien werden in @@ -177,13 +180,17 @@ dieser Unterschied ist der Zustand, in dem sich jede Instanz mitten im Upgrade b 2. Release-Tarball herunterladen und entpacken (Weg A), die Release-Notes lesen. 3. Die **Maschinerie** aus dem Tarball über die Instanz kopieren: `tools/`, `types/`, - `instructions/`, `AGENTS.md`, `VERSION`, `.wikitool-release.json`. Nicht anfassen: `kb/`, - `raw/`, `work/`, `.wikitool-kb.json` und `.git/` - das ist die Instanz selbst. -4. Achtung bei lokal angepassten Contract-Dateien: wer z. B. die KB-Sprache umgestellt hat - (Schritt 5 in `setup-instance.md`), hat `kb/CONTRACT.md` und die Templates unter `types/` - verändert. Diese Änderungen vorher sichern und danach wieder einspielen. Welche Dateien das - sind, verrät ein Vergleich gegen die sha256-Summen im `files`-Block der alten - `.wikitool-release.json`. + `instructions/`, `AGENTS.md`, `VERSION`, `.wikitool-release.json` - **und `kb/CONTRACT.md`**. + Die letzte Datei liegt unter einem Content-Verzeichnis, ist aber Stack-Eigentum: sie hält, + was `wikitool` erzwingt, und ist in jeder Instanz gleich. Nicht anfassen: alles andere unter + `kb/` und `raw/`, `work/`, `.wikitool-kb.json` und `.git/` - das ist die Instanz selbst, + `kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` eingeschlossen. +4. Achtung bei lokal angepassten Stack-Dateien. Die Autorenkonventionen gehören **nicht** dazu: + `kb/CONVENTIONS.md` und die `kb/*/COLLECTION.md` liegen unter `kb/`, werden in Schritt 3 + also ohnehin nicht angefasst - genau dafür ist der Schnitt da. Wer darüber hinaus etwas + unter `tools/`, `types/` oder `instructions/` verändert hat, sichert das vorher und spielt + es danach wieder ein. Welche Dateien das sind, verrät ein Vergleich gegen die sha256-Summen + im `files`-Block der alten `.wikitool-release.json`. 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 diff --git a/README.md b/README.md index 556464f..b39ad85 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,15 @@ and maintains a persistent wiki** that compounds over time. English; the compiled pages under `kb/` are not. What stays English inside them is everything that is an *identifier* rather than prose - page titles, section headings, wikilink targets, citation ids, schema enum values, tags, commands, paths and code - so `GitOps Ownership Model` and -`## Beziehungen` sit in the same page without contradiction. The rule is -[kb/CONTRACT.md § Language](kb/CONTRACT.md#language); the vocabulary behind it is +`## Beziehungen` sit in the same page without contradiction. Which lines are identifiers is +[kb/CONTRACT.md § Language and identifiers](kb/CONTRACT.md#language-and-identifiers); *which +language* the prose is in, and what the tool-owned headings are called, is this instance's own +[kb/CONVENTIONS.md](kb/CONVENTIONS.md), and the vocabulary behind it is [instructions/german-terminology.md](instructions/german-terminology.md). -This is a per-instance decision, not a property of the pattern. A new instance built with -`dist export` starts empty and can pick any language by editing that one contract section before +This is a per-instance decision, not a property of the pattern - which is why it lives in a file +the instance owns rather than in one the stack ships. A new instance built with +`dist export` starts empty and picks any language by filling in `kb/CONVENTIONS.md` before the first ingest. ## Getting started @@ -401,7 +404,7 @@ This wiki is tailored for IT work with: - **Entity types** specific to software development and systems - **Relationship types** like `hängt ab von`, `verwendet`, `implementiert` - the vocabulary is in - [kb/CONTRACT.md § Linking](kb/CONTRACT.md#linking) + [kb/CONVENTIONS.md](kb/CONVENTIONS.md), because it is this instance's rather than the stack's - **Templates** for projects, systems, tools, technologies, ADRs - **Guidelines** for documenting technical decisions - **Cross-reference patterns** for code and architecture diff --git a/VERSION b/VERSION index 437459c..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.0 +3.0.0 diff --git a/instructions/CONTRACT.md b/instructions/CONTRACT.md index b8130ce..bbec30c 100644 --- a/instructions/CONTRACT.md +++ b/instructions/CONTRACT.md @@ -58,8 +58,10 @@ whether an instruction is still reachable, which is exactly why the answer means `instructions/dev/` boundary check below asks the opposite question - what would *dangle* in a distributed instance - and does scan README.md, because `dist export` ships it verbatim. -Two kinds of file use the Manual tier today: [german-terminology.md](german-terminology.md), a -vocabulary consulted on demand rather than a procedure, and every migration document (below). +Three kinds of file use the Manual tier today: [german-terminology.md](german-terminology.md), a +vocabulary consulted on demand rather than a procedure; [kb-profiles.md](kb-profiles.md), the +catalogue of authoring profiles an instance may adopt into its own `kb/CONVENTIONS.md` and +`COLLECTION.md` files; and every migration document (below). ## `instructions/migrations/` @@ -153,7 +155,8 @@ What lives where: |-------|------| | [AGENTS.md](../AGENTS.md) | Invariants and routing - what must always hold | | `instructions/` | How the tooling is *operated* | -| [kb/CONTRACT.md](../kb/CONTRACT.md) + each `COLLECTION.md` | How a page is *authored* | +| [kb/CONTRACT.md](../kb/CONTRACT.md) | What the stack enforces about a page, in every instance | +| `kb/CONVENTIONS.md` + each `COLLECTION.md` | What *this* instance decided about authoring - owned by the instance, shipped only as a `.template` | | [types/](../types/type-spec.md) | What a page structurally *is* | | [tools/CONTRACT.md](../tools/CONTRACT.md) | What each command does and how it fails | diff --git a/instructions/dev/testing-conventions.md b/instructions/dev/testing-conventions.md index f6857be..7cf02b5 100644 --- a/instructions/dev/testing-conventions.md +++ b/instructions/dev/testing-conventions.md @@ -35,6 +35,11 @@ Do not re-do any of this per test; it is done for you, per test, via `monkeypatc fixture redirects it into `tmp_path`. Tracing is never disabled suite-wide, because two telemetry tests assert that a trace gets written. +Two in-process caches are cleared alongside the environment, for the same reason: `config`'s +resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewrites* the +conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the +boundary between tests, not for one inside a test. + ## When to run Whenever you add or change a test under `tools/chemenu/tests/`. diff --git a/instructions/german-terminology.md b/instructions/german-terminology.md index 21899c0..10487a5 100644 --- a/instructions/german-terminology.md +++ b/instructions/german-terminology.md @@ -7,9 +7,13 @@ manual: true # German terminology for `kb/` -Reference vocabulary for [kb/CONTRACT.md](../kb/CONTRACT.md#language)'s rule that pages are -written in German. The rule lives there; the word list lives here, because it is lookup material -rather than a norm and would otherwise be loaded on every write. +Reference vocabulary for [kb/CONVENTIONS.md](../kb/CONVENTIONS.md#language)'s rule that this +instance's pages are written in German. The rule lives there; the word list lives here, because +it is lookup material rather than a norm and would otherwise be loaded on every write. + +**This file belongs to the `german` language profile, not to the stack.** An instance writing in +another language deletes or replaces it - see +[kb-profiles.md](kb-profiles.md). Derived from translating all 248 pages on 2026-08-29. Every entry below is a decision that was made wrong at least once first - each cost a correction pass across published pages, which is why @@ -98,8 +102,8 @@ none of them structural, so no check found them. It is the one thing to watch fo instructional prose. - **Quotations are never reworded**, neither translated nor moved into the impersonal register. -- Buzzwords and AI filler are banned by [kb/CONTRACT.md](../kb/CONTRACT.md#tone); the German list - is there. +- Buzzwords and AI filler are banned by [kb/CONVENTIONS.md](../kb/CONVENTIONS.md#tone); the + German list is there. - Dash as ` - `, not `—`. - German number formatting only in prose („10.000 Punkte"). Never inside code, version numbers or measurements (`75-85 px`, `10m`, `0.90`). @@ -108,4 +112,4 @@ instructional prose. This is about prose in `kb/`. What is prose and what is an identifier - titles, headings, wikilink targets, cite-ids, enum values, tags, code - is decided by -[kb/CONTRACT.md](../kb/CONTRACT.md#language), not here. +[kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers), not here. diff --git a/instructions/kb-profiles.md b/instructions/kb-profiles.md new file mode 100644 index 0000000..7dc177a --- /dev/null +++ b/instructions/kb-profiles.md @@ -0,0 +1,191 @@ +--- +type: types/instruction.md +name: kb-profiles +description: Ready-made answers for kb/CONVENTIONS.md and each COLLECTION.md - the proven collection contracts and language profiles this stack has shipped, offered as a palette to adopt or adapt, never as a binding source. +manual: true +--- +# Pick a profile for a collection or for this instance's conventions + +**This page is a palette, not an enum.** Each `kb//COLLECTION.md` stays authoritative for +its own collection and `kb/CONVENTIONS.md` for the instance as a whole; an entry here is a +proven starting point, nothing more. Adopting one means *copying its text into* that file - not +pointing at this page and inheriting whatever it says later. Nothing in the stack reads this +document, and `profile:` in a contract's frontmatter records where the text came from, not where +it lives. + +That direction is deliberate and it is the opposite of how this repo used to work. Language, +tone, naming and the relationship vocabulary sat in `kb/CONTRACT.md`, a file `dist export` ships +verbatim - so every instance that wanted something else edited a stack file, and an upstream +merge handed the stack's answer back. What binds is now the instance's; what ships is this +catalogue, and it binds nothing. + +## When to run + +- Setting up a new instance: the KB-language step of + [setup-instance.md](setup-instance.md) sends you here to fill `kb/CONVENTIONS.md`. +- Adding a collection to an existing instance, and wanting a contract that already works rather + than a blank one. +- Rewriting an existing `COLLECTION.md` or `kb/CONVENTIONS.md` and wanting to see what the + alternatives were. + +Not for changing what the *stack* enforces. That is [kb/CONTRACT.md](../kb/CONTRACT.md), and it +is not a profile. + +## Steps + +1. **Decide what you are filling.** Two different files, and they are not interchangeable: + + | File | Holds | Profiles below | + |---|---|---| + | `kb/CONVENTIONS.md` | Language, section headings, naming forms, tone, relationship labels, confidence rubric - once per instance | [Language profiles](#language-profiles) | + | `kb//COLLECTION.md` | What one collection holds, its quality goal, its local linking and naming rules | [Collection profiles](#collection-profiles) | + +2. **Copy the entry's text into the file**, then edit it until it is true of this instance. + A profile you adopted and then changed is still that profile's `profile:` value - the field + records the starting point, not a promise of fidelity. + +3. **Record it.** `profile: ` in the file's frontmatter, or `profile: none` for a + collection written from scratch. `wikitool docs verify` checks the field is there; it does + not check the value against this page, because a collection an instance invented has no + entry here to name. + +4. **Set `required_by_stack:` on a collection - and set it correctly.** This one is *not* a + choice: it says whether `wikitool` resolves against the collection by name, and + `docs verify` checks it against the stack's own list. `sources` is `true`, everything else + is `false`. See [kb/CONTRACT.md § Collections](../kb/CONTRACT.md#collections). + +## Language profiles + +A language profile answers all of `kb/CONVENTIONS.md` at once. There is one today, because one +is what this repo has actually run. + +### `german` + +The profile this repo's own instance uses, and the reason this catalogue exists: it was the +stack's hardcoded behaviour until the conventions file existed. + +| Decides | Value | +|---|---| +| `language:` | `de` | +| `sections:` | `Beziehungen` / `Siehe auch` / `Fußnoten` | +| Naming | Human-readable titles with spaces; singular for entities; `adr-NNN-` for decisions; `X vs Y` for comparisons | +| Tone | Wikipedia register, with a German buzzword and filler list | +| Relationship labels | `hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` · `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` | +| Confidence rubric | 0.5 base, +0.2 per supporting source (max +0.6), recency and source-quality bonuses; hedge with "möglicherweise"/"kann" below 0.6, "unsicher"/"unbestätigt" below 0.4 | +| Terminology | [german-terminology.md](german-terminology.md) - which English terms stay English, and which have a settled German form | + +**The full text to copy** is this repo's own [kb/CONVENTIONS.md](../kb/CONVENTIONS.md). An +instance adopting it takes that file, not this table; the table is what the profile *decides*, +so you can tell at a glance whether it is the one you want. + +Adopting it also means keeping `german-terminology.md`. An instance on any other language +deletes or replaces that file - it is the profile's lookup material, not the stack's. + +### `english` + +What `kb/CONVENTIONS.md.template` ships as its default, so "adopt `english`" means "fill in the +template and change nothing structural". `sections:` are `Relationships` / `See Also` / +`Footnotes`, which are also the names this stack wrote before it had a conventions file - so a +corpus that predates the split needs no translation pass to adopt this profile. + +There is no worked text for the rest of it. The template's placeholders are the questions; +`german` above is what a filled answer looks like. + +### Writing a third one + +A language profile is not a translation of `german`. Two of its sections are judgment about a +language rather than vocabulary in it - which foreign technical terms stay untranslated, and how +to hedge a low-confidence claim - and those are exactly the two that read as awkward when +translated mechanically. Write them, do not convert them. + +The one part that is mechanical: `section_aliases:`. Whatever the corpus used before goes in +that list, and the pages then migrate one at a time instead of all at once. + +## Collection profiles + +The four collections this repo runs. Each is a whole `COLLECTION.md`, and **the text to copy is +the file itself** - `dist export` ships each one as `kb//COLLECTION.md.template`, which a +new instance adopts by renaming. What follows is what each decides, so you can tell whether you +want it. + +### `entities` + +Concrete, pointable things: projects, deployed systems, tools, technologies, people. + +- **Quality goal:** pointability plus currency - what the thing is, where it actually is, and + whether that is still true. +- **Areas** driven by the `entity_type:` field: `projects/`, `systems/`, `tools/`, + `technologies/`, `people/`. Areas, not collections - they inherit the contract and carry no + `COLLECTION.md`. +- **Per-area emphasis** spelled out, so a system page is not written like a technology page. +- `required_by_stack: false`. + +Take it when the wiki is about things that exist. Adapt the area list first: it is the part most +likely to be wrong for another domain. + +### `concepts` + +Ideas rather than things: architectures, patterns, protocols, workflows, recurring problems, +and the decisions taken about them. + +- **Quality goal:** explanatory sufficiency - the page answers *why it is done this way* without + the reader opening the entity pages that use it. +- Carries the **ADR shape**: context, decision, consequences, status, and the rule that a + superseded decision is never rewritten. +- Routes head-to-head arguments out to `comparisons/` rather than hosting them. +- `required_by_stack: false`. + +Take it whenever `entities` is taken - the split between the two is what keeps either from +becoming an essay. + +### `sources` + +One page per ingested source, carrying the `raw_files:` provenance every citation resolves +against. + +- **Quality goal:** faithful compression - what *this source* said, not what was concluded from + it. A source page improved beyond its source is no longer evidence. +- Titles carry the `Source - ` prefix, applied by `wikitool new source`. +- `required_by_stack: **true**`. `sources coverage`, `[^cite-id]` resolution and + `kb/provenance.md` resolve against the name `sources`. + +Not optional in the way the others are. An instance may rewrite its authoring rules and may not +rename or drop it. + +### `comparisons` + +Structured head-to-head evaluations of two or more things that already have pages. + +- **Quality goal:** decidability - named, checkable dimensions and a stated trade-off, so a + reader with a concrete situation can choose. +- Every subject must already have a page; a comparison is a view over existing knowledge. +- **Exempt from the orphan check** - comparisons are reached through the catalog, not through + inbound prose links. +- `required_by_stack: false`. + +Skip it in a wiki that records rather than decides. It is the one of the four that is genuinely +optional. + +## Decision points + +- **A profile is almost right?** Copy and edit. There is no partial adoption and no override + file - the copy *is* the mechanism, and `profile:` still records where it started. +- **Two collections want the same profile?** Fine. `profile:` is not unique, and two + collections holding different subject matter under the same authoring rules is an ordinary + outcome. +- **Changing `sections:` after pages exist?** That is a corpus migration, not an edit. Put the + old names in `section_aliases:` first, then translate page by page - the tool keeps finding + the old headings for as long as the alias stands. See + [migrate-corpus.md](migrate-corpus.md). +- **Tempted to make this page binding** - to have `COLLECTION.md` say `profile: entities` and + nothing else? Do not. That is the arrangement this split was written to end: the instance + would be bound by a file the stack ships and upgrades, which is how an upstream merge changes + an instance's authoring rules without anyone deciding to. + +## Scope + +Covers what an instance authors under `kb/`. It says nothing about what the stack enforces +([kb/CONTRACT.md](../kb/CONTRACT.md)), what a page structurally is +([types/type-spec.md](../types/type-spec.md)), or how a command behaves +([tools/CONTRACT.md](../tools/CONTRACT.md)). None of those are profiles, and none of them are +the instance's to change. diff --git a/instructions/migrations/3.0.0-authoring-conventions.md b/instructions/migrations/3.0.0-authoring-conventions.md new file mode 100644 index 0000000..b5bd64b --- /dev/null +++ b/instructions/migrations/3.0.0-authoring-conventions.md @@ -0,0 +1,150 @@ +--- +type: types/instruction.md +name: 3.0.0-authoring-conventions +description: 'Adopt the instance-owned authoring conventions introduced in 3.0.0 - write kb/CONVENTIONS.md, declare profile:/required_by_stack: on every COLLECTION.md, and replace kb/CONTRACT.md with the shipped one.' +manual: true +migrates_to: 3.0.0 +migration_kind: mechanical +--- +# Adopt this instance's own authoring conventions (3.0.0) + +Until 3.0.0, the rules for writing a page were split by *location*: everything about `kb/` sat +in `kb/CONTRACT.md`, a file every distribution ships verbatim. Half of it was never the stack's +to decide - the language pages are written in, the three tool-owned section headings, the naming +forms, the tone, the relationship labels, the confidence rubric - so an instance that wanted +something else edited a file the stack also ships, and an upstream merge handed the stack's +answer back. + +3.0.0 splits it by *ownership* instead. `kb/CONTRACT.md` keeps only what `wikitool` enforces; +everything else moves into a new `kb/CONVENTIONS.md` that belongs to this instance, and each +`kb//COLLECTION.md` now declares what it is. The compiler reads its section headings from +that file rather than from `tools/chemenu/sections.py`. + +**No page changes.** Not one line under `kb/entities/`, `kb/concepts/`, `kb/sources/` or +`kb/comparisons/` is touched. What changes are the contracts beside them, which is why this is +`mechanical` and takes minutes rather than a workshop. + +## When to run + +After installing 3.0.0 machinery over an instance that was on 2.x, when `tools/wikitool doctor` +reports `FAIL conventions` or `tools/wikitool docs verify` reports a `COLLECTION.md` with no +frontmatter. `tools/wikitool migrate status` names this document. + +**Until it has run, the compiler answers out of a fallback.** `xref add` and `cite add` write +`## Beziehungen` / `## Siehe auch` / `## Fußnoten` - what this stack hardcoded before the +conventions file existed. That is correct for a corpus written under them and wrong for any +other, so run this before the next `wiki-ingest` or `wiki-manage`, not afterwards. + +## Steps + +1. **Replace `kb/CONTRACT.md` from the release.** It is machinery that happens to live under a + content directory, and the tarball update path used to skip it (see `INSTALL.md`, which now + names it explicitly). The 3.0.0 version is roughly half the length of the 2.x one - the + removed half is what step 2 is about to write into a file of yours. + + ```bash + cp /kb/CONTRACT.md kb/CONTRACT.md + ``` + + A private instance cloned from an upstream takes it with the merge instead - see + [private-instance.md](../private-instance.md), whose update procedure now re-takes the + upstream side for exactly this path. + +2. **Write `kb/CONVENTIONS.md`.** Two ways in, and the first is almost always right: + + - **This instance writes German pages** (it did, unless you changed it): copy the release's + `kb/CONVENTIONS.md.template` and fill it from the `german` profile in + [kb-profiles.md](../kb-profiles.md) - whose worked full text is the origin repo's own + `kb/CONVENTIONS.md`. Everything in it was already true of your corpus; it was simply + written down somewhere you did not own. + - **You had changed the language**, and therefore hold local edits to `kb/CONTRACT.md`, + `types/*.md` and `tools/chemenu/sections.py`: those edits are what this file replaces. Copy + the canonical heading names out of your old `sections.py` into `sections:`, the labels and + tone rules out of your old `kb/CONTRACT.md`, then **discard the local edits under `tools/` + and `types/`** and take the shipped versions. That is the whole point of the change: there + is nothing left to patch there. + + The minimum the tool needs is the frontmatter: + + ```yaml + --- + language: de + profile: german + sections: + relationships: Beziehungen + see_also: Siehe auch + footnotes: Fußnoten + --- + ``` + + Set `sections:` to the names **your existing pages already carry**, not to what you would + prefer. Changing them is a separate, real corpus migration; `section_aliases:` is how it is + done page by page ([migrate-corpus.md](../migrate-corpus.md)). + + Drop the `wikitool:template-unfilled` sentinel line while filling it in - `doctor` FAILs on a + renamed-but-unanswered template exactly as it does for `USER.md`. + +3. **Declare each collection.** Two frontmatter lines at the top of every + `kb//COLLECTION.md`: + + ```yaml + --- + profile: + required_by_stack: false + --- + ``` + + `required_by_stack: true` on `kb/sources/` and **nowhere else**. It is not a preference: + `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` resolve against that name, + and `docs verify` checks the field against the stack's own list in both directions. + + For the four default collections, the shipped `kb//COLLECTION.md.template` files carry + the right values already. + +4. **Verify.** All three must pass: + + ```bash + tools/wikitool doctor # `conventions` must be OK + tools/wikitool docs verify + tools/wikitool lint + ``` + + `migrate verify` is deliberately not in that list: it compares pages, and no page changed. + Running it would report nothing and prove nothing. + +5. **Record it.** + + ```bash + tools/wikitool migrate done 3.0.0 --pages 0 + ``` + + `--pages 0` is honest, not a placeholder - see the note under step 1. + +## How to tell a migrated instance from an unmigrated one + +`kb/CONVENTIONS.md` exists, carries no `wikitool:template-unfilled` line, and names all three +slots under `sections:`; every `kb/*/COLLECTION.md` opens with a frontmatter block; and +`kb/CONTRACT.md` has a `## Language and identifiers` heading rather than a `## Language` one. +`doctor` answers all of that in one call. + +## Decision points + +- **`doctor` says `conventions: FAIL` after step 2?** It prints which slot is missing. The three + keys are `relationships`, `see_also` and `footnotes` - the *slot* names are fixed, only their + values are yours. +- **A collection this instance invented, with no profile behind it?** `profile: none`. The field + records where the text came from; it is free text and `docs verify` does not check it against + the catalogue, because an invented collection has no entry there to name. +- **Tempted to point `profile:` at the catalogue instead of copying the text?** Do not. An + adopted profile is a copy; a reference would put your binding authoring rules in a file the + stack ships and upgrades, which is the arrangement 3.0.0 exists to end. +- **Your old `kb/CONTRACT.md` had local edits you still want?** They belong in + `kb/CONVENTIONS.md` now. If something you edited has no home there, it was a stack rule you + overrode - file it as an issue against the origin repo rather than re-applying it. + +## Scope + +One instance's contracts, once. It changes no page, no frontmatter on a page, and nothing under +`raw/`. The machinery half of the 3.0.0 upgrade - copying `tools/`, `types/`, `instructions/`, +`AGENTS.md`, `VERSION` and `.wikitool-release.json` - is `INSTALL.md`'s, and has to have +happened before step 1. diff --git a/instructions/private-instance.md b/instructions/private-instance.md index bdf68e5..078530d 100644 --- a/instructions/private-instance.md +++ b/instructions/private-instance.md @@ -100,17 +100,36 @@ So the merge has to be scoped. That is the procedure below, and it is not option [setup-instance.md](setup-instance.md), then [bootstrap.md](bootstrap.md) for the venv and the skills. + A clone inherits the upstream's `kb/CONVENTIONS.md` and `kb/*/COLLECTION.md` rather than + templates, because it inherits the upstream's whole tree. They are yours from this point on: + rewrite them if this instance writes its pages differently - the update procedure below + restores them on every merge, so the change sticks. [kb-profiles.md](kb-profiles.md) has the + alternatives. + ## Taking a stack update Take the machinery, never the content. The merge is held open, the content stages are forced -back to your own state, and only then does it close: +back to your own state, and only then does it close. + +**Three files under those stages are machinery, not content**, and forcing them back is how an +upstream contract change gets silently discarded: + +| Path | Why it must take the upstream side | +|---|---| +| `kb/CONTRACT.md` | The stack's own knowledge-layer contract. Every rule in it is enforced by `wikitool`; an instance never edits it | +| `kb/CONVENTIONS.md.template` | The template your `kb/CONVENTIONS.md` was filled from. The filled file is yours; the template is the stack's | +| `raw/CONTRACT.md` | The raw stage's contract, for the same reason as the first row | + +Everything else under `kb/` and `raw/` is yours, `kb/CONVENTIONS.md` and each +`kb//COLLECTION.md` included - they bind your corpus, and they are exactly what the +restore below is protecting. ```bash BEFORE=$(git rev-parse HEAD) git fetch upstream # --no-commit holds the merge open; it may report conflicts under kb/ or raw/, -# which the next three lines are about to make irrelevant. +# which the next four lines are about to make irrelevant. git merge --no-commit --no-ff upstream/main || true # Whatever the merge did to the content stages, undo it. HEAD is still your @@ -119,18 +138,32 @@ git rm -rq --cached --ignore-unmatch kb raw rm -rf kb raw git checkout HEAD -- kb raw +# ...then take the upstream side back for the machinery that lives among it. +# MERGE_HEAD is still resolvable while the merge is open. +git checkout MERGE_HEAD -- kb/CONTRACT.md kb/CONVENTIONS.md.template raw/CONTRACT.md + git commit --no-edit ``` -Then **check that it worked**, rather than trusting that it did: +Then **check that it worked**, rather than trusting that it did. The same three paths are +excluded here, spelled out rather than held in a variable so that the check can be read on its +own and copied on its own: ```bash -git diff --name-only $BEFORE HEAD -- kb raw # must print nothing +git diff --name-only "$BEFORE" HEAD -- kb raw \ + | grep -vE '^(kb/CONTRACT\.md|kb/CONVENTIONS\.md\.template|raw/CONTRACT\.md)$' ``` +Must print nothing. + An empty result is the proof that the update touched machinery only. A non-empty one means a path slipped through - inspect it before going further. +**The exclusion is not cosmetic.** Without it the check reports *empty* for an update that just +ate a `kb/CONTRACT.md` change - it would be confirming the failure it exists to catch. If one of +the three paths does not appear in the diff at all, that is fine: it means upstream did not +touch it. + Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`, `lint`. A `migrate status` with outstanding links means the update crossed a compatibility boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else. @@ -157,11 +190,20 @@ merge above. Nothing is lost by the detour: the fix has to pass that CI either w above overwrites those stages with your own afterwards, so the conflict resolves itself. Never resolve one by hand with `git add -A` - that is exactly how the upstream version, which git left sitting in your working tree, gets committed into your instance. -- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop. The scoping step - did not take. Do not publish; find out which path came through and where from. +- **`git diff` after the merge shows something under `kb/` or `raw/`?** Stop - unless it is one + of the three machinery paths the check excludes, which is the update working as intended. For + anything else the scoping step did not take: do not publish; find out which path came through + and where from. - **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which step "Where stack development happens" says not to do. Take the upstream side and re-file the change as an issue there. +- **...but you changed how *your pages* are written?** That is not a stack change and the rule + above does not apply to it. Language, section headings, naming forms, tone, relationship + labels and the confidence rubric live in `kb/CONVENTIONS.md`, and each collection's authoring + rules in `kb//COLLECTION.md` - all under `kb/`, all yours, all restored by the merge + procedure rather than overwritten by it. If you find yourself editing `tools/` or `types/` to + change an authoring convention, that is a stack bug: file it, because the split exists + precisely so you do not have to. ## Scope diff --git a/instructions/setup-instance.md b/instructions/setup-instance.md index 41cb832..940a757 100644 --- a/instructions/setup-instance.md +++ b/instructions/setup-instance.md @@ -60,24 +60,58 @@ bereit für den ersten `Ingest`. - Nicht genannt: lokal bleiben - dann braucht **jeder** spätere `tools/wikitool publish` ein `--no-push` (dessen Branch-Prüfung dabei ohnehin entfällt, siehe Schritt 2). -5. **Entscheidungspunkt - KB-Sprache.** Frage den Nutzer, in welcher Sprache die Seiten unter - `kb/` geschrieben werden sollen. Diese Instanz erbt aus dem Quell-Repo **Deutsch** - sowohl die - Regel in [kb/CONTRACT.md](../kb/CONTRACT.md#language) als auch das Vokabular in - [german-terminology.md](german-terminology.md) und die deutschen Abschnittsnamen in - `tools/chemenu/sections.py`. Das ist eine Entscheidung der Ursprungsinstanz, keine - Eigenschaft des Musters, und sie wird hier nicht stillschweigend weitergereicht. +5. **Entscheidungspunkt - Autorenkonventionen.** Die Distribution bringt keine ausgefüllten + Konventionen mit, sondern `kb/CONVENTIONS.md.template` und je Collection ein + `kb//COLLECTION.md.template`. Beide **binden**, sobald sie übernommen sind, und beide + gehören dieser Instanz - deshalb liefert der Stack nur die Vorlage. Die eine Entscheidung + dahinter ist: **in welcher Sprache und in welchem Ton schreibt diese Instanz ihre Seiten?** - - **Deutsch bestätigt:** nichts zu tun. - - **Andere Sprache:** *vor dem ersten Ingest* umstellen, denn danach ist es eine Migration - jeder vorhandenen Seite. Zu ändern sind der Abschnitt "Language" in `kb/CONTRACT.md`, die - Tonfall-Beispiele und Hedge-Wörter darunter, die vier Page-Type-Templates in `types/`, die - kanonischen Namen in `sections.py` (die bisherigen als Alias behalten) und die - Beziehungslabels in `kb/CONTRACT.md` § Linking. `german-terminology.md` wird dann ersetzt - oder gelöscht. + Ablauf: - Unverändert bleibt in jedem Fall die eigentliche Regel: **jede Zeile einer Seite ist Prosa - oder Identifier, und nur Prosa wird übersetzt.** Titel, Wikilink-Ziele, Cite-IDs, Enum-Werte, - Tags, Befehle und Pfade folgen keiner KB-Sprache. + 1. Die Collection-Contracts übernehmen - vier Kopien, keine Frage an den Nutzer, denn was + dort steht ist unabhängig von der Sprache brauchbar: + + ```bash + for template in kb/*/COLLECTION.md.template; do + cp "$template" "${template%.template}" + done + ``` + + Die `.template`-Dateien bleiben liegen; sie sind die Vorlage für den nächsten Export. + + 2. Den Nutzer nach der KB-Sprache fragen. `kb/CONVENTIONS.md.template` ist auf **Englisch** + voreingestellt; [kb-profiles.md](kb-profiles.md) hält daneben ein vollständiges + deutsches Profil bereit, und dessen Volltext ist die `kb/CONVENTIONS.md` des Quell-Repos. + Der Profilkatalog ist eine **Palette, kein Enum**: übernommen wird der Text *in* die + Instanzdatei, nicht ein Verweis auf den Katalog. + + 3. `kb/CONVENTIONS.md.template` nach `kb/CONVENTIONS.md` kopieren, entlang des gewählten + Profils ausfüllen - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels, + Confidence-Rubrik - und dabei die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen. + Die Platzhalter in geschweiften Klammern **sind** der Fragenkatalog. + + 4. Bei einer anderen Sprache als der des Quell-Repos: `german-terminology.md` löschen oder + durch das eigene Vokabular ersetzen - sie ist Material des deutschen Profils, nicht des + Stacks. + + **Vor dem ersten Ingest entscheiden.** Die `sections:`-Namen in `kb/CONVENTIONS.md` sind die + Überschriften, die `xref` und `cite` in jede Seite schreiben; sie danach zu ändern ist eine + Migration jeder vorhandenen Seite (`section_aliases:` trägt die alten Namen, siehe + [migrate-corpus.md](migrate-corpus.md)). + + **Nichts davon liegt unter `tools/` oder `types/`.** Der Compiler liest die Abschnittsnamen + aus `kb/CONVENTIONS.md`, und die vier Page-Type-Templates setzen sie über + `{section.…}`-Variablen ein - eine anderssprachige Instanz ändert dort keine Datei. + + Unverändert bleibt in jedem Fall die Regel, die dem Stack gehört: **jede Zeile einer Seite + ist Prosa oder Identifier, und nur Prosa wird übersetzt** ([kb/CONTRACT.md § Language and + identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titel, Wikilink-Ziele, Cite-IDs, + Enum-Werte, Tags, Befehle und Pfade folgen keiner KB-Sprache. + + `tools/wikitool doctor` prüft das Ergebnis in Schritt 12 (`conventions`): eine fehlende + Datei ist ein `FAIL`, eine mit Sentinel oder ohne vollständigen `sections:`-Block ebenso. + `docs verify` prüft zusätzlich `profile:` und `required_by_stack:` auf jedem + `COLLECTION.md`. 6. **Entscheidungspunkt - Personalization.** Die Distribution bringt `USER.md.template` und `SOUL.md.template` mit, aber keine ausgefüllten Fassungen: wer diese diff --git a/instructions/wiki-ingest/SKILL.md b/instructions/wiki-ingest/SKILL.md index f784e20..ba94bb1 100644 --- a/instructions/wiki-ingest/SKILL.md +++ b/instructions/wiki-ingest/SKILL.md @@ -61,8 +61,9 @@ pages should never have cost the concept contract. Field-level requirements alwa article also pass `--set source_url=`; `raw_files:` must still point at the local copy. Then write the Summary / Key Takeaways / Action Items prose from step 4 - in the KB language, whatever the source's own language is, quoting verbatim passages in the - original. The rule and what is exempt from it: - [kb/CONTRACT.md](../../kb/CONTRACT.md#language). + original. Which language that is: [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#language). + What is exempt from it, in any language: + [kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers). 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 @@ -70,8 +71,9 @@ pages should never have cost the concept contract. Field-level requirements alwa 6. **Create or update entity pages.** Read [kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and - [kb/CONTRACT.md](../../kb/CONTRACT.md) first - the second is where tone, naming, provenance - and citation are defined. + [kb/CONTRACT.md](../../kb/CONTRACT.md) plus + [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and + citation are defined, the third where this instance's tone and naming forms are. New: diff --git a/instructions/wiki-manage/SKILL.md b/instructions/wiki-manage/SKILL.md index 59f36bc..2ea53ec 100644 --- a/instructions/wiki-manage/SKILL.md +++ b/instructions/wiki-manage/SKILL.md @@ -13,10 +13,12 @@ integrating into an existing one. **Before the first `wikitool` call:** [session-setup.md](../session-setup.md). -**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - naming, tone, linking, -provenance and confidence - together with the target collection's own `COLLECTION.md`, which -carries its quality goal and what is local to that subtree. Field-level requirements come from -`tools/wikitool types describe `. +**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - linking, provenance and the +confidence machinery, all of which the tool enforces - and +[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md), which is where this instance's language, naming +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 +requirements come from `tools/wikitool types describe `. ## Creating a page @@ -46,7 +48,7 @@ carries its quality goal and what is local to that subtree. Field-level requirem 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 - [kb/CONTRACT.md](../../kb/CONTRACT.md#tone). If `provenance:` is `sourced` or `mixed`, cite + [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#tone). If `provenance:` is `sourced` or `mixed`, cite hard facts as you write them with `tools/wikitool cite add --page "" --source "Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints. diff --git a/kb/CONTRACT.md b/kb/CONTRACT.md index b7d6f1c..9f5ca88 100644 --- a/kb/CONTRACT.md +++ b/kb/CONTRACT.md @@ -7,12 +7,25 @@ material in `raw/`, and is expected to stay correct without being re-derived. **Quality goal:** a page should answer a future question *without* re-reading the source it came from. If answering still requires the raw file, the page is incomplete. -This file holds the rules that apply in **every** collection. Each `kb/<name>/COLLECTION.md` -declares that it inherits them and adds only what is local to its own subtree - read this file -together with the target collection's contract before writing or editing a page. +This file holds the rules that apply in **every** collection **and in every instance**. That +second half is the cut: what is written here is enforced by `tools/wikitool` or follows from +how it works, so it is identical everywhere and `dist export` ships it verbatim. + +**What an instance decides for itself is next door, in +[kb/CONVENTIONS.md](CONVENTIONS.md)** - the language pages are written in and its three +tool-owned section headings, the naming forms, the tone, the relationship-label vocabulary, the +confidence rubric. That file binds exactly as this one does; it is simply owned by the instance +rather than by the stack, so the distribution ships only its `.template` and the instance writes +the real one. Read both, plus the target collection's `kb/<name>/COLLECTION.md` (also +instance-owned), before writing or editing a page. + +The split is by **who may change the sentence**, not by what it is about. Language, tone and +naming used to sit here, which meant every instance that answered "not German" to +`setup-instance.md` was locally editing a file the stack also ships - and a merge from upstream +would quietly hand it back. Structural facts (which frontmatter fields exist, which are required, what the body skeleton -looks like) are *not* here - they belong to the type-specs and are printed by +looks like) are in neither - they belong to the type-specs and are printed by `tools/wikitool types describe <type>`. Never hand-write frontmatter; scaffold with `tools/wikitool new <type> --name "<Name>" --set field=value ...`. @@ -21,7 +34,15 @@ looks like) are *not* here - they belong to the type-specs and are printed by `kb/` is a **namespace, not a collection**. It carries no `COLLECTION.md` of its own. A directory under `kb/` is a **collection** exactly when it contains a `COLLECTION.md`. That -file is the local authoring contract for every page in the subtree. +file is the local authoring contract for every page in the subtree, and it belongs to the +instance: it declares in its frontmatter which profile from +[instructions/kb-profiles.md](../instructions/kb-profiles.md) it adopted, and whether the stack +resolves against it by name. + +| Field | Means | +|---|---| +| `profile:` | Which catalogue entry this contract started from, or `none`. Free text - the catalogue is a palette, not an enum, and a collection an instance invented has no entry to name | +| `required_by_stack:` | Whether `wikitool` itself depends on this collection *by name*. Not the instance's to choose: `docs verify` checks it against the stack's own list. `kb/sources/` is `true` - `sources coverage`, `[^cite-id]` resolution and `kb/provenance.md` all resolve against that name - and everything else is `false` | - A subdirectory *inside* a collection is an **area**. It inherits the enclosing contract and must not carry a `COLLECTION.md` of its own - `kb/entities/systems/` is an area of @@ -40,9 +61,11 @@ file is the local authoring contract for every page in the subtree. | `kb/sources/` | One summary page per ingested source, carrying its `raw_files:` provenance | [sources/COLLECTION.md](sources/COLLECTION.md) | | `kb/comparisons/` | Structured comparisons of two or more existing pages | [comparisons/COLLECTION.md](comparisons/COLLECTION.md) | -**Adding a collection:** `mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md`. Collections +The four rows above are this instance's collections, not a fixed set. **Adding one:** +`mkdir kb/<name>` and write a `kb/<name>/COLLECTION.md` with the two fields above. Collections are discovered by contract presence, so no code change is needed. A collection only becomes -*writable* once some type-spec declares a matching `base_dir:`. +*writable* once some type-spec declares a matching `base_dir:`. Renaming or dropping one is the +instance's call too - except where `required_by_stack: true` says otherwise. **Where a page goes** is decided by its type-spec, never by hand - see [types/type-spec.md](../types/type-spec.md). @@ -61,18 +84,16 @@ Never hand-edit these; they are produced by `tools/wikitool`: To *find* a page, search rather than read the catalog: `tools/wikitool search "<text>"`, or `tools/wikitool search --field <predicate>` for a structured query over frontmatter. -## Naming +## Titles are identifiers -- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case. -- Singular for entities: `ha-core.md`, not `ha-cores.md`. -- Comparison pages read as a comparison: `Go vs Rust.md`. -- ADRs are prefixed: `adr-001-use-go-modules.md`. -- The filename stem *is* the page title, and `[[wikilinks]]` must match it exactly. -- Prefer readability over convention when the two conflict. +**The filename stem *is* the page title, and `[[wikilinks]]` must match it exactly.** That is +not a naming preference; it is the wiki's only way to address a page. `wikitool lint` reports an +H1 that stops matching its title, `rename`/`rm` rewrite every reference to a stem, and a +`[^cite-id]` resolves through one. -What to name a thing: projects use their repository or common name; systems a descriptive -name; tools the tool's own name; technologies their standard spelling and capitalization; -people a full name or common handle. +Which *form* those titles take - spaces or kebab-case, singular or plural, what prefixes a +decision record - is the instance's, in +[kb/CONVENTIONS.md § Naming](CONVENTIONS.md#naming). ## Every page should @@ -83,31 +104,20 @@ people a full name or common handle. - [ ] Duplicate no existing page - [ ] Appear in the catalog (guaranteed by `wikitool index rebuild`) -## Tone +## Quotation cap -Wikipedia style: factual, neutral, specific. +At most 2 blockquoted lines per page. `wikitool lint` reports overages as advisory, since +exceeding the cap can be a legitimate judgment call - but the page should carry the knowledge +itself, not delegate it to quotations. The cap is about how much of the page you let quotes +carry; it does not apply to text you are citing verbatim from a source. -- No buzzwords ("bahnbrechend", "hochmodern", "leistungsstark", "revolutioniert"). -- No AI filler ("es sei angemerkt", "es ist wichtig zu betonen", "in der heutigen Zeit"). -- No em-dash asides carrying parenthetical reasoning. -- At most 2 blockquoted lines per page. `wikitool lint` reports overages as advisory, since - exceeding the cap can be a legitimate judgment call - but the page should carry the - knowledge itself, not delegate it to quotations. The cap is about how much of the page you - let quotes carry; it does not apply to text you are citing verbatim from a source. +The register those lines are written in - what counts as a buzzword, what filler is refused - +is the instance's, in [kb/CONVENTIONS.md § Tone](CONVENTIONS.md#tone). -Good: "MQTT ist ein leichtgewichtiges Publish-Subscribe-Protokoll für Geräte mit knappen -Ressourcen." +## Language and identifiers -Bad: "MQTT ist ein bahnbrechendes, hochmodernes Protokoll, das die IoT-Kommunikation -revolutioniert - und es sei angemerkt, dass es ein Publish-Subscribe-Muster verwendet." - -## Language - -Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it - -the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and -`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the -control plane stays English: AGENTS.md, the stage contracts including this one, `instructions/`, -and the type-specs for non-page artifacts. +*Which* language pages are written in is [kb/CONVENTIONS.md](CONVENTIONS.md)'s to say. What +follows here is the part that is not a choice, because the tool resolves against it. Every line of a page is either **prose** or an **identifier**. Only prose is translated. @@ -118,20 +128,14 @@ source page's Summary / Key Takeaways / Action Items / Not Extracted, and `summa | Identifier | Why | |---|---| -| Page titles, and the H1 that repeats one | A title is the wiki's only identifier for a page and follows the subject's own established name - see [Naming](#naming). `wikitool lint` reports an H1 that stops matching its title | +| Page titles, and the H1 that repeats one | A title is the wiki's only identifier for a page and follows the subject's own established name - see [Titles are identifiers](#titles-are-identifiers). `wikitool lint` reports an H1 that stops matching its title | | The subtype value on the generated `**Typ:**` line | It renders a schema enum value (`technology`, `workflow`), which `search --field` filters on. The label is prose; the value is not | | `tags:` | Search keys, not prose | | Commands, paths, config keys, hostnames, code | They are what they are | | Quotations | Quoted verbatim in the source's own language | -Established English technical terms stay English inside German prose - "GitOps", "Ownership -Model", "Reverse Proxy", "Pull Request". Translate a term only where the German one is genuinely -the more common usage. A coined German equivalent nobody else writes makes the page harder to -find, not more idiomatic. - -Which terms those are, which have a settled German form, and the register the prose is written in: -[instructions/german-terminology.md](../instructions/german-terminology.md). It is lookup material, -not a second rule - every entry in it is a decision that was made wrong once first. +Which foreign technical terms stay untranslated inside that prose is a judgment call the +instance records - see [kb/CONVENTIONS.md § Language](CONVENTIONS.md#language). **A source in another language** is still summarized in the KB language: a source page is evidence *about* a source, not a substitute for it. Quote verbatim in the original language and @@ -141,14 +145,18 @@ record the raw file's language in `source_language:`. Three headings are a vocabulary the tool owns rather than prose an author picks: `xref add` writes into Relationships and See Also, and `cite add` owns the trailing Footnotes block. They -follow the KB language like everything else - `## Beziehungen`, `## Siehe auch`, `## Fußnoten` - -and `tools/chemenu/sections.py` is the single place naming them. +follow the KB language like everything else, so **the instance names them**, in +`kb/CONVENTIONS.md`'s `sections:` frontmatter. `tools/chemenu/conventions.py` reads that +declaration and `tools/chemenu/sections.py` is what the rest of the compiler asks - there is no +heading text in the compiler itself. Each has aliases the tool still *recognizes* but no longer writes, which is what lets the corpus be translated page by page: a page still carrying `## Relationships` is found and appended to correctly, and `cite sync` leaves an untranslated `## Footnotes` heading alone rather than -retitling it. Renaming a heading is the translation pass's job, never a side effect of another -command. Any *other* heading an author adds is ordinary prose and is translated with the rest. +retitling it. The recognized set is the canonical name, any `section_aliases:` the instance +declared, and the names this stack wrote before the declaration existed. Renaming a heading is +the translation pass's job, never a side effect of another command. Any *other* heading an +author adds is ordinary prose and is translated with the rest. ## Linking @@ -156,14 +164,10 @@ Every page links to what it mentions, in both directions. Cross-references are c `tools/wikitool xref add --a "<A>" --b "<B>" --rel-a "<label>" --rel-b "<label>"`, never by hand-editing the `related:` array or the Relationships/See Also bullets. -Use a typed relationship label rather than a generic one: - -`hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` -· `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` -(last resort) - -The labels are prose written into a `- **label:** [[Title]]` bullet; no code matches on them, so -an untranslated page's English label is stale wording, not a broken reference. +Use a typed relationship label rather than a generic one. The label is free text as far as the +tool is concerned - it is written into a `- **label:** [[Title]]` bullet and no code matches on +it - so which vocabulary this instance uses is +[kb/CONVENTIONS.md § Relationship labels](CONVENTIONS.md#relationship-labels)'s to list. A page is expected to have at least one inbound link; `wikitool lint` reports orphans. Comparison pages are exempt - they are reached through the catalog. @@ -187,15 +191,16 @@ Every claim is either traceable to a raw file or explicitly marked as not. command, or config value. `tools/wikitool cite add --page "<Title>" --source "Source - X" [--file <qualifier>]` mints the id, upserts its `[[Source - X]]` (or `[[Source - X|storage-model.md]]` for a multi-file source) definition in the page's trailing - `## Footnotes` block, and adds `Source - X` to `sources:` - it prints the marker to paste at + Footnotes block (named per [Section headings](#section-headings)), and adds `Source - X` to + `sources:` - it prints the marker to paste at the fact; placing it is still manual. Never hand-type a cite-id (AGENTS.md invariant 1). This differs from a plain `[[Source - X]]` link, which only means "related to". - **Notation inside code is notation, not a reference.** A `[^cite-id]` or a `[[wikilink]]` written in backticks or a fenced block is read as an example: the citation does not count and the link does not exist. That is what lets a page document this stack's own syntax. It also - means a marker appended to a line *inside* a fence cites nothing - put it on a - `Quelle: [^cite-id]` line under the block, where it renders as a footnote instead of - travelling with the command when someone copies it. + means a marker appended to a line *inside* a fence cites nothing - put it on a source line + under the block (`<source-word>: [^cite-id]`, in the KB language), where it renders as a + footnote instead of travelling with the command when someone copies it. - A source cited inline must also appear in the page's frontmatter `sources:` list; `wikitool lint` checks this in both directions, and hard-errors on a leftover pre-migration `^[[...]]` marker, an undefined `[^cite-id]` reference, or an orphaned Footnotes definition. @@ -215,23 +220,20 @@ one - and never file the synthesized version back into the wiki. `confidence` is *derived* from it by `tools/wikitool confidence decay` and must never be edited directly. -Base score for a single source is 0.5, adjusted by: - -- **+0.2 per supporting source** (max +0.6) -- **+0.2** if confirmed <30 days ago, **+0.1** if <90 days -- **+0.1** for official documentation, **+0.05** for a reputable secondary source -- **+0.1** if multiple independent sources agree - Re-assess a page with `tools/wikitool touch --page "<Title>" --confidence-base <value>`. -In prose, hedge according to the score: below 0.6 write "möglicherweise"/"kann"; below 0.4 -write "unsicher"/"unbestätigt". +What the number *means* - the base score, what raises it and by how much, and how to hedge in +prose below a threshold - is a rubric rather than a mechanism, so it is +[kb/CONVENTIONS.md § Confidence rubric](CONVENTIONS.md#confidence-rubric)'s. ## What does not belong here - Raw source material - it stays immutable under `raw/`. - Type definitions, frontmatter contracts, or templates - those live in `types/`. - Procedures for operating the tooling - those live in `instructions/`. +- **Anything an instance would have to rewrite for itself** - language, naming forms, tone, + relationship labels, the confidence rubric. Those are `kb/CONVENTIONS.md`'s, and a sentence + of that kind here is a sentence the stack ships over the instance's own answer. - Rules that apply to only one collection - those belong in that collection's `COLLECTION.md`. - Hand-edited generated files - see [Generated files](#generated-files). diff --git a/kb/CONVENTIONS.md b/kb/CONVENTIONS.md new file mode 100644 index 0000000..f19348c --- /dev/null +++ b/kb/CONVENTIONS.md @@ -0,0 +1,126 @@ +--- +language: de +profile: german +sections: + relationships: Beziehungen + see_also: Siehe auch + footnotes: Fußnoten +--- + +# kb/ - Authoring Conventions of This Instance + +The decisions [kb/CONTRACT.md](CONTRACT.md) deliberately does not make. The contract holds what +the code enforces and is identical in every instance; this file holds what *this* instance +chose, and no other instance has to agree with a word of it. + +**It binds all the same.** Everything below applies to every page under `kb/`, exactly as the +contract does. The difference is ownership, not authority: a rule here is changed by editing +this file, a rule there by changing the stack. + +Adopted from the `german` profile in +[instructions/kb-profiles.md](../instructions/kb-profiles.md). That catalogue is a palette, not +an enum - what is written here is what holds, whether or not a profile says the same thing. + +The frontmatter above is the one machine-read part. `sections:` names the three headings +`wikitool xref` and `wikitool cite` write into; `tools/chemenu/conventions.py` reads them and +`tools/chemenu/sections.py` is what the rest of the compiler asks. Renaming one here changes +what the tool *writes*; what it still *recognizes* is the union of that name, any +`section_aliases:` declared beside it, and the names this stack wrote before this file existed. +That asymmetry is the translation path: a page keeps working under its old heading until it is +itself translated. + +## Language + +Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it - +the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and +`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the +control plane stays English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and +the type-specs for non-page artifacts. + +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 +[kb/CONTRACT.md § Language and identifiers](CONTRACT.md#language-and-identifiers). + +Established English technical terms stay English inside German prose - "GitOps", "Ownership +Model", "Reverse Proxy", "Pull Request". Translate a term only where the German one is genuinely +the more common usage. A coined German equivalent nobody else writes makes the page harder to +find, not more idiomatic. + +Which terms those are, which have a settled German form, and the register the prose is written +in: [instructions/german-terminology.md](../instructions/german-terminology.md). It is lookup +material, not a second rule - every entry in it is a decision that was made wrong once first. + +### Section headings + +The canonical names are the frontmatter's: `## Beziehungen`, `## Siehe auch`, `## Fußnoten`. +The English forms this stack wrote before the corpus was translated are still recognized, so a +page carrying `## Relationships` is found and appended to correctly and `cite sync` leaves an +untranslated `## Footnotes` alone. Renaming such a heading is the translation pass's job, never +a side effect of another command. Any *other* heading an author adds is ordinary prose and is +translated with the rest. + +## Naming + +- Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case. +- Singular for entities: `ha-core.md`, not `ha-cores.md`. +- Comparison pages read as a comparison: `Go vs Rust.md`. +- ADRs are prefixed: `adr-001-use-go-modules.md`. +- Prefer readability over convention when the two conflict. + +What to name a thing: projects use their repository or common name; systems a descriptive +name; tools the tool's own name; technologies their standard spelling and capitalization; +people a full name or common handle. + +The one naming fact that is *not* a choice, and therefore lives in the contract: the filename +stem is the page title, and `[[wikilinks]]` must match it exactly. + +## Tone + +Wikipedia style: factual, neutral, specific. + +- No buzzwords ("bahnbrechend", "hochmodern", "leistungsstark", "revolutioniert"). +- No AI filler ("es sei angemerkt", "es ist wichtig zu betonen", "in der heutigen Zeit"). +- No em-dash asides carrying parenthetical reasoning. + +Good: "MQTT ist ein leichtgewichtiges Publish-Subscribe-Protokoll für Geräte mit knappen +Ressourcen." + +Bad: "MQTT ist ein bahnbrechendes, hochmodernes Protokoll, das die IoT-Kommunikation +revolutioniert - und es sei angemerkt, dass es ein Publish-Subscribe-Muster verwendet." + +The blockquote cap is not here: `wikitool lint` reports it, so it is the contract's. + +## Relationship labels + +`tools/wikitool xref add --rel-a/--rel-b` takes a free-text label. This instance uses a typed +one rather than a generic one: + +`hängt ab von` · `verwendet` · `implementiert` · `erweitert` · `ersetzt` · `steht in Konflikt mit` +· `benötigt` · `erzeugt` · `konsumiert` · `besitzt` · `pflegt` · `läuft auf` · `verwandt mit` +(last resort) + +The labels are prose written into a `- **label:** [[Title]]` bullet; no code matches on them, so +an untranslated page's English label is stale wording, not a broken reference. + +## Confidence rubric + +`confidence_base` is set by hand and `confidence` is derived from it - that mechanism is the +contract's. What the number *means* is this instance's: + +Base score for a single source is 0.5, adjusted by: + +- **+0.2 per supporting source** (max +0.6) +- **+0.2** if confirmed <30 days ago, **+0.1** if <90 days +- **+0.1** for official documentation, **+0.05** for a reputable secondary source +- **+0.1** if multiple independent sources agree + +In prose, hedge according to the score: below 0.6 write "möglicherweise"/"kann"; below 0.4 +write "unsicher"/"unbestätigt". + +## Keeping this file honest + +Change it when a convention actually changes, and treat a change to `sections:` as a corpus +migration rather than an edit: existing pages keep their old headings until something translates +them, and the alias list is what carries them in the meantime. `wikitool doctor` FAILs on a +missing or unfilled file, and `wikitool docs verify` refuses a `sections:` block that does not +name all three slots. diff --git a/kb/CONVENTIONS.md.template b/kb/CONVENTIONS.md.template new file mode 100644 index 0000000..233cd98 --- /dev/null +++ b/kb/CONVENTIONS.md.template @@ -0,0 +1,104 @@ +--- +# wikitool:template-unfilled - delete this line once the file is answered. +language: en +profile: none +sections: + relationships: Relationships + see_also: See Also + footnotes: Footnotes +# Headings this instance no longer writes but still recognizes, so a corpus can +# be translated page by page instead of all at once. Optional; the names this +# stack wrote before this file existed are always recognized anyway. +# section_aliases: +# relationships: [Beziehungen] +# see_also: [Siehe auch] +# footnotes: [Fußnoten] +--- + +# kb/ - Authoring Conventions of This Instance + +The decisions [kb/CONTRACT.md](CONTRACT.md) deliberately does not make. The contract holds what +the code enforces and is identical in every instance; this file holds what *this* instance +chooses, and no other instance has to agree with a word of it. + +**It binds all the same.** Everything below applies to every page under `kb/`, exactly as the +contract does. The difference is ownership, not authority: a rule here is changed by editing +this file, a rule there by changing the stack. + +Ready-made answers to every section below - including a complete German profile - are in +[instructions/kb-profiles.md](../instructions/kb-profiles.md). That catalogue is a palette, not +an enum: adopt an entry, adapt it, or write your own. What is written *here* is what holds. + +The frontmatter above is the one machine-read part. `sections:` names the three headings +`wikitool xref` and `wikitool cite` write into. Set them before the first page is written: +afterwards, changing one is a corpus migration rather than an edit. + +## Language + +Pages are written in **{language}**. This binds `kb/` and the authoring surface that shapes it - +the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and +`types/comparison.md`, whose `## Template` blocks are the body skeleton every new page starts +from. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the control plane stays +English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and the type-specs for +non-page artifacts. + +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 +[kb/CONTRACT.md § Language and identifiers](CONTRACT.md#language-and-identifiers). + +{Which established foreign-language technical terms stay untranslated inside this instance's +prose, and where the vocabulary for that is looked up. Delete this paragraph if the KB language +is the one those terms are already in.} + +### Section headings + +The canonical names are the frontmatter's. Any name this instance previously wrote stays +recognized through `section_aliases:`, which is what lets a corpus be translated page by page. +Renaming such a heading is the translation pass's job, never a side effect of another command. +Any *other* heading an author adds is ordinary prose. + +## Naming + +- {Title form - words and spaces, or kebab-case, or the subject's own spelling.} +- {Singular or plural for entities.} +- {How a comparison page's title reads.} +- {The ADR prefix, if this instance files decisions as pages.} +- {What to name a thing: projects, systems, tools, technologies, people.} + +The one naming fact that is *not* a choice, and therefore lives in the contract: the filename +stem is the page title, and `[[wikilinks]]` must match it exactly. + +## Tone + +{The register pages are written in, in one line.} + +- {Words and constructions this instance refuses, with examples in the KB language.} + +Good: {one sentence that is what this instance wants.} + +Bad: {the same sentence written the way it must not be.} + +## Relationship labels + +`tools/wikitool xref add --rel-a/--rel-b` takes a free-text label. Listing the ones this +instance uses is what keeps a graph typed rather than a wiki full of "related to": + +{the label vocabulary, in the KB language} + +No code matches on these, so an old label on an untranslated page is stale wording, not a +broken reference. + +## Confidence rubric + +`confidence_base` is set by hand and `confidence` is derived from it - that mechanism is the +contract's. What the number *means* is this instance's: + +{the base score, what raises it, and by how much} + +{How to hedge in prose at a low score, in the KB language.} + +## Keeping this file honest + +Change it when a convention actually changes, and treat a change to `sections:` as a corpus +migration rather than an edit. `wikitool doctor` FAILs on a missing or unfilled file, and +`wikitool docs verify` refuses a `sections:` block that does not name all three slots. diff --git a/kb/comparisons/COLLECTION.md b/kb/comparisons/COLLECTION.md index cb9b3a7..aaf36c3 100644 --- a/kb/comparisons/COLLECTION.md +++ b/kb/comparisons/COLLECTION.md @@ -1,3 +1,8 @@ +--- +profile: comparisons +required_by_stack: false +--- + # kb/comparisons/ - Collection Contract Structured head-to-head evaluations of two or more things that already have pages here. A @@ -7,8 +12,10 @@ comparison exists so that neither subject's own page has to argue against the ot That needs named, checkable dimensions and a stated trade-off; a page that lists differences without saying what they cost has described, not compared. -Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence -are defined there and are not restated here. +Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics, +provenance, citation, the confidence machinery - and +[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms, +tone, relationship labels, the confidence rubric. Neither is restated here. ## Types offered @@ -16,8 +23,9 @@ are defined there and are not restated here. ## Naming -The title reads as a comparison: `Go vs Rust.md`, `Traefik vs nginx.md`. Order the subjects as -they are most commonly spoken, not alphabetically. +The title form is [kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming)'s. What is local here +is the ordering: name the subjects as they are most commonly spoken together, not +alphabetically. ## Requirements diff --git a/kb/concepts/COLLECTION.md b/kb/concepts/COLLECTION.md index 818933d..b351f19 100644 --- a/kb/concepts/COLLECTION.md +++ b/kb/concepts/COLLECTION.md @@ -1,3 +1,8 @@ +--- +profile: concepts +required_by_stack: false +--- + # kb/concepts/ - Collection Contract Ideas rather than things: architectures, patterns, protocols, workflows, recurring problems, @@ -8,8 +13,10 @@ records *what*. without the reader having to open the entity pages that use it. If the explanation only makes sense once you already know the system, it is on the wrong page. -Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence -are defined there and are not restated here. +Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics, +provenance, citation, the confidence machinery - and +[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms, +tone, relationship labels, the confidence rubric. Neither is restated here. ## Types offered @@ -17,8 +24,8 @@ are defined there and are not restated here. ## Decisions and ADRs -An architectural decision is a concept page prefixed `adr-NNN-`, e.g. -`adr-001-use-go-modules.md`. It records: +An architectural decision is a concept page, prefixed as +[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records: - **Context** - what forced a decision. - **Decision** - what was chosen. diff --git a/kb/entities/COLLECTION.md b/kb/entities/COLLECTION.md index 6fbfcdf..6cc5a00 100644 --- a/kb/entities/COLLECTION.md +++ b/kb/entities/COLLECTION.md @@ -1,3 +1,8 @@ +--- +profile: entities +required_by_stack: false +--- + # kb/entities/ - Collection Contract Concrete things that exist: a project, a deployed system, a CLI tool, a technology, a person or @@ -7,8 +12,10 @@ an organization. If it can be pointed at, it is an entity. is, where it actually is, and whether that is still true. An entity page that describes a system correctly but names no host, path, version or status has not earned its keep. -Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence -are defined there and are not restated here. +Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics, +provenance, citation, the confidence machinery - and +[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms, +tone, relationship labels, the confidence rubric. Neither is restated here. ## Types offered diff --git a/kb/sources/COLLECTION.md b/kb/sources/COLLECTION.md index c808c4c..a98610b 100644 --- a/kb/sources/COLLECTION.md +++ b/kb/sources/COLLECTION.md @@ -1,3 +1,8 @@ +--- +profile: sources +required_by_stack: true +--- + # kb/sources/ - Collection Contract One page per ingested source. A source page is the bridge between the untrusted material in @@ -9,8 +14,14 @@ concluded from it. Where the source is wrong, say what it claims and let the sub carry the correction. A source page that has been improved beyond its source is no longer evidence for anything. -Inherits [kb/CONTRACT.md](../CONTRACT.md) - naming, tone, linking, provenance and confidence -are defined there and are not restated here. +Inherits [kb/CONTRACT.md](../CONTRACT.md) for the rules the stack enforces - linking mechanics, +provenance, citation, the confidence machinery - and +[kb/CONVENTIONS.md](../CONVENTIONS.md) for what this instance decided: language, naming forms, +tone, relationship labels, the confidence rubric. Neither is restated here. + +**This collection is `required_by_stack`.** `sources coverage`, `[^cite-id]` resolution and +`kb/provenance.md` resolve against it by name, so unlike every other collection it may not be +renamed or dropped - its authoring rules below are the instance's, its existence is not. ## Types offered diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index a98ae2a..066b4f7 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -67,10 +67,10 @@ tools/wikitool <command> --help | `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 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 here (and vice versa), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) | +| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), 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, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, everything ignored under `reports/` and the published skill directories) | | `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate | | `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) | -| `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/`, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, every `kb/*/COLLECTION.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `VERSION`, `USER.md.template`/`SOUL.md.template` (the templates ship; a filled `USER.md`/`SOUL.md` never does - the root allowlist is what makes that automatic), 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/`, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/`, `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` never do - all four bind their instance and none of them 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 | | `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 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 | @@ -80,7 +80,7 @@ tools/wikitool <command> --help | `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, and structural frontmatter. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate | | `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable | | `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it | -| `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 environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. 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, and `WIKITOOL_SESSION_ID`. 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 diff --git a/tools/README.md b/tools/README.md index 59b74f7..95523a7 100644 --- a/tools/README.md +++ b/tools/README.md @@ -39,7 +39,8 @@ tools/ errors.py ChemenuError / ValidationError / BackendError corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty kb_scan.py page iteration/loading over kb/ - kb_collections.py collection discovery (a directory with COLLECTION.md) + kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself + conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces type_resolver.py type-spec loading and schema resolution lint_core.py the lint checks and the report, with no CLI attached types_core.py type-spec listing/description, with no CLI attached @@ -110,15 +111,23 @@ procedure written down in advance is one an agent can complete alone. Whether a human *actually* saw it is not enforced here - that question is answered in the eval layer (`evals/trajectory.py`, `clearance-ended-the-turn`). -**Section names are a vocabulary, not literals.** `xref add` writes into Relationships and See -Also, and `cite add` owns the trailing Footnotes block, so those three headings are structure the -tool matches on. They are named once in `sections.py`, and each has one canonical spelling - what -the tool writes - plus aliases it still recognizes. That asymmetry is what let the wiki be -translated page by page instead of atomically: an untranslated `## Relationships` is still found -and appended to. Dropping an alias is therefore a breaking change for any page not yet converted, -not a cleanup. Renaming a heading is a migration's job; no other command may do it as a side -effect (see `cite_block_heading` in `provenance.py`, which exists solely so `cite sync` stays a -no-op on an untranslated page). +**Section names are a vocabulary, not literals - and not the stack's.** `xref add` writes into +Relationships and See Also, and `cite add` owns the trailing Footnotes block, so those three +headings are structure the tool matches on. *Which words they are* is the corpus's own answer: +`conventions.py` reads them from `kb/CONVENTIONS.md`, `sections.py` resolves them on access +(PEP 562, the way `config` resolves its paths), and no heading text is written down in Python +except the pre-conventions fallback for an instance that has not declared one yet. + +Each slot has one canonical spelling - what the tool writes - plus aliases it still recognizes. +That asymmetry is what let the wiki be translated page by page instead of atomically: an +untranslated `## Relationships` is still found and appended to. Dropping an alias is therefore a +breaking change for any page not yet converted, not a cleanup. Renaming a heading is a +migration's job; no other command may do it as a side effect (see `cite_block_heading` in +`provenance.py`, which exists solely so `cite sync` stays a no-op on an untranslated page). + +Because the value is resolved rather than bound, nothing may capture it at import time - not a +module constant, not an evaluated default argument. That is why `provenance.CITE_BLOCK_HEADING` +is a module `__getattr__` and `render_cite_block(heading=None)` resolves inside the call. **Generated output is never committed.** `reports/`, `.agents/skills/` and `.claude/skills/` are build output; `docs verify` carries canaries in both diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index e99384d..35d5d33 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -2,9 +2,13 @@ repo's machinery. `export` copies the pipeline's schema/compiler/control-plane layers (types/, -tools/, instructions/, the stage contracts, every kb/*/COLLECTION.md) into an -empty target, with no kb/ pages, no raw/ content, and no git history - see -instructions/setup-instance.md for what happens after. It never calls git. +tools/, instructions/, the stage contracts) into an empty target, with no kb/ +pages, no raw/ content, and no git history - see instructions/setup-instance.md +for what happens after. It never calls git. + +The two binding-but-instance-owned documents under kb/ - each collection's +COLLECTION.md and kb/CONVENTIONS.md - cross as `.template` and are adopted by a +rename, the same split USER.md/SOUL.md use at the repo root. Three independent exclusion mechanisms feed the plan, for three different shapes of "does not belong in someone else's instance": @@ -35,7 +39,7 @@ from typing import Callable, NamedTuple, Optional, Union import typer -from chemenu import config, kb_collections, kb_state, version as version_mod +from chemenu import config, conventions, kb_collections, kb_state, version as version_mod from chemenu.commands._util import fail, rel_path, success, today_iso app = typer.Typer(help="Build a distributable copy of the wiki machinery.") @@ -289,12 +293,32 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]: for hook_dir in HOOK_DIRS: plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset())) + # `kb/CONTRACT.md` is stack-owned and ships verbatim; everything beside it + # under `kb/` is the instance's own and ships only as a `.template`. That is + # the personalization split (`USER.md`/`SOUL.md`) one directory down, and + # the reason for it is the same: a distribution can say what the file + # decides, never what this instance decided. kb_contract = config.KB_DIR / "CONTRACT.md" if kb_contract.is_file(): plan["kb/CONTRACT.md"] = _read_planned_file(kb_contract, "kb/CONTRACT.md") + + conventions_template = config.KB_DIR / conventions.CONVENTIONS_TEMPLATE + if conventions_template.is_file(): + rel = f"kb/{conventions.CONVENTIONS_TEMPLATE}" + plan[rel] = _read_planned_file(conventions_template, rel) + + # A collection contract is instance-owned too, but unlike `USER.md` the + # shipped content is not *wrong* for the receiver - it is the profile this + # repo's own collections adopted, and a fine starting point. So the file + # itself ships, under the template name: one source of truth here, and a + # receiving instance that has to rename it before it counts. Keeping a + # separate `.template` beside each contract would have meant maintaining two + # near-identical copies of the same text, which is the drift AGENTS.md + # invariant 8 exists to prevent. for collection in kb_collections.iter_kb_collections(): - rel = f"kb/{collection.name}/COLLECTION.md" - plan[rel] = _read_planned_file(collection / "COLLECTION.md", rel) + source = collection / kb_collections.CONTRACT_NAME + rel = f"kb/{collection.name}/{kb_collections.CONTRACT_NAME}.template" + plan[rel] = _read_planned_file(source, rel) for relative in CONTRACT_ONLY_STAGES: source = config.ROOT / relative @@ -339,8 +363,21 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]: # IP literals) was considered and rejected - the project's own host legitimately # appears in INSTALL.md and version.py, so such a scan would either whitelist # the very string it is looking for or cry wolf on every export. +# +# `COLLECTION.md` and `CONVENTIONS.md` are deliberately *not* on the allowed +# list any more. Both bind, and both are the instance's to write, so they cross +# the boundary as `.template` and are adopted by a rename - a plan carrying the +# filled name would hand a new instance this one's authoring conventions as +# though they were the stack's. _CONTENT_PREFIXES = ("kb/", "raw/") -_CONTENT_ALLOWED_NAMES = ("CONTRACT.md", "COLLECTION.md", "log.md", ".gitkeep") +_CONTENT_ALLOWED_NAMES = ( + "CONTRACT.md", + f"{kb_collections.CONTRACT_NAME}.template", + conventions.CONVENTIONS_TEMPLATE, + "log.md", + ".gitkeep", +) +_INSTANCE_OWNED_KB_FILES = (kb_collections.CONTRACT_NAME, conventions.CONVENTIONS_FILENAME) def find_leaks(plan: dict[str, PlannedFile]) -> list[str]: @@ -350,6 +387,8 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]: name = relative.rsplit("/", 1)[-1] if name in config.PERSONALIZATION_FILES or name == config.ENVIRONMENT_FILE: leaks.append(f"{relative} (one instance's own personalization)") + elif relative.startswith("kb/") and name in _INSTANCE_OWNED_KB_FILES: + leaks.append(f"{relative} (this instance's authoring conventions; ship the .template)") elif relative.startswith("instructions/dev/"): leaks.append(f"{relative} (stack-development only)") elif relative.startswith(_CONTENT_PREFIXES) and name not in _CONTENT_ALLOWED_NAMES: @@ -394,7 +433,8 @@ def export_command( AGENTS.md/README.md (dev-instance-only marker blocks removed), instructions/ (no instructions/dev/), types/, tools/ (no venv/caches), the .github/hooks/+.vibe session-tracing config plus .claude/settings.json, - every kb/*/COLLECTION.md (no pages, no areas), empty + kb/CONTRACT.md plus a COLLECTION.md.template per collection and + kb/CONVENTIONS.md.template (no pages, no areas), empty raw/{articles,documents,notes,assets}/, VERSION, the USER.md/SOUL.md personalization templates (never the filled files), and a .wikitool-release.json stamp. The --source-*/--release-url/--update-url diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index 1730d5a..d220954 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -37,7 +37,7 @@ from typing import Optional import typer -from chemenu import config, kb_collections, version as version_mod +from chemenu import config, conventions, kb_collections, version as version_mod from chemenu.commands._util import fail, rel_path, success app = typer.Typer(help="Verify documentation that mirrors the code or repo layout.") @@ -114,6 +114,12 @@ REQUIRED_TRACKED_PATHS = ( "instructions/CONTRACT.md", "instructions/wiki-query/SKILL.md", "ENVIRONMENT.md.template", + # The one `.template` that lives under a content directory. It is what a + # distribution ships in place of this instance's own `kb/CONVENTIONS.md`, so + # an ignore rule reaching it would produce exports whose receiving instance + # has nothing to fill in - and `find_leaks` refuses to substitute the filled + # file, correctly, so the export would simply be missing it. + "kb/CONVENTIONS.md.template", ) CLI_README = config.ROOT / "tools" / "CONTRACT.md" @@ -207,13 +213,22 @@ def check_cli_readme() -> list[str]: def check_collection_contracts() -> list[str]: - """The three structural rules that define what a collection is. + """The structural rules that define what a collection is, plus what each one + has to declare about itself. Collections are discovered by contract presence rather than listed here, so `mkdir kb/<name>` + a COLLECTION.md is all it takes to add one. That only works if the inverse is also checked: a directory under kb/ *without* a contract is an unclaimed subtree whose pages obey no local rules, and a contract outside kb/ quietly widens "collection" back out to "any directory". + + Presence alone stopped being enough once the contracts became + instance-owned. A `COLLECTION.md` an instance wrote can be about anything, + so the two facts the stack still needs from it - which profile it adopted, + and whether the stack resolves against it by name - are declared in its + frontmatter and checked here (`kb_collections.declaration_issues`), together + with the shape of `kb/CONVENTIONS.md`, whose section names the compiler + reads. """ issues = [] @@ -243,6 +258,9 @@ def check_collection_contracts() -> list[str]: if not (config.ROOT / relative_path).exists(): issues.append(f"{relative_path} is missing - it is the authoring contract for its stage") + issues += kb_collections.declaration_issues() + issues += conventions.declaration_issues() + return issues diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index a80077c..e89d9e3 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -20,7 +20,7 @@ from typing import Optional import typer from rich.console import Console -from chemenu import config, kb_collections, version as version_mod +from chemenu import config, conventions, kb_collections, version as version_mod from chemenu.commands import git_publish, instructions_cmd from chemenu.commands._util import rel_path from chemenu.session import ENV_VAR as SESSION_ENV_VAR @@ -213,6 +213,47 @@ def check_personalization() -> Check: return Check("personalization", "OK", f"{', '.join(config.PERSONALIZATION_FILES)} present and filled") +def check_conventions() -> Check: + """Whether this instance has said how its own pages are written. + + `kb/CONVENTIONS.md` carries the decisions `kb/CONTRACT.md` deliberately no + longer makes: the KB language and its three tool-owned section headings, the + relationship-label vocabulary, the tone examples, the confidence rubric, the + ADR prefix. The compiler reads the section names out of it, so an instance + without one is not merely undocumented - `xref add` and `cite add` fall back + to the names this stack hardcoded before the file existed, which is right + only for a corpus that was written under them. + + Hence `FAIL` rather than `WARN`, and hence the same two failure modes the + personalization pair has: the distribution can ship the template but never + the filled file, so a template renamed and left unanswered looks present and + decides nothing. + """ + path = conventions.conventions_file() + fix = ( + "Copy kb/CONVENTIONS.md.template to kb/CONVENTIONS.md and answer it - the KB-language " + "step of instructions/setup-instance.md walks it, and instructions/kb-profiles.md has " + "the ready-made profiles to adopt" + ) + if not path.is_file(): + return Check( + "conventions", "FAIL", + f"kb/{conventions.CONVENTIONS_FILENAME} is missing - this instance has not " + "declared how its pages are written", + fix, + ) + issues = conventions.declaration_issues() + if issues: + return Check("conventions", "FAIL", "; ".join(issues), fix) + declared = conventions.language() or "unspecified" + headings = ", ".join(conventions.canonical(slot) for slot in conventions.SLOTS) + return Check( + "conventions", "OK", + f"kb/{conventions.CONVENTIONS_FILENAME} present, language {declared}, " + f"sections {headings}", + ) + + def check_environment() -> Check: """Whether this checkout records the environment it works through. @@ -399,6 +440,7 @@ def run_doctor() -> list[Check]: check_skills(), check_structure(), check_personalization(), + check_conventions(), check_environment(), check_publish_remotes(), check_generated_files(), @@ -411,9 +453,9 @@ def doctor_command( json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"), ): """Check that this instance is correctly configured: dependencies, author, - git identity/remote, published skills, structure, personalization, - generated files, and session scoping. Read-only. Exits 1 only if a check - FAILs.""" + git identity/remote, published skills, structure, personalization, KB + conventions, generated files, and session scoping. Read-only. Exits 1 only + if a check FAILs.""" checks = run_doctor() if json_out: diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index dde5fd9..b3e239b 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -24,7 +24,7 @@ import re import typer -from chemenu import config +from chemenu import config, conventions from chemenu.commands._util import ( check_collision, check_raw_files_exist, @@ -166,7 +166,11 @@ def _apply_template_variables(template: str, variables: Dict[str, Any]) -> str: """Apply variable substitutions to a template string. Supports: - - `{field}` - plain substitution from `variables[field]` + - `{field}` - plain substitution from `variables[field]`, including the + `{section.<slot>}` names this instance gave the three tool-owned + headings (see chemenu.conventions). Those are what took the KB language + out of `types/*.md`: a template writes `## {section.relationships}`, so + scaffolding a page in another language needs no edit under `types/` - `{field|filter}` - apply a named filter (bullets, join, capitalize) to `variables[field]`'s value, so templates can render list/enum frontmatter fields directly instead of the caller precomputing a @@ -324,7 +328,13 @@ def new_page_command( path = target_dir / f"{page_title}.md" body = _apply_template_variables( - template, {**frontmatter, "name": name, "today": today.isoformat()} + template, + { + **frontmatter, + "name": name, + "today": today.isoformat(), + **conventions.section_variables(), + }, ) write_page(path, frontmatter, body) diff --git a/tools/chemenu/conventions.py b/tools/chemenu/conventions.py new file mode 100644 index 0000000..96b0eff --- /dev/null +++ b/tools/chemenu/conventions.py @@ -0,0 +1,224 @@ +"""What this instance decided, read from `kb/CONVENTIONS.md`. + +`kb/CONTRACT.md` and this file answer two different questions. The contract +holds what the code enforces - what a collection is, which files are generated, +how `provenance:` and `confidence_base` work - and is identical in every +instance, so `dist export` ships it verbatim. `kb/CONVENTIONS.md` holds what +each instance decides for itself: the language its pages are written in, the +relationship-label vocabulary, the tone examples, the confidence rubric, the +ADR prefix. The distribution ships only `kb/CONVENTIONS.md.template`, exactly +the split `USER.md`/`SOUL.md` already use one directory up. + +Only one part of it is machine-read, and it is the part that used to be Python: +the three section headings `xref add` and `cite add` write. While +`RELATIONSHIPS = "Beziehungen"` sat in `sections.py`, an instance writing its +pages in any other language had to edit the compiler to say so - which made the +KB language a stack property in code while every document called it an instance +decision. + +**A missing conventions file is not an error here.** It is the state an +instance is in between installing this machinery and running the migration that +writes the file, and every command has to keep working through it. The fallback +is `PRE_CONVENTIONS_NAMES` - not "the stack's language", but *what this stack +hardcoded before the file existed*, which is by construction what any corpus +reaching that state was written with. `wikitool doctor` is what says the file is +missing; degrading loudly here would take out `doctor` itself. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +from chemenu import config +from chemenu.frontmatter_io import read_page + +CONVENTIONS_FILENAME = "CONVENTIONS.md" +CONVENTIONS_TEMPLATE = f"{CONVENTIONS_FILENAME}.template" + +# The three tool-owned headings, by slot name. The slot is the stable +# identifier - it is what code, the type-spec templates and the conventions +# file all key on - while the heading text itself is the instance's to choose. +RELATIONSHIPS = "relationships" +SEE_ALSO = "see_also" +FOOTNOTES = "footnotes" +SLOTS = (RELATIONSHIPS, SEE_ALSO, FOOTNOTES) + +# Frontmatter keys read out of kb/CONVENTIONS.md. +SECTIONS_KEY = "sections" +SECTION_ALIASES_KEY = "section_aliases" +LANGUAGE_KEY = "language" + +# Every heading name this stack has ever written as canonical, newest first. +# Two jobs, and they are separate: the first entry is the fallback for an +# instance that has no conventions file yet, and the whole tuple is an implicit +# alias set that every instance recognizes regardless of what it declares. The +# second is what makes a corpus translatable page by page - a page still +# carrying `## Footnotes` is untranslated, not broken, and `cite sync` has to +# stay a no-op on it. +PRE_CONVENTIONS_NAMES: dict[str, tuple[str, ...]] = { + RELATIONSHIPS: ("Beziehungen", "Relationships"), + SEE_ALSO: ("Siehe auch", "See Also"), + FOOTNOTES: ("Fußnoten", "Footnotes"), +} + + +def conventions_file() -> Path: + return config.KB_DIR / CONVENTIONS_FILENAME + + +# (path, mtime_ns, size) -> frontmatter. `heading_re()` is called once per page +# per lint run, so re-reading the file each time would put a stat+parse on a +# per-page path for a document that changes about once per instance. Keyed on +# the stat rather than on the path alone, so a test that rewrites the file +# inside one process is not answered out of the cache. +_CACHE: dict[tuple[str, int, int], dict[str, Any]] = {} + + +def read_conventions() -> dict[str, Any]: + """`kb/CONVENTIONS.md`'s frontmatter, or `{}` if the file is absent. + + Permissive on purpose, like `read_page` itself: a conventions file with + broken YAML degrades to the pre-conventions defaults rather than taking + every command down with it. `doctor` and `docs verify` are where that + surfaces as a finding. + """ + path = conventions_file() + if not path.is_file(): + return {} + stat = path.stat() + key = (str(path), stat.st_mtime_ns, stat.st_size) + if key not in _CACHE: + frontmatter, _ = read_page(path) + _CACHE.clear() + _CACHE[key] = frontmatter + return _CACHE[key] + + +def reset_cache() -> None: + """Drop the parsed conventions. For a caller that rewrote the file and has + to see the new value within the same stat resolution.""" + _CACHE.clear() + + +def _mapping(key: str) -> dict[str, Any]: + value = read_conventions().get(key) + return value if isinstance(value, dict) else {} + + +def language() -> Optional[str]: + """The declared KB language tag (e.g. `de`), or None if undeclared. + + Nothing in the compiler branches on it - the language is carried by the + prose the instance writes, not by a switch. It is here because the + conventions file is where a human and an agent look the answer up, and + because `doctor` reports it. + """ + value = read_conventions().get(LANGUAGE_KEY) + if value is None: + return None + return str(value).strip() or None + + +def canonical(slot: str) -> str: + """The heading name this instance writes for `slot`.""" + declared = _mapping(SECTIONS_KEY).get(slot) + if isinstance(declared, str) and declared.strip(): + return declared.strip() + return PRE_CONVENTIONS_NAMES[slot][0] + + +def names(slot: str) -> tuple[str, ...]: + """Every heading name `slot` is recognized under, canonical first. + + The canonical name, then any `section_aliases:` the instance declared, then + the names this stack wrote before the conventions file existed. Deduplicated + while preserving that order, so an instance declaring English does not end + up with `Relationships` listed twice. + """ + declared_aliases = _mapping(SECTION_ALIASES_KEY).get(slot) + extra = declared_aliases if isinstance(declared_aliases, list) else [] + ordered = [ + canonical(slot), + *(str(name).strip() for name in extra if str(name).strip()), + *PRE_CONVENTIONS_NAMES[slot], + ] + seen: dict[str, None] = {} + for name in ordered: + seen.setdefault(name, None) + return tuple(seen) + + +def section_variables() -> dict[str, str]: + """The `{section.<slot>}` substitutions a type-spec template can use. + + This is what took the three German headings out of `types/*.md`: a template + writes `## {section.relationships}` and the instance's own conventions fill + it in, so scaffolding a page in another language needs no edit under + `types/`. + """ + return {f"section.{slot}": canonical(slot) for slot in SLOTS} + + +def declaration_issues() -> list[str]: + """What is wrong with this instance's conventions file, if anything. + + Shared by `doctor` (which FAILs on it) and `docs verify` (which refuses a + tree with it), so the two cannot disagree about what a valid declaration + looks like. An absent file is *not* reported here - that is a separate + finding with a separate fix, and only `doctor` makes it one. + """ + path = conventions_file() + if not path.is_file(): + return [] + + issues: list[str] = [] + frontmatter, _ = read_page(path) + if not frontmatter: + return [ + f"kb/{CONVENTIONS_FILENAME} has no readable frontmatter - it must declare " + f"`{SECTIONS_KEY}:` with the heading names this instance writes" + ] + + declared = frontmatter.get(SECTIONS_KEY) + if not isinstance(declared, dict): + return [ + f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}:` must be a mapping of " + f"{'/'.join(SLOTS)} to the heading text this instance writes" + ] + for slot in SLOTS: + value = declared.get(slot) + if not isinstance(value, str) or not value.strip(): + issues.append( + f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is missing or empty - " + "`xref add` and `cite add` write into a heading this instance has not named" + ) + for slot in sorted(set(declared) - set(SLOTS)): + issues.append( + f"kb/{CONVENTIONS_FILENAME}: `{SECTIONS_KEY}.{slot}` is not a section the tool " + f"owns; the slots are {', '.join(SLOTS)}" + ) + + aliases = frontmatter.get(SECTION_ALIASES_KEY, {}) + if not isinstance(aliases, dict): + issues.append( + f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}:` must be a mapping of a " + "slot to the list of headings still recognized under it" + ) + else: + for slot, value in sorted(aliases.items()): + if slot not in SLOTS: + issues.append( + f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` is not a " + f"section the tool owns; the slots are {', '.join(SLOTS)}" + ) + elif not isinstance(value, list): + issues.append( + f"kb/{CONVENTIONS_FILENAME}: `{SECTION_ALIASES_KEY}.{slot}` must be a list" + ) + + if config.TEMPLATE_SENTINEL in path.read_text(encoding="utf-8"): + issues.append( + f"kb/{CONVENTIONS_FILENAME} still carries the `{config.TEMPLATE_SENTINEL}` line - " + "a renamed template is not a filled one" + ) + return issues diff --git a/tools/chemenu/kb_collections.py b/tools/chemenu/kb_collections.py index 700ecd7..9dd3c2b 100644 --- a/tools/chemenu/kb_collections.py +++ b/tools/chemenu/kb_collections.py @@ -20,11 +20,32 @@ Two corollaries are enforced rather than documented: from __future__ import annotations from pathlib import Path +from typing import Any from chemenu import config CONTRACT_NAME = "COLLECTION.md" +# What a collection declares about itself, in `COLLECTION.md`'s frontmatter. +# +# Presence on the filesystem says a collection *exists*; it cannot say who owns +# the rules inside it. A `COLLECTION.md` is instance-owned - the distribution +# ships a `.template` per default collection and the instance writes the real +# one - so the two facts the stack still needs from it have to be declared +# rather than inferred from the directory name, which an instance is free to +# choose. +PROFILE_FIELD = "profile" +REQUIRED_BY_STACK_FIELD = "required_by_stack" + +# Collections `wikitool` itself depends on by name, as opposed to ones that +# merely hold pages. `sources` is here because three parts of the stack resolve +# against it rather than against a page's type: `sources coverage` asks which +# raw files no source page claims, every `[^cite-id]` footnote resolves to a +# page in it, and `sources rebuild-index` writes `kb/provenance.md` from it. An +# instance may add, rename or drop any collection that is not on this list; +# renaming one that is leaves those three with nothing to resolve against. +STACK_REQUIRED_COLLECTIONS = ("sources",) + def iter_kb_collections(kb_dir: Path | None = None) -> list[Path]: """Return every collection directory under kb/, sorted by name. @@ -82,6 +103,83 @@ def stray_collection_contracts(root: Path | None = None, kb_dir: Path | None = N return sorted(stray) +def collection_declaration(collection: Path) -> dict[str, Any]: + """A collection's own `COLLECTION.md` frontmatter, or `{}` if it has none. + + Permissive like every other frontmatter read in this package: an unreadable + declaration degrades to empty here and is reported by `docs verify`, rather + than taking down the discovery every command starts with. + """ + from chemenu.frontmatter_io import read_page + + contract = collection / CONTRACT_NAME + if not contract.is_file(): + return {} + frontmatter, _ = read_page(contract) + return frontmatter + + +def declaration_issues(kb_dir: Path | None = None) -> list[str]: + """What each `COLLECTION.md` fails to declare about itself. + + Two fields, for two questions the filesystem cannot answer. `profile:` + names the entry in `instructions/kb-profiles.md` this collection adopted - + free text, because the profile catalogue is a palette rather than an enum, + and a collection an instance invented has no entry there to name. + `required_by_stack:` is not the instance's to choose at all: it must agree + with `STACK_REQUIRED_COLLECTIONS`, so a collection whose contract claims the + stack depends on it - or one the stack does depend on and that says it does + not - is a finding rather than a preference. + """ + root = kb_dir if kb_dir is not None else config.KB_DIR + issues: list[str] = [] + + present = {path.name for path in iter_kb_collections(root)} + for name in STACK_REQUIRED_COLLECTIONS: + if name not in present: + issues.append( + f"kb/{name}/ is missing - `sources coverage`, `[^cite-id]` resolution and " + f"`kb/provenance.md` all resolve against it by name" + ) + + for collection in iter_kb_collections(root): + relative = f"kb/{collection.name}/{CONTRACT_NAME}" + declared = collection_declaration(collection) + if not declared: + issues.append( + f"{relative} has no frontmatter - it must declare `{PROFILE_FIELD}:` and " + f"`{REQUIRED_BY_STACK_FIELD}:` (see instructions/kb-profiles.md)" + ) + continue + + profile = declared.get(PROFILE_FIELD) + if not isinstance(profile, str) or not profile.strip(): + issues.append( + f"{relative}: `{PROFILE_FIELD}:` is missing or empty - name the profile from " + f"instructions/kb-profiles.md this collection adopted, or `none`" + ) + + required = declared.get(REQUIRED_BY_STACK_FIELD) + expected = collection.name in STACK_REQUIRED_COLLECTIONS + if not isinstance(required, bool): + issues.append( + f"{relative}: `{REQUIRED_BY_STACK_FIELD}:` is missing or not a boolean - " + f"it must be {str(expected).lower()} for this collection" + ) + elif required != expected: + issues.append( + f"{relative}: `{REQUIRED_BY_STACK_FIELD}: {str(required).lower()}` contradicts " + f"the stack, which " + + ( + "does depend on this collection by name" + if expected + else "depends on no collection of this name" + ) + + f" - it must be {str(expected).lower()}" + ) + return issues + + def _is_vendored(path: Path, repo_root: Path) -> bool: try: relative = path.relative_to(repo_root) diff --git a/tools/chemenu/kb_scan.py b/tools/chemenu/kb_scan.py index 36433a1..e3a4eeb 100644 --- a/tools/chemenu/kb_scan.py +++ b/tools/chemenu/kb_scan.py @@ -14,9 +14,9 @@ WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)") # Root-level files under kb/ that are not pages: the generated catalog map, log -# and provenance index, plus the contract that constrains the tree rather than -# living in it. -_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md"} +# and provenance index, plus the two documents that constrain the tree rather +# than living in it - the stack's contract and this instance's own conventions. +_KB_META_FILES = {"index.md", "log.md", "provenance.md", "CONTRACT.md", "CONVENTIONS.md"} # The per-collection authoring contract. Unlike the meta files above it is never # at the kb root - it sits one level down, in every collection - so it has to be diff --git a/tools/chemenu/provenance.py b/tools/chemenu/provenance.py index 9426313..f75ce59 100644 --- a/tools/chemenu/provenance.py +++ b/tools/chemenu/provenance.py @@ -64,7 +64,22 @@ LEGACY_CITE_RE = re.compile(r"\^\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]") # Written under the canonical name, but split_cite_block() matches the aliases # too - a page whose block still says "## Footnotes" keeps working until it is # translated. See chemenu/sections.py. -CITE_BLOCK_HEADING = f"## {sections.FOOTNOTES}" +# +# Resolved on access rather than bound at import (PEP 562), because the +# canonical name is now this instance's own - `kb/CONVENTIONS.md`, via +# chemenu.conventions - and a module constant would freeze whichever corpus the +# process started in. The functions below take it as a default the same way, via +# None rather than an evaluated default argument. +def __getattr__(name: str) -> str: + if name == "CITE_BLOCK_HEADING": + return f"## {sections.FOOTNOTES}" + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def cite_block_heading_default() -> str: + """The Footnotes heading this instance writes, `## ` included.""" + return f"## {sections.FOOTNOTES}" + _SOURCE_TITLE_PREFIX = "Source - " @@ -193,18 +208,23 @@ def cite_block_heading(body: str) -> str: alias is untranslated, not broken, and `cite sync` has to stay a no-op on it. Translating the heading is the migration's job, not the tool's.""" match = sections.heading_re(sections.FOOTNOTES).search(body) - return match.group(0).strip() if match else CITE_BLOCK_HEADING + return match.group(0).strip() if match else cite_block_heading_default() def render_cite_block( - definitions: dict[str, tuple[str, Optional[str]]], heading: str = CITE_BLOCK_HEADING + definitions: dict[str, tuple[str, Optional[str]]], heading: Optional[str] = None ) -> str: """Render the Footnotes block for `definitions` (cite_id -> (title, qualifier)), preserving dict order. Empty dict renders "" - a page with - no citations carries no block at all.""" + no citations carries no block at all. + + `heading=None` means this instance's canonical Footnotes heading, resolved + at call time. It cannot be an evaluated default: the name comes from + `kb/CONVENTIONS.md`, so a default bound at import would answer for whichever + corpus the process started in.""" if not definitions: return "" - lines = [heading, ""] + lines = [heading or cite_block_heading_default(), ""] for cid, (title, qualifier) in definitions.items(): target = f"{title}|{qualifier}" if qualifier else title lines.append(f"[^{cid}]: [[{target}]]") @@ -214,7 +234,7 @@ def render_cite_block( def render_page_body( head: str, definitions: dict[str, tuple[str, Optional[str]]], - heading: str = CITE_BLOCK_HEADING, + heading: Optional[str] = None, ) -> str: """Reassemble a page body from its non-Footnotes content and citation definitions - the inverse of split_cite_block(). Pass the original body's diff --git a/tools/chemenu/sections.py b/tools/chemenu/sections.py index e2ddc7d..439fa92 100644 --- a/tools/chemenu/sections.py +++ b/tools/chemenu/sections.py @@ -5,10 +5,12 @@ See Also by name, and `cite add` owns the trailing Footnotes block. An author may add any other heading they like - only the ones named here are matched by the tool, and only these have to stay predictable. -kb/CONTRACT.md's Language rule puts page prose in the KB language. That used to -force these three to stay English, because a translated heading did not error - -it made `xref add` append a *second* section, silently. This module removes that -constraint by making the vocabulary explicit in one place. +**Which words they are is the instance's decision, not the stack's.** They +follow the KB language, and the KB language is declared in `kb/CONVENTIONS.md` +(see `chemenu.conventions`). This module used to hold `RELATIONSHIPS = +"Beziehungen"` as a Python constant, which made an instance writing its pages +in any other language edit the compiler to say so - the one place a documented +instance convention had leaked into code. Each heading has one **canonical** name - what the tool writes - and any number of **aliases** it still recognizes. That asymmetry is what lets a corpus migrate @@ -16,24 +18,59 @@ page by page instead of all at once: a page still carrying `## Relationships` is found and appended to correctly, and only takes the canonical name when the page itself is translated. Removing an alias is therefore a breaking change for every page not yet converted, not a cleanup. + +The three module attributes below resolve on access (PEP 562), the same way +`config`'s paths do and for the same reason: a caller that repoints `KB_DIR` +must not be answered out of a value bound at import time by whichever tree the +process started in. """ +from __future__ import annotations import re -RELATIONSHIPS = "Beziehungen" -SEE_ALSO = "Siehe auch" -FOOTNOTES = "Fußnoten" +from chemenu import conventions -ALIASES: dict[str, tuple[str, ...]] = { - RELATIONSHIPS: ("Relationships",), - SEE_ALSO: ("See Also",), - FOOTNOTES: ("Footnotes",), +# The slots, re-exported so a caller keeps using `sections.RELATIONSHIPS` as an +# opaque handle. The value it resolves to is the heading text; the name it is +# looked up under is stable. +_SLOT_ATTRS = { + "RELATIONSHIPS": conventions.RELATIONSHIPS, + "SEE_ALSO": conventions.SEE_ALSO, + "FOOTNOTES": conventions.FOOTNOTES, } +def __getattr__(name: str) -> str: + slot = _SLOT_ATTRS.get(name) + if slot is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return conventions.canonical(slot) + + +def __dir__() -> list[str]: + return sorted([*globals(), *_SLOT_ATTRS]) + + +def _slot_of(canonical: str) -> str: + """The slot whose current canonical name is `canonical`. + + Callers hold on to the resolved heading text (`sections.FOOTNOTES`), not to + the slot, so the lookup has to go back the other way. Falls back to matching + against every name a slot is recognized under, so a caller that resolved the + attribute before the conventions file changed still lands on the right slot. + """ + for slot in conventions.SLOTS: + if canonical == conventions.canonical(slot): + return slot + for slot in conventions.SLOTS: + if canonical in conventions.names(slot): + return slot + raise ValueError(f"{canonical!r} is not a tool-owned section heading") + + def names(canonical: str) -> tuple[str, ...]: """Every name `canonical` is recognized under, canonical first.""" - return (canonical, *ALIASES.get(canonical, ())) + return conventions.names(_slot_of(canonical)) def heading_re(canonical: str) -> re.Pattern[str]: @@ -44,4 +81,4 @@ def heading_re(canonical: str) -> re.Pattern[str]: def is_known(heading: str) -> bool: """True if `heading` is a canonical name or an alias of one.""" - return any(heading in names(canonical) for canonical in ALIASES) + return any(heading in conventions.names(slot) for slot in conventions.SLOTS) diff --git a/tools/chemenu/tests/conftest.py b/tools/chemenu/tests/conftest.py index 4b80f20..2c867d1 100644 --- a/tools/chemenu/tests/conftest.py +++ b/tools/chemenu/tests/conftest.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from chemenu import config +from chemenu import config, conventions from chemenu.frontmatter_io import write_page from chemenu.type_resolver import resolver @@ -77,9 +77,17 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # attribute. That binding outlives the test and hands the next one a # corpus directory belonging to the previous tree. Cleared on both sides, # so neither a leak from before nor one from this test can be inherited. + # One layer further in again: `conventions` parses `kb/CONVENTIONS.md` once + # and keys the result on the file's own path and stat, so a repointed + # `KB_DIR` cannot be answered out of it. Cleared here anyway, on both sides, + # for the same reason `config.reset()` is - a fixture that leaves state + # behind is the hole this file exists to close, and the cost of proving it + # cannot leak is one function call per test. config.reset() + conventions.reset_cache() yield home config.reset() + conventions.reset_cache() def use_shipped_type_specs(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tools/chemenu/tests/test_conventions.py b/tools/chemenu/tests/test_conventions.py new file mode 100644 index 0000000..7d8303f --- /dev/null +++ b/tools/chemenu/tests/test_conventions.py @@ -0,0 +1,167 @@ +"""Tests for `kb/CONVENTIONS.md` - the instance-owned half of the authoring rules. + +Two things are under test here, and they are the two the split exists for: the +compiler reads its section headings from the corpus rather than from Python, and +a collection declares who owns its rules rather than having it inferred from the +directory name. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from chemenu import config, conventions, kb_collections, sections + +GERMAN = ( + "---\n" + "language: de\n" + "profile: german\n" + "sections:\n" + " relationships: Beziehungen\n" + " see_also: Siehe auch\n" + " footnotes: Fußnoten\n" + "---\n\n# conventions\n" +) + +FRENCH = ( + "---\n" + "language: fr\n" + "profile: none\n" + "sections:\n" + " relationships: Relations\n" + " see_also: Voir aussi\n" + " footnotes: Notes\n" + "section_aliases:\n" + " relationships: [Beziehungen]\n" + "---\n\n# conventions\n" +) + + +@pytest.fixture +def kb_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + kb = tmp_path / "kb" + kb.mkdir() + monkeypatch.setattr(config, "ROOT", tmp_path) + monkeypatch.setattr(config, "KB_DIR", kb) + conventions.reset_cache() + yield kb + conventions.reset_cache() + + +def _write(kb: Path, text: str) -> None: + (kb / conventions.CONVENTIONS_FILENAME).write_text(text, encoding="utf-8") + conventions.reset_cache() + + +def _collection(kb: Path, name: str, profile: str = "none", required: bool = False) -> Path: + directory = kb / name + directory.mkdir(parents=True, exist_ok=True) + (directory / kb_collections.CONTRACT_NAME).write_text( + f"---\nprofile: {profile}\nrequired_by_stack: {str(required).lower()}\n---\n\n# {name}\n", + encoding="utf-8", + ) + return directory + + +def test_missing_file_falls_back_to_what_the_stack_used_to_hardcode(kb_root): + """The state between installing this machinery and running the migration + that writes the file. Every command has to keep working through it, and the + only corpus that can be in it was written under these names.""" + assert conventions.canonical(conventions.FOOTNOTES) == "Fußnoten" + assert sections.FOOTNOTES == "Fußnoten" + + +def test_the_compiler_writes_the_headings_the_instance_declared(kb_root): + _write(kb_root, FRENCH) + assert sections.RELATIONSHIPS == "Relations" + assert sections.SEE_ALSO == "Voir aussi" + assert sections.FOOTNOTES == "Notes" + + +def test_declared_aliases_and_the_pre_conventions_names_are_both_recognized(kb_root): + """The translation path. A page still carrying the old heading has to be + found and appended to, or a language change would silently split every page + into two Relationships sections.""" + _write(kb_root, FRENCH) + pattern = sections.heading_re(sections.RELATIONSHIPS) + for heading in ("## Relations", "## Beziehungen", "## Relationships"): + assert pattern.search(f"# Page\n\n{heading}\n\n- x\n"), heading + + +def test_the_canonical_name_is_not_duplicated_among_its_aliases(kb_root): + """An instance declaring the pre-conventions name gets it once, not twice - + otherwise `heading_re`'s alternation carries a redundant branch and + `names()` misreports what a page could be carrying.""" + _write( + kb_root, + "---\nsections:\n relationships: Relationships\n" + " see_also: See Also\n footnotes: Footnotes\n---\n", + ) + names = conventions.names(conventions.RELATIONSHIPS) + assert names[0] == "Relationships" + assert len(names) == len(set(names)) + + +def test_section_variables_are_what_a_type_spec_template_substitutes(kb_root): + _write(kb_root, GERMAN) + assert conventions.section_variables() == { + "section.relationships": "Beziehungen", + "section.see_also": "Siehe auch", + "section.footnotes": "Fußnoten", + } + + +def test_a_rewritten_file_is_not_answered_out_of_the_cache(kb_root): + _write(kb_root, GERMAN) + assert sections.FOOTNOTES == "Fußnoten" + _write(kb_root, FRENCH) + assert sections.FOOTNOTES == "Notes" + + +def test_an_incomplete_sections_block_is_reported(kb_root): + _write(kb_root, "---\nlanguage: de\nsections:\n relationships: Beziehungen\n---\n") + issues = conventions.declaration_issues() + assert any("sections.see_also" in issue for issue in issues) + assert any("sections.footnotes" in issue for issue in issues) + + +def test_an_unfilled_template_is_reported_like_a_missing_one(kb_root): + _write(kb_root, GERMAN.replace("language: de", f"# {config.TEMPLATE_SENTINEL}\nlanguage: de")) + assert any(config.TEMPLATE_SENTINEL in issue for issue in conventions.declaration_issues()) + + +def test_an_absent_file_is_not_a_declaration_issue(kb_root): + """`doctor` FAILs on absence; `docs verify` must not, or a fresh export + would be unverifiable before the setup step that writes the file.""" + assert conventions.declaration_issues() == [] + + +def test_a_collection_must_declare_its_profile_and_stack_dependence(kb_root): + _collection(kb_root, "sources", profile="sources", required=True) + (kb_root / "notes").mkdir() + (kb_root / "notes" / kb_collections.CONTRACT_NAME).write_text("# notes\n", encoding="utf-8") + issues = kb_collections.declaration_issues(kb_root) + assert any("kb/notes/COLLECTION.md has no frontmatter" in issue for issue in issues) + + +def test_required_by_stack_is_checked_against_the_stack_not_taken_on_trust(kb_root): + """The one field an instance may not choose. A collection claiming the stack + depends on it would make a rename look unsafe when it is not - and, worse, + `sources` claiming otherwise would make one look safe when it is not.""" + _collection(kb_root, "sources", required=False) + _collection(kb_root, "entities", required=True) + issues = kb_collections.declaration_issues(kb_root) + assert any("kb/sources/COLLECTION.md" in issue and "must be true" in issue for issue in issues) + assert any("kb/entities/COLLECTION.md" in issue and "must be false" in issue for issue in issues) + + +def test_a_missing_stack_required_collection_is_reported(kb_root): + _collection(kb_root, "entities") + assert any("kb/sources/ is missing" in issue for issue in kb_collections.declaration_issues(kb_root)) + + +def test_a_correct_declaration_reports_nothing(kb_root): + _collection(kb_root, "sources", profile="sources", required=True) + _collection(kb_root, "entities", profile="entities") + assert kb_collections.declaration_issues(kb_root) == [] diff --git a/tools/chemenu/tests/test_dist_cmd.py b/tools/chemenu/tests/test_dist_cmd.py index 3f53f2e..22a01b7 100644 --- a/tools/chemenu/tests/test_dist_cmd.py +++ b/tools/chemenu/tests/test_dist_cmd.py @@ -113,6 +113,14 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (kb / "entities" / "COLLECTION.md").write_text("# entities collection\n", encoding="utf-8") (kb / "entities" / "aurora.md").write_text("---\ntype: types/entity.md\n---\n", encoding="utf-8") (kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8") + (kb / "CONVENTIONS.md").write_text( + "---\nlanguage: de\nsections:\n relationships: Beziehungen\n" + " see_also: Siehe auch\n footnotes: Fußnoten\n---\n\n# this instance\n", + encoding="utf-8", + ) + (kb / "CONVENTIONS.md.template").write_text( + f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8" + ) for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"): path = root / relative @@ -262,6 +270,10 @@ def test_find_leaks_is_silent_on_a_clean_plan(repo): "instructions/dev/commonplace-kb.md", "kb/entities/aurora.md", "raw/notes/personal-note.md", + # Both bind every page and both are the instance's to write, so the + # filled name must never cross - only the `.template` beside it does. + "kb/CONVENTIONS.md", + "kb/entities/COLLECTION.md", ], ) def test_find_leaks_catches_one_instance_own_data(repo, relative): @@ -283,12 +295,28 @@ def test_export_refuses_a_plan_that_leaks(repo, tmp_path, monkeypatch): assert not target.exists() -def test_plan_copies_collection_contracts_not_pages(repo): +def test_plan_ships_collection_contracts_as_templates_not_pages(repo): + """The stack's own contract crosses verbatim; the instance-owned ones cross + under the template name and are adopted by a rename. Shipping + `kb/entities/COLLECTION.md` would hand a new instance this one's authoring + conventions as though the stack had decided them.""" plan = dist_cmd.build_plan() - assert "kb/entities/COLLECTION.md" in plan + assert "kb/entities/COLLECTION.md.template" in plan + assert "kb/entities/COLLECTION.md" not in plan assert "kb/CONTRACT.md" in plan assert not any(relative.endswith("aurora.md") for relative in plan) + # The shipped template is the contract's own text - one source of truth in + # the origin repo, renamed across the boundary. A second file kept beside + # each contract would be a near-identical copy, maintained by hand. + assert plan["kb/entities/COLLECTION.md.template"].content == "# entities collection\n" + + +def test_plan_ships_the_conventions_template_and_not_the_filled_file(repo): + plan = dist_cmd.build_plan() + assert "kb/CONVENTIONS.md.template" in plan + assert "kb/CONVENTIONS.md" not in plan + def test_plan_creates_empty_raw_subdirs_not_real_content(repo): plan = dist_cmd.build_plan() @@ -397,7 +425,7 @@ def test_export_into_a_fresh_directory_works(repo, tmp_path): target = tmp_path / "dist" dist_cmd.run_export(target, dry_run=False) assert (target / "AGENTS.md").is_file() - assert (target / "kb" / "entities" / "COLLECTION.md").is_file() + assert (target / "kb" / "entities" / "COLLECTION.md.template").is_file() assert (target / "raw" / "notes" / ".gitkeep").is_file() diff --git a/tools/chemenu/tests/test_doctor.py b/tools/chemenu/tests/test_doctor.py index 356fb44..f1cd049 100644 --- a/tools/chemenu/tests/test_doctor.py +++ b/tools/chemenu/tests/test_doctor.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from chemenu import config +from chemenu import config, conventions from chemenu.commands import doctor, instructions_cmd @@ -30,6 +30,12 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: (kb / "log.md").write_text("# Log\n", encoding="utf-8") (kb / "provenance.md").write_text("# Provenance\n", encoding="utf-8") (kb / "CONTRACT.md").write_text("# kb contract\n", encoding="utf-8") + (kb / "CONVENTIONS.md").write_text( + "---\nlanguage: en\nprofile: none\nsections:\n" + " relationships: Relationships\n see_also: See Also\n footnotes: Footnotes\n" + "---\n\n# conventions\n", + encoding="utf-8", + ) (root / "VERSION").write_text("0.1.0\n", encoding="utf-8") (root / "USER.md").write_text("# USER.md - Fixture\n", encoding="utf-8") (root / "SOUL.md").write_text("# SOUL.md - Fixture\n", encoding="utf-8") @@ -194,6 +200,32 @@ def test_a_renamed_but_unfilled_template_fails(instance): assert "template" in next(c.detail for c in checks if c.name == "personalization") +def test_conventions_are_ok_when_declared(instance): + checks = doctor.run_doctor() + assert _status(checks, "conventions") == "OK" + assert "Relationships" in next(c.detail for c in checks if c.name == "conventions") + + +def test_missing_conventions_fail(instance): + """Unlike `ENVIRONMENT.md`, this one is not optional: `xref add` and + `cite add` write headings out of it, so an instance without it is being + answered by whatever the stack hardcoded before the file existed.""" + (config.KB_DIR / conventions.CONVENTIONS_FILENAME).unlink() + checks = doctor.run_doctor() + assert _status(checks, "conventions") == "FAIL" + assert "missing" in next(c.detail for c in checks if c.name == "conventions") + + +def test_conventions_with_an_incomplete_sections_block_fail(instance): + """Present and deciding nothing - the same failure mode the personalization + sentinel check exists for, one directory down.""" + (config.KB_DIR / conventions.CONVENTIONS_FILENAME).write_text( + "---\nlanguage: en\nsections:\n relationships: Relationships\n---\n", encoding="utf-8" + ) + conventions.reset_cache() + assert _status(doctor.run_doctor(), "conventions") == "FAIL" + + def test_environment_is_ok_when_absent(instance): """The file is optional, so absence is a healthy end state - a FAIL here would make it mandatory through the back door.""" diff --git a/tools/chemenu/tests/test_new_page.py b/tools/chemenu/tests/test_new_page.py index 9f5946d..d392426 100644 --- a/tools/chemenu/tests/test_new_page.py +++ b/tools/chemenu/tests/test_new_page.py @@ -75,6 +75,31 @@ def test_new_entity_creates_page_with_expected_frontmatter(monkeypatch, kb_dir): assert "# gateway.example.net" in body +def test_scaffolded_body_carries_the_headings_this_instance_declared(monkeypatch, kb_dir): + """The type-spec writes `## {section.relationships}`, not a heading text, so + an instance in another language scaffolds its own headings without editing + anything under `types/`. This is that path end to end.""" + from chemenu import conventions + + (kb_dir / conventions.CONVENTIONS_FILENAME).write_text( + "---\nlanguage: fr\nprofile: none\nsections:\n relationships: Relations\n" + " see_also: Voir aussi\n footnotes: Notes\n---\n\n# conventions\n", + encoding="utf-8", + ) + conventions.reset_cache() + try: + result = _invoke_new(monkeypatch, kb_dir, [ + "new", "entity", "--name", "passerelle", "--set", "entity_type=system", + ]) + assert result.exit_code == 0, result.output + _fm, body = read_page(kb_dir / "entities/systems/passerelle.md") + assert "## Relations" in body + assert "## Voir aussi" in body + assert "{section." not in body + finally: + conventions.reset_cache() + + def test_new_entity_applies_schema_declared_defaults(monkeypatch, kb_dir): """provenance and confidence are no longer Typer flag defaults - they come from the schema's own `default:`, so omitting them still yields a diff --git a/tools/chemenu/tests/test_types_cmd.py b/tools/chemenu/tests/test_types_cmd.py index fb57a76..6e399ae 100644 --- a/tools/chemenu/tests/test_types_cmd.py +++ b/tools/chemenu/tests/test_types_cmd.py @@ -1,6 +1,6 @@ from typer.testing import CliRunner -from chemenu import sections +from chemenu import conventions from chemenu.cli import app runner = CliRunner() @@ -45,9 +45,10 @@ def test_types_describe_entity_reports_schema_and_body(): ] assert fields_by_name["tags"]["required"] is False # The body must carry the page skeleton an authoring LLM works from. Anchored on the - # tool-owned section vocabulary rather than a literal, so that translating the spec - or - # the section names themselves - does not turn this into a tripwire. - assert f"## {sections.RELATIONSHIPS}" in data["body"] + # template *variable* rather than on any heading text: the spec no longer names the + # tool-owned sections at all - `kb/CONVENTIONS.md` does, and `new` substitutes it - so a + # literal here would assert the very coupling that was removed. + assert f"## {{section.{conventions.RELATIONSHIPS}}}" in data["body"] def test_types_describe_unknown_name_fails_cleanly(): diff --git a/types/comparison.md b/types/comparison.md index d537ec5..4edb9d1 100644 --- a/types/comparison.md +++ b/types/comparison.md @@ -36,7 +36,7 @@ page_ref_fields: [entities] ## 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`, Abschnitte "Naming" und "Language") +- 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 @@ -68,8 +68,8 @@ TODO: Falls möglich - was wann und für wen zu verwenden ist. Unter welchen Ums `# Comparison:` bleibt als Präfix stehen - anders als bei `source` ist es kein `title_prefix`, sondern reine Template-Konvention, und der Seitentitel selbst (`Go vs Rust`) trägt es nicht. -Fügt `wikitool xref` eine Beziehung hinzu, entsteht `## Siehe auch`; der Name steht in -`tools/chemenu/sections.py`. +Fügt `wikitool xref` eine Beziehung hinzu, entsteht der toolgeführte Querverweis-Abschnitt; wie +er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`). --- diff --git a/types/concept.md b/types/concept.md index 615d4d6..bedd4e2 100644 --- a/types/concept.md +++ b/types/concept.md @@ -45,7 +45,7 @@ page_ref_fields: [related, sources] ## Autorenanweisungen -- Der Titel ist der kanonische Name des Concepts und folgt der etablierten Fachbezeichnung, nicht der KB-Sprache (`kb/CONTRACT.md`, Abschnitte "Naming" und "Language") +- 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 @@ -90,8 +90,9 @@ TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist ``` Der Wert hinter `**Typ:**` bleibt der englische Enum-Wert - danach filtert `search --field`. -Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich `## Beziehungen` und -`## Siehe auch`; deren Namen stehen in `tools/chemenu/sections.py`. +Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich die beiden toolgeführten +Abschnitte für Beziehungen und Querverweise; wie sie heißen, entscheidet die Instanz in +`kb/CONVENTIONS.md` (`sections:`). --- diff --git a/types/entity.md b/types/entity.md index 0c18ead..3e2be95 100644 --- a/types/entity.md +++ b/types/entity.md @@ -50,7 +50,7 @@ layout: ## Autorenanweisungen -- Der Titel ist der kanonische Name der Entity und folgt der etablierten Bezeichnung des Gegenstands, nicht der KB-Sprache (`kb/CONTRACT.md`, Abschnitte "Naming" und "Language") +- 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 - @@ -77,7 +77,7 @@ TODO: 1-2 Absätze dazu, was diese Entity ist und wozu sie dient. - **Verantwortlich:** TODO (falls zutreffend) - **Repository:** TODO (falls zutreffend) -## Beziehungen +## {section.relationships} - **Hängt ab von:** TODO - **Verwendet von:** TODO @@ -91,14 +91,16 @@ TODO: Ausführliche Informationen, nach sinnvollen Abschnitten gegliedert - [{today}] - Page created via wikitool -## Siehe auch +## {section.see_also} - TODO: Verwandte Seiten ``` -`## Beziehungen` und `## Siehe auch` sind toolgeführt: `wikitool xref` schreibt in genau diese -Abschnitte, benannt in `tools/chemenu/sections.py`. Der Wert hinter `**Typ:**` bleibt der -englische Enum-Wert - danach filtert `search --field`. +Die beiden `{section.…}`-Platzhalter sind toolgeführte Abschnitte: `wikitool xref` schreibt in +genau sie hinein, und wie sie heißen, entscheidet die Instanz in `kb/CONVENTIONS.md` +(`sections:`) - nicht dieser Type-Spec und nicht der Compiler. `wikitool new` setzt den +aktuellen Namen ein. Der Wert hinter `**Typ:**` bleibt der englische Enum-Wert - danach filtert +`search --field`. --- diff --git a/types/source.md b/types/source.md index 3c03d1a..6982fd8 100644 --- a/types/source.md +++ b/types/source.md @@ -48,7 +48,7 @@ page_ref_fields: [entities, concepts] - `raw_files` listet jede Raw-Datei, die diese Quelle abdeckt (eine Source-Seite pro logischer Quelle, nicht pro Datei) - 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/CONTRACT.md`, Abschnitt "Language") +- 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 @@ -108,8 +108,8 @@ TODO: 2-3 Absätze zu den Kernaussagen des Quellmaterials. `# Source:` bleibt als Präfix stehen - es spiegelt den `title_prefix` und damit den Titel, unter dem die Seite verlinkt und zitiert wird. Der Wert hinter `**Typ:**` bleibt der englische -Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende `## Fußnoten`; der Name -steht in `tools/chemenu/sections.py`. +Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende der toolgeführte +Fußnoten-Block; wie er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`). --- diff --git a/types/type-spec.md b/types/type-spec.md index e0172df..3e8ecb3 100644 --- a/types/type-spec.md +++ b/types/type-spec.md @@ -98,17 +98,24 @@ The `## Template` block is filled from the page's own frontmatter, plus `{name}` `{entities|table_cells}`. `{field|literal text}` falls back to the literal when the field is absent. +Three further variables come from the instance rather than from the page: +`{section.relationships}`, `{section.see_also}` and `{section.footnotes}`, filled from +`kb/CONVENTIONS.md`'s `sections:` declaration. A template writes a tool-owned heading through +one of those and never as literal text - that is what lets an instance change the KB language +without editing anything under `types/`. + ### Ownership boundary -| Owned here | Owned by `kb/CONTRACT.md` and the collection contracts | -|------------|-----------------------------------------------------------| -| Frontmatter fields, enums, defaults, required-ness | Quality goal and tone | -| Directory placement and title prefix | Naming conventions | -| Body skeleton (template) | Linking policy and relationship vocabulary | -| When to use / not use this type | Provenance and confidence practice | +| Owned here | Owned by `kb/CONTRACT.md` | Owned by `kb/CONVENTIONS.md` and the collection contracts | +|------------|--------------------------|-----------------------------------------------------------| +| Frontmatter fields, enums, defaults, required-ness | Provenance and citation mechanics | Quality goal and tone | +| Directory placement and title prefix | The confidence machinery | Naming conventions and the confidence rubric | +| Body skeleton (template) | Linking mechanics and the orphan check | Relationship vocabulary | +| When to use / not use this type | The prose/identifier rule | The KB language and its section-heading names | -If a rule would be identical for every type, it belongs in `kb/CONTRACT.md`, not in a -type-spec. If it is identical for every page in one collection, it belongs in that +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/CONVENTIONS.md`. If it is identical for every page in one collection, it belongs in that collection's `COLLECTION.md`. ### What does not belong here