Compare commits

..

6 Commits

Author SHA1 Message Date
torben d29d400dd3 feat: Versionskandidat statt Bump-pro-Release - VERSION traegt -beta.N, version release fixiert (4.4.0, #42)
CI / verify (push) Successful in 47s
Release / release (push) Successful in 35s
Files changed:
- .gitea/workflows/release.yml
- AGENTS.md
- CHANGES.md
- DEVELOPMENT.md
- README.md
- VERSION
- docs/version-model.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/kb_state.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_migrate_cmd.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-03 22:19:41 +02:00
torben b1883befc7 docs: Modellwahl nach Pruefbarkeit; stack-dev bricht an den Phasenwechseln fuer den Model-Switch (4.3.3)
CI / verify (push) Successful in 49s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- instructions/claude-code-model-selection.md
- instructions/dev/stack-dev/SKILL.md
2026-09-03 21:30:54 +02:00
torben 56ecfc7fee docs: stack-dev - Issue-Abschluss als nummerierter Schritt 5, Routing-Blurb rebalanciert (4.3.2, #45)
CI / verify (push) Successful in 47s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
2026-09-03 20:46:10 +02:00
torben 4e80a07ac7 docs/ befuellt - Stack-Hintergrund fuer vier Themen, Pflegeklausel in AGENTS.md ergaenzt (4.3.1, #45)
CI / verify (push) Successful in 48s
Release / release (push) Successful in 36s
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/ownership-and-templates.md
- docs/pipeline-rationale.md
- docs/version-model.md
- docs/why-gates-are-code.md
2026-09-03 19:40:39 +02:00
torben 0b8ca746fa docs/ als ausgelieferter Hintergrund-Ort; Decision-Seiten bleiben in kb/, Decay-Skip fuer concept_type: decision (4.3.0, #38)
CI / verify (push) Successful in 49s
Release / release (push) Successful in 36s
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/kb-profiles.md
- kb/CONVENTIONS.md
- kb/concepts/COLLECTION.md
- tools/CONTRACT.md
- 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
2026-09-03 19:01:21 +02:00
torben 9b461421e8 feat: Korpus-Kuratierungsrichtlinie - Floors und Leitplanke fuer reaktive Fixes (4.2.0, #28)
CI / verify (push) Successful in 52s
Release / release (push) Successful in 37s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/corpus-policy.md
- instructions/dev/stack-dev/SKILL.md
2026-09-03 08:11:16 +02:00
32 changed files with 1868 additions and 159 deletions
+23
View File
@@ -66,6 +66,26 @@ jobs:
run: | run: |
set -eu set -eu
version="$(cat VERSION | tr -d '[:space:]')" 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}" tag="v${version}"
echo "version=${version}" >> "$GITHUB_OUTPUT" echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT"
@@ -82,6 +102,7 @@ jobs:
- name: Release notes from CHANGES.md - name: Release notes from CHANGES.md
# `version notes` fails when the changelog has no entry for this # `version notes` fails when the changelog has no entry for this
# version, which is the last place that mistake can still be caught. # version, which is the last place that mistake can still be caught.
if: steps.version.outputs.skip != 'true'
run: | run: |
set -eu set -eu
tools/wikitool docs verify tools/wikitool docs verify
@@ -90,6 +111,7 @@ jobs:
- name: Build the distribution tarball - name: Build the distribution tarball
id: build id: build
if: steps.version.outputs.skip != 'true'
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
@@ -109,6 +131,7 @@ jobs:
echo "name=${name}" >> "$GITHUB_OUTPUT" echo "name=${name}" >> "$GITHUB_OUTPUT"
- name: Publish the release - name: Publish the release
if: steps.version.outputs.skip != 'true'
env: env:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TOKEN: ${{ gitea.token }} TOKEN: ${{ gitea.token }}
+21 -1
View File
@@ -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 | | `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 | | `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 | | `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 | | `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 | | `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>.md` | Agents | By link, or on explicit request |
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published | | `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim | | `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
| `INDEX.md` | Both | Generated - never hand-edited | | `INDEX.md` | Both | Generated - never hand-edited |
A stage may carry both a `README.md` and a `CONTRACT.md`: different readers, different 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 contract is a second copy that drifts. `docs verify` enforces the specific case that already
happened once: no README may hold a copy of the `wikitool` command table. happened once: no README may hold a copy of the `wikitool` command table.
**`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 ## Personalization
`USER.md` and `SOUL.md` are read at session start, if the runtime has not already injected `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 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: **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 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 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. 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.
+270
View File
@@ -18,6 +18,276 @@ heading, and `wikitool docs verify` refuses a tree whose `VERSION` and newest
versioned entry disagree. Entries below `0.1.0` predate versioning and keep versioned entry disagree. Entries below `0.1.0` predate versioning and keep
their date-only headings. 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 ## 4.1.2 - 2026-09-03 - Issue-Abschluss ist ein Body-Rewrite, nicht nur ein Kommentar
+99
View File
@@ -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.
+1
View File
@@ -115,6 +115,7 @@ chemenu/
Dev-instance-only (see `tools/CONTRACT.md` for how it got here): 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 └── commonplace/ # Vendored, read-only knowledge base
``` ```
<!-- dist:strip-end --> <!-- dist:strip-end -->
+1 -1
View File
@@ -1 +1 @@
4.1.2 4.4.0
+71
View File
@@ -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.
+64
View File
@@ -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.
+114
View File
@@ -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).
+57
View File
@@ -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.
+60 -16
View File
@@ -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 # 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 Scale the model and effort to **what catches a mistake in this part of the work** - not to how
the most capable model and highest effort is safe but wasteful: the gates in [gates.md](gates.md) important the task feels, and not to its name. Running everything at the most capable model and
are enforced in code, not by model judgment, so a weaker model cannot bypass them - it can only highest effort is safe but wasteful: the gates in [gates.md](gates.md) are enforced in code, not
do a worse job of the calls the gates don't cover. 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, 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 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 ## Steps
1. **Recommend the session's model and effort by the skill in use**, when asked or when the 1. **Recommend the session's model and effort by what catches a mistake in the phase it is in**,
mismatch is worth one sentence. Say it once and continue working either way - a session that when asked or when the mismatch is worth one sentence. Say it once and continue working either
argues about its own model instead of doing the task has already cost more than the model way - a session that argues about its own model instead of doing the task has already cost
difference: more than the model difference:
| Skill / task | Model | Effort | | Phase / task | What catches a mistake here | Model | Effort |
|---|---|---| |---|---|---|---|
| `wiki-status`, simple `wiki-query` lookups | Sonnet | default | | `wiki-status`, simple `wiki-query` lookups | the answer is re-checkable against the corpus | Sonnet | default |
| `wiki-lint` | Sonnet | default | | `wiki-lint` | `lint` itself is the check | Sonnet | default |
| `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | Sonnet | high | | `wiki-ingest`, `wiki-manage`, judgment-heavy `wiki-query` | `lint` and `docs verify`, partly - the judgment about a claim is not covered | Sonnet | high |
| Stack development: `tools/`, `types/`, `instructions/` as code | Opus | 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:` 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`: 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 mechanical one - `wikitool` carries the mechanical part regardless of which model is
supervising it. supervising it.
- **Unsure which row applies?** Default to Sonnet at high effort, not the most capable model at - **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 the highest effort. Under-provisioning *where a check exists* costs one worse answer in one
over-provisioning is a standing cost paid every session. 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 ## Scope
+94
View File
@@ -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.
+71 -7
View File
@@ -45,18 +45,44 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work [issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
of the issue you are working on is this session's plan file:** keep it current as the state of the issue you are working on is this session's plan file:** keep it current as the state
moves, not at the end, so an interrupted session leaves a body the next one can resume from. moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
Read it before filing something for later, before editing or closing an issue, or before to its final state before closing. Both halves bind; the second is step 6 below. Read it
deciding what to pick up next. 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 [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 empty machine; what the autouse fixture already neutralizes, and what a test still has to
establish itself. Read it before adding or changing a test. establish itself. Read it before adding or changing a test.
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the [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 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 More instructions are added here incrementally as stack-development needs come up - this
list grows without needing this skill file to change shape. 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 `instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
version and a changelog entry: version and a changelog entry:
@@ -93,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/` 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. 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 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 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 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 `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. 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 ## Decision points
- **Touches both stack code and wiki content in one session?** Apply this skill's rules to the - **Touches both stack code and wiki content in one session?** Apply this skill's rules to the
@@ -110,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 user decides whether it is worth that: show them what breaks, what an instance has to do about
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one, it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
or split it behind a deprecation window), then recommend one and wait for a go-ahead. or split it behind a deprecation window), then recommend one and wait for a go-ahead.
[version-parts.md](../version-parts.md) step 4 has the full shape. [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 ## Scope
+56 -12
View File
@@ -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 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. 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 ## When to run
Before every `tools/wikitool version bump` - the `stack-dev` skill's step 3 sends you here. 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` | | Fix, no interface change | `--patch` |
| New capability, drop-in in both directions | `--minor` | | 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 4. **Stop and talk to the user before the bump that first escalates a candidate past the
other two parts are not: every existing instance pays for it, once, by hand. Put in front of boundary.** It is expensive in a way the other two parts are not: every existing instance pays
them, in this order: 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 breaks**, concretely - which file, which name, which call site.
- **What each existing instance must do**, as the steps they would actually run. - **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. 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 5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry: requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
entry:
```bash ```bash
tools/wikitool version bump --major \ 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 --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; The line, once written, stays in the entry across every later bump of the same candidate -
`docs verify` checks the newest boundary-crossing entry still carries the line. Write it for a follow-up `--major` does not need to repeat `--breaking`, because the entry it would repeat
the operator of an instance that has not read this repository: what stops working, and what it into is the same one. `--breaking` is refused on a bump that crosses nothing, and required
they do about it. 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 6. **Then answer the migration question separately.** Boundary-crossing and
content-migrating are independent: content-migrating are independent:
- Content must change → write the migration document under `instructions/migrations/` per - 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. - 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 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 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 cannot reconstruct from the diff, and it is what the next session in this position will read
instead of guessing. instead of guessing.
+1 -1
View File
@@ -68,7 +68,7 @@ stack's hardcoded behaviour until the conventions file existed.
|---|---| |---|---|
| `language:` | `de` | | `language:` | `de` |
| `sections:` | `Beziehungen` / `Siehe auch` / `Fußnoten` | | `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 | | 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` | | 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 | | 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 |
+2 -1
View File
@@ -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. - Human-readable titles with spaces: `Hybrid Search.md`, `Gitea Actions.md` - not kebab-case.
- Singular for entities: `ha-core.md`, not `ha-cores.md`. - Singular for entities: `ha-core.md`, not `ha-cores.md`.
- Comparison pages read as a comparison: `Go vs Rust.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. - Prefer readability over convention when the two conflict.
What to name a thing: projects use their repository or common name; systems a descriptive What to name a thing: projects use their repository or common name; systems a descriptive
+18 -10
View File
@@ -27,19 +27,27 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
`concept` (`tools/wikitool types describe concept`). `concept` (`tools/wikitool types describe concept`).
## Decisions and ADRs ## Decisions
An architectural decision is a concept page, prefixed as An architectural decision is an ordinary concept page with `concept_type: decision`
[kb/CONVENTIONS.md § Naming](../CONVENTIONS.md#naming) says. It records: (`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. The body is organic prose under this collection's usual sections, not a fixed template. What it
- **Decision** - what was chosen. still has to carry: what was decided, what forced the decision, what it costs (not only what it
- **Consequences** - what this costs, not only what it buys. buys), and a link to every entity the decision affects. A `**Status:**` line is optional - most
- **Status** - proposed / accepted / deprecated / superseded. decision pages in this instance carry none, because the page's own prose already says whether the
- Links to every entity the decision affects. decision stands.
A superseded ADR is never deleted or rewritten. The new one declares `supersedes` pointing at A decision superseded by a later one is never deleted or rewritten. The new page declares
it; the old one needs no edge back, because its inbound view renders the replacement. `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 ## Authorised labels
+6 -4
View File
@@ -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 sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout | | `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only | | `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
| `docs verify` | Check the docs that mirror the code: every CLI command documented 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 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) | | `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 show [--json]` | Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare `wikitool version` is an alias for this. Read-only, offline, and **exempt from the Iteration Budget Gate** |
| `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate | | `version check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **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 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 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 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 | | `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 | | `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 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 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 | | `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 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 | | `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 idempotent - decaying the stored `confidence` in place (the pre-2026-08-13
behavior) compounded on every run, because the elapsed-months factor kept behavior) compounded on every run, because the elapsed-months factor kept
growing while the multiplicand had already shrunk. 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 from __future__ import annotations
@@ -118,6 +126,8 @@ def confidence_decay(
missing_base = [] missing_base = []
for title, page in sorted(pages.items()): for title, page in sorted(pages.items()):
if page.frontmatter.get("concept_type") == "decision":
continue
confidence = page.frontmatter.get("confidence") confidence = page.frontmatter.get("confidence")
if confidence is None: if confidence is None:
continue continue
+24 -2
View File
@@ -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 # 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 # optional. A distribution whose server is present but undocumented is one
# whose operator finds the module by reading the source. # 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 = ( ROOT_FILES = (
"AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md", "AGENTS.md", "CLAUDE.md", "README.md", "EVALS.md", "INSTALL.md", "INSTALL-MCP.md",
".gitignore", "VERSION", ".gitignore", "VERSION",
@@ -347,6 +356,12 @@ def build_plan(origin: Optional[Origin] = None) -> dict[str, PlannedFile]:
for hook_dir in HOOK_DIRS: for hook_dir in HOOK_DIRS:
plan.update(_copy_tree(config.ROOT / hook_dir, hook_dir, frozenset())) 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 # `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 # under `kb/` is the instance's own and ships only as a `.template`. That is
# the personalization split (`USER.md`/`SOUL.md`) one directory down, and # the 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 # machinery expects - which is exactly what makes the initial declaration
# safe to write here rather than leaving it to `migrate baseline`. Only an # safe to write here rather than leaving it to `migrate baseline`. Only an
# instance predating this file has to answer that question by hand. # 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( 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 # 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: """Export a contentless, distributable copy of this repo's machinery:
AGENTS.md/README.md (dev-instance-only marker blocks removed), 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, the .github/hooks/+.vibe session-tracing config plus .claude/settings.json,
kb/CONTRACT.md plus a COLLECTION.md.template per collection and kb/CONTRACT.md plus a COLLECTION.md.template per collection and
kb/CONVENTIONS.md.template (no pages, no areas), empty kb/CONVENTIONS.md.template (no pages, no areas), empty
+20 -23
View File
@@ -453,10 +453,12 @@ def check_version_changelog() -> list[str]:
This is the check that makes `version bump` more than a convenience: a 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 version raised with nothing written about it would ship a release whose
notes describe the previous one. A changelog with *no* versioned entry at notes describe the previous one. `VERSION` may name a running candidate
all is fine - that is a fresh distribution, and this repo's own pre- (`-beta.N`) rather than a release - `Version.parse`/equality read the
versioning history, neither of which claims to describe the current suffix like any other component, so a candidate is compared exactly like a
version. 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 version_path = config.ROOT / version_mod.VERSION_FILENAME
if not version_path.is_file(): if not version_path.is_file():
@@ -483,15 +485,6 @@ def check_version_changelog() -> list[str]:
return [] 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]: def check_migration_for_boundary() -> list[str]:
"""A version that crosses the compatibility boundary must say how to cross it. """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 document targeting it, or an explicit statement in its changelog entry that
no content has to change. no content has to change.
Only the newest entry is checked. Older boundaries were either satisfied Only the newest entry is checked, against the **last release** rather than
when they were written or cannot be fixed retroactively, and re-reporting the entry beneath it - between two candidates of the same running upgrade
them forever would make the check noise. (`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 from chemenu import kb_state
@@ -514,15 +510,15 @@ def check_migration_for_boundary() -> list[str]:
text = changes_path.read_text(encoding="utf-8") text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text) 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: 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: if current.compat_key == previous.compat_key:
return [] return []
if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""): if version_mod.MIGRATION_NONE_MARKER in (version_mod.changes_section(text, current) or ""):
return [] 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 []
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 import name or flag - satisfies that check and still leaves every existing
instance with something to do by hand. instance with something to do by hand.
Only the newest entry is checked, for the same reason: older crossings are Only the newest entry is checked, against the **last release** - see
history, and re-reporting them forever would make the check noise. `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 changes_path = config.ROOT / version_mod.CHANGES_FILENAME
version_path = config.ROOT / version_mod.VERSION_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") text = changes_path.read_text(encoding="utf-8")
current = version_mod.top_changes_version(text) 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: 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: if current.compat_key == previous.compat_key:
return [] return []
+3 -2
View File
@@ -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')}" 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: def check_kb_version() -> Check:
@@ -420,7 +421,7 @@ def check_kb_version() -> Check:
"never lagged behind its machinery", "never lagged behind its machinery",
) )
if kb_version < stack: 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: if pending:
return Check( return Check(
"kb-version", "WARN", "kb-version", "WARN",
+3 -3
View File
@@ -160,7 +160,7 @@ def status_command(
) )
return 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())) offered = kb_state.offers(migrations, kb_state.applied_names(kb_state.read_kb_state()))
divergent = kb_state.divergent_files() divergent = kb_state.divergent_files()
@@ -271,7 +271,7 @@ def done_command(
) )
return return
expected = kb_state.next_link(migrations, kb_version, stack) expected = kb_state.next_link(migrations, kb_version, stack.base)
if expected is None: if expected is None:
fail( fail(
f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no " f"Nothing is outstanding: content is at {kb_version}, machinery at {stack}, and no "
@@ -297,7 +297,7 @@ def done_command(
return return
kb_state.write_kb_state(target, applied) kb_state.write_kb_state(target, applied)
remaining = kb_state.chain(migrations, target, stack) remaining = kb_state.chain(migrations, target, stack.base)
success( success(
f"Content is now {target} ({expected.name}). " f"Content is now {target} ({expected.name}). "
+ ( + (
+120 -35
View File
@@ -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 Four jobs that all hang off one number (see `chemenu/version.py` for what that
that number means): number means, and `instructions/dev/version-parts.md` for the candidate model):
- `version show` answers "which stack is this instance running", offline, from - `version show` answers "which stack is this instance running", offline, from
`VERSION` plus the release stamp `dist export` writes. `VERSION` plus the release stamp `dist export` writes.
- `version bump` moves it, and writes the changelog *heading* that has to - `version bump` raises or continues the one running candidate between two
accompany the move - the same structure-by-tool/prose-by-author split as releases, and writes the changelog *heading* that has to accompany it - the
`new`. `docs verify` then holds the two together. 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. - `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 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 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)"), major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"),
minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"), minor: bool = typer.Option(False, "--minor", help="Bump MINOR (resets PATCH)"),
patch: bool = typer.Option(False, "--patch", help="Bump 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( breaking: Optional[str] = typer.Option(
None, None,
"--breaking", "--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( no_migration: Optional[str] = typer.Option(
None, None,
"--no-migration", "--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"), 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 Between two releases the stack carries **one** candidate, not a fresh
entry's body stays the author's to write, the same way `new` produces number per bump: `--patch/--minor/--major` is max-wins escalation against
frontmatter and leaves the prose. `docs verify` afterwards enforces that the last release, never a step back down, and the candidate's bump count
the two agree, so a bump with no entry cannot reach a release. (`-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 A bump whose escalation first crosses the compatibility boundary - the new
not a drop-in replacement, whether or not any content moves - requires version is not a drop-in replacement, whether or not any content moves -
`--breaking "<what stops working>"`, and on top of that either a migration requires `--breaking "<what stops working>"`, and on top of that either a
document for the new version or `--no-migration "<reason>"`. An instance migration document for the new base or `--no-migration "<reason>"`. Both
learning that it must migrate, with nothing telling it what broke or how to lines are written into the entry once and then persist across every later
cross, is the gap these close. Which part to pass stays a judgment call bump at the same stage: a follow-up bump need not repeat them, and passing
this command does not make - it enforces only that a crossing says what it either on a bump that crosses nothing at all is refused."""
costs."""
selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen] selected = [name for name, chosen in (("major", major), ("minor", minor), ("patch", patch)) if chosen]
if len(selected) != 1: if len(selected) != 1:
fail("Pass exactly one of --major / --minor / --patch") fail("Pass exactly one of --major / --minor / --patch")
@@ -226,7 +232,6 @@ def bump_command(
try: try:
current = version_mod.read_version() current = version_mod.read_version()
new_version = current.bumped(selected[0])
except VersionError as exc: except VersionError as exc:
fail(str(exc)) fail(str(exc))
return return
@@ -236,19 +241,29 @@ def bump_command(
fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself") fail(f"{version_mod.CHANGES_FILENAME} is missing - a bump has nowhere to record itself")
return return
text = changes.read_text(encoding="utf-8") 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( fail(
f"{version_mod.CHANGES_FILENAME} already documents {existing}, which is not older " f"{version_mod.CHANGES_FILENAME}'s newest entry is {top_entry}, but "
f"than {new_version} - bump past it, or fix the changelog" f"{version_mod.VERSION_FILENAME} is {current} - they must agree before a bump. "
"Fix whichever is wrong."
) )
return return
last_release = version_mod.last_release(text)
new_version = version_mod.escalate(last_release, current, selected[0])
author = config.default_author() or "unknown" 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 "" 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( fail(
f"{current} -> {new_version} crosses the compatibility boundary, so it is not a " 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 " f"drop-in replacement - re-run with --breaking \"<what stops working, and what an "
@@ -265,14 +280,14 @@ def bump_command(
) )
return return
if crossing and not no_migration: if crossing and not was_already_crossing and not no_migration:
from chemenu import kb_state 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( fail(
f"{current} -> {new_version} crosses the compatibility boundary, so every existing " f"{current} -> {new_version} crosses the compatibility boundary, so every existing "
f"instance must migrate - but no migration document targets {new_version}.\n" 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}-<slug>.md " 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"(see instructions/migrate-corpus.md), or, if no content actually has to change, "
f're-run with --no-migration "<reason>".' f're-run with --no-migration "<reason>".'
) )
@@ -298,6 +313,76 @@ def bump_command(
encoding="utf-8", encoding="utf-8",
) )
success( success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and opened " f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry - write its body before publishing." 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."
) )
+18 -1
View File
@@ -78,6 +78,10 @@ def read_kb_version() -> Optional[Version]:
None is a real state, not an error: an instance created before the KB 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 version existed has content of unknown vintage, and guessing would be
worse than asking (`migrate baseline`). 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() state = read_kb_state()
if state is None: if state is None:
@@ -85,7 +89,13 @@ def read_kb_version() -> Optional[Version]:
raw = state.get("kb_version") raw = state.get("kb_version")
if not raw: if not raw:
return None 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]: def read_kb_state() -> Optional[dict]:
@@ -171,6 +181,13 @@ def chain(
Targets above the installed machinery are excluded: the instance has no code Targets above the installed machinery are excluded: the instance has no code
for them yet. 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 `offered` migrations are deliberately absent. They are not links in the
version chain: declining one leaves the content in a shape the machinery version chain: declining one leaves the content in a shape the machinery
still accepts, so counting it as owed would make `kb_version` unreachable still accepts, so counting it as owed would make `kb_version` unreachable
+20 -1
View File
@@ -5,7 +5,7 @@ import pytest
from chemenu import config from chemenu import config
from chemenu.commands import confidence_decay from chemenu.commands import confidence_decay
from chemenu.commands.confidence_decay import FLOOR, compute_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(): 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) confidence_decay.confidence_decay(apply=True)
frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md") frontmatter, _ = read_page(decay_wiki / "entities/systems/aurora.md")
assert frontmatter["confidence"] == 0.9 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
+25
View File
@@ -135,6 +135,10 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
f"<!-- {config.TEMPLATE_SENTINEL} -->\n# conventions template\n", encoding="utf-8" 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"): for relative in ("raw/CONTRACT.md", "reports/CONTRACT.md", "work/CONTRACT.md"):
path = root / relative path = root / relative
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -156,6 +160,11 @@ def test_plan_never_includes_commonplace(repo):
assert "commonplace" not in combined 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): def test_plan_never_includes_instructions_dev(repo):
"""instructions/dev/ - flat dev-only instructions and the nested skill """instructions/dev/ - flat dev-only instructions and the nested skill
that switches a session into tool-development mode - is pruned 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"] == [] 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): def test_export_refuses_a_tree_with_no_version(repo, tmp_path):
(repo / "VERSION").unlink() (repo / "VERSION").unlink()
target = tmp_path / "dist" target = tmp_path / "dist"
+28 -9
View File
@@ -190,6 +190,12 @@ def test_this_repos_boundary_is_accounted_for():
def _boundary_tree(tmp_path, monkeypatch, current: str, previous: str, marker: str = ""): 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 / "VERSION").write_text(f"{current}\n", encoding="utf-8")
(tmp_path / "CHANGES.md").write_text( (tmp_path / "CHANGES.md").write_text(
"# Changelog\n\n---\n\n" "# 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): 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 """`version check` tells an instance it must migrate; without this, that is
where the trail ends.""" 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() 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): 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() == [] 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): def test_an_explicit_none_required_marker_satisfies_the_check(tmp_path, monkeypatch):
from chemenu import version as version_mod from chemenu import version as version_mod
_boundary_tree( _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", marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
) )
assert docs_verify.check_migration_for_boundary() == [] assert docs_verify.check_migration_for_boundary() == []
def test_a_migration_document_satisfies_the_check(tmp_path, monkeypatch): 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( (root / "instructions" / "migrations" / "2.0.0-retype.md").write_text(
"---\ntype: types/instruction.md\nname: 2.0.0-retype\n" "---\ntype: types/instruction.md\nname: 2.0.0-retype\n"
"description: Retype.\nmanual: true\nmigrates_to: 2.0.0\n---\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 from chemenu import version as version_mod
_boundary_tree( _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", marker=f"{version_mod.MIGRATION_NONE_MARKER} - nothing to change.\n\n",
) )
assert docs_verify.check_migration_for_boundary() == [] assert docs_verify.check_migration_for_boundary() == []
issues = docs_verify.check_breaking_change_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): 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() == [] 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 from chemenu import version as version_mod
_boundary_tree( _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", marker=f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved.\n\n",
) )
assert docs_verify.check_breaking_change_for_boundary() == [] assert docs_verify.check_breaking_change_for_boundary() == []
+27
View File
@@ -162,6 +162,33 @@ def test_an_unreadable_kb_state_fails(instance):
assert _status(doctor.run_doctor(), "kb-version") == "FAIL" 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): def test_no_collections_at_all_fails_structure(instance):
"""Removing one collection is a legitimate state - collections are """Removing one collection is a legitimate state - collections are
discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md). discovered by COLLECTION.md presence, not a fixed list (kb/CONTRACT.md).
+24 -1
View File
@@ -11,7 +11,7 @@ import typer
from chemenu import config, kb_state from chemenu import config, kb_state
from chemenu.commands import migrate_cmd 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" 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"] 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): def test_list_reports_every_document_sorted_by_target(instance, capsys):
migrate_cmd.list_command(json_out=True) migrate_cmd.list_command(json_out=True)
targets = [m["migrates_to"] for m in json.loads(capsys.readouterr().out)] 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 -------------------------------------------------------------- # --- 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): def test_baseline_declares_the_version_once(instance):
migrate_cmd.baseline_command(version="1.3.1", force=False) migrate_cmd.baseline_command(version="1.3.1", force=False)
assert kb_state.read_kb_version() == Version(1, 3, 1) assert kb_state.read_kb_version() == Version(1, 3, 1)
+265 -10
View File
@@ -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 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(): 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") status = version_mod.UpdateStatus(Version(0, 1, 0), Version(0, 2, 0), "migration")
assert "migration" in status.headline.lower() 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 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" text = CHANGES_HEADER + "## 0.1.0 - 2026-08-29 - Older\n\nBody.\n"
result = version_mod.insert_changes_entry( 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 "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 ---------------------------------------------------------- # --- 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", major=False, minor=True, patch=False, title="Something happened",
breaking=None, no_migration=None, dry_run=False, 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") 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 "Something happened" in changes
assert "**Author:** Test Author" 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): def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command( version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, dry_run=True 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): def test_bump_refuses_when_version_and_changelog_disagree(tree):
"""A changelog documenting a version the tree has not reached means """A changelog whose newest entry names a different version than VERSION
someone edited one of the two by hand; bumping past it would hide that.""" means someone edited one of the two by hand; bumping past it would hide
that instead of surfacing it."""
(tree / "CHANGES.md").write_text( (tree / "CHANGES.md").write_text(
CHANGES_HEADER + "## 1.5.0 - 2026-09-01 - Ahead\n\nBody.\n", encoding="utf-8" 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", major=True, minor=False, patch=False, title="Breaking",
breaking="every page is retyped", no_migration=None, dry_run=False, 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): 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" 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 --------------------------------------------------------- # --- 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 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): def test_notes_fails_for_a_version_with_no_entry(tree):
with pytest.raises(typer.Exit): with pytest.raises(typer.Exit):
version_cmd.notes_command(version="9.9.9") version_cmd.notes_command(version="9.9.9")
+251 -18
View File
@@ -33,6 +33,7 @@ because the tests (and `dist export`'s own fixtures) relocate the root.
""" """
from __future__ import annotations from __future__ import annotations
import functools
import json import json
import os import os
import re import re
@@ -42,7 +43,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from chemenu import config from chemenu import blocks, config
VERSION_FILENAME = "VERSION" VERSION_FILENAME = "VERSION"
CHANGES_FILENAME = "CHANGES.md" CHANGES_FILENAME = "CHANGES.md"
@@ -65,12 +66,24 @@ UPDATE_URL_ENV = "WIKITOOL_UPDATE_URL"
UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN" UPDATE_TOKEN_ENV = "WIKITOOL_UPDATE_TOKEN"
PARTS = ("major", "minor", "patch") 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 # `x.y.z`, optionally followed by exactly one pre-release channel: `-beta.<n>`.
# second ordering rule everywhere a version is compared - the release feed, the # Deliberately not a general SemVer pre-release alphabet - one channel keeps the
# migration chain, the compatibility check - to serve a workflow this stack does # ordering numeric and total. See "Candidates and releases" below.
# not have. _SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?\s*$")
_SEMVER_RE = re.compile(r"^\s*v?(\d+)\.(\d+)\.(\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 # Written into a CHANGES.md entry whose version crosses a compatibility
# boundary that needs no content migration. `docs verify` accepts it in place # 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 # A changelog entry that names a version. Entries predating versioning start
# with a date instead and are deliberately not matched - they are history, not # with a date instead and are deliberately not matched - they are history, not
# a claim about which version the tree is. # 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): class VersionError(ValueError):
@@ -92,11 +105,34 @@ class VersionError(ValueError):
written to be shown to the user verbatim.""" written to be shown to the user verbatim."""
@dataclass(frozen=True, order=True) @functools.total_ordering
@dataclass(frozen=True)
class Version: 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 major: int
minor: int minor: int
patch: int patch: int
beta: Optional[int] = None
@classmethod @classmethod
def parse(cls, text: str) -> "Version": def parse(cls, text: str) -> "Version":
@@ -104,11 +140,32 @@ class Version:
if not match: if not match:
raise VersionError( 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 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": def bumped(self, part: str) -> "Version":
if part == "major": 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 `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` 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. 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) components = (self.major, self.minor, self.patch)
for index, component in enumerate(components): for index, component in enumerate(components):
@@ -135,6 +196,44 @@ class Version:
return components 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) @dataclass(frozen=True)
class UpdateStatus: class UpdateStatus:
"""The answer `version check` reports. `state` is the actionable part: """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)) 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]: def changes_section(text: str, version: Version) -> Optional[str]:
"""The body of one version's entry, heading included, ready to become """The body of one version's entry, heading included, ready to become
release notes. release notes.
@@ -342,6 +458,81 @@ def changes_section(text: str, version: Version) -> Optional[str]:
return None 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( def insert_changes_entry(
text: str, text: str,
version: Version, version: Version,
@@ -351,18 +542,35 @@ def insert_changes_entry(
no_migration_reason: Optional[str] = None, no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None, breaking_reason: Optional[str] = None,
) -> str: ) -> 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 `version bump` always lands on a candidate (see `escalate`); only
boundary is crossed - the line saying what breaks, plus the line saying no `version release` fixes one, and it edits the heading directly rather than
content has to change where that applies. The entry's actual content is through this path (`version_cmd.release_command`), which is what makes "is
written afterwards by whoever made the change, which is also why `bump` the topmost heading still a pre-release" the right test for "is a
refuses to invent a title. candidate still open" here.
The break comes first: it is what an operator reading the release notes has A fresh entry gets the skeleton only: heading, date, author, the
to act on, and the migration line only qualifies it. 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}", ""] lines = [f"## {version} - {date} - {title}", "", f"**Author:** {author}", ""]
if version.is_prerelease:
lines += [_bumps_block([title]), ""]
if breaking_reason: if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""] lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if no_migration_reason: if no_migration_reason:
@@ -372,3 +580,28 @@ def insert_changes_entry(
if anchor: if anchor:
return text[: anchor.start()] + entry + text[anchor.start():] return text[: anchor.start()] + entry + text[anchor.start():]
return text.rstrip() + "\n\n---\n\n" + entry 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:]