Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0dc2129bb | |||
| f140e26a4c | |||
| 0fb8fd6122 | |||
| dc688e5726 | |||
| 1b0158fc8d | |||
| 9a2d7d34f5 | |||
| 9ef021bea1 |
+199
-7
@@ -25,13 +25,205 @@ 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
|
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
|
opening another: the heading's version/date/title move, and the bump's
|
||||||
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
|
||||||
the entry's `**Author:**` line - written and read by `wikitool version bump`,
|
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
|
||||||
never by hand. `wikitool version release` is what closes a candidate: it
|
written and read by `wikitool version bump`, never by hand.
|
||||||
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
|
That list is graded, not a flat chronological dump: each bump carries an
|
||||||
instance never sees a `-beta.` version at all (`release.yml` only ever
|
impact (`--impact high|medium|low`, default `medium`), and the list renders
|
||||||
releases a fixed one), so this suffix and the list beneath it are a
|
grouped under `**High/Medium/Low impact**` headings - except when every bump
|
||||||
dev-checkout concern - readable here, never shipped as something to parse.
|
so far is `medium`, where it stays flat with no headings at all, exactly as
|
||||||
|
it always did before grading existed. `wikitool version regrade` corrects a
|
||||||
|
grade after the fact, against a single read of the whole list. Below the
|
||||||
|
list comes a short summary paragraph, written once at release time, and below
|
||||||
|
that one `### <bump title>` changeset per bump, in chronological order -
|
||||||
|
`wikitool version release` refuses to close a candidate that collected two or
|
||||||
|
more bumps and has no summary there (a one-bump candidate is exempt, since its
|
||||||
|
single changeset already reads as one). This layering exists because a
|
||||||
|
long-running candidate's bump list, left flat and ungraded, grows unreadable
|
||||||
|
as a release announcement - the concrete case that forced it was `5.0.0`, one
|
||||||
|
entry across roughly 1440 lines.
|
||||||
|
|
||||||
|
`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 list,
|
||||||
|
summary and changesets 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 the suffix and everything below the heading are a dev-checkout
|
||||||
|
concern - readable here, never shipped as something to parse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.0.0-beta.2 - 2026-09-13 - docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
**Breaking Change:** docs verify loest ab dieser Version jeden relativen Markdown-Link in den Referenzdateien auf und meldet ein totes Ziel als Fehler - auch in kb/CONVENTIONS.md und kb/<collection>/COLLECTION.md, die eine Instanz selbst besitzt und die ein Drop-in-Copy der Maschinerie nicht ersetzt. Eine Instanz, deren eigene Konventions- oder Collection-Datei einen relativen Link mit falscher ../-Tiefe oder auf eine inzwischen geloeschte Datei traegt, sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief. Reparatur: den in der Meldung genannten Datei:Zeile-Link korrigieren - kein Werkzeuglauf, keine Inhaltsmigration.
|
||||||
|
|
||||||
|
**Migration:** none required - Keine kb/-Seite aendert ihre Form. Der Grenzuebertritt ist ein strengerer Check auf instanz-eigener Prosa, keine Schema- oder Frontmatteraenderung.
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||||
|
- docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||||
|
|
||||||
|
**Medium impact**
|
||||||
|
- docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||||
|
|
||||||
|
**Low impact**
|
||||||
|
- gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
### gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
|
||||||
|
|
||||||
|
Doku-Nachzug zu `5.1.0`. Beide Dateien beschrieben die Budget-Ausnahme als feste Liste pro
|
||||||
|
Kommandoname ("fixed allowlist"); `version regrade` ist die erste Ausnahme, die nur in *einer*
|
||||||
|
Aufrufform liest - bar listet sie, mit Positionen schreibt sie `CHANGES.md`. Die Liste selbst
|
||||||
|
bleibt an ihrem einen Ort (`tools/CONTRACT.md`), beide Stellen benennen jetzt aber, dass dort ein
|
||||||
|
Eintrag pro Aufruf statt pro Namen gilt. Aufgefallen in der Schlussphase derselben Arbeit, deshalb
|
||||||
|
ein eigener Patch-Bump: der Pfad `instructions/` liegt im Version-Gate der CI.
|
||||||
|
|
||||||
|
### SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
|
||||||
|
|
||||||
|
`instructions sync` kopiert jede `SKILL.md` byteidentisch in `.agents/skills/` und
|
||||||
|
`.claude/skills/` - eine andere Verzeichnistiefe als die Quelle, ohne deren Nachbardateien. 52 von
|
||||||
|
58 relativen Markdown-Links in den sieben Skills zeigten deshalb in der publizierten Kopie ins
|
||||||
|
Leere, unbemerkt, weil kein Check je ein Linkziel gelesen hat (Gitea-Meldung: ein `session-setup.md`-Read
|
||||||
|
schlug in einer ausgelieferten Instanz fehl). Alle 58 Links sind jetzt repo-root-relative
|
||||||
|
Klartextpfade (`instructions/session-setup.md` statt `[session-setup.md](../session-setup.md)`) -
|
||||||
|
sie ueberleben die Kopie unveraendert, weil sie nicht von der Position der lesenden Datei abhaengen.
|
||||||
|
`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link" traegt die
|
||||||
|
Regel.
|
||||||
|
|
||||||
|
Zwei neue mechanische Checks verhindern das Wiederauftreten: `instructions verify` verbietet jeden
|
||||||
|
relativen Markdown-Link in einer `SKILL.md` (`check_skill_reference_paths`), `docs verify` loest
|
||||||
|
jeden relativen Link in den flachen Instructions und Contracts gegen den Arbeitsbaum auf
|
||||||
|
(`check_reference_targets`, ueber denselben Dateisatz wie `docs toc`). Nebenbei behoben:
|
||||||
|
`instructions/dev/doc-pull-through.md` hatte zwei Links mit falscher `../`-Tiefe, unabhaengig vom
|
||||||
|
Skill-Copy-Problem.
|
||||||
|
|
||||||
|
### docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
|
||||||
|
|
||||||
|
Nachtraegliche Neueinstufung des Bumps darueber, kein zusaetzlicher Code. `check_reference_targets`
|
||||||
|
laeuft ueber den Dateisatz von `docs toc`, und fuenf Dateien darin gehoeren der Instanz statt dem
|
||||||
|
Stack: `kb/CONVENTIONS.md` und die vier `kb/<collection>/COLLECTION.md`. Ein Drop-in-Copy von
|
||||||
|
`tools/`, `types/`, `instructions/` und `AGENTS.md` ersetzt sie nicht - ein toter relativer Link
|
||||||
|
darin laesst `docs verify` nach dem Update fehlschlagen, wo es vorher durchlief. Genau die Form,
|
||||||
|
die `tools/README.md` § Adding a command Schritt 5 als MAJOR-Zeile benennt ("a stricter check that
|
||||||
|
newly fails on shipped content an instance already had"), und `instructions/dev/version-parts.md`
|
||||||
|
entscheidet den Zweifelsfall zugunsten des Grenzuebertritts.
|
||||||
|
|
||||||
|
Gemessen bricht heute nichts: die zweite bekannte Instanz traegt 25 relative Links in diesen fuenf
|
||||||
|
Dateien, davon null tote; dieses Repo ebenso. Die Einstufung folgt der Reichweite des Checks, nicht
|
||||||
|
einem beobachteten Schaden - der Preis einer unnoetigen MAJOR ist eine Release-Notiz, der Preis
|
||||||
|
einer MINOR, die doch bricht, ist eine Instanz mit fehlschlagendem Update-Pfad unter einer
|
||||||
|
Versionsnummer, die Drop-in versprochen hat. Aufgefallen ist es in der Schlussphase beim Lesen der
|
||||||
|
eigenen Regel in `tools/README.md`, nicht durch einen Check - was `docs/version-model.md` ueber
|
||||||
|
genau diese Stelle sagt ("a person looking at the diff ... not a validator"), hat sich hier
|
||||||
|
wiederholt.
|
||||||
|
|
||||||
|
### docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
|
||||||
|
|
||||||
|
Defektbehebung am Check aus den beiden Bumps darueber, gefunden unmittelbar nach deren Publish.
|
||||||
|
`check_reference_targets` meldete auf einem frisch exportierten Baum 13 tote Links - `kb/CONTRACT.md`
|
||||||
|
neunmal, dazu `german-terminology.md`, `kb-profiles.md` und `link-taxonomy.md` - und zwar dafuer,
|
||||||
|
dass der Export tut, was er soll. `kb/CONVENTIONS.md` und die vier `kb/<name>/COLLECTION.md` sind
|
||||||
|
instanz-eigen: die Distribution traegt `<name>.template`, und die Instanz uebernimmt sie erst im
|
||||||
|
Personalisierungsschritt von `instructions/setup-instance.md` durch Umbenennen. Zwischen
|
||||||
|
`dist export` und diesem Schritt existiert die fertige Datei berechtigterweise nicht, waehrend die
|
||||||
|
stack-eigenen Dateien sie unter ihrem kuenftigen Namen verlinken - korrekt, denn so wird sie heissen.
|
||||||
|
|
||||||
|
Ein Linkziel gilt jetzt auch dann als aufgeloest, wenn daneben `<ziel>.template` liegt. Die
|
||||||
|
Ausnahme ist eng: fehlt beides, bleibt es ein Befund. Damit beschreibt der Check nicht laenger
|
||||||
|
"noch nicht personalisiert" als "kaputter Link" - diesen Zustand meldet `doctor` unter
|
||||||
|
`conventions` praezise und zustaendig.
|
||||||
|
|
||||||
|
CI war davon nie rot: der Replay in `.gitea/workflows/ci.yml` uebernimmt die Templates, bevor er
|
||||||
|
`docs verify` aufruft, und der dokumentierte Weg in `setup-instance.md` stellt die Personalisierung
|
||||||
|
(Schritt 5/6) ebenfalls vor die Verifikation (Schritt 13). Getroffen haette es jeden, der nach dem
|
||||||
|
Export einmal zur Kontrolle `docs verify` aufruft. Aufgefallen ist es, weil die Verifikation des
|
||||||
|
vorherigen Publishes den Arbeitsbaum geprueft hatte und nicht den exportierten - ausgerechnet bei
|
||||||
|
einer Aenderung, deren ganzer Gegenstand Kopien in anderer Verzeichnistiefe sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Seit `4.4.0` sammelt ein laufender Kandidat alle Bumps in einem Eintrag; bei `5.0.0` wurde das
|
||||||
|
mit 20 Bumps und ~1440 Zeilen unlesbar, weil die Liste chronologisch und ungewichtet war und die
|
||||||
|
Release-Seite genau diesen Eintrag 1:1 uebernimmt (`version notes`, `release.yml`). Der Eintrag
|
||||||
|
ist jetzt geschichtet statt einer einzigen Wand Text: `version bump --impact high|medium|low`
|
||||||
|
(Default `medium`) graduiert jeden Bump, die Liste rendert nach High/Medium/Low gruppiert - ausser
|
||||||
|
wenn alles `medium` ist, dann bleibt sie flach wie bisher, damit jeder alte Eintrag und jeder
|
||||||
|
einfache Patch unveraendert bleibt. `version regrade` korrigiert eine Note im Nachhinein, gegen
|
||||||
|
einen einzelnen Lesevorgang der ganzen Liste, bevor der Kandidat geschlossen wird. Direkt unter
|
||||||
|
der Liste steht jetzt eine kurze Zusammenfassung, darunter je Bump ein eigener
|
||||||
|
`### <Bump-Titel>`-Changeset-Absatz; `version release` verweigert das Schliessen eines Kandidaten
|
||||||
|
mit zwei oder mehr Bumps, solange diese Zusammenfassung fehlt (ein Kandidat mit genau einem Bump
|
||||||
|
ist ausgenommen - sein Changeset ist bereits die Zusammenfassung, wie hier). Geschlossene
|
||||||
|
Eintraege wie der zu `5.0.0` bleiben in der alten Form stehen: die Release-Seiten sind laut
|
||||||
|
eigenem Footer unveraenderliche Snapshots, und `instructions/dev/version-parts.md` sowie
|
||||||
|
`docs/version-model.md` zitieren den `2.0.0`-Eintrag mit Abschnittsnamen.
|
||||||
|
|
||||||
|
`--breaking`/`**Migration:**` sitzen jetzt oberhalb der Bump-Liste statt darunter, damit die fuer
|
||||||
|
einen Operator wichtigste Zeile nicht unter einer moeglicherweise langen Liste verschwindet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.0.1 - 2026-09-12 - publish: ungeborene main ist kein detached HEAD, erster Push zu leerem Remote (schliesst #96, #97)
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
<!-- wikitool:bumps -->
|
||||||
|
- publish: ungeborene main ist kein detached HEAD, erster Push zu leerem Remote (schliesst #96, #97)
|
||||||
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
|
Zwei Defekte, die zusammen dazu führten, dass eine frisch aufgesetzte Instanz
|
||||||
|
sich über keinen dokumentierten Weg initial veröffentlichen ließ. Beide sitzen
|
||||||
|
in `publish` und wurden erst beim Einrichten einer 5.0.0-Instanz aus dem
|
||||||
|
Release-Tarball sichtbar - der Pfad, den `instructions/setup-instance.md`
|
||||||
|
Schritt 14 als *den* ersten schreibenden Aufruf einer neuen Instanz nennt.
|
||||||
|
|
||||||
|
**Ein ungeborener Branch ist kein detached HEAD.** `current_branch()` fragte
|
||||||
|
`git rev-parse --abbrev-ref HEAD`. Nach `git init -b main`, vor dem ersten
|
||||||
|
Commit, zeigt `HEAD` auf einen Ref, der sich nicht auflöst: `rev-parse` endet
|
||||||
|
mit Exit 128 und war damit von einem echten detached HEAD nicht zu
|
||||||
|
unterscheiden. Der erste `publish` einer neuen Instanz brach deshalb mit
|
||||||
|
"Refusing to push `main` from a detached HEAD" ab, und Invariante 5 schneidet
|
||||||
|
den naheliegenden Ausweg (`git commit` von Hand) ab. Die Funktion liest jetzt
|
||||||
|
`git symbolic-ref --short -q HEAD` - was `HEAD` *benennt* statt worauf es
|
||||||
|
zeigt. Damit beantwortet die Branch-Prüfung den ungeborenen Fall korrekt,
|
||||||
|
statt für ihn ausgesetzt werden zu müssen: sie vergleicht `main` mit `main`
|
||||||
|
und lässt durch. Ein echter detached HEAD wird unverändert abgelehnt, und ein
|
||||||
|
`--branch`, das nicht dem ausgecheckten entspricht, ebenfalls.
|
||||||
|
|
||||||
|
**Ein nie gepushter Branch ist nicht "nicht ahead".** `_local_ahead_of_remote()`
|
||||||
|
entscheidet, ob ein sauberer Working-Tree trotzdem etwas zu pushen hat, und
|
||||||
|
gab `False` zurück, sobald kein Tracking-Ref existierte. Genau das ist der Fall
|
||||||
|
bei einem frisch angelegten, leeren Remote-Repository: der lokale Commit stand,
|
||||||
|
`publish` meldete "Nothing to commit" und pushte nie - beliebig oft
|
||||||
|
wiederholbar. Unterschieden wird jetzt über `git ls-remote --exit-code`, dessen
|
||||||
|
Exit-Code die drei Lagen ohne Textvergleich trennt (0 = Ref vorhanden,
|
||||||
|
2 = Remote erreichbar und ohne diesen Ref, 128 = unerreichbar oder gar nicht
|
||||||
|
konfiguriert); auf die Meldung zu matchen schiede aus, weil git sie übersetzt.
|
||||||
|
Nur der mittlere Fall gilt als "ahead", und auch dort nur, wenn lokal
|
||||||
|
überhaupt ein Commit existiert. Ein unerreichbares Remote behält bewusst das
|
||||||
|
bisherige Verhalten, damit Offline- und Nur-lokal-Instanzen keine
|
||||||
|
Verhaltensänderung sehen. `remote_ref_exists()` bleibt unangetastet - sein
|
||||||
|
zweiter Aufrufer `reconcile()` meint damit weiterhin richtig "nichts zum
|
||||||
|
Abgleichen da".
|
||||||
|
|
||||||
|
`tools/CONTRACT.md` zieht beides nach: die `publish`-Zeile beschrieb den
|
||||||
|
Strandungsfall bisher als gelöst, was für einen nie gepushten Branch nicht
|
||||||
|
stimmte, und der Fehlerkontrakt benennt die Branch-Prüfung jetzt als
|
||||||
|
eigenständigen Exit-1-Grund. `instructions/setup-instance.md` und `INSTALL.md`
|
||||||
|
blieben inhaltlich richtig - sie hatten den Umweg nie beschrieben, sondern den
|
||||||
|
Weg, der jetzt tatsächlich funktioniert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -22,19 +22,24 @@ ein Release verbraucht wird - steht in
|
|||||||
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
[docs/version-model.md](docs/version-model.md). Hier nur der Ablauf, in der Reihenfolge, in der
|
||||||
eine Sitzung ihn tatsächlich durchläuft:
|
eine Sitzung ihn tatsächlich durchläuft:
|
||||||
|
|
||||||
1. **Bump eröffnet oder eskaliert den Kandidaten.**
|
1. **Bump eröffnet oder eskaliert den Kandidaten, gewichtet mit `--impact`.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool version bump --minor --title "Was sich geändert hat"
|
tools/wikitool version bump --minor --title "Was sich geändert hat" --impact medium
|
||||||
```
|
```
|
||||||
|
|
||||||
Schreibt `VERSION` als `X.Y.Z-beta.N` und öffnet (oder aktualisiert) den passenden
|
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
|
`CHANGES.md`-Eintrag. Mehrere Bumps für dieselbe Änderung sind normal - jeder aktualisiert
|
||||||
denselben Eintrag, statt einen neuen zu eröffnen.
|
denselben Eintrag, statt einen neuen zu eröffnen. `--impact high|medium|low` (Default
|
||||||
|
`medium`) gruppiert den Eintrag; `tools/wikitool version regrade` korrigiert eine Note später,
|
||||||
|
wenn der Gesamteindruck des Kandidaten den Blick auf einen früheren Bump ändert.
|
||||||
|
|
||||||
2. **Der Eintrag bekommt seine Prosa.** `bump` schreibt nur das Skelett (Heading, Datum, Autor,
|
2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading,
|
||||||
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
|
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
|
||||||
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
|
Breaking-/Migration-Zeile). Darunter kommen zwei Autorenanteile: eine kurze Zusammenfassung
|
||||||
|
(ein paar Sätze, worum es in diesem Release geht) direkt unter der Liste, und darunter je Bump
|
||||||
|
ein eigener `### <Bump-Titel>`-Changeset-Absatz. Details dazu in
|
||||||
|
[instructions/dev/version-parts.md](instructions/dev/version-parts.md) § The candidate model.
|
||||||
|
|
||||||
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
3. **Verify laufen lassen, bevor irgendetwas gepublished wird:**
|
||||||
|
|
||||||
@@ -52,8 +57,10 @@ eine Sitzung ihn tatsächlich durchläuft:
|
|||||||
|
|
||||||
Streicht den `-beta.N`-Suffix aus `VERSION` und schließt den Changelog-Eintrag. `--title` ist
|
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,
|
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
|
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der
|
||||||
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
|
Kandidat zwei oder mehr Bumps gesammelt hat und die Zusammenfassung aus Schritt 2 noch fehlt -
|
||||||
|
ein Kandidat mit genau einem Bump ist davon ausgenommen. Committet und pusht nichts
|
||||||
|
(Invariante 5 in [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
5. **Publish bewegt `VERSION` auf `main`.**
|
5. **Publish bewegt `VERSION` auf `main`.**
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ alongside [AGENTS.md](../AGENTS.md).
|
|||||||
- [Publishing](#publishing)
|
- [Publishing](#publishing)
|
||||||
- [Writing an instruction](#writing-an-instruction)
|
- [Writing an instruction](#writing-an-instruction)
|
||||||
- [A skill's H1 is a name, not an imperative](#a-skills-h1-is-a-name-not-an-imperative)
|
- [A skill's H1 is a name, not an imperative](#a-skills-h1-is-a-name-not-an-imperative)
|
||||||
|
- [A skill's outbound reference is a plain path, not a link](#a-skills-outbound-reference-is-a-plain-path-not-a-link)
|
||||||
- [Reference depth: bundled files, not repo-wide contracts](#reference-depth-bundled-files-not-repo-wide-contracts)
|
- [Reference depth: bundled files, not repo-wide contracts](#reference-depth-bundled-files-not-repo-wide-contracts)
|
||||||
- [When a skill carries a copy-in checklist](#when-a-skill-carries-a-copy-in-checklist)
|
- [When a skill carries a copy-in checklist](#when-a-skill-carries-a-copy-in-checklist)
|
||||||
- [How much reasoning a step may carry](#how-much-reasoning-a-step-may-carry)
|
- [How much reasoning a step may carry](#how-much-reasoning-a-step-may-carry)
|
||||||
@@ -190,6 +191,35 @@ exception in the same breath - "for promoted skills, the skill name is the title
|
|||||||
That is the whole exception. Everything else in this section binds a `SKILL.md` exactly as it
|
That is the whole exception. Everything else in this section binds a `SKILL.md` exactly as it
|
||||||
binds an instruction.
|
binds an instruction.
|
||||||
|
|
||||||
|
### A skill's outbound reference is a plain path, not a link
|
||||||
|
|
||||||
|
`tools/wikitool instructions sync` copies each `SKILL.md` byte for byte into
|
||||||
|
`.agents/skills/<name>/` and `.claude/skills/<name>/` (§ Publishing, above) - a different depth
|
||||||
|
than the source, and without the sibling files a relative link might expect. A markdown link
|
||||||
|
correct at `instructions/<name>/SKILL.md` (`../session-setup.md`, `../../kb/CONTRACT.md`)
|
||||||
|
resolves to a different, usually nonexistent, file once copied: the number of `../` segments
|
||||||
|
that reaches a target from `instructions/` does not reach the same target from
|
||||||
|
`.claude/skills/`. Fifty-two of the fifty-eight relative links across this repo's seven skills
|
||||||
|
broke exactly this way before this rule existed, silently - nothing rendered the copy to notice,
|
||||||
|
and no check read a link target.
|
||||||
|
|
||||||
|
So a `SKILL.md` never writes an outbound reference as a relative markdown link, correct depth or
|
||||||
|
not. It names the target as a repo-root-relative **plain path** instead - `` `instructions/session-setup.md` ``, not `[session-setup.md](../session-setup.md)`; `` `kb/CONTRACT.md` `` for a
|
||||||
|
whole file, `` `kb/CONVENTIONS.md` § Tone `` for a section rather than an anchored link. The path
|
||||||
|
survives the copy unchanged because it does not depend on where the reading file sits: an
|
||||||
|
agent's working directory is the instance root regardless of which published copy it opened, so
|
||||||
|
the same plain path resolves in the source and in both published copies alike. The cost is that
|
||||||
|
the reference is no longer clickable from the source file - accepted deliberately, because the
|
||||||
|
source is not where an agent reads it from; the harness reads the published copy.
|
||||||
|
`tools/wikitool instructions verify` enforces the ban mechanically
|
||||||
|
(`check_skill_reference_paths`).
|
||||||
|
|
||||||
|
This binds only `SKILL.md`. The flat `instructions/<name>.md` form - this file included - is
|
||||||
|
never copied anywhere, so its relative links stay exactly as correct as their `../` count says,
|
||||||
|
and stay ordinary links; `tools/wikitool docs verify` (`check_reference_targets`) resolves those
|
||||||
|
against the working tree instead of banning the syntax, over the same reference-file scope
|
||||||
|
`tools/wikitool docs toc` uses.
|
||||||
|
|
||||||
### Reference depth: bundled files, not repo-wide contracts
|
### Reference depth: bundled files, not repo-wide contracts
|
||||||
|
|
||||||
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
|
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
|
||||||
@@ -201,9 +231,11 @@ That rule governs **skill-bundled** material: files sitting in `instructions/<na
|
|||||||
`OOXML.md`), and it says nothing about files outside the skill directory. No skill in this repo
|
`OOXML.md`), and it says nothing about files outside the skill directory. No skill in this repo
|
||||||
has a bundled file today, so as written the rule currently binds nothing here.
|
has a bundled file today, so as written the rule currently binds nothing here.
|
||||||
|
|
||||||
A link from a skill to a repo-wide contract - [kb/CONTRACT.md](../kb/CONTRACT.md),
|
A skill's reference to a repo-wide contract - `kb/CONTRACT.md`, `tools/CONTRACT.md`,
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md), [gates.md](gates.md) - is a different category, and
|
`instructions/gates.md` (written as a plain path per § "A skill's outbound reference is a plain
|
||||||
the two halves of the question have different answers:
|
path, not a link" above; this file is a flat instruction rather than a `SKILL.md`, so its own
|
||||||
|
references to the same three files, a few sections up and below, stay ordinary links) - is a
|
||||||
|
different category, and the two halves of the question have different answers:
|
||||||
|
|
||||||
- **The mechanic is real and directory-independent.** A contract reached at the second hop can
|
- **The mechanic is real and directory-independent.** A contract reached at the second hop can
|
||||||
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
|
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ touched; a row that does not apply needs no action.
|
|||||||
|
|
||||||
| Touched surface | Document(s) that make a claim about it |
|
| Touched surface | Document(s) that make a claim about it |
|
||||||
|---|---|
|
|---|---|
|
||||||
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
| A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) |
|
||||||
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
|
| A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `<stage>/CONTRACT.md` |
|
||||||
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
| A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) |
|
||||||
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
| A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader |
|
||||||
@@ -42,7 +42,7 @@ touched; a row that does not apply needs no action.
|
|||||||
|
|
||||||
3. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
|
3. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
|
||||||
membership, ignore-canary state. That enumeration lives once, in
|
membership, ignore-canary state. That enumeration lives once, in
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
[tools/CONTRACT.md](../../tools/CONTRACT.md)'s own `docs verify` row; copying it here would be a
|
||||||
second copy that drifts, the exact failure this instruction exists to describe (Gitea #90).
|
second copy that drifts, the exact failure this instruction exists to describe (Gitea #90).
|
||||||
This instruction is only about the prose no check reads.
|
This instruction is only about the prose no check reads.
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ there and hands off here rather than continuing into this phase in the same brea
|
|||||||
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
printed its stack-machinery note ("this publish touched stack machinery...") and nothing has
|
||||||
closed the work package it belongs to yet; or a package was published in an earlier session and
|
closed the work package it belongs to yet; or a package was published in an earlier session and
|
||||||
never went through this skill (the gap this split exists to make impossible to skip past
|
never went through this skill (the gap this split exists to make impossible to skip past
|
||||||
silently - see [issue-tracking.md](../issue-tracking.md)'s note that a closed body is the version
|
silently - see `instructions/dev/issue-tracking.md`'s note that a closed body is the version
|
||||||
everyone reads afterwards and nobody revisits).
|
everyone reads afterwards and nobody revisits).
|
||||||
|
|
||||||
**This directory is dev-only.** Same boundary as `stack-dev`
|
**This directory is dev-only.** Same boundary as `stack-dev`
|
||||||
([its own note](../stack-dev/SKILL.md) has the full reasoning) - `dist export` prunes
|
(its own `instructions/dev/stack-dev/SKILL.md` has the full reasoning) - `dist export` prunes
|
||||||
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
`instructions/dev/` wholesale, so this skill never reaches a distributed instance.
|
||||||
|
|
||||||
## Why this is a separate skill, not `stack-dev`'s step 6
|
## Why this is a separate skill, not `stack-dev`'s step 6
|
||||||
@@ -25,13 +25,13 @@ everyone reads afterwards and nobody revisits).
|
|||||||
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
The two phases around the mechanical middle of a stack-dev session have no mechanical guard at
|
||||||
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
all - `pytest`, `docs verify` and `instructions verify` cover the code and tests in between, and
|
||||||
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
nothing covers a changelog entry's accuracy, a `docs/` page's staleness, or an issue body's final
|
||||||
state (see [docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md)). Asking the
|
state (see `docs/model-and-effort-selection.md`). Asking the
|
||||||
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
same session to notice it has crossed into that second unchecked stretch - as a prose break inside
|
||||||
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
`stack-dev`'s own step 6 - failed twice in a row on this stack (Gitea #42, then #30): both times
|
||||||
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
the session knew the rule and skipped past it anyway, because nothing in the moment forced the
|
||||||
question. Splitting the phase into its own skill does not add a check either - `wikitool` still
|
question. Splitting the phase into its own skill does not add a check either - `wikitool` still
|
||||||
does not know this tracker exists and must not learn (see
|
does not know this tracker exists and must not learn (see
|
||||||
[issue-tracking.md](../issue-tracking.md) § What no tool checks) - but it removes the thing that
|
`instructions/dev/issue-tracking.md` § What no tool checks) - but it removes the thing that
|
||||||
was actually failing: the closing *procedure* is no longer sitting in the session's context as a
|
was actually failing: the closing *procedure* is no longer sitting in the session's context as a
|
||||||
next step to run past - it exists only inside a skill someone has to invoke.
|
next step to run past - it exists only inside a skill someone has to invoke.
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ and a fresh subagent starts without the session's context).
|
|||||||
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
- what was verified is named - which checks ran, which CI run - not a commit hash alone
|
||||||
|
|
||||||
Then one short comment naming what changed against the previous state, and nothing else -
|
Then one short comment naming what changed against the previous state, and nothing else -
|
||||||
[issue-tracking.md](../issue-tracking.md) steps 2-3 and 7 have the full shape; this is that
|
`instructions/dev/issue-tracking.md` steps 2-3 and 7 have the full shape; this is that
|
||||||
procedure, run at the point this skill exists to guarantee it actually gets run.
|
procedure, run at the point this skill exists to guarantee it actually gets run.
|
||||||
|
|
||||||
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
**A closing report in a comment does not satisfy this**, however thorough: it reads as
|
||||||
@@ -80,7 +80,7 @@ and a fresh subagent starts without the session's context).
|
|||||||
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
|
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
|
||||||
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row
|
`<stage>/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row
|
||||||
membership, never for what a cell or a section actually says
|
membership, never for what a cell or a section actually says
|
||||||
([doc-pull-through.md](../doc-pull-through.md)); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
(`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md`
|
||||||
prose; and of a new instruction's own wording, which `instructions verify` checks structurally
|
prose; and of a new instruction's own wording, which `instructions verify` checks structurally
|
||||||
but never for what it claims. If the change this package shipped moved the reasoning or the
|
but never for what it claims. If the change this package shipped moved the reasoning or the
|
||||||
behaviour one of these documents describes, update it now; if none did, say so rather than
|
behaviour one of these documents describes, update it now; if none did, say so rather than
|
||||||
@@ -110,7 +110,7 @@ and a fresh subagent starts without the session's context).
|
|||||||
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
- **The work package spans several sessions?** Run this skill once, at the point the package is
|
||||||
actually finished and its last publish has landed - not after every individual publish. A
|
actually finished and its last publish has landed - not after every individual publish. A
|
||||||
package still open across sessions keeps its body current per
|
package still open across sessions keeps its body current per
|
||||||
[issue-tracking.md](../issue-tracking.md) step 2 in the meantime; that is maintenance, not
|
`instructions/dev/issue-tracking.md` step 2 in the meantime; that is maintenance, not
|
||||||
closing.
|
closing.
|
||||||
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
- **Resuming a package whose publish landed in an earlier, already-ended session?** Run this
|
||||||
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
skill now, on whatever model the current session is - do not reopen the earlier session to run
|
||||||
|
|||||||
@@ -39,30 +39,30 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
1. **Confirm the mode.** If the task is ambiguous between "extend the tool" and "operate the
|
1. **Confirm the mode.** If the task is ambiguous between "extend the tool" and "operate the
|
||||||
wiki", ask rather than guess - the two have different rules for the same directories.
|
wiki", ask rather than guess - the two have different rules for the same directories.
|
||||||
2. **Consult `instructions/dev/` for the concrete procedure.** Currently:
|
2. **Consult `instructions/dev/` for the concrete procedure.** Currently:
|
||||||
[commonplace-kb.md](../commonplace-kb.md) - vendored knowledge base on agent context
|
`instructions/dev/commonplace-kb.md` - vendored knowledge base on agent context
|
||||||
engineering, memory and deploy-time learning; consult before a design decision in those
|
engineering, memory and deploy-time learning; consult before a design decision in those
|
||||||
areas.
|
areas.
|
||||||
[issue-tracking.md](../issue-tracking.md) - open work lives in Gitea issues, one per work
|
`instructions/dev/issue-tracking.md` - open work lives in Gitea issues, one per work
|
||||||
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
package, labelled `area/`, `kind/`, `prio/` and `size/`. There is no `TODO.md`. **The body
|
||||||
of the issue you are working on is this session's plan file:** keep it current as the state
|
of the issue you are working on is this session's plan file:** keep it current as the state
|
||||||
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
|
||||||
to its final state before closing. Both halves bind; the second is what
|
to its final state before closing. Both halves bind; the second is what
|
||||||
[`stack-close`](../stack-close/SKILL.md) carries out once this skill's own work is published -
|
`stack-close` (`instructions/dev/stack-close/SKILL.md`) carries out once this skill's own work is published -
|
||||||
see step 6 below. An issue labelled `status/incoming` is the exception to all of that: it is a
|
see step 6 below. An issue labelled `status/incoming` is the exception to all of that: it is a
|
||||||
human's stub, not a spec, and it is **never implemented as it stands** - it gets worked out and
|
human's stub, not a spec, and it is **never implemented as it stands** - it gets worked out and
|
||||||
triaged first. Read this file before filing something for later, before editing or closing an
|
triaged first. Read this file before filing something for later, before editing or closing an
|
||||||
issue, before picking up an incoming stub, or before deciding what to pick up next.
|
issue, before picking up an incoming stub, or before deciding what to pick up next.
|
||||||
[testing-conventions.md](../testing-conventions.md) - the suite runs against a deliberately
|
`instructions/dev/testing-conventions.md` - the suite runs against a deliberately
|
||||||
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
empty machine; what the autouse fixture already neutralizes, and what a test still has to
|
||||||
establish itself. Read it before adding or changing a test.
|
establish itself. Read it before adding or changing a test.
|
||||||
[version-parts.md](../version-parts.md) - which part a change bumps: the drop-in test, the
|
`instructions/dev/version-parts.md` - which part a change bumps: the drop-in test, the
|
||||||
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
catalogue of breaks that cross the compatibility boundary with `kb/` untouched, and what to
|
||||||
put in front of the user before a breaking bump. Read it before step 4.
|
put in front of the user before a breaking bump. Read it before step 4.
|
||||||
[corpus-policy.md](../corpus-policy.md) - what "curated enough" means for the shared
|
`instructions/dev/corpus-policy.md` - what "curated enough" means for the shared
|
||||||
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
demo/testbed `kb/`, the measurable floors that define it, and what a reactive fix may and may
|
||||||
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
not do to corpus content. Read it before judging whether the corpus can exercise a change, or
|
||||||
before any fix that would touch `kb/` content.
|
before any fix that would touch `kb/` content.
|
||||||
[doc-pull-through.md](../doc-pull-through.md) - which document makes a claim about a touched
|
`instructions/dev/doc-pull-through.md` - which document makes a claim about a touched
|
||||||
surface (a `wikitool` command, a stage's rules, an `AGENTS.md` rule/gate/invariant, a
|
surface (a `wikitool` command, a stage's rules, an `AGENTS.md` rule/gate/invariant, a
|
||||||
README-shaped human doc, a `docs/` page's reasoning) and therefore needs updating alongside
|
README-shaped human doc, a `docs/` page's reasoning) and therefore needs updating alongside
|
||||||
the code, since `docs verify` never reads a cell's prose. Read it before step 6.
|
the code, since `docs verify` never reads a cell's prose. Read it before step 6.
|
||||||
@@ -100,16 +100,21 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
|
|
||||||
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
Effort is the cheaper lever than the model, and `high` is the floor for anything touching more
|
||||||
than one file or a contract. Full table and reasoning:
|
than one file or a contract. Full table and reasoning:
|
||||||
[docs/model-and-effort-selection.md](../../../docs/model-and-effort-selection.md).
|
`docs/model-and-effort-selection.md`.
|
||||||
|
|
||||||
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
4. **Raise the version, if the change ships.** A change under `tools/`, `types/`,
|
||||||
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
|
||||||
version and a changelog entry:
|
version and a changelog entry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool version bump --patch --title "<what changed>"
|
tools/wikitool version bump --patch --title "<what changed>" --impact medium
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--impact high|medium|low` (default `medium`) grades this bump in the changelog entry's own
|
||||||
|
list - `tools/wikitool version regrade` corrects it later if the candidate's overall shape
|
||||||
|
changes the read on an earlier one; see
|
||||||
|
`instructions/dev/version-parts.md` § The candidate model.
|
||||||
|
|
||||||
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
Never edit `VERSION` or the entry's heading by hand - `bump` writes both, and `docs verify`
|
||||||
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
|
fails a tree where they disagree. Pick the part by whether the new version is a **drop-in
|
||||||
replacement** for the old one - not by whether content has to be migrated:
|
replacement** for the old one - not by whether content has to be migrated:
|
||||||
@@ -123,12 +128,12 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Content migration is one way to land in the last row, not the definition of it: a rename of
|
Content migration is one way to land in the last row, not the definition of it: a rename of
|
||||||
the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
|
the update path, the artefact, an import name, a flag or an envvar breaks a swap with `kb/`
|
||||||
entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
|
entirely untouched. The full test, the catalogue of such breaks, and what to put in front of
|
||||||
the user first are in [version-parts.md](../version-parts.md) - **read it before choosing
|
the user first are in `instructions/dev/version-parts.md` - **read it before choosing
|
||||||
`--major`.**
|
`--major`.**
|
||||||
|
|
||||||
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
|
A `--major` bump therefore needs two things recorded. `--breaking "<what stops working>"`
|
||||||
is required on every boundary-crossing bump; on top of it, a migration document for the new
|
is required on every boundary-crossing bump; on top of it, a migration document for the new
|
||||||
version - written per [migrate-corpus.md](../../migrate-corpus.md) - or
|
version - written per `instructions/migrate-corpus.md` - or
|
||||||
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
|
`--no-migration "<reason>"` when no content actually has to change. `bump` refuses without
|
||||||
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
|
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
|
||||||
telling it how, is a dead end.
|
telling it how, is a dead end.
|
||||||
@@ -140,7 +145,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
do not need a bump - CI's version gate is scoped to what changes behaviour.
|
||||||
|
|
||||||
5. **Pull through every document that makes a claim about the surface you touched - `docs verify`
|
5. **Pull through every document that makes a claim about the surface you touched - `docs verify`
|
||||||
checks a cell's presence, never its prose.** [doc-pull-through.md](../doc-pull-through.md) has
|
checks a cell's presence, never its prose.** `instructions/dev/doc-pull-through.md` has
|
||||||
the table of which document that is, per surface.
|
the table of which document that is, per surface.
|
||||||
|
|
||||||
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
|
||||||
@@ -157,7 +162,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
|
|
||||||
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
|
||||||
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
checking for `docs/` staleness, and naming which model ran which phase of the session - lives
|
||||||
in [`stack-close`](../stack-close/SKILL.md), not in a further step of this one. Invoke it now;
|
in `stack-close` (`instructions/dev/stack-close/SKILL.md`), not in a further step of this one. Invoke it now;
|
||||||
do not fold its work into this session under this skill's rules, and do not treat "the change
|
do not fold its work into this session under this skill's rules, and do not treat "the change
|
||||||
is published" as this work package being done.
|
is published" as this work package being done.
|
||||||
|
|
||||||
@@ -171,7 +176,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
user decides whether it is worth that: show them what breaks, what an instance has to do about
|
||||||
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
|
||||||
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
|
||||||
[version-parts.md](../version-parts.md) step 4 has the full shape. A surfacing boundary crossing
|
`instructions/dev/version-parts.md` step 4 has the full shape. A surfacing boundary crossing
|
||||||
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
is also a reason to offer the model switch back up (step 3): the judgment it needs has no
|
||||||
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
|
||||||
part was chosen correctly.
|
part was chosen correctly.
|
||||||
@@ -181,4 +186,4 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
|
|||||||
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
|
||||||
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
`wiki-status` for that. Not for setting up a new instance (`instructions/setup-instance.md`) or
|
||||||
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
a fresh clone of this repo (`instructions/bootstrap.md`). Not for closing a work package after
|
||||||
its publish has landed - that is [`stack-close`](../stack-close/SKILL.md).
|
its publish has landed - that is `stack-close` (`instructions/dev/stack-close/SKILL.md`).
|
||||||
|
|||||||
@@ -55,10 +55,32 @@ a new one, and only `version release` turns it into something the release workfl
|
|||||||
its parser never has to know the suffix exists.
|
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
|
- **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
|
(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 -
|
bump's `--title`, graded by `--impact`); every later bump of the *same* candidate updates that
|
||||||
heading, date and the bumps list all move, but the entry's own prose (written below the
|
entry in place - heading, date and the bumps list all move, but the entry's own prose is left
|
||||||
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
|
alone. `version notes` therefore still prints exactly one entry per release, whatever a
|
||||||
release, whatever a candidate's history of bumps looked like.
|
candidate's history of bumps looked like.
|
||||||
|
- **The entry is layered, not one undifferentiated block.** A long-running candidate can collect
|
||||||
|
dozens of bumps, chronological and equally weighted, which is unreadable as a release
|
||||||
|
announcement - `5.0.0` did this at ~1440 lines for one entry. So the entry reads, top to bottom,
|
||||||
|
as four layers with different authors and different lifetimes:
|
||||||
|
|
||||||
|
1. **Heading, author, breaking/migration lines** - written by `version bump`, anchored right
|
||||||
|
above the bump list so the line an operator most needs to act on never sits beneath a list
|
||||||
|
that can run long.
|
||||||
|
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
|
||||||
|
`version bump`'s `--impact` (default `medium`), corrected after the fact by `version regrade`.
|
||||||
|
Flat and ungrouped, exactly as before this layering existed, when every bump is `medium` - the
|
||||||
|
common case, and the shape every pre-existing region still is.
|
||||||
|
3. **The release summary** - a short paragraph, written once, by hand, when the candidate is
|
||||||
|
ready to ship. `version release` refuses to close an entry with two or more bumps and no
|
||||||
|
summary here; a one-bump entry is exempt, since there the bump's own changeset already reads
|
||||||
|
as the summary.
|
||||||
|
4. **The changesets**, one `### <bump title>` heading per bump, in chronological order - the
|
||||||
|
detail a reader follows into from the graded list above. A changeset is a few sentences,
|
||||||
|
not the full rationale; what needs more than that belongs in the issue tracker, not here.
|
||||||
|
|
||||||
|
The list is the index into the changesets, which is why the bump list's title text and a
|
||||||
|
changeset's `###` heading are the same string.
|
||||||
- **`version bump` opens or continues a candidate; `version release` fixes one.** Only `release`
|
- **`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
|
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.
|
`tools/CONTRACT.md`. Nothing else does, and nothing auto-fixes a candidate on its own.
|
||||||
@@ -126,9 +148,12 @@ 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 escalation bump itself.** The bump that first crosses the boundary
|
5. **Grade the bump while you are making it, with `--impact high|medium|low`** (default
|
||||||
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
|
`medium`) - the judgment is easiest right after you did the work, not weeks later staring at a
|
||||||
entry:
|
chronological list. It is not final: `version regrade` corrects it before release if the
|
||||||
|
candidate's overall shape changes the read on an earlier bump. Then record the break in the
|
||||||
|
escalation bump itself. The bump that first crosses the boundary requires
|
||||||
|
`--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the entry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool version bump --major \
|
tools/wikitool version bump --major \
|
||||||
@@ -168,16 +193,27 @@ the three-line test below is usually enough.
|
|||||||
never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for
|
never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for
|
||||||
a TOC-verification change, then absorbed a schema removal that migrates 152 pages.
|
a TOC-verification change, then absorbed a schema removal that migrates 152 pages.
|
||||||
|
|
||||||
7. **Fix the candidate once it is ready to ship.** `version bump` only ever opens or escalates
|
7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run
|
||||||
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
|
`tools/wikitool version regrade` with no arguments - it lists every bump at its current grade,
|
||||||
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
|
numbered in rendered order. A candidate that grew over several sessions often has a bump graded
|
||||||
point to pass a summarising `--title` if the candidate collected several bump titles along the
|
in isolation that reads differently once the whole shape is visible; `version regrade 3 7
|
||||||
way; without one, the heading simply keeps whichever bump last set it.
|
--impact high` corrects one or several positions against a single read of that list, put the
|
||||||
|
result in front of the user, and re-list to confirm. Only then run
|
||||||
|
`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
|
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
|
||||||
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
|
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
|
||||||
cannot reconstruct from the diff, and it is what the next session in this position will read
|
release time, right below the graded bump list: what this release is about, and why, for a
|
||||||
instead of guessing.
|
reader who will not read the changesets underneath. `version release` refuses to close an
|
||||||
|
entry that collected two or more bumps and has no summary - a one-bump entry is exempt, since
|
||||||
|
there the bump's changeset already reads as one. Each **changeset**, under its own
|
||||||
|
`### <bump title>` heading, is a few sentences on what changed and why - it is the one thing a
|
||||||
|
future reader cannot reconstruct from the diff, but it is not the place for the full rationale
|
||||||
|
of a decision; that belongs in the issue tracker or the commit history, and a changeset that
|
||||||
|
is growing past a paragraph or two is a sign it belongs there instead.
|
||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
|
|
||||||
|
|||||||
@@ -193,10 +193,17 @@ command you actually need to run, and only with the user's approval.
|
|||||||
|
|
||||||
A dozen commands are exempt from this budget entirely - `search` and `doctor` because retrieval
|
A dozen commands are exempt from this budget entirely - `search` and `doctor` because retrieval
|
||||||
and diagnosis are reading, not iterating, plus the read-only forms of `links`, `cite`, `budget`,
|
and diagnosis are reading, not iterating, plus the read-only forms of `links`, `cite`, `budget`,
|
||||||
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that fixed allowlist in
|
`eval`, `version`, `migrate` and `upstream verify`. The exemption is that allowlist in
|
||||||
[tools/CONTRACT.md](../tools/CONTRACT.md), not a "does not change the wiki" rule of thumb: `lint`
|
[tools/CONTRACT.md](../tools/CONTRACT.md), not a "does not change the wiki" rule of thumb: `lint`
|
||||||
only writes to gitignored `reports/` and still counts, because it is not on the list.
|
only writes to gitignored `reports/` and still counts, because it is not on the list.
|
||||||
|
|
||||||
|
**One entry is read-only only in one of its two forms.** `version regrade` lists the running
|
||||||
|
candidate's graded bump titles when called bare, and writes `CHANGES.md` when called with
|
||||||
|
positions to regrade - so the exemption is per *invocation* there, not per command name. It is
|
||||||
|
the only such case; every other row on the list is exempt however it is called. Its
|
||||||
|
`tools/CONTRACT.md` row says which form is which, which is still the single place that list
|
||||||
|
lives.
|
||||||
|
|
||||||
### Taking a new session id
|
### Taking a new session id
|
||||||
|
|
||||||
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
|
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
|
||||||
|
|||||||
@@ -57,11 +57,12 @@ refusal. See [gates.md](gates.md).
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
**The exemption is a fixed allowlist, not "read-only" or "does not change the wiki."** A command
|
**The exemption is an allowlist, not "read-only" or "does not change the wiki."** A command
|
||||||
needs this setup unless it is one of the dozen `tools/CONTRACT.md` marks exempt in its command
|
needs this setup unless it is one of the dozen `tools/CONTRACT.md` marks exempt in its command
|
||||||
table (`search`, `doctor`, `links show`, `cite id`, `budget status`, the read-only forms of
|
table (`search`, `doctor`, `links show`, `cite id`, `budget status`, the read-only forms of
|
||||||
`eval`, `version`, `migrate` and `upstream verify`) - that table, not a rule of thumb here, is
|
`eval`, `version`, `migrate` and `upstream verify`) - that table, not a rule of thumb here, is
|
||||||
the single list.
|
the single list. One entry on it, `version regrade`, is exempt only in its bare listing form and
|
||||||
|
counted when it is given positions to regrade; every other entry is exempt however it is called.
|
||||||
|
|
||||||
`lint` is the case that breaks the "changes the wiki" reading: it only writes to `reports/`,
|
`lint` is the case that breaks the "changes the wiki" reading: it only writes to `reports/`,
|
||||||
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
|
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ description: Process a new source file into the LLM wiki - extract entities and
|
|||||||
**Trigger:** User drops a file into `incoming/` (the normal path - see step 1) or directly into
|
**Trigger:** User drops a file into `incoming/` (the normal path - see step 1) or directly into
|
||||||
`raw/`, or explicitly requests ingestion.
|
`raw/`, or explicitly requests ingestion.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
Contracts are read **when the step needs them**, not upfront: a source that produces no concept
|
Contracts are read **when the step needs them**, not upfront: a source that produces no concept
|
||||||
pages should never have cost the concept contract. Field-level requirements always come from
|
pages should never have cost the concept contract. Field-level requirements always come from
|
||||||
@@ -40,7 +40,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Promote from `incoming/` if that is where the file sits.** Read
|
1. **Promote from `incoming/` if that is where the file sits.** Read
|
||||||
[raw/CONTRACT.md](../../raw/CONTRACT.md) "Getting a file in" and "Capture fields" if you have
|
`raw/CONTRACT.md` "Getting a file in" and "Capture fields" if you have
|
||||||
not this session - the directory and any bundling are computed, never chosen by hand, but the
|
not this session - the directory and any bundling are computed, never chosen by hand, but the
|
||||||
two capture flags are not: `raw accept` refuses without them.
|
two capture flags are not: `raw accept` refuses without them.
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
**A file that arrived through the MCP `submit` tool is not yet in `incoming/`** - it sits in
|
**A file that arrived through the MCP `submit` tool is not yet in `incoming/`** - it sits in
|
||||||
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
|
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
|
||||||
with `wikitool upload accept <id> --confirm <token>`, per
|
with `wikitool upload accept <id> --confirm <token>`, per
|
||||||
[instructions/ingest-queue.md](../ingest-queue.md); once accepted it is an ordinary file in
|
`instructions/ingest-queue.md`; once accepted it is an ordinary file in
|
||||||
`incoming/` and this step applies to it exactly as to anything dropped there by hand.
|
`incoming/` and this step applies to it exactly as to anything dropped there by hand.
|
||||||
|
|
||||||
**If this refuses because the name is already claimed** (a file stem or a bundle directory
|
**If this refuses because the name is already claimed** (a file stem or a bundle directory
|
||||||
@@ -79,7 +79,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
|
|
||||||
**Check the size first, on both axes.** *Volume* - how many raw files this ingest covers -
|
**Check the size first, on both axes.** *Volume* - how many raw files this ingest covers -
|
||||||
and *breadth* - how many entities and concepts this one source would produce or update.
|
and *breadth* - how many entities and concepts this one source would produce or update.
|
||||||
Either one past the thresholds in [ingest-large-tree.md](../ingest-large-tree.md) § When to
|
Either one past the thresholds in `instructions/ingest-large-tree.md` § When to
|
||||||
run is that procedure, not this one: stop and follow it. There, volume is cut into units;
|
run is that procedure, not this one: stop and follow it. There, volume is cut into units;
|
||||||
breadth cannot be cut at all (`raw/` keeps a file whole, and one raw file has one owning
|
breadth cannot be cut at all (`raw/` keeps a file whole, and one raw file has one owning
|
||||||
source page) and buys an extract pass instead, before any page is written. Skipping either
|
source page) and buys an extract pass instead, before any page is written. Skipping either
|
||||||
@@ -107,7 +107,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
which entities/concepts to create or update, any specific emphasis.
|
which entities/concepts to create or update, any specific emphasis.
|
||||||
|
|
||||||
6. **Create the source page.** Read
|
6. **Create the source page.** Read
|
||||||
[kb/sources/COLLECTION.md](../../kb/sources/COLLECTION.md) first - it holds what this
|
`kb/sources/COLLECTION.md` first - it holds what this
|
||||||
instance expects of a source page's sections and how it names one.
|
instance expects of a source page's sections and how it names one.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -140,18 +140,18 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
article also pass `--set source_url=<upstream URL>`; `raw_files:` must still point at the
|
||||||
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 5 - in the
|
local copy. Then write the Summary / Key Takeaways / Action Items prose from step 5 - in the
|
||||||
KB language, whatever the source's own language is, quoting verbatim passages in the
|
KB language, whatever the source's own language is, quoting verbatim passages in the
|
||||||
original. Which language that is: [kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#language).
|
original. Which language that is: `kb/CONVENTIONS.md` § Language.
|
||||||
What is exempt from it, in any language:
|
What is exempt from it, in any language:
|
||||||
[kb/CONTRACT.md](../../kb/CONTRACT.md#language-and-identifiers).
|
`kb/CONTRACT.md` § Language and identifiers.
|
||||||
|
|
||||||
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
Fill `## Not Extracted` in the same pass: what you read and deliberately did not promote,
|
||||||
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
with the reason. Nothing in the repository can re-derive that judgment, and without it the
|
||||||
same source gets re-litigated on the next pass.
|
same source gets re-litigated on the next pass.
|
||||||
|
|
||||||
7. **Create or update entity pages.** Read
|
7. **Create or update entity pages.** Read
|
||||||
[kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and
|
`kb/entities/COLLECTION.md` and
|
||||||
[kb/CONTRACT.md](../../kb/CONTRACT.md) plus
|
`kb/CONTRACT.md` plus
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and
|
`kb/CONVENTIONS.md` first - the second is where provenance and
|
||||||
citation are defined, the third where this instance's tone and naming forms are.
|
citation are defined, the third where this instance's tone and naming forms are.
|
||||||
|
|
||||||
**A subject earns a page when the source carries material for one.** A name the source
|
**A subject earns a page when the source carries material for one.** A name the source
|
||||||
@@ -186,7 +186,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
|
|
||||||
8. **Create or update concept pages** - only if the source produced any. Same pattern, including
|
8. **Create or update concept pages** - only if the source produced any. Same pattern, including
|
||||||
step 7's rule about which subjects earn a page at all, reading
|
step 7's rule about which subjects earn a page at all, reading
|
||||||
[kb/concepts/COLLECTION.md](../../kb/concepts/COLLECTION.md) first:
|
`kb/concepts/COLLECTION.md` first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/wikitool new concept --name "<Name>" \
|
tools/wikitool new concept --name "<Name>" \
|
||||||
@@ -211,7 +211,7 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
The new raw file(s) must no longer be listed as uncovered, and no `raw_files:` entry may be
|
The new raw file(s) must no longer be listed as uncovered, and no `raw_files:` entry may be
|
||||||
broken.
|
broken.
|
||||||
|
|
||||||
11. **Close out.** Follow [publish-cycle.md](../publish-cycle.md) with `--op ingest` and a
|
11. **Close out.** Follow `instructions/publish-cycle.md` with `--op ingest` and a
|
||||||
message of the form `ingest: <raw path>`.
|
message of the form `ingest: <raw path>`.
|
||||||
|
|
||||||
12. **Check the lint cadence.**
|
12. **Check the lint cadence.**
|
||||||
@@ -230,16 +230,16 @@ validator complains - and the ticked list is the only record that they happened.
|
|||||||
Two pages on one subject is the failure this step exists to prevent.
|
Two pages on one subject is the failure this step exists to prevent.
|
||||||
- **One source names far more subjects than usual?** That is breadth, not volume. It is not
|
- **One source names far more subjects than usual?** That is breadth, not volume. It is not
|
||||||
split into several sources - it cannot be - and it does not get a page per name either:
|
split into several sources - it cannot be - and it does not get a page per name either:
|
||||||
[ingest-large-tree.md](../ingest-large-tree.md) § A broad source is not cut.
|
`instructions/ingest-large-tree.md` § A broad source is not cut.
|
||||||
- **No raw file backs a claim you want to write?** Leave it out, or mark the page
|
- **No raw file backs a claim you want to write?** Leave it out, or mark the page
|
||||||
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
|
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
|
||||||
- **`publish` exited 42?** A single ingest is normally well under the Mass-Update Gate
|
- **`publish` exited 42?** A single ingest is normally well under the Mass-Update Gate
|
||||||
threshold. If it trips - a source touching many entities - show the user the output and stop;
|
threshold. If it trips - a source touching many entities - show the user the output and stop;
|
||||||
see [gates.md](../gates.md).
|
see `instructions/gates.md`.
|
||||||
- **A gate or the loop-breaker refuses anything?** Stop and follow [gates.md](../gates.md).
|
- **A gate or the loop-breaker refuses anything?** Stop and follow `instructions/gates.md`.
|
||||||
A multi-tool ingest should land in roughly 20-35 `wikitool` calls; needing far more is a sign
|
A multi-tool ingest should land in roughly 20-35 `wikitool` calls; needing far more is a sign
|
||||||
the source should be split into several ingests - which is
|
the source should be split into several ingests - which is
|
||||||
[ingest-large-tree.md](../ingest-large-tree.md), not a bigger budget.
|
`instructions/ingest-large-tree.md`, not a bigger budget.
|
||||||
|
|
||||||
## wikitool commands used
|
## wikitool commands used
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ description: Health-check the LLM wiki - broken links, orphan pages, uncovered r
|
|||||||
threshold reached - `wiki-ingest`'s last step checks it after every publish, so the count is
|
threshold reached - `wiki-ingest`'s last step checks it after every publish, so the count is
|
||||||
never something an agent has to remember.
|
never something an agent has to remember.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
## Run checklist
|
## Run checklist
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
The *Redundant see-also* section is the one that looks mechanical and is not - do **not**
|
||||||
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
clear it under step 7. It names a `see-also` edge standing beside a specific label on the
|
||||||
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
reverse direction, and the obvious repair destroys the thing worth keeping: `xref remove`
|
||||||
clears the reference in *both* directions (see [tools/CONTRACT.md](../../tools/CONTRACT.md)),
|
clears the reference in *both* directions (see `tools/CONTRACT.md`),
|
||||||
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
so removing the weak edge takes the labelled one with it and the pair ends up saying nothing
|
||||||
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
at all. Either relabel the weak edge to something true with `xref add`, which only ever
|
||||||
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
touches the source page, or leave it and report it at step 9. Clearing a batch of these is a
|
||||||
@@ -90,7 +90,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
7. **Repair what is mechanical.** A dangling frontmatter reference is either a page that should
|
7. **Repair what is mechanical.** A dangling frontmatter reference is either a page that should
|
||||||
exist (`tools/wikitool new ...`) or a reference that should not
|
exist (`tools/wikitool new ...`) or a reference that should not
|
||||||
(`tools/wikitool xref remove --a "<Page>" --b "<Missing>"`). A title that changed is
|
(`tools/wikitool xref remove --a "<Page>" --b "<Missing>"`). A title that changed is
|
||||||
`tools/wikitool rename` - see [page-lifecycle.md](../page-lifecycle.md). Never hand-edit a
|
`tools/wikitool rename` - see `instructions/page-lifecycle.md`. Never hand-edit a
|
||||||
frontmatter array to clear one.
|
frontmatter array to clear one.
|
||||||
|
|
||||||
8. **Verify the stack.**
|
8. **Verify the stack.**
|
||||||
@@ -129,7 +129,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
|
|
||||||
- **Publish?** Lint does not auto-publish. Run `tools/wikitool publish` only if asked.
|
- **Publish?** Lint does not auto-publish. Run `tools/wikitool publish` only if asked.
|
||||||
- **Bulk fixes touched 10+ files?** Expected for a lint pass: `publish` exits 42. Show the
|
- **Bulk fixes touched 10+ files?** Expected for a lint pass: `publish` exits 42. Show the
|
||||||
user its output and stop; see [gates.md](../gates.md). Consider `--path` batches instead.
|
user its output and stop; see `instructions/gates.md`. Consider `--path` batches instead.
|
||||||
- **The gate or loop-breaker keeps tripping?** That is a signal to stop and re-plan with the
|
- **The gate or loop-breaker keeps tripping?** That is a signal to stop and re-plan with the
|
||||||
user, not to pass `--override-budget`. A full pass should land in roughly 20-35 calls.
|
user, not to pass `--override-budget`. A full pass should land in roughly 20-35 calls.
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ mechanical half looks exactly like a complete one.
|
|||||||
`publish` (only if asked)
|
`publish` (only if asked)
|
||||||
|
|
||||||
**Deliberately absent:** `rm` - a lint pass never deletes a page, and
|
**Deliberately absent:** `rm` - a lint pass never deletes a page, and
|
||||||
[page-lifecycle.md](../page-lifecycle.md) is where a deletion belongs. `log status` - it decides
|
`instructions/page-lifecycle.md` is where a deletion belongs. `log status` - it decides
|
||||||
this skill's *trigger*, but `wiki-ingest`'s last step is what runs it.
|
this skill's *trigger*, but `wiki-ingest`'s last step is what runs it.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ catalog and the audit log in sync.
|
|||||||
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
|
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
|
||||||
integrating into an existing one.
|
integrating into an existing one.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
**Read before drafting:** [kb/CONTRACT.md](../../kb/CONTRACT.md) - linking and provenance,
|
**Read before drafting:** `kb/CONTRACT.md` - linking and provenance,
|
||||||
both of which the tool enforces - and
|
both of which the tool enforces - and
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md), which is where this instance's language, naming
|
`kb/CONVENTIONS.md`, which is where this instance's language, naming
|
||||||
forms, tone and relationship labels are, together with the target collection's own
|
forms, tone and relationship labels are, together with the target collection's own
|
||||||
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
|
||||||
requirements come from `tools/wikitool types describe <type>`.
|
requirements come from `tools/wikitool types describe <type>`.
|
||||||
@@ -48,7 +48,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
subjects - so the prose connects to existing pages instead of restating them.
|
subjects - so the prose connects to existing pages instead of restating them.
|
||||||
|
|
||||||
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
5. **Draft.** Fill in the generated skeleton's TODO sections, following the tone rules in
|
||||||
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md#tone). If `provenance:` is `sourced` or `mixed`, cite
|
`kb/CONVENTIONS.md` § Tone. If `provenance:` is `sourced` or `mixed`, cite
|
||||||
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
hard facts as you write them with `tools/wikitool cite add --page "<Title>" --source
|
||||||
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
"Source - X"`, which also adds `X` to `sources:` - paste the `[^cite-id]` marker it prints.
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
|
|
||||||
One per relationship. Never hand-edit `related:`.
|
One per relationship. Never hand-edit `related:`.
|
||||||
|
|
||||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op create`.
|
7. **Close out.** `instructions/publish-cycle.md`, `--op create`.
|
||||||
|
|
||||||
## Updating a page
|
## Updating a page
|
||||||
|
|
||||||
@@ -84,11 +84,11 @@ requirements come from `tools/wikitool types describe <type>`.
|
|||||||
|
|
||||||
Never hand-edit `modified:`, `summary:` or `provenance:`.
|
Never hand-edit `modified:`, `summary:` or `provenance:`.
|
||||||
|
|
||||||
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op update`.
|
7. **Close out.** `instructions/publish-cycle.md`, `--op update`.
|
||||||
|
|
||||||
## Renaming, deleting, or unlinking
|
## Renaming, deleting, or unlinking
|
||||||
|
|
||||||
That is [page-lifecycle.md](../page-lifecycle.md). A title is the wiki's only identifier for a
|
That is `instructions/page-lifecycle.md`. A title is the wiki's only identifier for a
|
||||||
page, so none of it is a file operation.
|
page, so none of it is a file operation.
|
||||||
|
|
||||||
## Decision points
|
## Decision points
|
||||||
@@ -98,7 +98,7 @@ page, so none of it is a file operation.
|
|||||||
- **Entity or concept?** A thing you can point at is an entity; a *why* or *how* is a concept.
|
- **Entity or concept?** A thing you can point at is an entity; a *why* or *how* is a concept.
|
||||||
The collection contracts draw the line.
|
The collection contracts draw the line.
|
||||||
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
|
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
|
||||||
[gates.md](../gates.md).
|
`instructions/gates.md`.
|
||||||
|
|
||||||
## wikitool commands used
|
## wikitool commands used
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ page, so none of it is a file operation.
|
|||||||
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
|
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
|
||||||
|
|
||||||
`xref remove` belongs to the unlinking case, which this skill delegates whole to
|
`xref remove` belongs to the unlinking case, which this skill delegates whole to
|
||||||
[page-lifecycle.md](../page-lifecycle.md) rather than describing in a step of its own.
|
`instructions/page-lifecycle.md` rather than describing in a step of its own.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description: Answer a question using the LLM wiki's compiled knowledge - read-on
|
|||||||
|
|
||||||
**Trigger:** User asks a question.
|
**Trigger:** User asks a question.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md).
|
**Before the first `wikitool` call:** `instructions/session-setup.md`.
|
||||||
|
|
||||||
**Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a
|
**Hard rule:** read-only with respect to wiki *content*. Never modify, hand-edit, or scaffold a
|
||||||
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
|
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
|
||||||
@@ -46,7 +46,7 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
|||||||
|
|
||||||
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
|
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
|
||||||
Hedge to what those sources carry, not to a number - see
|
Hedge to what those sources carry, not to a number - see
|
||||||
[kb/CONVENTIONS.md § Hedging](../../kb/CONVENTIONS.md#hedging).
|
`kb/CONVENTIONS.md` § Hedging.
|
||||||
|
|
||||||
5. **Decide what earns a page - before the first `new`.** Name every page you are considering,
|
5. **Decide what earns a page - before the first `new`.** Name every page you are considering,
|
||||||
then hold each one on its own against all three criteria: the answer required synthesis
|
then hold each one on its own against all three criteria: the answer required synthesis
|
||||||
@@ -75,9 +75,9 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
|
|||||||
exist under different words. Then say the wiki has no confident source, and offer to ingest
|
exist under different words. Then say the wiki has no confident source, and offer to ingest
|
||||||
one.
|
one.
|
||||||
- **Filed a page?** Query does **not** auto-publish. Run `tools/wikitool publish` only if asked;
|
- **Filed a page?** Query does **not** auto-publish. Run `tools/wikitool publish` only if asked;
|
||||||
the sequence is in [publish-cycle.md](../publish-cycle.md).
|
the sequence is in `instructions/publish-cycle.md`.
|
||||||
- **Several answers filed at once?** That can trip the Mass-Update Gate - see
|
- **Several answers filed at once?** That can trip the Mass-Update Gate - see
|
||||||
[gates.md](../gates.md). The gate is a brake, not the check: it counts files and knows nothing
|
`instructions/gates.md`. The gate is a brake, not the check: it counts files and knows nothing
|
||||||
about whether any of them earned a page. Step 5 is what decides that, and a batch small enough
|
about whether any of them earned a page. Step 5 is what decides that, and a batch small enough
|
||||||
to pass the gate has not been cleared by it.
|
to pass the gate has not been cleared by it.
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ semantic review a lint pass does.
|
|||||||
|
|
||||||
**Trigger:** User asks for wiki statistics, "what's new", or a quick health snapshot.
|
**Trigger:** User asks for wiki statistics, "what's new", or a quick health snapshot.
|
||||||
|
|
||||||
**Before the first `wikitool` call:** [session-setup.md](../session-setup.md) - step 2's `lint` is
|
**Before the first `wikitool` call:** `instructions/session-setup.md` - step 2's `lint` is
|
||||||
not on the budget's exemption allowlist and is counted like any other call, gitignored report or
|
not on the budget's exemption allowlist and is counted like any other call, gitignored report or
|
||||||
not (§ Scope there).
|
not (§ Scope there).
|
||||||
|
|
||||||
**Hard rule:** read-only with respect to wiki *content*. Never create, modify, or scaffold a
|
**Hard rule:** read-only with respect to wiki *content*. Never create, modify, or scaffold a
|
||||||
page, never repair a finding, never publish. One file does get written: the report `lint`
|
page, never repair a finding, never publish. One file does get written: the report `lint`
|
||||||
produces in step 2. That is not an exception being stretched - `reports/` is gitignored and holds
|
produces in step 2. That is not an exception being stretched - `reports/` is gitignored and holds
|
||||||
no wiki page ([reports/CONTRACT.md](../../reports/CONTRACT.md)), so the write leaves nothing
|
no wiki page (`reports/CONTRACT.md`), so the write leaves nothing
|
||||||
behind that the wiki ships. If something looks wrong, point the user at `wiki-lint` or
|
behind that the wiki ships. If something looks wrong, point the user at `wiki-lint` or
|
||||||
`wiki-manage` instead of fixing it here.
|
`wiki-manage` instead of fixing it here.
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -150,7 +150,7 @@ tools/wikitool <command> --help
|
|||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
|
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
|
||||||
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
|
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. Refuses before staging anything when the push target is not the checked-out branch, so a `git push <branch>` cannot quietly publish a ref other than the commit just made; the *unborn* branch of a fresh `git init -b main` counts as checked out, which is what lets the first publish of a new instance work (`instructions/setup-instance.md` step 14), while a genuine detached HEAD is still refused. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it, and neither does a branch the remote has never seen (a newly created, empty remote repository). A remote that cannot be reached at all is deliberately not read that way: it keeps reporting "Nothing to commit" on a clean tree rather than attempting a push, so an offline or local-only instance is unaffected. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
|
||||||
|
|
||||||
### Workshop runs and session budget
|
### Workshop runs and session budget
|
||||||
|
|
||||||
@@ -168,9 +168,9 @@ tools/wikitool <command> --help
|
|||||||
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
|
||||||
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
|
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
|
||||||
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
|
||||||
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
|
||||||
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
|
||||||
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), and every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent. The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
|
||||||
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines that `AGENTS.md`, a stage/collection contract, or the flat `instructions/**.md` form covers - the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
@@ -189,8 +189,9 @@ tools/wikitool <command> --help
|
|||||||
| `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>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far) 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. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
|
||||||
| `version 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` |
|
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
|
||||||
|
| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` |
|
||||||
|
|
||||||
### Content migrations
|
### Content migrations
|
||||||
|
|
||||||
@@ -360,7 +361,7 @@ is atomic, and whether a retry is safe.
|
|||||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||||
|---------|--------------|---------|--------------|
|
|---------|--------------|---------|--------------|
|
||||||
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
|
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
|
||||||
| `publish` | git failed, **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
|
| `publish` | git failed, the push target is not the checked-out branch (including a real detached HEAD - but *not* the unborn branch of a fresh `git init`, which is a normal first publish), **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
|
||||||
|
|
||||||
### Workshop runs and session budget
|
### Workshop runs and session budget
|
||||||
|
|
||||||
@@ -378,9 +379,9 @@ is atomic, and whether a retry is safe.
|
|||||||
| `types list` | Never fails | Read-only | Safe to retry freely |
|
| `types list` | Never fails | Read-only | Safe to retry freely |
|
||||||
| `types describe` | Unknown type name | Read-only | Fix the name and retry |
|
| `types describe` | Unknown type name | Read-only | Fix the name and retry |
|
||||||
| `instructions sync` | No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed | No - one directory copy per skill per target (`.agents/skills/`, `.claude/skills/`); each copy is idempotent, so a re-run converges even after a partial failure | Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry |
|
| `instructions sync` | No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed | No - one directory copy per skill per target (`.agents/skills/`, `.claude/skills/`); each copy is idempotent, so a re-run converges even after a partial failure | Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry |
|
||||||
| `instructions verify` | Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
| `instructions verify` | Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins |
|
||||||
| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely |
|
| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely |
|
||||||
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, or a reference file's table-of-contents region is missing or stale | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region |
|
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name |
|
||||||
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
|
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
|
||||||
|
|
||||||
### Telemetry
|
### Telemetry
|
||||||
@@ -399,8 +400,9 @@ is atomic, and whether a retry is safe.
|
|||||||
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
|
| `version show` | `VERSION` is missing or unparseable | Read-only | Fix `VERSION` and retry |
|
||||||
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
| `version check` | The feed could not be reached, answered non-JSON, or carried no `tag_name`. **Never** answers "up to date" for a question it could not ask | Read-only, no local writes | A network failure is transient - retry once, then report it. HTTP 401/403 names `$WIKITOOL_UPDATE_TOKEN`; 404 means no release exists yet or the URL points at the wrong repo |
|
||||||
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
|
| `version notes` | An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, a missing `CHANGES.md`, or no entry naming the requested version | Read-only | Fix the named argument or file, then retry. Safe to retry |
|
||||||
| `version 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`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
| `version bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
|
||||||
| `version 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 |
|
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
|
||||||
|
| `version release` | A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running candidate), `VERSION` and the changelog's newest entry naming different versions, or (from two bumps on) an entry with no summary paragraph above the changesets | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run fails outright once the suffix is gone. If the outcome is uncertain, read `VERSION` before retrying - a release-shaped `VERSION` means it already ran |
|
||||||
|
|
||||||
### Content migrations
|
### Content migrations
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -91,11 +91,16 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
|
|||||||
5. Raise the version: `wikitool version bump --minor --title "..."` for a new
|
5. Raise the version: `wikitool version bump --minor --title "..."` for a new
|
||||||
command (`--patch` for a fix, `--major` when the new version is **not a
|
command (`--patch` for a fix, `--major` when the new version is **not a
|
||||||
drop-in replacement** - a renamed flag or artefact, a stricter check that
|
drop-in replacement** - a renamed flag or artefact, a stricter check that
|
||||||
newly fails on content an instance already had, anything needing hand-work
|
newly fails on **shipped content an instance already had** (a `kb/` page,
|
||||||
after the copy). Content migration is one way to land there, not the
|
an `instructions/*.md` file), anything needing hand-work after the copy).
|
||||||
definition of it: a `--major` may well ship `--no-migration`, and one that
|
A new command that is merely pickier about its *own* fresh input - a flag
|
||||||
does migrate also needs a document under `instructions/migrations/`. The
|
it did not previously accept, a write it now refuses without more from the
|
||||||
full test is `instructions/dev/version-parts.md` - read it before choosing
|
caller - is the ordinary MINOR case: nothing an instance already has stops
|
||||||
|
validating, there is simply more to say when the command is next invoked.
|
||||||
|
Content migration is one way to land in the MAJOR row, not the definition
|
||||||
|
of it: a `--major` may well ship `--no-migration`, and one that does
|
||||||
|
migrate also needs a document under `instructions/migrations/`. The full
|
||||||
|
test is `instructions/dev/version-parts.md` - read it before choosing
|
||||||
`--major`.
|
`--major`.
|
||||||
A new command reaches every future instance, and CI's version gate refuses a
|
A new command reaches every future instance, and CI's version gate refuses a
|
||||||
stack change that moved no version.
|
stack change that moved no version.
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ A sixth checks a *reference* rather than a copy: no document `dist export`
|
|||||||
ships may cite an issue number, because the board those numbers live on
|
ships may cite an issue number, because the board those numbers live on
|
||||||
exists only in the origin repo.
|
exists only in the origin repo.
|
||||||
|
|
||||||
|
A seventh checks the other half of the same reference problem: every relative
|
||||||
|
markdown link in a reference file - `toc.target_files()`'s scope, the same one
|
||||||
|
the table-of-contents check uses - must resolve to a file that actually
|
||||||
|
exists. A link with the wrong `../` count is invisible to every check above:
|
||||||
|
it is present, it names an existing command or contract by title, and nothing
|
||||||
|
renders it to notice the target is unreachable. The complementary half - that
|
||||||
|
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
|
||||||
|
because `instructions sync` copies it to a different depth than its links
|
||||||
|
assume - is `instructions verify`'s job, not this one, since that module
|
||||||
|
already owns the Skill/Instruction split (`skill_dirs()` vs
|
||||||
|
`instruction_files()`).
|
||||||
|
|
||||||
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
Everything here is a hard oracle: a set comparison or a regex, no judgment.
|
||||||
Content quality of the contracts themselves stays with the LLM.
|
Content quality of the contracts themselves stays with the LLM.
|
||||||
"""
|
"""
|
||||||
@@ -44,7 +56,7 @@ from typing import Optional
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from chemenu import config, conventions, kb_collections, toc, version as version_mod
|
from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod
|
||||||
from chemenu.commands import dist_cmd
|
from chemenu.commands import dist_cmd
|
||||||
from chemenu.commands._util import fail, rel_path, success
|
from chemenu.commands._util import fail, rel_path, success
|
||||||
|
|
||||||
@@ -452,6 +464,83 @@ def check_toc_regions() -> list[str]:
|
|||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
# A markdown link, `[text](target)`. The target excludes `)` and whitespace -
|
||||||
|
# the same restriction every link in this repo's own instructions already
|
||||||
|
# follows; a target needing either would need CommonMark's <angle-bracket>
|
||||||
|
# escaping, which nothing here uses.
|
||||||
|
MARKDOWN_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
|
||||||
|
|
||||||
|
# The suffix `dist export` re-keys an instance-owned file to, and the one
|
||||||
|
# `setup-instance.md` renames away again. Spelled here rather than imported
|
||||||
|
# from `ownership`, whose own `.template` handling answers a different
|
||||||
|
# question (which side an upstream merge keeps) over a narrower scope
|
||||||
|
# (paths under a content stage).
|
||||||
|
TEMPLATE_SUFFIX = ".template"
|
||||||
|
|
||||||
|
|
||||||
|
def is_external_or_anchor(target: str) -> bool:
|
||||||
|
"""A link this check does not resolve as a filesystem path: an absolute
|
||||||
|
URL, a `mailto:`, or a pure in-page `#anchor`.
|
||||||
|
|
||||||
|
Public (not `_`-prefixed): `instructions_cmd.check_skill_reference_paths`
|
||||||
|
imports this alongside `MARKDOWN_LINK_RE` rather than keeping a second
|
||||||
|
copy - the two checks classify the same link shape, just over different
|
||||||
|
file sets (AGENTS.md invariant 8)."""
|
||||||
|
return target.startswith(("http://", "https://", "mailto:", "#"))
|
||||||
|
|
||||||
|
|
||||||
|
def check_reference_targets() -> list[str]:
|
||||||
|
"""Every relative markdown link in a reference file resolves to a real file.
|
||||||
|
|
||||||
|
Scoped to `toc.target_files()` - AGENTS.md, the stage and collection
|
||||||
|
contracts, and every flat `instructions/**.md` file - the same scope the
|
||||||
|
table-of-contents check uses. That scope already excludes `SKILL.md`
|
||||||
|
(banned from carrying a markdown link at all - `instructions verify`'s
|
||||||
|
`check_skill_reference_paths`), `commonplace/` (vendored, not stack
|
||||||
|
material) and `raw/`/`kb/` page content (data, not documentation) beyond
|
||||||
|
the two files that are themselves reference material.
|
||||||
|
|
||||||
|
A target's `#anchor` suffix is stripped before resolving - CommonMark
|
||||||
|
anchors are not filesystem paths, and nothing here renders one to notice
|
||||||
|
a stale one anyway. Code fences and inline code spans are masked first
|
||||||
|
(`markdown_code.strip_code_spans`), so a passage that shows link syntax
|
||||||
|
as an example is not mistaken for a real reference.
|
||||||
|
|
||||||
|
**A target the stack ships only as a `.template` counts as resolving.**
|
||||||
|
`kb/CONVENTIONS.md` and every `kb/<name>/COLLECTION.md` are instance-owned:
|
||||||
|
a distribution carries `<name>.template` and the instance adopts it by
|
||||||
|
renaming, during `instructions/setup-instance.md`'s personalization step.
|
||||||
|
Between `dist export` and that step the real file legitimately does not
|
||||||
|
exist yet - while `kb/CONTRACT.md` and three flat instructions link to it
|
||||||
|
by its adopted name, correctly, because that is the name it will have.
|
||||||
|
Reporting those as dead links would fail a fresh export for doing exactly
|
||||||
|
what it is supposed to do, and would describe "not personalized yet" as a
|
||||||
|
broken link when `doctor`'s `conventions` check already says it precisely.
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
for path in toc.target_files():
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
masked = markdown_code.strip_code_spans(text)
|
||||||
|
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||||
|
for match in MARKDOWN_LINK_RE.finditer(masked_line):
|
||||||
|
target = match.group(1)
|
||||||
|
if is_external_or_anchor(target):
|
||||||
|
continue
|
||||||
|
target_path = target.split("#", 1)[0]
|
||||||
|
if not target_path:
|
||||||
|
continue
|
||||||
|
resolved = (path.parent / target_path).resolve()
|
||||||
|
if resolved.exists():
|
||||||
|
continue
|
||||||
|
if resolved.with_name(resolved.name + TEMPLATE_SUFFIX).exists():
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
f"{rel_path(path)}:{line_number} links to `{target}`, which does not "
|
||||||
|
"resolve to an existing file"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def command_table_free_readmes() -> list[Path]:
|
def command_table_free_readmes() -> list[Path]:
|
||||||
"""Every README that must not carry a copy of the command table.
|
"""Every README that must not carry a copy of the command table.
|
||||||
|
|
||||||
@@ -768,7 +857,7 @@ def check_breaking_change_for_boundary() -> list[str]:
|
|||||||
|
|
||||||
@app.command("verify")
|
@app.command("verify")
|
||||||
def verify():
|
def verify():
|
||||||
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
|
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
|
||||||
issues = (
|
issues = (
|
||||||
check_cli_readme()
|
check_cli_readme()
|
||||||
+ check_readmes_have_no_command_table()
|
+ check_readmes_have_no_command_table()
|
||||||
@@ -780,6 +869,7 @@ def verify():
|
|||||||
+ check_breaking_change_for_boundary()
|
+ check_breaking_change_for_boundary()
|
||||||
+ check_no_issue_references()
|
+ check_no_issue_references()
|
||||||
+ check_toc_regions()
|
+ check_toc_regions()
|
||||||
|
+ check_reference_targets()
|
||||||
)
|
)
|
||||||
|
|
||||||
if issues:
|
if issues:
|
||||||
@@ -791,7 +881,8 @@ def verify():
|
|||||||
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
|
||||||
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
|
||||||
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
f"no issue references in {len(shipped_prose())} shipped document(s), "
|
||||||
f"tables of contents current on {len(toc.target_files())} reference file(s), "
|
f"tables of contents current and every link resolving on "
|
||||||
|
f"{len(toc.target_files())} reference file(s), "
|
||||||
f"{version_mod.CHANGES_FILENAME} documents version "
|
f"{version_mod.CHANGES_FILENAME} documents version "
|
||||||
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -348,12 +348,22 @@ def _changed_files(paths: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def current_branch() -> Optional[str]:
|
def current_branch() -> Optional[str]:
|
||||||
"""The checked-out branch, or None in a detached HEAD / non-checkout."""
|
"""The checked-out branch, or None in a detached HEAD / non-checkout.
|
||||||
result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
||||||
|
`git symbolic-ref` and not `git rev-parse --abbrev-ref`, because the two disagree on
|
||||||
|
exactly the state a brand-new instance is in. On an *unborn* branch - `git init -b main`,
|
||||||
|
no commit yet, which is where `instructions/setup-instance.md` calls `publish` for the
|
||||||
|
first time - `HEAD` points at a ref that does not resolve, so `rev-parse` exits 128 and
|
||||||
|
the caller cannot tell it apart from a real detached HEAD. `symbolic-ref` reads what HEAD
|
||||||
|
*names* rather than what it resolves to, so it answers `main` there, empty (exit 1) on a
|
||||||
|
detached HEAD, and the branch name in the ordinary case - the distinction the branch check
|
||||||
|
below actually needs.
|
||||||
|
"""
|
||||||
|
result = _run(["git", "symbolic-ref", "--short", "-q", "HEAD"])
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
name = result.stdout.strip()
|
name = result.stdout.strip()
|
||||||
return None if not name or name == "HEAD" else name
|
return name or None
|
||||||
|
|
||||||
|
|
||||||
def branch_mismatch_message(checked_out: Optional[str], branch: str) -> str:
|
def branch_mismatch_message(checked_out: Optional[str], branch: str) -> str:
|
||||||
@@ -877,13 +887,39 @@ def reconcile(remote: str, branch: str, confirm_rebase: Optional[str] = None) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_lacks_branch(remote: str, branch: str) -> bool:
|
||||||
|
"""Whether `remote` is reachable *and* has no `branch` on it - the brand-new instance whose
|
||||||
|
remote repository was created empty, as distinct from a remote nobody can reach at all.
|
||||||
|
|
||||||
|
`git ls-remote --exit-code` separates the two by exit code alone: 0 when the ref is there,
|
||||||
|
2 when the remote answered and does not have it, 128 when the remote could not be reached
|
||||||
|
(a bad URL, no network, refused auth) or is not configured. Reading the code rather than the
|
||||||
|
message is not a style preference - git translates `fatal: couldn't find remote ref main`,
|
||||||
|
so matching on the text would work only in an English locale.
|
||||||
|
"""
|
||||||
|
return _run(["git", "ls-remote", "--exit-code", remote, branch]).returncode == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _has_commits(branch: str) -> bool:
|
||||||
|
"""Whether `branch` points at anything yet - false on the unborn branch of a fresh
|
||||||
|
`git init`, where there is nothing that could be ahead of anything."""
|
||||||
|
result = _run(["git", "rev-list", "--count", "-n", "1", branch])
|
||||||
|
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
|
||||||
|
|
||||||
|
|
||||||
def _local_ahead_of_remote(remote: str, branch: str) -> bool:
|
def _local_ahead_of_remote(remote: str, branch: str) -> bool:
|
||||||
"""Whether `branch` currently has a commit `<remote>/<branch>` lacks - true right after a
|
"""Whether `branch` currently has a commit `<remote>/<branch>` lacks - true right after a
|
||||||
stranded commit from a previous failed push, and also right after `reconcile` rebases local
|
stranded commit from a previous failed push, and also right after `reconcile` rebases local
|
||||||
work on top of a moved remote. Used to decide whether an otherwise-empty working tree still
|
work on top of a moved remote. Used to decide whether an otherwise-empty working tree still
|
||||||
has something to push."""
|
has something to push."""
|
||||||
if not remote_ref_exists(remote, branch):
|
if not remote_ref_exists(remote, branch):
|
||||||
return False
|
# No tracking ref, which `remote_ref_exists` cannot tell apart from an unreachable
|
||||||
|
# remote - and the very first publish of an instance lands here. A remote that answers
|
||||||
|
# and simply has no such branch yet means every local commit is unpushed, which is
|
||||||
|
# precisely the stranded state above; an unreachable one keeps the old answer, so an
|
||||||
|
# offline or local-only instance sees no new behaviour and the eventual `git push`
|
||||||
|
# (when there is something to stage) still reports the real error.
|
||||||
|
return remote_lacks_branch(remote, branch) and _has_commits(branch)
|
||||||
result = _run(["git", "rev-list", "--count", f"{remote}/{branch}..{branch}"])
|
result = _run(["git", "rev-list", "--count", f"{remote}/{branch}..{branch}"])
|
||||||
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
|
return result.returncode == 0 and result.stdout.strip() not in ("", "0")
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ Both target directories are gitignored. A fresh clone has no skills until `sync`
|
|||||||
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
runs; `instructions/bootstrap.md` is the procedure, and `verify` says so rather
|
||||||
than reporting an error when *every* copy is missing, because that is the
|
than reporting an error when *every* copy is missing, because that is the
|
||||||
expected state of a clean checkout rather than a fault.
|
expected state of a clean checkout rather than a fault.
|
||||||
|
|
||||||
|
The copy is also a different depth than the source, and without the sibling
|
||||||
|
files a relative link might expect - a plain `shutil.copytree` per skill
|
||||||
|
directory, not a mirror of the whole `instructions/` tree. A relative markdown
|
||||||
|
link correct at `instructions/<name>/SKILL.md` therefore resolves to a
|
||||||
|
different, usually nonexistent, file in the published copy the harness
|
||||||
|
actually reads. `verify` forbids the shape outright
|
||||||
|
(`check_skill_reference_paths`) rather than checking depth arithmetic, and a
|
||||||
|
`SKILL.md` writes an outbound reference as a repo-root-relative plain path
|
||||||
|
instead - see instructions/CONTRACT.md § "A skill's outbound reference is a
|
||||||
|
plain path, not a link". `docs_verify.check_reference_targets` is the
|
||||||
|
complementary check, over the flat instructions and contracts that are still
|
||||||
|
allowed to link normally because nothing ever copies them elsewhere.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,8 +44,8 @@ from pathlib import Path
|
|||||||
import typer
|
import typer
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from chemenu import config
|
from chemenu import config, markdown_code
|
||||||
from chemenu.commands import dist_cmd
|
from chemenu.commands import dist_cmd, docs_verify
|
||||||
from chemenu.commands._util import fail, rel_path, success
|
from chemenu.commands._util import fail, rel_path, success
|
||||||
from chemenu.type_resolver import resolver
|
from chemenu.type_resolver import resolver
|
||||||
|
|
||||||
@@ -313,6 +326,45 @@ def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[s
|
|||||||
return referenced
|
return referenced
|
||||||
|
|
||||||
|
|
||||||
|
def check_skill_reference_paths() -> list[str]:
|
||||||
|
"""No `SKILL.md` may carry a relative markdown link.
|
||||||
|
|
||||||
|
`sync` copies each skill directory verbatim into `.agents/skills/<name>/`
|
||||||
|
and `.claude/skills/<name>/` - a different depth than
|
||||||
|
`instructions/<name>/SKILL.md` itself, and without the sibling files a
|
||||||
|
relative link might expect. A markdown link that resolves correctly at
|
||||||
|
the source (`../session-setup.md`, `../../kb/CONTRACT.md`) resolves to a
|
||||||
|
different, usually nonexistent, file once copied: the number of `../`
|
||||||
|
segments that reaches a target from `instructions/<name>/` does not reach
|
||||||
|
the same target from `.claude/skills/<name>/`.
|
||||||
|
|
||||||
|
So a `SKILL.md` never writes an outbound reference as a relative markdown
|
||||||
|
link - it names the target as a repo-root-relative plain path instead
|
||||||
|
(`` `instructions/session-setup.md` ``, not
|
||||||
|
`[session-setup.md](../session-setup.md)`). See instructions/CONTRACT.md
|
||||||
|
§ "A skill's outbound reference is a plain path, not a link" for why that
|
||||||
|
form survives the copy unchanged.
|
||||||
|
`docs_verify.check_reference_targets` is the complementary check, over the
|
||||||
|
flat instructions and contracts that are still allowed to link normally
|
||||||
|
because nothing ever copies them elsewhere."""
|
||||||
|
issues: list[str] = []
|
||||||
|
for source in skill_dirs():
|
||||||
|
path = source / SKILL_FILE
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
masked = markdown_code.strip_code_spans(text)
|
||||||
|
for line_number, masked_line in enumerate(masked.splitlines(), start=1):
|
||||||
|
for match in docs_verify.MARKDOWN_LINK_RE.finditer(masked_line):
|
||||||
|
target = match.group(1)
|
||||||
|
if docs_verify.is_external_or_anchor(target):
|
||||||
|
continue
|
||||||
|
issues.append(
|
||||||
|
f"{rel_path(path)}:{line_number} carries a relative markdown link to "
|
||||||
|
f"`{target}` - `instructions sync` copies this file to a different depth, "
|
||||||
|
"so write the target as a plain repo-root-relative path instead"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
@app.command("sync")
|
@app.command("sync")
|
||||||
def sync(
|
def sync(
|
||||||
force: bool = typer.Option(
|
force: bool = typer.Option(
|
||||||
@@ -351,7 +403,7 @@ def sync(
|
|||||||
|
|
||||||
@app.command("verify")
|
@app.command("verify")
|
||||||
def verify():
|
def verify():
|
||||||
"""Check instructions/ against its type, and every published copy against its source."""
|
"""Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source."""
|
||||||
sources = skill_dirs()
|
sources = skill_dirs()
|
||||||
instructions = instruction_files()
|
instructions = instruction_files()
|
||||||
if not sources and not instructions:
|
if not sources and not instructions:
|
||||||
@@ -398,7 +450,11 @@ def verify():
|
|||||||
if not frontmatter.get("description"):
|
if not frontmatter.get("description"):
|
||||||
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
issues.append(f"{source.name}: SKILL.md is missing (or has an empty) `description`")
|
||||||
|
|
||||||
# 3. Published copies match their sources. Missing *everywhere* is a clean
|
# 3. No skill carries a relative markdown link - see
|
||||||
|
# check_skill_reference_paths's own docstring for why the copy breaks it.
|
||||||
|
issues.extend(check_skill_reference_paths())
|
||||||
|
|
||||||
|
# 4. Published copies match their sources. Missing *everywhere* is a clean
|
||||||
# checkout, not a fault - say what to run instead of reporting drift.
|
# checkout, not a fault - say what to run instead of reporting drift.
|
||||||
expected = len(sources) * len(target_dirs())
|
expected = len(sources) * len(target_dirs())
|
||||||
missing = 0
|
missing = 0
|
||||||
@@ -419,7 +475,7 @@ def verify():
|
|||||||
if missing and not bootstrap_needed:
|
if missing and not bootstrap_needed:
|
||||||
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
issues.append(f"{missing} published copy/copies missing - run `wikitool instructions sync`")
|
||||||
|
|
||||||
# 4. An instruction nothing loads is inert. Nothing else would report it -
|
# 5. An instruction nothing loads is inert. Nothing else would report it -
|
||||||
# unless it is `manual: true`, which inverts the rule over a narrower
|
# unless it is `manual: true`, which inverts the rule over a narrower
|
||||||
# haystack: that instruction must not be linked from AGENTS.md or a
|
# haystack: that instruction must not be linked from AGENTS.md or a
|
||||||
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
# skill (automatic pickup), though a CONTRACT.md mentioning it by name
|
||||||
@@ -441,7 +497,7 @@ def verify():
|
|||||||
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
"Link it from a skill, a contract, AGENTS.md, or CLAUDE.md, or delete it."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
# 6. instructions/dev/ is a hard boundary: `dist export` prunes it whole,
|
||||||
# so nothing outside it may depend on something inside it staying
|
# so nothing outside it may depend on something inside it staying
|
||||||
# around in a distributed instance. See dev_only_forbidden_references's
|
# around in a distributed instance. See dev_only_forbidden_references's
|
||||||
# docstring for the dist:strip exemption.
|
# docstring for the dist:strip exemption.
|
||||||
|
|||||||
@@ -125,7 +125,15 @@ def is_exempt(command: str, args: list[str]) -> bool:
|
|||||||
if command in SKIP_COMMANDS:
|
if command in SKIP_COMMANDS:
|
||||||
return True
|
return True
|
||||||
subcommand = args[0] if args and not args[0].startswith("-") else ""
|
subcommand = args[0] if args and not args[0].startswith("-") else ""
|
||||||
return (command, subcommand) in SKIP_COMMAND_PATHS
|
if (command, subcommand) in SKIP_COMMAND_PATHS:
|
||||||
|
return True
|
||||||
|
# `version regrade` only reads when called with no further arguments at
|
||||||
|
# all - the bare listing. Any index (with `--impact`) writes CHANGES.md
|
||||||
|
# and stays counted like `version bump`, so this cannot join
|
||||||
|
# SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot.
|
||||||
|
if command == "version" and subcommand == "regrade":
|
||||||
|
return len(args) == 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _session_id() -> str:
|
def _session_id() -> str:
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
|||||||
holds the two together.
|
holds the two together.
|
||||||
- `version release` fixes that candidate: strips its `-beta.N` suffix and
|
- `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
|
closes its changelog entry. It is the only thing that turns a candidate into
|
||||||
a number a release actually consumes.
|
a number a release actually consumes. Refuses if the candidate collected
|
||||||
|
more than one bump and its entry still carries no summary above the
|
||||||
|
changesets - see `version_mod.summary_prose`.
|
||||||
|
- `version regrade` lists or changes the impact grade (high/medium/low) of
|
||||||
|
the running candidate's bump titles, addressed by their position in the
|
||||||
|
rendered list - the correction path for the judgment `version bump
|
||||||
|
--impact` made at the time, per Gitea #95's fix for an unreadably long,
|
||||||
|
ungraded bump list.
|
||||||
- `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
|
||||||
@@ -20,6 +27,7 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -210,6 +218,13 @@ def bump_command(
|
|||||||
"Requires a migration document already targeting the new base, and refuses when the entry "
|
"Requires a migration document already targeting the new base, and refuses when the entry "
|
||||||
"carries no --no-migration line to retract.",
|
"carries no --no-migration line to retract.",
|
||||||
),
|
),
|
||||||
|
impact: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--impact",
|
||||||
|
help="high|medium|low - how much this bump matters to a reader of the release notes "
|
||||||
|
"(default: medium). Grouped into the entry's bump list; `version regrade` corrects it "
|
||||||
|
"later if the running candidate's own judgment changes.",
|
||||||
|
),
|
||||||
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 or continue the running candidate, and open or update its
|
"""Raise or continue the running candidate, and open or update its
|
||||||
@@ -242,6 +257,10 @@ def bump_command(
|
|||||||
if not title.strip():
|
if not title.strip():
|
||||||
fail("--title must not be empty - it becomes the changelog entry's heading")
|
fail("--title must not be empty - it becomes the changelog entry's heading")
|
||||||
return
|
return
|
||||||
|
if impact is not None and impact not in version_mod.IMPACT_LEVELS:
|
||||||
|
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
|
||||||
|
return
|
||||||
|
chosen_impact = impact or version_mod.DEFAULT_IMPACT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
@@ -351,13 +370,15 @@ def bump_command(
|
|||||||
no_migration_reason=no_migration.strip() if no_migration else None,
|
no_migration_reason=no_migration.strip() if no_migration else None,
|
||||||
breaking_reason=breaking.strip() if breaking else None,
|
breaking_reason=breaking.strip() if breaking else None,
|
||||||
migration_required=migration_required,
|
migration_required=migration_required,
|
||||||
|
impact=chosen_impact,
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
|
||||||
success(
|
success(
|
||||||
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
|
||||||
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
|
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
|
||||||
f"`version release` once the candidate is ready to ship."
|
f"publishing, and `version release` once the candidate is ready to ship."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -382,7 +403,12 @@ def release_command(
|
|||||||
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
|
||||||
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
|
||||||
Refuses when `VERSION` is already a release: there is no running candidate
|
Refuses when `VERSION` is already a release: there is no running candidate
|
||||||
to fix."""
|
to fix. Also refuses - Gitea #95 - when the candidate collected two or
|
||||||
|
more bumps and its entry still has no summary paragraph above the
|
||||||
|
individual changesets: a release note that is only a chronological bump
|
||||||
|
list is exactly the thing this refusal exists to stop shipping. A
|
||||||
|
candidate with exactly one bump is exempt - there, the bump's own
|
||||||
|
changeset already is the summary."""
|
||||||
try:
|
try:
|
||||||
current = version_mod.read_version()
|
current = version_mod.read_version()
|
||||||
except VersionError as exc:
|
except VersionError as exc:
|
||||||
@@ -412,6 +438,18 @@ def release_command(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
section = version_mod.changes_section(text, current) or ""
|
||||||
|
bump_count = len(version_mod.bump_entries(section))
|
||||||
|
summary_chars = len(re.sub(r"\s+", "", version_mod.summary_prose(section)))
|
||||||
|
if bump_count >= 2 and summary_chars < version_mod.SUMMARY_MIN_CHARS:
|
||||||
|
fail(
|
||||||
|
f"This candidate collected {bump_count} bumps, but its {version_mod.CHANGES_FILENAME} "
|
||||||
|
"entry carries no summary above the individual changesets - write a short paragraph "
|
||||||
|
"(a few sentences on what this release is about) right below the bump list before "
|
||||||
|
"releasing. `version regrade` (no arguments) shows the bump list first, if that helps."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
new_version = current.base
|
new_version = current.base
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -428,3 +466,77 @@ def release_command(
|
|||||||
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
|
||||||
"is what release.yml reacts to."
|
"is what release.yml reacts to."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("regrade")
|
||||||
|
def regrade_command(
|
||||||
|
indices: Optional[list[int]] = typer.Argument(
|
||||||
|
None,
|
||||||
|
help="1-based positions in the rendered bump list to regrade (see the bare listing). "
|
||||||
|
"Omit to just list.",
|
||||||
|
),
|
||||||
|
impact: Optional[str] = typer.Option(
|
||||||
|
None, "--impact", help="high|medium|low - required together with indices"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""List the running candidate's bump titles with their impact grade, or
|
||||||
|
change one or more of them in a single call.
|
||||||
|
|
||||||
|
Positions are `version_mod.bump_entries`'s own rendered order - grouped
|
||||||
|
High before Medium before Low, chronological within a grade - as it
|
||||||
|
stands *before* this call: `wikitool version regrade 3 7 --impact high`
|
||||||
|
regrades both against today's list in one read, not #3 first and then #7
|
||||||
|
against whatever regrading #3 produced. Run the bare command again
|
||||||
|
afterwards to see the result and its new numbering.
|
||||||
|
|
||||||
|
The bare listing is read-only and, like `version notes`, exempt from the
|
||||||
|
Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted
|
||||||
|
like `version bump`, because that is what it does."""
|
||||||
|
try:
|
||||||
|
current = version_mod.read_version()
|
||||||
|
except VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
changes = version_mod.changes_file()
|
||||||
|
if not changes.is_file():
|
||||||
|
fail(f"{version_mod.CHANGES_FILENAME} is missing - there is nothing to regrade")
|
||||||
|
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 regrade. "
|
||||||
|
"Fix whichever is wrong."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
section = version_mod.changes_section(text, current) or ""
|
||||||
|
entries = version_mod.bump_entries(section)
|
||||||
|
if not entries:
|
||||||
|
fail(f"{current}'s {version_mod.CHANGES_FILENAME} entry has no bump list to regrade.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not indices:
|
||||||
|
for position, (level, bump_title) in enumerate(entries, start=1):
|
||||||
|
typer.echo(f"{position}. [{level}] {bump_title}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if impact is None:
|
||||||
|
fail("--impact is required when regrading - pass one of high/medium/low.")
|
||||||
|
return
|
||||||
|
if impact not in version_mod.IMPACT_LEVELS:
|
||||||
|
fail(f"--impact must be one of {', '.join(version_mod.IMPACT_LEVELS)}, not {impact!r}")
|
||||||
|
return
|
||||||
|
|
||||||
|
updates = {index: impact for index in indices}
|
||||||
|
try:
|
||||||
|
new_text = version_mod.regrade(text, current, updates)
|
||||||
|
except VersionError as exc:
|
||||||
|
fail(str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
changes.write_text(new_text, encoding="utf-8")
|
||||||
|
success(f"Regraded {len(indices)} bump title(s) to {impact} impact.")
|
||||||
|
|||||||
@@ -533,6 +533,100 @@ def test_no_shipped_document_cites_an_issue():
|
|||||||
assert docs_verify.check_no_issue_references() == []
|
assert docs_verify.check_no_issue_references() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dead_relative_link_is_reported(tmp_path, monkeypatch):
|
||||||
|
"""Regression guard for the bug this check exists to catch: a `../` count
|
||||||
|
wrong for the file's own depth is invisible to every other check - the
|
||||||
|
name it links to is real, the text renders, and nothing resolves the
|
||||||
|
target to notice it lands nowhere."""
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [tools/CONTRACT.md](../tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
issues = docs_verify.check_reference_targets()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "example.md:1" in issues[0]
|
||||||
|
assert "../tools/CONTRACT.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_resolving_relative_link_is_not_reported(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "tools").mkdir()
|
||||||
|
(tmp_path / "tools" / "CONTRACT.md").write_text("# Contract\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [tools/CONTRACT.md](tools/CONTRACT.md) for the command table.\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absolute_url_is_not_resolved_as_a_path(tmp_path, monkeypatch):
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [Anthropic](https://www.anthropic.com).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_section_anchor_is_stripped_before_resolving(tmp_path, monkeypatch):
|
||||||
|
"""CommonMark anchors are not filesystem paths - only the path part of
|
||||||
|
`target#anchor` is checked for existence."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md").write_text("## Tone\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text("See [kb/CONVENTIONS.md § Tone](kb/CONVENTIONS.md#tone).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(tmp_path, monkeypatch):
|
||||||
|
"""A passage documenting bad link syntax must not be mistaken for a real
|
||||||
|
reference - code fences are masked before scanning, mirroring `toc.py`."""
|
||||||
|
fake = tmp_path / "example.md"
|
||||||
|
fake.write_text(
|
||||||
|
"Do not write it like this:\n\n```markdown\n[gates.md](../nonexistent.md)\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_target_shipped_only_as_a_template_is_not_dead(tmp_path, monkeypatch):
|
||||||
|
"""Regression guard for a defect this check shipped with. A fresh
|
||||||
|
`dist export` carries `kb/CONVENTIONS.md.template`, not
|
||||||
|
`kb/CONVENTIONS.md` - the instance adopts it by renaming, during
|
||||||
|
`setup-instance.md`'s personalization step. `kb/CONTRACT.md` and three
|
||||||
|
flat instructions link to the adopted name, correctly. Before this
|
||||||
|
exemption the check reported 13 dead links on a just-exported tree, for
|
||||||
|
doing exactly what a fresh export is supposed to do."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
(tmp_path / "kb" / "CONVENTIONS.md.template").write_text("# Conventions\n", encoding="utf-8")
|
||||||
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||||
|
fake.write_text("What this instance decided: [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_target_with_neither_the_file_nor_a_template_is_still_dead(tmp_path, monkeypatch):
|
||||||
|
"""The exemption is narrow: it covers a file the stack ships as a
|
||||||
|
template, not any missing target."""
|
||||||
|
(tmp_path / "kb").mkdir()
|
||||||
|
fake = tmp_path / "kb" / "CONTRACT.md"
|
||||||
|
fake.write_text("See [kb/CONVENTIONS.md](CONVENTIONS.md).\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(docs_verify.toc, "target_files", lambda: [fake])
|
||||||
|
issues = docs_verify.check_reference_targets()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "CONVENTIONS.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_reference_files_link_targets_resolve():
|
||||||
|
"""Forward direction, against the real tree: every relative link in a file
|
||||||
|
`toc.target_files()` covers must resolve - this is what a `../` count
|
||||||
|
wrong for the file's own depth is caught by."""
|
||||||
|
assert docs_verify.check_reference_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_raises_when_a_reference_target_is_dead(monkeypatch):
|
||||||
|
monkeypatch.setattr(docs_verify, "check_reference_targets", lambda: ["dangling"])
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
docs_verify.verify()
|
||||||
|
|
||||||
|
|
||||||
def test_a_cited_issue_number_is_reported(monkeypatch):
|
def test_a_cited_issue_number_is_reported(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
docs_verify,
|
docs_verify,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from chemenu.commands.git_publish import (
|
|||||||
clearance_message,
|
clearance_message,
|
||||||
collect_changes,
|
collect_changes,
|
||||||
counted_files,
|
counted_files,
|
||||||
|
current_branch,
|
||||||
describe_status,
|
describe_status,
|
||||||
format_changes,
|
format_changes,
|
||||||
group_of,
|
group_of,
|
||||||
@@ -27,6 +28,7 @@ from chemenu.commands.git_publish import (
|
|||||||
parse_porcelain_z,
|
parse_porcelain_z,
|
||||||
publish_command,
|
publish_command,
|
||||||
reconcile,
|
reconcile,
|
||||||
|
remote_lacks_branch,
|
||||||
rerun_command,
|
rerun_command,
|
||||||
scale_line,
|
scale_line,
|
||||||
sync_command,
|
sync_command,
|
||||||
@@ -342,6 +344,41 @@ def repo(tmp_path, monkeypatch):
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_instance(tmp_path, monkeypatch):
|
||||||
|
"""A brand-new instance as `instructions/setup-instance.md` builds one: `git init -b main`,
|
||||||
|
an identity, a remote pointing at an *empty* repository, and no commit anywhere - neither
|
||||||
|
locally nor on the remote.
|
||||||
|
|
||||||
|
Deliberately not the `repo` fixture with a step removed. That one makes an initial commit
|
||||||
|
and pushes it, which is exactly the state the first publish of an instance has not reached
|
||||||
|
yet: the local branch is unborn and the remote has no `main`. Both defects this covers live
|
||||||
|
in that gap.
|
||||||
|
"""
|
||||||
|
root = tmp_path / "instance"
|
||||||
|
remote = tmp_path / "empty-remote.git"
|
||||||
|
root.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-b", "main", "--bare", str(remote)], check=True, capture_output=True)
|
||||||
|
_git(root, "init", "-b", "main")
|
||||||
|
_git(root, "config", "user.name", "Test")
|
||||||
|
_git(root, "config", "user.email", "test@example.com")
|
||||||
|
_git(root, "remote", "add", "origin", str(remote))
|
||||||
|
(root / "kb").mkdir()
|
||||||
|
(root / "README.md").write_text("fresh instance\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", root)
|
||||||
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "test-session")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_log(remote_path):
|
||||||
|
"""The remote's one-line log, or "" for a remote with no commits at all - `git log` exits
|
||||||
|
non-zero on an empty repository, so `_git`'s assertion cannot be used here."""
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "log", "--oneline"], cwd=remote_path, capture_output=True, text=True,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
def _write_files(root, n, prefix="kb/page"):
|
def _write_files(root, n, prefix="kb/page"):
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
(root / f"{prefix}{i}.md").write_text(f"page {i}\n", encoding="utf-8")
|
(root / f"{prefix}{i}.md").write_text(f"page {i}\n", encoding="utf-8")
|
||||||
@@ -724,6 +761,94 @@ def test_publish_pushes_a_stranded_local_commit_with_no_new_changes(repo):
|
|||||||
assert "stranded commit" in remote_log
|
assert "stranded commit" in remote_log
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_branch_names_an_unborn_branch_rather_than_calling_it_detached(fresh_instance):
|
||||||
|
"""`git init -b main` with no commit yet: HEAD names `main` but does not resolve. Reporting
|
||||||
|
None here is what made the documented first publish of an instance fail as a detached HEAD."""
|
||||||
|
assert current_branch() == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_branch_still_reports_a_real_detached_head_as_none(fresh_instance):
|
||||||
|
"""The other half of the same distinction - the state the refusal is actually for."""
|
||||||
|
_git(fresh_instance, "add", "-A")
|
||||||
|
_git(fresh_instance, "commit", "-m", "first")
|
||||||
|
head = _git(fresh_instance, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
_git(fresh_instance, "checkout", "--detach", head)
|
||||||
|
|
||||||
|
assert current_branch() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_makes_the_first_commit_of_a_new_instance(fresh_instance):
|
||||||
|
"""The exact `setup-instance.md` step 14 scenario: unborn `main`, empty remote, nothing but
|
||||||
|
untracked files. It must commit and push, not refuse as a detached HEAD."""
|
||||||
|
_publish(message="chore: initial instance setup", threshold=100)
|
||||||
|
|
||||||
|
assert "initial instance setup" in _remote_log(fresh_instance.parent / "empty-remote.git")
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_on_an_unborn_branch_still_refuses_a_different_target_branch(fresh_instance):
|
||||||
|
"""Naming the unborn branch does not weaken the branch check - it makes it answerable. A
|
||||||
|
push aimed elsewhere is refused with the same `--branch` hint as on any other checkout."""
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="change", branch="feature", threshold=100)
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == 1
|
||||||
|
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gate_still_holds_on_a_fresh_instance_and_stages_nothing(fresh_instance):
|
||||||
|
"""A real distribution is far over the threshold at step 14, so the first publish is meant
|
||||||
|
to end at the gate - and leave the tree untouched, which has to hold with no HEAD to
|
||||||
|
compare against either."""
|
||||||
|
_write_files(fresh_instance, 10)
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(message="chore: initial instance setup")
|
||||||
|
|
||||||
|
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
|
||||||
|
# Nothing staged, nothing committed: all 11 files (10 pages plus the fixture's README)
|
||||||
|
# are still untracked, and the branch is still unborn.
|
||||||
|
status = _git(fresh_instance, "status", "--porcelain", "-uall").stdout.strip().splitlines()
|
||||||
|
assert len(status) == 11 and all(line.startswith("??") for line in status)
|
||||||
|
assert current_branch() == "main" and not git_publish._has_commits("main")
|
||||||
|
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_pushes_a_committed_stand_to_a_remote_that_has_no_branch_yet(fresh_instance):
|
||||||
|
"""The `--no-push`-then-`publish` chain: the commit exists, the working tree is clean, and
|
||||||
|
the remote has never seen this branch. "Nothing to commit" here strands the commit forever,
|
||||||
|
because invariant 5 rules out a hand-made `git push`."""
|
||||||
|
_publish(message="chore: initial instance setup", push=False, threshold=100)
|
||||||
|
assert _remote_log(fresh_instance.parent / "empty-remote.git") == ""
|
||||||
|
|
||||||
|
_publish(message="chore: initial instance setup", threshold=100)
|
||||||
|
|
||||||
|
assert "initial instance setup" in _remote_log(fresh_instance.parent / "empty-remote.git")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unborn_branch_is_not_ahead_of_an_empty_remote(fresh_instance):
|
||||||
|
"""Nothing committed anywhere: there is no commit that could be ahead, and claiming
|
||||||
|
otherwise would send `publish` into a push of nothing."""
|
||||||
|
assert git_publish._local_ahead_of_remote("origin", "main") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_remote_is_not_mistaken_for_one_that_lacks_the_branch(fresh_instance):
|
||||||
|
""""Cannot reach the remote" and "the remote has no such branch" both leave no tracking ref.
|
||||||
|
Only the second means every local commit is unpushed; treating the first that way would turn
|
||||||
|
every offline publish into a failed push."""
|
||||||
|
_git(fresh_instance, "add", "-A")
|
||||||
|
_git(fresh_instance, "commit", "-m", "local only")
|
||||||
|
_git(fresh_instance, "remote", "set-url", "origin", str(fresh_instance.parent / "nope.git"))
|
||||||
|
|
||||||
|
assert remote_lacks_branch("origin", "main") is False
|
||||||
|
assert git_publish._local_ahead_of_remote("origin", "main") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reachable_remote_without_the_branch_is_told_apart_from_one_that_has_it(repo):
|
||||||
|
"""The positive half, on the fixture whose remote genuinely carries `main`."""
|
||||||
|
assert remote_lacks_branch("origin", "main") is False
|
||||||
|
assert remote_lacks_branch("origin", "never-pushed") is True
|
||||||
|
|
||||||
|
|
||||||
def test_publish_auto_rebases_a_disjoint_divergence(repo):
|
def test_publish_auto_rebases_a_disjoint_divergence(repo):
|
||||||
"""The writer's change and this session's change touch different files: (a) alone is
|
"""The writer's change and this session's change touch different files: (a) alone is
|
||||||
enough, so this must go straight through - no exit 42."""
|
enough, so this must go straight through - no exit 42."""
|
||||||
|
|||||||
@@ -341,6 +341,123 @@ def test_a_self_mention_does_not_count_as_a_reference(layer):
|
|||||||
assert "gates.md" not in instructions_cmd.referenced_names()
|
assert "gates.md" not in instructions_cmd.referenced_names()
|
||||||
|
|
||||||
|
|
||||||
|
# --- skill reference paths ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fixture_skill_carries_no_relative_link(layer):
|
||||||
|
"""Forward direction: the fixture's own `wiki-demo/SKILL.md` mentions
|
||||||
|
`gates.md` as a bare word (`referenced_names()` relies on exactly that
|
||||||
|
substring match), never as a markdown link - so it must not trip the ban."""
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_relative_markdown_link_in_a_skill_is_reported(layer):
|
||||||
|
"""`sync` copies `wiki-demo/SKILL.md` to `.claude/skills/wiki-demo/SKILL.md`
|
||||||
|
and `.agents/skills/wiki-demo/SKILL.md` - a different depth than the
|
||||||
|
source - so a link written `../gates.md` here would resolve to a
|
||||||
|
different, usually nonexistent, file once published."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
issues = instructions_cmd.check_skill_reference_paths()
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "wiki-demo" in issues[0] and "../gates.md" in issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_root_relative_path_is_not_a_reported_link(layer):
|
||||||
|
"""The decided fix: a `SKILL.md` names its target as a repo-root-relative
|
||||||
|
plain path, not a link - even one written correctly with brackets and
|
||||||
|
parens. `instructions/gates.md` on its own, with no `[...]`, must pass."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee `instructions/gates.md`.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_absolute_url_in_a_skill_is_not_reported(layer):
|
||||||
|
"""The ban is about relative paths breaking under the copy - an external
|
||||||
|
link is not affected by where the file sits, so it stays a normal link."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [the spec](https://example.com/spec).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pure_anchor_link_in_a_skill_is_not_reported(layer):
|
||||||
|
"""A same-page `#anchor` link is not a filesystem reference and does not
|
||||||
|
move when the file is copied."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\n## Steps\n\nSee [Steps](#steps) above.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_syntax_shown_as_an_example_in_a_fence_is_not_flagged(layer):
|
||||||
|
"""A skill documenting the banned syntax as an example (rather than using
|
||||||
|
it) must not trip its own ban - code fences are masked before scanning,
|
||||||
|
the same way `toc.py` masks them before hunting for headings."""
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nDo not write it like this:\n\n"
|
||||||
|
"```markdown\n[gates.md](../gates.md)\n```\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert instructions_cmd.check_skill_reference_paths() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_a_skill_with_a_relative_markdown_link(layer):
|
||||||
|
(layer / "instructions" / "wiki-demo" / "SKILL.md").write_text(
|
||||||
|
"---\nname: wiki-demo\ndescription: Demo skill.\n---\n\n"
|
||||||
|
"# Demo\n\nSee [gates.md](../gates.md).\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
instructions_cmd.sync(force=False)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
instructions_cmd.verify()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_path_resolves_the_same_regardless_of_which_copy_reads_it(layer):
|
||||||
|
"""The property the whole fix rests on. A repo-root-relative plain path
|
||||||
|
(`instructions/gates.md`) names the same file whether it is read from the
|
||||||
|
source (`instructions/wiki-demo/SKILL.md`) or from either published copy
|
||||||
|
(`.claude/skills/wiki-demo/SKILL.md`, `.agents/skills/wiki-demo/SKILL.md`),
|
||||||
|
because it is resolved against the instance root, never against the
|
||||||
|
reading file's own directory.
|
||||||
|
|
||||||
|
A relative link has no such property, which is the defect this whole
|
||||||
|
check exists to prevent: the same `../gates.md` means
|
||||||
|
`instructions/gates.md` from the source but a nonexistent
|
||||||
|
`.claude/skills/gates.md` / `.agents/skills/gates.md` from either
|
||||||
|
published copy - one directory short of the real file, because `sync`
|
||||||
|
copies the skill one level shallower than `instructions/<name>/` sits."""
|
||||||
|
instructions_cmd.sync(force=False)
|
||||||
|
target = layer / "instructions" / "gates.md"
|
||||||
|
assert target.is_file()
|
||||||
|
|
||||||
|
source_dir = layer / "instructions" / "wiki-demo"
|
||||||
|
claude_copy_dir = _skill_copy(layer, ".claude")
|
||||||
|
agents_copy_dir = _skill_copy(layer, ".agents")
|
||||||
|
|
||||||
|
# The plain path resolves against the instance root, regardless of which
|
||||||
|
# of the three directories above is doing the reading.
|
||||||
|
for _ in (source_dir, claude_copy_dir, agents_copy_dir):
|
||||||
|
assert (layer / "instructions" / "gates.md").is_file()
|
||||||
|
|
||||||
|
# The equivalent relative link would not: correct from the source, wrong
|
||||||
|
# from both copies.
|
||||||
|
assert (source_dir / ".." / "gates.md").resolve() == target.resolve()
|
||||||
|
assert not (claude_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||||
|
assert not (agents_copy_dir / ".." / "gates.md").resolve().is_file()
|
||||||
|
|
||||||
|
|
||||||
# --- instructions/dev/ boundary ----------------------------------------------
|
# --- instructions/dev/ boundary ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,19 @@ def test_search_exemption_survives_a_query_that_looks_like_a_subcommand():
|
|||||||
assert not run_budget.is_exempt("publish", ["--message", "x"])
|
assert not run_budget.is_exempt("publish", ["--message", "x"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_regrade_bare_listing_is_exempt():
|
||||||
|
"""Gitea #95: `version regrade` only reads when called with no further
|
||||||
|
arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS
|
||||||
|
(that dict only ever looks at the subcommand slot), so it is its own
|
||||||
|
branch in `is_exempt`."""
|
||||||
|
assert run_budget.is_exempt("version", ["regrade"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_regrade_with_indices_is_counted():
|
||||||
|
assert not run_budget.is_exempt("version", ["regrade", "1", "--impact", "high"])
|
||||||
|
assert not run_budget.is_exempt("version", ["regrade", "3"])
|
||||||
|
|
||||||
|
|
||||||
def test_reset_command_requires_yes():
|
def test_reset_command_requires_yes():
|
||||||
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
|
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_summary(tree: Path, words: str = "Summary paragraph. ") -> None:
|
||||||
|
"""Append enough non-whitespace prose above the changesets for `version
|
||||||
|
release`'s summary requirement to pass - the way an author's own edit
|
||||||
|
would, between the bumps region and any `### <bump title>` heading."""
|
||||||
|
path = tree / "CHANGES.md"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
marker = version_mod._BUMPS_CLOSE + "\n"
|
||||||
|
assert marker in text
|
||||||
|
path.write_text(text.replace(marker, marker + "\n" + words * 15 + "\n", 1), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
# --- the version itself ----------------------------------------------------
|
# --- the version itself ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -318,13 +329,237 @@ def test_release_entry_can_replace_the_title():
|
|||||||
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
assert "## 0.2.0 - 2026-09-05 - Summarising title" in released
|
||||||
|
|
||||||
|
|
||||||
|
# --- the changelog: impact grading (Gitea #95) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumps_block_with_all_medium_renders_flat_like_before():
|
||||||
|
section = version_mod._bumps_block([("medium", "First"), ("medium", "Second")])
|
||||||
|
assert version_mod._IMPACT_GROUP_RE.search(section) is None
|
||||||
|
assert "- First" in section and "- Second" in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumps_block_groups_by_impact_in_high_medium_low_order():
|
||||||
|
section = version_mod._bumps_block(
|
||||||
|
[("low", "Low one"), ("high", "High one"), ("medium", "Medium one")]
|
||||||
|
)
|
||||||
|
assert section.index("**High impact**") < section.index("**Medium impact**")
|
||||||
|
assert section.index("**Medium impact**") < section.index("**Low impact**")
|
||||||
|
assert "- High one" in section and "- Medium one" in section and "- Low one" in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_bumps_block_omits_empty_groups():
|
||||||
|
section = version_mod._bumps_block([("high", "Only one"), ("low", "Another")])
|
||||||
|
assert "**Medium impact**" not in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_entries_round_trips_through_bumps_block():
|
||||||
|
"""Round-trips the *grouping*, not the original chronological order: the
|
||||||
|
region is grouped by grade, so a reparse reads back grouped too."""
|
||||||
|
entries = [("high", "First"), ("low", "Second"), ("high", "Third")]
|
||||||
|
section = version_mod._bumps_block(entries)
|
||||||
|
assert version_mod.bump_entries(section) == [
|
||||||
|
("high", "First"), ("high", "Third"), ("low", "Second"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_entries_reads_a_pre_impact_flat_region_as_all_medium():
|
||||||
|
section = f"{version_mod._BUMPS_OPEN}\n- Old style\n- Another\n{version_mod._BUMPS_CLOSE}"
|
||||||
|
assert version_mod.bump_entries(section) == [("medium", "Old style"), ("medium", "Another")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_prose_is_the_text_between_the_bumps_region_and_the_first_changeset():
|
||||||
|
section = (
|
||||||
|
f"## 1.1.0 - 2026-09-12 - Title\n\n**Author:** Someone\n\n"
|
||||||
|
f"{version_mod._bumps_block([('medium', 'A')])}\n\n"
|
||||||
|
"This is the summary.\n\n### A\n\nDetail.\n"
|
||||||
|
)
|
||||||
|
assert version_mod.summary_prose(section).strip() == "This is the summary."
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_changes_several_indices_against_one_read():
|
||||||
|
text = CHANGES_HEADER + version_mod.insert_changes_entry(
|
||||||
|
"", Version(0, 2, 0, beta=1), "2026-09-01", "First", "Someone",
|
||||||
|
)
|
||||||
|
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=2), "2026-09-02", "Second", "Someone")
|
||||||
|
text = version_mod.insert_changes_entry(text, Version(0, 2, 0, beta=3), "2026-09-03", "Third", "Someone")
|
||||||
|
regraded = version_mod.regrade(text, Version(0, 2, 0, beta=3), {1: "high", 3: "low"})
|
||||||
|
section = version_mod.changes_section(regraded, Version(0, 2, 0, beta=3))
|
||||||
|
assert version_mod.bump_entries(section) == [
|
||||||
|
("high", "First"), ("medium", "Second"), ("low", "Third"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_refuses_an_out_of_range_index():
|
||||||
|
text = CHANGES_HEADER + version_mod.insert_changes_entry(
|
||||||
|
"", Version(0, 2, 0, beta=1), "2026-09-01", "Only", "Someone",
|
||||||
|
)
|
||||||
|
with pytest.raises(VersionError):
|
||||||
|
version_mod.regrade(text, Version(0, 2, 0, beta=1), {5: "high"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- version bump: --impact -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_without_impact_defaults_to_medium(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||||
|
assert version_mod.bump_entries(section) == [("medium", "First bump")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_with_impact_grades_the_bump_and_a_follow_up_regroups_the_list(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact="low", dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||||
|
assert version_mod.bump_entries(section) == [("high", "First bump"), ("low", "Second bump")]
|
||||||
|
assert "**High impact**" in section and "**Low impact**" in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_bump_refuses_an_unknown_impact(tree):
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact="urgent", dry_run=False,
|
||||||
|
)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_breaking_line_sits_above_the_bumps_region(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
|
||||||
|
impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
# --- version regrade --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_lists_the_running_candidates_bumps(tree, capsys):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact="high", dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.regrade_command(indices=None, impact=None)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "1. [high] First bump" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_writes_the_new_impact_and_leaves_version_untouched(tree, capsys):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_before = (tree / "VERSION").read_text(encoding="utf-8")
|
||||||
|
version_cmd.regrade_command(indices=[1, 2], impact="high")
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8") == version_before
|
||||||
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
|
section = version_mod.changes_section(changes, version_mod.read_version())
|
||||||
|
assert version_mod.bump_entries(section) == [("high", "First bump"), ("high", "Second bump")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_refuses_without_impact(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.regrade_command(indices=[1], impact=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_refuses_an_out_of_range_index_via_the_cli(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.regrade_command(indices=[9], impact="high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_regrade_fails_without_a_running_candidate(tree):
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.regrade_command(indices=None, impact=None)
|
||||||
|
|
||||||
|
|
||||||
|
# --- version release: the summary requirement (Gitea #95) -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_refuses_two_bumps_with_no_summary(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_dry_run_also_refuses_without_a_summary(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
version_cmd.release_command(title=None, dry_run=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_passes_with_two_bumps_once_a_summary_is_written(tree):
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
|
)
|
||||||
|
_write_summary(tree)
|
||||||
|
version_cmd.release_command(title=None, dry_run=False)
|
||||||
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_passes_with_a_single_bump_and_no_summary(tree):
|
||||||
|
"""The one-bump exemption: there, the bump's own changeset already is the
|
||||||
|
summary. `test_release_fixes_version_and_the_changelog_heading` already
|
||||||
|
covers this shape; this test names the exemption explicitly."""
|
||||||
|
version_cmd.bump_command(
|
||||||
|
major=False, minor=True, patch=False, title="Only bump",
|
||||||
|
breaking=None, no_migration=None, migration_required=False, impact=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"
|
||||||
|
|
||||||
|
|
||||||
# --- version bump ----------------------------------------------------------
|
# --- version bump ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="Something happened",
|
major=False, minor=True, patch=False, title="Something happened",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
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")
|
||||||
@@ -336,11 +571,11 @@ def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
|
|||||||
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First",
|
major=False, minor=True, patch=False, title="First",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second",
|
major=False, minor=False, patch=True, title="Second",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.2"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
@@ -350,7 +585,7 @@ def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(t
|
|||||||
|
|
||||||
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, migration_required=False, dry_run=True
|
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=True
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
|
assert "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
@@ -363,7 +598,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
|||||||
major, minor, patch = flags
|
major, minor, patch = flags
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
major=major, minor=minor, patch=patch, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
@@ -371,7 +606,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
|
|||||||
def test_bump_refuses_an_empty_title(tree):
|
def test_bump_refuses_an_empty_title(tree):
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
major=False, minor=False, patch=True, title=" ", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +619,7 @@ def test_bump_refuses_when_version_and_changelog_disagree(tree):
|
|||||||
)
|
)
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, dry_run=False
|
major=False, minor=False, patch=True, title="x", breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
@@ -398,7 +633,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
|
|||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
|
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
@@ -413,7 +648,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
|
|||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
|
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.1"
|
||||||
|
|
||||||
@@ -430,11 +665,11 @@ def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migratio
|
|||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="every page is retyped", no_migration=None, migration_required=False, dry_run=False,
|
breaking="every page is retyped", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Follow-up",
|
major=True, minor=False, patch=False, title="Follow-up",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "2.0.0-beta.2"
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
@@ -446,7 +681,7 @@ def test_no_migration_records_the_reason_in_the_changelog(tree):
|
|||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the release feed moved",
|
breaking="the release feed moved",
|
||||||
no_migration="no distributed instance exists yet", migration_required=False, dry_run=False,
|
no_migration="no distributed instance exists yet", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
assert version_mod.MIGRATION_NONE_MARKER in changes
|
||||||
@@ -458,7 +693,7 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
|
|||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Fix",
|
major=False, minor=False, patch=True, title="Fix",
|
||||||
breaking=None, no_migration="not needed", migration_required=False, dry_run=False,
|
breaking=None, no_migration="not needed", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -479,7 +714,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
|||||||
one after all has no other way to take that statement back."""
|
one after all has no other way to take that statement back."""
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes
|
assert version_mod.MIGRATION_NONE_MARKER in changes
|
||||||
@@ -487,7 +722,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
|||||||
_migration_document(tree, "2.0.0")
|
_migration_document(tree, "2.0.0")
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
major=True, minor=False, patch=False, title="Turns out it migrates",
|
||||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
assert version_mod.MIGRATION_NONE_MARKER not in changes
|
||||||
@@ -498,12 +733,12 @@ def test_migration_required_retracts_the_no_migration_line(tree):
|
|||||||
def test_migration_required_is_refused_without_a_migration_document(tree):
|
def test_migration_required_is_refused_without_a_migration_document(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, dry_run=False,
|
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Turns out it migrates",
|
major=True, minor=False, patch=False, title="Turns out it migrates",
|
||||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
|
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
|
||||||
@@ -513,12 +748,12 @@ def test_migration_required_is_refused_with_no_no_migration_line_to_retract(tree
|
|||||||
_migration_document(tree, "2.0.0")
|
_migration_document(tree, "2.0.0")
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Breaking",
|
major=True, minor=False, patch=False, title="Breaking",
|
||||||
breaking="the feed moved", no_migration=None, migration_required=False, dry_run=False,
|
breaking="the feed moved", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Nothing to retract",
|
major=True, minor=False, patch=False, title="Nothing to retract",
|
||||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -527,7 +762,7 @@ def test_migration_required_is_refused_together_with_no_migration(tree):
|
|||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Contradiction",
|
major=True, minor=False, patch=False, title="Contradiction",
|
||||||
breaking="the feed moved", no_migration="kb/ untouched",
|
breaking="the feed moved", no_migration="kb/ untouched",
|
||||||
migration_required=True, dry_run=False,
|
migration_required=True, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -536,7 +771,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
|
|||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Fix",
|
major=False, minor=False, patch=True, title="Fix",
|
||||||
breaking=None, no_migration=None, migration_required=True, dry_run=False,
|
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -564,7 +799,7 @@ def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
|
|||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||||
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
|
breaking=None, no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
@@ -573,7 +808,7 @@ def test_breaking_records_what_stops_working_in_the_changelog(tree):
|
|||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=True, minor=False, patch=False, title="Renamed the feed",
|
major=True, minor=False, patch=False, title="Renamed the feed",
|
||||||
breaking="update_url points at a repo path that no longer exists",
|
breaking="update_url points at a repo path that no longer exists",
|
||||||
no_migration="kb/ keeps its shape", migration_required=False, dry_run=False,
|
no_migration="kb/ keeps its shape", migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
|
||||||
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
assert version_mod.BREAKING_CHANGE_MARKER in changes
|
||||||
@@ -590,7 +825,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
|||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="New command",
|
major=False, minor=True, patch=False, title="New command",
|
||||||
breaking="nothing, really", no_migration=None, migration_required=False, dry_run=False,
|
breaking="nothing, really", no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.0.0"
|
||||||
|
|
||||||
@@ -601,7 +836,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
|
|||||||
def test_release_fixes_version_and_the_changelog_heading(tree):
|
def test_release_fixes_version_and_the_changelog_heading(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.release_command(title=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"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
|
||||||
@@ -614,12 +849,13 @@ def test_release_fixes_version_and_the_changelog_heading(tree):
|
|||||||
def test_release_can_replace_the_title(tree):
|
def test_release_can_replace_the_title(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second bump",
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
|
_write_summary(tree)
|
||||||
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
version_cmd.release_command(title="Summary of both bumps", dry_run=False)
|
||||||
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 - " in changes
|
||||||
@@ -632,7 +868,7 @@ def test_release_can_replace_the_title(tree):
|
|||||||
def test_release_dry_run_writes_nothing(tree):
|
def test_release_dry_run_writes_nothing(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.release_command(title=None, dry_run=True)
|
version_cmd.release_command(title=None, dry_run=True)
|
||||||
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
|
||||||
@@ -647,7 +883,7 @@ def test_release_refuses_when_version_is_already_a_release(tree):
|
|||||||
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
def test_release_refuses_when_version_and_changelog_disagree(tree):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
(tree / "VERSION").write_text("9.9.9-beta.1\n", encoding="utf-8")
|
||||||
with pytest.raises(typer.Exit):
|
with pytest.raises(typer.Exit):
|
||||||
@@ -665,11 +901,11 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
|
|||||||
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=True, patch=False, title="First bump",
|
major=False, minor=True, patch=False, title="First bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.bump_command(
|
version_cmd.bump_command(
|
||||||
major=False, minor=False, patch=True, title="Second bump",
|
major=False, minor=False, patch=True, title="Second bump",
|
||||||
breaking=None, no_migration=None, migration_required=False, dry_run=False,
|
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
|
||||||
)
|
)
|
||||||
version_cmd.notes_command(version=None)
|
version_cmd.notes_command(version=None)
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
|
|||||||
+141
-28
@@ -459,20 +459,125 @@ def changes_section(text: str, version: Version) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _bumps_block(titles: list[str]) -> str:
|
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
|
||||||
lines = "\n".join(f"- {title}" for title in titles)
|
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
|
||||||
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
# Grading it at bump time, and letting a session regrade it before release,
|
||||||
|
# is the fix; see instructions/dev/version-parts.md § The candidate model.
|
||||||
|
IMPACT_LEVELS = ("high", "medium", "low")
|
||||||
|
DEFAULT_IMPACT = "medium"
|
||||||
|
_IMPACT_GROUP_RE = re.compile(r"^\*\*(High|Medium|Low) impact\*\*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def _bump_titles(section: str) -> list[str]:
|
def _bumps_block(entries: list[tuple[str, str]]) -> str:
|
||||||
|
"""Render the bumps region from `(impact, title)` pairs.
|
||||||
|
|
||||||
|
Grouped under a `**High/Medium/Low impact**` heading, in that order, each
|
||||||
|
present only if it holds at least one title. **Except** when every entry
|
||||||
|
is `medium` (the default, and the only grade that existed before this):
|
||||||
|
rendered flat, with no heading at all, exactly as `version bump` always
|
||||||
|
wrote it. That keeps a single-bump patch entry, and every entry a build
|
||||||
|
that predates `--impact` ever wrote, byte-identical to what it was.
|
||||||
|
"""
|
||||||
|
if all(impact == DEFAULT_IMPACT for impact, _ in entries):
|
||||||
|
lines = "\n".join(f"- {title}" for _, title in entries)
|
||||||
|
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
|
||||||
|
groups: dict[str, list[str]] = {level: [] for level in IMPACT_LEVELS}
|
||||||
|
for impact, title in entries:
|
||||||
|
groups[impact].append(title)
|
||||||
|
rendered = [
|
||||||
|
f"**{level.capitalize()} impact**\n" + "\n".join(f"- {title}" for title in groups[level])
|
||||||
|
for level in IMPACT_LEVELS
|
||||||
|
if groups[level]
|
||||||
|
]
|
||||||
|
return f"{_BUMPS_OPEN}\n" + "\n\n".join(rendered) + f"\n{_BUMPS_CLOSE}"
|
||||||
|
|
||||||
|
|
||||||
|
def bump_entries(section: str) -> list[tuple[str, str]]:
|
||||||
|
"""The bumps region parsed back into `(impact, title)` pairs, in rendered
|
||||||
|
order - the addressing `version regrade` and `version_cmd.release_command`
|
||||||
|
use.
|
||||||
|
|
||||||
|
A `**<Grade> impact**` heading sets the running grade for the `- ` lines
|
||||||
|
beneath it; a `- ` line with none above it - the shape every region had
|
||||||
|
before `--impact` existed, and the flat shape `_bumps_block` still writes
|
||||||
|
when every grade is `medium` - reads as `medium`. That is what makes an
|
||||||
|
old region parse the same as a new one that happens to grade everything
|
||||||
|
the same way.
|
||||||
|
"""
|
||||||
match = _BUMPS_RE.search(section)
|
match = _BUMPS_RE.search(section)
|
||||||
if not match:
|
if not match:
|
||||||
return []
|
return []
|
||||||
return [
|
entries: list[tuple[str, str]] = []
|
||||||
line[2:].strip()
|
current = DEFAULT_IMPACT
|
||||||
for line in match.group(1).strip("\n").splitlines()
|
for line in match.group(1).strip("\n").splitlines():
|
||||||
if line.strip().startswith("- ")
|
stripped = line.strip()
|
||||||
|
heading_match = _IMPACT_GROUP_RE.match(stripped)
|
||||||
|
if heading_match:
|
||||||
|
current = heading_match.group(1).lower()
|
||||||
|
continue
|
||||||
|
if stripped.startswith("- "):
|
||||||
|
entries.append((current, stripped[2:].strip()))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# The free-form paragraph `version release` requires above the changesets once
|
||||||
|
# a candidate collected more than one bump - see `summary_prose` and
|
||||||
|
# `version_cmd.release_command`. A number, not a quality judgement: it catches
|
||||||
|
# the empty and the one-line "TODO" case, nothing subtler.
|
||||||
|
SUMMARY_MIN_CHARS = 200
|
||||||
|
_CHANGESET_HEADING_RE = re.compile(r"^### ", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def summary_prose(section: str) -> str:
|
||||||
|
"""The text between the bumps region (or, for an entry with none, the
|
||||||
|
heading) and the first `### <bump title>` changeset heading - the
|
||||||
|
candidate's own summary of what it did, as opposed to the per-bump detail
|
||||||
|
below it.
|
||||||
|
"""
|
||||||
|
close = section.find(_BUMPS_CLOSE)
|
||||||
|
if close != -1:
|
||||||
|
start = close + len(_BUMPS_CLOSE)
|
||||||
|
else:
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
start = heading_match.end() if heading_match else 0
|
||||||
|
heading = _CHANGESET_HEADING_RE.search(section, start)
|
||||||
|
end = heading.start() if heading else len(section)
|
||||||
|
return section[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def regrade(text: str, version: "Version", updates: dict[int, str]) -> str:
|
||||||
|
"""Change the impact grade of one or more of the topmost entry's bump
|
||||||
|
titles, addressed by their 1-based position in `bump_entries`'s rendered
|
||||||
|
order.
|
||||||
|
|
||||||
|
All of `updates` are read against a **single** parse of the region, so
|
||||||
|
`{3: "high", 7: "high"}` in one call means "regrade these two against
|
||||||
|
today's list" - not "regrade #3, re-render, then regrade #7 against
|
||||||
|
whatever that produced". `version_cmd.regrade_command` is the only
|
||||||
|
caller; `version` must already equal the entry it addresses (the same
|
||||||
|
VERSION/newest-entry agreement every other write here requires).
|
||||||
|
"""
|
||||||
|
start, end = _entry_span(text)
|
||||||
|
section = text[start:end]
|
||||||
|
heading_match = _CHANGES_ENTRY_RE.match(section)
|
||||||
|
if not heading_match or Version.parse(heading_match.group(1)) != version:
|
||||||
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry does not name {version}")
|
||||||
|
|
||||||
|
entries = bump_entries(section)
|
||||||
|
if not entries:
|
||||||
|
raise VersionError(f"{version}'s {CHANGES_FILENAME} entry has no bump list to regrade")
|
||||||
|
out_of_range = sorted(i for i in updates if i < 1 or i > len(entries))
|
||||||
|
if out_of_range:
|
||||||
|
raise VersionError(
|
||||||
|
f"index/indices out of range (1-{len(entries)}): {', '.join(map(str, out_of_range))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_entries = [
|
||||||
|
(updates.get(position, impact), title)
|
||||||
|
for position, (impact, title) in enumerate(entries, start=1)
|
||||||
]
|
]
|
||||||
|
new_section = _BUMPS_RE.sub(lambda _m: _bumps_block(new_entries), section, count=1)
|
||||||
|
return text[:start] + new_section + text[end:]
|
||||||
|
|
||||||
|
|
||||||
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
def _set_marker_line(section: str, marker: str, line: str) -> str:
|
||||||
@@ -481,17 +586,17 @@ def _set_marker_line(section: str, marker: str, line: str) -> str:
|
|||||||
Used for the breaking-change and no-migration lines, which - unlike the
|
Used for the breaking-change and no-migration lines, which - unlike the
|
||||||
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
bumps list - are not accumulated: a later bump that repeats `--breaking`
|
||||||
restates it rather than growing a list nobody would read as history.
|
restates it rather than growing a list nobody would read as history.
|
||||||
|
Anchored just above the bumps region (not below it, as before Gitea #95):
|
||||||
|
with a graded, potentially 30-line list, the line an operator most needs
|
||||||
|
to act on stayed the deepest thing in the entry otherwise.
|
||||||
"""
|
"""
|
||||||
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
pattern = re.compile(rf"^{re.escape(marker)}.*$", re.MULTILINE)
|
||||||
if pattern.search(section):
|
if pattern.search(section):
|
||||||
return pattern.sub(line, section, count=1)
|
return pattern.sub(line, section, count=1)
|
||||||
anchor = section.find(_BUMPS_CLOSE)
|
anchor = section.find(_BUMPS_OPEN)
|
||||||
if anchor != -1:
|
if anchor != -1:
|
||||||
insert_at = section.find("\n", anchor)
|
return section[:anchor] + f"{line}\n\n" + section[anchor:]
|
||||||
insert_at = insert_at + 1 if insert_at != -1 else len(section)
|
return section.rstrip() + f"\n\n{line}\n"
|
||||||
else:
|
|
||||||
insert_at = len(section)
|
|
||||||
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_marker_line(section: str, marker: str) -> str:
|
def _clear_marker_line(section: str, marker: str) -> str:
|
||||||
@@ -525,10 +630,12 @@ def _update_open_candidate(
|
|||||||
breaking_reason: Optional[str],
|
breaking_reason: Optional[str],
|
||||||
no_migration_reason: Optional[str],
|
no_migration_reason: Optional[str],
|
||||||
migration_required: bool = False,
|
migration_required: bool = False,
|
||||||
|
impact: str = DEFAULT_IMPACT,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
"""Move the topmost entry's heading to `version`/`date`/`title`, append
|
||||||
`title` to its machine-managed bump list, and set the breaking/no-migration
|
`(impact, title)` to its machine-managed bump list, and set the
|
||||||
lines only where this call supplies them - see `insert_changes_entry`.
|
breaking/no-migration lines only where this call supplies them - see
|
||||||
|
`insert_changes_entry`.
|
||||||
|
|
||||||
`migration_required` retracts an earlier `--no-migration` line instead of
|
`migration_required` retracts an earlier `--no-migration` line instead of
|
||||||
setting one - the two are mutually exclusive on a single bump, enforced by
|
setting one - the two are mutually exclusive on a single bump, enforced by
|
||||||
@@ -541,7 +648,9 @@ def _update_open_candidate(
|
|||||||
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
|
||||||
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
|
||||||
|
|
||||||
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
|
section = _BUMPS_RE.sub(
|
||||||
|
lambda _m: _bumps_block(bump_entries(section) + [(impact, title)]), section, count=1
|
||||||
|
)
|
||||||
|
|
||||||
if breaking_reason:
|
if breaking_reason:
|
||||||
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
|
||||||
@@ -562,6 +671,7 @@ 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,
|
||||||
migration_required: bool = False,
|
migration_required: bool = False,
|
||||||
|
impact: str = DEFAULT_IMPACT,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Open a new entry above the newest existing one, or - when the topmost
|
"""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 is still an open candidate (a pre-release heading) - update that
|
||||||
@@ -573,14 +683,17 @@ def insert_changes_entry(
|
|||||||
the topmost heading still a pre-release" the right test for "is a
|
the topmost heading still a pre-release" the right test for "is a
|
||||||
candidate still open" here.
|
candidate still open" here.
|
||||||
|
|
||||||
A fresh entry gets the skeleton only: heading, date, author, the
|
A fresh entry gets the skeleton only: heading, date, author, - when a
|
||||||
machine-managed bump-title list (started with this one title, for a
|
compatibility boundary is crossed - the line saying what breaks, plus the
|
||||||
candidate), and - when a compatibility boundary is crossed - the line
|
line saying no content has to change where that applies, and then the
|
||||||
saying what breaks, plus the line saying no content has to change where
|
machine-managed bump list (started with this one `(impact, title)` pair,
|
||||||
that applies. The break comes first: it is what an operator reading the
|
for a candidate). The break comes first, above the bump list rather than
|
||||||
release notes has to act on, and the migration line only qualifies it. The
|
below it (Gitea #95): it is what an operator reading the release notes has
|
||||||
entry's actual prose is written afterwards by whoever made the change,
|
to act on, the migration line only qualifies it, and neither should sit
|
||||||
which is also why `bump` refuses to invent a title.
|
beneath a list that can run to dozens of graded entries. The entry's
|
||||||
|
actual prose - the release summary, and each bump's own changeset - is
|
||||||
|
written afterwards by whoever made the change, which is also why `bump`
|
||||||
|
refuses to invent a title.
|
||||||
|
|
||||||
`migration_required` only has anything to retract on an already-open
|
`migration_required` only has anything to retract on an already-open
|
||||||
candidate, so a fresh entry ignores it - there is no earlier
|
candidate, so a fresh entry ignores it - there is no earlier
|
||||||
@@ -591,16 +704,16 @@ def insert_changes_entry(
|
|||||||
return _update_open_candidate(
|
return _update_open_candidate(
|
||||||
text, version, date, title,
|
text, version, date, title,
|
||||||
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
breaking_reason=breaking_reason, no_migration_reason=no_migration_reason,
|
||||||
migration_required=migration_required,
|
migration_required=migration_required, impact=impact,
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
lines += [f"{MIGRATION_NONE_MARKER} - {no_migration_reason}", ""]
|
||||||
|
if version.is_prerelease:
|
||||||
|
lines += [_bumps_block([(impact, title)]), ""]
|
||||||
entry = "\n".join(lines) + "\n---\n\n"
|
entry = "\n".join(lines) + "\n---\n\n"
|
||||||
anchor = re.search(r"^## ", text, re.MULTILINE)
|
anchor = re.search(r"^## ", text, re.MULTILINE)
|
||||||
if anchor:
|
if anchor:
|
||||||
|
|||||||
Reference in New Issue
Block a user