Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29d400dd3 | |||
| b1883befc7 | |||
| 56ecfc7fee | |||
| 4e80a07ac7 | |||
| 0b8ca746fa | |||
| 9b461421e8 | |||
| 41f5dfe1cd | |||
| 23307c3c5f | |||
| cfe925a76c | |||
| 23e34a940c |
@@ -66,6 +66,26 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
version="$(cat VERSION | tr -d '[:space:]')"
|
||||
|
||||
# A running candidate (`X.Y.Z-beta.N`) is never released - betas are
|
||||
# a dev-checkout state, not a distributed one (see
|
||||
# instructions/dev/version-parts.md). This guard sits *before* the
|
||||
# API query below: without it, every `version bump` on a candidate
|
||||
# would push VERSION and trigger a wasted round-trip against the
|
||||
# releases API for a tag that was never going to be created. Ending
|
||||
# the job cleanly here (not `exit 1`) is what keeps a beta bump a
|
||||
# normal, unremarkable push rather than a failing CI run - skipping
|
||||
# every later step is what "cleanly" means in Actions: mark this one
|
||||
# skip and gate the rest on it.
|
||||
case "$version" in
|
||||
*-beta.*)
|
||||
echo "VERSION is a running candidate (${version}) - nothing to release. Skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
tag="v${version}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
@@ -82,6 +102,7 @@ jobs:
|
||||
- name: Release notes from CHANGES.md
|
||||
# `version notes` fails when the changelog has no entry for this
|
||||
# version, which is the last place that mistake can still be caught.
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
tools/wikitool docs verify
|
||||
@@ -90,6 +111,7 @@ jobs:
|
||||
|
||||
- name: Build the distribution tarball
|
||||
id: build
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
@@ -109,6 +131,7 @@ jobs:
|
||||
echo "name=${name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
TOKEN: ${{ gitea.token }}
|
||||
|
||||
@@ -71,6 +71,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
||||
|------|-----|--------|
|
||||
| `README.md` | Humans - technical documentation and how to develop the thing in that directory | Never by an agent as instruction |
|
||||
| `EVALS.md` | Humans - how telemetry and evaluation work; routes to the contracts that bind | Never by an agent as instruction |
|
||||
| `DEVELOPMENT.md` | Humans - the release workflow (`version bump`/`version release`/`publish`/CI), for whoever develops this stack rather than an instance built on it | Never by an agent as instruction. Not shipped: `dist_cmd.ROOT_FILES` excludes it deliberately, the same way `instructions/dev/` (which it may link to, unlike the documents `instructions verify` holds to that rule) is excluded - a distributed instance has no release workflow to document |
|
||||
| `AGENTS.md` | Agents | Always, every session |
|
||||
| `CLAUDE.md` | Agents on Claude Code | Automatically by that harness, which does not load `AGENTS.md` - so it imports this file and the two below, and carries no rules itself. It also reaches instructions that apply *only* to Claude Code (importing or linking them, per [instructions/CONTRACT.md](instructions/CONTRACT.md)), which is the one thing this file cannot do for them: from here they would load into every other harness too |
|
||||
| `USER.md` | Agents | Always, every session |
|
||||
@@ -82,6 +83,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
|
||||
| `instructions/<name>.md` | Agents | By link, or on explicit request |
|
||||
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
|
||||
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
|
||||
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
|
||||
| `INDEX.md` | Both | Generated - never hand-edited |
|
||||
|
||||
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different
|
||||
@@ -89,6 +91,16 @@ documents. What it may not carry is the same content twice - a README that resta
|
||||
contract is a second copy that drifts. `docs verify` enforces the specific case that already
|
||||
happened once: no README may hold a copy of the `wikitool` command table.
|
||||
|
||||
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
|
||||
background a session consults in passing, not a rule it must follow. Anything that would bind
|
||||
belongs in a `CONTRACT.md` instead, which is what keeps invariant 8 intact here: `docs/` is
|
||||
never a second place a rule could live, only prose about rules that live elsewhere. That is also
|
||||
why nothing verifies its content - there is no rule in it to check. It has no frontmatter, no type, no index, no lint, no decay, no provenance, and no
|
||||
`COLLECTION.md` - which [kb/CONTRACT.md § Collections](kb/CONTRACT.md#collections) forbids
|
||||
outside `kb/` anyway, but the point holds independently: `docs/` stays a plain directory of
|
||||
prose, invisible to everything `tools/wikitool` does except `dist export`, which copies it
|
||||
verbatim. A fresh instance needs the reasoning as much as this one does.
|
||||
|
||||
## Personalization
|
||||
|
||||
`USER.md` and `SOUL.md` are read at session start, if the runtime has not already injected
|
||||
@@ -145,7 +157,8 @@ input schema + compiler output derived (gitignored)
|
||||
work/ tracked scratch, deleted when the run closes
|
||||
```
|
||||
|
||||
Alongside it, not part of it: `instructions/` (what agents are told to do) and this file.
|
||||
Alongside it, not part of it: `instructions/` (what agents are told to do), `docs/` (why the
|
||||
stack is built the way it is - see [File naming](#file-naming)), and this file.
|
||||
|
||||
**By stage** - read the contract for the stage you are writing in:
|
||||
|
||||
@@ -263,3 +276,10 @@ and `tools/README.md` are part of the change that introduced a stage, a command
|
||||
not follow-up work: nobody comes back for them, and a document that describes a repo which no
|
||||
longer exists is worse than none. The mechanical half - command tables, contracts, ignore
|
||||
canaries - is checked by `tools/wikitool docs verify`; the prose half is yours.
|
||||
|
||||
`docs/` pages are held to a different clock than those three. A README goes stale on every new
|
||||
flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate
|
||||
that stops living in code, an ownership line that moves, a boundary redrawn - which is rarer
|
||||
and not tied to any one commit. Nothing checks this by construction: a page there carries no
|
||||
normative sentence (see [File naming](#file-naming)), so there is no rule for `docs verify` to
|
||||
check, only a rationale for a session to notice has gone stale and to update or retire.
|
||||
|
||||
+430
@@ -18,6 +18,436 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
|
||||
versioned entry disagree. Entries below `0.1.0` predate versioning and keep
|
||||
their date-only headings.
|
||||
|
||||
Since `4.4.0` the stack carries **one running candidate** between two
|
||||
releases rather than a fresh version per bump - see
|
||||
`instructions/dev/version-parts.md`. While a candidate is open its heading
|
||||
names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
|
||||
every bump of that same candidate updates this one entry in place rather than
|
||||
opening another: the heading's version/date/title move, and the bump's
|
||||
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
||||
never by hand. `wikitool version release` is what closes a candidate: it
|
||||
strips the suffix and turns the entry into an ordinary, suffix-free one,
|
||||
leaving the bump-title list as the record of what happened. A distributed
|
||||
instance never sees a `-beta.` version at all (`release.yml` only ever
|
||||
releases a fixed one), so this suffix and the list beneath it are a
|
||||
dev-checkout concern - readable here, never shipped as something to parse.
|
||||
|
||||
---
|
||||
|
||||
## 4.4.0 - 2026-09-03 - Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
<!-- wikitool:bumps -->
|
||||
- Versionskandidat statt Bump-pro-Release: VERSION traegt -beta.N, version release fixiert
|
||||
<!-- /wikitool:bumps -->
|
||||
|
||||
Bisher bekam jeder `version bump` sofort eine fixierte, dauerhafte Nummer, unabhängig davon, ob
|
||||
je ein Release dazu erschien - fünf Minor-Bumps ohne Release ergaben fünf Nummern, von denen vier
|
||||
nie ausgeliefert wurden. `VERSION` trägt jetzt zwischen zwei Releases **einen** laufenden
|
||||
Kandidaten (`X.Y.Z-beta.N`) statt einer neuen Nummer pro Bump: `--major/--minor/--patch`
|
||||
eskaliert diesen Kandidaten max-wins gegen den letzten Release, statt daneben eine neue Nummer zu
|
||||
schreiben, und schrittet dabei nie zurück.
|
||||
|
||||
`Version` versteht den Suffix, mit einer expliziten Ordnung
|
||||
(`4.4.0-beta.1 < 4.4.0-beta.2 < 4.4.0`, numerisch nach `N`, nicht lexikografisch). `CHANGES.md`
|
||||
trägt genau einen offenen Eintrag pro Kandidat: der erste Bump eröffnet ihn, jeder weitere
|
||||
aktualisiert Heading und die maschinenverwaltete Bump-Titel-Liste in
|
||||
`<!-- wikitool:bumps -->` (Marker-Konvention aus `blocks.py`, aber bewusst nicht in
|
||||
`blocks.BLOCKS` - diese Region gehört zu `CHANGES.md`, nicht zu einer Seite). `version release`
|
||||
ist neu und fixiert einen Kandidaten: Suffix weg, Eintrag geschlossen, committet und pusht nichts.
|
||||
|
||||
Vier Stellen am Bestand angepasst, die das Kandidatenmodell sonst still beschädigt hätten:
|
||||
`release.yml` überspringt einen suffixbehafteten `VERSION`-Push sauber, bevor die Releases-API
|
||||
gefragt wird, statt jeden Beta-Bump zu veröffentlichen; die Grenzübertritts-Checks in
|
||||
`docs verify` (`check_migration_for_boundary`, `check_breaking_change_for_boundary`) messen jetzt
|
||||
gegen den **letzten Release** (`version_mod.last_release`) statt gegen den zweitobersten Eintrag,
|
||||
der zwischen zwei Betas keine Grenze mehr hergibt; `kb_state.chain()`/`next_link()` vergleichen
|
||||
gegen die **Kandidatenbasis**, weil eine Migration mit Ziel `4.4.0` sonst bei installiertem
|
||||
`4.4.0-beta.1` aus dem Intervall fällt (`4.4.0-beta.1 < 4.4.0`); `read_kb_version()` verweigert
|
||||
einen Prerelease, weil eine Inhaltsform kein Beta kennt. `dist export` schreibt `VERSION` und den
|
||||
Stamp weiterhin ehrlich mit Suffix, aber `.wikitool-kb.json` bekommt die Basis.
|
||||
|
||||
Menschendoku für die Erzeuger-Seite: `DEVELOPMENT.md` im Repo-Root, bewusst nicht in
|
||||
`dist_cmd.ROOT_FILES` (Begründung als Kommentar dort), mit Zeile in `AGENTS.md` § File naming und
|
||||
Zeiger aus `README.md`. `docs/version-model.md` hat einen neuen Abschnitt, warum eine Nummer erst
|
||||
durch ein Release verbraucht wird.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.3 - 2026-09-03 - Modellwahl nach Pruefbarkeit statt nach Aufgabenname; stack-dev bricht an den Phasenwechseln fuer den Model-Switch
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
`instructions/claude-code-model-selection.md` routete bisher nach Skill: eine Zeile "Stack
|
||||
development -> Opus/high" fuer alles, was `tools/`, `types/` oder `instructions/` anfasst. Das ist
|
||||
zu grob in beide Richtungen - es verteuert die lange, mechanische Mitte einer Stack-Sitzung, und es
|
||||
sagt nichts darueber, dass Anfang und Ende derselben Sitzung anders zu behandeln sind.
|
||||
|
||||
**Die neue Achse ist "was faengt hier einen Fehler ab".** Wo ein Check in Code steht - `pytest`,
|
||||
`docs verify`, `instructions verify`, CI, die Gates - kostet der Fehler eines schwaecheren Modells
|
||||
eine Runde und faellt auf. Wo die einzige Durchsetzung eine Sitzung ist, die Prosa liest, faellt
|
||||
derselbe Fehler gar nicht auf: er wird ausgeliefert und bleibt stehen. Das ist dasselbe Argument,
|
||||
das `docs/why-gates-are-code.md` fuer Gates fuehrt, angewandt auf die Modellwahl.
|
||||
|
||||
Stack-Entwicklung ist damit **nicht mehr eine Zeile, sondern drei**:
|
||||
|
||||
| Phase | Was einen Fehler faengt | Modell |
|
||||
|---|---|---|
|
||||
| Design, Versionsstelle, Grenzuebertritts-Urteil | nichts | Opus/high |
|
||||
| Code, Tests, mechanische Doku-Synchronisation | pytest, CI, `docs verify` | Sonnet/high |
|
||||
| Issue-Abschluss, `docs/`-Veralterung, Changelog-Prosa | nichts, per Konstruktion | Opus/high |
|
||||
|
||||
Die Mitte ist die lange Phase und die mit den Checks - dort liegt die Ersparnis. Die beiden
|
||||
Raender sind kurz (Minuten, nicht Stunden), haben aber keinen maschinellen Waechter: `wikitool`
|
||||
kennt den Issue-Tracker bewusst nicht, und eine `docs/`-Seite traegt keinen normativen Satz, also
|
||||
gibt es dort nichts zu verifizieren. Sie oben zu lassen ist billig und schuetzt genau die Arbeit,
|
||||
die still scheitert.
|
||||
|
||||
Zwei Praezisierungen dazu: **Effort ist der billigere Hebel als das Modell** - `medium` steht fuer
|
||||
Stack-Arbeit bewusst in keiner Zeile, weil Mehrdatei-Konsistenz das ist, was ein reduzierter
|
||||
Effort zuerst aufgibt; `high` ist die Untergrenze, sobald mehr als eine Datei oder ein Contract
|
||||
betroffen ist. Und die Asymmetrie ist benannt: eine unnoetige Opus-Phase kostet einmal Geld, eine
|
||||
ungepruefte Sonnet-Phase kann etwas ausliefern, das nie wieder jemand ansieht.
|
||||
|
||||
**Damit die Tabelle ueberhaupt wirksam wird, braucht sie Haltepunkte.** Eine Sitzung kann ihr
|
||||
eigenes Modell nicht wechseln - das ist `/model` und gehoert dem Nutzer. Eine Empfehlung, die
|
||||
niemand zum richtigen Zeitpunkt ausspricht, aendert nichts. `instructions/dev/stack-dev/SKILL.md`
|
||||
bekommt deshalb zwei ausdrueckliche Breaks:
|
||||
|
||||
- **Neuer Schritt 3** - "Settle the design before building", mit dem Angebot zum Wechsel nach
|
||||
unten, sobald der Plan steht und die Arbeit mechanisch wird. Einmal aussprechen, dann so oder
|
||||
so weiterarbeiten.
|
||||
- **Schritt 6 (Abschluss) bricht in die Gegenrichtung** - ab dort greift wieder kein Check. Mit
|
||||
der ausdruecklichen Auflage, die Arbeit **unabhaengig von der Antwort** zu tun: nach dem Publish
|
||||
auf einen Modellwechsel zu blockieren wuerde genau den Zustand hinterlassen, den Schritt 6
|
||||
verhindern soll. Lief die Phase auf dem billigeren Modell, gehoert das in die Uebergabe statt
|
||||
ins Schweigen.
|
||||
|
||||
Ein auftauchender Grenzuebertritt ist unter den Decision points ebenfalls als Anlass zum Wechsel
|
||||
nach oben benannt: `docs verify` prueft, dass ein Uebertritt sich dokumentiert, nie dass die
|
||||
Stelle richtig gewaehlt war.
|
||||
|
||||
Die uebrigen Schritte sind unveraendert und nur umnummeriert (alt 3-5 -> neu 4-6).
|
||||
|
||||
---
|
||||
|
||||
## 4.3.2 - 2026-09-03 - stack-dev: Issue-Abschluss ist ein nummerierter Schritt, kein Zeiger in einer Routing-Liste
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Nachfassen zu 4.1.2 (#44), das die Regel geschaerft, aber den Weg zu ihr nicht geaendert hat.
|
||||
`instructions/dev/issue-tracking.md` bekam damals Schritt 7 ("Closing is the last body update,
|
||||
not a comment"); `instructions/dev/stack-dev/SKILL.md` bekam nur eine umformulierte Zeile in
|
||||
seiner Routing-Liste. Eine Stunde spaeter schloss #45 auf exakt dieselbe Weise: gruendlicher
|
||||
Abschlusskommentar ueber einem Body mit unangehakten Kriterien.
|
||||
|
||||
**Die Ursache lag nicht am Text der Regel, sondern an ihrer Erreichbarkeit.** Die nummerierten
|
||||
Schritte des Skills endeten bei "Verify before publishing". Ein Issue zu schliessen war ueberhaupt
|
||||
kein Schritt - es hing an einem Zeiger *innerhalb* von Schritt 2, und Schritt 2 ist eine
|
||||
Routing-Tabelle aus fuenf "read X before Y"-Eintraegen, keine Checkliste. Eine Sitzung folgt dem
|
||||
Spine, den sie im Kontext hat; was nur hinter einem Link steht, wird genau in dem Moment nicht
|
||||
aufgeschlagen, in dem es greift - am Ende einer langen Sitzung, wenn der Kontext am vollsten und
|
||||
die verbleibende Instruktionsflaeche am duennsten ist.
|
||||
|
||||
Verschaerfend arbeitete der Blurb gegen seine eigene Regel: fett gesetzt war "keep it current as
|
||||
the state moves, **not at the end**". Wer den Body unterwegs ungefaehr gepflegt hatte, las daraus
|
||||
Konformitaet - der eigentliche Abschlusstest stand nur in der verlinkten Datei.
|
||||
|
||||
Geaendert:
|
||||
|
||||
- **Neuer Schritt 5 in `stack-dev/SKILL.md`** - "Close the issue with a body rewrite, not a
|
||||
comment", mit dem Test inline (Kriterien abgehakt oder mit Begruendung gestrichen,
|
||||
Entscheidungen als entschieden formuliert, kein Praesens ueber einen behobenen Defekt,
|
||||
Verifikation benannt) und dem Verweis auf Schritt 7 fuer die volle Form. Damit steht der
|
||||
Abschluss auf dem Spine.
|
||||
- **Schritt-2-Blurb rebalanciert** - beide Haelften binden jetzt sichtbar: fortlaufende Pflege
|
||||
*und* der Rewrite vor dem Schliessen, mit Verweis auf Schritt 5.
|
||||
|
||||
Nichts davon ist maschinell pruefbar, und das bleibt richtig so: `wikitool` kennt den Tracker
|
||||
nicht und darf ihn nicht lernen, weil es an Instanzen ausliefert, die kein Board haben
|
||||
(`issue-tracking.md` § "What no tool checks"). Der Skill-Spine ist die einzige Durchsetzung, die
|
||||
es geben kann - was der Grund ist, den Schritt zu nummerieren statt ihn zu verlinken.
|
||||
|
||||
Verallgemeinerbar: eine Regel, die in eine verlinkte Instruction geschrieben wird, erreicht
|
||||
Sitzungen nur, wenn die nummerierten Schritte des zustaendigen Skills sie in dem Moment
|
||||
ansteuern, in dem sie greift.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.1 - 2026-09-03 - docs/ befuellt - Stack-Hintergrund fuer vier Themen, Pflegeklausel in AGENTS.md ergaenzt
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Gitea #45: die von #38 angelegte, bis dahin leere `docs/` bekommt ihre ersten vier Seiten - frisch
|
||||
geschrieben, nicht durch Umzug aus `kb/` befuellt, jede ohne normativen Satz und mit Verweis auf
|
||||
das bindende Dokument statt einer Wiederholung seiner Regeln:
|
||||
|
||||
- `docs/pipeline-rationale.md` - warum `raw -> types/tools -> kb -> reports` vier getrennte Stufen
|
||||
sind und was "never re-derive, always compile" praktisch bedeutet
|
||||
- `docs/why-gates-are-code.md` - warum Mass-Update-, Publish-Remote- und Iteration-Budget-Gate in
|
||||
`tools/wikitool` statt in einer Instruktion stehen
|
||||
- `docs/ownership-and-templates.md` - der Unterschied zwischen stack-eigenen, verbatim
|
||||
ausgelieferten Dateien und instanz-eigenen `.template`-Dateien
|
||||
- `docs/version-model.md` - warum Drop-in-Kompatibilitaet und Migrationsbedarf zwei unabhaengige
|
||||
Fragen sind, illustriert an der 2.0.0-Fallstudie
|
||||
|
||||
**AGENTS.md § Changelog:** neue Klausel zur Pflege von `docs/`, ergaenzt neben der bestehenden
|
||||
Regel zu `README.md`/`EVALS.md`/`tools/README.md`. Eine `docs/`-Seite veraltet nicht wie ein
|
||||
README bei jedem neuen Flag, sondern nur, wenn die aufgeschriebene Begruendung selbst nicht mehr
|
||||
traegt - per Konstruktion ungeprueft, da die Seite keinen normativen Satz enthaelt, den
|
||||
`docs verify` pruefen koennte.
|
||||
|
||||
---
|
||||
|
||||
## 4.3.0 - 2026-09-03 - docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Gitea #38: `dist export` lieferte bislang keine einzige `kb/`-Seite aus - eine frische Instanz
|
||||
bekam den Stack, aber keinen Grund für seine Form. Die dokumentierte `adr-NNN-`-Konvention in
|
||||
`kb/concepts/COLLECTION.md` existierte zudem nur auf Papier: keine der sieben
|
||||
`concept_type: decision`-Seiten folgte ihr, und `confidence_decay()` lief bedingungslos über sie
|
||||
- ein Kategorienfehler, weil Zeitablauf eine Entscheidung nicht falscher macht, nur Supersession
|
||||
tut das.
|
||||
|
||||
**Neu:** `docs/` - ein inertes Verzeichnis für Stack-Hintergrund (warum der Stack so gebaut ist,
|
||||
nicht was diese Instanz entschieden hat). Keine Frontmatter, kein Typ, kein Index, kein Lint,
|
||||
keine Decay, keine Provenance, keine `COLLECTION.md`. `dist export` liefert es verbatim aus, wie
|
||||
`instructions/` und `types/`. Befüllung folgt in Gitea #45.
|
||||
|
||||
**Verworfen, nach Prüfung:** ein Umzug der sieben Decision-Seiten nach `decisions/`. Der
|
||||
Subtyp-Floor aus #28 verlangt mindestens eine Seite je deklariertem `concept_type`, und ein
|
||||
Umzug hätte `decision` auf null gebracht; dazu zeigen 89 Wikilinks aus `kb/` sowie
|
||||
tool-eigene Frontmatter-Arrays auf die sieben, und `links.py`/`xref add` kennen kein Ziel
|
||||
außerhalb `kb/`. Die sieben bleiben in `kb/concepts/`, ebenso ein zweiter, separat erwogener
|
||||
Rename (`docs verify` → `parity verify`) - der wäre nur nötig gewesen, wenn ein Befehl auf das
|
||||
Verzeichnis `docs/` wirkt, und keiner tut das.
|
||||
|
||||
**Geändert:**
|
||||
- `confidence_decay()` überspringt `concept_type: decision` strukturell (kategorische Ausnahme,
|
||||
nicht als Brücke gebaut - Begründung im Docstring).
|
||||
- `kb/concepts/COLLECTION.md` § Decisions ersetzt die tote ADR-Vorlage durch die real gelebte
|
||||
Form: eine Entscheidung ist eine gewöhnliche Concept-Seite, organische Prosa, kein
|
||||
`adr-NNN-`-Präfix, `**Status:**` optional, Supersession per `supersedes`-Link.
|
||||
- `kb/CONVENTIONS.md` § Naming und `instructions/kb-profiles.md` (Profil `german`) korrigiert -
|
||||
beide dokumentierten noch die verworfene `adr-NNN-`-Namensregel.
|
||||
- `AGENTS.md` § File naming und § Routing: `docs/`-Zeile, plus die Regel, dass `docs/` keinen
|
||||
normativen Satz trägt (das hält Invariante 8 heil - was binden würde, gehört in einen
|
||||
Contract).
|
||||
- `tools/CONTRACT.md`: Klarstellung, dass `docs verify` Dokumentations-Parität prüft, nicht das
|
||||
`docs/`-Verzeichnis, sowie `docs/` in der `dist export`-Zeile ergänzt.
|
||||
|
||||
Additiv und in beide Richtungen drop-in: eine bestehende Instanz ohne `docs/` exportiert
|
||||
weiterhin identisch (leerer `_copy_tree`-Treffer), eine Instanz mit `docs/` bekommt es ab jetzt
|
||||
mitgeliefert. Kein Feld, kein Kommando ändert sein Verhalten für bestehenden Inhalt.
|
||||
|
||||
**Migration:** none required.
|
||||
|
||||
Berührt: `tools/chemenu/commands/confidence_decay.py`, `tools/chemenu/commands/dist_cmd.py`,
|
||||
`tools/chemenu/tests/test_confidence_decay.py`, `tools/chemenu/tests/test_dist_cmd.py`,
|
||||
`kb/concepts/COLLECTION.md`, `kb/CONVENTIONS.md`, `instructions/kb-profiles.md`, `AGENTS.md`,
|
||||
`tools/CONTRACT.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4.2.0 - 2026-09-03 - Korpus-Kuratierungsrichtlinie: Untergrenzen und Leitplanke für reaktive Fixes
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Ein Demo-Korpus will klein und stabil sein, ein Testbett groß, unordentlich und in Bewegung -
|
||||
dieses Repo verlangt seit der Veröffentlichung beides vom selben `kb/` (Gitea #28). Die Sitzung
|
||||
vom 2026-09-02 hatte Fixture, `--with-demo` und ein zweites Repo bereits verworfen; offen blieb
|
||||
nur, wie kuratiert "kuratiert genug" heißt und welche Leitplanke reaktive Fixes bekommen.
|
||||
|
||||
**Neu:** `instructions/dev/corpus-policy.md`. Fünf Untergrenzen, jede mit einer bestehenden
|
||||
`wikitool`-Prüfung messbar, keine davon durch neuen Tool-Code: jeder Seitentyp und jeder
|
||||
deklarierte Subtyp mit mindestens einer Seite, mindestens fünf Seiten mit mindestens drei
|
||||
Quellen, ein bis zehn Orphan-Seiten, im Schnitt mindestens vier ausgehende Wikilinks pro Seite.
|
||||
Gemessen am 2026-09-03: 181 Seiten, alle Typ-/Subtyp-Floors erfüllt, 12 Seiten mit ≥3 Quellen, 3
|
||||
Orphans, Ø 6,2 ausgehende Links - der Korpus war bereits groß genug, ohne dass eine einzige
|
||||
Seite eigens dafür angelegt werden musste. Eine Untergrenze wird nie durch eine erfundene Seite
|
||||
gefüllt, sondern durch eine echte Quelle beim nächsten passenden Ingest - Invariante 3 gilt
|
||||
unverändert.
|
||||
|
||||
**Die Leitplanke für reaktive Fixes** unterscheidet drei Stufen: punktuelle Änderungen (immer
|
||||
erlaubt, gewöhnliche Arbeit), korpusweite Änderungen (nur geplant, mit eigenem Issue und
|
||||
`work/`-Run - trifft eine Session das Mass-Update-Gate während sie etwas anderes tat, holt sie
|
||||
sich nicht den `--confirm`-Token, sondern stoppt und legt ein Issue an) und reaktive Eingriffe
|
||||
in Korpusinhalt, um einen Test grün zu machen oder einen Tool-Bug zu umgehen (nie erlaubt,
|
||||
Invariante 7). Das Verhältnis zu `kb_dir`/`raw_dir` und `test_pipeline_l0.py` bleibt wie im
|
||||
ursprünglichen Befund: kleiner, isolierter Fall in der Fixture, großer, vernetzter Fall in
|
||||
`kb/` - keine Fixture-Extraktion aus dem Korpus.
|
||||
|
||||
Dev-only und rein additiv - kein Feld, kein Kommando, keine Datei außerhalb von
|
||||
`instructions/dev/` ändert sich, daher `--minor` ohne `--breaking`.
|
||||
|
||||
**Migration:** none required.
|
||||
|
||||
Berührt: `instructions/dev/corpus-policy.md` (neu),
|
||||
`instructions/dev/stack-dev/SKILL.md` (Schritt 2, Routing-Zeile).
|
||||
|
||||
---
|
||||
|
||||
## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Aufgefallen beim Schließen von #44: der Abschlussbericht stand als Kommentar da, der Body
|
||||
darunter weiterhin als offene Arbeit — Abschnitt „Zu entscheiden" über eine längst getroffene
|
||||
Entscheidung, ungehakte Checkliste, Präsens über einen Defekt, den es nicht mehr gab.
|
||||
|
||||
Die Regel gab es dafür schon: Schritt 2 von `instructions/dev/issue-tracking.md` sagt, der Body
|
||||
ist die aktuelle Wahrheit und wird umgeschrieben, wenn sich der Stand ändert. Nur ließ die
|
||||
Formulierung offen, *wann* — und Schritt 7 („Close with what actually happened") war vollständig
|
||||
erfüllbar, ohne den Body anzufassen. Ein Abschlussbericht im Kommentar fühlt sich beim Schreiben
|
||||
vollständig an; dass der Body dabei zurückbleibt, merkt erst der nächste Leser.
|
||||
|
||||
**Schritt 2 ist deshalb schärfer geworden: der Body ist das Plan-File dieses Stacks.** Dasselbe,
|
||||
was das Plan-Dokument eines Harness ist, und genauso gepflegt — fortlaufend, sobald etwas darin
|
||||
nicht mehr stimmt, nicht am Ende. Der Maßstab ist der Abbruch, nicht der Meilenstein: eine
|
||||
Session kann jederzeit enden, und was der Body in diesem Moment sagt, ist die vollständige
|
||||
Übergabe. Eine frische Session muss zu **jedem** Zeitpunkt allein aus dem Body weiterarbeiten
|
||||
können, ohne Kommentare rückwärts zu lesen und ohne einen Menschen, der es neu erklärt. Entschieden
|
||||
ersetzt die Frage, erledigt hakt das Kriterium ab, verworfen steht mit Begründung dort, wo das
|
||||
Kriterium stand.
|
||||
|
||||
Schritt 7 ist damit kein Sonderakt mehr, sondern die letzte dieser Aktualisierungen: erst Body
|
||||
auf den Endstand, dann schließen, dann die Changelog-Zeile aus Schritt 3. Wer Schritt 2 befolgt
|
||||
hat, ist fast fertig; wer nicht, zahlt die ganze Schuld im schlechtesten Moment — der
|
||||
geschlossene Body ist die Fassung, die danach alle lesen und niemand mehr aufsucht. #44 steht
|
||||
als Beispiel drin.
|
||||
|
||||
Schritt 3 zieht die Konsequenz: **ein Kommentar pro Session-Umfang, nicht pro Edit.** Ein
|
||||
fortlaufend gepflegter Body mit einem Changelog-Kommentar je Änderung wäre Lärm; triviale Pflege
|
||||
braucht gar keinen. Der `stack-dev`-Skill sagt es beim Aufgreifen mit, weil dort die Entscheidung
|
||||
fällt, ob eine Session den Body überhaupt anfasst.
|
||||
|
||||
**Und die ehrliche Antwort auf die Frage nach dem Tooling: es gibt keins, und es soll keins
|
||||
geben.** `wikitool` kennt diesen Tracker nicht. Es wird an Instanzen ausgeliefert, die unter
|
||||
dieser URL keine Issues haben, während `instructions/dev/` von `dist export` gepruned wird —
|
||||
ein Gitea-Client im ausgelieferten Tool wäre eine Dev-Abhängigkeit, die jede Instanz mitträgt,
|
||||
um ein Board zu prüfen, das keine von ihnen hat. Der Tracker ist ausschließlich über
|
||||
`gitea-mcp` erreichbar, also in einer Session, durch einen Agenten.
|
||||
|
||||
Kein `docs verify` fängt also einen geschlossenen Issue, dessen Body offen klingt, einen Body,
|
||||
der seinen eigenen Kommentaren widerspricht, oder ein fehlendes Pflichtlabel. Das steht jetzt
|
||||
als eigener Abschnitt „What no tool checks" in der Instruktion — nicht als Bedauern, sondern als
|
||||
Begründung dafür, warum die Reihenfolge in Schritt 7 ausgeschrieben ist statt aus Schritt 2
|
||||
erschlossen zu werden.
|
||||
|
||||
---
|
||||
|
||||
## 4.1.1 - 2026-09-03 - Testisolation: kb_dir repointet config.ROOT, lint löst Kollektionen gegen den übergebenen Baum auf
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Issue #44, gefunden beim Bau der Migrations-Gate-Tests für 4.1.0: die `kb_dir`-Fixture baute
|
||||
ihren Baum unter `tmp_path`, ließ `config.ROOT` aber auf dem echten Checkout stehen. Jeder
|
||||
Codepfad, der eine Datei über `config.ROOT`/`config.KB_DIR` auflöst statt über das übergebene
|
||||
Verzeichnis, traf damit das echte Repository.
|
||||
|
||||
**Der laute Fall** war ein Test, der `kb_state.write_kb_state()` rief und dabei das
|
||||
`.wikitool-kb.json` des Repos überschrieb — Applied-Ledger leer statt zwei Einträgen. In
|
||||
`git status` sofort sichtbar und reversibel; bei einer gitignorierten Datei wäre es das nicht
|
||||
gewesen.
|
||||
|
||||
**Der stillere Fall** ist der teurere. `lint`s Kollektions-Lookup löste eine Seite gegen
|
||||
`config.KB_DIR` auf. Für eine Seite unter `tmp_path/kb/` warf das `ValueError`, die Funktion
|
||||
antwortete „keine Kollektion", und die Label-Autorisierung übersprang die Kante wortlos.
|
||||
`unauthorised_labels` war damit faktisch ungetestet — jeder Test, der das Finding hätte
|
||||
auslösen können, bekam eine leere Liste und behauptete nichts. Ein grüner Lauf, der wie eine
|
||||
Zusicherung aussah.
|
||||
|
||||
**Der Fix ist der Codepfad, nicht die Fixture.** `run_lint()` bekommt ein Verzeichnis
|
||||
übergeben und löst jetzt auch intern dagegen auf; `authorised_labels()` bekommt denselben Baum
|
||||
gereicht, statt auf `config.KB_DIR` zurückzufallen. Der Regressionstest lintet einen Baum, von
|
||||
dem `ROOT` bewusst wegzeigt — genau der Fall, den die alte Auflösung verschluckte. Eine Funktion,
|
||||
die ein Verzeichnis entgegennimmt, löst dagegen auf: keine Fixture kann diese Form von außen
|
||||
reparieren.
|
||||
|
||||
**Beide Korpus-Fixturen repointen jetzt.** `kb_dir` tut, was `raw_dir` längst tat — `ROOT` auf
|
||||
das eigene `tmp_path`, plus `use_shipped_type_specs()`. Der Suite-Lauf kippte dadurch keinen
|
||||
einzigen Test. Die lokale `rooted_kb`-Umgehung aus 4.1.0 entfällt damit; die Auswahl zwischen
|
||||
zwei fast gleichen Fixturen war Wissen, das nirgends stand.
|
||||
|
||||
**Und ein Wächter für die ganze Klasse.** `repository_tree_guard` (session-scoped, autouse)
|
||||
vergleicht `git status --porcelain` vor und nach dem Lauf und lässt die Suite scheitern, wenn
|
||||
sich im Checkout etwas bewegt hat — zwei `git status`-Aufrufe pro Lauf, deshalb per Default an.
|
||||
Er vergleicht vorher gegen nachher statt einen sauberen Baum zu verlangen, sagt also nichts über
|
||||
die unveröffentlichte Arbeit des Entwicklers. Den Verursacher benennt er nicht;
|
||||
`CHEMENU_TREE_GUARD=each` prüft nach jedem Test und tut es. Ohne git oder außerhalb eines
|
||||
Repositorys sind beide still.
|
||||
|
||||
Was der Wächter nicht sieht: eine Prüfung, die unter Test nichts tut, schreibt keine Datei.
|
||||
Dagegen hilft nur ein Test, der das Finding tatsächlich auslöst — der neue tut das.
|
||||
|
||||
`instructions/dev/testing-conventions.md` hat dafür einen eigenen Abschnitt („Which tree a test
|
||||
writes into"), einen Schritt in der Checkliste und die Regel für neue Fixturen.
|
||||
|
||||
---
|
||||
|
||||
## 4.1.0 - 2026-09-03 - Link-Taxonomie: Lint-Findings hart ab kb_version 4.0.0, outbound: an das Type-Spec gebunden, part-of/composition als Inversenpaar
|
||||
|
||||
**Author:** Torben Nehmer
|
||||
|
||||
Der Rest von Issue #40, nachdem die Korpus-Migration durch ist: die beiden aufgeschobenen
|
||||
Lint-Findings werden hart, und die drei Befunde aus dem Abschlusskommentar des Migrationslaufs
|
||||
werden aufgelöst.
|
||||
|
||||
**`unlabelled_edges` und `unauthorised_labels` sind harte Fehler — aber an `kb_version`
|
||||
gebunden, nicht an ein Datum.** Der Weg, den `legacy_citation_markers` genommen hat, war ein
|
||||
Umlegen in einer späteren Version: eine Instanz, die die Zitat-Migration noch schuldete, lebte
|
||||
danach mit rotem Lint. Das Ledger kann die Frage inzwischen beantworten, also tut es das.
|
||||
Unterhalb `kb_version` 4.0.0 bleiben beide beratend — genau das Fenster, in dem
|
||||
`instructions/migrations/4.0.0-link-taxonomy.md` der Instanz sagt, sie solle den halb
|
||||
konvertierten Korpus Einheit für Einheit publizieren; ein Check, der dabei fehlschlägt, würde
|
||||
den Korpus verweigern, dessen Fortschritt er misst. Ab 4.0.0 ist eine kahle Titelangabe in
|
||||
`related:` keine Seite mehr, die auf ihre Umstellung wartet, sondern eine Kante, deren Autor
|
||||
nicht gesagt hat, was sie behauptet. `hard_error_keys()` liefert die jeweils geltende Menge,
|
||||
`HARD_ERROR_KEYS` bleibt die vollständige.
|
||||
|
||||
**`outbound:` ist an das Type-Spec gebunden.** `kb/sources/` und `kb/comparisons/`
|
||||
autorisierten Label, die dort strukturell nicht schreibbar waren: keiner der beiden Type-Specs
|
||||
führte ein `related:`. Folgenlos war das nicht — die einzige Comparison-Seite des Korpus trug
|
||||
`- **compares-with:** [[amd-pstate]]` als *handgeschriebene Prosa*, ohne Marker-Region, ohne
|
||||
Frontmatter, für `lint` unsichtbar. Also ein Identifier zurück im Fließtext, gut vier Stunden
|
||||
nachdem 4.0.0 genau das beendet hatte. Eine leere Autorisierung liest sich als Lizenz.
|
||||
|
||||
Aufgelöst nach dem, was die beiden Contracts jeweils selbst sagen: `comparison` bekommt ein
|
||||
`related:` (die `compares-with`-Kante gegen jedes Subjekt ist die eine Aussage, für die die
|
||||
Seite existiert), `kb/sources/` verliert seinen `outbound:`-Block ersatzlos (dessen Contract
|
||||
sagt ausdrücklich, seine Verknüpfungen seien der mechanische Provenance-Pfad und keine
|
||||
Autorenkanten). Neu prüft `docs verify` die Kombination: ein `outbound:`-Block auf einer
|
||||
Collection, in die kein Typ mit `related:` schreibt, ist ein Befund und nennt beide Richtungen
|
||||
der Reparatur.
|
||||
|
||||
**`composition` / `part-of` ist das dritte Inversenpaar**, neben `depends-on` / `required-by`
|
||||
und `runs-on` / `hosts`. Aus der Messung, nicht vom Schreibtisch: der u3-Lauf hatte entschieden,
|
||||
die Gegenseite eines `composition` bekomme `see-also`, weil `part-of` ein Spiegel wäre. Ist es
|
||||
nicht — der Satz des Elternteils zählt seine Teile auf, der des Kindes benennt das Ganze, zu
|
||||
dem es gehört, und ein Leser, der auf dem Kind landet, braucht den zweiten. Übrig blieben 16
|
||||
`see-also`-Kanten für eine Beziehung, für die der Katalog ein Wort hat; sie sind auf `part-of`
|
||||
umgestellt. Ein Inversenpaar macht die Gegenkante weiterhin **nicht** zur Pflicht — Richtung
|
||||
wird verfasst, nicht gespiegelt —, es legt nur fest, welches Label sie trägt, wenn jemand sie
|
||||
schreibt.
|
||||
|
||||
**Stack- und Korpusänderung laufen hier in einem Zug**, entgegen der sonstigen Trennung. Der
|
||||
neue `docs verify`-Check würde eine bestehende 4.0.x-Instanz beim bloßen Kopieren der neuen
|
||||
Maschinerie fehlschlagen lassen, weil deren `kb/sources/COLLECTION.md` den `outbound:`-Block
|
||||
noch trägt — nach [instructions/dev/version-parts.md](instructions/dev/version-parts.md)
|
||||
Schritt 1 ein Grenzübertritt. Statt dafür eine `5.0.0` zu lösen, ist die Ursache mitbeseitigt:
|
||||
die Collection-Contracts dieser Instanz sind angepasst, und `dist export` leitet die
|
||||
`COLLECTION.md.template` daraus ab, also liefert jede neue Distribution die korrigierte Form
|
||||
aus. Für eine bereits bestehende 4.0.x-Instanz bleibt eine Handbewegung übrig, und sie wird
|
||||
hier benannt statt versteckt: die zwei `outbound:`-Zeilen aus `kb/sources/COLLECTION.md`
|
||||
löschen. Das neue `related:` im `comparison`-Type-Spec erreicht sie ohnehin nicht — die vier
|
||||
Page-Type-Specs gehören seit 4.0.0 der Instanz und werden nur als `.template` ausgeliefert.
|
||||
|
||||
Offen aus #40 bleibt nichts mehr; Befund 2 des Migrationslaufs (dem Katalog fehlt ein Register
|
||||
für Urheberschaft) ist als eigenes Issue erfasst.
|
||||
|
||||
---
|
||||
|
||||
## 4.0.1 - 2026-09-02 - Issue-Board: vier Pflicht-Label-Familien und Body-als-Wahrheit
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Entwicklung dieses Stacks
|
||||
|
||||
Dieses Dokument richtet sich an Menschen, die an `tools/wikitool`, dem Type-Schema oder der
|
||||
Instruction-/Skill-Schicht selbst arbeiten - nicht an den Konsumenten einer Instanz. Für die
|
||||
Gegenseite (eine Instanz installieren, aktualisieren, betreiben) siehe [INSTALL.md](INSTALL.md).
|
||||
|
||||
**Diese Datei wird nicht ausgeliefert.** Sie ist das menschliche Gegenstück zu
|
||||
`instructions/dev/`, das `tools/wikitool dist export` vollständig ausschließt: eine
|
||||
ausgelieferte Instanz hat keinen Release-Workflow, keine CI und kein Issue-Board, also braucht
|
||||
sie auch keine Anleitung dafür. `dist_cmd.ROOT_FILES` listet sie deshalb bewusst nicht - der
|
||||
Grund steht dort als Kommentar, damit eine spätere Sitzung die vermeintliche Lücke nicht
|
||||
"repariert". Und weil sie nicht ausgeliefert wird, darf sie - anders als `README.md`,
|
||||
`INSTALL.md` oder `EVALS.md`, die `instructions verify` auf genau diesen Punkt prüft - nach
|
||||
`instructions/dev/` verlinken.
|
||||
|
||||
## Der Release-Ablauf
|
||||
|
||||
Zwischen zwei Releases führt der Stack **einen** laufenden Versionskandidaten statt einer neuen
|
||||
Nummer pro Bump. Das volle Modell - Zustandsort, Eskalationslogik, warum eine Nummer erst durch
|
||||
ein Release verbraucht wird - steht in
|
||||
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) und
|
||||
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||
eine Sitzung ihn tatsächlich durchläuft:
|
||||
|
||||
1. **Bump eröffnet oder eskaliert den Kandidaten.**
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
||||
```
|
||||
|
||||
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
|
||||
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
|
||||
denselben Eintrag, statt einen neuen zu eröffnen.
|
||||
|
||||
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
|
||||
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
||||
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
||||
|
||||
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||
|
||||
```bash
|
||||
cd tools && .venv/bin/python -m pytest -q
|
||||
tools/wikitool docs verify
|
||||
tools/wikitool instructions verify
|
||||
```
|
||||
|
||||
4. **`version release` fixiert den Kandidaten**, sobald er ausgeliefert werden soll:
|
||||
|
||||
```bash
|
||||
tools/wikitool version release --title "Zusammenfassender Titel"
|
||||
```
|
||||
|
||||
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
|
||||
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
|
||||
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
|
||||
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||
|
||||
5. **Publish bewegt `VERSION` auf `main`.**
|
||||
|
||||
```bash
|
||||
tools/wikitool publish --message "..."
|
||||
```
|
||||
|
||||
Das Mass-Update-Gate und das Publish-Remote-Gate gelten wie bei jedem anderen Publish -
|
||||
siehe [instructions/gates.md](instructions/gates.md).
|
||||
|
||||
6. **CI übernimmt den Rest.** `.gitea/workflows/release.yml` reagiert auf jeden Push, der
|
||||
`VERSION` bewegt: Eine suffixbehaftete `VERSION` (ein Kandidat) lässt den Job sauber
|
||||
überspringen, bevor er die Releases-API überhaupt anfragt - Betas werden nie veröffentlicht.
|
||||
Eine suffixfreie `VERSION` baut die Distribution (`dist export`), erzeugt Tag und Release und
|
||||
lädt Tarball plus Prüfsumme hoch. **CI setzt den Tag, nie eine Sitzung** - das hält
|
||||
Invariante 5 intakt.
|
||||
|
||||
## Verify-Befehle im Überblick
|
||||
|
||||
| Befehl | Prüft |
|
||||
|---|---|
|
||||
| `cd tools && .venv/bin/python -m pytest -q` | Die gesamte Testsuite, hermetisch gegen eine leere Maschine (siehe `instructions/dev/testing-conventions.md`) |
|
||||
| `tools/wikitool docs verify` | CLI-Kommandotabelle, Contract-Präsenz, Type-Drift, `.gitignore`-Kanarienvögel, `VERSION`/`CHANGES.md`-Übereinstimmung, Grenzübertritts-Dokumentation |
|
||||
| `tools/wikitool instructions verify` | Jede Instruction und jeder Skill unter `instructions/`, verwaiste Dateien, `instructions/dev/`-Referenzen von außerhalb |
|
||||
|
||||
Die volle Kommandoreferenz inklusive Fehlerkontrakt: [tools/CONTRACT.md](tools/CONTRACT.md).
|
||||
|
||||
## Die CI-Hälfte
|
||||
|
||||
`.gitea/workflows/ci.yml` läuft auf jeden Push/PR gegen `main` (Content-Pfade ausgenommen) und
|
||||
führt Testsuite, `docs verify`, `instructions verify` sowie einen vollständigen
|
||||
`setup-instance.md`-Replay gegen einen frischen `dist export` aus - derselbe Pfad, den ein neuer
|
||||
Nutzer tatsächlich geht. `.gitea/workflows/nightly.yml` ist der Drift-Check gegen die Zeit statt
|
||||
gegen einen Commit. `.gitea/workflows/release.yml` ist Schritt 6 oben.
|
||||
|
||||
## Stack-Entwicklung als eigener Sitzungstyp
|
||||
|
||||
Der `stack-dev`-Skill (`instructions/dev/`, nur in diesem Ursprungs-Repo vorhanden) fasst die
|
||||
Regeln für eine Sitzung, die den Stack selbst statt Wiki-Inhalt bearbeitet: wann
|
||||
Quellenbindung nicht gilt, wo Design endet und die mechanische Phase beginnt (mit dem
|
||||
Modellwechsel-Hinweis), und dass Issue-Abschluss ein Body-Rewrite ist, kein Kommentar. Siehe
|
||||
[instructions/dev/issue-tracking.md](instructions/dev/issue-tracking.md) für den
|
||||
Issue-Tracker selbst.
|
||||
@@ -115,6 +115,7 @@ chemenu/
|
||||
Dev-instance-only (see `tools/CONTRACT.md` for how it got here):
|
||||
|
||||
```
|
||||
├── DEVELOPMENT.md # Human-readable: the release workflow (version bump/release/publish/CI)
|
||||
└── commonplace/ # Vendored, read-only knowledge base
|
||||
```
|
||||
<!-- dist:strip-end -->
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Ownership and Templates
|
||||
|
||||
Chemenu ships two kinds of files side by side, and at a glance they look the same: both are
|
||||
plain markdown, both sit in the repo root or under `kb/`, both get read at session start. But a
|
||||
stack upgrade treats them completely differently. Some - [AGENTS.md](../AGENTS.md),
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md), the per-stage contracts - are identical in every instance
|
||||
that runs this stack and can simply be overwritten by the next release. Others - `USER.md`,
|
||||
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
|
||||
overwriting them would silently erase a choice someone made on purpose.
|
||||
|
||||
## Two different kinds of truth
|
||||
|
||||
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
|
||||
what `tools/wikitool` enforces or what follows mechanically from how it operates - see
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md), lines 10-13. That kind of statement doesn't vary by
|
||||
instance: the compiler behaves the same way regardless of who is running it, so the sentence
|
||||
describing that behavior can be copied byte-for-byte into every checkout without becoming
|
||||
wrong anywhere.
|
||||
|
||||
The instance-owned files describe a choice: which language pages are written in, what tone the
|
||||
agent takes, who the operator is, which git remote is authoritative, which MCP servers are
|
||||
reachable. None of that follows from the tool's mechanics - two instances of the identical
|
||||
stack can answer all of these differently and both be correct. [AGENTS.md § Personalization](../AGENTS.md#personalization)
|
||||
frames the split the same way for `kb/CONTRACT.md` versus `kb/CONVENTIONS.md`: "the split is by
|
||||
who may change the sentence, not by what it is about." A rule about page structure could in
|
||||
principle have been written per-instance too, but then every instance answering "not German" to
|
||||
setup would be hand-editing a file the stack also ships, and the next `dist export` merge would
|
||||
hand the instance's own file back to it, discarding the customization.
|
||||
|
||||
## Why silent overwrite is the failure being designed against
|
||||
|
||||
A stack update is meant to be a routine, low-risk operation: pull the latest release, get
|
||||
whatever fixes and features shipped since the last one. That only stays low-risk if the update
|
||||
knows which files it's allowed to touch. If `USER.md` or `kb/CONVENTIONS.md` were treated the
|
||||
same as `AGENTS.md` - shipped and periodically re-copied - an upgrade would quietly replace a
|
||||
description of *this* operator, in *this* language, with whatever placeholder or default the
|
||||
stack maintainers wrote. The damage wouldn't be loud: nothing crashes, the files still parse,
|
||||
the agent just starts acting on the wrong premises until someone notices the voice or the
|
||||
language changed.
|
||||
|
||||
Keeping the boundary at the file level, rather than trying to merge changes within a shared
|
||||
file, means an upgrade never has to guess which lines are "stack" and which are "instance" -
|
||||
the file itself already answers that.
|
||||
|
||||
## Why a `.template`, not just an absent file
|
||||
|
||||
The mechanism for instance-owned content is a `.template` file the distribution ships instead
|
||||
of the real one - `USER.md.template`, `SOUL.md.template`, `kb/CONVENTIONS.md.template`,
|
||||
`ENVIRONMENT.md.template`. An alternative would have been to ship nothing at all and let a
|
||||
brand-new instance start from a blank page. The template exists because a blank page doesn't
|
||||
tell [instructions/setup-instance.md](../instructions/setup-instance.md) what shape the answer
|
||||
should take, and it gives nothing for a validator to check afterward.
|
||||
|
||||
A template carries a placeholder value - a sentinel - in the fields that need a real answer.
|
||||
Setup interviews the operator and replaces the sentinel with what they actually said. That
|
||||
gives `doctor` a mechanical way to tell "personalized" from "not yet": a file that still
|
||||
contains the sentinel hasn't been through setup, regardless of whether the file exists. That's
|
||||
also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent - see
|
||||
[AGENTS.md § Environment](../AGENTS.md#environment) - while a missing or unfilled
|
||||
`USER.md`/`SOUL.md`/`kb/CONVENTIONS.md` is a harder failure: `ENVIRONMENT.md` describes one
|
||||
checkout among possibly several and is gitignored for that reason, so its absence is a normal
|
||||
state rather than a sign setup was skipped.
|
||||
|
||||
## The consequence in practice
|
||||
|
||||
Running a stack upgrade against an existing instance boils down to: overwrite the verbatim
|
||||
files, leave the `.template`-sourced files alone. The verbatim files are safe to replace
|
||||
wholesale because they were never instance-specific to begin with - identical content going
|
||||
back in changes nothing an instance actually decided. The template-sourced files were filled in
|
||||
once, by a person, for a reason, and nothing about a newer release of the stack's mechanics
|
||||
gives it standing to override that.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Why the pipeline has four stages
|
||||
|
||||
Chemenu could, in principle, be one directory: drop a file in, ask a question, get an answer
|
||||
computed fresh each time. It isn't built that way. The pipeline in
|
||||
[AGENTS.md](../AGENTS.md#routing) - `raw/` -> `[types/ + tools/]` -> `kb/` -> `reports/`, with
|
||||
`work/` alongside rather than inside it - separates *material* from *meaning* from
|
||||
*byproduct*, and each seam exists because collapsing it costs something specific.
|
||||
|
||||
## Why raw material stays untouched
|
||||
|
||||
[raw/CONTRACT.md](../raw/CONTRACT.md) keeps a source exactly as it arrived. The reasoning is
|
||||
simple once stated: the moment someone "cleans up" or reformats a source on the way in, the
|
||||
thing later claims get checked against is no longer the thing that was actually said. An
|
||||
immutable `raw/` means a citation always resolves to the original, not to somebody's tidied
|
||||
memory of it. It also draws a trust boundary in one place instead of scattering it - everything
|
||||
past `raw/` can be treated as reviewed, because nothing upstream of it silently already was.
|
||||
|
||||
## Why extraction happens once, through a schema
|
||||
|
||||
[types/type-spec.md](../types/type-spec.md) is what stands between a raw file and a `kb/` page:
|
||||
a type-spec defines what a conforming instance of a page looks like, and the compiler
|
||||
(`tools/wikitool`) applies it. The alternative - every query re-reading and re-interpreting the
|
||||
source on demand - would mean paying the cost of understanding the material every single time,
|
||||
and getting a slightly different answer each time depending on how the question was phrased.
|
||||
Extracting once, against a fixed schema, turns "re-read and re-guess" into "look up what was
|
||||
already compiled." That is the "never re-derive, always compile" principle from
|
||||
[AGENTS.md](../AGENTS.md): understanding a source is expensive and worth doing exactly once,
|
||||
after which it becomes a cheap, stable lookup.
|
||||
|
||||
## Why a `kb/` page has to stand on its own
|
||||
|
||||
[kb/CONTRACT.md](../kb/CONTRACT.md) sets the bar for the compiled layer: a page should answer a
|
||||
future question without sending the reader back to the source it came from. That's the payoff
|
||||
of compiling in the first place - if every answer still bottomed out in "go re-read the raw
|
||||
file," the `kb/` layer would just be a pointer with extra steps, and the cost of extraction
|
||||
would have bought nothing. A page that stands alone is what makes the corpus fast and
|
||||
consistent to query: the work of understanding is already sitting there, done.
|
||||
|
||||
## Why `reports/` doesn't need to be maintained
|
||||
|
||||
[reports/CONTRACT.md](../reports/CONTRACT.md) treats most of what lands in `reports/` -
|
||||
lint output, telemetry traces - as disposable. The structural content of a lint report can be
|
||||
recomputed from the tree at any commit, so keeping an old copy around would just be a second
|
||||
version of something the tool can already answer on demand, and a second copy is exactly the
|
||||
kind of thing that quietly goes stale. Treating it as derived output rather than a fourth thing
|
||||
to maintain means there is nothing there to fall out of sync - regenerating it is cheaper than
|
||||
reconciling it. The one part that genuinely can't be recomputed - the judgment a pass produced -
|
||||
is carried out into `kb/` or `kb/log.md` before the report itself is discarded, which is the
|
||||
distinction between what's recomputable and what isn't.
|
||||
|
||||
## Where `work/` fits
|
||||
|
||||
[work/CONTRACT.md](../work/CONTRACT.md) describes a workshop, not a fifth pipeline stage: a
|
||||
place for the notes, extracts and open decisions of a task that spans more than one session, on
|
||||
its way toward becoming a `kb/` page. It sits beside the raw -> kb -> reports flow rather than
|
||||
inside it - closer in spirit to a desk than to a conveyor belt.
|
||||
|
||||
## The shape this produces
|
||||
|
||||
Four stages, each answering a different question: `raw/` - what was actually said; `types/` +
|
||||
`tools/` - how to turn that into structured understanding; `kb/` - what is now known;
|
||||
`reports/` - what a pass over the corpus noticed in passing. Keeping them separate is what lets
|
||||
each one be trusted for what it is, instead of every layer having to double as all four at
|
||||
once.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Why the stack version splits compatibility from migration
|
||||
|
||||
A stack version number looks like it answers one question. It actually answers two, and the two
|
||||
are independent of each other.
|
||||
|
||||
## Two questions, not one
|
||||
|
||||
The first question is whether the new version is a drop-in replacement for the old one - whether
|
||||
an existing instance can install it, and can also go back, without anyone doing hand-work. That
|
||||
is what a version number *is*: a promise. The second question is whether the existing corpus in
|
||||
`kb/` needs to change shape to keep working under the new version. These sound like the same
|
||||
question, because most of the time a change that breaks compatibility also happens to touch
|
||||
content, and most of the time a change that leaves content untouched also happens to be
|
||||
compatible. The correlation is real; it just is not a law. `instructions/dev/version-parts.md`
|
||||
carries the actual test for telling them apart and the steps that follow from it - this page is
|
||||
about why the split exists at all.
|
||||
|
||||
## Why "kb/ untouched" is not proof of anything
|
||||
|
||||
The tempting shortcut is: if no page in `kb/` had to change, the bump can't be that serious. This
|
||||
is exactly backwards for a class of changes that live entirely outside the corpus - a renamed
|
||||
release artefact, a Python import path, an environment variable, the URL an instance's own
|
||||
updater points at. None of those touch a single page. All of them can strand an existing
|
||||
instance just as thoroughly as a rewritten type-spec would. The corpus is the part of the stack
|
||||
that looks at itself; the compatibility question is about everything an instance depends on to
|
||||
keep functioning, most of which the corpus never sees.
|
||||
|
||||
## Reading compatibility off the leftmost non-zero component
|
||||
|
||||
Semantic versioning gives every component a job, but only one of them is where an existing
|
||||
instance's tooling actually looks to decide "is this safe." On a `2.x` stack that is MAJOR; on a
|
||||
still-pre-1.0 `0.x` stack, by the same convention, it's MINOR - the leftmost slot that isn't
|
||||
pinned to zero is the one an automated updater treats as the compatibility boundary. Bump
|
||||
anything to its left, or bump that slot itself, and the promise changes. Everything to the right
|
||||
of it can move as freely as the project likes without touching that promise. This is why the
|
||||
question "is it boundary-crossing" always resolves to one specific digit, not to a feeling about
|
||||
how big the change is.
|
||||
|
||||
## Downgrade is half the promise
|
||||
|
||||
It's natural to test compatibility by only asking "does the upgrade work." The other half -
|
||||
"can an instance that upgraded put the old version back and land where it started" - carries
|
||||
equal weight, and it's the half that's easy to forget because forward motion is what everyone is
|
||||
testing for anyway. A state file the old version can no longer parse, a generated index in a new
|
||||
shape, a stamp file that got renamed: none of these have to break the upgrade to break the
|
||||
downgrade. An instance that can go forward but not back has already lost the property a
|
||||
compatible version number is supposed to guarantee.
|
||||
|
||||
## A promise made to a machine, not only to a person
|
||||
|
||||
A human reading a changelog can absorb "this technically isn't compatible but it's fine, just
|
||||
update those two things by hand." An instance's own update mechanism cannot. It reads a version
|
||||
number, decides whether to pull the new release, and has no channel for nuance - which is exactly
|
||||
why the update path itself is one of the sharpest ways to cross the boundary invisibly: if the
|
||||
new version moves where updates come from, the very channel that would have told an instance to
|
||||
adjust is the channel that just broke. The version number isn't documentation aimed at a reader;
|
||||
it's an input consumed by code that has no other way to ask.
|
||||
|
||||
## The 2.0.0 story
|
||||
|
||||
This isn't hypothetical for this stack. The rebranding that produced Chemenu renamed the repo,
|
||||
the release artefact, and the Python package - and left every page in `kb/` untouched. The first
|
||||
instinct was a MINOR bump, on the reasoning that nothing in the corpus needed migrating. That
|
||||
reasoning was correct on its own terms and answered the wrong question. Three things broke
|
||||
underneath it: every existing instance's `update_url` pointed at a repo path that no longer
|
||||
existed and, because it's a machine-written file, couldn't be hand-repaired; the release artefact
|
||||
name changed, breaking every download script and pin against it; and the import name changed,
|
||||
breaking anything importing the package from outside the shipped tree. The corpus had nothing to
|
||||
say about any of this, because none of it lived in the corpus.
|
||||
|
||||
What caught the mistake was a person looking at the diff and asking whether it really was a
|
||||
drop-in replacement, not a validator. No check in `docs verify` or anywhere else confirms that a
|
||||
version part was chosen correctly - it only confirms that a boundary-crossing bump documents
|
||||
what it breaks. The 2.0.0 entry in `CHANGES.md` carries the corrected reasoning in full, and the
|
||||
version bump that shipped it was `--major --no-migration`: boundary-crossing and untouched
|
||||
corpus, at the same time, which is precisely the combination the two-question split exists to
|
||||
make visible.
|
||||
|
||||
## Why a number is only spent by a release
|
||||
|
||||
Everything above is about what a version number *promises*. A separate question turned out to
|
||||
matter just as much in practice: how many numbers get handed out along the way to making one
|
||||
release. For a while the answer was "one per bump," and that turned out to be the wrong grain
|
||||
entirely.
|
||||
|
||||
The two things that actually consume a version number are a release and CI's version gate - and
|
||||
they disagree about granularity. The gate wants `VERSION` to move on every push that touches
|
||||
stack-shaped paths, which is a *commit*-level question: has this tree changed since the last
|
||||
push. A release wants to know something else: has *this specific number* been published, ever.
|
||||
Handing out a fresh number per bump answers the gate's question by accident and the release's
|
||||
question wrongly - it treats every bump as if it were about to ship, when most of them are steps
|
||||
toward a release that hasn't happened yet. Four bumps in one session, on the same day, for the
|
||||
same eventual release, produced four numbers that a version-check feed would have reported as
|
||||
four different available upgrades, three of which were never real.
|
||||
|
||||
The fix is not to slow the gate down - it still wants `VERSION` to move every time, and it still
|
||||
gets that. It's to stop treating every movement as a new number. Between two releases the stack
|
||||
now carries one running candidate, escalating through `-beta.N` as bumps accumulate, and only
|
||||
`version release` spends the number for real by fixing it and closing its changelog entry. A
|
||||
number is proposed by a bump and consumed by a release; conflating the two was the actual defect,
|
||||
not the arithmetic of any single bump.
|
||||
|
||||
This is also why a candidate never gets to a distributed instance. The promise a released version
|
||||
makes - "install this, and it is exactly what its number says" - has no equivalent for something
|
||||
still being decided during a single dev checkout's session. `release.yml`'s only job with respect
|
||||
to this is refusing to act on a suffixed `VERSION` at all: not because a beta is unsafe, but
|
||||
because there is nothing yet to promise.
|
||||
|
||||
## Where the procedure lives
|
||||
|
||||
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
|
||||
steps for a boundary-crossing bump - the `--breaking` line, the migration document or
|
||||
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
|
||||
place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).
|
||||
@@ -0,0 +1,57 @@
|
||||
# Why gates are code
|
||||
|
||||
Chemenu has three hard limits - the Mass-Update Gate, the Publish-Remote Gate, and the
|
||||
Iteration Budget Gate - and all three live inside `tools/wikitool`, not in a paragraph of
|
||||
instructions an agent reads and follows. The rules themselves, and what to do when one trips,
|
||||
are in [AGENTS.md § Gates](../AGENTS.md#gates) and [instructions/gates.md](../instructions/gates.md).
|
||||
This page is only about the design choice underneath them: why code, and why these three
|
||||
mechanisms in particular.
|
||||
|
||||
## A suggestion an agent can talk itself past
|
||||
|
||||
An instruction like "don't publish too much at once" or "don't loop forever" lives in the same
|
||||
place as every other piece of guidance a session is holding - alongside the task, the user's
|
||||
last message, and whatever context made the moment feel urgent. Under pressure, or with a
|
||||
plausible-sounding reason ("this batch is different, it's mechanical"), that guidance can be
|
||||
reasoned around without anyone deciding to break a rule. Nothing enforces it; it just competes
|
||||
for attention with everything else in the context window, and sometimes loses.
|
||||
|
||||
A check compiled into the tool doesn't have that problem, because it isn't part of the
|
||||
conversation at all. It runs before the command dispatches, regardless of how convincing the
|
||||
case for skipping it seemed a moment earlier. The difference isn't that code is smarter than a
|
||||
well-written instruction - it's that code doesn't get talked into anything.
|
||||
|
||||
## Why three different mechanisms, not one
|
||||
|
||||
The three gates ask three different questions, and each one's shape follows from what kind of
|
||||
question it is.
|
||||
|
||||
The Mass-Update Gate asks *is this change too large to publish unreviewed* - a judgment that
|
||||
varies changeset by changeset, so it clears with a `--confirm` token tied to the specific
|
||||
output the user just read. Approval is scoped to that one publish.
|
||||
|
||||
The Publish-Remote Gate asks something underneath that: *is this even the right repository*.
|
||||
That's not a per-push judgment, it's a standing property of the checkout - true or false for
|
||||
every publish that checkout will ever attempt, not just this one. A confirm token would let an
|
||||
agent clear it once and then treat the answer as settled, which is exactly backwards for a
|
||||
question whose answer shouldn't move at all mid-session. The only way past it is the user
|
||||
editing `.wikitool-remotes.json` directly, outside the gate's own flow.
|
||||
|
||||
The Iteration Budget Gate asks a third kind of question - not "is this instance correct" but
|
||||
"has this session stopped making progress." That's read from the shape of the call history
|
||||
itself (call count, repeated identical calls), not from anything about the content of any one
|
||||
call.
|
||||
|
||||
## Numbers that come from measurement, not intuition
|
||||
|
||||
The iteration ceiling didn't start where it sits now. It used to run 15-25, borrowed from a
|
||||
general rule of thumb, until four real ingest runs measured 24, 26, 29 and 30 calls apiece -
|
||||
every one of them an ordinary workflow doing nothing wrong, and every one of them at or past
|
||||
where the old ceiling would have refused it. A limit that the normal case keeps tripping stops
|
||||
functioning as a limit; it becomes background noise a session learns to route `--override-budget`
|
||||
around as a matter of course, and the whole point of a hard-coded check is that it isn't supposed
|
||||
to feel routine.
|
||||
|
||||
That's the deeper reason these numbers live in a tool rather than in prose: prose is read once
|
||||
and remembered loosely, but a threshold enforced every call is tested by every call, and a
|
||||
threshold that fails its own test gets noticed and re-measured rather than quietly ignored.
|
||||
@@ -6,10 +6,24 @@ description: Which Claude model and effort level to run a Claude Code session, a
|
||||
|
||||
# Pick the Claude model and effort level for the task at hand
|
||||
|
||||
Scale the model and effort to how much judgment the task actually needs. Running everything at
|
||||
the most capable model and highest effort is safe but wasteful: the gates in [gates.md](gates.md)
|
||||
are enforced in code, not by model judgment, so a weaker model cannot bypass them - it can only
|
||||
do a worse job of the calls the gates don't cover.
|
||||
Scale the model and effort to **what catches a mistake in this part of the work** - not to how
|
||||
important the task feels, and not to its name. Running everything at the most capable model and
|
||||
highest effort is safe but wasteful: the gates in [gates.md](gates.md) are enforced in code, not
|
||||
by model judgment, so a weaker model cannot bypass them - it can only do a worse job of the calls
|
||||
the gates don't cover.
|
||||
|
||||
That last clause is the whole rule, turned into a test. Where a check lives in code - `pytest`,
|
||||
`docs verify`, `instructions verify`, CI, the gates - a weaker model's mistake surfaces and costs
|
||||
one more round. Where the only enforcement is a session reading prose, the same mistake does not
|
||||
surface at all: it ships, and it stays until someone happens to notice. The two are not the same
|
||||
risk, and they should not get the same model. This is the argument
|
||||
[docs/why-gates-are-code.md](../docs/why-gates-are-code.md) makes about gates, applied to who is
|
||||
holding the keyboard.
|
||||
|
||||
Both directions cost something, which is why the axis matters rather than a blanket answer:
|
||||
over-provisioning is a standing cost paid every session, while under-provisioning in an unchecked
|
||||
phase is a silent error with a long tail. A corrective session, its bump, its CI runs and its
|
||||
release together cost more compute than the model difference they were saving.
|
||||
|
||||
Claude-Code-only, and imported by CLAUDE.md rather than linked from AGENTS.md: the model names,
|
||||
the `/code-review` effort dial and the `Agent` tool's `model:` override have no equivalent in the
|
||||
@@ -28,17 +42,41 @@ to *make*, not a setting to apply.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Recommend the session's model and effort by the skill in use**, when asked or when the
|
||||
mismatch is worth one sentence. Say it once and continue working either way - a session that
|
||||
argues about its own model instead of doing the task has already cost more than the model
|
||||
difference:
|
||||
1. **Recommend the session's model and effort by what catches a mistake in the phase it is in**,
|
||||
when asked or when the mismatch is worth one sentence. Say it once and continue working either
|
||||
way - a session that argues about its own model instead of doing the task has already cost
|
||||
more than the model difference:
|
||||
|
||||
| Skill / task | Model | Effort |
|
||||
|---|---|---|
|
||||
| `wiki-status`, simple `wiki-query` lookups | Sonnet | default |
|
||||
| `wiki-lint` | Sonnet | default |
|
||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | Sonnet | high |
|
||||
| Stack development: `tools/`, `types/`, `instructions/` as code | Opus | high |
|
||||
| Phase / task | What catches a mistake here | Model | Effort |
|
||||
|---|---|---|---|
|
||||
| `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
|
||||
| `wiki-lint` | `lint` itself is the check | Sonnet | default |
|
||||
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly - the judgment about a claim is not covered | Sonnet | high |
|
||||
| Stack dev: design, the version part, a boundary-crossing judgment | nothing - `docs verify` checks that a crossing documents itself, never that the part was right | Opus | high |
|
||||
| Stack dev: code, tests, mechanical doc sync (command tables, contract rows) | `pytest`, `docs verify`, `instructions verify`, CI | Sonnet | high |
|
||||
| Stack dev: closing an issue, `docs/` staleness, changelog prose | nothing, by construction - see below | Opus | high |
|
||||
|
||||
**Stack development is not one row**, which is the point of splitting it. The middle phase is
|
||||
where the tokens are and where the checks are, so it is the phase worth running cheaper. The
|
||||
two around it have no mechanical guard at all - a `docs/` page carries no normative sentence,
|
||||
so there is nothing for `docs verify` to check ([AGENTS.md](../AGENTS.md) § File naming), and
|
||||
the same holds for whatever tracker an instance keeps its open work in, which `wikitool`
|
||||
deliberately knows nothing about. Those two phases are short - minutes, not hours - so keeping
|
||||
them on the stronger model is cheap, and it protects the only work in the session that fails
|
||||
silently.
|
||||
|
||||
**Effort is the cheaper lever than the model.** Reach for it first: `medium` deliberately does
|
||||
not appear in this table for stack work, because multi-file consistency is exactly what a
|
||||
reduced effort level gives up. Sonnet at `high` is the floor for anything touching more than
|
||||
one file or a contract; `default` is for a single-file mechanical edit with a test behind it.
|
||||
|
||||
**A session cannot switch its own model**, so these rows only become real if someone offers the
|
||||
switch at the moment the phase changes - once, without arguing about it, and never as a reason
|
||||
to stop work that is already underway.
|
||||
<!-- dist:strip-start -->
|
||||
In this repo those moments are named: the `stack-dev` skill breaks for them at its steps 3
|
||||
(design settled, work turns mechanical) and 6 (publish done, the unchecked tail begins).
|
||||
<!-- dist:strip-end -->
|
||||
|
||||
2. **Pick a spawned subagent's model by what it does**, via the `Agent` tool's `model:`
|
||||
parameter - the values are `haiku`, `sonnet`, `opus`, `fable`:
|
||||
@@ -66,8 +104,14 @@ to *make*, not a setting to apply.
|
||||
mechanical one - `wikitool` carries the mechanical part regardless of which model is
|
||||
supervising it.
|
||||
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at
|
||||
the highest effort. Under-provisioning costs one worse answer in one session; reflexively
|
||||
over-provisioning is a standing cost paid every session.
|
||||
the highest effort. Under-provisioning *where a check exists* costs one worse answer in one
|
||||
session; reflexively over-provisioning is a standing cost paid every session.
|
||||
- **Unsure whether the phase is checked?** Treat it as unchecked. The asymmetry is not symmetric:
|
||||
a needless Opus phase costs money once, an unchecked Sonnet phase can ship something nobody
|
||||
looks at again.
|
||||
- **Mid-session and the phase changed, but nobody switched?** Do the work anyway - never block a
|
||||
publish or an issue close on a model the session cannot change itself. Say which phase ran on
|
||||
which model in the handover, so the gap is visible rather than silent.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: types/instruction.md
|
||||
name: corpus-policy
|
||||
description: What "curated enough" means for kb/ when it is demo and testbed at once, the measurable floors that define it, and what a reactive fix to the corpus may and may not do.
|
||||
---
|
||||
# Keep kb/ curated enough to develop against, without a second corpus
|
||||
|
||||
This instance runs one `kb/` for two purposes at once: a public demo and the testbed this stack
|
||||
is developed against. There is deliberately no fixture corpus, no `--with-demo` export, and no
|
||||
second repository - see Gitea #28. The corpus's size and shape are set by what targeted
|
||||
development needs, not by a synthetic fixture size or a demo aesthetic.
|
||||
|
||||
## When to run
|
||||
|
||||
- Before judging whether the corpus can exercise a change under development - ranking, index
|
||||
scaling, orphan detection, a new label, a new type-spec.
|
||||
- Before a reactive fix touches `kb/` content rather than the failing code - the floors below
|
||||
are what decides whether the fix may proceed as-is.
|
||||
- Picking up Gitea #28 or #30, or any issue that references this file.
|
||||
|
||||
## The floors
|
||||
|
||||
Each is mechanically checkable with an existing `wikitool` command; none needs new tool code.
|
||||
A floor exists to keep some class of bug observable, not to describe an aesthetic target - so
|
||||
when a session is about to make one of these numbers *worse*, that is the signal to stop and
|
||||
think, not a number to defend for its own sake.
|
||||
|
||||
| Floor | Check | Why this number |
|
||||
|---|---|---|
|
||||
| Every page type has ≥1 page | `wikitool search --field type=types/<t>.md` | A type with zero pages means its schema, its collection contract and its lint rules are unexercised |
|
||||
| Every declared subtype has ≥1 page | `wikitool search --field <x>_type=<v>` | Same reasoning, one level down - `entity_type`, `concept_type`, `source_type` |
|
||||
| ≥5 pages corpus-wide with ≥3 `sources:` entries | one-off script, see below | Provenance fan-in - multiple sources backing one claim - is a real case only a handful of pages exercise; fewer than 5 and a provenance-index bug can hide |
|
||||
| Orphan pages (no inbound link) between 1 and 10 | `wikitool lint` | Zero orphans makes orphan detection itself unobservable; more than 10 means the corpus stopped being curated |
|
||||
| Average outbound wikilinks per page ≥4 | one-off script, see below | Below this, ranking and graph-traversal work has too little structure to exercise |
|
||||
|
||||
A floor is a lower bound only. There is no upper bound on page count or on any of these numbers
|
||||
except the orphan ceiling above - a corpus that outgrows these floors through real ingests is
|
||||
not a problem this file cares about.
|
||||
|
||||
**Measured 2026-09-03** (see Gitea #28): 181 pages, 14/14 types and subtypes covered, 12 pages
|
||||
with ≥3 sources, 3 orphans, 6.2 average outbound links. All floors held without any manufactured
|
||||
content - the corpus was already big enough when the question was asked.
|
||||
|
||||
A type or subtype sitting at exactly the floor - one page - shows no set-level bugs, only that
|
||||
the type is *reachable*. That is a soft target for the next `wiki-ingest` that happens to
|
||||
produce a matching page, never a reason to write one: filing an unsourced page to clear a floor
|
||||
is exactly what AGENTS.md invariant 3 forbids, floor or no floor. The same holds for an
|
||||
authorised link label with zero live uses (`wikitool xref` reports these) - fill it when a real
|
||||
edge calls for it, never manufacture one to exercise the label.
|
||||
|
||||
To check the two floors without a dedicated command, walk `kb/**/*.md` (excluding
|
||||
`INDEX.md`/`COLLECTION.md`/`CONTRACT.md`/`CONVENTIONS.md`), parse frontmatter, and: count pages
|
||||
whose `related:` array (resolved against page titles) has ≥3 entries for outbound density; count
|
||||
`sources:` array length ≥3 for the provenance floor. `wikitool search` and `wikitool lint`
|
||||
cover everything else in the table.
|
||||
|
||||
## What a reactive fix may do to kb/ content
|
||||
|
||||
Three tiers, by how much of the corpus a change touches:
|
||||
|
||||
1. **Pointwise - always allowed.** Creating, updating, renaming or deleting a single page
|
||||
through the normal tools (`new`, `touch`, the page-lifecycle procedure), below the
|
||||
Mass-Update Gate's threshold. This is ordinary work and needs no special permission.
|
||||
2. **Corpus-wide - planned only, never reactive.** A migration, a vocabulary sweep, a bulk
|
||||
`touch` across many pages. This needs its own issue and, per `work/CONTRACT.md`, a `work/`
|
||||
run - never a same-session reaction to whatever the session was originally doing. If a
|
||||
session hits the Mass-Update Gate (exit 42, see `instructions/gates.md`) while working on
|
||||
something else, it does not fetch the `--confirm` token to push through: it stops, opens an
|
||||
issue for the corpus-wide change, and finishes the original task without it.
|
||||
3. **Reactive - never allowed.** Deleting or reshaping a page to make a failing test pass;
|
||||
restructuring corpus content to route around a tool bug (AGENTS.md invariant 7); using
|
||||
`kb/` as a scratch surface for a tool experiment. If a stack change under development needs a
|
||||
corpus shape that does not exist, build it as a pytest fixture (see the next section) -
|
||||
never manufacture it in `kb/`.
|
||||
|
||||
## Relationship to the test fixtures
|
||||
|
||||
`tools/chemenu/tests/conftest.py`'s `kb_dir`/`raw_dir` fixtures and `test_pipeline_l0.py` cover
|
||||
the **small, isolated** case: a handful of pages, built fresh per test, hermetic. `kb/` covers
|
||||
the **large, connected** case: 181+ pages, grown link density, real provenance history that no
|
||||
per-test fixture reconstructs economically. The cut: if a `tmp_path` tree can reproduce what the
|
||||
test needs, it belongs in a fixture; if the test needs density or scale that only a grown corpus
|
||||
has, it belongs against `kb/`. Neither absorbs the other's job - see
|
||||
[testing-conventions.md](testing-conventions.md).
|
||||
|
||||
## Decision points
|
||||
|
||||
- **A floor would be violated by an in-progress change - is that a blocker?** Only for the
|
||||
orphan ceiling and the type/subtype floors, since those two can go to zero. The density and
|
||||
provenance floors move gradually with ordinary ingests and are not gating on any single
|
||||
session.
|
||||
- **Corpus is "too small" for a feature under development?** That is not this file's problem to
|
||||
solve by adding pages - see tier 3 above. Either the feature waits for a real ingest to supply
|
||||
the shape, or it gets a pytest fixture.
|
||||
@@ -28,8 +28,12 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
an assumption nobody has checked, a decision that needs the user.
|
||||
- Picking an issue up: before doing anything else, read the body as the current
|
||||
spec, and re-label it if the ground has moved since.
|
||||
- **While working on one:** the body is updated as the state moves, not at the
|
||||
end (step 2). A session that is interrupted leaves the body as its handover.
|
||||
- Prioritising: deciding what to pick up next, or re-labelling after the ground
|
||||
moved.
|
||||
- Closing one: the body is rewritten to its final state first, and only then
|
||||
closed (step 7).
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -38,23 +42,47 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
specific files or commands involved. An issue that only makes sense to
|
||||
whoever wrote it is a note, and notes were the problem.
|
||||
|
||||
2. **Treat the body as the current truth, not as a historical first post.**
|
||||
Work on one issue spans several sessions, often weeks apart, and the body is
|
||||
the only thing that connects them: a session opening the issue must be able
|
||||
to reconstruct what is decided and what is still open from the body alone,
|
||||
without a human re-explaining it. So when the state changes, **rewrite the
|
||||
body** - do not append to a text that has become wrong. An additively grown
|
||||
log forces every later reader to reconstruct the current state by filtering
|
||||
the whole history.
|
||||
2. **The body is the working state, not a historical first post - keep it
|
||||
current as you go.** It is this stack's plan file: the same thing a harness's
|
||||
own plan document is, and it is maintained the same way. Not written once,
|
||||
not brought up to date at the end, but **updated whenever something in it
|
||||
stops being true** - a decision made, a criterion met, an approach ruled out,
|
||||
a new constraint found.
|
||||
|
||||
The test is an abort, not a milestone. A session can end at any moment - an
|
||||
interrupt, a context limit, a crash, a human walking away - and whatever the
|
||||
body says at that instant is the entire handover. So the standard is: **at
|
||||
every point, a fresh session must be able to open the body and pick the work
|
||||
up from there**, without a human re-explaining it and without reading back
|
||||
through the comments. If the body would mislead someone who read it right
|
||||
now, it is already out of date, whether or not the work is finished.
|
||||
|
||||
That means updating *during* the work, not only at its end:
|
||||
|
||||
- a decision gets made → the decision and its reasoning replace the question
|
||||
- an acceptance criterion is done → tick it, in the same session that did it
|
||||
- something turns out differently than the issue assumed → the assumption is
|
||||
corrected where it stands, not contradicted three paragraphs later
|
||||
- work is deferred or dropped → say so, with the reason, where the criterion is
|
||||
|
||||
**Rewrite, never append.** Do not add to a text that has become wrong: an
|
||||
additively grown log forces every later reader to reconstruct the current
|
||||
state by filtering the whole history, which is the exact cost the body exists
|
||||
to remove. Comments carry the history (step 3); the body carries the state.
|
||||
|
||||
Body rewrites and comments are an LLM session's job. A human normally
|
||||
touches only labels and metadata directly.
|
||||
|
||||
3. **Comment a changelog, never a copy.** Every body rewrite gets one short
|
||||
comment naming only what changed against the previous state - what is new,
|
||||
what is gone, what was corrected. Do not snapshot the old body into a
|
||||
comment: a full copy per revision forces a human to diff two prose texts,
|
||||
which is not a readable history, only another copy.
|
||||
3. **Comment a changelog, never a copy.** A body rewrite gets one short comment
|
||||
naming only what changed against the previous state - what is new, what is
|
||||
gone, what was corrected. Do not snapshot the old body into a comment: a full
|
||||
copy per revision forces a human to diff two prose texts, which is not a
|
||||
readable history, only another copy.
|
||||
|
||||
One comment per *session's worth* of change, not per edit. Step 2 asks the
|
||||
body to be kept current continuously, and a comment for every tick would bury
|
||||
the board in noise; the changelog line summarises what that session moved.
|
||||
Trivial upkeep - a typo, a tightened sentence - needs no comment at all.
|
||||
|
||||
```
|
||||
**Changelog:** Decision 2 tightened - `kind/` may now change over an
|
||||
@@ -127,10 +155,46 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
answered can drop a size and move `kind/decision` to `kind/build`. Silent
|
||||
re-labelling is how a board stops meaning anything.
|
||||
|
||||
7. **Close with what actually happened**, not with a commit hash alone: which
|
||||
proposals were implemented, which were deliberately left out and why, and
|
||||
what was verified. The issue is the only place that record survives - a
|
||||
changelog entry says what changed, not what was decided against.
|
||||
7. **Closing is the last body update, not a comment.** If step 2 was followed
|
||||
the body is already nearly there, and closing only settles what the final
|
||||
run established. If it was not, closing is where the whole debt comes due -
|
||||
and it comes due at the worst moment, because a closed body is the version
|
||||
everyone reads afterwards and nobody revisits.
|
||||
|
||||
Either way the body reaches its final state *before* the issue closes:
|
||||
proposals that were decided read as decided, a "to decide" section has become
|
||||
the decision with its reasoning, acceptance criteria are ticked or struck with
|
||||
a reason, and what was verified is named. Then close, with the one-line
|
||||
changelog comment step 3 asks for.
|
||||
|
||||
Record what actually happened, not a commit hash alone: which proposals were
|
||||
implemented, which were deliberately left out and why, and what was verified.
|
||||
The issue is the only place that record survives - a changelog entry says
|
||||
what changed, not what was decided against.
|
||||
|
||||
**A closing report in a comment does not satisfy this.** It reads as
|
||||
complete to whoever writes it and leaves a body still phrased as open work:
|
||||
unticked boxes, an undecided decision section, present tense about a defect
|
||||
that no longer exists. #44 closed exactly that way, with a thorough comment
|
||||
above a body that still asked for a decision that had already been made and
|
||||
shipped. Nothing mechanical catches it (see below), which is why it is a step
|
||||
rather than a habit.
|
||||
|
||||
## What no tool checks
|
||||
|
||||
`wikitool` does not know this tracker exists, and should not learn. It ships to
|
||||
instances that have no issues at that URL, while this file and the workflow it
|
||||
describes are pruned by `dist export` - a Gitea client inside the shipped tool
|
||||
would be a dev-only dependency carried by every instance, to check a board none
|
||||
of them have. The tracker is reachable only through the `gitea-mcp` server, in a
|
||||
session, by an agent.
|
||||
|
||||
So there is no `docs verify` for the board. Nothing reports a closed issue whose
|
||||
body still reads as open, a body that contradicts its own comments, or an issue
|
||||
missing one of the four mandatory labels. Every one of those is caught by a
|
||||
session following this file, or not at all - which is the argument for the
|
||||
sequence in step 7 being explicit about the order (body first, then close),
|
||||
rather than leaving it to be inferred from step 2.
|
||||
|
||||
## Decision points
|
||||
|
||||
@@ -144,7 +208,8 @@ issues at that URL, which is exactly why `dist export` excludes
|
||||
- **Rewrite the body, or add a comment?** Rewrite whenever a reader of the body
|
||||
alone would otherwise be misled - a changed decision, a dropped criterion, a
|
||||
new constraint. A comment carries the changelog line for that rewrite, and
|
||||
nothing else that a future session needs in order to act.
|
||||
nothing else that a future session needs in order to act. Closing an issue is
|
||||
always a rewrite - see step 7.
|
||||
- **An old issue carries only `prio/` and `size/`?** Complete it to all four
|
||||
when you touch it, rather than in a sweep. The board reaches the new scheme
|
||||
issue by issue, as each is picked up.
|
||||
|
||||
@@ -43,18 +43,46 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
engineering, memory and deploy-time learning; consult before a design decision in those
|
||||
areas.
|
||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`, with the body kept as the current
|
||||
truth rather than as a first post. There is no `TODO.md`. Read it before filing something
|
||||
for later, before editing an issue, or before deciding what to pick up next.
|
||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||
to its final state before closing. Both halves bind; the second is step 6 below. Read it
|
||||
before filing something for later, before editing or closing an issue, or before deciding
|
||||
what to pick up next.
|
||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||
establish itself. Read it before adding or changing a test.
|
||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||
put in front of the user before a breaking bump. Read it before step 3.
|
||||
put in front of the user before a breaking bump. Read it before step 4.
|
||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||
before any fix that would touch `kb/` content.
|
||||
More instructions are added here incrementally as stack-development needs come up - this
|
||||
list grows without needing this skill file to change shape.
|
||||
3. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||
3. **Settle the design before building - and break there for the model switch.** These are two
|
||||
different kinds of work, and the split is not stylistic: design, the version part and any
|
||||
boundary judgment have **no** mechanical guard, while the code and tests that follow have
|
||||
`pytest`, `docs verify`, `instructions verify` and CI behind them.
|
||||
|
||||
So when the design is settled - the issue body says what will be built, the open questions are
|
||||
answered - stop and say so, in one sentence:
|
||||
|
||||
> Der Plan steht, ab hier ist die Arbeit mechanisch und durch Tests/CI abgedeckt. Wenn du auf
|
||||
> Opus bist, ist jetzt der Moment für `/model sonnet` bei Effort `high`.
|
||||
|
||||
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
|
||||
setting an agent applies. Offer it once and keep working either way; a session that argues
|
||||
about its own model has already cost more than the difference. If the design turns out not to
|
||||
be settled after all - a boundary crossing surfaces, an assumption breaks - that is a reason to
|
||||
offer the switch back up, not to decide it alone.
|
||||
|
||||
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||
than one file or a contract. Full table and reasoning:
|
||||
[claude-code-model-selection.md](../../claude-code-model-selection.md).
|
||||
|
||||
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||
version and a changelog entry:
|
||||
|
||||
@@ -91,13 +119,48 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
Prose-only changes (`README.md`, `INSTALL.md`, `EVALS.md`) and the workflows under `.gitea/`
|
||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||
|
||||
4. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
||||
5. **Verify before publishing.** `tools/wikitool docs verify`, `tools/wikitool instructions
|
||||
verify`, and the relevant `pytest` run in `tools/` - the same checks any stack change must
|
||||
pass, run explicitly rather than assumed. CI (`.gitea/workflows/ci.yml`) runs these plus a
|
||||
full `setup-instance.md` replay against a fresh `dist export`; a push to `main` that moves
|
||||
`VERSION` additionally triggers a tagged release. **CI does the tagging** - a session never
|
||||
creates a tag, which is what keeps AGENTS.md invariant 5 intact.
|
||||
|
||||
6. **Close the issue with a body rewrite, not a comment.** The last act of a session that
|
||||
finished a work package, and the one most easily skipped: by here the change is published and
|
||||
the issue feels done. It is not. The body is the version everyone reads afterwards and nobody
|
||||
revisits, so it is the one place the debt comes due at the worst moment.
|
||||
|
||||
**Break here too, in the other direction.** Everything left in the session - this rewrite,
|
||||
whether a `docs/` page's reasoning went stale, the changelog prose - is the unchecked kind of
|
||||
work again, the mirror of step 3. If the session dropped to Sonnet there, say so now:
|
||||
|
||||
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
|
||||
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
|
||||
|
||||
Then **do the work regardless of the answer.** Never block a close on a model switch: the
|
||||
change is already published, and a session that stops here leaves exactly the state this step
|
||||
exists to prevent. If it ran on the cheaper model, name that in the handover rather than
|
||||
leaving it silent.
|
||||
|
||||
Rewrite it to its final state *first*, then close. The test is what a reader who opens the
|
||||
closed issue tomorrow would conclude:
|
||||
|
||||
- every acceptance criterion ticked, or struck with the reason it was dropped
|
||||
- proposals that were decided read as decided; a "to decide" section has become the decision
|
||||
and its reasoning
|
||||
- nothing left in the present tense about a defect that no longer exists
|
||||
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
||||
|
||||
Then one short comment naming what changed against the previous state, and nothing else.
|
||||
|
||||
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||
complete to whoever writes it and leaves a body still phrased as open work. Nothing
|
||||
mechanical catches it - `wikitool` does not know this tracker exists and must not learn it,
|
||||
since it ships to instances that have no board - so this step is the only enforcement there
|
||||
is. #44 and #45 both closed exactly this way, the second an hour after the rule was written.
|
||||
[issue-tracking.md](../issue-tracking.md) step 7 has the full shape.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **Touches both stack code and wiki content in one session?** Apply this skill's rules to the
|
||||
@@ -108,7 +171,10 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||
[version-parts.md](../version-parts.md) step 4 has the full shape.
|
||||
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
||||
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||
part was chosen correctly.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -40,6 +40,48 @@ resolved paths and `conventions`' parsed `kb/CONVENTIONS.md`. A test that *rewri
|
||||
conventions file mid-test calls `conventions.reset_cache()` itself - the fixture answers for the
|
||||
boundary between tests, not for one inside a test.
|
||||
|
||||
## Which tree a test writes into
|
||||
|
||||
The environment is one half of the isolation; `config.ROOT` is the other. With `CHEMENU_ROOT`
|
||||
cleared, `ROOT` falls back to the checkout pytest is running from - deliberately, because most
|
||||
tests want the shipped `types/`. It also means that any code path resolving a file through
|
||||
`config.ROOT` or `config.KB_DIR` reaches **the real repository**, no matter which tree the
|
||||
fixture built.
|
||||
|
||||
Both corpus fixtures therefore repoint it: `raw_dir` and `kb_dir` each set
|
||||
`config.ROOT` to their `tmp_path` and re-declare the shipped `types/` through
|
||||
`use_shipped_type_specs()`. `config`'s module `__getattr__` resolves the derived paths on
|
||||
access, so repointing `ROOT` carries `KB_DIR`, `RAW_DIR` and the rest with it. A new fixture
|
||||
that builds a tree does the same thing - that is the rule here, not a per-test judgment.
|
||||
|
||||
`kb_dir` did not, until Gitea #44. Two things came of that. A test calling
|
||||
`kb_state.write_kb_state()` overwrote the real `.wikitool-kb.json`, which `git status` made
|
||||
visible within the minute. Quieter and worse: `lint`'s collection lookup resolved a page
|
||||
against `config.KB_DIR`, so every fixture page read back as "no collection" and the
|
||||
`unauthorised_labels` check skipped every edge in silence - the finding had no working test at
|
||||
all, and its green run read like an assurance.
|
||||
|
||||
Two guards came out of it, both in `conftest.py`:
|
||||
|
||||
| Guard | Default | Cost |
|
||||
|---|---|---|
|
||||
| `repository_tree_guard` (session) | on | two `git status --porcelain` calls per run |
|
||||
| `per_test_tree_guard` | off, `CHEMENU_TREE_GUARD=each` turns it on | one `git status` per test |
|
||||
|
||||
The session guard compares the working tree before against after and fails the run if anything
|
||||
moved, so it says nothing about uncommitted work a developer already had. It cannot name the
|
||||
test that did it; `CHEMENU_TREE_GUARD=each` can, and is the way to bisect once it fires. Where
|
||||
git is unavailable or the checkout is not a repository, both are silently inert.
|
||||
|
||||
Neither guard sees the second, quieter half: a check that silently *does nothing* under test
|
||||
writes no file. That one is only caught by a test that asserts the finding actually fires -
|
||||
which is why `test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb`
|
||||
lints a tree `ROOT` deliberately points away from.
|
||||
|
||||
**A function that takes a directory resolves against that directory.** `run_lint(kb_dir)`
|
||||
reading `config.KB_DIR` for one of its own lookups was the defect behind the quiet half, and
|
||||
no fixture can fix that shape from the outside.
|
||||
|
||||
## When to run
|
||||
|
||||
Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
@@ -80,7 +122,12 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`conftest.py` in the same change. A variable the tool reads and the fixture does not clear
|
||||
is the exact hole this whole file is about, reopened.
|
||||
|
||||
5. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
5. **Writing a fixture that builds a tree?** Repoint `config.ROOT` at it and call
|
||||
`use_shipped_type_specs(monkeypatch)`, as `raw_dir` and `kb_dir` do - see
|
||||
[Which tree a test writes into](#which-tree-a-test-writes-into). A fixture that returns a
|
||||
path without repointing hands the code under test the real repository.
|
||||
|
||||
6. **Verify against an empty machine before publishing**, not only in your own shell:
|
||||
|
||||
```bash
|
||||
cd tools && env -i PATH="$PATH" HOME="$(mktemp -d)" \
|
||||
@@ -92,7 +139,7 @@ Whenever you add or change a test under `tools/chemenu/tests/`.
|
||||
`.venv/bin/python -m pytest -q`. A difference between the two is a leak, and the leaking
|
||||
variable belongs in step 4's list.
|
||||
|
||||
6. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
7. **Check the coverage report when adding tests to close a gap**, rather than guessing which
|
||||
lines were uncovered:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,6 +20,36 @@ Two questions decide a version bump, and they are **not the same question**:
|
||||
Getting these backwards is how a genuinely breaking change ships as a MINOR. It happened once
|
||||
already (see the case study at the end), which is why this file exists.
|
||||
|
||||
## The candidate model
|
||||
|
||||
Between two releases the stack carries **one running candidate**, not a fresh version per
|
||||
`bump`. Five bumps with no release in between used to mean five numbers, four of which nothing
|
||||
ever consumed - the release-granularity CI's version gate wants (`VERSION` must move on every
|
||||
stack-touching push) was being paid at bump granularity instead. A candidate closes that gap
|
||||
without touching the gate: `VERSION` still moves on every bump, it just escalates the *same*
|
||||
number instead of handing out a new one.
|
||||
|
||||
- **State lives in `VERSION` itself**, as an optional `-beta.N` suffix (`4.4.0-beta.3`). No
|
||||
second state file: the last release is read back out of `CHANGES.md` (the newest entry with no
|
||||
suffix), and the escalation stage is the difference between the candidate's base and that
|
||||
release - derived, not stored.
|
||||
- **`--major`/`--minor`/`--patch` is max-wins escalation**, not a step you can undo. A `--patch`
|
||||
bump on a candidate already at MINOR only advances its bump count (`N`); nothing ever steps a
|
||||
candidate back down. Declaring the part is still your judgment call, made the same way section
|
||||
below describes - `escalate()` only ever raises it further.
|
||||
- **A candidate is never released.** Pre-release is a dev-checkout state; `release.yml` only acts
|
||||
on a suffix-free `VERSION`, so a distributed instance never sees a `-beta.` version at all, and
|
||||
its parser never has to know the suffix exists.
|
||||
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
|
||||
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
|
||||
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
|
||||
heading, date and the bumps list all move, but the entry's own prose (written below the
|
||||
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
||||
release, whatever a candidate's history of bumps looked like.
|
||||
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
|
||||
strips the suffix and turns the entry into a real, closed release - see its own row in
|
||||
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
|
||||
|
||||
## When to run
|
||||
|
||||
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here.
|
||||
@@ -63,9 +93,11 @@ the three-line test below is usually enough.
|
||||
| Fix, no interface change | `--patch` |
|
||||
| New capability, drop-in in both directions | `--minor` |
|
||||
|
||||
4. **Stop and talk to the user before a boundary-crossing bump.** It is expensive in a way the
|
||||
other two parts are not: every existing instance pays for it, once, by hand. Put in front of
|
||||
them, in this order:
|
||||
4. **Stop and talk to the user before the bump that first escalates a candidate past the
|
||||
boundary.** It is expensive in a way the other two parts are not: every existing instance pays
|
||||
for it, once, by hand. That escalation happens exactly once per candidate - a later bump that
|
||||
keeps the candidate at the same stage (another `--major` on one already there, say) does not
|
||||
re-cross anything and needs no second conversation. Put in front of the user, in this order:
|
||||
|
||||
- **What breaks**, concretely - which file, which name, which call site.
|
||||
- **What each existing instance must do**, as the steps they would actually run.
|
||||
@@ -81,8 +113,9 @@ the three-line test below is usually enough.
|
||||
|
||||
Then wait for an explicit go-ahead. Do not bump across the boundary on your own initiative.
|
||||
|
||||
5. **Record the break in the bump itself.** A boundary-crossing bump requires
|
||||
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
|
||||
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
|
||||
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
||||
entry:
|
||||
|
||||
```bash
|
||||
tools/wikitool version bump --major \
|
||||
@@ -91,22 +124,33 @@ the three-line test below is usually enough.
|
||||
--no-migration "<why no page has to change>" # only if that is true
|
||||
```
|
||||
|
||||
`--breaking` is refused on a bump that crosses nothing, and required on one that does;
|
||||
`docs verify` checks the newest boundary-crossing entry still carries the line. Write it for
|
||||
the operator of an instance that has not read this repository: what stops working, and what
|
||||
they do about it.
|
||||
The line, once written, stays in the entry across every later bump of the same candidate -
|
||||
a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat
|
||||
it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required
|
||||
on the one that does. `docs verify` checks the newest boundary-crossing entry still carries
|
||||
the line. Write it for the operator of an instance that has not read this repository: what
|
||||
stops working, and what they do about it.
|
||||
|
||||
6. **Then answer the migration question separately.** Boundary-crossing and
|
||||
content-migrating are independent:
|
||||
|
||||
- Content must change → write the migration document under `instructions/migrations/` per
|
||||
[migrate-corpus.md](../migrate-corpus.md). `bump` finds it by its `migrates_to:` field.
|
||||
[migrate-corpus.md](../migrate-corpus.md). The escalation bump finds it by the document's
|
||||
`migrates_to:` field, matched against the candidate's **base** - a document targets the
|
||||
release the candidate will become, never a `-beta.N` form of it.
|
||||
- Content need not change → `--no-migration "<reason>"`, which records that in the entry.
|
||||
|
||||
Both are also needed by `docs verify`, for the same reason: an instance that learns it must
|
||||
migrate, with nothing telling it how, is a dead end.
|
||||
migrate, with nothing telling it how, is a dead end. Like `--breaking`, both persist across
|
||||
later bumps of the same candidate without being repeated.
|
||||
|
||||
7. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
|
||||
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
||||
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
||||
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
||||
way; without one, the heading simply keeps whichever bump last set it.
|
||||
|
||||
8. **Write the entry's body.** `bump` leaves it empty on purpose. A boundary-crossing entry
|
||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
||||
instead of guessing.
|
||||
|
||||
@@ -68,7 +68,7 @@ stack's hardcoded behaviour until the conventions file existed.
|
||||
|---|---|
|
||||
| `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 |
|
||||
| Naming | Human-readable titles with spaces; singular for entities; a decision named like any other concept, no `adr-NNN-` prefix; `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 |
|
||||
|
||||
@@ -40,9 +40,18 @@ edge merely to mirror the first one.** The inbound view is rendered from the gra
|
||||
`index rebuild` and `search`, so a reader landing on the target sees what points at it whether
|
||||
or not anyone wrote a second edge.
|
||||
|
||||
That is why most labels below have no inverse. Only two pairs do, because in each the reverse
|
||||
That is why most labels below have no inverse. Only three pairs do, because in each the reverse
|
||||
direction is a genuine primary statement someone would write on its own: `depends-on` /
|
||||
`required-by` and `runs-on` / `hosts`.
|
||||
`required-by`, `runs-on` / `hosts`, and `composition` / `part-of`.
|
||||
|
||||
The third was added after the 4.0.0 migration, from measurement rather than from the desk. A
|
||||
parent-child structure - a tier list and its tiers, a spectrum and its levels - produces the
|
||||
question on nearly every page: the parent writes `composition`, and the child then reaches for
|
||||
either `part-of` or `see-also`. The migration run answered `see-also`, on the reading that
|
||||
`part-of` would be a mirror, and left sixteen edges saying "these two are related" about a
|
||||
relationship the catalogue already had a word for. It is not a mirror: the parent's sentence
|
||||
lists its parts, the child's names the whole it belongs to, and a reader landing on the child
|
||||
needs the second one.
|
||||
|
||||
## When to run
|
||||
|
||||
@@ -129,6 +138,13 @@ Inference and comparison between ideas.
|
||||
| `composition` | is composed of the target |
|
||||
| `part-of` | is a component of the target |
|
||||
|
||||
`composition` / `part-of` is the third **inverse pair**, alongside `depends-on` / `required-by`
|
||||
and `runs-on` / `hosts` in the operational register. Being a pair does not make the second edge
|
||||
obligatory - direction is still authored - it settles *which label* the second edge takes when
|
||||
someone does write it. The child of a `composition` writes `part-of`, not `see-also`: what it
|
||||
is a component of is a primary statement about the child, and `see-also` says strictly less
|
||||
about the same fact.
|
||||
|
||||
`grounds` / `rests-on` is a genuine pair and both directions are primary statements; they are
|
||||
listed separately rather than as inverses because either page may legitimately carry only its
|
||||
own side.
|
||||
|
||||
@@ -36,8 +36,9 @@ you know the run is finished.
|
||||
**Nothing breaks while it is outstanding.** Unlabelled edges and undelimited regions are read,
|
||||
not rejected: `links.py` treats a bare title as an edge whose label is not declared yet, and
|
||||
`provenance.split_cite_block` falls back to the pre-marker layout. That is deliberate - a corpus
|
||||
has to stay readable while it is being converted - and it is why the two lint findings are
|
||||
advisory until step 6 promotes them.
|
||||
has to stay readable while it is being converted - and it is why the two lint findings stay
|
||||
advisory for as long as `kb_version` is below 4.0.0, which is exactly as long as this document
|
||||
is outstanding.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -102,7 +103,7 @@ advisory until step 6 promotes them.
|
||||
is otherwise silent: the region becomes ordinary prose and the next write appends a second
|
||||
one beside it.
|
||||
|
||||
6. **Record it, then tighten the checks:**
|
||||
6. **Record it. The checks tighten themselves:**
|
||||
|
||||
```bash
|
||||
tools/wikitool lint # unlabelled_edges and unauthorised_labels must be 0
|
||||
@@ -110,8 +111,14 @@ advisory until step 6 promotes them.
|
||||
```
|
||||
|
||||
Only once `lint` reports zero of both is the run finished. The two findings are advisory
|
||||
during the window and become hard errors afterwards - the same path
|
||||
`legacy_citation_markers` took after the citation migration.
|
||||
while `kb_version` is below 4.0.0 and hard from the moment `migrate done` records it -
|
||||
nothing to flip by hand, and no window in which a half-converted corpus is refused by the
|
||||
check that is measuring its progress.
|
||||
|
||||
Do not record the migration to silence the findings. The promotion is what makes the run
|
||||
stick: after it, a bare title in `related:` is a hard error rather than a page still
|
||||
waiting, so a corpus recorded early fails its next lint instead of quietly keeping the old
|
||||
shape.
|
||||
|
||||
## How to tell a migrated page from an unmigrated one
|
||||
|
||||
|
||||
+2
-1
@@ -62,7 +62,8 @@ There is no `## Siehe auch` region any more. It was the reciprocal half of a bid
|
||||
- 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`.
|
||||
- A decision (`concept_type: decision`) is named like any other concept - no `adr-NNN-` prefix.
|
||||
See [kb/concepts/COLLECTION.md § Decisions](concepts/COLLECTION.md#decisions).
|
||||
- Prefer readability over convention when the two conflict.
|
||||
|
||||
What to name a thing: projects use their repository or common name; systems a descriptive
|
||||
|
||||
@@ -3,8 +3,10 @@ type: types/comparison.md
|
||||
tags: [kernel, power-management, amd, cpu, driver]
|
||||
created: 2026-07-31
|
||||
entities: [amd-pstate, acpi-cpufreq]
|
||||
summary: "Vergleich zweier AMD-CPU-Power-Management-Treiber: CPPC-basiertes amd-pstate gegen\xFC\
|
||||
ber ACPI-basiertem acpi-cpufreq."
|
||||
summary: "Vergleich zweier AMD-CPU-Power-Management-Treiber: CPPC-basiertes amd-pstate gegen\xFCber ACPI-basiertem acpi-cpufreq."
|
||||
related:
|
||||
- compares-with: amd-pstate
|
||||
- compares-with: acpi-cpufreq
|
||||
---
|
||||
# Comparison: amd-pstate vs acpi-cpufreq
|
||||
|
||||
@@ -131,8 +133,9 @@ ls /sys/devices/system/cpu/cpu0/cpufreq/cppc_*
|
||||
|
||||
**amd-pstate** stellt einen bedeutenden Fortschritt in der CPU-Energieverwaltung für AMD-Prozessoren dar und bietet fein-körnige Steuerung, bessere Effizienz und verbessertes Batterielebensdauer. **acpi-cpufreq** bleibt ein zuverlässiger Fallback und dient weiterhin älterer Hardware. Die Wahl zwischen ihnen hängt hauptsächlich von Hardware-Unterstützung und Kernel-Version ab, wobei amd-pstate die klare Präferenz für moderne AMD-Systeme ist.
|
||||
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **compares-with:** [[amd-pstate]]
|
||||
- **compares-with:** [[acpi-cpufreq]]
|
||||
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
+18
-10
@@ -27,19 +27,27 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
|
||||
|
||||
`concept` (`tools/wikitool types describe concept`).
|
||||
|
||||
## Decisions and ADRs
|
||||
## Decisions
|
||||
|
||||
An architectural decision is a concept page, prefixed as
|
||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records:
|
||||
An architectural decision is an ordinary concept page with `concept_type: decision`
|
||||
(`tools/wikitool types describe concept`) - not a separate format, and not a separate location.
|
||||
There is no `adr-NNN-`-prefixed filename and no dedicated directory: naming follows
|
||||
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) like every other concept, and the page
|
||||
lives in `kb/concepts/` like every other concept.
|
||||
|
||||
- **Context** - what forced a decision.
|
||||
- **Decision** - what was chosen.
|
||||
- **Consequences** - what this costs, not only what it buys.
|
||||
- **Status** - proposed / accepted / deprecated / superseded.
|
||||
- Links to every entity the decision affects.
|
||||
The body is organic prose under this collection's usual sections, not a fixed template. What it
|
||||
still has to carry: what was decided, what forced the decision, what it costs (not only what it
|
||||
buys), and a link to every entity the decision affects. A `**Status:**` line is optional - most
|
||||
decision pages in this instance carry none, because the page's own prose already says whether the
|
||||
decision stands.
|
||||
|
||||
A superseded ADR is never deleted or rewritten. The new one declares `supersedes` pointing at
|
||||
it; the old one needs no edge back, because its inbound view renders the replacement.
|
||||
A decision superseded by a later one is never deleted or rewritten. The new page declares
|
||||
`supersedes` pointing at it; the old one needs no edge back, because its inbound view renders the
|
||||
replacement.
|
||||
|
||||
`concept_type: decision` is also the one subtype [kb/CONTRACT.md](../CONTRACT.md)'s confidence
|
||||
machinery treats differently: `confidence decay` skips it structurally, because elapsed time does
|
||||
not falsify a decision - only a later decision superseding it does.
|
||||
|
||||
## Authorised labels
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Consolidation Tiers
|
||||
- part-of: Consolidation Tiers
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -41,5 +41,5 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Consolidation Tiers]]
|
||||
- **part-of:** [[Consolidation Tiers]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| [[Hybrid Search]] | architecture | Multimodale Suche, die BM25-Schlüsselwortabgleich, Vektor-Embeddings und Graph Traversal verbindet, um Wissensabruf im Wiki skalierbar zu machen. | 2026-08-29 |
|
||||
| [[Implementation Spectrum]] | architecture | Modularer Einführungspfad für die Funktionen von LLM Wiki v2, vom minimal tragfähigen Wiki bis zur vollen Umsetzung mit Automatisierung und Governance. | 2026-08-29 |
|
||||
| [[Index Scaling]] | workflow | Skalierungsregeln für Indexseiten: Tabellenabschnitte ab 50 Einträgen teilen, ab 200 Seiten _meta/topic-map.md anlegen | 2026-08-29 |
|
||||
| [[Issue Label Scheme]] | decision | Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und size/XS..L, bewusst keine dritte Achse; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf | 2026-08-31 |
|
||||
| [[Issue Label Scheme]] | decision | Pflicht-Labelschema fuer das Gitea-Board: seit 2026-09-02 vier Achsen (area/kind/prio/size) plus zwei optionale status/-Flags, dazu der Issue-Body als aktuelle Wahrheit; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf | 2026-09-02 |
|
||||
| [[Iteration and Cost Limits]] | workflow | Im Code durchgesetzte Obergrenze von 60 wikitool-Aufrufen je Session, Loop-Breaker bei 3 identischen Wiederholungen, Slot-Erstattung, ein gemessenes Kalibrierungsband, und Retrieval sowie der MCP-Leseserver bleiben ausgenommen | 2026-09-02 |
|
||||
| [[KB Migration]] | workflow | Migration des KB-Inhalts entlang einer geordneten Versionskette; abgegrenzt gegen offene Instanz-Aktionen, die in den doctor-Check gehoeren statt in die Kette | 2026-08-31 |
|
||||
| [[KB Stack Versioning]] | decision | Semantische Versionierung des Wiki-Stacks: VERSION beschreibt die Maschinerie, Kompatibilitaet (Drop-in-Ersatz) und Inhaltsmigration sind seit 2.5.0 getrennte, unabhaengig geprueft Fragen | 2026-09-02 |
|
||||
|
||||
@@ -3,17 +3,17 @@ type: types/concept.md
|
||||
concept_type: decision
|
||||
tags: [issues, gitea, triage, labels, backlog]
|
||||
created: 2026-08-31
|
||||
modified: 2026-08-31
|
||||
modified: 2026-09-02
|
||||
related:
|
||||
- operates-on: Chemenu
|
||||
- mechanism: Gitea MCP Server
|
||||
- see-also: KB Stack Versioning
|
||||
- see-also: Detect-Repair Asymmetry
|
||||
sources: [Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]
|
||||
sources: [Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||
confidence: 0.70
|
||||
confidence_base: 0.70
|
||||
confidence_base: 0.85
|
||||
provenance: sourced
|
||||
summary: 'Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und size/XS..L, bewusst keine dritte Achse; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf'
|
||||
summary: 'Pflicht-Labelschema fuer das Gitea-Board: seit 2026-09-02 vier Achsen (area/kind/prio/size) plus zwei optionale status/-Flags, dazu der Issue-Body als aktuelle Wahrheit; die Regel liegt in instructions/dev/, weil sie keine ausgelieferte Instanz erreichen darf'
|
||||
---
|
||||
# Issue Label Scheme
|
||||
|
||||
@@ -22,36 +22,78 @@ summary: 'Zweiachsiges Pflicht-Labelschema fuer das Gitea-Board: prio/1..3 und s
|
||||
## Definition
|
||||
|
||||
Issue Label Scheme ist die Entscheidung, offene Arbeit an diesem Stack ausschließlich als
|
||||
Gitea-Issues zu führen und jedes Issue mit genau zwei Pflicht-Labels zu versehen: einer
|
||||
Priorität `prio/1..3` und einer Größe `size/XS..L`. Eine dritte Achse gibt es bewusst nicht.
|
||||
Getroffen wurde die Entscheidung am 2026-08-31, gemeinsam mit der Löschung von `TODO.md`[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||
Gitea-Issues zu führen und jedes offene Issue mit vier Pflicht-Labels zu versehen: einem
|
||||
Bereich `area/`, einer Art `kind/`, einer Priorität `prio/` und einer Größe `size/`. Dazu
|
||||
kommen zwei optionale `status/`-Flags. Getroffen wurde die Entscheidung in dieser Form am
|
||||
2026-09-02[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02]; sie ersetzt das
|
||||
zweiachsige Schema vom 2026-08-31 (siehe [Historie](#historie)).
|
||||
|
||||
| Priorität | Bedeutung |
|
||||
| `area/` | Bedeutung |
|
||||
|---|---|
|
||||
| `prio/1` | Blockiert oder beschädigt laufende Arbeit. Als Nächstes. |
|
||||
| `prio/2` | Sammelt Zinsen. Eingeplant. |
|
||||
| `prio/3` | Lohnend, wartet auf einen benannten Auslöser. |
|
||||
| `area/kb` | `kb/`-Schema, Contract, Confidence, Lint - die Wissensbasis als System. |
|
||||
| `area/distribution` | Auslieferung, Upgrade und Versionierung einer Instanz. |
|
||||
| `area/corpus` | Inhalt und Umfang von `kb/` in dieser Instanz, samt Demo-/Testbett-Frage. |
|
||||
| `area/workflow` | Git, Merge, Branching, Publish, PRs. |
|
||||
| `area/process` | Der Entwicklungsprozess selbst, nicht der Stack als Artefakt. |
|
||||
|
||||
| Größe | Bedeutung |
|
||||
| `kind/` | Bedeutung |
|
||||
|---|---|
|
||||
| `kind/decision` | Wartet auf eine Betreiberentscheidung. |
|
||||
| `kind/build` | Spezifiziert, wartet nur noch auf Umsetzungszeit. |
|
||||
| `kind/defect` | Befund: Doku und Realität, oder zwei Dokus, widersprechen sich. |
|
||||
|
||||
| `prio/` | Bedeutung |
|
||||
|---|---|
|
||||
| `prio/blocking` | Blockiert oder beschädigt laufende Arbeit. Als Nächstes. |
|
||||
| `prio/planned` | Sammelt Zinsen. Eingeplant. |
|
||||
| `prio/waiting` | Lohnend, wartet auf einen benannten Auslöser. |
|
||||
|
||||
| `size/` | Bedeutung |
|
||||
|---|---|
|
||||
| `size/XS` | Minuten. Oft nur eine Entscheidung oder eine Beobachtung. |
|
||||
| `size/S` | Eine Sitzung, ein Publish, ein klarer Schnitt. |
|
||||
| `size/M` | Mehrere Dateien; eine Contract- oder Instruction-Änderung; eigener Testaufwand. |
|
||||
| `size/L` | Mehrere Sitzungen, oder offene Entwurfsfragen vor dem ersten Commit. |
|
||||
|
||||
Die sieben Labels wurden angelegt und auf alle zehn zu dem Zeitpunkt offenen Issues
|
||||
angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||
| `status/` (optional) | Bedeutung |
|
||||
|---|---|
|
||||
| `status/blocked` | Wartet auf ein anderes, noch offenes Issue - unabhängig vom `prio`-Wert nicht eigenständig bearbeitbar. |
|
||||
| `status/unconfirmed` | Gemeldeter Verdacht, noch nicht gegen tatsächliches Verhalten geprüft; `size` und `prio` sind solange vorläufig. |
|
||||
|
||||
Sechzehn Labels stehen in Gitea; `prio/1`, `prio/2`, `prio/3` und `size/XS` existieren nicht
|
||||
mehr[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
|
||||
## Kernpunkte
|
||||
|
||||
- **Beide Achsen sind Pflicht, weil eine Priorität ohne Kosten eine halbe Entscheidung ist.**
|
||||
Größe ist Aufwand und nicht Wichtigkeit, deshalb ist `prio/1 size/XS` das Beste, was auf
|
||||
einem Board stehen kann, und `prio/3 size/L` etwas, worüber gesprochen wird, bevor jemand
|
||||
anfängt.
|
||||
- **`prio/3` ist kein Friedhof.** Der Auslöser muss im Issue benannt sein, sonst ist das Label
|
||||
ein höfliches Nein[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||
- **Keine dritte Achse.** Art, Bereich oder Status wurden verworfen als der Punkt, ab dem eine
|
||||
Taxonomie eigene Pflege braucht. Das Board hat einen einzigen Betreuer.
|
||||
- **Vier Achsen sind Pflicht, weil ihre Pflege maschinell läuft.** Der ursprüngliche Einwand
|
||||
gegen eine dritte Achse war der Aufwand für einen einzelnen menschlichen Betreuer. Da
|
||||
Body-Rewrites und Labelpflege über eine LLM-Sitzung laufen und ein Mensch in der Regel nur
|
||||
Metadaten anfasst, trägt dieser Einwand
|
||||
nicht mehr[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **Der Issue-Body ist die aktuelle Wahrheit, nicht der Ursprungstext.** Die Umsetzung eines
|
||||
Issues zieht sich über mehrere, zeitlich getrennte Sitzungen, und der Body ist das einzige,
|
||||
was sie verbindet: eine Sitzung muss allein aus ihm rekonstruieren können, was entschieden
|
||||
und was offen ist. Er wird deshalb umgeschrieben statt
|
||||
ergänzt[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **Ein Kommentar ist ein Changelog, keine Kopie.** Ein Volltext-Snapshot des alten Bodys pro
|
||||
Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare
|
||||
Historie, sondern nur eine weitere
|
||||
Kopie[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **`area/` folgt der Systemgrenze, nicht dem Codeort.** Die Werte folgen der Stufenteilung aus
|
||||
`AGENTS.md`. Ein `area/tools` gibt es bewusst nicht - Tooling wird nach der Domäne
|
||||
einsortiert, die es
|
||||
bedient[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **`kind/` darf sich im Lauf eines Issues ändern.** Der Wechsel von `decision` zu `build`,
|
||||
sobald entschieden ist, ist erwünschtes Session-Memory-Verhalten und kein
|
||||
Makel[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **Eine Priorität ohne Kosten ist eine halbe Entscheidung.** Größe ist Aufwand und nicht
|
||||
Wichtigkeit, deshalb ist `prio/blocking size/S` das Beste, was auf einem Board stehen kann,
|
||||
und `prio/waiting size/L` etwas, worüber gesprochen wird, bevor jemand anfängt.
|
||||
- **`prio/waiting` ist kein Friedhof.** Der Auslöser muss im Issue benannt sein, sonst ist das
|
||||
Label ein höfliches Nein[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||
- **Kein unbelegter Verdacht bleibt offen liegen.** Die Triage eines `status/unconfirmed`
|
||||
endet entweder mit entferntem Flag und verbindlichen `size`/`prio`-Werten oder mit einem
|
||||
geschlossenen Issue samt Begründung - die Prozessentsprechung zu Invariante 3 des
|
||||
Stacks[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
- **Priorisiert wird nach Schaden, nicht nach Aufwand.** Das Kriterium der ersten Triage
|
||||
lautete: was blockiert oder beschädigt laufende Arbeit. Ein Werkzeugfehler, der seinen
|
||||
Benutzer gegen eine Invariante des Stacks drückt, rangiert deshalb vor einer fehlenden
|
||||
@@ -63,6 +105,28 @@ angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-0
|
||||
Linkliste auf Issues; der zweite, die Recherche-Notiz, ging vollständig nach #15. Danach gab
|
||||
es nichts mehr in der Datei, was nicht auf Gitea stand.
|
||||
|
||||
## Historie
|
||||
|
||||
Das ursprüngliche Schema vom 2026-08-31 hatte ~~genau zwei Pflicht-Labels, `prio/1..3` und
|
||||
`size/XS..L`, und verzichtete ausdrücklich auf eine dritte Achse: Art, Bereich oder Status
|
||||
wurden verworfen als der Punkt, ab dem eine Taxonomie eigene Pflege braucht, und das Board
|
||||
habe einen einzigen Betreuer.~~ Sieben Labels wurden angelegt und auf alle zehn zu dem
|
||||
Zeitpunkt offenen Issues
|
||||
angewandt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31].
|
||||
|
||||
Was sich am 2026-09-02 geändert hat:
|
||||
|
||||
| Achse | Vorher | Jetzt |
|
||||
|---|---|---|
|
||||
| `prio/` | `1`, `2`, `3` | `blocking`, `planned`, `waiting` - reine Umbenennung, Bedeutung unverändert |
|
||||
| `size/` | `XS`, `S`, `M`, `L` | `S`, `M`, `L` - `XS` entfällt, die übrigen unverändert |
|
||||
| `area/` | - | fünf Werte, neu |
|
||||
| `kind/` | - | drei Werte, neu |
|
||||
| `status/` | - | zwei optionale Flags, neu |
|
||||
|
||||
Der Verzicht auf die dritte Achse fiel damit weg, nicht weil die Begründung falsch war,
|
||||
sondern weil ihre Voraussetzung entfallen ist: gepflegt wird das Board nicht mehr von Hand.
|
||||
|
||||
## Wo die Regel liegt
|
||||
|
||||
Die Platzierung war die tragende Entscheidung, nicht das Schema selbst. `README.md` und
|
||||
@@ -80,42 +144,42 @@ Instanz ändert sich nichts. Das CI-Versions-Gate verlangte den Bump trotzdem, w
|
||||
auf `instructions/` passt und `instructions/dev/` darunter liegt[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]. Siehe
|
||||
[[KB Stack Versioning]].
|
||||
|
||||
Für die Erweiterung auf vier Achsen galt dieselbe Rechnung noch einmal: sie ging als `4.0.1`
|
||||
und damit ebenfalls als PATCH
|
||||
hinaus[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02].
|
||||
|
||||
## Beispiele
|
||||
|
||||
- [[Chemenu]] - das Repository, dessen Board nach dem Schema geführt wird; sieben Labels
|
||||
wurden angelegt und auf alle zehn offenen Issues angewandt
|
||||
- [[Gitea MCP Server]] - der Weg, auf dem Issues und Labels gelesen und geschrieben werden, da
|
||||
das Origin-Repository privat ist
|
||||
- [[Chemenu]] - das Repository, dessen Board nach dem Schema geführt wird; sechzehn Labels
|
||||
stehen dort, verteilt auf vier Pflicht- und eine optionale Familie
|
||||
- [[Gitea MCP Server]] - der Weg, auf dem Issues und Labels gelesen und geschrieben werden
|
||||
- [[Detect-Repair Asymmetry]] - Issue #14 ist der Fall, den dieses Concept beschreibt, und
|
||||
trägt `prio/2 size/S`
|
||||
trug in der ersten Triage `prio/2 size/S`, nach der Umbenennung also `prio/planned size/S`
|
||||
|
||||
## Wann zu verwenden
|
||||
|
||||
- Auf einem Board mit einem einzigen Betreuer, das eine erkennbare Reihenfolge braucht, aber
|
||||
keinen Prozess.
|
||||
- Auf einem Board mit einem einzigen menschlichen Betreuer, dessen Labelpflege maschinell
|
||||
läuft. Erst das macht mehr als zwei Achsen bezahlbar.
|
||||
- Sobald offene Arbeit sonst in Prosa-Dateien wandert, die niemand als Board liest und die
|
||||
gegen den Tracker driften.
|
||||
- Sobald die Bearbeitung eines Issues sich über mehrere, zeitlich getrennte Sitzungen zieht -
|
||||
dann trägt die Body-als-Wahrheit-Konvention den Kontext, den sonst ein Mensch jedes Mal neu
|
||||
erzählen müsste.
|
||||
|
||||
## Wann NICHT zu verwenden
|
||||
|
||||
- Nicht auf einem Board mit mehreren Teams, wo Zuständigkeit und Bereich echte Information
|
||||
tragen. Dann ist die dritte Achse keine Taxonomie-Pflege, sondern Routing.
|
||||
- Nicht dort, wo Labels von Hand gepflegt werden. Dann ist die ursprüngliche Zweiachsigkeit
|
||||
die tragfähigere Wahl, und die Begründung von 2026-08-31 gilt unverändert.
|
||||
- Nicht als Ersatz für die Abnahmekriterien im Issue-Text. Die Labels ordnen ein Issue ein; ob
|
||||
es fertig ist, sagen sie nicht.
|
||||
- Nicht mit umgeschriebenen Bodys dort, wo mehrere Menschen denselben Thread lesen und den
|
||||
Verlauf brauchen. Die Konvention tauscht Historie gegen Aktualität und setzt voraus, dass
|
||||
der Changelog-Kommentar als Historie genügt.
|
||||
- Nicht in einer ausgelieferten Instanz. Das Schema beschreibt das Entwicklungs-Repository und
|
||||
hat außerhalb davon keinen Gegenstand.
|
||||
|
||||
## Beziehungen
|
||||
|
||||
## Siehe auch
|
||||
|
||||
- [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
||||
|
||||
## Fußnoten
|
||||
|
||||
[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]: [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
||||
|
||||
<!-- wikitool:links -->
|
||||
|
||||
## Beziehungen
|
||||
|
||||
- **operates-on:** [[Chemenu]]
|
||||
@@ -123,3 +187,10 @@ auf `instructions/` passt und `instructions/dev/` darunter liegt[^s-conversation
|
||||
- **see-also:** [[KB Stack Versioning]]
|
||||
- **see-also:** [[Detect-Repair Asymmetry]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
<!-- wikitool:footnotes -->
|
||||
## Fußnoten
|
||||
|
||||
[^s-gitea-issue-41-issue-management-and-label-scheme-2026-09-02]: [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]]
|
||||
[^s-conversation-issue-triage-labels-and-todo-retirement-session-2026-08-31]: [[Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]]
|
||||
<!-- /wikitool:footnotes -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [memory, lifecycle, confidence, knowledge-management]
|
||||
created: 2026-07-26
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: LLM Wiki Pattern
|
||||
- part-of: LLM Wiki Pattern
|
||||
- see-also: Confidence Scoring
|
||||
- composition: Supersession
|
||||
- see-also: Consolidation Tiers
|
||||
@@ -126,7 +126,7 @@ Basierend auf [[Agent Memory]]-Erfahrung:
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[LLM Wiki Pattern]]
|
||||
- **part-of:** [[LLM Wiki Pattern]]
|
||||
- **see-also:** [[Confidence Scoring]]
|
||||
- **composition:** [[Supersession]]
|
||||
- **see-also:** [[Consolidation Tiers]]
|
||||
|
||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- exemplifies: Implementation Spectrum
|
||||
- see-also: Multi-Agent Collaboration
|
||||
- part-of: Multi-Agent Collaboration
|
||||
- evidenced-by: Source - LLM Wiki v2
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
@@ -46,6 +46,6 @@ TODO
|
||||
## Beziehungen
|
||||
|
||||
- **exemplifies:** [[Implementation Spectrum]]
|
||||
- **see-also:** [[Multi-Agent Collaboration]]
|
||||
- **part-of:** [[Multi-Agent Collaboration]]
|
||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Consolidation Tiers
|
||||
- part-of: Consolidation Tiers
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -43,5 +43,5 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Consolidation Tiers]]
|
||||
- **part-of:** [[Consolidation Tiers]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Hybrid Search
|
||||
- part-of: Hybrid Search
|
||||
- exemplifies: LLM Wiki Pattern
|
||||
- evidenced-by: Source - LLM Wiki v2
|
||||
sources: []
|
||||
@@ -45,7 +45,7 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Hybrid Search]]
|
||||
- **part-of:** [[Hybrid Search]]
|
||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Consolidation Tiers
|
||||
- part-of: Consolidation Tiers
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -43,5 +43,5 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Consolidation Tiers]]
|
||||
- **part-of:** [[Consolidation Tiers]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Multi-Agent Collaboration
|
||||
- part-of: Multi-Agent Collaboration
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -43,5 +43,5 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Multi-Agent Collaboration]]
|
||||
- **part-of:** [[Multi-Agent Collaboration]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [split, threshold, lines, pages]
|
||||
created: 2026-08-03
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Content Quality Control
|
||||
- part-of: Content Quality Control
|
||||
- see-also: Stub Threshold
|
||||
- see-also: Index Scaling
|
||||
sources: [Source - LLM Improvements Sonnet Analysis]
|
||||
@@ -69,7 +69,7 @@ Split Threshold definiert die maximale Größe, die eine Wiki-Seite erreichen so
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Content Quality Control]]
|
||||
- **part-of:** [[Content Quality Control]]
|
||||
- **see-also:** [[Stub Threshold]]
|
||||
- **see-also:** [[Index Scaling]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [stub, minimum, quality, lines]
|
||||
created: 2026-08-03
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Content Quality Control
|
||||
- part-of: Content Quality Control
|
||||
- see-also: Split Threshold
|
||||
- see-also: Semantic Lint Automation
|
||||
sources: [Source - LLM Improvements Sonnet Analysis, Source - LLM Wiki v2]
|
||||
@@ -80,7 +80,7 @@ It provides comprehensive information about the topic. It clearly exceeds the st
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Content Quality Control]]
|
||||
- **part-of:** [[Content Quality Control]]
|
||||
- **see-also:** [[Split Threshold]]
|
||||
- **see-also:** [[Semantic Lint Automation]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [versioning, knowledge, updates, lifecycle]
|
||||
created: 2026-07-26
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Memory Lifecycle
|
||||
- part-of: Memory Lifecycle
|
||||
- rests-on: Confidence Scoring
|
||||
- see-also: Knowledge Graph
|
||||
- exemplifies: LLM Wiki Pattern
|
||||
@@ -138,7 +138,7 @@ Wenn Aussage B Aussage A ersetzt:
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Memory Lifecycle]]
|
||||
- **part-of:** [[Memory Lifecycle]]
|
||||
- **rests-on:** [[Confidence Scoring]]
|
||||
- **see-also:** [[Knowledge Graph]]
|
||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||
|
||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- exemplifies: Implementation Spectrum
|
||||
- see-also: Knowledge Graph
|
||||
- part-of: Knowledge Graph
|
||||
- evidenced-by: Source - LLM Wiki v2
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
@@ -46,6 +46,6 @@ TODO
|
||||
## Beziehungen
|
||||
|
||||
- **exemplifies:** [[Implementation Spectrum]]
|
||||
- **see-also:** [[Knowledge Graph]]
|
||||
- **part-of:** [[Knowledge Graph]]
|
||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Hybrid Search
|
||||
- part-of: Hybrid Search
|
||||
- exemplifies: LLM Wiki Pattern
|
||||
- evidenced-by: Source - LLM Wiki v2
|
||||
sources: []
|
||||
@@ -45,7 +45,7 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Hybrid Search]]
|
||||
- **part-of:** [[Hybrid Search]]
|
||||
- **exemplifies:** [[LLM Wiki Pattern]]
|
||||
- **evidenced-by:** [[Source - LLM Wiki v2]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -6,7 +6,7 @@ created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- exemplifies: Implementation Spectrum
|
||||
- see-also: Multi-Agent Collaboration
|
||||
- part-of: Multi-Agent Collaboration
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -45,5 +45,5 @@ TODO
|
||||
## Beziehungen
|
||||
|
||||
- **exemplifies:** [[Implementation Spectrum]]
|
||||
- **see-also:** [[Multi-Agent Collaboration]]
|
||||
- **part-of:** [[Multi-Agent Collaboration]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: []
|
||||
created: 2026-08-02
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: Consolidation Tiers
|
||||
- part-of: Consolidation Tiers
|
||||
sources: []
|
||||
confidence: 0.50
|
||||
confidence_base: 0.50
|
||||
@@ -43,5 +43,5 @@ TODO
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[Consolidation Tiers]]
|
||||
- **part-of:** [[Consolidation Tiers]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -14,7 +14,7 @@ related:
|
||||
- implements: MCP-Leseserver
|
||||
- uses: wikitool
|
||||
- composition: AGENTS.md
|
||||
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]
|
||||
sources: [Source - Copilot Skill Restructure Instructions, Source - Conversation - AGENTS.md Skill Restructuring Session 2026-08-04, Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Comma Bug Budget Refund and Lint Report Path Session 2026-08-31, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31, Source - Conversation - Gate Counting and Measured Calibration Session 2026-08-31, Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31, Source - Conversation - Hardening the Test Suite Against Silent Environment Dependencies Session 2026-08-31, 'Source - Public Release, Corpus Purge and History Squash Session 2026-09-01', Source - Publish-Remote Gate and Issue Triage Session 2026-09-01, Source - Private-Instance Merge Correction and Issue 30 Session 2026-09-01, Source - MCP Read Server Implementation Session 2026-09-02, Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||
confidence: 0.90
|
||||
confidence_base: 0.90
|
||||
provenance: mixed
|
||||
|
||||
@@ -7,7 +7,7 @@ modified: 2026-08-29
|
||||
related:
|
||||
- depends-on: Wine
|
||||
- see-also: Proton
|
||||
- see-also: Wine GE
|
||||
- part-of: Wine GE
|
||||
- see-also: Arch Linux
|
||||
sources: [Source - Wine]
|
||||
confidence: 0.85
|
||||
@@ -68,6 +68,6 @@ Wine-Staging-Patches enthalten typischerweise:
|
||||
|
||||
- **depends-on:** [[Wine]]
|
||||
- **see-also:** [[Proton]]
|
||||
- **see-also:** [[Wine GE]]
|
||||
- **part-of:** [[Wine GE]]
|
||||
- **see-also:** [[Arch Linux]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
@@ -8,7 +8,7 @@ related:
|
||||
- implements: Issue Label Scheme
|
||||
- uses: Gitea
|
||||
- uses: Gitea Actions
|
||||
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31]
|
||||
sources: [Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30, Source - Conversation - Issue Triage Labels and TODO Retirement Session 2026-08-31, Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]
|
||||
confidence: 0.70
|
||||
confidence_base: 0.70
|
||||
provenance: sourced
|
||||
|
||||
@@ -5,7 +5,7 @@ tags: [schema, taxonomy, external, farzaa-gist]
|
||||
created: 2026-08-03
|
||||
modified: 2026-08-29
|
||||
related:
|
||||
- see-also: farzaa gist
|
||||
- part-of: farzaa gist
|
||||
- see-also: AGENTS.md
|
||||
- evidenced-by: Source - LLM Improvements Sonnet Analysis
|
||||
sources: [Source - LLM Improvements Sonnet Analysis]
|
||||
@@ -79,7 +79,7 @@ Dies sind handlungsfähige Empfehlungen, die in der Sonnet-Analyse als wertvoll
|
||||
<!-- wikitool:links -->
|
||||
## Beziehungen
|
||||
|
||||
- **see-also:** [[farzaa gist]]
|
||||
- **part-of:** [[farzaa gist]]
|
||||
- **see-also:** [[AGENTS.md]]
|
||||
- **evidenced-by:** [[Source - LLM Improvements Sonnet Analysis]]
|
||||
<!-- /wikitool:links -->
|
||||
|
||||
+4
-4
@@ -13,12 +13,12 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Total Pages:** 180
|
||||
- **Total Pages:** 181
|
||||
- **Comparisons:** 1
|
||||
- **Concepts:** 80
|
||||
- **Entities:** 72
|
||||
- **Sources:** 27
|
||||
- **Last Updated:** 2026-09-02
|
||||
- **Sources:** 28
|
||||
- **Last Updated:** 2026-09-03
|
||||
|
||||
---
|
||||
|
||||
@@ -29,7 +29,7 @@ The page tables live in a generated `INDEX.md` inside each collection, linked be
|
||||
| `comparisons/` | 1 | [comparisons/INDEX.md](comparisons/INDEX.md) |
|
||||
| `concepts/` | 80 | [concepts/INDEX.md](concepts/INDEX.md) |
|
||||
| `entities/` | 72 | [entities/INDEX.md](entities/INDEX.md) |
|
||||
| `sources/` | 27 | [sources/INDEX.md](sources/INDEX.md) |
|
||||
| `sources/` | 28 | [sources/INDEX.md](sources/INDEX.md) |
|
||||
|
||||
### entities/
|
||||
|
||||
|
||||
@@ -121,3 +121,19 @@ Einheit u3 der Link-Taxonomie-Migration: alle 80 Seiten unter kb/concepts/ von P
|
||||
Abschluss der Korpus-Migration auf die Link-Taxonomie (Gitea #40, Abschnitte 2 und 3). Zuvor uebersehene Restmenge nachgeholt: 37 unlabelled edges in kb/entities/technologies und kb/entities/tools, die u1 nach dem damaligen 'protect, don't remove'-Muster bewusst unbelegt gelassen hatte - mit der u3-Erkenntnis, dass xref add nur die Quellseite anfasst, waren sie gefahrlos nachlabelbar (Wine-/Arch-/Agent-CLI-Cliquen ueberwiegend see-also, dazu echte Kanten: AUR part-of Arch Linux, Arch Linux uses GPG, Obsidian hosts Dataview/Marp, Obsidian required-by Obsidian Web Clipper, Wine required-by Proton, gdeploy uses Go). Erst damit erfuellt der Korpus das Abschlusskriterium des Migrationsdokuments (Schritt 6: unlabelled_edges und unauthorised_labels muessen 0 sein) - vorher waere migrate done eine unbelegte Behauptung gewesen. Endstand: lint meldet 0 unlabelled_edges, 0 unauthorised_labels, 0 malformed_edges, 0 unbalanced_markers, 0 broken_links, 0 dangling_frontmatter_refs, 0 schema_validation_errors; lint --fail-on-error und docs verify beide exit 0. migrate done 4.0.0 --pages 153 gesetzt, kb_version steht auf 4.0.0. VERSION stand bereits auf 4.0.0 (Bump erfolgte mit dem Mechanismus in u0, Commit 177c7e9), ein zweiter Bump entfaellt daher, und CHANGES.md dokumentiert 4.0.0 bereits vollstaendig. Workshop work/link-taxonomy-migration/ nach work/CONTRACT.md geschlossen und geloescht; die dauerhafte Ausgabe ist der gelabelte Korpus selbst. Eine Notiz aus glossary.md hat sich beim Abschluss als Rueckschritt erwiesen: die dort als Erkenntnis notierte Asymmetrie zwischen xref add (einseitig) und xref remove (bidirektional) steht seit jeher woertlich in tools/CONTRACT.md Zeilen 43-44 - sie war nachzulesen, nicht zu entdecken. Offen und an #40 gemeldet: im Katalog fehlt ein Label fuer Urheberschaft (Person erstellt Entity oder Concept); alle solchen Kanten stehen jetzt auf see-also.
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-02] update | Issue Label Scheme - Vierachsen-Schema und Body-als-Wahrheit
|
||||
|
||||
Gitea-Issue #41 als raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md aufgenommen und als Source-Seite erfasst. kb/concepts/Issue Label Scheme.md auf den Stand vom 2026-09-02 gebracht: vier Pflicht-Achsen (area/kind/prio/size), zwei optionale status/-Flags, Body-als-Wahrheit-Konvention. Das abgeloeste Zweiachsen-Schema steht als Abschnitt Historie mit Diff-Tabelle in der Seite, nicht geloescht. confidence_base 0.70 -> 0.85 (zweite unabhaengige Quelle, Bestaetigung juenger als 30 Tage).
|
||||
|
||||
---
|
||||
|
||||
## [2026-09-03] update | Link-Taxonomie 4.1.0: 16 see-also-Kanten auf part-of, Comparison-Seite auf gelabelte Kanten
|
||||
|
||||
Befund 3 aus #40: composition/part-of ist jetzt das dritte Inversenpaar. Die 16 Gegenkanten eines composition, die im u3-Lauf auf see-also gesetzt wurden, sind per xref add auf part-of relabelt - betroffen sind Consolidation Tiers, Content Quality Control, Hybrid Search, Knowledge Graph, LLM Wiki Pattern, Memory Lifecycle, Multi-Agent Collaboration, Wine GE und farzaa gist samt ihrer Kinder.
|
||||
|
||||
Befund 1 aus #40: die einzige Comparison-Seite (amd-pstate vs acpi-cpufreq) trug ihre compares-with-Bullets als handgeschriebene Prosa ohne Frontmatter-Deckung, weil types/comparison.md kein related: fuehrte. Der Type-Spec hat es jetzt; die beiden Kanten stehen als deklarierte Kanten in einer wikitool:links-Region. kb/sources/COLLECTION.md hat seinen inerten outbound:-Block verloren.
|
||||
|
||||
migrate verify --from HEAD: 181 Seiten, 0 hinzugefuegt, 0 entfernt, 18 Befunde - alle Label-Wechsel im Frontmatter, keine Aenderung an Wikilink- oder Zitatzahlen. lint --fail-on-error gruen.
|
||||
|
||||
---
|
||||
|
||||
+12
-2
@@ -8,8 +8,8 @@ inline `[^cite-id]` footnote).
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
- **Total raw files:** 26
|
||||
- **Covered:** 26
|
||||
- **Total raw files:** 28
|
||||
- **Covered:** 28
|
||||
- **Uncovered:** 0
|
||||
|
||||
---
|
||||
@@ -106,6 +106,11 @@ inline `[^cite-id]` footnote).
|
||||
- Covered by: [[Source - Conversation - Two Round-Trip Defects Found by an Ingest Session 2026-08-31]]
|
||||
- Cited by: [[Chemenu]], [[Command Round-Trip Integrity]], [[Denylist over Allowlist]], [[Detect-Repair Asymmetry]], [[Gitea]], [[Green Suite Blind Spot]], [[Write-Once Frontmatter Fields]], [[wikitool]]
|
||||
|
||||
### `raw/notes/Conversation Transcript - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02.md`
|
||||
|
||||
- Covered by: [[Source - Version Part Nomenclature and Breaking Change Gate Session 2026-09-02]]
|
||||
- Cited by: [[Chemenu]], [[KB Stack Versioning]], [[wikitool]]
|
||||
|
||||
### `raw/notes/Conversation Transcript - Versioning, CI-CD and Content Migration Session 2026-08-30.md`
|
||||
|
||||
- Covered by: [[Source - Conversation - Versioning CI-CD and Content Migration Session 2026-08-30]]
|
||||
@@ -121,6 +126,11 @@ inline `[^cite-id]` footnote).
|
||||
- Covered by: [[Source - Docker Cheatsheet]]
|
||||
- Cited by: [[Docker]]
|
||||
|
||||
### `raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md`
|
||||
|
||||
- Covered by: [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]]
|
||||
- Cited by: [[Chemenu]], [[Gitea MCP Server]], [[Issue Label Scheme]]
|
||||
|
||||
### `raw/notes/Wine.md`
|
||||
|
||||
- Covered by: [[Source - Wine]]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
---
|
||||
profile: sources
|
||||
outbound:
|
||||
any: [is-evidence-for, defined-in, see-also]
|
||||
required_by_stack: true
|
||||
---
|
||||
|
||||
@@ -40,16 +38,17 @@ The `raw_files:`/`source_url:`/citation rules are shared and live in
|
||||
- `tools/wikitool sources trace --raw <path>` answers "what did we learn from this?";
|
||||
`tools/wikitool sources coverage` lists raw files no source page claims yet.
|
||||
|
||||
## Authorised labels
|
||||
## No authorised labels
|
||||
|
||||
The `outbound:` block above is what `wikitool lint` and `xref add` check: which labels a page in
|
||||
this collection may use, per destination. The catalogue they are drawn from - and what each one
|
||||
asserts - is [instructions/link-taxonomy.md](../../instructions/link-taxonomy.md), which binds
|
||||
nothing on its own.
|
||||
This collection has **no `outbound:` block**, and that is the declaration rather than an
|
||||
omission: the `source` type-spec offers no `related:` field, so a source page has nowhere to
|
||||
put a labelled edge. Everything it would want to assert is already carried by `raw_files:`,
|
||||
`entities:`, `concepts:` and `[^cite-id]` - the mechanical provenance path, not authored edges.
|
||||
|
||||
Deliberately narrow. A source page is evidence *about* a source; almost everything it would want to say is already carried by `raw_files:`, `sources:` and `[^cite-id]`, which are the mechanical provenance path rather than authored edges.
|
||||
|
||||
Adding a label here is a deliberate contract change, not a way around a refusal.
|
||||
An `outbound:` block here would authorise labels that no page in this collection can write.
|
||||
`wikitool docs verify` refuses that combination, so the two cannot drift apart: giving source
|
||||
pages labelled edges means giving the type-spec a `related:` field first, which is a deliberate
|
||||
contract change and not a way around a refusal.
|
||||
|
||||
## Outbound linking
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# kb/sources/ - Index
|
||||
|
||||
27 page(s). Regenerated by `wikitool index rebuild`.
|
||||
28 page(s). Regenerated by `wikitool index rebuild`.
|
||||
|
||||
## All
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
| [[Source - Conversation - Write-Once Frontmatter Fields and touch --set Session 2026-08-31]] | notes | Sitzung, die write-once-Frontmatterfelder reparierbar macht: touch bekommt --set/--add/--remove ueber eine Denylist statt einer Allowlist, ein idempotentes --remove und einen bewusst engen Scope (Stack 1.4.0, Gitea-Issue #14) | 2026-08-31 |
|
||||
| [[Source - Copilot Skill Restructure Instructions]] | notes | Anweisungssatz zur Aufteilung der monolithischen AGENTS.md in einzelne plattformübergreifende Agent-Skills | 2026-08-03 |
|
||||
| [[Source - Docker Cheatsheet]] | notes | Praktisches Bash-Skript zur Fehlersuche bei Docker-Volumes und Overlay2, um den Container zu einem Verzeichnis im Dateisystem zu ermitteln. | 2026-07-31 |
|
||||
| [[Source - Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02]] | notes | Threadkopie zu Gitea-Issue #41: vier Pflicht-Label-Achsen statt zwei, zwei optionale status/-Flags, und der Issue-Body als aktuelle Wahrheit statt als Ursprungstext | 2026-09-02 |
|
||||
| [[Source - LLM Improvements Codex Analysis]] | notes | Codex-Analyse, die AGENTS.md und wikitool mit awesome-llm-wiki und Farzas Gist vergleicht und 7 aussichtsreiche Verbesserungen sowie zu vermeidende Anti-Muster benennt. Hinweis: eine Sonnet-Analyse zum Vergleich ist vorgesehen. | 2026-08-03 |
|
||||
| [[Source - LLM Improvements Production Agent Gaps 2026]] | notes | Externe Kritik (dzone, 2026) am Fehlen harter Iterations- und Kostengrenzen sowie eines Loop-Breakers; umgesetzt als Iteration Budget Gate in wikitool. | 2026-08-07 |
|
||||
| [[Source - LLM Improvements Sonnet Analysis]] | notes | Sonnet-Analyse, die AGENTS.md und wikitool mit Farzas Gist und awesome-llm-wiki vergleicht und die Codex-Analyse um konkrete Empfehlungen zu Qualitätsschwellen, Stilrichtlinie, Auditrhythmus und Skalierung ergänzt | 2026-08-03 |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
type: types/source.md
|
||||
source_type: notes
|
||||
author: Torben Nehmer
|
||||
raw_files: [raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md]
|
||||
source_language: de
|
||||
date: 2026-09-02
|
||||
tags: [issues, gitea, labels, triage, process]
|
||||
entities: [Chemenu, Gitea MCP Server]
|
||||
concepts: [Issue Label Scheme]
|
||||
summary: 'Threadkopie zu Gitea-Issue #41: vier Pflicht-Label-Achsen statt zwei, zwei optionale status/-Flags, und der Issue-Body als aktuelle Wahrheit statt als Ursprungstext'
|
||||
---
|
||||
# Source: Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02
|
||||
|
||||
**Autor:** Torben Nehmer
|
||||
**Datum:** 2026-09-02
|
||||
**Raw-Dateien:** raw/notes/Gitea Issue 41 - Issue Management and Label Scheme 2026-09-02.md
|
||||
**Typ:** Notes
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Wörtliche Kopie des Threads zu Gitea-Issue #41, gezogen am 2026-09-02: Issue-Body im Stand
|
||||
nach der Umsetzung, die drei Changelog-Kommentare und das zu diesem Zeitpunkt in Gitea
|
||||
angelegte Label-Set. Das Issue hält eine Diskussion vom selben Tag fest, die aus der
|
||||
Aufarbeitung von fünf als "Fallouts" des Entwicklungsprozesses eingestuften Issues (#38, #30,
|
||||
#28, #7, #27) hervorging und den Rahmen für deren Bearbeitung setzen sollte.
|
||||
|
||||
Verhandelt wurden drei Dinge. Erstens bleibt ein eingehender Wunsch ein Issue und wird keine
|
||||
`kb/`-Seite, weil Issues ephemer sind und Wünsche abbilden - neu ist daran nur, wie ein Issue
|
||||
gepflegt wird: der Body ist aktuelle Wahrheit und wird umgeschrieben, Kommentare tragen einen
|
||||
Changelog statt einer Vollkopie, und beides macht in der Regel eine LLM-Sitzung. Zweitens
|
||||
werden aus zwei Pflicht-Label-Achsen vier: `area/`, `kind/`, `prio/` und `size/`. Drittens
|
||||
kommen zwei optionale Flags dazu, `status/blocked` und `status/unconfirmed`.
|
||||
|
||||
Die Quelle ist zugleich der Beleg für die Umsetzung: das Issue verzeichnet, dass das
|
||||
Label-Set in Gitea steht und dass das Schema seit Stack-Version `4.0.1` kanonisch in
|
||||
`instructions/dev/issue-tracking.md` liegt.
|
||||
|
||||
## Kernaussagen
|
||||
|
||||
- **Der Issue-Body ist die Lifeline für das Agent-Memory.** Die Umsetzung eines Issues zieht
|
||||
sich über mehrere, zeitlich getrennte LLM-Sitzungen, und der Body ist der einzige Ort, der
|
||||
sie verbindet: eine Sitzung muss allein aus ihm rekonstruieren können, was entschieden und
|
||||
was offen ist. Ein additiv wachsendes Log zwingt dagegen zum Lesen der ganzen Geschichte,
|
||||
um den aktuellen Stand herauszufiltern.
|
||||
- **Kommentare sind Changelog, nicht Kopie.** Ein Volltext-Snapshot des alten Bodys pro
|
||||
Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare
|
||||
Historie, sondern nur eine weitere Kopie. Der Kommentar nennt nur, was neu, entfallen oder
|
||||
korrigiert ist.
|
||||
- **Vier Pflichtachsen sind bezahlbar geworden, weil die Pflege maschinell läuft.** Der
|
||||
ursprüngliche Einwand gegen eine dritte Achse war der Pflegeaufwand für einen einzelnen
|
||||
menschlichen Betreuer; da Body-Rewrites und Labelpflege über eine LLM-Sitzung laufen, trägt
|
||||
er nicht mehr.
|
||||
- **`area/` folgt der Systemgrenze, nicht dem Codeort.** Die Werte `kb`, `distribution`,
|
||||
`corpus`, `workflow`, `process` folgen der Stufenteilung aus `AGENTS.md`. Ein `area/tools`
|
||||
gibt es bewusst nicht - Tooling wird nach der Domäne einsortiert, die es bedient.
|
||||
- **`kind/` darf sich im Lauf eines Issues ändern.** Der Wechsel von `decision` zu `build`,
|
||||
sobald entschieden ist, ist erwünschtes Session-Memory-Verhalten und kein Makel.
|
||||
- **`prio/` wurde nur umbenannt.** `1`/`2`/`3` heißen jetzt `blocking`/`planned`/`waiting`,
|
||||
die Bedeutung ist unverändert. Bei `size/` entfällt `XS`; `S`/`M`/`L` bleiben, wie sie
|
||||
waren.
|
||||
- **Ein unbelegter Verdacht bleibt nicht offen liegen.** Solange `status/unconfirmed` gesetzt
|
||||
ist, sind `size` und `prio` vorläufig. Die Triage endet mit entferntem Flag und
|
||||
verbindlichen Werten oder mit einem geschlossenen Issue samt Begründung - vom Issue selbst
|
||||
als Prozessentsprechung zu Invariante 3 des Stacks bezeichnet.
|
||||
- **Sechzehn Label stehen in Gitea**, gelesen am 2026-09-02: fünf `area/`, drei `kind/`, drei
|
||||
`prio/`, drei `size/`, zwei `status/`. `size/XS`, `prio/1`, `prio/2` und `prio/3`
|
||||
existieren nicht mehr.
|
||||
- **Der Release war ein PATCH.** `4.0.1`, weil `dist export` `instructions/dev/` vollständig
|
||||
ausschließt und sich für eine ausgelieferte Instanz nichts ändert - dieselbe Begründung wie
|
||||
bei `1.2.1`, das das Zweiachsen-Schema eingeführt hatte.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Bestehende Sachissues nach und nach auf die vier Pflicht-Label umstellen (laut Issue
|
||||
bereits umgestellt: #38, #27 geschlossen, #7, #42)
|
||||
|
||||
## Nicht übernommen
|
||||
|
||||
- **Die Diskussion, aus der das Issue hervorging.** Das Issue nennt sie ("Diskussion vom
|
||||
2026-09-02"), aber ihr Transkript liegt nicht in `raw/`. Die Quelle ist damit das Ergebnis
|
||||
der Debatte, nicht ihr Verlauf; die verworfenen Alternativen sind nicht rekonstruierbar.
|
||||
- **Die Sachinhalte der referenzierten Issues #38, #30, #28, #27, #7, #42, #39, #40.** Sie
|
||||
kommen im Thread nur als Nummern vor. Was in ihnen steht, gehört in die Seiten zu den
|
||||
jeweiligen Gegenständen, nicht hierher.
|
||||
- **Der Volltext von `instructions/dev/issue-tracking.md`.** Das Issue beschreibt, was dort
|
||||
hineingeschrieben wurde; die Instruction selbst ist Teil des Stacks und kein Rohmaterial.
|
||||
|
||||
## Verwandte Entities
|
||||
|
||||
- [[Chemenu]]
|
||||
- [[Gitea MCP Server]]
|
||||
|
||||
## Verwandte Concepts
|
||||
|
||||
- [[Issue Label Scheme]]
|
||||
@@ -0,0 +1,111 @@
|
||||
# Gitea Issue #41 — Issue-Management: Label-Schema und Body-als-Wahrheit-Konvention
|
||||
|
||||
Wörtliche Kopie des Issue-Threads von <https://gitea.nehmer.net/torben/chemenu/issues/41>,
|
||||
gezogen am 2026-09-02 nach dem Body-Rewrite, der die Umsetzung in `4.0.1` festhält. Autor
|
||||
aller Beiträge: torben. Erstellt 2026-09-02T20:48:56Z, zuletzt geändert 2026-09-02T21:13:11Z.
|
||||
|
||||
Labels zum Zeitpunkt der Kopie: `area/process`, `kind/build`, `prio/blocking`, `size/M`.
|
||||
Status: offen.
|
||||
|
||||
---
|
||||
|
||||
## Issue-Body (Stand 2026-09-02T21:13:11Z)
|
||||
|
||||
## Kontext
|
||||
|
||||
Bündelt die Ergebnisse einer Diskussion am 2026-09-02 über den Entwicklungsprozess dieses Repos, ausgehend von den Fallouts in #38, #30, #28, #7, #27. Bewusst das erste Issue, das umgesetzt wird - es setzt den Rahmen für die Bearbeitung aller anderen.
|
||||
|
||||
**Stand:** Label-Set steht in Gitea, das Schema ist seit `4.0.1` kanonisch in `instructions/dev/issue-tracking.md` (Commit `c8c2385`). Offen ist nur noch die Relabelung der verbliebenen Sachissues und eine `kb/`-Seite, die noch das alte Schema beschreibt.
|
||||
|
||||
## Entscheidung 1: Issues bleiben Storage für eingehende Specs, mit schärferer Pflege
|
||||
|
||||
Ein eingehender Wunsch/Requirement bleibt Issue, nicht `kb/`-Seite - Issues sind ephemer und bilden Wünsche ab, `kb/` bildet verifiziertes, dauerhaftes Wissen ab (unverändert gegenüber `instructions/dev/issue-tracking.md`).
|
||||
|
||||
**Motivation für die verschärfte Pflege:** Die Umsetzung der hier verhandelten Issues zieht sich über mehrere, oft zeitlich getrennte LLM-Sitzungen. Der Issue-Body ist der einzige Ort, der diese Sitzungen verbindet - er ist die Lifeline für das Agent-Memory. Eine Sitzung, die ein Issue neu öffnet, muss allein aus dem Body rekonstruieren können, was entschieden ist und was noch offen ist, ohne dass ein Mensch den Kontext erneut vorkaut. Ein additiv wachsendes Log zwingt zum Lesen der ganzen Geschichte, um den aktuellen Stand herauszufiltern - ein aktuell gehaltener Body liefert ihn direkt. Das ist der eigentliche Grund für die folgenden drei Regeln, nicht Ordnung um der Ordnung willen.
|
||||
|
||||
- **Body = aktuelle Wahrheit.** Der Body wird aktiv umgeschrieben, wenn sich der Stand ändert - kein additives Anhängen an einen veralteten Ursprungstext.
|
||||
- **Kommentare = Changelog, nicht Kopie.** Beim Body-Rewrite wird kein Volltext-Snapshot des alten Stands als Kommentar gesichert, sondern ein kurzer Changelog-Eintrag, der nur benennt, was sich gegenüber dem vorherigen Stand geändert hat - neu, entfallen, korrigiert. Eine Vollkopie pro Revision zwingt einen Menschen zum Diffen zweier Fließtexte und ist damit keine lesbare Historie, sondern nur eine weitere Kopie.
|
||||
- **Bearbeitung primär durch LLM.** Menschen fassen in der Regel nur Labels/Metadaten direkt an; Body-Rewrites und Kommentare laufen über eine LLM-Sitzung.
|
||||
|
||||
## Entscheidung 2: Vier Pflicht-Label-Familien statt zwei
|
||||
|
||||
| Familie | Werte | Bedeutung |
|
||||
|---|---|---|
|
||||
| `area/` | `kb`, `distribution`, `corpus`, `workflow`, `process` | Welche Systemgrenze betroffen ist, entlang der bestehenden Stufenteilung aus `AGENTS.md` (kein `area/tools` - Tooling wird nach der Domäne eingeordnet, die es bedient, nicht nach Codeort) |
|
||||
| `size/` | `S`, `M`, `L` (verdichtet von vier auf drei Stufen, `XS` entfällt) | Aufwand, unverändert in der Bedeutung von `S`/`M`/`L` |
|
||||
| `prio/` | `blocking`, `planned`, `waiting` (Umbenennung von `1`/`2`/`3`, Bedeutung unverändert) | Dringlichkeit, weiterhin fließend zu handhaben |
|
||||
| `kind/` | `decision`, `build`, `defect` | Art der Offenheit: wartet auf eine Betreiberentscheidung, ist spezifiziert und wartet auf Umsetzungszeit, oder ist ein Befund über einen Widerspruch. Darf sich im Lauf eines Issues ändern (z.B. `decision` → `build`, sobald entschieden) - das ist erwünschtes Session-Memory-Verhalten, kein Makel |
|
||||
|
||||
Alle vier sind Pflicht auf jedem offenen Issue, weil maschinelle Pflege durch das LLM den ursprünglichen Einwand gegen eine dritte/vierte Achse (Pflegeaufwand für einen einzelnen Menschen) entkräftet.
|
||||
|
||||
## Entscheidung 3: Zwei optionale Status-Flags
|
||||
|
||||
- `status/blocked` - wartet auf ein anderes, noch offenes Issue; unabhängig vom `prio`-Wert nicht eigenständig bearbeitbar. Nicht mandatory, weil es eine Beziehung zwischen Issues abbildet, keine Eigenschaft eines einzelnen.
|
||||
- `status/unconfirmed` - gemeldeter Verdacht, noch nicht gegen tatsächliches Verhalten geprüft. Gilt für jeden `kind`-Wert, nicht nur `defect`. Solange gesetzt, sind `size` und `prio` vorläufig. Nach Triage: Flag entfernt und `size`/`prio` verbindlich gesetzt, oder Issue mit Begründung geschlossen (kein unbelegter Verdacht bleibt offen liegen - Analogie zu Invariante 3 des Stacks, nur auf Prozessebene).
|
||||
|
||||
## Migration der bestehenden Labels
|
||||
|
||||
`prio/1` → `prio/blocking`, `prio/2` → `prio/planned`, `prio/3` → `prio/waiting` (reine Umbenennung). `size/XS` entfällt, `size/S`/`M`/`L` bleiben unverändert. `area/*`, `kind/*`, `status/*` sind neu. **Erledigt** - alle 16 Label stehen in Gitea.
|
||||
|
||||
## Umgesetzt in `4.0.1`
|
||||
|
||||
`instructions/dev/issue-tracking.md` ist auf das Schema umgeschrieben: Schritt 2 (Body als aktuelle Wahrheit inkl. Mehrsitzungs-Begründung), Schritt 3 (Changelog-Kommentar statt Vollkopie, mit Beispiel), Schritt 4 (alle vier Pflichtachsen als vier Tabellen), Schritt 5 (die beiden `status/`-Flags und der Triage-Ausgang), Schritt 6 (Re-Labeling schließt `kind/`-Wechsel ein). Der Entscheidungspunkt „Two labels feel too coarse?" ist entfallen, an seine Stelle treten zwei neue („Rewrite the body, or add a comment?" und der Umgang mit Altissues, die nur zwei Label tragen). Die Beschreibungszeile in `instructions/dev/stack-dev/SKILL.md` nennt jetzt die vier Achsen und die Body-Konvention.
|
||||
|
||||
PATCH und nicht MINOR, weil `dist export` `instructions/dev/` vollständig ausschließt - für eine ausgelieferte Instanz ändert sich nichts. Gleiche Begründung wie bei `1.2.1`, das das ursprüngliche Zweiachsen-Schema eingeführt hat.
|
||||
|
||||
## Nicht in diesem Issue
|
||||
|
||||
Die Relabelung der bestehenden Sachissues erfolgt in deren jeweiligen Einzel-Sitzungen. Bereits umgestellt: #38, #27 (geschlossen), #7, #42.
|
||||
|
||||
`kb/concepts/Issue Label Scheme.md` beschreibt weiterhin das zweiachsige Schema von 2026-08-31 (`prio/1..3`, `size/XS..L`, „keine dritte Achse") und ist damit veraltet. Die Aktualisierung ist ein `kb/`-Schreibzugriff und braucht eine `wiki-manage`-Sitzung mit ordentlicher Quelle - nicht Teil dieses Issues.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [x] Label-Set in Gitea angelegt/umbenannt (dieses Issue)
|
||||
- [x] `instructions/dev/issue-tracking.md` in einer stack-dev-Sitzung um dieses Schema ergänzt (`4.0.1`, Commit `c8c2385`)
|
||||
- [ ] Bestehende Sachissues nach und nach auf die vier Pflicht-Label umgestellt
|
||||
- [ ] `kb/concepts/Issue Label Scheme.md` in einer `wiki-manage`-Sitzung nachgezogen
|
||||
- [ ] Dieses Issue dient bis dahin als Referenz für das Schema
|
||||
|
||||
## Vorgeschichte
|
||||
|
||||
Entschieden in der Diskussion vom 2026-09-02, im Rahmen einer Aufarbeitung von #38, #30, #28, #7, #27 als "Fallouts" des Entwicklungsprozesses. #39 und #40 liefen parallel und unabhängig, nicht Teil dieser Debatte.
|
||||
|
||||
---
|
||||
|
||||
## Kommentar 1 (2026-09-02T20:57:51Z, issuecomment-512)
|
||||
|
||||
**Changelog:** Entscheidung 1, dritter Punkt korrigiert. Vorher: "Kommentare = Historie", der bisherige Body-Stand wird beim Umschreiben vollständig als Kommentar gesichert. Jetzt: "Kommentare = Changelog, nicht Kopie" - ein Kommentar nennt nur, was sich geändert hat, keine Volltextkopie des alten Bodys. Grund: eine Vollkopie pro Revision ist für einen Menschen nicht diffbar und damit keine brauchbare Historie.
|
||||
|
||||
## Kommentar 2 (2026-09-02T21:07:47Z, issuecomment-539)
|
||||
|
||||
**Changelog:** Entscheidung 1 um Motivationsabsatz ergänzt (Body als Lifeline für Agent-Memory über mehrere Sitzungen hinweg, nicht Ordnung um der Ordnung willen). Akzeptanzkriterium 1 abgehakt, Referenzliste der bereits umgestellten Issues (#38, #27, #7, #42) ergänzt.
|
||||
|
||||
## Kommentar 3 (2026-09-02T21:13:19Z, issuecomment-545)
|
||||
|
||||
**Changelog:** Akzeptanzkriterium 2 abgehakt - das Schema ist mit `4.0.1` (Commit `c8c2385`) kanonisch in `instructions/dev/issue-tracking.md`. Neu: Stand-Zeile im Kontext, Abschnitt „Umgesetzt in `4.0.1`" (was genau in der Instruction steht, und warum PATCH), Vermerk „Erledigt" an der Label-Migration. Neu als offener Punkt und als fünftes Akzeptanzkriterium: `kb/concepts/Issue Label Scheme.md` beschreibt noch das Zweiachsen-Schema und braucht eine eigene `wiki-manage`-Sitzung. Entfallen: der Absatz „Die eigentliche Textänderung ... braucht eine separate stack-dev-Sitzung" - genau die ist jetzt gelaufen.
|
||||
|
||||
---
|
||||
|
||||
## Label-Set in Gitea (gelesen 2026-09-02, `label_read list_repo_labels`)
|
||||
|
||||
| Label | Beschreibung |
|
||||
|---|---|
|
||||
| `area/kb` | Betrifft kb/-Schema, Contract, Confidence, Lint, Wissensbasis |
|
||||
| `area/distribution` | Betrifft Auslieferung, Upgrade, Versionierung einer Instanz |
|
||||
| `area/corpus` | Betrifft Demo-/Testbett-Frage, Inhalt und Umfang von kb/ |
|
||||
| `area/workflow` | Betrifft Git, Merge, Branching, Publish, PRs |
|
||||
| `area/process` | Betrifft den Entwicklungsprozess selbst, nicht den Stack als Artefakt |
|
||||
| `kind/decision` | Wartet auf eine Betreiberentscheidung |
|
||||
| `kind/build` | Spezifiziert, wartet nur noch auf Umsetzungszeit |
|
||||
| `kind/defect` | Befund: Doku und Realitaet, oder zwei Dokus, widersprechen sich |
|
||||
| `prio/blocking` | Blockiert oder beschaedigt laufende Arbeit - als naechstes |
|
||||
| `prio/planned` | Traegt bald Zinsen - eingeplant |
|
||||
| `prio/waiting` | Sinnvoll, wartet auf einen Ausloeser |
|
||||
| `size/S` | Eine Sitzung, ein Publish, klar umrissener Schnitt |
|
||||
| `size/M` | Mehrere Dateien, Contract- oder Instruction-Aenderung, eigener Testaufwand |
|
||||
| `size/L` | Mehrere Sitzungen oder offene Designfragen vor dem ersten Commit |
|
||||
| `status/blocked` | Wartet auf ein anderes, noch offenes Issue - nicht eigenstaendig bearbeitbar |
|
||||
| `status/unconfirmed` | Gemeldeter Verdacht, noch nicht gegen tatsaechliches Verhalten geprueft - size/prio vorlaeufig |
|
||||
|
||||
Sechzehn Label. `size/XS`, `prio/1`, `prio/2` und `prio/3` existieren nicht mehr.
|
||||
+7
-5
@@ -50,7 +50,7 @@ tools/wikitool <command> --help
|
||||
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
|
||||
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
|
||||
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
|
||||
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
|
||||
@@ -68,14 +68,15 @@ 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 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) |
|
||||
| `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). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||
| `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/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `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`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
||||
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), 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`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
|
||||
| `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 |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise `VERSION` and open the matching `CHANGES.md` entry - heading, date and author only; the body stays the author's to write, the way `new` writes frontmatter and leaves the prose. Refuses more or fewer than one part, an empty title, and a changelog already documenting a version that is not older than the new one. Compatibility follows the **leftmost non-zero component**, which for this stack (at `1.0.0` and up, no pre-release suffixes anywhere) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. A MAJOR bump therefore requires `--breaking "<what stops working>"`, which is refused on any other part, and on top of it a migration document targeting the new version or `--no-migration "<reason>"`; both are recorded in the entry. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
|
||||
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
|
||||
| `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, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. 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 |
|
||||
@@ -191,7 +192,8 @@ is atomic, and whether a retry is safe.
|
||||
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
|
||||
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
|
||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, a changelog already documenting a version not older than the new one, a boundary-crossing bump without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run bumps again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, or `--breaking`/`--no-migration` on a bump that crosses nothing | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), or `VERSION` and the changelog's newest entry naming different versions | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
|
||||
| `migrate list` / `migrate status` | `list` never fails; `status` exits 1 when `.wikitool-kb.json` is missing or unreadable, or `VERSION` is | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
|
||||
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
|
||||
|
||||
@@ -11,6 +11,14 @@ Keeping the undecayed anchor in `confidence_base` is what makes repeated runs
|
||||
idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
|
||||
behavior) compounded on every run, because the elapsed-months factor kept
|
||||
growing while the multiplicand had already shrunk.
|
||||
|
||||
Pages with `concept_type: decision` are skipped structurally, not as an
|
||||
interim measure. The formula models staleness - a claim that nobody has
|
||||
re-checked in a while becomes less trustworthy - and a decision is not a
|
||||
claim about the world that time can falsify. What retires a decision is a
|
||||
later decision superseding it, never elapsed months on its own; that is a
|
||||
category the decay formula does not have a term for, so it does not apply
|
||||
one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -118,6 +126,8 @@ def confidence_decay(
|
||||
missing_base = []
|
||||
|
||||
for title, page in sorted(pages.items()):
|
||||
if page.frontmatter.get("concept_type") == "decision":
|
||||
continue
|
||||
confidence = page.frontmatter.get("confidence")
|
||||
if confidence is None:
|
||||
continue
|
||||
|
||||
@@ -73,6 +73,15 @@ DIST_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "dist_templates"
|
||||
# read server is part of what an instance *has*, even though its dependency is
|
||||
# optional. A distribution whose server is present but undocumented is one
|
||||
# whose operator finds the module by reading the source.
|
||||
#
|
||||
# `DEVELOPMENT.md` is deliberately **absent** from this tuple, unlike every
|
||||
# other root doc above. It documents the release workflow (`version bump` ->
|
||||
# `version release` -> `publish` -> CI tags) and points at `instructions/dev/`,
|
||||
# which this same function excludes wholesale a few lines down - a distributed
|
||||
# instance has no release workflow, no CI and no issue board, so it has
|
||||
# nothing for that document to describe. Do not "fix" this by adding it back:
|
||||
# a root file absent from ROOT_FILES is silently skipped by every export, and
|
||||
# that silence is the correct behaviour here, not a gap.
|
||||
ROOT_FILES = (
|
||||
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
|
||||
".gitignore", "VERSION",
|
||||
@@ -347,6 +356,12 @@ 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()))
|
||||
|
||||
# docs/ is stack background - why the stack is built the way it is - and
|
||||
# ships verbatim like instructions/ and types/: it carries no page, no
|
||||
# frontmatter, and (AGENTS.md § File naming) no normative sentence, so
|
||||
# there is nothing instance-owned in it to split off as a .template.
|
||||
plan.update(_copy_tree(config.ROOT / "docs", "docs", 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
|
||||
@@ -394,8 +409,14 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
|
||||
# machinery expects - which is exactly what makes the initial declaration
|
||||
# safe to write here rather than leaving it to `migrate baseline`. Only an
|
||||
# instance predating this file has to answer that question by hand.
|
||||
#
|
||||
# `.base`, not the raw `VERSION`: a content shape has no beta channel
|
||||
# (`kb_state.read_kb_version` refuses one), so exporting mid-candidate
|
||||
# still declares the release the content is shaped for, not the candidate
|
||||
# in progress. The stamp below carries the honest, suffix-inclusive value -
|
||||
# the two files answer different questions.
|
||||
plan[kb_state.KB_STATE_FILENAME] = PlannedFile(
|
||||
kb_state.render_kb_state(version_mod.read_version(), [])
|
||||
kb_state.render_kb_state(version_mod.read_version().base, [])
|
||||
)
|
||||
|
||||
# Last, so it can digest everything above it. It is the one file in the
|
||||
@@ -492,7 +513,8 @@ def export_command(
|
||||
):
|
||||
"""Export a contentless, distributable copy of this repo's machinery:
|
||||
AGENTS.md/README.md (dev-instance-only marker blocks removed),
|
||||
instructions/ (no instructions/dev/), types/, tools/ (no venv/caches),
|
||||
instructions/ (no instructions/dev/), types/, docs/ verbatim,
|
||||
tools/ (no venv/caches),
|
||||
the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
|
||||
kb/CONTRACT.md plus a COLLECTION.md.template per collection and
|
||||
kb/CONVENTIONS.md.template (no pages, no areas), empty
|
||||
|
||||
@@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]:
|
||||
|
||||
This is the check that makes `version bump` more than a convenience: a
|
||||
version raised with nothing written about it would ship a release whose
|
||||
notes describe the previous one. A changelog with *no* versioned entry at
|
||||
all is fine - that is a fresh distribution, and this repo's own pre-
|
||||
versioning history, neither of which claims to describe the current
|
||||
version.
|
||||
notes describe the previous one. `VERSION` may name a running candidate
|
||||
(`-beta.N`) rather than a release - `Version.parse`/equality read the
|
||||
suffix like any other component, so a candidate is compared exactly like a
|
||||
release here. A changelog with *no* versioned entry at all is fine - that
|
||||
is a fresh distribution, and this repo's own pre-versioning history,
|
||||
neither of which claims to describe the current version.
|
||||
"""
|
||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||
if not version_path.is_file():
|
||||
@@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _second_changes_version(text: str) -> Optional["version_mod.Version"]:
|
||||
"""The version named by the second-newest versioned entry, or None."""
|
||||
seen = [
|
||||
version_mod.Version.parse(match.group(1))
|
||||
for match in version_mod._CHANGES_ENTRY_RE.finditer(text)
|
||||
]
|
||||
return seen[1] if len(seen) > 1 else None
|
||||
|
||||
|
||||
def check_migration_for_boundary() -> list[str]:
|
||||
"""A version that crosses the compatibility boundary must say how to cross it.
|
||||
|
||||
@@ -501,9 +494,12 @@ def check_migration_for_boundary() -> list[str]:
|
||||
document targeting it, or an explicit statement in its changelog entry that
|
||||
no content has to change.
|
||||
|
||||
Only the newest entry is checked. Older boundaries were either satisfied
|
||||
when they were written or cannot be fixed retroactively, and re-reporting
|
||||
them forever would make the check noise.
|
||||
Only the newest entry is checked, against the **last release** rather than
|
||||
the entry beneath it - between two candidates of the same running upgrade
|
||||
(`4.4.0-beta.2` above `4.4.0-beta.1`) there is no boundary at all, and
|
||||
comparing to the entry beneath would find none even when the candidate
|
||||
genuinely crosses one relative to what is actually installed anywhere. See
|
||||
instructions/dev/version-parts.md.
|
||||
"""
|
||||
from chemenu import kb_state
|
||||
|
||||
@@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]:
|
||||
|
||||
text = changes_path.read_text(encoding="utf-8")
|
||||
current = version_mod.top_changes_version(text)
|
||||
previous = _second_changes_version(text)
|
||||
previous = version_mod.last_release(text)
|
||||
if current is None or previous is None:
|
||||
return [] # the first versioned entry has no predecessor to cross from
|
||||
return [] # no release recorded yet to cross from (fresh distribution)
|
||||
if current.compat_key == previous.compat_key:
|
||||
return []
|
||||
|
||||
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
|
||||
return []
|
||||
if any(m.target == current for m in kb_state.load_migrations()):
|
||||
if any(m.target == current.base for m in kb_state.load_migrations()):
|
||||
return []
|
||||
|
||||
return [
|
||||
@@ -544,8 +540,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
import name or flag - satisfies that check and still leaves every existing
|
||||
instance with something to do by hand.
|
||||
|
||||
Only the newest entry is checked, for the same reason: older crossings are
|
||||
history, and re-reporting them forever would make the check noise.
|
||||
Only the newest entry is checked, against the **last release** - see
|
||||
`check_migration_for_boundary` for why the entry beneath it is the wrong
|
||||
comparison once a candidate can span more than one bump.
|
||||
"""
|
||||
changes_path = config.ROOT / version_mod.CHANGES_FILENAME
|
||||
version_path = config.ROOT / version_mod.VERSION_FILENAME
|
||||
@@ -554,9 +551,9 @@ def check_breaking_change_for_boundary() -> list[str]:
|
||||
|
||||
text = changes_path.read_text(encoding="utf-8")
|
||||
current = version_mod.top_changes_version(text)
|
||||
previous = _second_changes_version(text)
|
||||
previous = version_mod.last_release(text)
|
||||
if current is None or previous is None:
|
||||
return [] # the first versioned entry has no predecessor to cross from
|
||||
return [] # no release recorded yet to cross from (fresh distribution)
|
||||
if current.compat_key == previous.compat_key:
|
||||
return []
|
||||
|
||||
|
||||
@@ -383,7 +383,8 @@ def check_stack_version() -> Check:
|
||||
)
|
||||
|
||||
origin = "development tree" if stamp is None else f"distribution, exported {stamp.get('exported_at', 'unknown')}"
|
||||
return Check("stack-version", "OK", f"{current} ({origin})")
|
||||
candidate = " - a running pre-release candidate, not yet fixed by `version release`" if current.is_prerelease else ""
|
||||
return Check("stack-version", "OK", f"{current} ({origin}){candidate}")
|
||||
|
||||
|
||||
def check_kb_version() -> Check:
|
||||
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
|
||||
"never lagged behind its machinery",
|
||||
)
|
||||
if kb_version < stack:
|
||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack)
|
||||
pending = kb_state.chain(kb_state.load_migrations(), kb_version, stack.base)
|
||||
if pending:
|
||||
return Check(
|
||||
"kb-version", "WARN",
|
||||
|
||||
@@ -16,10 +16,12 @@ from chemenu import config
|
||||
from chemenu.commands._util import rel_path, success
|
||||
from chemenu.lint_core import (
|
||||
HARD_ERROR_KEYS,
|
||||
MIGRATION_GATED_KEYS,
|
||||
MOST_LINKED_COUNT,
|
||||
QUOTE_LIMIT,
|
||||
count_quote_blocks,
|
||||
default_report_path,
|
||||
hard_error_keys,
|
||||
has_hard_errors,
|
||||
render_markdown,
|
||||
render_summary,
|
||||
@@ -30,10 +32,12 @@ from chemenu.lint_core import (
|
||||
# so does every other name the tests and sibling commands already import.
|
||||
__all__ = [
|
||||
"HARD_ERROR_KEYS",
|
||||
"MIGRATION_GATED_KEYS",
|
||||
"MOST_LINKED_COUNT",
|
||||
"QUOTE_LIMIT",
|
||||
"count_quote_blocks",
|
||||
"default_report_path",
|
||||
"hard_error_keys",
|
||||
"has_hard_errors",
|
||||
"render_markdown",
|
||||
"render_summary",
|
||||
|
||||
@@ -160,7 +160,7 @@ def status_command(
|
||||
)
|
||||
return
|
||||
|
||||
pending = kb_state.chain(migrations, kb_version, stack)
|
||||
pending = kb_state.chain(migrations, kb_version, stack.base)
|
||||
offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
|
||||
divergent = kb_state.divergent_files()
|
||||
|
||||
@@ -271,7 +271,7 @@ def done_command(
|
||||
)
|
||||
return
|
||||
|
||||
expected = kb_state.next_link(migrations, kb_version, stack)
|
||||
expected = kb_state.next_link(migrations, kb_version, stack.base)
|
||||
if expected is None:
|
||||
fail(
|
||||
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
|
||||
@@ -297,7 +297,7 @@ def done_command(
|
||||
return
|
||||
|
||||
kb_state.write_kb_state(target, applied)
|
||||
remaining = kb_state.chain(migrations, target, stack)
|
||||
remaining = kb_state.chain(migrations, target, stack.base)
|
||||
success(
|
||||
f"Content is now {target} ({expected.name}). "
|
||||
+ (
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""`wikitool version` - report, bump, and check the stack's version.
|
||||
"""`wikitool version` - report, bump, release, and check the stack's version.
|
||||
|
||||
Three jobs that all hang off one number (see `chemenu/version.py` for what
|
||||
that number means):
|
||||
Four jobs that all hang off one number (see `chemenu/version.py` for what that
|
||||
number means, and `instructions/dev/version-parts.md` for the candidate model):
|
||||
|
||||
- `version show` answers "which stack is this instance running", offline, from
|
||||
`VERSION` plus the release stamp `dist export` writes.
|
||||
- `version bump` moves it, and writes the changelog *heading* that has to
|
||||
accompany the move - the same structure-by-tool/prose-by-author split as
|
||||
`new`. `docs verify` then holds the two together.
|
||||
- `version bump` raises or continues the one running candidate between two
|
||||
releases, and writes the changelog *heading* that has to accompany it - the
|
||||
same structure-by-tool/prose-by-author split as `new`. `docs verify` then
|
||||
holds the two together.
|
||||
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
||||
closes its changelog entry. It is the only thing that turns a candidate into
|
||||
a number a release actually consumes.
|
||||
- `version check` is the one command in `wikitool` that makes a network call.
|
||||
It is deliberately its own command: nothing else reaches for it implicitly,
|
||||
it needs no key, it times out, and a feed that cannot be reached is reported
|
||||
@@ -188,34 +192,36 @@ def bump_command(
|
||||
major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
|
||||
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
|
||||
patch: bool = typer.Option(False, "--patch", help="Bump PATCH"),
|
||||
title: str = typer.Option(..., "--title", help="One-line title for the new CHANGES.md entry"),
|
||||
title: str = typer.Option(..., "--title", help="One-line title for the new/updated CHANGES.md entry"),
|
||||
breaking: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--breaking",
|
||||
help="What stops working, for a boundary-crossing bump (recorded in CHANGES.md). Required on one, refused on any other",
|
||||
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
|
||||
),
|
||||
no_migration: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--no-migration",
|
||||
help="Why this boundary-crossing bump needs no content migration (recorded in CHANGES.md)",
|
||||
help="Why the escalation to a boundary crossing needs no content migration (recorded in CHANGES.md)",
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||
):
|
||||
"""Raise the stack version and open its `CHANGES.md` entry.
|
||||
"""Raise or continue the running candidate, and open or update its
|
||||
`CHANGES.md` entry.
|
||||
|
||||
Writes `VERSION` and inserts the entry's heading, date and author - the
|
||||
entry's body stays the author's to write, the same way `new` produces
|
||||
frontmatter and leaves the prose. `docs verify` afterwards enforces that
|
||||
the two agree, so a bump with no entry cannot reach a release.
|
||||
Between two releases the stack carries **one** candidate, not a fresh
|
||||
number per bump: `--patch/--minor/--major` is max-wins escalation against
|
||||
the last release, never a step back down, and the candidate's bump count
|
||||
(`-beta.N`) advances either way. See
|
||||
`instructions/dev/version-parts.md` for the full model, and
|
||||
`version release` for what fixes a candidate into a release.
|
||||
|
||||
A bump that crosses the compatibility boundary - one whose new version is
|
||||
not a drop-in replacement, whether or not any content moves - requires
|
||||
`--breaking "<what stops working>"`, and on top of that either a migration
|
||||
document for the new version or `--no-migration "<reason>"`. An instance
|
||||
learning that it must migrate, with nothing telling it what broke or how to
|
||||
cross, is the gap these close. Which part to pass stays a judgment call
|
||||
this command does not make - it enforces only that a crossing says what it
|
||||
costs."""
|
||||
A bump whose escalation first crosses the compatibility boundary - the new
|
||||
version is not a drop-in replacement, whether or not any content moves -
|
||||
requires `--breaking "<what stops working>"`, and on top of that either a
|
||||
migration document for the new base or `--no-migration "<reason>"`. Both
|
||||
lines are written into the entry once and then persist across every later
|
||||
bump at the same stage: a follow-up bump need not repeat them, and passing
|
||||
either on a bump that crosses nothing at all is refused."""
|
||||
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
|
||||
if len(selected) != 1:
|
||||
fail("Pass exactly one of --major / --minor / --patch")
|
||||
@@ -226,7 +232,6 @@ def bump_command(
|
||||
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
new_version = current.bumped(selected[0])
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
@@ -236,19 +241,29 @@ def bump_command(
|
||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
|
||||
return
|
||||
text = changes.read_text(encoding="utf-8")
|
||||
existing = version_mod.top_changes_version(text)
|
||||
if existing is not None and existing >= new_version:
|
||||
|
||||
top_entry = version_mod.top_changes_version(text)
|
||||
if top_entry is not None and top_entry != current:
|
||||
fail(
|
||||
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older "
|
||||
f"than {new_version} - bump past it, or fix the changelog"
|
||||
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
|
||||
"Fix whichever is wrong."
|
||||
)
|
||||
return
|
||||
|
||||
last_release = version_mod.last_release(text)
|
||||
new_version = version_mod.escalate(last_release, current, selected[0])
|
||||
|
||||
author = config.default_author() or "unknown"
|
||||
crossing = new_version.compat_key != current.compat_key
|
||||
crossing = last_release is not None and new_version.compat_key != last_release.compat_key
|
||||
was_already_crossing = (
|
||||
last_release is not None
|
||||
and current.is_prerelease
|
||||
and current.compat_key != last_release.compat_key
|
||||
)
|
||||
boundary = " (crosses a compatibility boundary - instances must migrate)" if crossing else ""
|
||||
|
||||
if crossing and not breaking:
|
||||
if crossing and not was_already_crossing and not breaking:
|
||||
fail(
|
||||
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a "
|
||||
f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
|
||||
@@ -265,14 +280,14 @@ def bump_command(
|
||||
)
|
||||
return
|
||||
|
||||
if crossing and not no_migration:
|
||||
if crossing and not was_already_crossing and not no_migration:
|
||||
from chemenu import kb_state
|
||||
|
||||
if not any(m.target == new_version for m in kb_state.load_migrations()):
|
||||
if not any(m.target == new_version.base for m in kb_state.load_migrations()):
|
||||
fail(
|
||||
f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
|
||||
f"instance must migrate - but no migration document targets {new_version}.\n"
|
||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version}-<slug>.md "
|
||||
f"instance must migrate - but no migration document targets {new_version.base}.\n"
|
||||
f"Write one under {rel_path(kb_state.migrations_dir())}/{new_version.base}-<slug>.md "
|
||||
f"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
|
||||
f're-run with --no-migration "<reason>".'
|
||||
)
|
||||
@@ -298,6 +313,76 @@ def bump_command(
|
||||
encoding="utf-8",
|
||||
)
|
||||
success(
|
||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened "
|
||||
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing."
|
||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
|
||||
f"`version release` once the candidate is ready to ship."
|
||||
)
|
||||
|
||||
|
||||
@app.command("release")
|
||||
def release_command(
|
||||
title: Optional[str] = typer.Option(
|
||||
None, "--title", help="Replace the entry's heading title (default: the last bump's)"
|
||||
),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the change without writing"),
|
||||
):
|
||||
"""Fix the running candidate: strip its `-beta.N` suffix and close its
|
||||
`CHANGES.md` entry.
|
||||
|
||||
Ends the pre-release phase this checkout has been in since its last
|
||||
`version bump` - the candidate's base becomes the release. Without
|
||||
`--title` the heading keeps whichever bump last set it; with it, the
|
||||
heading gets a summarising title instead, which is the normal case for a
|
||||
candidate that collected several bump titles along the way. The
|
||||
machine-managed list of those titles is left in the entry as the record of
|
||||
what happened, not replaced.
|
||||
|
||||
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||
Refuses when `VERSION` is already a release: there is no running candidate
|
||||
to fix."""
|
||||
try:
|
||||
current = version_mod.read_version()
|
||||
except VersionError as exc:
|
||||
fail(str(exc))
|
||||
return
|
||||
|
||||
if not current.is_prerelease:
|
||||
fail(
|
||||
f"{version_mod.VERSION_FILENAME} is already {current}, a release - there is no running "
|
||||
"candidate to fix. `version release` only ends a pre-release phase that `version bump` "
|
||||
"started."
|
||||
)
|
||||
return
|
||||
|
||||
changes = version_mod.changes_file()
|
||||
if not changes.is_file():
|
||||
fail(f"{version_mod.CHANGES_FILENAME} is missing - the candidate has nowhere to be fixed")
|
||||
return
|
||||
text = changes.read_text(encoding="utf-8")
|
||||
|
||||
top_entry = version_mod.top_changes_version(text)
|
||||
if top_entry != current:
|
||||
fail(
|
||||
f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
|
||||
f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a release. "
|
||||
"Fix whichever is wrong."
|
||||
)
|
||||
return
|
||||
|
||||
new_version = current.base
|
||||
|
||||
if dry_run:
|
||||
success(f"Dry run: {current} -> {new_version} (release). Nothing written.")
|
||||
return
|
||||
|
||||
version_mod.write_version(new_version)
|
||||
changes.write_text(
|
||||
version_mod.release_entry(text, today_iso(), title.strip() if title else None),
|
||||
encoding="utf-8",
|
||||
)
|
||||
success(
|
||||
f"{current} -> {new_version} (release). Wrote {version_mod.VERSION_FILENAME} and fixed the "
|
||||
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||
"is what release.yml reacts to."
|
||||
)
|
||||
|
||||
@@ -183,6 +183,33 @@ def authorised_labels(source: str, destination: str, kb_dir: Path | None = None)
|
||||
return labels
|
||||
|
||||
|
||||
LABELLED_EDGE_FIELD = "related"
|
||||
|
||||
|
||||
def collections_that_can_carry_labelled_edges() -> set[str]:
|
||||
"""Collection names whose offered page types actually have somewhere to put
|
||||
a labelled edge.
|
||||
|
||||
Derived from `page_ref_fields:`, the same way `stack_required_collections()`
|
||||
is derived from `base_dir:`: a type that does not offer `related:` cannot
|
||||
carry a label, no matter what its collection's contract authorises.
|
||||
"""
|
||||
from chemenu.type_resolver import resolver
|
||||
|
||||
names: set[str] = set()
|
||||
for type_path, _frontmatter in resolver.list_type_specs():
|
||||
try:
|
||||
if resolver.get_root(type_path) != "kb":
|
||||
continue
|
||||
base_dir = resolver.get_base_dir(type_path)
|
||||
fields = resolver.get_page_ref_fields(type_path)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if base_dir and LABELLED_EDGE_FIELD in fields:
|
||||
names.add(str(base_dir).strip("/"))
|
||||
return names
|
||||
|
||||
|
||||
def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
"""What each `COLLECTION.md` fails to declare about itself.
|
||||
|
||||
@@ -200,6 +227,7 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
required = stack_required_collections()
|
||||
can_label = collections_that_can_carry_labelled_edges()
|
||||
present = {path.name for path in iter_kb_collections(root)}
|
||||
for name in required:
|
||||
if name not in present:
|
||||
@@ -244,6 +272,20 @@ def declaration_issues(kb_dir: Path | None = None) -> list[str]:
|
||||
)
|
||||
+ f" - it must be {str(expected).lower()}"
|
||||
)
|
||||
|
||||
# An `outbound:` block on a collection whose types offer no `related:`
|
||||
# authorises labels that no page there can write. That is not a harmless
|
||||
# extra: it reads as a licence, so the label gets written into the prose
|
||||
# by hand instead - an identifier back in free text, which is the exact
|
||||
# thing the labelled-edge model exists to end. The two halves have to
|
||||
# move together, so the check names both directions of the fix.
|
||||
if declared.get(OUTBOUND_FIELD) and collection.name not in can_label:
|
||||
issues.append(
|
||||
f"{relative}: `{OUTBOUND_FIELD}:` authorises labels, but no page type writing "
|
||||
f"into kb/{collection.name}/ offers a `{LABELLED_EDGE_FIELD}:` field - so no "
|
||||
f"page here can carry a labelled edge. Either drop the block, or give the "
|
||||
f"type-spec a `{LABELLED_EDGE_FIELD}:` in its `page_ref_fields:` and schema"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
|
||||
None is a real state, not an error: an instance created before the KB
|
||||
version existed has content of unknown vintage, and guessing would be
|
||||
worse than asking (`migrate baseline`).
|
||||
|
||||
Refuses a pre-release (`-beta.N`): a content *shape* has no beta channel,
|
||||
only the machinery does, so a `kb_version` naming one means something
|
||||
wrote a stack version into this field by hand or by mistake.
|
||||
"""
|
||||
state = read_kb_state()
|
||||
if state is None:
|
||||
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
|
||||
raw = state.get("kb_version")
|
||||
if not raw:
|
||||
return None
|
||||
return Version.parse(str(raw))
|
||||
version = Version.parse(str(raw))
|
||||
if version.is_prerelease:
|
||||
raise VersionError(
|
||||
f"{KB_STATE_FILENAME} names a pre-release kb_version ({version}) - content has no "
|
||||
"beta channel, only the stack version does"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def read_kb_state() -> Optional[dict]:
|
||||
@@ -171,6 +181,13 @@ def chain(
|
||||
Targets above the installed machinery are excluded: the instance has no code
|
||||
for them yet.
|
||||
|
||||
`stack_version` must be release-shaped (no `-beta.N`) - pass `.base` when
|
||||
the installed machinery is a running candidate. A migration document
|
||||
targets a release (`migrates_to: 4.4.0`), and a candidate's own version
|
||||
sorts *before* that release (`4.4.0-beta.1 < 4.4.0`), so comparing against
|
||||
the raw candidate would drop its own target out of the interval right
|
||||
when the machinery that owes it is installed.
|
||||
|
||||
`offered` migrations are deliberately absent. They are not links in the
|
||||
version chain: declining one leaves the content in a shape the machinery
|
||||
still accepts, so counting it as owed would make `kb_version` unreachable
|
||||
|
||||
+58
-10
@@ -27,6 +27,7 @@ from chemenu.provenance import legacy_source_pages as find_legacy_source_pages
|
||||
from chemenu.provenance import orphan_footnote_defs as find_orphan_footnote_defs
|
||||
from chemenu.provenance import uncovered_raw_files as find_uncovered_raw_files
|
||||
from chemenu.provenance import undefined_footnote_refs as find_undefined_footnote_refs
|
||||
from chemenu.version import Version
|
||||
from chemenu.kb_scan import (
|
||||
GENERATED_INDEX,
|
||||
WIKILINK_RE,
|
||||
@@ -164,9 +165,15 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
# propagated, a deleted page, or a URL pasted where a title belongs - used
|
||||
# to pass every check. Which fields hold page titles is declared by each
|
||||
# type-spec's `page_ref_fields:`, not hardcoded here.
|
||||
# Resolved against the directory `run_lint()` was handed, not against
|
||||
# `config.KB_DIR`. A page under a tree that is not the configured corpus -
|
||||
# every fixture tree, and any `lint <path>` aimed elsewhere - raised
|
||||
# `ValueError` here and read as "no collection", which made the label
|
||||
# authorisation below skip the edge in silence rather than judge it
|
||||
# (Gitea #44).
|
||||
def _collection_of(page):
|
||||
try:
|
||||
return page.path.relative_to(config.KB_DIR).parts[0]
|
||||
return page.path.relative_to(kb_dir).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
@@ -210,7 +217,9 @@ def run_lint(kb_dir: Path) -> dict:
|
||||
destination = _collection_of(target_page)
|
||||
if destination is None:
|
||||
continue
|
||||
allowed = kb_collections.authorised_labels(source_collection, destination)
|
||||
allowed = kb_collections.authorised_labels(
|
||||
source_collection, destination, kb_dir
|
||||
)
|
||||
if edge.label not in allowed:
|
||||
unauthorised_labels.append(
|
||||
{
|
||||
@@ -478,13 +487,6 @@ def default_report_path(report: dict) -> Path:
|
||||
# through the index or navigation only. `quote_limit_violations` is advisory
|
||||
# too - it flags a habit, not a broken tree.
|
||||
#
|
||||
# `unlabelled_edges` and `unauthorised_labels` are advisory **for now**, and
|
||||
# that is a dated decision rather than a judgment about severity: they describe
|
||||
# exactly the state a corpus is in between the 4.0.0 machinery landing and the
|
||||
# migration reaching each page, which is the window `.wikitool-kb.json` exists
|
||||
# to represent. They become hard errors once the migration is recorded - the
|
||||
# same path `legacy_citation_markers` took.
|
||||
#
|
||||
# `malformed_edges` and `unbalanced_markers` are hard from the start: neither
|
||||
# describes an unconverted page, only a broken one.
|
||||
#
|
||||
@@ -505,11 +507,57 @@ HARD_ERROR_KEYS = (
|
||||
"dangling_frontmatter_refs",
|
||||
"malformed_edges",
|
||||
"unbalanced_markers",
|
||||
"unlabelled_edges",
|
||||
"unauthorised_labels",
|
||||
"invalid_type_paths",
|
||||
"type_resolution_errors",
|
||||
"schema_validation_errors",
|
||||
)
|
||||
|
||||
# Findings that only become hard once the corpus has reached a given shape.
|
||||
#
|
||||
# `unlabelled_edges` and `unauthorised_labels` describe exactly the state a
|
||||
# corpus is in between the 4.0.0 machinery landing and the migration reaching
|
||||
# each page - the window `.wikitool-kb.json` exists to represent. Failing on
|
||||
# them during that window would refuse the very corpus that
|
||||
# `instructions/migrations/4.0.0-link-taxonomy.md` tells an instance to publish
|
||||
# unit by unit. So the promotion is tied to `kb_version` rather than to a
|
||||
# release date: below 4.0.0 they are advisory, at or above it an unlabelled
|
||||
# edge is no longer a page awaiting conversion but an edge whose author did not
|
||||
# say what it asserts.
|
||||
#
|
||||
# Gated rather than simply promoted, which is where this departs from
|
||||
# `legacy_citation_markers`: that one was flipped in a later version and any
|
||||
# instance still owing the citation migration had to live with a red lint. The
|
||||
# ledger can answer the question now, so it does.
|
||||
MIGRATION_GATED_KEYS: dict[str, Version] = {
|
||||
"unlabelled_edges": Version(4, 0, 0),
|
||||
"unauthorised_labels": Version(4, 0, 0),
|
||||
}
|
||||
|
||||
_ALWAYS_HARD = Version(0, 0, 0)
|
||||
|
||||
|
||||
def hard_error_keys(kb_version: Version | None = None) -> tuple[str, ...]:
|
||||
"""`HARD_ERROR_KEYS` minus the findings this corpus has not grown into yet.
|
||||
|
||||
`kb_version` defaults to what `.wikitool-kb.json` records. A tree without
|
||||
one - a fresh instance, which starts at the current shape rather than
|
||||
migrating into it - keeps every key: there is no outstanding migration for
|
||||
a gated finding to be the noise of.
|
||||
"""
|
||||
from chemenu import kb_state
|
||||
|
||||
if kb_version is None:
|
||||
kb_version = kb_state.read_kb_version()
|
||||
if kb_version is None:
|
||||
return HARD_ERROR_KEYS
|
||||
return tuple(
|
||||
key
|
||||
for key in HARD_ERROR_KEYS
|
||||
if kb_version >= MIGRATION_GATED_KEYS.get(key, _ALWAYS_HARD)
|
||||
)
|
||||
|
||||
|
||||
def has_hard_errors(report: dict) -> bool:
|
||||
return any(report.get(key) for key in HARD_ERROR_KEYS)
|
||||
return any(report.get(key) for key in hard_error_keys())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -37,6 +38,78 @@ _GIT_ENV = (
|
||||
)
|
||||
|
||||
|
||||
def _working_tree_state() -> str | None:
|
||||
"""`git status --porcelain` for the checkout the tests live in, or None if
|
||||
there is no git available to ask."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(config._PACKAGE_ROOT), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return result.stdout if result.returncode == 0 else None
|
||||
|
||||
|
||||
_TREE_GUARD_MESSAGE = (
|
||||
"A test wrote into the repository checkout instead of into its tmp_path.\n"
|
||||
"`git status --porcelain` moved while the suite ran:\n\n"
|
||||
" before:\n{before}\n"
|
||||
" after:\n{after}\n\n"
|
||||
"This is the class of bug Gitea #44 describes: code under test resolves a "
|
||||
"path through `config.ROOT`/`config.KB_DIR` rather than through the "
|
||||
"directory the fixture handed it, so the write lands in the real tree. Fix "
|
||||
"the fixture (repoint `config.ROOT`, as `raw_dir` and `kb_dir` do) or the "
|
||||
"code path, never the symptom.\n"
|
||||
"To find the test that did it, re-run with CHEMENU_TREE_GUARD=each - the "
|
||||
"guard then checks after every test and fails on the first one that moves "
|
||||
"the tree."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def repository_tree_guard():
|
||||
"""Fail the run if the suite moved a file in the real checkout.
|
||||
|
||||
Two `git status` calls for the whole session, which is why this is on by
|
||||
default: it catches the whole class rather than the one case that was
|
||||
noticed. It compares before against after rather than demanding a clean
|
||||
tree, so it says nothing about a developer's own uncommitted work.
|
||||
|
||||
It cannot name the culprit - set `CHEMENU_TREE_GUARD=each` for that, which
|
||||
trades a `git status` per test for a failure on the test that did it.
|
||||
"""
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is None or after is None or before == after:
|
||||
return
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def per_test_tree_guard(repository_tree_guard):
|
||||
"""The bisect half of `repository_tree_guard`, off unless asked for.
|
||||
|
||||
`CHEMENU_TREE_GUARD=each` turns the session-wide "something moved the tree"
|
||||
into "this test moved the tree", at the cost of a `git status` per test.
|
||||
"""
|
||||
if os.environ.get("CHEMENU_TREE_GUARD") != "each":
|
||||
yield
|
||||
return
|
||||
before = _working_tree_state()
|
||||
yield
|
||||
after = _working_tree_state()
|
||||
if before is not None and after is not None and before != after:
|
||||
raise AssertionError(
|
||||
_TREE_GUARD_MESSAGE.format(before=before or "(clean)", after=after or "(clean)")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Cut every test off from the machine it runs on.
|
||||
@@ -159,13 +232,25 @@ def raw_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kb_dir(tmp_path: Path) -> Path:
|
||||
def kb_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A minimal fixture kb/ with the standard collection layout, populated
|
||||
with a handful of pages covering entities/concepts/sources/comparisons.
|
||||
|
||||
Every collection carries a COLLECTION.md, both because that is what makes it
|
||||
a collection and because the scanner must prove it skips them at a depth the
|
||||
kb-root meta files never reach."""
|
||||
kb-root meta files never reach.
|
||||
|
||||
`config.ROOT` is repointed for the same reason `raw_dir` does it, one
|
||||
collection over: code under test that resolves a path through
|
||||
`config.ROOT`/`config.KB_DIR` rather than through the directory it was
|
||||
handed otherwise reaches the *real* repository. That was not theoretical
|
||||
either - a test calling `kb_state.write_kb_state()` overwrote this
|
||||
checkout's `.wikitool-kb.json`, and `lint`'s collection lookup answered
|
||||
"no collection" for every fixture page, which left `unauthorised_labels`
|
||||
with no working test at all (Gitea #44).
|
||||
"""
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
use_shipped_type_specs(monkeypatch)
|
||||
kb = tmp_path / "kb"
|
||||
for sub in ("entities/projects", "entities/systems", "entities/tools",
|
||||
"entities/technologies", "entities/people",
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from chemenu import config
|
||||
from chemenu.commands import confidence_decay
|
||||
from chemenu.commands.confidence_decay import FLOOR, compute_decay
|
||||
from chemenu.frontmatter_io import read_page
|
||||
from chemenu.frontmatter_io import read_page, write_page
|
||||
|
||||
|
||||
def test_no_decay_at_zero_months():
|
||||
@@ -68,3 +68,22 @@ def test_decay_skips_pages_without_a_base(decay_wiki):
|
||||
confidence_decay.confidence_decay(apply=True)
|
||||
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
|
||||
assert frontmatter["confidence"] == 0.9
|
||||
|
||||
|
||||
def test_decay_skips_decision_pages(decay_wiki):
|
||||
"""A decision is not falsified by elapsed time, only by a later decision
|
||||
superseding it - `concept_type: decision` is a categorical skip, not
|
||||
something an old `modified` date should ever decay (Gitea #38)."""
|
||||
decision_path = decay_wiki / "concepts" / "some-decision.md"
|
||||
write_page(
|
||||
decision_path,
|
||||
{
|
||||
"type": "types/concept.md", "concept_type": "decision",
|
||||
"tags": [], "created": "2015-01-01", "modified": "2015-01-01",
|
||||
"related": [], "sources": [], "confidence": 0.9, "confidence_base": 0.9,
|
||||
},
|
||||
"\n# some-decision\n",
|
||||
)
|
||||
confidence_decay.confidence_decay(apply=True)
|
||||
frontmatter, _ = read_page(decision_path)
|
||||
assert frontmatter["confidence"] == 0.9
|
||||
|
||||
@@ -192,3 +192,29 @@ def test_an_undeclared_destination_authorises_nothing(kb_root):
|
||||
missing declaration to be filled in with a permissive default."""
|
||||
_authorising(kb_root, "entities", " concepts: [implements]")
|
||||
assert kb_collections.authorised_labels("entities", "sources") == set()
|
||||
|
||||
|
||||
def test_outbound_on_a_collection_that_cannot_carry_labels_is_a_finding(kb_root):
|
||||
"""`kb/sources/` is the live case: the `source` type-spec offers no
|
||||
`related:`, so an `outbound:` block there authorises labels no page can
|
||||
write. Left unchecked it reads as a licence and the label gets written into
|
||||
the prose by hand instead - an identifier back in free text, which is what
|
||||
labelled edges exist to end."""
|
||||
_authorising(kb_root, "sources", " any: [is-evidence-for]", required=True)
|
||||
issues = kb_collections.declaration_issues(kb_root)
|
||||
assert any(
|
||||
"kb/sources/COLLECTION.md" in issue and "no page type writing into kb/sources/" in issue
|
||||
for issue in issues
|
||||
)
|
||||
|
||||
|
||||
def test_outbound_is_fine_on_a_collection_whose_type_offers_related(kb_root):
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
_authorising(kb_root, "entities", " any: [uses]")
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
|
||||
def test_a_collection_without_outbound_is_not_a_finding(kb_root):
|
||||
"""Absence is the declaration `kb/sources/` makes: no authored edges here."""
|
||||
_collection(kb_root, "sources", profile="sources", required=True)
|
||||
assert kb_collections.declaration_issues(kb_root) == []
|
||||
|
||||
@@ -135,6 +135,10 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
docs_dir = root / "docs"
|
||||
docs_dir.mkdir()
|
||||
(docs_dir / "why-gates-are-code.md").write_text("# Why gates are code\n", encoding="utf-8")
|
||||
|
||||
for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -156,6 +160,11 @@ def test_plan_never_includes_commonplace(repo):
|
||||
assert "commonplace" not in combined
|
||||
|
||||
|
||||
def test_plan_ships_docs_verbatim(repo):
|
||||
plan = dist_cmd.build_plan()
|
||||
assert plan["docs/why-gates-are-code.md"].content == "# Why gates are code\n"
|
||||
|
||||
|
||||
def test_plan_never_includes_instructions_dev(repo):
|
||||
"""instructions/dev/ - flat dev-only instructions and the nested skill
|
||||
that switches a session into tool-development mode - is pruned
|
||||
@@ -421,6 +430,22 @@ def test_plan_declares_the_fresh_instance_content_version(repo):
|
||||
assert state["applied"] == []
|
||||
|
||||
|
||||
def test_kb_version_is_the_candidates_base_while_the_stamp_stays_honest(repo):
|
||||
"""Exporting mid-candidate answers two different questions: the stamp says
|
||||
what was actually exported (suffix included - "an export says what it
|
||||
is"), the KB version says what shape the content is built for. A content
|
||||
shape has no beta channel, so it must be the base."""
|
||||
from chemenu import kb_state
|
||||
|
||||
(repo / "VERSION").write_text("0.4.0-beta.2\n", encoding="utf-8")
|
||||
plan = dist_cmd.build_plan()
|
||||
stamp = json.loads(plan[version_mod.RELEASE_STAMP_FILENAME].content)
|
||||
state = json.loads(plan[kb_state.KB_STATE_FILENAME].content)
|
||||
assert stamp["version"] == "0.4.0-beta.2"
|
||||
assert plan["VERSION"].content.strip() == "0.4.0-beta.2"
|
||||
assert state["kb_version"] == "0.4.0"
|
||||
|
||||
|
||||
def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
|
||||
(repo / "VERSION").unlink()
|
||||
target = tmp_path / "dist"
|
||||
|
||||
@@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for():
|
||||
|
||||
|
||||
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""):
|
||||
"""A changelog with `current` as the topmost entry and `previous` as the
|
||||
last release beneath it. `current` is normally an open candidate
|
||||
(`2.0.0-beta.1`) - the checks compare the newest entry against the **last
|
||||
release** (`version_mod.last_release`), which skips right past a topmost
|
||||
entry that is itself already a release (that one's crossing, if any, was
|
||||
already checked while it was still the open candidate)."""
|
||||
(tmp_path / "VERSION").write_text(f"{current}\n", encoding="utf-8")
|
||||
(tmp_path / "CHANGES.md").write_text(
|
||||
"# Changelog\n\n---\n\n"
|
||||
@@ -207,28 +213,41 @@ def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: s
|
||||
def test_a_breaking_release_without_a_migration_is_reported(tmp_path, monkeypatch):
|
||||
"""`version check` tells an instance it must migrate; without this, that is
|
||||
where the trail ends."""
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||
issues = docs_verify.check_migration_for_boundary()
|
||||
assert any("2.0.0" in issue and "must migrate" in issue for issue in issues)
|
||||
assert any("2.0.0-beta.1" in issue and "must migrate" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_compatible_release_needs_no_migration(tmp_path, monkeypatch):
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_fixed_release_is_never_re_checked_against_its_own_crossing(tmp_path, monkeypatch):
|
||||
"""Regression for finding #2: comparing against the entry *beneath* the
|
||||
newest one (rather than the last release) would find no boundary between
|
||||
two betas of the same candidate - and would also, wrongly, re-flag an
|
||||
already-fixed release forever. Once `current` is itself a release,
|
||||
`last_release` returns it directly, so there is nothing left to compare."""
|
||||
_boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||
)
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
|
||||
|
||||
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch):
|
||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0", "1.4.0")
|
||||
"""The document targets the candidate's *base* (`2.0.0`), not its full
|
||||
pre-release form - matching what `version bump` looks for."""
|
||||
root = _boundary_tree(tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0")
|
||||
(root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||
@@ -243,16 +262,16 @@ def test_a_breaking_release_without_a_breaking_note_is_reported(tmp_path, monkey
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
|
||||
)
|
||||
assert docs_verify.check_migration_for_boundary() == []
|
||||
issues = docs_verify.check_breaking_change_for_boundary()
|
||||
assert any("2.0.0" in issue and "drop-in" in issue for issue in issues)
|
||||
assert any("2.0.0-beta.1" in issue and "drop-in" in issue for issue in issues)
|
||||
|
||||
|
||||
def test_a_compatible_release_needs_no_breaking_note(tmp_path, monkeypatch):
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0", "1.4.0")
|
||||
_boundary_tree(tmp_path, monkeypatch, "1.5.0-beta.1", "1.4.0")
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
|
||||
@@ -260,7 +279,7 @@ def test_a_breaking_change_marker_satisfies_the_check(tmp_path, monkeypatch):
|
||||
from chemenu import version as version_mod
|
||||
|
||||
_boundary_tree(
|
||||
tmp_path, monkeypatch, "2.0.0", "1.4.0",
|
||||
tmp_path, monkeypatch, "2.0.0-beta.1", "1.4.0",
|
||||
marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
|
||||
)
|
||||
assert docs_verify.check_breaking_change_for_boundary() == []
|
||||
|
||||
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
|
||||
assert _status(doctor.run_doctor(), "kb-version") == "FAIL"
|
||||
|
||||
|
||||
def test_a_running_candidate_is_named_as_such(instance):
|
||||
(config.ROOT / "VERSION").write_text("0.2.0-beta.1\n", encoding="utf-8")
|
||||
detail = next(c.detail for c in doctor.run_doctor() if c.name == "stack-version")
|
||||
assert "0.2.0-beta.1" in detail
|
||||
assert "candidate" in detail
|
||||
|
||||
|
||||
def test_kb_version_chain_still_reaches_a_target_matching_a_running_candidate(instance):
|
||||
"""Regression for finding #3: comparing the chain against the raw
|
||||
candidate would sort `2.0.0` (the migration's target) *before*
|
||||
`2.0.0-beta.1` (what is installed), dropping it out of the owed range."""
|
||||
(config.ROOT / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||
(config.ROOT / ".wikitool-kb.json").write_text(
|
||||
'{"schema": 1, "kb_version": "1.0.0", "applied": []}', encoding="utf-8"
|
||||
)
|
||||
migrations = config.INSTRUCTIONS_DIR / "migrations"
|
||||
migrations.mkdir(parents=True, exist_ok=True)
|
||||
(migrations / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
checks = doctor.run_doctor()
|
||||
assert _status(checks, "kb-version") == "WARN"
|
||||
assert "outstanding" in next(c.detail for c in checks if c.name == "kb-version")
|
||||
|
||||
|
||||
def test_no_collections_at_all_fails_structure(instance):
|
||||
"""Removing one collection is a legitimate state - collections are
|
||||
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
|
||||
|
||||
@@ -74,3 +74,23 @@ def test_wiki_author_overrides_the_git_identity(tmp_path: Path,
|
||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||
monkeypatch.setenv("WIKI_AUTHOR", "Env Override")
|
||||
assert config.default_author() == "Env Override"
|
||||
|
||||
|
||||
def test_kb_dir_repoints_the_configured_root_at_its_own_tree(kb_dir: Path, tmp_path: Path):
|
||||
"""The other half of the isolation, and the one `kb_dir` was missing until
|
||||
Gitea #44: a fixture that builds a corpus but leaves `config.ROOT` on the
|
||||
real checkout hands every `config.KB_DIR` lookup the developer's own wiki -
|
||||
which is how a test overwrote the repository's `.wikitool-kb.json`."""
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.KB_DIR == kb_dir
|
||||
|
||||
|
||||
def test_raw_dir_repoints_the_configured_root_at_its_own_tree(raw_dir: Path, tmp_path: Path):
|
||||
assert config.ROOT == tmp_path
|
||||
assert config.RAW_DIR == raw_dir
|
||||
|
||||
|
||||
def test_both_corpus_fixtures_keep_the_shipped_type_specs_reachable(kb_dir: Path):
|
||||
"""Repointing `ROOT` moves `TYPES_DIR` with it, so the repoint has to be
|
||||
paired with `use_shipped_type_specs()` or no page type resolves at all."""
|
||||
assert (config.TYPES_DIR / "entity.md").is_file()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from chemenu import config
|
||||
import pytest
|
||||
|
||||
from chemenu import config, kb_state
|
||||
from chemenu.commands.lint import (
|
||||
HARD_ERROR_KEYS,
|
||||
hard_error_keys,
|
||||
has_hard_errors,
|
||||
lint_command,
|
||||
render_markdown,
|
||||
@@ -11,6 +15,7 @@ from chemenu.commands.lint import (
|
||||
)
|
||||
from chemenu.frontmatter_io import write_page
|
||||
from chemenu.provenance import cite_id, render_cite_block
|
||||
from chemenu.version import Version
|
||||
|
||||
|
||||
def test_lint_detects_unparsable_frontmatter(kb_dir):
|
||||
@@ -472,3 +477,105 @@ def test_lint_does_not_count_a_shell_prompt_as_a_quote(kb_dir):
|
||||
)
|
||||
report = run_lint(kb_dir)
|
||||
assert [i for i in report["quote_limit_violations"] if i["page"] == "shelly"] == []
|
||||
|
||||
|
||||
# --- the migration gate on `unlabelled_edges` / `unauthorised_labels` -------
|
||||
#
|
||||
# These read and write `.wikitool-kb.json`, which `kb_state` resolves relative
|
||||
# to `config.ROOT`. The `kb_dir` fixture repoints `ROOT` at its own tmp_path
|
||||
# (Gitea #44), so the gate is read off the fixture tree; before it did, these
|
||||
# four ran against the real repository's state file and one of them overwrote
|
||||
# it.
|
||||
|
||||
|
||||
def _page_with_an_unlabelled_edge(kb_dir):
|
||||
"""A `related:` entry that is a bare title rather than a `label: title`
|
||||
mapping - the shape every page was in before the 4.0.0 migration."""
|
||||
write_page(
|
||||
kb_dir / "entities/tools/bare-edge.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": ["Modbus"], "sources": [], "confidence": 0.8,
|
||||
"provenance": "general", "summary": "One edge whose label was never declared."},
|
||||
"\n# bare-edge\n\nAn edge without a label.\n",
|
||||
)
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_advisory_below_kb_version_4(kb_dir):
|
||||
"""The window the migration document describes: the machinery has landed,
|
||||
the corpus has not been converted yet, and `lint --fail-on-error` must not
|
||||
refuse the very tree the migration tells the instance to publish unit by
|
||||
unit."""
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(3, 0, 0), [])
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" not in hard_error_keys()
|
||||
# Narrowed to the finding under test: the fixture corpus carries unrelated
|
||||
# hard errors of its own, so asserting on the whole report would prove
|
||||
# nothing about the gate.
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is False
|
||||
|
||||
|
||||
def test_unlabelled_edge_is_hard_at_kb_version_4(kb_dir):
|
||||
"""Once the migration is recorded, a bare title is no longer a page waiting
|
||||
its turn - it is an edge whose author did not say what it asserts."""
|
||||
_page_with_an_unlabelled_edge(kb_dir)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unlabelled_edges"] != []
|
||||
assert "unlabelled_edges" in hard_error_keys()
|
||||
assert has_hard_errors({"unlabelled_edges": report["unlabelled_edges"]}) is True
|
||||
|
||||
|
||||
def test_unauthorised_label_is_hard_at_kb_version_4(kb_dir):
|
||||
"""The fixture contracts authorise `depends-on` but not `contradicts`."""
|
||||
write_page(
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
kb_state.write_kb_state(Version(4, 0, 0), [])
|
||||
report = run_lint(kb_dir)
|
||||
assert report["unauthorised_labels"] != []
|
||||
assert "unauthorised_labels" in hard_error_keys()
|
||||
assert has_hard_errors({"unauthorised_labels": report["unauthorised_labels"]}) is True
|
||||
|
||||
|
||||
def test_unauthorised_label_is_judged_in_a_tree_that_is_not_the_configured_kb(
|
||||
kb_dir, tmp_path, monkeypatch
|
||||
):
|
||||
"""`run_lint()` judges the tree it was handed, not the configured corpus.
|
||||
|
||||
The collection lookup used to resolve a page against `config.KB_DIR`; a
|
||||
page anywhere else raised `ValueError`, read back as "no collection", and
|
||||
the label check skipped the edge without a word. That is why
|
||||
`unauthorised_labels` was untested in practice before Gitea #44 - every
|
||||
fixture tree was somewhere else. Here `ROOT` deliberately points away from
|
||||
the tree under lint, which is the case the old code got wrong.
|
||||
"""
|
||||
write_page(
|
||||
kb_dir / "entities/tools/off-menu.md",
|
||||
{"type": "types/entity.md", "entity_type": "tool", "tags": [], "created": "2026-09-03",
|
||||
"modified": "2026-09-03", "related": [{"contradicts": "Modbus"}], "sources": [],
|
||||
"confidence": 0.8, "provenance": "general", "summary": "A label off this menu."},
|
||||
"\n# off-menu\n\nA label the source collection never authorised.\n",
|
||||
)
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.setattr(config, "ROOT", elsewhere)
|
||||
assert config.KB_DIR != kb_dir
|
||||
|
||||
report = run_lint(kb_dir)
|
||||
assert {
|
||||
"page": "off-menu", "target": "Modbus", "label": "contradicts", "destination": "concepts"
|
||||
} in report["unauthorised_labels"]
|
||||
|
||||
|
||||
def test_a_tree_that_never_declared_a_kb_version_keeps_every_key(kb_dir):
|
||||
"""No `.wikitool-kb.json` means a fresh instance, which starts at the
|
||||
current shape rather than migrating into it - so there is no outstanding
|
||||
migration for a gated finding to be the noise of."""
|
||||
assert kb_state.read_kb_version() is None
|
||||
assert hard_error_keys() == HARD_ERROR_KEYS
|
||||
|
||||
@@ -11,7 +11,7 @@ import typer
|
||||
|
||||
from chemenu import config, kb_state
|
||||
from chemenu.commands import migrate_cmd
|
||||
from chemenu.version import Version
|
||||
from chemenu.version import Version, VersionError
|
||||
|
||||
CHANGES = "# Changelog\n\n---\n\n## 1.0.0 - 2026-08-30 - First\n\nBody.\n"
|
||||
|
||||
@@ -108,6 +108,19 @@ def test_status_lists_the_chain_in_order(instance, capsys):
|
||||
assert [m["migrates_to"] for m in result["pending"]] == ["1.4.0", "1.7.0", "2.0.0"]
|
||||
|
||||
|
||||
def test_status_chain_still_reaches_a_target_matching_a_running_candidate(instance, capsys):
|
||||
"""Regression for finding #3: a migration targeting `2.0.0` must still be
|
||||
owed while `VERSION` is the running candidate `2.0.0-beta.1` - `2.0.0` sorts
|
||||
*above* its own candidate, so comparing against the raw pre-release would
|
||||
drop it out of the chain right when the machinery that owes it installs."""
|
||||
(instance / "VERSION").write_text("2.0.0-beta.1\n", encoding="utf-8")
|
||||
set_kb_version(instance, "1.7.0")
|
||||
migrate_cmd.status_command(json_out=True)
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["stack_version"] == "2.0.0-beta.1"
|
||||
assert [m["migrates_to"] for m in result["pending"]] == ["2.0.0"]
|
||||
|
||||
|
||||
def test_list_reports_every_document_sorted_by_target(instance, capsys):
|
||||
migrate_cmd.list_command(json_out=True)
|
||||
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)]
|
||||
@@ -155,6 +168,16 @@ def test_done_without_a_declared_kb_version_is_refused(instance):
|
||||
# --- baseline --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_kb_version_refuses_a_pre_release(instance):
|
||||
"""A content shape has no beta channel - only the stack version does."""
|
||||
set_kb_version(instance, "1.3.1")
|
||||
(instance / kb_state.KB_STATE_FILENAME).write_text(
|
||||
json.dumps({"schema": 1, "kb_version": "1.4.0-beta.1", "applied": []}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(VersionError):
|
||||
kb_state.read_kb_version()
|
||||
|
||||
|
||||
def test_baseline_declares_the_version_once(instance):
|
||||
migrate_cmd.baseline_command(version="1.3.1", force=False)
|
||||
assert kb_state.read_kb_version() == Version(1, 3, 1)
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_get_page_ref_fields_reads_the_type_spec():
|
||||
assert resolver.get_page_ref_fields("types/entity.md") == ["related", "sources"]
|
||||
assert resolver.get_page_ref_fields("types/concept.md") == ["related", "sources"]
|
||||
assert resolver.get_page_ref_fields("types/source.md") == ["entities", "concepts"]
|
||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities"]
|
||||
assert resolver.get_page_ref_fields("types/comparison.md") == ["entities", "related"]
|
||||
|
||||
|
||||
def test_page_ref_fields_exist_in_the_type_schema():
|
||||
|
||||
@@ -83,6 +83,86 @@ def test_compare_separates_a_compatible_update_from_a_migration(local, latest, s
|
||||
assert version_mod.compare(Version.parse(local), Version.parse(latest)) == state
|
||||
|
||||
|
||||
# --- candidates: parsing and ordering ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["4.4.0-beta.1", "4.4.0-beta.10", "v4.4.0-beta.2"])
|
||||
def test_parse_accepts_a_candidate_suffix(text):
|
||||
version = Version.parse(text)
|
||||
assert version.is_prerelease
|
||||
assert version.beta == int(text.rsplit(".", 1)[1])
|
||||
|
||||
|
||||
def test_a_release_has_no_beta():
|
||||
version = Version.parse("4.4.0")
|
||||
assert not version.is_prerelease
|
||||
assert version.beta is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lesser,greater",
|
||||
[
|
||||
("4.4.0-beta.1", "4.4.0"),
|
||||
("4.4.0-beta.1", "4.4.0-beta.2"),
|
||||
("4.4.0-beta.9", "4.4.0-beta.10"), # numeric, not lexicographic
|
||||
("4.4.0-beta.9", "4.4.1"),
|
||||
],
|
||||
)
|
||||
def test_a_candidate_sorts_before_its_release_and_by_numeric_beta(lesser, greater):
|
||||
assert Version.parse(lesser) < Version.parse(greater)
|
||||
assert Version.parse(greater) > Version.parse(lesser)
|
||||
|
||||
|
||||
def test_base_strips_the_candidate_suffix():
|
||||
assert str(Version.parse("4.4.0-beta.3").base) == "4.4.0"
|
||||
assert Version.parse("4.4.0").base == Version.parse("4.4.0")
|
||||
|
||||
|
||||
def test_bumped_always_returns_a_release_even_from_a_candidate():
|
||||
"""`bumped()` answers "what would the next fixed version be" - it is
|
||||
`escalate()` that knows about running candidates."""
|
||||
assert not Version.parse("4.4.0-beta.3").bumped("patch").is_prerelease
|
||||
|
||||
|
||||
# --- candidates: escalation --------------------------------------------------
|
||||
|
||||
|
||||
def test_escalate_opens_the_first_candidate_at_beta_one():
|
||||
release = Version.parse("4.3.3")
|
||||
candidate = version_mod.escalate(release, release, "minor")
|
||||
assert str(candidate) == "4.4.0-beta.1"
|
||||
|
||||
|
||||
def test_escalate_on_the_same_stage_only_advances_the_bump_count():
|
||||
release = Version.parse("4.3.3")
|
||||
first = version_mod.escalate(release, release, "minor")
|
||||
second = version_mod.escalate(release, first, "patch")
|
||||
assert str(second) == "4.4.0-beta.2"
|
||||
|
||||
|
||||
def test_escalate_never_steps_back_down():
|
||||
release = Version.parse("1.4.0")
|
||||
major = version_mod.escalate(release, release, "major")
|
||||
still_major = version_mod.escalate(release, major, "patch")
|
||||
assert still_major.base == major.base
|
||||
assert still_major.beta == 2
|
||||
|
||||
|
||||
def test_escalate_raises_the_base_and_resets_the_bump_count():
|
||||
release = Version.parse("4.3.3")
|
||||
minor = version_mod.escalate(release, release, "minor")
|
||||
major = version_mod.escalate(release, minor, "major")
|
||||
assert str(major) == "5.0.0-beta.1"
|
||||
|
||||
|
||||
def test_escalate_with_no_last_release_bumps_the_current_version_directly():
|
||||
"""The fresh-distribution edge case: a changelog with no versioned entry at
|
||||
all opens a candidate straight from `current`, rather than failing."""
|
||||
fresh = Version.parse("0.1.0")
|
||||
candidate = version_mod.escalate(None, fresh, "patch")
|
||||
assert str(candidate) == "0.1.1-beta.1"
|
||||
|
||||
|
||||
def test_a_migration_headline_says_so_rather_than_just_being_louder():
|
||||
status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
|
||||
assert "migration" in status.headline.lower()
|
||||
@@ -167,14 +247,75 @@ def test_changes_section_is_none_for_an_undocumented_version():
|
||||
assert version_mod.changes_section(CHANGES_HEADER, Version(9, 9, 9)) is None
|
||||
|
||||
|
||||
def test_insert_changes_entry_lands_above_the_newest_entry():
|
||||
def test_last_release_skips_an_open_candidate_above_it():
|
||||
text = (
|
||||
CHANGES_HEADER
|
||||
+ "## 0.2.0-beta.1 - 2026-09-04 - Candidate\n\nBody.\n\n---\n\n"
|
||||
+ "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
)
|
||||
assert version_mod.last_release(text) == Version(0, 1, 0)
|
||||
|
||||
|
||||
def test_last_release_is_none_with_no_versioned_entry_at_all():
|
||||
text = CHANGES_HEADER + "## 2026-08-01 - Before versioning\n\nBody.\n"
|
||||
assert version_mod.last_release(text) is None
|
||||
|
||||
|
||||
def test_insert_changes_entry_opens_a_fresh_candidate_above_the_newest_entry():
|
||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
result = version_mod.insert_changes_entry(
|
||||
text, Version(0, 2, 0), "2026-09-01", "Newer", "Someone"
|
||||
text, Version(0, 2, 0, beta=1), "2026-09-01", "Newer", "Someone"
|
||||
)
|
||||
assert result.index("## 0.2.0") < result.index("## 0.1.0")
|
||||
assert result.index("## 0.2.0-beta.1") < result.index("## 0.1.0")
|
||||
assert "Preamble." in result
|
||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0)
|
||||
assert "- Newer" in result # the bump-title list seeds itself with this title
|
||||
assert version_mod.top_changes_version(result) == Version(0, 2, 0, beta=1)
|
||||
|
||||
|
||||
def test_insert_changes_entry_updates_an_open_candidate_in_place():
|
||||
"""The second bump of the same candidate must not open a second entry -
|
||||
one entry per running candidate, per instructions/dev/version-parts.md."""
|
||||
text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
first = version_mod.insert_changes_entry(
|
||||
text, Version(0, 2, 0, beta=1), "2026-09-01", "First title", "Someone"
|
||||
)
|
||||
second = version_mod.insert_changes_entry(
|
||||
first, Version(0, 2, 0, beta=2), "2026-09-02", "Second title", "Someone"
|
||||
)
|
||||
assert second.count("## 0.2.0") == 1
|
||||
assert "## 0.2.0-beta.2 - 2026-09-02 - Second title" in second
|
||||
assert "- First title" in second
|
||||
assert "- Second title" in second
|
||||
assert "## 0.1.0" in second # the older, already-released entry survives untouched
|
||||
|
||||
|
||||
def test_insert_changes_entry_keeps_the_breaking_line_across_a_later_bump():
|
||||
text = CHANGES_HEADER + "## 1.4.0 - 2026-08-29 - Older\n\nBody.\n"
|
||||
first = version_mod.insert_changes_entry(
|
||||
text, Version(2, 0, 0, beta=1), "2026-09-01", "Breaking bump", "Someone",
|
||||
breaking_reason="the feed moved", no_migration_reason="kb untouched",
|
||||
)
|
||||
second = version_mod.insert_changes_entry(
|
||||
first, Version(2, 0, 0, beta=2), "2026-09-02", "Follow-up", "Someone",
|
||||
)
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in second
|
||||
assert "the feed moved" in second
|
||||
assert version_mod.MIGRATION_NONE_MARKER in second
|
||||
assert "kb untouched" in second
|
||||
|
||||
|
||||
def test_release_entry_fixes_the_heading_and_keeps_the_bump_titles():
|
||||
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\n<!-- wikitool:bumps -->\n- First title\n- Second title\n<!-- /wikitool:bumps -->\n\nBody.\n"
|
||||
released = version_mod.release_entry(text, "2026-09-05")
|
||||
assert "## 0.2.0 - 2026-09-05 - Second title" in released
|
||||
assert "- First title" in released
|
||||
assert "- Second title" in released
|
||||
|
||||
|
||||
def test_release_entry_can_replace_the_title():
|
||||
text = CHANGES_HEADER + "## 0.2.0-beta.2 - 2026-09-02 - Second title\n\n**Author:** Someone\n\nBody.\n"
|
||||
released = version_mod.release_entry(text, "2026-09-05", title="Summarising title")
|
||||
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||
|
||||
|
||||
# --- version bump ----------------------------------------------------------
|
||||
@@ -185,13 +326,28 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||
major=False, minor=True, patch=False, title="Something happened",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "## 1.1.0-beta.1 - " in changes
|
||||
assert "Something happened" in changes
|
||||
assert "**Author:** Test Author" in changes
|
||||
|
||||
|
||||
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert changes.count("## 1.1.0") == 1
|
||||
assert "First" in changes and "Second" in changes
|
||||
|
||||
|
||||
def test_bump_dry_run_writes_nothing(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True
|
||||
@@ -219,9 +375,10 @@ def test_bump_refuses_an_empty_title(tree):
|
||||
)
|
||||
|
||||
|
||||
def test_bump_refuses_when_the_changelog_is_already_ahead(tree):
|
||||
"""A changelog documenting a version the tree has not reached means
|
||||
someone edited one of the two by hand; bumping past it would hide that."""
|
||||
def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
||||
"""A changelog whose newest entry names a different version than VERSION
|
||||
means someone edited one of the two by hand; bumping past it would hide
|
||||
that instead of surfacing it."""
|
||||
(tree / "CHANGES.md").write_text(
|
||||
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8"
|
||||
)
|
||||
@@ -258,7 +415,31 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0"
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||
|
||||
|
||||
def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migration(tree):
|
||||
"""Finding #4: the requirement fires once, at the bump that first escalates
|
||||
to the boundary; a later bump of the same candidate is not asked again."""
|
||||
migrations = tree / "instructions" / "migrations"
|
||||
(migrations / "2.0.0-retype.md").write_text(
|
||||
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
|
||||
"description: Retype every page.\nmanual: true\n"
|
||||
"migrates_to: 2.0.0\nmigration_kind: assisted\n---\n\n# M\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Breaking",
|
||||
breaking="every page is retyped", no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=True, minor=False, patch=False, title="Follow-up",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||
assert "every page is retyped" in changes
|
||||
|
||||
|
||||
def test_no_migration_records_the_reason_in_the_changelog(tree):
|
||||
@@ -321,6 +502,65 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
# --- version release --------------------------------------------------------
|
||||
|
||||
|
||||
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "-beta." not in changes.split("## 1.1.0")[1].split("## ")[0]
|
||||
assert "First bump" in changes # kept, since --title was not given
|
||||
|
||||
|
||||
def test_release_can_replace_the_title(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||
assert "## 1.1.0 - " in changes
|
||||
assert "Summary of both bumps" in changes
|
||||
# the machine-managed bump list is left as the record of what happened
|
||||
assert "First bump" in changes
|
||||
assert "Second bump" in changes
|
||||
|
||||
|
||||
def test_release_dry_run_writes_nothing(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.release_command(title=None, dry_run=True)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||
|
||||
|
||||
def test_release_refuses_when_version_is_already_a_release(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||
|
||||
|
||||
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.release_command(title=None, dry_run=False)
|
||||
|
||||
|
||||
# --- version notes ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -329,6 +569,21 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
||||
assert "## 1.0.0" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=True, patch=False, title="First bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.bump_command(
|
||||
major=False, minor=False, patch=True, title="Second bump",
|
||||
breaking=None, no_migration=None, dry_run=False,
|
||||
)
|
||||
version_cmd.notes_command(version=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "## 1.1.0-beta.2" in out
|
||||
assert "First bump" in out and "Second bump" in out
|
||||
|
||||
|
||||
def test_notes_fails_for_a_version_with_no_entry(tree):
|
||||
with pytest.raises(typer.Exit):
|
||||
version_cmd.notes_command(version="9.9.9")
|
||||
|
||||
+252
-19
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -42,7 +43,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from chemenu import config
|
||||
from chemenu import blocks, config
|
||||
|
||||
VERSION_FILENAME = "VERSION"
|
||||
CHANGES_FILENAME = "CHANGES.md"
|
||||
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
|
||||
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
|
||||
|
||||
PARTS = ("major", "minor", "patch")
|
||||
_STAGE_RANK = {"patch": 0, "minor": 1, "major": 2}
|
||||
|
||||
# Plain `x.y.z` only: no `-rc1`, no `+build`. Pre-release channels would mean a
|
||||
# second ordering rule everywhere a version is compared - the release feed, the
|
||||
# migration chain, the compatibility check - to serve a workflow this stack does
|
||||
# not have.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)\s*$")
|
||||
# `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
|
||||
# Deliberately not a general SemVer pre-release alphabet - one channel keeps the
|
||||
# ordering numeric and total. See "Candidates and releases" below.
|
||||
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
|
||||
|
||||
# The marker pair `bumps` inside a CHANGES.md entry: the machine-managed list of
|
||||
# every `--title` a candidate has collected across its bumps. Reuses
|
||||
# `blocks.open_marker`/`close_marker` (the same delimiter convention as a page
|
||||
# body's generated regions) but is **not** added to `blocks.BLOCKS` - that tuple
|
||||
# feeds `xref`, `cite` and the `unbalanced_markers` lint check, all of which are
|
||||
# about a page's body, and `CHANGES.md` is not a page. The region itself, and
|
||||
# its rendering, belong here instead.
|
||||
BUMPS_BLOCK_NAME = "bumps"
|
||||
_BUMPS_OPEN = blocks.open_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_CLOSE = blocks.close_marker(BUMPS_BLOCK_NAME)
|
||||
_BUMPS_RE = re.compile(re.escape(_BUMPS_OPEN) + r"(.*?)" + re.escape(_BUMPS_CLOSE), re.DOTALL)
|
||||
|
||||
# Written into a CHANGES.md entry whose version crosses a compatibility
|
||||
# boundary that needs no content migration. `docs verify` accepts it in place
|
||||
@@ -84,7 +97,7 @@ BREAKING_CHANGE_MARKER = "**Breaking Change:**"
|
||||
# A changelog entry that names a version. Entries predating versioning start
|
||||
# with a date instead and are deliberately not matched - they are history, not
|
||||
# a claim about which version the tree is.
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?: - (.*))?$", re.MULTILINE)
|
||||
_CHANGES_ENTRY_RE = re.compile(r"^## (\d+\.\d+\.\d+(?:-beta\.\d+)?)(?: - (.*))?$", re.MULTILINE)
|
||||
|
||||
|
||||
class VersionError(ValueError):
|
||||
@@ -92,23 +105,67 @@ class VersionError(ValueError):
|
||||
written to be shown to the user verbatim."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
@functools.total_ordering
|
||||
@dataclass(frozen=True)
|
||||
class Version:
|
||||
"""A stack version: `MAJOR.MINOR.PATCH`, optionally a running candidate
|
||||
(`-beta.N`) between two releases.
|
||||
|
||||
**Candidates and releases.** Between two releases the stack carries at
|
||||
most one running candidate rather than a fresh number per `bump` - see
|
||||
`instructions/dev/version-parts.md`. `VERSION` holds either a release
|
||||
(`beta is None`) or a candidate (`beta` is the bump count since the
|
||||
candidate's base was last raised). `base` strips the suffix; `bumped()`
|
||||
always returns a release-shaped `Version`, because it answers "what would
|
||||
the *next fixed* version be", never "what candidate comes next" - that
|
||||
answer needs `escalate()`, which also knows the last release to escalate
|
||||
against.
|
||||
|
||||
**Ordering** is `(major, minor, patch, released, beta)`, `released` sorting
|
||||
a real release after every candidate that shares its base - `4.4.0-beta.1
|
||||
< 4.4.0`. `order=True` on the dataclass cannot express this: `None` and
|
||||
`int` do not compare, and the ordering is inverted relative to field
|
||||
declaration order anyway. `functools.total_ordering` plus an explicit
|
||||
`__lt__` is the direct way to say what the ordering actually is.
|
||||
"""
|
||||
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
beta: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> "Version":
|
||||
match = _SEMVER_RE.match(text or "")
|
||||
if not match:
|
||||
raise VersionError(
|
||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH"
|
||||
f"{text.strip()!r} is not a semantic version - expected MAJOR.MINOR.PATCH "
|
||||
"or MAJOR.MINOR.PATCH-beta.N"
|
||||
)
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||||
beta = int(match.group(4)) if match.group(4) is not None else None
|
||||
return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), beta)
|
||||
|
||||
def __str__(self) -> str: # noqa: D105 - obvious
|
||||
return f"{self.major}.{self.minor}.{self.patch}"
|
||||
suffix = f"-beta.{self.beta}" if self.beta is not None else ""
|
||||
return f"{self.major}.{self.minor}.{self.patch}{suffix}"
|
||||
|
||||
def _sort_key(self) -> tuple[int, int, int, int, int]:
|
||||
return (self.major, self.minor, self.patch, 0 if self.is_prerelease else 1, self.beta or 0)
|
||||
|
||||
def __lt__(self, other: "Version") -> bool:
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
return self._sort_key() < other._sort_key()
|
||||
|
||||
@property
|
||||
def is_prerelease(self) -> bool:
|
||||
return self.beta is not None
|
||||
|
||||
@property
|
||||
def base(self) -> "Version":
|
||||
"""This version with any candidate suffix stripped - what it would be
|
||||
once fixed. A no-op on a version that is already a release."""
|
||||
return Version(self.major, self.minor, self.patch)
|
||||
|
||||
def bumped(self, part: str) -> "Version":
|
||||
if part == "major":
|
||||
@@ -127,6 +184,10 @@ class Version:
|
||||
`0.1.9` share `(0, 1)`; `0.2.0` does not. An all-zero version has no
|
||||
non-zero component, so it compares by all three - during `0.0.x`
|
||||
every release is a breaking one, which is what that range means.
|
||||
|
||||
Computed over major/minor/patch alone, i.e. over the **base**: a
|
||||
candidate's pre-release suffix carries no compatibility information of
|
||||
its own, it is the base that will be released that does.
|
||||
"""
|
||||
components = (self.major, self.minor, self.patch)
|
||||
for index, component in enumerate(components):
|
||||
@@ -135,6 +196,44 @@ class Version:
|
||||
return components
|
||||
|
||||
|
||||
def _stage_between(reference: Version, base: Version) -> Optional[str]:
|
||||
"""Which part `base` has escalated past `reference` on, or None if equal.
|
||||
|
||||
Both are release-shaped (no beta): `reference` is the last real release,
|
||||
`base` is a candidate's base. Exactly one of major/minor/patch differs,
|
||||
because `bumped()` always resets everything to the right of the part it
|
||||
raises - so the leftmost differing component *is* the stage.
|
||||
"""
|
||||
for part in PARTS:
|
||||
if getattr(reference, part) != getattr(base, part):
|
||||
return part
|
||||
return None
|
||||
|
||||
|
||||
def escalate(last_release: Optional[Version], current: Version, part: str) -> Version:
|
||||
"""The next candidate: `current` escalated by `part` against `last_release`,
|
||||
max-wins.
|
||||
|
||||
A running candidate never steps back down: bumping `--patch` on a MINOR
|
||||
candidate only advances its bump count (`beta`), it does not lower the
|
||||
base. `last_release=None` is the fresh-distribution edge case - a
|
||||
changelog with no versioned entry at all - where there is nothing to
|
||||
escalate against, so the candidate's base is simply `current` bumped by
|
||||
`part`; see instructions/dev/version-parts.md for why that is not an
|
||||
error.
|
||||
"""
|
||||
if part not in _STAGE_RANK:
|
||||
raise VersionError(f"unknown version part {part!r} - expected one of {', '.join(PARTS)}")
|
||||
reference = last_release if last_release is not None else (
|
||||
current.base if current.is_prerelease else current
|
||||
)
|
||||
old_stage = _stage_between(reference, current.base) if current.is_prerelease else None
|
||||
new_stage = part if old_stage is None else max(old_stage, part, key=_STAGE_RANK.get)
|
||||
new_base = reference.bumped(new_stage)
|
||||
new_beta = (current.beta + 1) if (current.is_prerelease and current.base == new_base) else 1
|
||||
return Version(new_base.major, new_base.minor, new_base.patch, new_beta)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateStatus:
|
||||
"""The answer `version check` reports. `state` is the actionable part:
|
||||
@@ -323,6 +422,23 @@ def top_changes_version(text: str) -> Optional[Version]:
|
||||
return Version.parse(match.group(1))
|
||||
|
||||
|
||||
def last_release(text: str) -> Optional[Version]:
|
||||
"""The newest entry that is a **release**, not a running candidate, or
|
||||
`None` if the changelog names no release at all yet.
|
||||
|
||||
Entries are inserted newest-first (see `insert_changes_entry`), so the
|
||||
first non-pre-release heading found scanning top-down is the last release
|
||||
- whether or not the very top entry is an open candidate sitting above it.
|
||||
A changelog with no versioned entry (a fresh distribution) answers `None`,
|
||||
which `escalate()` treats as its own edge case rather than an error.
|
||||
"""
|
||||
for match in _CHANGES_ENTRY_RE.finditer(text):
|
||||
version = Version.parse(match.group(1))
|
||||
if not version.is_prerelease:
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
"""The body of one version's entry, heading included, ready to become
|
||||
release notes.
|
||||
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _bumps_block(titles: list[str]) -> str:
|
||||
lines = "\n".join(f"- {title}" for title in titles)
|
||||
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||
|
||||
|
||||
def _bump_titles(section: str) -> list[str]:
|
||||
match = _BUMPS_RE.search(section)
|
||||
if not match:
|
||||
return []
|
||||
return [
|
||||
line[2:].strip()
|
||||
for line in match.group(1).strip("\n").splitlines()
|
||||
if line.strip().startswith("- ")
|
||||
]
|
||||
|
||||
|
||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||
"""Add or replace the one-line `marker ...` paragraph in `section`.
|
||||
|
||||
Used for the breaking-change and no-migration lines, which - unlike the
|
||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||
restates it rather than growing a list nobody would read as history.
|
||||
"""
|
||||
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||
if pattern.search(section):
|
||||
return pattern.sub(line, section, count=1)
|
||||
anchor = section.find(_BUMPS_CLOSE)
|
||||
if anchor != -1:
|
||||
insert_at = section.find("\n", anchor)
|
||||
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
||||
else:
|
||||
insert_at = len(section)
|
||||
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
||||
|
||||
|
||||
def _entry_span(text: str) -> tuple[int, int]:
|
||||
"""Start/end offsets of the topmost entry, heading included."""
|
||||
match = re.search(r"^## ", text, re.MULTILINE)
|
||||
if not match:
|
||||
raise VersionError(f"{CHANGES_FILENAME} has no entry to update")
|
||||
start = match.start()
|
||||
following = re.search(r"^## ", text[start + 1:], re.MULTILINE)
|
||||
end = start + 1 + following.start() if following else len(text)
|
||||
return start, end
|
||||
|
||||
|
||||
def _update_open_candidate(
|
||||
text: str,
|
||||
version: Version,
|
||||
date: str,
|
||||
title: str,
|
||||
breaking_reason: Optional[str],
|
||||
no_migration_reason: Optional[str],
|
||||
) -> str:
|
||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||
`title` to its machine-managed bump list, and set the breaking/no-migration
|
||||
lines only where this call supplies them - see `insert_changes_entry`."""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||
|
||||
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
||||
|
||||
if breaking_reason:
|
||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||
if no_migration_reason:
|
||||
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
|
||||
|
||||
return text[:start] + section + text[end:]
|
||||
|
||||
|
||||
def insert_changes_entry(
|
||||
text: str,
|
||||
version: Version,
|
||||
@@ -351,18 +542,35 @@ def insert_changes_entry(
|
||||
no_migration_reason: Optional[str] = None,
|
||||
breaking_reason: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Add a heading for `version` above the newest existing entry.
|
||||
"""Open a new entry above the newest existing one, or - when the topmost
|
||||
entry is still an open candidate (a pre-release heading) - update that
|
||||
entry in place instead.
|
||||
|
||||
Only the skeleton: heading, date, author, and - when a compatibility
|
||||
boundary is crossed - the line saying what breaks, plus the line saying no
|
||||
content has to change where that applies. The entry's actual content is
|
||||
written afterwards by whoever made the change, which is also why `bump`
|
||||
refuses to invent a title.
|
||||
`version bump` always lands on a candidate (see `escalate`); only
|
||||
`version release` fixes one, and it edits the heading directly rather than
|
||||
through this path (`version_cmd.release_command`), which is what makes "is
|
||||
the topmost heading still a pre-release" the right test for "is a
|
||||
candidate still open" here.
|
||||
|
||||
The break comes first: it is what an operator reading the release notes has
|
||||
to act on, and the migration line only qualifies it.
|
||||
A fresh entry gets the skeleton only: heading, date, author, the
|
||||
machine-managed bump-title list (started with this one title, for a
|
||||
candidate), and - when a compatibility boundary is crossed - the line
|
||||
saying what breaks, plus the line saying no content has to change where
|
||||
that applies. The break comes first: it is what an operator reading the
|
||||
release notes has to act on, and the migration line only qualifies it. The
|
||||
entry's actual prose is written afterwards by whoever made the change,
|
||||
which is also why `bump` refuses to invent a title.
|
||||
"""
|
||||
top = top_changes_version(text)
|
||||
if top is not None and top.is_prerelease:
|
||||
return _update_open_candidate(
|
||||
text, version, date, title,
|
||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||
)
|
||||
|
||||
lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
|
||||
if version.is_prerelease:
|
||||
lines += [_bumps_block([title]), ""]
|
||||
if breaking_reason:
|
||||
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
|
||||
if no_migration_reason:
|
||||
@@ -372,3 +580,28 @@ def insert_changes_entry(
|
||||
if anchor:
|
||||
return text[: anchor.start()] + entry + text[anchor.start():]
|
||||
return text.rstrip() + "\n\n---\n\n" + entry
|
||||
|
||||
|
||||
def release_entry(text: str, date: str, title: Optional[str] = None) -> str:
|
||||
"""Fix the topmost entry: strip its version's `-beta.N` suffix and write
|
||||
today's heading, keeping the previous title unless `title` overrides it.
|
||||
|
||||
Leaves the rest of the entry - the bump-title list included - untouched:
|
||||
it is the record of what happened across the candidate's life, and a
|
||||
release call has no reason to discard it. `version_cmd.release_command`
|
||||
is the only caller; it has already checked the topmost entry names a
|
||||
pre-release, so a non-pre-release version reaching here is a caller bug.
|
||||
"""
|
||||
start, end = _entry_span(text)
|
||||
section = text[start:end]
|
||||
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||
if not heading_match:
|
||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||
|
||||
current = Version.parse(heading_match.group(1))
|
||||
rest = heading_match.group(2) or ""
|
||||
_, _, existing_title = rest.partition(" - ")
|
||||
new_title = title if title is not None else existing_title
|
||||
|
||||
section = f"## {current.base} - {date} - {new_title}" + section[heading_match.end():]
|
||||
return text[:start] + section + text[end:]
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ name: comparison
|
||||
description: Strukturierter Typ für Vergleichsseiten, die mehrere Entities oder Ansätze gegenüberstellen
|
||||
schema: types/comparison.schema.yaml
|
||||
base_dir: comparisons
|
||||
page_ref_fields: [entities]
|
||||
page_ref_fields: [entities, related]
|
||||
---
|
||||
|
||||
# Comparison
|
||||
@@ -32,6 +32,7 @@ page_ref_fields: [entities]
|
||||
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
|
||||
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
|
||||
| `entities` | Ja | Titel der verglichenen Entities |
|
||||
| `related` | Nein | Deklarierte ausgehende Kanten - je Subjekt eine `compares-with`-Kante, geschrieben von `wikitool xref add` |
|
||||
| `summary` | Ja | Einzeiler für `kb/index.md` |
|
||||
|
||||
## Autorenanweisungen
|
||||
|
||||
@@ -21,6 +21,23 @@ properties:
|
||||
type: string
|
||||
description: Entity titles being compared
|
||||
minItems: 2
|
||||
related:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
minProperties: 1
|
||||
maxProperties: 1
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: >-
|
||||
Declared outbound edges, in the same shape entity and concept pages use.
|
||||
A comparison's own assertion is `compares-with` against each subject: the
|
||||
titles are already in `entities:`, but that field is the untyped
|
||||
provenance-style list, so without this one the edge the page exists to
|
||||
make would live only in hand-written prose - which is the thing
|
||||
instructions/link-taxonomy.md was built to end.
|
||||
summary:
|
||||
type: string
|
||||
description: 1-line summary for index.md
|
||||
|
||||
Reference in New Issue
Block a user