Compare commits

...

15 Commits

Author SHA1 Message Date
torben 5d26698cd0 stack: Version 6.0.0 freigegeben
CI / verify (push) Failing after 40s
Release / release (push) Successful in 40s
Files changed:
- CHANGES.md
- VERSION
2026-09-15 21:34:53 +02:00
torben bb097f614b search: Pfad und Titel vollstaendig in der Trefferzeile, Trunkierung wird benannt (schliesst #100)
CI / verify (push) Failing after 40s
Release / release (push) Successful in 36s
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/wiki-query/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/api.py
- tools/chemenu/commands/search.py
- tools/chemenu/mcp/server.py
- tools/chemenu/search/service.py
- tools/chemenu/search/types.py
- tools/chemenu/tests/test_api.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_search.py
2026-09-15 21:26:39 +02:00
torben 55f65c1ab1 fix: types/type-spec.schema.yaml enforced against real type-spec frontmatter, doc-pull-through.md docs/-page count corrected (closes #105)
CI / verify (push) Failing after 45s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- VERSION
- instructions/dev/doc-pull-through.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/tests/test_docs_verify.py
- types/type-spec.md
- types/type-spec.schema.yaml
2026-09-15 19:45:33 +02:00
torben 6eb3f84256 docs: README-Typenbaum und language-boundaries auf den guidance-Split nachgezogen (#104)
CI / verify (push) Failing after 38s
Files changed:
- README.md
- docs/language-boundaries.md
2026-09-15 18:25:18 +02:00
torben d49513bda6 types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert (schliesst #104)
CI / verify (push) Failing after 43s
Release / release (push) Successful in 34s
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/ownership-and-templates.md
- instructions/migrations/6.0.0-type-guidance-split.md
- instructions/setup-instance.md
- tools/CONTRACT.md
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/tests/test_dist_cmd.py
- tools/chemenu/tests/test_dist_upgrade.py
- tools/chemenu/tests/test_type_resolver.py
- tools/chemenu/tests/test_types_cmd.py
- tools/chemenu/type_resolver.py
- tools/chemenu/types_core.py
- types/comparison.guidance.md
- types/comparison.md
- types/concept.guidance.md
- types/concept.md
- types/entity.guidance.md
- types/entity.md
- types/source.guidance.md
- types/source.md
- types/type-guidance.md
- types/type-guidance.schema.yaml
- types/type-spec.md
2026-09-15 18:23:38 +02:00
torben 90ce41964f docs: ownership-and-templates verweist fuer die Sprachachse auf language-boundaries (#103)
CI / verify (push) Failing after 44s
Files changed:
- docs/ownership-and-templates.md
2026-09-15 16:59:29 +02:00
torben f350999053 stack: Control-Plane-Sprache universell - Achse ist das Publikum, kein Instanz-Schalter (#103)
CI / verify (push) Failing after 45s
Release / release (push) Successful in 38s
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- docs/language-boundaries.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- tools/chemenu/commands/dist_cmd.py
2026-09-15 16:58:55 +02:00
torben 05a75065ba types/type-spec.md: Ownership und Sprache getrennt benannt (Nachzug zu #99)
CI / verify (push) Successful in 44s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- types/type-spec.md
2026-09-15 16:31:08 +02:00
torben ef60e2984c docs: ownership-and-templates benennt die gemischte Zustaendigkeit im Seiten-Type-Spec (Nachzug zu #99)
CI / verify (push) Successful in 48s
Files changed:
- docs/ownership-and-templates.md
2026-09-15 16:29:42 +02:00
torben c64479fe02 stack: TOC-Scope auf types/ und docs/, Sprachregeln nach AGENTS.md zentralisiert, alle Templates auf Control-Plane-Sprache, --breaking akkumuliert (schliesst #99)
CI / verify (push) Successful in 45s
Release / release (push) Successful in 36s
Files changed:
- AGENTS.md
- CHANGES.md
- ENVIRONMENT.md.template
- SOUL.md
- SOUL.md.template
- USER.md.template
- VERSION
- docs/ownership-and-templates.md
- docs/version-model.md
- instructions/CONTRACT.md
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- instructions/setup-instance.md
- kb/CONVENTIONS.md
- kb/CONVENTIONS.md.template
- kb/concepts/COLLECTION.md
- kb/sources/COLLECTION.md
- tools/CONTRACT.md
- tools/chemenu/commands/types_cmd.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_toc.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/toc.py
- tools/chemenu/version.py
- types/comparison.md
- types/concept.md
- types/entity.md
- types/lint-report.md
- types/source.md
2026-09-15 16:21:02 +02:00
torben c0dc2129bb docs verify: nur als .template ausgeliefertes Linkziel gilt als aufgeloest (Defekt aus 0fb8fd6)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/tests/test_docs_verify.py
2026-09-13 00:40:10 +02:00
torben f140e26a4c stack: Linkziel-Check als Grenzuebertritt eingestuft, Kandidat auf 6.0.0 eskaliert
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
2026-09-12 23:46:51 +02:00
torben 0fb8fd6122 stack: SKILL.md-Links auf repo-root-relative Pfade umgestellt, docs verify/instructions verify pruefen Linkziele
CI / verify (push) Successful in 52s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- instructions/CONTRACT.md
- instructions/dev/doc-pull-through.md
- instructions/dev/stack-close/SKILL.md
- instructions/dev/stack-dev/SKILL.md
- instructions/wiki-ingest/SKILL.md
- instructions/wiki-lint/SKILL.md
- instructions/wiki-manage/SKILL.md
- instructions/wiki-query/SKILL.md
- instructions/wiki-status/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/instructions_cmd.py
- tools/chemenu/tests/test_docs_verify.py
- tools/chemenu/tests/test_instructions_cmd.py
2026-09-12 23:21:45 +02:00
torben dc688e5726 stack: Budget-Ausnahme von version regrade haengt an der Aufrufform (Doku-Nachzug zu #95)
CI / verify (push) Successful in 52s
Release / release (push) Successful in 36s
Files changed:
- CHANGES.md
- VERSION
- instructions/gates.md
- instructions/session-setup.md
2026-09-12 18:25:57 +02:00
torben 1b0158fc8d stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s
Files changed:
- CHANGES.md
- DEVELOPMENT.md
- VERSION
- instructions/dev/stack-dev/SKILL.md
- instructions/dev/version-parts.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/version_cmd.py
- tools/chemenu/tests/test_run_budget.py
- tools/chemenu/tests/test_version_cmd.py
- tools/chemenu/version.py
2026-09-12 18:23:58 +02:00
72 changed files with 3753 additions and 890 deletions
+44 -3
View File
@@ -99,6 +99,7 @@ What a file is called says who it is for and how it is loaded. This is a rule, n
| `instructions/<name>.md` | Agents | By link, or on explicit request |
| `instructions/<name>/SKILL.md` | Agents | By the harness, once published |
| `types/<name>.md` | Agents + validator | Via `tools/wikitool types describe`. Split by `root:`: a page type-spec (`root: kb`) belongs to the instance and ships as `.template`; one describing a stack artifact ships verbatim |
| `types/<name>.guidance.md` | Agents + validator | Via `tools/wikitool types describe`, composed with the `types/<name>.md` it documents. Stack-owned regardless of the type-spec's own `root:` - it ships verbatim and is optional, present only where the type-spec declares `guidance:` |
| `docs/<name>.md` | Agents and humans | By link, or on explicit request - never automatically, and never as instruction |
| `INDEX.md` | Both | Generated - never hand-edited |
@@ -107,20 +108,53 @@ documents. What it may not carry is the same content twice - a README that resta
contract is a second copy that drifts. `docs verify` enforces the specific case that already
happened once: no README may hold a copy of the `wikitool` command table.
**Two languages, and which is which.** Which one a line is written in follows from the *For*
column above - who reads it - and from nothing else: not from who owns the file, and not from
whether it ever leaves this checkout.
1. **The control plane is written in English** - this file, `CLAUDE.md`, every `CONTRACT.md`,
everything under `instructions/`, and the type-specs for non-page artifacts. Quoted
vocabulary is not prose and stays as it is: a section name, a relationship label or a
translated term cited as evidence. What addresses the *page* goes the other way - `kb/` pages,
and inside a page type-spec the parts that become page text - and follows
`kb/CONVENTIONS.md`, which is also where the instance's own terminology material is reached
from.
This holds for a control-plane file an instance writes **only for itself** and never ships:
an instruction of its own, a page type it added (`types/` takes one without a code change),
a further stage contract. Such a file is instance-owned end to end, which settles who may
change it, not who reads it - and the reader is still an agent. There is deliberately no
second language value beside `kb/CONVENTIONS.md`'s `language:`, and no instance setting that
moves this rule; [docs/language-boundaries.md](docs/language-boundaries.md) has the reasoning.
2. **An agent speaks the instance's KB language**, whatever this file is written in. The value
lives in `kb/CONVENTIONS.md`'s `language:` and nowhere else; an instruction that models a
sentence for the user writes it in English like the rest of the control plane, and the agent
says it in that language.
Nothing checks either mechanically - a stop-word scan would flag the quoted vocabulary above
and miss a translated paragraph that reads cleanly. They are held up by whoever writes an
instruction, which is why [instructions/CONTRACT.md](instructions/CONTRACT.md) § "Writing an
instruction" names them at the step where that happens.
**`docs/` carries no normative sentence.** It holds why the stack is built the way it is -
background consulted in passing, not a rule to follow; anything that would bind belongs in a
`CONTRACT.md` instead, which is what keeps invariant 8 intact here. It carries no frontmatter,
type, index, lint or provenance; `dist export` ships it verbatim and no other `tools/wikitool`
command touches it.
Four pages exist today, each read by link rather than automatically:
Five pages are reached from this file, each by link rather than automatically:
[docs/pipeline-rationale.md](docs/pipeline-rationale.md) (why the pipeline has four stages),
[docs/ownership-and-templates.md](docs/ownership-and-templates.md) (why a `.template` split
exists, and why silent overwrite is the failure it guards against),
[docs/why-gates-are-code.md](docs/why-gates-are-code.md) (why the four gates in
[docs/language-boundaries.md](docs/language-boundaries.md) (why the control plane is English
everywhere and the KB language is a value, and why the axis is the reader rather than the
owner), [docs/why-gates-are-code.md](docs/why-gates-are-code.md) (why the four gates in
[Gates](#gates) are code rather than instruction), and
[docs/version-model.md](docs/version-model.md) (why a version number answers a compatibility
question and a migration question separately).
question and a migration question separately). A sixth,
`docs/model-and-effort-selection.md`, is deliberately not linked here but from `CLAUDE.md`: it
decides something only that harness has to decide, and a link here would load it into the other
three.
## Personalization
@@ -217,6 +251,13 @@ tools/wikitool search --field entity_type=system --field '!sources'
`search` is read-only and exempt from the iteration budget.
**It is also exhaustive, so do not grep `kb/` yourself.** `search` *is* a `rg` run over `kb/`,
enriched with each hit's frontmatter and ranked; a grep of your own can therefore surface no
page it missed, only the generated files it deliberately excludes - `kb/index.md`, `kb/log.md`,
`kb/provenance.md`, every `INDEX.md` - which invariant 1 forbids acting on anyway. Each hit
carries the page's full path and full title, so it can be opened and passed to the commands
that take a title. A result cut short by `--limit` says so and names the total.
## Gates
Four limits are enforced in code rather than by instruction, because a prompt-level limit is
+413 -7
View File
@@ -25,13 +25,419 @@ names it with a `-beta.N` suffix (`## 4.4.0-beta.2 - <date> - <title>`), and
every bump of that same candidate updates this one entry in place rather than
opening another: the heading's version/date/title move, and the bump's
`--title` joins a machine-managed `<!-- wikitool:bumps -->` list right under
the entry's `**Author:**` line - written and read by `wikitool version bump`,
never by hand. `wikitool version release` is what closes a candidate: it
strips the suffix and turns the entry into an ordinary, suffix-free one,
leaving the bump-title list as the record of what happened. A distributed
instance never sees a `-beta.` version at all (`release.yml` only ever
releases a fixed one), so this suffix and the list beneath it are a
dev-checkout concern - readable here, never shipped as something to parse.
the entry's `**Author:**`/`**Breaking Change:**`/`**Migration:**` lines -
written and read by `wikitool version bump`, never by hand.
`**Breaking Change:**` accumulates, because one candidate can cross the
compatibility boundary more than once and each crossing is a separate thing an
operator has to act on: one reason stays on the marker line, a second and
further ones move to bullets beneath a bare marker. `**Migration:**` does not -
it answers one yes/no question about the candidate as a whole, so a later
answer replaces the earlier one.
That list is graded, not a flat chronological dump: each bump carries an
impact (`--impact high|medium|low`, default `medium`), and the list renders
grouped under `**High/Medium/Low impact**` headings - except when every bump
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 - 2026-09-15 - search: Pfad und Titel vollstaendig, Trunkierung sichtbar
**Author:** Torben Nehmer
**Breaking Change:**
- docs verify loest ab dieser Version jeden relativen Markdown-Link in den Referenzdateien auf und meldet ein totes Ziel als Fehler - auch in kb/CONVENTIONS.md und kb/<collection>/COLLECTION.md, die eine Instanz selbst besitzt und die ein Drop-in-Copy der Maschinerie nicht ersetzt. Eine Instanz, deren eigene Konventions- oder Collection-Datei einen relativen Link mit falscher ../-Tiefe oder auf eine inzwischen geloeschte Datei traegt, sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief. Reparatur: den in der Meldung genannten Datei:Zeile-Link korrigieren - kein Werkzeuglauf, keine Inhaltsmigration.
- docs verify prueft die TOC-Region ab dieser Version auch auf types/*.md und docs/*.md. Eine Instanz, die die Page-Type-Spec-Templates adoptiert hat, traegt types/source.md und types/concept.md ohne Region und sieht docs verify nach dem Update fehlschlagen, wo es vorher durchlief; dasselbe gilt fuer eine selbst angelegte oder lokal geaenderte docs/-Seite ueber 100 Zeilen. Reparatur: tools/wikitool docs toc --apply - ein Werkzeuglauf, keine Inhaltsmigration.
**Migration:** none required - Keine kb/-Seite aendert ihre Form. Der Grenzuebertritt ist ein strengerer Check auf instanz-eigener Prosa, keine Schema- oder Frontmatteraenderung.
<!-- wikitool:bumps -->
**High impact**
- SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
- docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
- TOC-Scope auf types/ und docs/ erweitert, Sprachregeln zentralisiert, --breaking akkumuliert
- types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert
**Medium impact**
- docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
- Control-Plane-Sprache universell: Achse ist das Publikum, kein Instanz-Schalter
- types/type-spec.schema.yaml enforced against real type-spec frontmatter
- search: Pfad und Titel vollstaendig, Trunkierung sichtbar
**Low impact**
- gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
- types/type-spec.md: Ownership und Sprache getrennt benannt (Nachzug zu #99)
<!-- /wikitool:bumps -->
Ausgangspunkt war ein realer Bruch: `instructions sync` kopiert jede `SKILL.md` in eine andere
Verzeichnistiefe, und 52 von 58 relativen Links darin zeigten in der publizierten Kopie ins Leere,
unbemerkt, weil kein Check je ein Linkziel gelesen hat. Die Reparatur - repo-root-relative Pfade
statt `../`-Links - zieht zwei neue mechanische Checks nach sich (`instructions verify` verbietet
relative Links in `SKILL.md`, `docs verify` loest Linkziele in allen Referenzdateien auf), und der
zweite Check erreicht auch instanz-eigene Prosa (`kb/CONVENTIONS.md`, `COLLECTION.md`), die ein
Drop-in-Copy nicht ersetzt - der Grenzuebertritt, der diesen Kandidaten auf `6.0.0` eskaliert hat.
Denselben Linkziel-Check bekommt die TOC-Pflicht gleich mit auf `types/` und `docs/` erweitert, und
die Sprachregeln fuer die Control-Plane sind zu einer einzigen, publikumsbasierten Regel in
`AGENTS.md` zentralisiert statt eines Instanz-Schalters. Daneben, unabhaengig vom Linkproblem: die
Seiten-Type-Spec-Anleitungsprosa ist in eine stackeigene Guidance-Datei ausgelagert,
`type-spec.schema.yaml` wird jetzt gegen echte Type-Spec-Frontmatter durchgesetzt, und `search`
zeigt Pfad und Titel eines Treffers vollstaendig statt trunkiert.
### gates.md/session-setup.md: die Budget-Ausnahme von version regrade haengt an der Aufrufform
Doku-Nachzug zu `5.1.0`. Beide Dateien beschrieben die Budget-Ausnahme als feste Liste pro
Kommandoname ("fixed allowlist"); `version regrade` ist die erste Ausnahme, die nur in *einer*
Aufrufform liest - bar listet sie, mit Positionen schreibt sie `CHANGES.md`. Die Liste selbst
bleibt an ihrem einen Ort (`tools/CONTRACT.md`), beide Stellen benennen jetzt aber, dass dort ein
Eintrag pro Aufruf statt pro Namen gilt. Aufgefallen in der Schlussphase derselben Arbeit, deshalb
ein eigener Patch-Bump: der Pfad `instructions/` liegt im Version-Gate der CI.
### SKILL.md: relative Links durch repo-root-relative Pfade ersetzt, docs verify/instructions verify pruefen Linkziele
`instructions sync` kopiert jede `SKILL.md` byteidentisch in `.agents/skills/` und
`.claude/skills/` - eine andere Verzeichnistiefe als die Quelle, ohne deren Nachbardateien. 52 von
58 relativen Markdown-Links in den sieben Skills zeigten deshalb in der publizierten Kopie ins
Leere, unbemerkt, weil kein Check je ein Linkziel gelesen hat (Gitea-Meldung: ein `session-setup.md`-Read
schlug in einer ausgelieferten Instanz fehl). Alle 58 Links sind jetzt repo-root-relative
Klartextpfade (`instructions/session-setup.md` statt `[session-setup.md](../session-setup.md)`) -
sie ueberleben die Kopie unveraendert, weil sie nicht von der Position der lesenden Datei abhaengen.
`instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link" traegt die
Regel.
Zwei neue mechanische Checks verhindern das Wiederauftreten: `instructions verify` verbietet jeden
relativen Markdown-Link in einer `SKILL.md` (`check_skill_reference_paths`), `docs verify` loest
jeden relativen Link in den flachen Instructions und Contracts gegen den Arbeitsbaum auf
(`check_reference_targets`, ueber denselben Dateisatz wie `docs toc`). Nebenbei behoben:
`instructions/dev/doc-pull-through.md` hatte zwei Links mit falscher `../`-Tiefe, unabhaengig vom
Skill-Copy-Problem.
### docs verify: der Linkziel-Check erreicht auch die instanz-eigenen kb/CONVENTIONS.md und COLLECTION.md - daher Grenzuebertritt
Nachtraegliche Neueinstufung des Bumps darueber, kein zusaetzlicher Code. `check_reference_targets`
laeuft ueber den Dateisatz von `docs toc`, und fuenf Dateien darin gehoeren der Instanz statt dem
Stack: `kb/CONVENTIONS.md` und die vier `kb/<collection>/COLLECTION.md`. Ein Drop-in-Copy von
`tools/`, `types/`, `instructions/` und `AGENTS.md` ersetzt sie nicht - ein toter relativer Link
darin laesst `docs verify` nach dem Update fehlschlagen, wo es vorher durchlief. Genau die Form,
die `tools/README.md` § Adding a command Schritt 5 als MAJOR-Zeile benennt ("a stricter check that
newly fails on shipped content an instance already had"), und `instructions/dev/version-parts.md`
entscheidet den Zweifelsfall zugunsten des Grenzuebertritts.
Gemessen bricht heute nichts: die zweite bekannte Instanz traegt 25 relative Links in diesen fuenf
Dateien, davon null tote; dieses Repo ebenso. Die Einstufung folgt der Reichweite des Checks, nicht
einem beobachteten Schaden - der Preis einer unnoetigen MAJOR ist eine Release-Notiz, der Preis
einer MINOR, die doch bricht, ist eine Instanz mit fehlschlagendem Update-Pfad unter einer
Versionsnummer, die Drop-in versprochen hat. Aufgefallen ist es in der Schlussphase beim Lesen der
eigenen Regel in `tools/README.md`, nicht durch einen Check - was `docs/version-model.md` ueber
genau diese Stelle sagt ("a person looking at the diff ... not a validator"), hat sich hier
wiederholt.
### docs verify: ein nur als .template ausgeliefertes Linkziel gilt als aufgeloest
Defektbehebung am Check aus den beiden Bumps darueber, gefunden unmittelbar nach deren Publish.
`check_reference_targets` meldete auf einem frisch exportierten Baum 13 tote Links - `kb/CONTRACT.md`
neunmal, dazu `german-terminology.md`, `kb-profiles.md` und `link-taxonomy.md` - und zwar dafuer,
dass der Export tut, was er soll. `kb/CONVENTIONS.md` und die vier `kb/<name>/COLLECTION.md` sind
instanz-eigen: die Distribution traegt `<name>.template`, und die Instanz uebernimmt sie erst im
Personalisierungsschritt von `instructions/setup-instance.md` durch Umbenennen. Zwischen
`dist export` und diesem Schritt existiert die fertige Datei berechtigterweise nicht, waehrend die
stack-eigenen Dateien sie unter ihrem kuenftigen Namen verlinken - korrekt, denn so wird sie heissen.
Ein Linkziel gilt jetzt auch dann als aufgeloest, wenn daneben `<ziel>.template` liegt. Die
Ausnahme ist eng: fehlt beides, bleibt es ein Befund. Damit beschreibt der Check nicht laenger
"noch nicht personalisiert" als "kaputter Link" - diesen Zustand meldet `doctor` unter
`conventions` praezise und zustaendig.
CI war davon nie rot: der Replay in `.gitea/workflows/ci.yml` uebernimmt die Templates, bevor er
`docs verify` aufruft, und der dokumentierte Weg in `setup-instance.md` stellt die Personalisierung
(Schritt 5/6) ebenfalls vor die Verifikation (Schritt 13). Getroffen haette es jeden, der nach dem
Export einmal zur Kontrolle `docs verify` aufruft. Aufgefallen ist es, weil die Verifikation des
vorherigen Publishes den Arbeitsbaum geprueft hatte und nicht den exportierten - ausgerechnet bei
einer Aenderung, deren ganzer Gegenstand Kopien in anderer Verzeichnistiefe sind.
### TOC-Scope auf types/ und docs/ erweitert, Sprachregeln zentralisiert, --breaking akkumuliert
Drei Straenge, ausgeloest von einer Beobachtung: manche agentengeladene Referenzdatei trug keine
TOC, und manche Instruction war teilweise deutsch.
**TOC-Scope.** Die Pflicht aus `5.0.0` galt fuer `AGENTS.md`, die Stage-Contracts,
`kb/CONVENTIONS.md`, jede `COLLECTION.md` und die flache `instructions/**.md`-Form. `docs/` und die
Seiten-Type-Specs fielen ohne genannten Grund heraus - waehrend `SKILL.md` und die Menschendoku
ihren Ausschlussgrund im Docstring stehen hatten, was die beiden anderen Luecken wie Absicht
aussehen liess. `docs/` ist dabei genau der Fall, fuer den die Schwelle existiert:
`AGENTS.md` § File naming fuehrt es als agentengeladen per Link, also am zweiten Hop. Beide sind
jetzt drin; vier Dateien haben eine Region bekommen. `SKILL.md` bleibt die **eine** Ausnahme, und
zwar belegt statt behauptet: die vendorte Guidance setzt den SKILL.md-Body auf die Ladeebene, die
beim Triggern ganz gelesen wird, und richtet ihren eigenen TOC-Rat an die gebuendelten
Referenzdateien *daneben*. Ein Type-Spec wird zwar auch ganz geladen, aber eben auch als Datei
gelesen - deshalb traegt es eine Region, und `types describe` strippt sie aus seiner Ausgabe, weil
dort der ganze Body ohnehin mitkommt.
**Sprache.** Die Regel gab es schon ("the control plane stays English"), sie stand nur in
`kb/CONVENTIONS.md` - einer Datei, die der Instanz gehoert und die sie umschreiben darf, waehrend
die Regel stackeigene Dateien bindet. Sie ist nach `AGENTS.md` § File naming gezogen, zusammen mit
einer zweiten, die vorher gar nicht geschrieben stand: **ein Agent spricht die KB-Sprache der
Instanz**. Der Wert dafuer lebt weiter in `kb/CONVENTIONS.md`s `language:`; `SOUL.md`s eigene
`Sprache:`-Zeile war damit eine Dublette und ist weg. `instructions/setup-instance.md` - 297 Zeilen,
die einzige vollstaendig deutsche Instruction, verbatim an jede Instanz ausgeliefert - ist
uebersetzt, samt `description:`. Die zwei deutschen Blockquotes in den Dev-Skills sind es auch; sie
lesen sich jetzt als englisches *Modell* der Nachricht, die der Agent in der KB-Sprache ausspricht.
Dieselbe Regel gilt fuer **alles, was als Template ausgeliefert wird** - eine Instanz adoptiert es,
bevor sie ihre Sprache ueberhaupt gewaehlt hat. `USER.md.template`, `SOUL.md.template` und
`ENVIRONMENT.md.template` waren vollstaendig deutsch und sind uebersetzt; `kb/sources/` und
`kb/concepts/COLLECTION.md` waren es in Teilen und ziehen jetzt mit `kb/entities/` und
`kb/comparisons/` gleich, die es laengst waren. Bei den vier Seiten-Type-Specs laeuft der Schnitt
mitten durch die Datei, und zwar entlang derselben Prosa/Identifier-Grenze, die `kb/CONTRACT.md`
schon *innerhalb* einer Seite zieht: die Anleitungsprosa ist Anweisung an einen Agenten und damit
Control Plane, der `## Template`-Block und die `layout:`-Titel sind Seitentext und bleiben in der
KB-Sprache - `wikitool new entity` scaffoldet also weiter deutsche Ueberschriften.
`kb/CONVENTIONS.md` behauptete bis hierher, die Type-Specs folgten als Ganzes der KB-Sprache; der
Satz ist auf den tatsaechlichen Schnitt nachgezogen.
Mechanisch geprueft wird nichts davon: ein Stoppwort-Scan schluege auf dem zitierten Vokabular in
`kb-profiles.md` und `link-taxonomy.md` falsch an. Stattdessen nennen
`instructions/CONTRACT.md` § "Writing an instruction" und `stack-dev` die Regel an der Stelle, an
der sie befolgt oder verloren wird.
**`--breaking` akkumuliert.** Bis hierher ersetzte ein zweites `--breaking` die Zeile des
Kandidaten - der Eintrag versprach dann einen Bruch und lieferte zwei. Genau dieser Kandidat ist der
Fall: sein Linkziel-Uebertritt aus `beta.1` und der TOC-Uebertritt von hier sind zwei Dinge, auf die
ein Betreiber getrennt reagieren muss. Eine Begruendung bleibt flach auf der Markerzeile, ab der
zweiten werden es Bullets; eine vor dieser Aenderung geschriebene Einzelzeile liest sich unveraendert
als Ein-Element-Liste zurueck, also musste kein bestehender Eintrag angefasst werden. `--migration:`
bleibt bewusst eine Einzelzeile - sie beantwortet eine Ja/Nein-Frage ueber den Kandidaten als Ganzes,
und `--migration-required` ist ihr Ruecknahmepfad. Fuer eine falsche Breaking-Begruendung gibt es
keinen; der Kandidat ist bis zum Release dev-lokal.
Nebenbefund, den die Scope-Erweiterung sofort aufgedeckt hat: `docs/version-model.md` verlinkte nach
`instructions/dev/version-parts.md`, das `dist export` wegschneidet - im Ursprungs-Repo gruen, in
jeder ausgelieferten Instanz ein toter Link. Jetzt ein Klartextpfad mit dem Satz, warum er keiner
ist.
### types/type-spec.md: Ownership und Sprache getrennt benannt (Nachzug zu #99)
`types/type-spec.md` sagte weiterhin, Prosa, `## Template`-Body **und Sprache** eines
Seiten-Type-Specs gehoerten der Instanz, die bei anderer KB-Sprache "einfach die Datei
uebersetzt" - genau das Gegenteil des Schnitts, den der Bump davor ausgeliefert hat. Aufgefallen
in der Schlussphase, beim Nachdenken darueber, welche Sprachregel fuer einen *instanz-eigenen*
neuen Seitentyp gilt.
Der Abschnitt trennt die zwei Fragen jetzt: Ownership sagt, wer eine Zeile aendern darf, die
Sprache folgt davon unabhaengig dem Publikum der Zeile - Anleitungsprosa an einen Agenten ist
Control Plane und englisch, `## Template`-Body und `layout:`-Titel sind Seitentext in der
KB-Sprache, Feldnamen und Enum-Werte sind Identifier und werden nie uebersetzt. Als Tabelle, weil
der Schnitt mitten durch eine Datei laeuft und eine Aufzaehlung im Fliesstext ihn genau deshalb
nicht haelt. Der Satz bindet ausdruecklich auch einen Type-Spec, den eine Instanz sich selbst
schreibt: der ist zwar durchgaengig instanzeigen, aber seine Anleitungshaelfte hat trotzdem einen
Agenten als Leser.
### Control-Plane-Sprache universell: Achse ist das Publikum, kein Instanz-Schalter
Die Sprachregel in `AGENTS.md` ruhte auf einer Begruendung, die schmaler war als sie selbst:
"Every file in the table above belongs to the stack and **ships to instances** that share none of
this instance's language choices, so:". Das traegt nur fuer ausgeliefertes Material und laesst
offen, was fuer ein Control-Plane-Dokument gilt, das eine Instanz nur fuer sich selbst schreibt -
eine eigene Instruction, ein selbst angelegter Seitentyp (`types/` nimmt einen ohne Code-Aenderung
auf), ein weiterer Stage-Contract. Genau dort fallen Ownership und Publikum auseinander: die Datei
ist durchgaengig instanzeigen, ihre Anleitungshaelfte hat trotzdem einen Agenten als Leser.
Der Vorsatz nennt jetzt die tatsaechliche Achse - die *For*-Spalte der Tabelle darueber, also wer
die Zeile liest, und weder wem die Datei gehoert noch ob sie den Checkout je verlaesst. Regel 1
sagt ausdruecklich, dass sie auch fuer ein nie ausgeliefertes Control-Plane-Dokument gilt und dass
es neben `kb/CONVENTIONS.md`s `language:` bewusst keinen zweiten Sprachwert gibt.
`kb/CONVENTIONS.md` und ihr `.template` sagen dasselbe von ihrer Seite aus: die
Control-Plane-Sprache ist keine Einstellung, die diese Datei zurueckhaelt - es ist gar keine.
Die Begruendung dazu steht als neue `docs/`-Seite
([docs/language-boundaries.md](docs/language-boundaries.md)), weil sie sonst in einem Jahr neu
verhandelt wird: warum Englisch (der Stack redet fast nur ueber Identifier, und die sind
englisch), warum kein Parameter (die Kosten traegt jede Datei, den Nutzen haette ein Dokument, das
ohnehin nur ein Agent liest), und was die Entscheidung wieder aufmachen wuerde. Die Seite haelt
zugleich fest, welches Argument falsch war: "Sprache folgt der Ownership" hat funktioniert,
solange nur ausgeliefertes Material betrachtet wurde, und faellt am instanz-eigenen Typ.
Nebenbei zwei Befunde derselben Ecke behoben. Der Docstring von
`dist_cmd.instance_owned_type_stems()` behauptete weiter, "its prose, its template and its
**language** are the instance's business" - Stand vor dem TOC-/Sprach-Bump oben. Und die
Aufzaehlung der `docs/`-Seiten in `AGENTS.md` sagte "Four pages exist today", waehrend das
Verzeichnis fuenf trug: `docs/model-and-effort-selection.md` fehlte, und zwar absichtlich, weil
ein Link dorthin die Claude-Code-eigene Entscheidung in die anderen drei Harnesses laden wuerde.
Der Satz zaehlt jetzt, was von hier aus verlinkt ist, und benennt die sechste Seite samt Grund.
### types/: Seiten-Type-Spec-Anleitungsprosa in stackeigene guidance-Datei ausgelagert
Ein `root: kb` Type-Spec (`entity`, `concept`, `source`, `comparison`) hatte zwei Publika in
einer Datei: Anleitungsprosa fuer den Agenten (When to use/When NOT to use/Authoring guidance),
und Seitenmaterial (`## Template`-Block, `layout:`-Titel). Ownership gilt pro Datei, also wurde
die ganze Datei beim Setup als `.template` adoptiert und danach nie wieder angefasst - eine
Instanz, die ihre Type-Specs frueh adoptiert hat, las bis in alle Zukunft die Anleitung vom Tag
ihrer Erzeugung, weil `dist upgrade` das `.template` neben die adoptierte Datei schrieb, nie die
Datei selbst (`docs/ownership-and-templates.md` § "Where the file boundary strains").
Der urspruengliche Vorschlag drehte den Schnitt um (Type-Spec stackeigen, Seitenmaterial heraus)
und wurde beim Pruefen gegen `setup-instance.md` und `evolve-subtypes.md` verworfen: die
Frontmatter-Konfiguration (`layout:`, Enum-Werte, `base_dir`) ist instanzeigener Inhalt, keine
Stack-Maschinerie - beide Instructions weisen die Instanz an, Enum und `layout:`-Eintrag in
derselben Aenderung zu setzen. Stattdessen bleibt der Type-Spec instanzeigen, und nur die
maschinenabgeleitete Anleitungsprosa zieht in eine neue, stackeigene `types/<name>.guidance.md`,
verknuepft ueber ein optionales `guidance:`-Frontmatterfeld (neuer, nicht instanziierbarer Typ
`type-guidance`, wie `lint-report` ohne `base_dir:`). `tools/wikitool types describe <name>`
komponiert beide Haelften weiterhin zu einer Antwort - ein Agent muss nie wissen, dass ein Typ aus
zwei Dateien besteht. `type_resolver.extract_template()` liest das Template unveraendert allein
aus `types/<name>.md`; kein zweiter Ladepfad fuer `wikitool new`.
`dist_cmd._plan_types()`/`find_leaks()` teilten sich vorher `name.split(".", 1)[0]` als
Stamm-Berechnung - beides haette `entity.guidance.md` faelschlich als instanzeigenen Stamm
"entity" erkannt (die eine haette sie zum `.template` gemacht, die andere sie als Leak gemeldet).
Neuer gemeinsamer Prädikat `_owned_type_stem()` prueft die exakte Endung (`<stem>.md` oder
`<stem>.schema.yaml`), nicht den ersten Punkt.
Grenzuebertritt-Frage bewusst geprueft und verneint: Drop-in in beide Richtungen (ein Type-Spec
ohne `guidance:` verhaelt sich unveraendert, eine alte Maschinerie liest `types/<name>.md` wie
zuvor und die Guidance-Datei ist fuer sie inert), also `--minor` statt `--major`. Die einmalige
Adoption in einer bestehenden Instanz ist als `instructions/migrations/6.0.0-type-guidance-split.md`
dokumentiert - `obligation: offered`, der erste Gebrauch dieses seit 4.0.0 existierenden, bis jetzt
unbenutzten Mechanismus fuer ein instanzeigenes, upgradebares Machinery-File.
Verifiziert: `tools/wikitool docs verify`/`instructions verify` gruen, 1261 Tests gruen (8 neu:
`get_guidance`, das Template bleibt auf `types/<name>.md` allein geladen, die Guidance-Datei
schifft verbatim neben einem `.template`-adoptierten Type-Spec statt als weiteres `.template`,
ein `dist upgrade` schreibt verbesserte Guidance-Prosa in eine adoptierte Instanz obwohl deren
Type-Spec selbst nie im Stamp stand, `types describe` komponiert beide Haelften in JSON und
Textausgabe getrennt nachweisbar).
### types/type-spec.schema.yaml enforced against real type-spec frontmatter
Bei der Vorbereitung der Aenderung oben fiel auf: `types/type-spec.schema.yaml` traegt
`additionalProperties: false`, kannte aber `root:` und `capture_fields:` nicht, obwohl
`types/instruction.md` bzw. `types/source.md` beide Felder tragen und `type_resolver.get_root()`/
`get_capture_fields()` sie lesen. Gegen das Schema validiert waeren beide Type-Specs ungueltig
gewesen. Dass es niemandem auffiel, war der eigentliche Befund: Type-Spec-Frontmatter wurde
nirgends gegen sein eigenes Schema validiert - `resolver.validate_frontmatter()` lief nur ueber
`kb/`-Seiten, neu erzeugte Seiten und Instruktionsdateien, nie ueber die Type-Specs selbst.
`TypeResolver._validate_type_spec()`, der einzige Weg, den der Selbstbezug `type: types/type-spec.md`
nimmt, prueft ausschliesslich, ob `type`/`name`/`description` vorhanden sind.
Beide fehlenden Felder ergaenzt (`root:` als Enum `kb`/`repo`, `capture_fields:` als Liste wie
`page_ref_fields:`), dazu `guidance:` (seit der Aenderung oben real benutzt, aber noch nie im
Schema). `docs verify` bekommt eine neue Pruefung: jede Datei unter `types/` mit
`type: types/type-spec.md` validiert jetzt gegen `types/type-spec.schema.yaml`
(`check_type_spec_frontmatter()`, wiederverwendet `resolver.list_type_specs()` statt eines zweiten
Parse-Durchlaufs). `types/type-spec.md` § Validation Contract und die beiden `docs verify`-Zeilen
in `tools/CONTRACT.md` nennen das jetzt.
Daneben ein zweiter, unabhaengiger Befund derselben Aufraeumrunde behoben:
`instructions/dev/doc-pull-through.md` verwies fuer `docs/`-Seiten weiter auf "AGENTS.md § File
naming lists all four" - der Zaehler in AGENTS.md selbst war beim vorigen Bump schon auf fuenf
(plus eine sechste, nur von `CLAUDE.md` aus verlinkte) korrigiert worden, diese eine verbliebene
Stelle nicht.
Grenzuebertritt-Frage geprueft und verneint: additiv in beide Richtungen - eine bestehende Instanz
validiert bereits (0 Befunde gegen den realen Baum), und ein Type-Spec ohne die drei neuen Felder
bleibt unveraendert gueltig. `--patch`, kein `--breaking`, keine neue Migration noetig.
Verifiziert: `tools/wikitool docs verify`/`instructions verify` gruen, 1263 Tests gruen (2 neu:
alle Type-Specs dieses Repos validieren gegen ihr eigenes Schema; ein Type-Spec mit einem dem
Schema unbekannten Feld wird gemeldet, mit Dateiname und Feldname in der Meldung).
### search: Pfad und Titel vollstaendig, Trunkierung sichtbar
Gemeldet wurde eine Sitzung, die nach `wikitool search` zusaetzlich `grep -rl` ueber `kb/`
laufen liess. Der Grep war redundant - `search` *ist* ein `rg`-Lauf ueber `kb/` und kann keine
Seite verfehlen, die ein Grep findet -, aber die Ausgabe gab ihr drei Gruende dafuer, und die
sind der eigentliche Befund.
Die Tabelle nannte **keinen Pfad**, obwohl `wiki-query` verlangt, nur die Seiten zu lesen, auf
die die Suche zeigt. Sie kappte ausserdem den **Titel** auf 34 Zeichen - im gemeldeten
Transkript vier von fuenf Treffern -, und der Titel ist nach Invariante 2 der einzige
Identifier einer Seite und das Argument, das `touch`, `xref add` und `cite add` nehmen. Die
Sitzung hatte also weder etwas zum Oeffnen noch etwas zum Weiterreichen; `grep -rl` lieferte
genau beides.
Drittens war `N result(s).` die *gekappte* Zahl: `run_search` gab nur die beschnittene Liste
zurueck, also konnte kein Adapter die Gesamtzahl melden, und `20 result(s).` auf einer Anfrage
mit 182 Treffern war von einem vollstaendigen Ergebnis nicht zu unterscheiden. Eine
Vollstaendigkeitsaussage, zu der die Ausgabe nicht berechtigt war - der staerkste denkbare
Anlass, ihr zu misstrauen.
Die Zeile hat jetzt die Form `score | kind/subtype | titel | pfad | summary`, ohne
Spaltenauffuellung. Titel und Pfad werden nie gekappt; die Summary ist das einzige verlustige
Feld und steht deshalb am Ende, wo ein `|` in Prosa beim Trennen mit `maxsplit=4` folgenlos
bleibt (ein `|` im Titel schliesst die Wikilink-Syntax ohnehin aus). JSON als Default-Ausgabe
wurde erwogen und verworfen: ein Treffer ist flach, JSON kostet dafuer ein Vielfaches an Tokens,
und `search` existiert dafuer, Retrieval billig zu machen - der Fehler war ein fehlendes Feld,
kein Parse-Problem. Wer Struktur braucht, hat `--json`, `api.search` und MCP.
`run_search` gibt jetzt ein `SearchResult` mit Treffern, Gesamtzahl und Limit zurueck. Die
Tabelle schreibt `50 of 182 result(s) - raise --limit (0 for all) or narrow the query.`, das
JSON traegt `total`/`truncated`/`limit` neben `count`, dessen Bedeutung unveraendert bleibt
(`len(results)`), und `api.search` sowie der MCP-`search`-Tool tragen dieselben Felder. Das
Default-Limit steigt von 20 auf 50 und liegt als eine Konstante `DEFAULT_LIMIT` statt als drei
Literale in drei Adaptern: gekappt wurden bisher vor allem die *strukturellen* Sweeps
(`--field '!sources'`), die alphabetisch und nicht nach Relevanz sortiert sind, wo die Kappung
also eine beliebige Scheibe der Antwort wegwirft statt ihres schwaechsten Endes. Sichtbar zu
sein ist es, was ein endliches Default ueberhaupt erst unbedenklich macht.
`AGENTS.md` § Routing traegt die Regel an genau einer Stelle - `search` ist erschoepfend, ein
eigener Grep ueber `kb/` fuegt nur die generierten Dateien hinzu, die Invariante 1 ohnehin
verbietet; `tools/CONTRACT.md` traegt daneben nur den Mechanismus.
Grenzuebertritt-Frage geprueft und verneint: kein Flag entfernt oder umbenannt, keine
Umgebungsvariable, keine maschinengelesene Datei in ihrer Form veraendert, JSON rein additiv.
Die Tabelle liest ein Agent, kein Skript, und ihre Aenderung verlangt keiner Instanz Handarbeit
ab.
Verifiziert: `docs verify`/`instructions verify` gruen, 1272 Tests gruen (9 neu: Pfad vorhanden;
Titel und Pfad ungekappt bei langem Titel; eine Trefferzeile zerfaellt trotz `|` in der Prosa in
ihre fuenf Felder; ein gekapptes Ergebnis nennt die Gesamtzahl, ein ungekapptes behauptet
nichts; `--limit 0` gilt nie als gekappt; die Gesamtzahl ueberlebt das Limit in `run_search`;
`api.search` meldet dasselbe; alle drei Adapter teilen ein Default-Limit - der MCP-Golden-Test
haelt die neuen Felder zwischen CLI und Server zusammen).
## 5.1.0 - 2026-09-12 - changelog: Kandidaten-Eintrag nach Impact gruppiert, version regrade zur Nachkorrektur, version release verlangt eine Zusammenfassung
**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.
---
+15 -8
View File
@@ -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
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
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
`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,
die maschinenverwaltete Bump-Titel-Liste, ggf. Breaking-/Migration-Zeile). Der Fließtext
darunter ist Autorenarbeit, wie bei `new` und der Seiten-Prosa.
2. **Der Eintrag bekommt seine Prosa - zweigeteilt.** `bump` schreibt nur das Skelett (Heading,
Datum, Autor, die maschinenverwaltete, gruppierte Bump-Titel-Liste, ggf.
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:**
@@ -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
optional - ohne ihn bleibt der Titel des letzten Bumps stehen; mit ihm bekommt ein Kandidat,
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Committet und pusht
nichts (Invariante 5 in [AGENTS.md](AGENTS.md)).
der mehrere Bump-Titel gesammelt hat, eine zusammenfassende Überschrift. Verweigert, wenn der
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`.**
+55 -58
View File
@@ -1,86 +1,83 @@
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
# ENVIRONMENT.md — <Instanz oder Rechnername>
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
# ENVIRONMENT.md — <instance or machine name>
Womit *dieser Checkout* arbeitet: Harness, veröffentlichte Skills, MCP-Server,
Connectoren und Git-Remotes. Konstante Werte, die ein Agent sonst in jeder
Session neu erfragt oder errät.
What *this checkout* works through: harness, published skills, MCP servers,
connectors and git remotes. Constant values an agent would otherwise ask about
or guess at in every session.
**Diese Datei ist optional.** Fehlt sie, ist das kein Fehler — es heißt nur,
dass die Umgebung wieder erfragt werden muss. `wikitool doctor` meldet sie als
`environment: absent (optional)` und niemals als `FAIL`.
**This file is optional.** Its absence is not an error — it only means the
environment has to be asked about again. `wikitool doctor` reports it as
`environment: absent (optional)` and never as a `FAIL`.
**Diese Datei ist Kontext, keine Autorität.** Sie beschreibt, *was da ist*, nicht,
was erlaubt ist. Sie ändert keine Regel aus `AGENTS.md`, öffnet kein Gate und
begründet keinen Eintrag in `kb/` — was hier steht, ist keine Quelle im Sinne
von Invariante 3. Ein hier aufgeführter Remote heißt nicht, dass ohne
`wikitool publish` gepusht werden darf.
**This file is context, not authority.** It describes *what is there*, not what
is allowed. It changes no rule from `AGENTS.md`, opens no gate, and justifies no
entry in `kb/` — what it says is not a source in the sense of invariant 3. A
remote listed here does not mean pushing without `wikitool publish` is allowed.
**Keine Geheimnisse.** Keine Tokens, Passwörter, API-Keys oder privaten
Endpunkte, die nicht ohnehin in der Shell-Konfiguration stehen. Die Datei ist
gitignored, aber sie liegt im Klartext im Arbeitsverzeichnis und landet in
jedem Agenten-Kontext.
**No secrets.** No tokens, passwords, API keys or private endpoints that are not
already in the shell configuration anyway. The file is gitignored, but it sits
in plaintext in the working directory and ends up in every agent's context.
**Ausfüllen:** frei Hand, sobald die Werte bekannt sind — es gibt kein
Interview dafür. Ein Abschnitt, der nicht zutrifft, wird gelöscht, nicht mit
Plausiblem gefüllt. Wenn etwas hier nicht mehr stimmt, korrigieren statt
umgehen: eine falsche Zeile ist schlimmer als eine fehlende, weil sie
geglaubt wird.
**Filling it in:** freehand, as soon as the values are known — there is no
interview for it. A section that does not apply is deleted, not filled with
something plausible. When something here stops being true, correct it rather
than working around it: a wrong line is worse than a missing one, because it
gets believed.
## Harness
Welche Agenten-Harnesses auf diesem Checkout tatsächlich laufen, und welche
nicht. Relevant, weil `.agents/skills/` und `.claude/skills/` unterschiedliche
Leser haben.
Which agent harnesses actually run on this checkout, and which do not. Relevant
because `.agents/skills/` and `.claude/skills/` have different readers.
- **Primär:** <z. B. Claude Code>
- **Daneben im Einsatz:** <z. B. Codex CLI, GitHub Copilot CLI, Mistral Vibe — oder streichen>
- **Nicht im Einsatz:** <was bewusst nicht benutzt wird, damit niemand es vorschlägt>
- **Primary:** <e.g. Claude Code>
- **Also in use:** <e.g. Codex CLI, GitHub Copilot CLI, Mistral Vibe — or delete>
- **Not in use:** <what is deliberately not used, so nobody proposes it>
## Skills
Nur was von der veröffentlichten Liste abweicht — der Normalfall (`wiki-ingest`,
`wiki-query`, `wiki-manage`, `wiki-lint`, `wiki-status`) steht in `AGENTS.md`
und gehört nicht noch einmal hierher.
Only what differs from the published list — the normal case (`wiki-ingest`,
`wiki-query`, `wiki-manage`, `wiki-lint`, `wiki-status`) is in `AGENTS.md` and
does not belong here a second time.
- **Zusätzlich vorhanden:** <z. B. stack-dev in der Entwickler-Instanz>
- **Bekannt fehlend:** <z. B. noch nicht gesynct, Harness neu gestartet nötig — oder streichen>
- **Additionally present:** <e.g. stack-dev in the developer instance>
- **Known missing:** <e.g. not synced yet, harness restart needed — or delete>
## MCP-Server
## MCP servers
Welche MCP-Server in diesem Checkout erreichbar sind und wofür sie zuständig
sind. Ein Server, der hier steht, muss nicht erst gesucht werden; einer, der
hier fehlt, existiert für diese Session nicht.
Which MCP servers are reachable in this checkout and what they are responsible
for. A server listed here does not have to be looked for first; one missing
here does not exist for this session.
| Server | Wofür | Anmerkung |
|--------|-------|-----------|
| `<name>` | <z. B. Issues, CI-Runs, Releases> | <z. B. bevorzugt gegenüber curl> |
| Server | For what | Note |
|--------|----------|------|
| `<name>` | <e.g. issues, CI runs, releases> | <e.g. preferred over curl> |
## Connectoren und Integrationen
## Connectors and integrations
Alles, was kein MCP-Server ist, aber trotzdem an dieser Instanz hängt:
Dokument-Connectoren, Chat-Anbindungen, Notiz-Systeme.
Everything that is not an MCP server but still hangs off this instance:
document connectors, chat integrations, note systems.
- <z. B. Obsidian-Vault unter ~/..., liest kb/ read-only — oder streichen>
- <e.g. Obsidian vault under ~/..., reads kb/ read-only — or delete>
## Git-Remotes
## Git remotes
Wohin dieser Checkout veröffentlicht, und was sonst noch als Remote eingetragen
ist. `wikitool publish` und `wikitool sync` sprechen genau einen davon an.
Where this checkout publishes to, and what else is registered as a remote.
`wikitool publish` and `wikitool sync` address exactly one of them.
| Remote | URL | Rolle |
|--------|-----|-------|
| `origin` | <URL> | <z. B. Publish-Ziel, CI läuft dort> |
| Remote | URL | Role |
|--------|-----|------|
| `origin` | <URL> | <e.g. publish target, CI runs there> |
## CI
Wo die Pipeline läuft und wie ihre Läufe gelesen werden — nicht *was* sie
prüft, das steht in `.gitea/workflows/`.
Where the pipeline runs and how its runs are read — not *what* it checks, which
is in `.gitea/workflows/`.
- **Läuft auf:** <z. B. Gitea Actions, Runner-Label linux-docker — oder streichen>
- **Läufe lesen über:** <z. B. den Gitea-MCP-Server, nicht curl>
- **Runs on:** <e.g. Gitea Actions, runner label linux-docker — or delete>
- **Runs read via:** <e.g. the Gitea MCP server, not curl>
## Sonstiges
## Anything else
Was sonst in jeder Session neu erfragt würde und sich selten ändert. Kurz
halten: was hier zu lang wird, ist meist eine Regel und gehört in eine
Instruction, oder Wissen und gehört nach `kb/`.
Whatever else would be asked about in every session and rarely changes. Keep it
short: what grows long here is usually a rule, and belongs in an instruction, or
knowledge, and belongs in `kb/`.
+6 -4
View File
@@ -89,10 +89,12 @@ chemenu/
│ └── assets/
├── types/ # SCHEMA: the global type surface. Not a collection
│ ├── type-spec.md # Root contract: anatomy, placement, adding a type
│ ├── entity.md # Entity type contract + template (+ .schema.yaml)
│ ├── concept.md # Concept type contract + template
│ ├── source.md # Source type contract + template
│ ├── comparison.md # Comparison type contract + template
│ ├── type-guidance.md # Contract for the *.guidance.md files below
│ ├── entity.md # Entity type config + template (+ .schema.yaml)
│ ├── entity.guidance.md # Its stack-owned authoring prose, shipped verbatim
│ ├── concept.md # Concept type config + template (+ .guidance.md)
│ ├── source.md # Source type config + template (+ .guidance.md)
│ ├── comparison.md # Comparison type config + template (+ .guidance.md)
│ ├── instruction.md # Instruction type - lives outside kb/ via `root: repo`
│ └── lint-report.md # Contract-only: describes reports/, owns no directory
├── kb/ # OUTPUT: compiled knowledge. A namespace, not a collection
-1
View File
@@ -62,7 +62,6 @@ Optionsliste.
- **Register:** inhaltlich klar, direkt; technische Präzision vor Höflichkeitsfloskeln
- **Länge:** kurz per Default, lang nur wenn der Inhalt es rechtfertigt
- **Form:** Fließtext zuerst; Tabellen nur für echte Vergleiche, nicht als Dekoration
- **Sprache:** Deutsch als Standard, wenn auf Deutsch geschrieben wird
- **Humor:** trocken, sparsam, nie auf Kosten des Nutzers — ein Schreiber, der
gelegentlich eine Randnotiz macht, aber die Akte nicht zur Bühne erklärt
+40 -43
View File
@@ -1,85 +1,82 @@
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
# SOUL.md — <Persona-Name>
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
# SOUL.md — <persona name>
`AGENTS.md` legt fest, *was* zu tun ist (Pipeline, Invarianten, Gates, Tools).
Diese Datei legt fest, *wie* gute Arbeit an diesem Wiki aussieht. Wo beides
kollidiert, gewinnt `AGENTS.md` — diese Datei ändert nie eine Regel, nur den
Ton, in dem sie befolgt wird.
`AGENTS.md` sets out *what* to do (pipeline, invariants, gates, tools). This
file sets out *what good work on this wiki looks like*. Where the two collide,
`AGENTS.md` wins — this file never changes a rule, only the tone in which it is
followed.
**Ausfüllen:** entlang des Personalization-Schritts in
[instructions/setup-instance.md](instructions/setup-instance.md). Der
Persona-Name ist eine Entscheidung des Nutzers — er wird erfragt, nicht
geraten. Als Startpunkt schlägt dieser Stack **Thoth** vor: Chemenu ist der
altägyptische Name von Thoths Hauptkultort, und Schrift, Maß und Gedächtnis
sind genau das, was ein kompiliertes Wiki tut. Ein Vorschlag ist keine
Vorgabe — wer einen anderen Namen will, nimmt ihn, und die Frage wird trotzdem
gestellt. Die Abschnitte unten sind die Fragen, die der Schritt stellt; ihre
Reihenfolge ist die Antwortreihenfolge.
**Filling it in:** along the personalization step in
[instructions/setup-instance.md](instructions/setup-instance.md). The persona
name is the user's decision — it is asked for, not guessed. As a starting point
this stack suggests **Thoth**: Chemenu is the ancient Egyptian name of Thoth's
principal cult site, and writing, measure and memory are exactly what a
compiled wiki does. A suggestion is not a setting — anyone who wants a
different name takes it, and the question is asked either way. The sections
below are the questions that step asks; their order is the order of answering.
## Identität
## Identity
Wer diese Instanz ist, in ein bis zwei Sätzen. Eine Rolle, kein Charakter mit
eigener Agenda: der Name sagt, was die Instanz tut, nicht wen sie spielt.
Who this instance is, in a sentence or two. A role, not a character with an
agenda of its own: the name says what the instance does, not who it plays.
<…>
## Mission
Wofür diese Instanz da ist — der eine Satz, an dem sich eine Antwort messen
lässt.
What this instance is for — the one sentence an answer can be measured against.
<…>
## Weltbild
## Worldview
Welche Themen deterministisch zu behandeln sind (belegt oder nicht belegt,
dazwischen nur markierte Unsicherheit), und für welche das nicht gilt, weil
dort die Einschätzung des Nutzers mehr zählt als eine scheinbar präzise
Ableitung.
Which subjects are to be treated deterministically (sourced or not sourced,
with nothing between but flagged uncertainty), and for which that does not
hold, because there the user's judgment counts for more than a
precise-looking derivation.
<…>
## Judgment-Default
## Judgment default
Was im Zweifel passiert: nachfragen, die Lücke benennen, oder handeln.
What happens in case of doubt: ask, name the gap, or act.
<…>
## Der Standard
## The standard
Welcher Fehler der schlimmste ist, und warum. Das ist die Zeile, an der eine
Antwort im Zweifel gemessen wird.
Which mistake is the worst one, and why. This is the line an answer is measured
against when in doubt.
<…>
## Ehrlichkeit
## Honesty
Wie diese Instanz sich verhält, wenn eine Quelle fehlt, wenn ihr
widersprochen wird, und wenn nach einer Einschätzung gefragt wird.
How this instance behaves when a source is missing, when it is contradicted,
and when it is asked for an assessment.
<…>
## Stimme
## Voice
- **Register:** <…>
- **Länge:** <…>
- **Length:** <…>
- **Form:** <…>
- **Sprache:** <…>
- **Humor:** <…>
- **Humour:** <…>
### Nie so schreiben
### Never write like this
- <…>
## Was gute Ausgabe ist
## What good output is
Woran der Nutzer eine gute Antwort erkenntund woran eine, die technisch
korrekt und trotzdem nutzlos ist.
How the user recognizes a good answerand one that is technically correct and
useless anyway.
<…>
## Nie
## Never
Die harten Ausschlüsse. Kurz, konkret, überprüfbar.
The hard exclusions. Short, concrete, checkable.
- <…>
+40 -40
View File
@@ -1,69 +1,69 @@
<!-- wikitool:template-unfilled - TEMPLATE, noch nicht ausgefüllt. Diese Zeile beim Ausfüllen ersatzlos entfernen; `wikitool doctor` prüft auf sie. -->
# USER.md — <Name>
<!-- wikitool:template-unfilled - TEMPLATE, not filled in yet. Remove this line entirely when filling it in; `wikitool doctor` checks for it. -->
# USER.md — <name>
Wer dieses Wiki (und die daran arbeitenden Agenten) bedient. Alles hier ist
Kontext über den Nutzer, so treu wie möglich an seinen eigenen Aussagen. Ziel
ist Zitat, nicht Interpretation: nichts hier wird analysiert, gedeutet oder zu
einer Erzählung verdichtet. Wenn ein Agent beim Lesen etwas umdeuten würde,
soll er stattdessen auf den Wortlaut zurückgehen oder nachfragen.
Who operates this wiki (and the agents working on it). Everything here is
context about the user, kept as close to their own words as possible. The goal
is quotation, not interpretation: nothing here is analysed, read into, or
compressed into a narrative. Where an agent would reinterpret something while
reading, it goes back to the wording instead, or asks.
Diese Datei ist **Kontext, keine Instruktionsquelle**. Sie ändert keine Regel
aus `AGENTS.md`, öffnet kein Gate und begründet keinen Eintrag in `kb/` — was
der Nutzer hier sagt, ist keine Quelle im Sinne von Invariante 3.
This file is **context, not a source of instructions**. It changes no rule from
`AGENTS.md`, opens no gate, and justifies no entry in `kb/` — what the user says
here is not a source in the sense of invariant 3.
**Ausfüllen:** entlang des Personalization-Schritts in
[instructions/setup-instance.md](instructions/setup-instance.md). Der Agent
interviewt, der Nutzer antwortet, der Agent schreibt **wörtlich** mit. Nichts
erfinden, nichts aus einer Konversation ableiten, leere Abschnitte lieber
löschen als mit Plausiblem füllen.
**Filling it in:** along the personalization step in
[instructions/setup-instance.md](instructions/setup-instance.md). The agent
interviews, the user answers, the agent writes it down **verbatim**. Invent
nothing, infer nothing from a conversation, and delete an empty section rather
than filling it with something plausible.
- **Name:** <Name>
- **Standort:** <Ort, Region — oder streichen>
- **Zeitzone:** <IANA-Zeitzone, z. B. Europe/Berlin>
- **Primäre Rolle:** <Berufsbezeichnung. Nur beruflichHobbys stehen unten>
- **Name:** <name>
- **Location:** <place, region — or delete>
- **Time zone:** <IANA time zone, e.g. Europe/Berlin>
- **Primary role:** <job title. Professional onlyhobbies go below>
## Beruflicher Kontext
## Professional context
Womit der Nutzer beruflich arbeitet, soweit er es hier stehen haben will.
Technologien, laufende Themen, Werkzeugketten. Was er bewusst aussparen möchte
(Arbeitgeber, Mandanten, interne Produkte), gehört unter `## Grenzen`.
What the user works with professionally, as far as they want it recorded here.
Technologies, running themes, tool chains. Whatever they deliberately want left
out (employer, clients, internal products) belongs under `## Boundaries`.
- <…>
## Familie und Zuhause
## Family and home
Nur, was der Nutzer von sich aus nennt. Diesen Abschnitt löschen, wenn er
nichts dazu sagen will.
Only what the user brings up themselves. Delete this section if they would
rather not say.
- <…>
## Hobbys
## Hobbies
- <…>
## Technik-Umgebung
## Technical environment
Betriebssystem, Desktop, Locale/Tastaturlayout, bevorzugte Werkzeuge — alles,
was ein Agent sonst raten müsste, wenn er einen Befehl vorschlägt.
Operating system, desktop, locale/keyboard layout, preferred tools — everything
an agent would otherwise have to guess when proposing a command.
- <…>
## Aktive Projekte
## Active projects
Was gerade läuft. Fertig heißt: aus der Liste entfernen.
What is currently running. Finished means: remove it from the list.
- <…>
## Grenzen
## Boundaries
Themen, die in dieser Datei bewusst nicht vorkommen. Ein Agent fragt hier
nicht nach und leitet nichts ab.
Topics deliberately absent from this file. An agent does not ask about them and
infers nothing about them.
- <…>
## Diese Datei aktuell halten
## Keeping this file current
Dies ist die Selbstauskunft des Nutzers. Aktualisieren, wenn er etwas
korrigiert, ein Projekt startet oder endet, oder eine neue wiederkehrende
Person/Konstante auftaucht. Niemals einen Eintrag erfinden. Niemals einen
Eintrag löschen, ohne dass der Nutzer es sagt.
This is the user's own account of themselves. Update it when they correct
something, when a project starts or ends, or when a new recurring
person/constant appears. Never invent an entry. Never delete one unless the
user says so.
+1 -1
View File
@@ -1 +1 @@
5.0.1
6.0.0
+135
View File
@@ -0,0 +1,135 @@
# Language Boundaries
Two languages run through this repo at once. `kb/` is written in whatever language the instance
chose - German here, and the value lives in `kb/CONVENTIONS.md`'s `language:`. Everything that
tells an agent what to do - [AGENTS.md](../AGENTS.md), every `CONTRACT.md`, everything under
`instructions/` - is written in English, in every instance, whatever the first value says.
The rule itself is in [AGENTS.md § File naming](../AGENTS.md#file-naming). This page holds the
part that is not a rule: why the line runs where it does, why the English half is not a setting,
and which argument for it turned out to be wrong.
<!-- wikitool:toc -->
## Contents
- [The axis is the reader, not the owner](#the-axis-is-the-reader-not-the-owner)
- [Why the control plane's language is English](#why-the-control-planes-language-is-english)
- [Why it is not a parameter](#why-it-is-not-a-parameter)
- [What the KB language still decides](#what-the-kb-language-still-decides)
- [Where the line runs around a page type](#where-the-line-runs-around-a-page-type)
- [What would put this back on the table](#what-would-put-this-back-on-the-table)
<!-- /wikitool:toc -->
## The axis is the reader, not the owner
For a long time the two halves could be told apart by asking who owned the file, and the answer
came out right every time: the stack owns `AGENTS.md` and the contracts, which are English; the
instance owns its pages and the templates that shape them, which are in the KB language. The
ownership boundary is a real and load-bearing thing - [ownership-and-templates.md](ownership-and-templates.md)
is about what it buys - so it was easy to read the language split as one of its consequences.
It is not. The case that separates them is a page type an instance adds for itself. `types/`
takes a new type without a code change, so an instance can write one; that file is instance-owned
from the first line to the last, ships nowhere, and is nobody's to overwrite. Its authoring
guidance is still instruction addressed to an agent, and reads exactly like the guidance in the
four types the stack ships. Ownership says "yours"; the audience has not moved at all.
So the question a line answers is not *whose file is this* but *who reads this line*, which is
the same cut [kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers) already makes inside a
single page between prose and identifiers - applied one level up, to the halves of a document.
Ownership decides who may change a sentence. The reader decides what language it is in. The two
questions were answered together for as long as they happened to agree.
## Why the control plane's language is English
Not because English is better for the purpose, and not to be neutral: this instance's operator
reads German, and the pages are German for that reason.
- **The control plane is almost entirely about identifiers, and the identifiers are English.**
`base_dir`, `provenance: sourced`, `--confirm`, exit 42, `root: kb`. A sentence in another
language explaining when to set `page_ref_fields` is already half English by the time it
reaches the verb, and the prose/identifier boundary inside it becomes something a reader has to
work out line by line.
- **It quotes a body of material that is English and stays English.** The harness documentation
it has to agree with, the vendored skill-authoring sources under `commonplace/`, the tool's own
`--help`. A contract that translates their vocabulary makes its own claims harder to check
against them, not easier.
- **One language keeps instances comparable.** Two instances running the same stack version hold
the same control plane byte for byte, so a question about one is answerable from the other -
and anything an instance changes locally shows up as a difference in content rather than in
language.
## Why it is not a parameter
The natural next move, once `kb/CONVENTIONS.md` holds `language:`, is a second value beside it -
`control_plane_language:` - defaulting to English and settable by an instance that would rather
read its contracts in its own language. That option is deliberately not taken.
- **The knob's cost is paid by every file; its benefit lands on the few a human reads.** Every
rule about writing an instruction would have to name which of the two languages it means, every
example would need a note saying which one it is in, and every review of an instruction would
start by establishing which language it should have been in. The stack has one mechanism for
that class of problem - one rule, one place (AGENTS.md invariant 8) - and a second language
value forks it everywhere at once.
- **The document the knob is for is read by an agent.** An instruction, a contract, a type-spec's
guidance half: the reader is a model, and a model reads the English fine. What the *operator*
reads is unaffected by any of this - see the section below.
- **Today's local document is tomorrow's upstream candidate.** An instruction an instance wrote
for itself is the most likely thing it ever contributes back. Written in the KB language it
would have to be translated first, and the translation would have to re-derive the
prose/identifier boundary that the original author had in their head and did not write down.
- **Nothing would check it.** There is no mechanical test for what language a paragraph is in -
a stop-word scan flags the quoted vocabulary the rule deliberately keeps and misses a cleanly
translated paragraph. A setting nothing enforces produces drift that is visible only to whoever
next opens the file.
## What the KB language still decides
Making the control plane English does not make the instance's language an implementation detail.
`kb/CONVENTIONS.md`'s `language:` decides two things, and both are the ones an operator actually
experiences:
- **Page text.** Every page under `kb/`, and inside the page type-specs exactly the parts that
become page text - each one's `## Template` block and its `layout:` titles.
- **What an agent says.** An agent speaks the KB language, whatever the file it just read was
written in. An instruction that models a sentence for the operator writes that model in
English, like the rest of the control plane, and the agent delivers it in the instance's
language.
So an operator who reads no English gets German pages and German answers from an agent reading
English instructions. The English is what the machinery is written in, not what it says back.
## Where the line runs around a page type
A page type's contract is where the two languages meet most closely, and it is worth knowing
which part is which before editing any of it. Its authoring guidance addresses an agent and is
English; its `## Template` block and `layout:` titles become the literal headings of pages and
follow the KB language; its field names and enum values are identifiers and are translated in
neither direction.
The language line did not move when the *file* line did. A `root: kb` type-spec may now put its
authoring guidance in a separate, stack-owned `types/<name>.guidance.md` rather than carrying it
beside the template, but that split was made for ownership reasons - so an upgrade can improve
the guidance without overwriting what the instance chose - and it leaves this page's argument
untouched: each part is still written in the language its own reader needs, and a type-spec that
declares no `guidance:` keeps both halves in one file with exactly the same rule applying inside
it. [types/type-spec.md § Who owns a type-spec](../types/type-spec.md#who-owns-a-type-spec) has
the split as a table, and [ownership-and-templates.md](ownership-and-templates.md) § "Where the
file boundary used to strain" has what it cost to keep two audiences in one file for as long as
it did.
## What would put this back on the table
A `docs/` page goes stale when the reasoning stops holding rather than when the code changes, so
it is worth naming what that would look like here. Two things would:
- **A human starts reading the control plane directly and routinely** - not an operator checking
a rule now and then, which is the case today, but a workflow where people rather than agents
are the primary readers of `instructions/`. The second argument above is the one that fails
first, and it is the load-bearing one.
- **The identifiers stop being English.** If the tool's own vocabulary were ever localized, the
first argument would invert: the prose would then be the only English left in a file that is
otherwise not, which is the situation this page argues against.
Neither is close. Both are cheaper to notice here than to rediscover in an argument about a
single file.
+59 -1
View File
@@ -9,6 +9,17 @@ below). Others - `USER.md`,
`SOUL.md`, `kb/CONVENTIONS.md`, `ENVIRONMENT.md` - describe one particular instance, and
overwriting them would silently erase a choice someone made on purpose.
<!-- wikitool:toc -->
## Contents
- [Two different kinds of truth](#two-different-kinds-of-truth)
- [Why silent overwrite is the failure being designed against](#why-silent-overwrite-is-the-failure-being-designed-against)
- [Why the boundary is a predicate rather than a list](#why-the-boundary-is-a-predicate-rather-than-a-list)
- [Why a `.template`, not just an absent file](#why-a-template-not-just-an-absent-file)
- [Where the file boundary used to strain](#where-the-file-boundary-used-to-strain)
- [The consequence in practice](#the-consequence-in-practice)
<!-- /wikitool:toc -->
## Two different kinds of truth
The stack-owned files describe how the tool works. `kb/CONTRACT.md` opens by saying it holds
@@ -81,13 +92,60 @@ also why `ENVIRONMENT.md` only warrants a WARN rather than a FAIL when absent -
checkout among possibly several and is gitignored for that reason, so its absence is a normal
state rather than a sign setup was skipped.
## Where the file boundary used to strain
"The file itself already answers that" held for every file above except one shape: a `root: kb`
type-spec used to carry two audiences inside one file.
Its authoring guidance - when to use this type, what each frontmatter field means, how to cite -
was instruction to an agent. It read like the stack's own prose because it *was* the stack's own
prose: a later release that learned something about writing entity pages would want to improve it
everywhere. Its `## Template` block and its `layout:` titles were the opposite: they became the
literal headings of pages this instance writes, in the language this instance chose, and no
release had any business touching them.
The same file is where the language question comes apart from the ownership one, and for the same
reason: ownership decides who may change a line, its reader decides what language it is in -
which is why a `root: kb` type-spec still keeps English prose around a template block written in
its own language. [language-boundaries.md](language-boundaries.md) has that argument; this page is
about ownership alone.
Ownership is per file, so a file carrying both audiences had to give both halves to whoever owned
it. The template half was correct that way. The guidance half paid for it: an instance that
adopted its type-specs at setup never received an improvement to the guidance again, because
`dist upgrade` wrote the `.template` beside the adopted file and never the file itself. Nothing
broke, and nothing reported it - the instance simply kept reading the guidance it was handed the
day it was created.
That was not an argument against the per-file boundary; the boundary is what makes an upgrade
safe at all, and merging inside a shared file is the failure the whole section above is about. It
was an argument that this particular file was cut in the wrong place - so it was cut again. A
`root: kb` type-spec may now declare `guidance:`, a repo-relative path to a second,
stack-owned file (`types/<name>.guidance.md`) holding exactly the half that used to be stranded:
when to use the type, when not to, and mechanism-level advice that holds for every instance. That
file ships verbatim and upgrades like any other machinery file, whether or not the type-spec that
links it has ever been adopted. `types/type-spec.md` §§ "Who owns a type-spec" and "Anatomy of a
type" hold the current shape; `tools/wikitool types describe <name>` composes both files into one
answer, so an agent asking for a type's contract never needs to know it comes from more than one
file. An instance that adopted its type-specs before this split existed takes it as an *offered*
migration rather than something an upgrade applies on its own - the same reasoning as any other
instance-owned file in the middle category below, spelled out for this one case because it is the
case that motivated the category existing at all.
A type-spec that declares no `guidance:` - one an instance writes entirely for itself - is
unaffected: it is still described from its own body alone, the way every type-spec worked before
`guidance:` existed. The split is optional exactly where there is no stack-owned improvement to
receive.
## The consequence in practice
An upgrade sorts every shipped path into three categories, not two - and the third one only
becomes visible once an upgrade is a command rather than a hand-run copy:
- **Verbatim files** - `AGENTS.md`, `kb/CONTRACT.md`, the per-stage contracts, everything under
`tools/`, `types/` and `instructions/` - are the release's to replace.
`tools/`, `types/` and `instructions/` - are the release's to replace. This is where a `root:
kb` type-spec's optional `types/<name>.guidance.md` sits: verbatim, even though the type-spec
it documents (below) is not.
- **`.template`-sourced files** - `USER.md`, `SOUL.md`, `kb/CONVENTIONS.md`, each
`kb/<name>/COLLECTION.md`, `ENVIRONMENT.md`, the `root: kb` type-specs - are never written by
an upgrade at all. The distribution ships only the `.template` beside them, so the filled file
+20 -2
View File
@@ -3,6 +3,19 @@
A stack version number looks like it answers one question. It actually answers two, and the two
are independent of each other.
<!-- wikitool:toc -->
## Contents
- [Two questions, not one](#two-questions-not-one)
- [Why "kb/ untouched" is not proof of anything](#why-kb-untouched-is-not-proof-of-anything)
- [Reading compatibility off the leftmost non-zero component](#reading-compatibility-off-the-leftmost-non-zero-component)
- [Downgrade is half the promise](#downgrade-is-half-the-promise)
- [A promise made to a machine, not only to a person](#a-promise-made-to-a-machine-not-only-to-a-person)
- [The 2.0.0 story](#the-200-story)
- [Why a number is only spent by a release](#why-a-number-is-only-spent-by-a-release)
- [Where the procedure lives](#where-the-procedure-lives)
<!-- /wikitool:toc -->
## Two questions, not one
The first question is whether the new version is a drop-in replacement for the old one - whether
@@ -114,6 +127,11 @@ because there is nothing yet to promise.
## Where the procedure lives
The drop-in test, the catalogue of changes that cross the boundary with no page touched, and the
steps for a boundary-crossing bump - the `--breaking` line, the migration document or
steps for a boundary-crossing bump - the `--breaking` lines, the migration document or
`--no-migration` reason, talking to the user before bumping - are one procedure, kept at one
place: [instructions/dev/version-parts.md](../instructions/dev/version-parts.md).
place: `instructions/dev/version-parts.md`.
Named as a plain path rather than linked, because it is not here to link to. `dist export`
prunes `instructions/dev/` wholesale, so that file exists only in the origin repo - the place
where a version is bumped at all. An instance reads this page to understand what a version
number promises it; it never runs the procedure.
+41 -3
View File
@@ -18,6 +18,7 @@ alongside [AGENTS.md](../AGENTS.md).
- [Publishing](#publishing)
- [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 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)
- [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)
@@ -171,6 +172,12 @@ Scaffold with `tools/wikitool new instruction --name "<name>"`; the contract is
at all. If that is worth preserving, it is a concept page under `kb/concepts/`, linked from
here. Where the line runs, and how to test a passage against it: below.
- **State scope boundaries.** When does this *not* apply, and what to do instead.
- **Write it in English, and let the agent speak the instance's language.** Both rules, and the
line between prose and quoted vocabulary, are stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming). They are named here because this is the
step where they are obeyed or lost: nothing checks either mechanically, and an instruction
that models a sentence for the user is where the two are easiest to confuse - the model is
written in English, the saying of it follows `kb/CONVENTIONS.md`'s `language:`.
### A skill's H1 is a name, not an imperative
@@ -190,6 +197,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
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
Anthropic's skill-authoring guidance asks that reference files stay **one level deep from
@@ -201,9 +237,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
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),
[tools/CONTRACT.md](../tools/CONTRACT.md), [gates.md](gates.md) - is a different category, and
the two halves of the question have different answers:
A skill's reference to a repo-wide contract - `kb/CONTRACT.md`, `tools/CONTRACT.md`,
`instructions/gates.md` (written as a plain path per § "A skill's outbound reference is a plain
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
be read partially exactly as a bundled file would be. Nothing about the path makes it safe.
+21 -4
View File
@@ -33,16 +33,33 @@ touched; a row that does not apply needs no action.
| 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 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 |
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all four) |
| The reasoning behind a gate, boundary, or design decision | The `docs/` page that carries it, if one exists (AGENTS.md § File naming lists all five reached from AGENTS.md itself, plus a sixth reached only from CLAUDE.md) |
| A skill's own step sequence or catalogue | The skill's `SKILL.md` source under `instructions/<name>/` or `instructions/dev/<name>/` |
3. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
3. **A heading you changed means a table of contents to regenerate - by the tool, never by
hand.** Every reference file over 100 lines carries one (`AGENTS.md`, the stage contracts,
`kb/CONVENTIONS.md`, each `COLLECTION.md`, the flat `instructions/**.md` form, the
type-specs, the `docs/` pages - a `SKILL.md` is the one exception). Adding, renaming,
reordering or deleting a `##`/`###` heading in one of them makes its region stale, and
`docs verify` fails on stale exactly as it fails on missing:
```bash
tools/wikitool docs toc # dry run: which files would change
tools/wikitool docs toc --apply # write them
```
The region is generated, so AGENTS.md invariant 1 applies to it like any other: editing the
list by hand is the failure, not the fix - and a hand-written entry survives until the next
`--apply` silently disagrees with it. It is cheap to over-run: `--apply` is idempotent and a
file whose headings did not move is left untouched.
4. **Do not re-derive what `docs verify` already checks mechanically** - existence, table-row
membership, ignore-canary state. That enumeration lives once, in
[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).
This instruction is only about the prose no check reads.
+17 -10
View File
@@ -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
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
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).
**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.
## 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
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
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
`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
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
[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
next step to run past - it exists only inside a skill someone has to invoke.
@@ -48,10 +48,12 @@ and a fresh subagent starts without the session's context).
## Steps
1. **Offer the model switch back up, once, and keep working either way.**
1. **Offer the model switch back up, once, and keep working either way.** A model of the
message, not a script to quote: say it in the instance's KB language, per `AGENTS.md`
§ File naming.
> Ab hier greift kein maschineller Check mehr - Issue-Body, `docs/`-Veralterung und
> Changelog-Prosa prüft nichts. Wenn du zurück auf Opus willst, ist jetzt der Moment.
> From here on no mechanical check applies - nothing verifies the issue body, `docs/`
> staleness, or the changelog prose. If you want to switch back to Opus, now is the moment.
**Never block on the answer.** The change is already published; a session that stops here
leaves exactly the state this skill exists to prevent.
@@ -66,7 +68,7 @@ and a fresh subagent starts without the session's context).
- what was verified is named - which checks ran, which CI run - not a commit hash alone
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.
**A closing report in a comment does not satisfy this**, however thorough: it reads as
@@ -80,12 +82,17 @@ and a fresh subagent starts without the session's context).
naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched
`<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
([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
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
leaving the question unasked.
**If that update moved a `##`/`###` heading, the file's table of contents is now stale** -
regenerate it with `tools/wikitool docs toc --apply`, never by editing the list. The region
is generated (AGENTS.md invariant 1), `docs verify` fails on stale exactly as on missing, and
a pull-through in this phase is a common way to move a heading without noticing.
**A pull-through of its own needs its own bump.** This phase runs *after* `stack-dev` step 4
has already bumped the version, and the documents it touches are frequently the ones CI's
version gate watches - `types/`, `instructions/`, `tools/`, `AGENTS.md`, any
@@ -110,7 +117,7 @@ and a fresh subagent starts without the session's context).
- **The work package spans several sessions?** Run this skill once, at the point the package is
actually finished and its last publish has landed - not after every individual publish. A
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.
- **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
+33 -21
View File
@@ -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
wiki", ask rather than guess - the two have different rules for the same directories.
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
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
of the issue you are working on is this session's plan file:** keep it current as the state
moves, so an interrupted session leaves a body the next one can resume from, *and* rewrite it
to its final state before closing. Both halves bind; the second is 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
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
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
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
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
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.
[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
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.
@@ -76,12 +76,13 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
So when the design is settled - the issue body says what will be built, the open questions are
answered - stop and say so, in one sentence that names what the mechanical stretch does **not**
cover:
cover. The message below is a model of what to say, not a script to quote: say it in the
instance's KB language, per `AGENTS.md` § File naming.
> Der Plan steht, ab hier ist die Arbeit größtenteils mechanisch und durch Tests/CI abgedeckt -
> mit Ausnahme der Changelog-Prosa (Schritt 4), einer berührten `docs/`-Seite, neuer
> Menschendoku oder des Prosa-Anteils einer Instruction. Wenn du auf Opus bist, ist jetzt der
> Moment für `/model sonnet` bei Effort `high`.
> The plan is settled. From here the work is mostly mechanical and covered by tests/CI -
> except the changelog prose (step 4), any `docs/` page you touch, new human-facing
> documentation, and the prose half of an instruction. If you are on Opus, now is the moment
> for `/model sonnet` at effort `high`.
**You cannot make this switch yourself** - the session's model is the user's `/model`, not a
setting an agent applies. Offer it once and keep working either way; a session that argues
@@ -100,16 +101,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
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/`,
`instructions/`, `AGENTS.md` or a `CONTRACT.md` reaches every future instance, so it needs a
version and a changelog entry:
```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`
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:
@@ -123,12 +129,12 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
Content migration is one way to land in the last row, not the definition of it: a rename of
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
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`.**
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
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
either, and so does `docs verify`: an instance learning that it must migrate, with nothing
telling it how, is a dead end.
@@ -140,8 +146,14 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
do not need a bump - CI's version gate is scoped to what changes behaviour.
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
the table of which document that is, per surface.
checks a cell's presence, never its prose.** `instructions/dev/doc-pull-through.md` has
the table of which document that is, per surface, and its step 3 for the one part of the
pull-through that is *not* prose: a reference file whose headings moved needs
`tools/wikitool docs toc --apply`, never a hand-written list.
Prose you write here is English, whatever language the session is being held in -
`AGENTS.md` § File naming has both language rules and the line between prose and quoted
vocabulary.
6. **Verify, then publish.** `tools/wikitool docs verify`, `tools/wikitool instructions verify`,
and the relevant `pytest` run in `tools/` - the same checks any stack change must pass, run
@@ -157,7 +169,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
**This skill stops here.** The closing phase - rewriting the issue body to its final state,
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
is published" as this work package being done.
@@ -171,7 +183,7 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
user decides whether it is worth that: show them what breaks, what an instance has to do about
it, and the alternatives (avoid the break with a shim, defer and batch it with the next one,
or split it behind a deprecation window), then recommend one and wait for a go-ahead.
[version-parts.md](../version-parts.md) step 4 has the full shape. 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
mechanical guard, and `docs verify` only checks that a crossing documents itself, never that the
part was chosen correctly.
@@ -181,4 +193,4 @@ stack development happens in the origin repo instead (see AGENTS.md's routing li
Not for wiki content work - use `wiki-ingest`/`wiki-query`/`wiki-manage`/`wiki-lint`/
`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
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`).
+57 -16
View File
@@ -55,10 +55,37 @@ a new one, and only `version release` turns it into something the release workfl
its parser never has to know the suffix exists.
- **One `CHANGES.md` entry per candidate**, not per bump. The first bump of a candidate opens it
(heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list seeded with that
bump's `--title`); every later bump of the *same* candidate updates that entry in place -
heading, date and the bumps list all move, but the entry's own prose (written below the
skeleton, by hand) is left alone. `version notes` therefore still prints exactly one entry per
release, whatever a candidate's history of bumps looked like.
bump's `--title`, graded by `--impact`); every later bump of the *same* candidate updates that
entry in place - heading, date and the bumps list all move, but the entry's own prose is left
alone. `version notes` therefore still prints exactly one entry per release, whatever a
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. The breaking line **accumulates** across a candidate's crossings - one
reason on the marker line, bullets under a bare marker from the second onward - because a
long-running candidate can break compatibility more than once and each break is its own
thing to act on. The migration line does not: it answers one yes/no about the candidate as
a whole, and `--migration-required` is its retraction path. Nothing retracts a breaking
reason; a wrong one is rare enough, and the candidate is dev-local until release.
2. **The bump list**, grouped `**High/Medium/Low impact**` (empty groups omitted) - rendered by
`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`
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.
@@ -126,9 +153,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.
5. **Record the break in the escalation bump itself.** The bump that first crosses the boundary
requires `--breaking "<what breaks>"`, which writes a `**Breaking Change:**` line into the
entry:
5. **Grade the bump while you are making it, with `--impact high|medium|low`** (default
`medium`) - the judgment is easiest right after you did the work, not weeks later staring at a
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
tools/wikitool version bump --major \
@@ -168,16 +198,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
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
one; nothing turns it into a release except `tools/wikitool version release`, which strips the
`-beta.N` suffix and closes the entry - see its row in `tools/CONTRACT.md`. That is also the
point to pass a summarising `--title` if the candidate collected several bump titles along the
way; without one, the heading simply keeps whichever bump last set it.
7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run
`tools/wikitool version regrade` with no arguments - it lists every bump at its current grade,
numbered in rendered order. A candidate that grew over several sessions often has a bump graded
in isolation that reads differently once the whole shape is visible; `version regrade 3 7
--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
earns a paragraph that says *why this is breaking* - it is the one thing a future reader
cannot reconstruct from the diff, and it is what the next session in this position will read
instead of guessing.
8. **Write the entry's prose - the summary, and each bump's own changeset.** `bump` leaves both
empty on purpose. The **summary** is a short paragraph (a few sentences) written once, at
release time, right below the graded bump list: what this release is about, and why, for a
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
+8 -1
View File
@@ -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
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`
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
The budget is scoped by `WIKITOOL_SESSION_ID` ([session-setup.md](session-setup.md)), so a new
@@ -0,0 +1,116 @@
---
type: types/instruction.md
name: 6.0.0-type-guidance-split
description: "Add a guidance: field to an adopted root:kb type-spec so it starts receiving the stack's authoring-prose improvements again, without touching the type-spec's own frontmatter or template."
manual: true
migrates_to: 6.0.0
migration_kind: assisted
obligation: offered
---
# Link an adopted type-spec to its stack-owned guidance file (6.0.0)
Before 6.0.0, a `root: kb` type-spec (`entity`, `concept`, `source`, `comparison`, or one this
instance added itself) carried its generic authoring prose - when to use the type, when not to,
mechanism-level advice such as citation and provenance rules - in the same file as its frontmatter
configuration and its `## Template` block. Adopting the type-spec at setup meant adopting all of
it at once, and an upgrade never touched the adopted file again: the prose an instance received
was frozen at the day it ran `setup-instance.md`, while every later improvement shipped only in
the `.template` beside it (`docs/ownership-and-templates.md` § "Where the file boundary used to
strain").
6.0.0 splits that prose into a separate, stack-owned `types/<name>.guidance.md`, linked from the
type-spec via an optional `guidance:` frontmatter field. The new file ships verbatim and upgrades
like any other machinery file from here on - but only once a type-spec actually points at it.
Taking this offer is exactly that: adding one frontmatter line per adopted type-spec. It is
`assisted`, not `mechanical`, because whether this instance's own copy of the prose has diverged
from the shipped default is a judgment call a script cannot make.
This migration is **offered, not required**. A type-spec with no `guidance:` keeps working
exactly as it did before 6.0.0 - it is described from its own body alone. Declining costs nothing
except future improvements to the prose half; nothing about the machinery stops fitting.
<!-- wikitool:toc -->
## Contents
- [When to run](#when-to-run)
- [Steps](#steps)
- [How to tell a migrated type-spec from an unmigrated one](#how-to-tell-a-migrated-type-spec-from-an-unmigrated-one)
- [Decision points](#decision-points)
- [Scope](#scope)
<!-- /wikitool:toc -->
## When to run
Any time after installing 6.0.0 machinery over an instance that adopted at least one `root: kb`
type-spec before this migration existed. `tools/wikitool migrate status` lists it under "optional
upgrade(s) available"; taking it is not gated on anything else being current.
## Steps
1. **Confirm the new guidance files actually arrived.** `dist upgrade` writes `types/<name>.guidance.md`
as an ordinary new/unchanged file - it does not depend on this migration at all. If
`ls types/*.guidance.md` shows nothing, the machinery upgrade has not landed yet; run that
first.
2. **For each adopted `root: kb` type-spec, decide whether its authoring prose still matches the
shipped default.** Compare the type-spec's current prose (everything outside `## Frontmatter`
and `## Template`) against the corresponding `types/<name>.guidance.md`:
- **Unchanged, or changed only in ways this instance is happy to lose:** proceed to step 3
directly - the new guidance file already carries the improved version.
- **Locally edited in a way worth keeping** (a house style note, an extra rule specific to
this corpus): that edit has to move somewhere before the old prose is dropped. Either fold
it into a local copy of the guidance file this instance then owns for itself (any path is
valid for `guidance:`, not only the shipped one), or keep it in the type-spec's own body
instead of adding `guidance:` at all - both are legitimate; declining the stack default for
one type is not an error.
3. **Add `guidance: types/<name>.guidance.md` to the type-spec's frontmatter** - by hand, the same
way any other type-spec frontmatter field is written (a type-spec is machinery, not a `kb/`
page, so this is not a `wikitool touch` call). Do not remove `## Frontmatter` or `## Template`;
only the generic prose around them is what the guidance file now carries.
4. **Delete the now-duplicated prose from the type-spec**, keeping the H1, a short pointer to the
guidance file (`types/entity.md`'s own current text is the worked example), `## Frontmatter`
and `## Template`. Where step 2 found a local edit worth keeping and it lives in the
type-spec's own body rather than a private guidance file, leave that part exactly where it is.
5. **Verify:**
```bash
tools/wikitool types describe <name>
```
The output must read the same as it did before this migration - the guidance prose composed
ahead of the type-spec's own body, in one answer. A diff against the pre-migration output of
the same command, restricted to wording, is expected only where step 2 found something to
drop or fold in; the structure (frontmatter fields, template block) must be byte-identical.
6. **Record it:**
```bash
tools/wikitool migrate done 6.0.0 --pages 0
```
`--pages 0` because no `kb/` page changes - this migration touches machinery under `types/`
only. This does **not** advance `kb_version`, per `obligation: offered` above; it only marks
the offer as taken so `migrate status` stops listing it.
## How to tell a migrated type-spec from an unmigrated one
`grep -L '^guidance:' types/*.md` (excluding `.guidance.md` files themselves, which never carry
the field) lists every `root: kb` type-spec that has not taken the offer yet.
## Decision points
- **A type this instance wrote entirely for itself?** No `types/<name>.guidance.md` exists for
it and none should be authored to match this migration artificially - `guidance:` is for
receiving a *stack* default, and a self-written type has none to receive. Leave it as it is.
- **Local prose worth keeping, but no interest in maintaining a private guidance file?** Skip
`guidance:` for that one type-spec. Nothing forces uniformity across an instance's own types.
## Scope
For `types/` machinery, not `kb/` content - the one migration document in this directory that
is. No page's frontmatter or body changes, `sources coverage`/`lint`/`kb_version` are all
unaffected, and `migrate done`'s `--pages` is `0` for exactly that reason.
+3 -2
View File
@@ -57,11 +57,12 @@ refusal. See [gates.md](gates.md).
## 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
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
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/`,
which is gitignored, so it looks side-effect-free - but it is not on the allowlist and is counted
+180 -180
View File
@@ -1,83 +1,83 @@
---
type: types/instruction.md
name: setup-instance
description: Eine frische Distribution (aus `dist export`) in eine funktionsfähige, eigenständige Wiki-Instanz verwandeln - Git-Repo, Identität/Autor, optionaler Remote, Bootstrap, erster Commit.
description: Turn a fresh distribution (from `dist export`) into a working, self-contained wiki instance - git repo, identity/author, optional remote, bootstrap, first commit.
---
# Neue Wiki-Instanz einrichten
# Set up a new wiki instance
Diese Anweisung führt eine leere, per `tools/wikitool dist export <ziel>` erzeugte Distribution
zu einer funktionsfähigen, eigenständigen Wiki-Instanz - mit eigenem Git-Repo, eigener Autor-
Identität und (optional) eigenem Remote. Am Ende ist die Instanz committet, verifiziert und
bereit für den ersten `Ingest`.
This instruction takes an empty distribution produced by `tools/wikitool dist export <target>`
and turns it into a working, self-contained wiki instance - with its own git repo, its own
author identity and (optionally) its own remote. At the end the instance is committed, verified
and ready for its first ingest.
<!-- wikitool:toc -->
## Contents
- [Wann anwenden](#wann-anwenden)
- [Schritte](#schritte)
- [When to run](#when-to-run)
- [Steps](#steps)
- [Scope](#scope)
<!-- /wikitool:toc -->
## Wann anwenden
## When to run
- Der Nutzer möchte eine neue, leere Wiki-Instanz aufsetzen (eigenes Thema, anderer Nutzer).
- Nicht für einen bestehenden Clone dieses (Quell-)Repos - siehe [bootstrap.md](bootstrap.md).
- Es gibt keinen Weg zurück: `dist export` lässt `instructions/dev/` (die Stack-Entwicklung
selbst, inkl. der vendorten `commonplace/`-Wissensbasis) bewusst und dauerhaft weg. Wer den
entstehenden Instanz-Stack weiterentwickeln will, tut das im Ursprungs-Repo (oder einer neuen
Dev-Instanz daraus) - nicht durch Nachrüsten in dieser Instanz.
- The user wants to set up a new, empty wiki instance (their own subject, a different person).
- Not for an existing clone of this (source) repo - see [bootstrap.md](bootstrap.md).
- There is no way back: `dist export` deliberately and permanently leaves out
`instructions/dev/` (stack development itself, including the vendored `commonplace/` knowledge
base). Anyone who wants to develop the resulting instance's stack further does that in the
origin repo (or a new dev instance made from it) - not by retrofitting it into this instance.
## Schritte
## Steps
1. **Distribution exportieren**, im Quell-Repo:
1. **Export the distribution**, in the source repo:
```bash
tools/wikitool dist export <ziel>
tools/wikitool dist export <target>
```
`<ziel>` muss nicht existieren oder leer sein; der Befehl bricht sonst mit `ERROR` ab. Danach
für alle folgenden Schritte in `<ziel>` arbeiten.
`<target>` must not exist, or must be empty; otherwise the command aborts with `ERROR`. Work
inside `<target>` for every step that follows.
2. **Git-Repo initialisieren:**
2. **Initialize the git repo:**
```bash
git init -b main
```
`-b main` ist Pflicht: `tools/wikitool publish` prüft beim tatsächlichen Push, ob der
ausgecheckte Branch dem Ziel-Branch entspricht (Default `main`), und lehnt sonst ab, um
nicht den falschen Branch zu veröffentlichen.
`-b main` is mandatory: on the actual push, `tools/wikitool publish` checks that the
checked-out branch matches the target branch (default `main`) and refuses otherwise, so that
the wrong branch is never published.
3. **Entscheidungspunkt - Identität.** Frage den Nutzer nach Namen und E-Mail-Adresse; rate sie
nie, und übernimm sie nie stillschweigend aus dem Quell-Repo (das ist eine andere Person, ein
anderes Projekt):
3. **Decision point - identity.** Ask the user for their name and email address; never guess
them, and never quietly carry them over from the source repo (that is a different person and
a different project):
```bash
git config user.name "<Name>"
git config user.email "<E-Mail>"
git config user.name "<name>"
git config user.email "<email>"
```
Das setzt zugleich den Autor jeder künftig angelegten Wiki-Seite: `tools/wikitool new`
löst `author:` über `$WIKI_AUTHOR` (Override) oder sonst `git config user.name` auf und
bricht mit `ERROR` ab, wenn beides fehlt - es gibt keinen stillen Platzhalter.
This also sets the author of every wiki page created from now on: `tools/wikitool new`
resolves `author:` from `$WIKI_AUTHOR` (an override) or else from `git config user.name`, and
aborts with `ERROR` when both are missing - there is no silent placeholder.
4. **Entscheidungspunkt - Remote.** Frage den Nutzer nach einer Remote-URL; ein rein lokales
Repo ist ein gültiger Endzustand:
- Genannt: `git remote add origin <url>`
- Nicht genannt: lokal bleiben - dann braucht **jeder** spätere `tools/wikitool publish`
ein `--no-push` (dessen Branch-Prüfung dabei ohnehin entfällt, siehe Schritt 2).
4. **Decision point - remote.** Ask the user for a remote URL; a purely local repo is a valid
end state:
- Given: `git remote add origin <url>`
- Not given: stay local - then **every** later `tools/wikitool publish` needs a `--no-push`
(which also drops its branch check, see step 2).
5. **Entscheidungspunkt - Autorenkonventionen.** Die Distribution bringt keine ausgefüllten
Konventionen mit, sondern `kb/CONVENTIONS.md.template` und je Collection ein
`kb/<name>/COLLECTION.md.template`. Beide **binden**, sobald sie übernommen sind, und beide
gehören dieser Instanz - deshalb liefert der Stack nur die Vorlage. Die eine Entscheidung
dahinter ist: **in welcher Sprache und in welchem Ton schreibt diese Instanz ihre Seiten?**
5. **Decision point - authoring conventions.** The distribution ships no filled-in conventions,
only `kb/CONVENTIONS.md.template` and one `kb/<name>/COLLECTION.md.template` per collection.
Both **bind** once adopted, and both belong to this instance - which is why the stack ships
the template alone. The one decision behind them is: **in which language and in what tone
does this instance write its pages?**
Ablauf:
Procedure:
1. Die Collection-Contracts **und die Page-Type-Specs** übernehmen - Kopien, keine Frage an
den Nutzer, denn was dort steht ist als Ausgangspunkt unabhängig von der Sprache brauchbar:
1. Adopt the collection contracts **and the page type-specs** - copies, no question to the
user, because what they say is usable as a starting point regardless of language:
```bash
for template in kb/*/COLLECTION.md.template types/*.template; do
@@ -85,110 +85,114 @@ bereit für den ersten `Ingest`.
done
```
Die `.template`-Dateien bleiben liegen; sie sind die Vorlage für den nächsten Export.
The `.template` files stay where they are; they are the source for the next export.
Unter `types/` betrifft das genau die Type-Specs mit `root: kb` - `entity`, `concept`,
`source`, `comparison` - samt ihrer `.schema.yaml`. Sie beschreiben Seiten, die *diese*
Instanz schreibt, also gehören sie ihr: Prosa, Template und Sprache dürfen umgeschrieben
werden. `instruction`, `lint-report` und `type-spec` beschreiben Stack-Artefakte und
kommen unverändert.
Under `types/` this covers exactly the type-specs with `root: kb` - `entity`, `concept`,
`source`, `comparison` - along with their `.schema.yaml`. They describe pages *this*
instance writes, so they belong to it: frontmatter, template and language may all be
rewritten. `instruction`, `lint-report`, `type-spec` and `type-guidance` describe stack
artifacts and arrive unchanged - the glob above never matches them because none of them
ships as a `.template` in the first place.
2. Den Nutzer nach der KB-Sprache fragen. `kb/CONVENTIONS.md.template` ist auf **Englisch**
voreingestellt; [kb-profiles.md](kb-profiles.md) hält daneben ein vollständiges
deutsches Profil bereit, und dessen Volltext ist die `kb/CONVENTIONS.md` des Quell-Repos.
Der Profilkatalog ist eine **Palette, kein Enum**: übernommen wird der Text *in* die
Instanzdatei, nicht ein Verweis auf den Katalog.
A `root: kb` type-spec's generic authoring guidance (when to use the type, when not to)
is not part of this adoption at all: it lives in a sibling `types/<name>.guidance.md`
this instance never renames, the same as `instruction.md` - it ships verbatim and a later
`dist upgrade` improves it directly, without the type-spec that links it needing to be
touched. `types/type-spec.md` § "Anatomy of a type" has the shape.
3. `kb/CONVENTIONS.md.template` nach `kb/CONVENTIONS.md` kopieren, entlang des gewählten
Profils ausfüllen - Sprache, Abschnittsnamen, Namensformen, Ton, Beziehungslabels,
Hedging-Regel - und dabei die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen.
Die Platzhalter in geschweiften Klammern **sind** der Fragenkatalog.
2. Ask the user for the KB language. `kb/CONVENTIONS.md.template` defaults to **English**;
[kb-profiles.md](kb-profiles.md) additionally holds a complete German profile, whose full
text is the source repo's own `kb/CONVENTIONS.md`. The profile catalogue is a **palette,
not an enum**: what gets adopted is the text *into* the instance file, not a reference to
the catalogue.
4. Bei einer anderen Sprache als der des Quell-Repos: `german-terminology.md` löschen oder
durch das eigene Vokabular ersetzen - sie ist Material des deutschen Profils, nicht des
Stacks.
3. Copy `kb/CONVENTIONS.md.template` to `kb/CONVENTIONS.md`, fill it in along the chosen
profile - language, section names, naming forms, tone, relationship labels, hedging rule -
and remove the sentinel line (`wikitool:template-unfilled`) while doing so. The
placeholders in curly braces **are** the list of questions.
5. Den Nutzer nach dem Anwendungsgebiet fragen und daraus einen `source_type`-Vorschlag
ableiten. [kb-profiles.md](kb-profiles.md) hält dafür zwei ausformulierte Domänenprofile
als Anschauung bereit, neben dem Wert, den dieses Repo selbst nutzt. Der Vorschlag ist ein
**Startpunkt, keine Festlegung** - zum Setup-Zeitpunkt hat der Betreiber null Quellen und
rät seine Taxonomie, bevor er auch nur eine Datei gesehen hat, und das ist der
schlechteste Moment, ein Enum festzuzurren. Vorschlag umgesetzt heißt: das Enum in
`types/source.schema.yaml` **und** die passende `layout:`-Zeile je Wert in
`types/source.md` in derselben Bearbeitung setzen - eine ohne die andere lässt einen Wert
ohne Zielverzeichnis zurück. Der sichtbare Auffangwert (`unclassified`) bleibt in jedem
Vorschlag erhalten; er ist kein Sammelbecken, sondern das Fach für eine Quelle, deren
Kategorie noch nicht feststeht. Die Liste später erweitern oder das Fach leeren:
[evolve-subtypes.md](evolve-subtypes.md) - nicht Teil dieses Schritts, aber der Weg dahin,
sobald echtes Material vorliegt.
4. For a language other than the source repo's: delete `german-terminology.md` or replace it
with your own vocabulary - it is material belonging to the German profile, not to the
stack.
**Unverändert lassen:** `fidelity` und `authority` auf `source`-Seiten. Die sind
Stack-Vokabular, keine Instanzentscheidung - [kb-profiles.md](kb-profiles.md) sagt das im
selben Abschnitt.
5. Ask the user about the subject area and derive a `source_type` proposal from it.
[kb-profiles.md](kb-profiles.md) holds two worked domain profiles as illustration, beside
the value this repo uses itself. The proposal is a **starting point, not a commitment** -
at setup time the operator has zero sources and is guessing a taxonomy before having seen
a single file, which is the worst possible moment to pin an enum down. Carrying out the
proposal means setting the enum in `types/source.schema.yaml` **and** the matching
`layout:` line per value in `types/source.md` in the same edit - one without the other
leaves a value with no target directory. The visible catch-all (`unclassified`) survives
every proposal; it is not a dumping ground but the slot for a source whose category is not
settled yet. Extending the list later, or emptying that slot:
[evolve-subtypes.md](evolve-subtypes.md) - not part of this step, but the way there once
real material exists.
**Vor dem ersten Ingest entscheiden.** Die `sections:`-Namen in `kb/CONVENTIONS.md` sind die
Überschriften, die `xref` und `cite` in jede Seite schreiben; sie danach zu ändern ist eine
Migration jeder vorhandenen Seite (`section_aliases:` trägt die alten Namen, siehe
**Leave unchanged:** `fidelity` and `authority` on `source` pages. Those are stack
vocabulary, not an instance decision - [kb-profiles.md](kb-profiles.md) says so in the
same section.
**Decide before the first ingest.** The `sections:` names in `kb/CONVENTIONS.md` are the
headings `xref` and `cite` write into every page; changing them afterwards is a migration of
every existing page (`section_aliases:` carries the old names, see
[migrate-corpus.md](migrate-corpus.md)).
**Nichts davon liegt in einer Stack-Datei.** Der Compiler liest die Abschnittsnamen aus
`kb/CONVENTIONS.md`; die vier Page-Type-Specs gehören ab Schritt 1 dieser Instanz. Eine
anderssprachige Instanz übersetzt sie einfach - das ist kein lokaler Patch an etwas
Ausgeliefertem mehr, sondern Arbeit an den eigenen Dateien, und ein Upgrade nimmt sie ihr
nicht wieder weg.
**None of this lives in a stack file.** The compiler reads the section names from
`kb/CONVENTIONS.md`; the four page type-specs have belonged to this instance since step 1. An
instance in another language simply translates them - that is no longer a local patch to
something shipped, but work on its own files, and an upgrade does not take it away again.
Was der Stack von `types/` überhaupt noch verlangt, ist eine Zeile: es muss einen Type-Spec
mit `name: source` geben, dessen Schema `raw_files` fordert. Daran hängt der gesamte
`raw/`→`kb/`-Provenance-Pfad (`sources coverage`, `[^cite-id]`-Auflösung, `kb/provenance.md`),
und `docs verify` prüft genau das - nicht mehr.
What the stack still requires of `types/` is one line: there must be a type-spec with
`name: source` whose schema requires `raw_files`. The entire `raw/`→`kb/` provenance path
hangs on it (`sources coverage`, `[^cite-id]` resolution, `kb/provenance.md`), and
`docs verify` checks exactly that - no more.
Unverändert bleibt in jedem Fall die Regel, die dem Stack gehört: **jede Zeile einer Seite
ist Prosa oder Identifier, und nur Prosa wird übersetzt** ([kb/CONTRACT.md § Language and
identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titel, Wikilink-Ziele, Cite-IDs,
Enum-Werte, Tags, Befehle und Pfade folgen keiner KB-Sprache.
What stays untouched in every case is the rule the stack owns: **every line of a page is
either prose or an identifier, and only prose is translated** ([kb/CONTRACT.md § Language and
identifiers](../kb/CONTRACT.md#language-and-identifiers)). Titles, wikilink targets, cite ids,
enum values, tags, commands and paths follow no KB language.
`tools/wikitool doctor` prüft das Ergebnis in Schritt 13 (`conventions`): eine fehlende
Datei ist ein `FAIL`, eine mit Sentinel oder ohne vollständigen `sections:`-Block ebenso.
`docs verify` prüft zusätzlich `profile:` und `required_by_stack:` auf jedem
`tools/wikitool doctor` checks the result in step 13 (`conventions`): a missing file is a
`FAIL`, and so is one carrying the sentinel or lacking a complete `sections:` block.
`docs verify` additionally checks `profile:` and `required_by_stack:` on every
`COLLECTION.md`.
6. **Entscheidungspunkt - Personalization.** Die Distribution bringt
`USER.md.template` und `SOUL.md.template` mit, aber keine ausgefüllten Fassungen: wer diese
Instanz bedient und wie sie klingt, ist Eigentum genau dieser Instanz und wird nie aus dem
Quell-Repo übernommen. Beide Dateien werden ab jetzt in **jeder** Session gelesen, also
entstehen sie hier - nicht später bei Gelegenheit.
6. **Decision point - personalization.** The distribution ships `USER.md.template` and
`SOUL.md.template`, but no filled-in versions: who operates this instance and how it sounds
is the property of this instance alone and is never carried over from the source repo. Both
files are read in **every** session from now on, so they come into being here - not later,
when the occasion arises.
Ablauf, für `USER.md` und `SOUL.md` je einmal:
Procedure, once each for `USER.md` and `SOUL.md`:
1. Das Template lesen. Seine Abschnitte **sind** der Fragenkatalog, in der Reihenfolge, in
der sie dort stehen.
2. Den Nutzer entlang dieser Abschnitte befragen - `USER.md`: Name, Standort, Zeitzone,
primäre Rolle (rein beruflich), beruflicher Kontext, Familie/Zuhause, Hobbys,
Technik-Umgebung, aktive Projekte, bewusste Grenzen. `SOUL.md`: Persona-Name, Identität,
Mission, Weltbild, Judgment-Default, Standard, Ehrlichkeit, Stimme, Ausschlüsse.
3. Die Antworten **wörtlich** übernehmen. Nicht deuten, nicht zu einer Erzählung
verdichten, nicht aus dem Gesprächsverlauf ableiten. Was der Nutzer nicht sagt, steht
nicht drin: einen Abschnitt lieber löschen als mit Plausiblem füllen.
4. Das Ergebnis als `USER.md` bzw. `SOUL.md` schreiben und die Sentinel-Zeile
(`wikitool:template-unfilled`) dabei entfernen. Die `.template`-Dateien bleiben liegen -
sie sind die Vorlage für den nächsten Export, nicht Abfall dieses Schritts.
1. Read the template. Its sections **are** the list of questions, in the order they appear.
2. Interview the user along those sections - `USER.md`: name, location, time zone, primary
role (professional only), professional context, family/home, hobbies, technical
environment, active projects, deliberate boundaries. `SOUL.md`: persona name, identity,
mission, worldview, judgment default, standard, honesty, voice, exclusions.
3. Take the answers **verbatim**. Do not interpret, do not compress into a narrative, do not
infer from the course of the conversation. What the user does not say does not go in:
better to delete a section than to fill it with something plausible.
4. Write the result as `USER.md` and `SOUL.md` respectively, removing the sentinel line
(`wikitool:template-unfilled`) in the process. The `.template` files stay where they are -
they are the source for the next export, not this step's leftovers.
Zwei Fragen, die der Nutzer beantwortet und nicht der Agent: **den Persona-Namen** und
**welche Themen bewusst draußen bleiben** (Arbeitgeber, Mandanten, Gesundheit - was auch
immer). Beides raten heißt, es falsch zu haben. Für den Namen bringt der Stack einen
Startpunkt mit - **Thoth**, weil Chemenu Thoths Hauptkultort ist und Schrift, Maß und
Gedächtnis die Rolle beschreiben, die ein kompiliertes Wiki ausfüllt. Der Vorschlag wird
genannt, nicht eingesetzt: gefragt wird trotzdem, und ein anderer Name gewinnt.
Two questions the user answers rather than the agent: **the persona name** and **which topics
deliberately stay out** (employer, clients, health - whatever they are). Guessing either
means getting it wrong. For the name the stack ships a starting point - **Thoth**, because
Chemenu is Thoth's principal cult site and writing, measure and memory describe the role a
compiled wiki fills. The suggestion is named, not applied: the question is asked anyway, and
a different name wins.
Was diese Dateien **nicht** sind: eine Instruktionsquelle und eine Quelle im Sinne von
Invariante 3. Sie ändern keine Regel aus [AGENTS.md](../AGENTS.md), und eine Nutzeraussage
wandert daraus nie ohne den normalen Quelle/Provenance-Prozess nach `kb/`.
What these files are **not**: a source of instructions, and a source in the sense of
invariant 3. They change no rule from [AGENTS.md](../AGENTS.md), and a user's statement never
travels from them into `kb/` without the normal source/provenance process.
`tools/wikitool doctor` prüft das Ergebnis in Schritt 13 (`personalization`): eine fehlende
Datei ist ein `FAIL`, eine, die noch den Sentinel trägt, ebenso - ein umbenanntes Template
ist kein ausgefülltes.
`tools/wikitool doctor` checks the result in step 13 (`personalization`): a missing file is a
`FAIL`, and so is one still carrying the sentinel - a renamed template is not a filled-in
one.
7. **Werkzeugumgebung anlegen** (Details: [bootstrap.md](bootstrap.md)):
7. **Create the tool environment** (details: [bootstrap.md](bootstrap.md)):
```bash
cd tools
@@ -197,67 +201,63 @@ bereit für den ersten `Ingest`.
cd ..
```
8. **Skills publizieren:**
8. **Publish the skills:**
```bash
tools/wikitool instructions sync
```
9. **Entscheidungspunkt - Umgebung festhalten.** Die Distribution bringt
`ENVIRONMENT.md.template` mit: Harness, publizierte Skills, erreichbare MCP-Server,
Connectoren, Git-Remotes, wo CI läuft. Konstanten, die eine Session sonst jedes Mal neu
erfragt.
9. **Decision point - record the environment.** The distribution ships
`ENVIRONMENT.md.template`: harness, published skills, reachable MCP servers, connectors, git
remotes, where CI runs. Constants a session would otherwise ask about every time.
Anders als Schritt 6 ist dieser Schritt **optional** und kein Interview. Was aus dem
Checkout selbst ablesbar ist (`git remote -v`, das laufende Harness, die eben publizierten
Skills), trägt der Agent ein; nach dem Rest fragt er einmal und akzeptiert "weiß ich nicht"
als Antwort - ein leerer Abschnitt wird gelöscht, nicht mit Plausiblem gefüllt. Beim
Schreiben die Sentinel-Zeile (`wikitool:template-unfilled`) entfernen; das `.template`
bleibt liegen.
Unlike step 6, this step is **optional** and not an interview. Whatever can be read off the
checkout itself (`git remote -v`, the running harness, the skills just published) the agent
fills in; for the rest it asks once and accepts "I don't know" as an answer - an empty
section is deleted, not filled with something plausible. Remove the sentinel line
(`wikitool:template-unfilled`) when writing; the `.template` stays where it is.
Wird der Schritt übersprungen, läuft alles weiter: `doctor` meldet in Schritt 13
`environment: absent (optional)`, kein `FAIL`. Die Datei ist gitignored und geht in keinen
Commit ein - sie beschreibt diesen Checkout, nicht das Repo.
If the step is skipped, everything still works: `doctor` reports
`environment: absent (optional)` in step 13, not a `FAIL`. The file is gitignored and enters
no commit - it describes this checkout, not the repo.
10. **Entscheidungspunkt - Telemetrie.** Der Default hängt am Installationsweg, nicht an
diesem Schritt: eine per `dist export` ausgelieferte Instanz - jede, die hier ankommt, ohne
Weg C (direkter Klon des Ursprungs-Repos) genommen zu haben - trägt eine
`.wikitool-release.json` und startet mit Telemetrie **aus**; niemand hat sie bestellt, und
`EVALS.md` liest ohnehin niemand, bevor die erste Datei geschrieben ist. Dieser Schritt
fragt nur, ob der Betreiber das umdrehen will.
10. **Decision point - telemetry.** The default follows the installation path, not this step: an
instance delivered via `dist export` - every instance that arrives here without having taken
route C (a direct clone of the origin repo) - carries a `.wikitool-release.json` and starts
with telemetry **off**; nobody asked for it, and nobody reads `EVALS.md` before the first
file is written anyway. This step only asks whether the operator wants to reverse that.
Den Nutzer einmal fragen: Telemetrie an? Falls ja, `.wikitool-telemetry.json` im
Repo-Root anlegen (pro Checkout, gitignored, kein `.template` - wie
`.wikitool-remotes.json`):
Ask the user once: telemetry on? If yes, create `.wikitool-telemetry.json` in the repo root
(per checkout, gitignored, no `.template` - like `.wikitool-remotes.json`):
```json
{ "enabled": true }
```
`max_session_bytes` (Default 5 MiB) und `keep_sessions` (Default 250) sind optional in
derselben Datei; die meisten Instanzen brauchen sie nicht anzufassen. Falls nein, nichts
tun - der Default steht bereits auf aus, und keine Datei entsteht. `WIKI_TRACE`
überschreibt beide Richtungen weiterhin, falls eine einzelne Session abweichen soll.
`max_session_bytes` (default 5 MiB) and `keep_sessions` (default 250) are optional in the
same file; most instances need not touch them. If no, do nothing - the default is already
off, and no file is created. `WIKI_TRACE` still overrides in both directions, should a
single session need to differ.
`tools/wikitool doctor` meldet das Ergebnis in Schritt 13 (`telemetry`): an/aus, warum
(Installationsform, diese Datei, oder `WIKI_TRACE`), und die aktuelle Menge gegen beide
Deckel - nie ein `FAIL`, in beide Richtungen ist das ein gültiger Zustand. Mehr dazu:
`tools/wikitool doctor` reports the result in step 13 (`telemetry`): on/off, why
(installation form, this file, or `WIKI_TRACE`), and the current volume against both caps -
never a `FAIL`, since both directions are a valid state. More on this:
[EVALS.md](../EVALS.md) § "Whether it runs at all".
11. **Session-Budget scopen** (Details: [session-setup.md](session-setup.md)):
11. **Scope the session budget** (details: [session-setup.md](session-setup.md)):
```bash
export WIKITOOL_SESSION_ID="wiki-$(date +%s)"
```
12. **Generierte Indizes erzeugen** - `dist export` liefert sie bewusst nicht mit:
12. **Build the generated indexes** - `dist export` deliberately does not ship them:
```bash
tools/wikitool index rebuild
tools/wikitool sources rebuild-index
```
13. **Verifizieren**, in dieser Reihenfolge:
13. **Verify**, in this order:
```bash
tools/wikitool doctor
@@ -266,32 +266,32 @@ bereit für den ersten `Ingest`.
tools/wikitool lint
```
`doctor` muss ohne `FAIL` durchlaufen, bevor es weitergeht - ein `WARN` (z. B. kein Remote,
keine `WIKITOOL_SESSION_ID`) ist kein Blocker. Ein `FAIL` benennt sein eigenes Fix-Kommando;
das ausführen und `doctor` erneut aufrufen.
`doctor` must run through without a `FAIL` before anything continues - a `WARN` (no remote,
no `WIKITOOL_SESSION_ID`, say) is not a blocker. A `FAIL` names its own fix command; run it
and call `doctor` again.
14. **Ersten Commit anstoßen:**
14. **Make the first commit:**
```bash
tools/wikitool publish --message "chore: initial instance setup"
```
Das Mass-Update-Gate greift hier erwartungsgemäß: eine frische Distribution besteht aus weit
mehr als den zehn gezählten Dateien, die den Schwellwert auslösen, also endet der Aufruf mit
Exit-Code 42. Die Ausgabe dem Nutzer **vollständig zeigen** und warten; sie enthält die
Dateiliste und die exakte `--confirm <token>`-Zeile, die nach seiner Freigabe
veröffentlicht. Details zum Gate: [gates.md](gates.md).
The Mass-Update Gate fires here as expected: a fresh distribution consists of far more than
the ten counted files that trip the threshold, so the call ends with exit code 42. Show the
output to the user **in full** and wait; it contains the file list and the exact
`--confirm <token>` line that publishes once they approve. Details on the gate:
[gates.md](gates.md).
15. **Agent-Session neu starten.** Harnesses lesen die Skill-Verzeichnisse beim Start; erst
danach sind `wiki-ingest`, `wiki-query`, `wiki-manage`, `wiki-lint` und `wiki-status`
verfügbar.
15. **Restart the agent session.** Harnesses read the skill directories at startup; only
afterwards are `wiki-ingest`, `wiki-query`, `wiki-manage`, `wiki-lint` and `wiki-status`
available.
## Scope
Gilt nur für eine per `dist export` erzeugte, leere Distribution. Für einen bestehenden Clone
dieses Quell-Repos siehe [bootstrap.md](bootstrap.md) - dort existieren Git-Repo, Autor und
Inhalt bereits, und nur die Werkzeugumgebung (Schritt 7) plus die Skills (Schritt 8) fehlen.
Applies only to an empty distribution produced by `dist export`. For an existing clone of this
source repo see [bootstrap.md](bootstrap.md) - there the git repo, author and content already
exist, and only the tool environment (step 7) plus the skills (step 8) are missing.
Eine Ausnahme: Schritt 6 (Personalization) gilt auch für einen bestehenden Clone, der noch
kein `USER.md`/`SOUL.md` hat - dort als einzelner nachgeholter Schritt, nicht als ganzer
Ablauf. `bootstrap.md` verweist dafür hierher.
One exception: step 6 (personalization) also applies to an existing clone that has no
`USER.md`/`SOUL.md` yet - there as a single catch-up step, not as a whole procedure.
`bootstrap.md` points here for it.
+16 -16
View File
@@ -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
`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
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
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
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
`mcp-upload/<id>/`, a quarantine no command in this step reads. A reviewer promotes it first
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.
**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 -
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;
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
@@ -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.
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.
```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
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
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:
[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,
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.
7. **Create or update entity pages.** Read
[kb/entities/COLLECTION.md](../../kb/entities/COLLECTION.md) and
[kb/CONTRACT.md](../../kb/CONTRACT.md) plus
[kb/CONVENTIONS.md](../../kb/CONVENTIONS.md) first - the second is where provenance and
`kb/entities/COLLECTION.md` and
`kb/CONTRACT.md` plus
`kb/CONVENTIONS.md` first - the second is where provenance and
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
@@ -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
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
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
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>`.
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.
- **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:
[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
`provenance: mixed` and put it under `## General Guidance (unsourced)`.
- **`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;
see [gates.md](../gates.md).
- **A gate or the loop-breaker refuses anything?** Stop and follow [gates.md](../gates.md).
see `instructions/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
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
+5 -5
View File
@@ -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
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
@@ -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**
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`
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
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
@@ -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
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 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.
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.
- **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
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)
**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.
## Output
+9 -9
View File
@@ -11,11 +11,11 @@ catalog and the audit log in sync.
**Trigger:** User requests a new entity/concept/comparison page, or new information needs
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
[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
`COLLECTION.md`, which carries its quality goal and what is local to that subtree. Field-level
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.
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
"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:`.
7. **Close out.** [publish-cycle.md](../publish-cycle.md), `--op create`.
7. **Close out.** `instructions/publish-cycle.md`, `--op create`.
## Updating a page
@@ -84,11 +84,11 @@ requirements come from `tools/wikitool types describe <type>`.
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
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.
## 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.
The collection contracts draw the line.
- **`publish` refused?** A single page is normally well under the threshold. If it trips,
[gates.md](../gates.md).
`instructions/gates.md`.
## wikitool commands used
@@ -106,7 +106,7 @@ page, so none of it is a file operation.
`sources rebuild-index`, `index rebuild`, `log append`, `publish`
`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
+12 -9
View File
@@ -9,7 +9,7 @@ description: Answer a question using the LLM wiki's compiled knowledge - read-on
**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
page while answering. Two exceptions, both mechanical: step 6 (filing a valuable answer through
@@ -34,19 +34,22 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
```bash
tools/wikitool search "backup" --kind entity --subtype system
tools/wikitool search --field entity_type=system --field '!sources' --sort -modified
tools/wikitool search --field tags=k8s --limit 30
tools/wikitool search "Longhorn" --matches # show the matching lines
tools/wikitool search --field tags=k8s --limit 0 # a sweep: every match, not the first 50
tools/wikitool search "Longhorn" --matches # show the matching lines
```
`search` is read-only and exempt from the iteration budget, so searching again is always
cheaper than reading more.
cheaper than reading more. A result that hit `--limit` says so and names the total, so read
the last line before treating a list as the whole answer - and do not grep `kb/` yourself,
per AGENTS.md § Routing.
3. **Read only the pages the search points at**, then follow their `related:` and `sources:`
entries. Check `kb/sources/` when the question is about what a specific source said.
3. **Read only the pages the search points at** - each hit carries its full path - then follow
their `related:` and `sources:` entries. Check `kb/sources/` when the question is about what
a specific source said.
4. **Answer and cite.** Name the wiki pages the answer came from, and the sources behind them.
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,
then hold each one on its own against all three criteria: the answer required synthesis
@@ -75,9 +78,9 @@ invariant - rather than synthesizing a plausible-sounding answer from general kn
exist under different words. Then say the wiki has no confident source, and offer to ingest
one.
- **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
[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
to pass the gate has not been cleared by it.
+2 -2
View File
@@ -10,14 +10,14 @@ semantic review a lint pass does.
**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 (§ Scope there).
**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`
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
`wiki-manage` instead of fixing it here.
+18 -5
View File
@@ -40,11 +40,24 @@ those regions and nothing else. Nothing matches on this text.
## Language
Pages are written in **German**. This binds `kb/` and the authoring surface that shapes it -
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
`types/comparison.md`. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the
control plane stays English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and
the type-specs for non-page artifacts.
Pages are written in **German** - the `language:` in this file's own frontmatter, and the one
place that value is written down. This binds `kb/`, and inside the page type-specs
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`) exactly the
parts that become page text: each one's `## Template` block, and the `layout:` titles that head a
catalog section. Their authoring guidance around those is instruction to an agent, so it follows
the control plane and stays English - the same prose/identifier cut
[kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a page, applied one level up.
`raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)).
Two things follow from that value rather than being decided here, both stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
instance writes its pages in, and an agent *speaks* the language named above.
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
above is the only language value in the tree, and what it binds is page text; a control-plane
document is English even when this instance wrote it for itself and never ships it. Why that is
an architecture decision rather than an unset parameter:
[docs/language-boundaries.md](../docs/language-boundaries.md).
Which line is prose and which is an identifier - and therefore what is translated at all - is
the contract's rule, not this file's: see
+20 -6
View File
@@ -27,12 +27,26 @@ marker pair, so a rename re-renders words and nothing else.
## Language
Pages are written in **{language}**. This binds `kb/` and the authoring surface that shapes it -
the page type-specs `types/entity.md`, `types/concept.md`, `types/source.md` and
`types/comparison.md`, whose `## Template` blocks are the body skeleton every new page starts
from. `raw/` is untouched ([raw/CONTRACT.md](../raw/CONTRACT.md)), and the control plane stays
English: `AGENTS.md`, the stage contracts, this file, `instructions/`, and the type-specs for
non-page artifacts.
Pages are written in **{language}** - the `language:` in this file's own frontmatter, and the
one place that value is written down. This binds `kb/`, and inside the page type-specs
(`types/entity.md`, `types/concept.md`, `types/source.md`, `types/comparison.md`) exactly the
parts that become page text: each one's `## Template` block - the body skeleton every new page
starts from - and the `layout:` titles that head a catalog section. Their authoring guidance
around those is instruction to an agent, so it follows the control plane and stays English - the
same prose/identifier cut [kb/CONTRACT.md](CONTRACT.md#language-and-identifiers) makes inside a
page, applied one level up. Adopting this template into a non-English instance therefore means
translating those blocks, not the whole file. `raw/` is untouched
([raw/CONTRACT.md](../raw/CONTRACT.md)).
Two things follow from that value rather than being decided here, both stated once in
[AGENTS.md § File naming](../AGENTS.md#file-naming): the control plane stays English whatever an
instance writes its pages in, and an agent *speaks* the language named above.
The first of those is not a setting this file withholds - it is not a setting at all. `language:`
above is the only language value in the tree, and what it binds is page text; a control-plane
document is English even when this instance wrote it for itself and never ships it. Why that is
an architecture decision rather than an unset parameter:
[docs/language-boundaries.md](../docs/language-boundaries.md).
Which line is prose and which is an identifier - and therefore what is translated at all - is
the contract's rule, not this file's: see
+21 -22
View File
@@ -35,32 +35,31 @@ tone, relationship labels, the confidence rubric. Neither is restated here.
## Types offered
`concept` (`tools/wikitool types describe concept`). Das Feld `concept_type:`
wählt die Area:
`concept` (`tools/wikitool types describe concept`). The `concept_type:` field
picks the area:
| Area | Hält |
|------|------|
| `architectures/` | Aufbau und Struktur: wie ein System geschnitten ist und warum die Schnitte dort liegen |
| `patterns/` | Wiederverwendbare Lösungsformen, die über mehr als einen Gegenstand hinweg gelten |
| `protocols/` | Kommunikationsprotokolle und Standards, in ihrer üblichen Schreibweise benannt |
| `workflows/` | Abläufe und Prozesse, die projektübergreifend wiederkehren |
| `decisions/` | Architektur- und Entwurfsentscheidungen (siehe unten) |
| `problems/` | Wiederkehrende Problemstellungen und ihre Lösungsansätze |
| Area | Holds |
|------|-------|
| `architectures/` | Shape and structure: how a system is cut up, and why the cuts fall where they do |
| `patterns/` | Reusable solution shapes that hold across more than one subject |
| `protocols/` | Communication protocols and standards, named in their usual spelling |
| `workflows/` | Procedures and processes that recur across projects |
| `decisions/` | Architectural and design decisions (see below) |
| `problems/` | Recurring problems and the approaches taken to them |
Das sind Areas, keine Collections: sie erben diesen Contract und tragen keine
eigene `COLLECTION.md`.
These are areas, not collections: they inherit this contract and carry no
`COLLECTION.md` of their own.
Die Zuordnung trifft niemand von Hand — sie steht als `layout:` in
`types/concept.md`, und `wikitool new` legt eine neue Seite direkt dort ab.
Eine Seite, die anderswo liegt, meldet `wikitool lint` als *misplaced*;
`wikitool move --page "<Titel>"` bringt sie an ihren berechneten Ort.
Nobody assigns them by hand — the mapping is the `layout:` in
`types/concept.md`, and `wikitool new` puts a new page straight there. A page
sitting anywhere else is reported by `wikitool lint` as *misplaced*;
`wikitool move --page "<title>"` moves it to its computed location.
Die Aufteilung ist keine Geschmacksfrage, sondern das, was die Shard-Schwelle
des Katalogs überhaupt wirksam macht: `index rebuild` teilt **pro Area**, und
eine Collection ohne Areas teilt sich niemit 80 Seiten in einer einzigen
Tabelle war die Schwelle hier ein toter Wert. Keine der sechs
Areas liegt derzeit über der Schwelle, also bekommt auch keine einen eigenen
Shard; wächst eine hinein, passiert das ohne Zutun.
The split is not a matter of taste but what makes the catalog's shard threshold
effective at all: `index rebuild` splits **per area**, and a collection without
areas never splitswith 80 pages in a single table the threshold was a dead
value here. None of the six areas is currently above it, so none gets a shard of
its own; when one grows into it, that happens without anyone acting.
## Decisions
+26 -28
View File
@@ -26,38 +26,36 @@ renamed or dropped - its authoring rules below are the instance's, its existence
## Types offered
`source` (`tools/wikitool types describe source`). Page titles carry the `Source - ` prefix,
applied automatically by `wikitool new source`. Das Feld `source_type:` wählt die Area - **ohne
Default**: `wikitool new source` verweigert ohne einen expliziten Wert.
applied automatically by `wikitool new source`. The `source_type:` field picks the area - **with
no default**: `wikitool new source` refuses without an explicit value.
| Area | Hält |
|------|------|
| `transcripts/` | Session-Transkripte: mitgeschriebener Dialog zwischen Mensch und Agent, oder zwischen Menschen |
| `analyses/` | Analyse-Output eines Modells über einen Gegenstand - kein Dialog, kein Protokoll, sondern eine eigenständige Einschätzung |
| `articles/` | Externe Artikel und Blogposts, mit `source_url:` |
| `documents/` | Eingelesene Dokumente, Handbücher, Spezifikationen |
| `notes/` | Echte eigene Notizen ohne Dialogform - Cheat Sheets, Merkzettel |
| `trackers/` | Exporte aus einem Issue-Tracker oder vergleichbaren System |
| `unclassified/` | Sichtbares Fach für eine Quelle, deren Kategorie noch nicht feststeht - beratender `lint`-Befund, kein Sammelbecken. Es wieder zu leeren, oder das Enum um einen neuen Wert zu erweitern: [instructions/evolve-subtypes.md](../../instructions/evolve-subtypes.md) |
| Area | Holds |
|------|-------|
| `transcripts/` | Session transcripts: recorded dialogue between a human and an agent, or between humans |
| `analyses/` | A model's analytical output about a subject - not dialogue, not a record, but an assessment in its own right |
| `articles/` | External articles and blog posts, with `source_url:` |
| `documents/` | Ingested documents, manuals, specifications |
| `notes/` | Genuinely own notes in no dialogue form - cheat sheets, reminders |
| `trackers/` | Exports from an issue tracker or comparable system |
| `unclassified/` | The visible slot for a source whose category is not settled yet - an advisory `lint` finding, not a dumping ground. Emptying it again, or extending the enum by a new value: [instructions/evolve-subtypes.md](../../instructions/evolve-subtypes.md) |
Das sind Areas, keine Collections: sie erben diesen Contract und tragen keine eigene
`COLLECTION.md`. Die Zuordnung trifft niemand von Hand - sie steht als `layout:` in
`types/source.md`, und `wikitool new` legt eine neue Seite direkt dort ab. Eine Seite, die
anderswo liegt, meldet `wikitool lint` als *misplaced*; `wikitool move --page "<Titel>"` bringt
sie an ihren berechneten Ort.
These are areas, not collections: they inherit this contract and carry no `COLLECTION.md` of
their own. Nobody assigns them by hand - the mapping is the `layout:` in `types/source.md`, and
`wikitool new` puts a new page straight there. A page sitting anywhere else is reported by
`wikitool lint` as *misplaced*; `wikitool move --page "<title>"` moves it to its computed
location.
**`analysis` gegen `document`:** die Unterscheidung läuft über die Autorschaft, nicht über den
Inhalt. Ein Modell, das über einen Gegenstand urteilt oder ihn zusammenfasst, ohne dass ein
Mensch oder eine Organisation dafür geradesteht, ist `analysis` - unabhängig davon, wie
artikelförmig der Text wirkt. Ein Handbuch, eine Spezifikation, eine Herstellerdoku ist
`document`, auch wenn ein Werkzeug sie generiert hat, solange eine Organisation die Aussage
verantwortet. Die Frage ist also "wer haftet für die Behauptung", nicht "wie liest sich der
Text".
**`analysis` versus `document`:** the distinction runs on authorship, not on content. A model
judging or summarizing a subject with no human or organization answering for it is `analysis` -
however article-shaped the text looks. A manual, a specification, a vendor document is
`document`, even where a tool generated it, as long as an organization is accountable for what
it says. The question is "who is liable for the claim", not "how does the text read".
Solange es diesen Default noch gab, fiel fast alles hierher in `notes/`, weil
`types/source.schema.yaml` `notes` als `default:` gesetzt hatte - der Compiler wählte das
Sammelbecken, sobald niemand widersprach.
22 der 29 damaligen Seiten waren tatsächlich Transkripte, Analysen oder Tracker-Exporte und
wurden per `wikitool touch --set source_type=…` umklassifiziert, bevor die Areas entstanden.
While that default still existed, nearly everything landed here in `notes/`, because
`types/source.schema.yaml` had `notes` as its `default:` - the compiler picked the dumping
ground whenever nobody objected. 22 of the 29 pages there at the time were in fact transcripts,
analyses or tracker exports, and were reclassified with `wikitool touch --set source_type=…`
before the areas existed.
## Provenance rules
+13 -11
View File
@@ -124,7 +124,7 @@ tools/wikitool <command> --help
| Command | Purpose |
|---------|---------|
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. Results carry kind/summary so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
### Provenance
@@ -166,12 +166,12 @@ tools/wikitool <command> --help
| Command | Purpose |
|---------|---------|
| `types list [--json]` | List every type-spec under `types/` (name, schema path, subtype field, description) - discover what page types exist without reading `types/*.md` directly |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body |
| `types describe <name> [--json]` | Print one type's full contract: required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body - composed with the stack-owned `types/<name>.guidance.md` where the type-spec declares `guidance:` (`--json` reports it separately as `guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract reads as one answer even though it may live in two files. A type-spec (or its guidance file) over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise |
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
| `instructions 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 |
| `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 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 verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page. Computed from those categories rather than listed, so a file added later is in scope without a code change. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy |
### Telemetry
@@ -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 check [--url U] [--timeout S] [--json]` | Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current\|update\|migration\|ahead`). **The only command in `wikitool` that makes a network call** - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is `$WIKITOOL_UPDATE_URL`, else the release stamp's, else the built-in origin; `$WIKITOOL_UPDATE_TOKEN` is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate |
| `version notes [--version X.Y.Z]` | Print one version's `CHANGES.md` entry, for use as release notes (default: this tree's `VERSION`). Read-only and exempt from the budget gate |
| `version bump --major\|--minor\|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--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 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 bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all |
| `version 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
@@ -378,9 +379,9 @@ is atomic, and whether a retry is safe.
| `types list` | Never fails | Read-only | Safe to retry freely |
| `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 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 |
| `docs verify` | A command, contract, or type-form mismatch was found, a shipped `.md`/`.template` cites an issue number, or a reference file's table-of-contents region is missing or stale | Read-only | Fix the documentation it names, then re-run. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region |
| `docs verify` | A command, contract, or type-form mismatch was found, a type-spec's own frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file | Read-only | Fix the documentation it names, then re-run. For a type-spec's own frontmatter: fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name |
| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again |
### 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 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 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 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 bump` | More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an unknown `--impact`, a missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, an escalation to a boundary crossing without `--breaking` or with neither a migration document nor `--no-migration`, `--breaking`/`--no-migration` on a bump that crosses nothing, or `--migration-required` combined with `--no-migration`, on a bump with no running candidate, with no `--no-migration` line to retract, or without a migration document already targeting the new base | No - `VERSION` then `CHANGES.md` | **Not idempotent**: a second run escalates or continues the candidate again. If the outcome is uncertain, read `VERSION` and the top of `CHANGES.md` before retrying |
| `version regrade` | A missing `VERSION`/`CHANGES.md`, `VERSION` and the changelog's newest entry naming different versions, a topmost entry with no bump list, an index outside the rendered list's range, indices given without `--impact`, or an unknown `--impact` | No - `CHANGES.md` only, and only when indices are given | The bare listing never writes anything. A write is **not idempotent** against a changed list: re-running the same indices after a first success regrades whatever is at those positions *now*, which may no longer be the same bumps - list again before retrying |
| `version 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
+10 -5
View File
@@ -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
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
newly fails on content an instance already had, anything needing hand-work
after the copy). Content migration is one way to land there, 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
newly fails on **shipped content an instance already had** (a `kb/` page,
an `instructions/*.md` file), anything needing hand-work after the copy).
A new command that is merely pickier about its *own* fresh input - a flag
it did not previously accept, a write it now refuses without more from the
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`.
A new command reaches every future instance, and CI's version gate refuses a
stack change that moved no version.
+10 -5
View File
@@ -38,7 +38,7 @@ from chemenu.lint_core import run_lint
from chemenu.search import filters
from chemenu.search.registry import resolve
from chemenu.search.service import run_search, unreadable_pages
from chemenu.search.types import Predicate, SearchQuery
from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery
from chemenu.types_core import describe_type, list_types
# Distinguishes "the caller did not pass a revision" from "the caller passed
@@ -120,7 +120,7 @@ class Corpus:
text: Optional[str] = None,
predicates: Iterable[str] = (),
regex: bool = False,
limit: int = 20,
limit: int = DEFAULT_LIMIT,
sort: Optional[str] = None,
backend: Optional[str] = None,
) -> dict[str, Any]:
@@ -142,13 +142,18 @@ class Corpus:
with self._rooted():
pages, revision = self._cache.load()
hits = run_search(query, pages, backends, self.kb_dir)
result = run_search(query, pages, backends, self.kb_dir)
return self._stamp({
"query": text,
"predicates": [p.render() for p in parsed],
"backend": ",".join(b.name for b in backends),
"count": len(hits),
"results": [hit.as_dict() for hit in hits],
# Same shape the CLI's `--json` prints: `count` is what came back,
# `total` is how many matched before `limit` cut it.
"count": len(result.hits),
"total": result.total,
"truncated": result.truncated,
"limit": result.limit,
"results": [hit.as_dict() for hit in result.hits],
"unreadable": unreadable_pages(pages),
}, revision)
+46 -7
View File
@@ -308,9 +308,17 @@ def instance_owned_type_stems() -> set[str]:
The line is `root:`, and it was already in the frontmatter before anyone
drew it: `root: kb` means the type describes a page the instance writes, so
its prose, its template and its language are the instance's business.
Anything else - `instruction` (`root: repo`), `lint-report` (no `base_dir`
at all), `type-spec` itself - describes a stack artifact and ships verbatim.
the file is the instance's to change. Anything else - `instruction`
(`root: repo`), `lint-report` (no `base_dir` at all), `type-spec` itself -
describes a stack artifact and ships verbatim.
Ownership, not language. What such a file is *written in* is decided by who
reads each half, not by who owns the file: its `## Template` block and its
`layout:` titles become page text and follow `kb/CONVENTIONS.md`, while the
authoring guidance around them addresses an agent and stays English like the
rest of the control plane (AGENTS.md § File naming, types/type-spec.md
§ Who owns a type-spec). That the two halves share one file, and what it
costs, is docs/ownership-and-templates.md § Where the file boundary strains.
Read from `types/` rather than listed, so an instance adding its own page
type gets the same treatment without a code change.
@@ -330,6 +338,32 @@ def instance_owned_type_stems() -> set[str]:
return stems
# The suffix a type-spec's own two files carry - `<stem>.md` and
# `<stem>.schema.yaml` - as opposed to a sibling file that merely starts with
# the same stem, such as `<stem>.guidance.md` (Gitea #104). Checked as an
# exact suffix rather than by splitting on the first `.`, which is what let
# `entity.guidance.md` be mistaken for the `entity` type-spec's own file
# before this existed - a stack-owned file re-keyed as though it were the
# instance's `.template` to adopt, and flagged as a leak by the other call
# site for not being one.
_TYPE_SCHEMA_SUFFIX = ".schema.yaml"
def _owned_type_stem(relative: str) -> Optional[str]:
"""The type stem `relative` (a path under `types/`, no `.template`
suffix) names, if it is exactly that type-spec's own `<stem>.md` or
`<stem>.schema.yaml` - `None` for anything else under `types/`,
including a `<stem>.guidance.md` file. `_plan_types()` and `find_leaks()`
both ask this instead of computing their own stem, so the two answer the
same question about the same path (AGENTS.md invariant 8)."""
name = relative.rsplit("/", 1)[-1]
if name.endswith(_TYPE_SCHEMA_SUFFIX):
return name[: -len(_TYPE_SCHEMA_SUFFIX)]
if name.endswith(".md") and not name.endswith(".guidance.md"):
return name[: -len(".md")]
return None
def _plan_types() -> dict[str, PlannedFile]:
"""`types/`, with the page type-specs re-keyed as templates.
@@ -339,6 +373,11 @@ def _plan_types() -> dict[str, PlannedFile]:
to be adopted before it counts. A type-spec's `.schema.yaml` travels with
it, because the two are one type (see types/type-spec.md § Anatomy) and
adopting half of it would leave a spec validated by a file it does not own.
A type-spec's optional `<name>.guidance.md` (Gitea #104) is the opposite:
stack-owned even where the type-spec itself is instance-owned, and ships
verbatim beside the `.template` - `_owned_type_stem` is what keeps it out
of this re-keying despite sharing the type-spec's own stem.
"""
plan = _copy_tree(config.TYPES_DIR, "types", frozenset())
stems = instance_owned_type_stems()
@@ -347,9 +386,8 @@ def _plan_types() -> dict[str, PlannedFile]:
rekeyed: dict[str, PlannedFile] = {}
for relative, planned in plan.items():
name = relative.rsplit("/", 1)[-1]
stem = name.split(".", 1)[0]
if stem in stems:
stem = _owned_type_stem(relative)
if stem is not None and stem in stems:
rekeyed[f"{relative}.template"] = planned
else:
rekeyed[relative] = planned
@@ -504,7 +542,8 @@ def find_leaks(plan: dict[str, PlannedFile]) -> list[str]:
elif (
relative.startswith("types/")
and not relative.endswith(".template")
and name.split(".", 1)[0] in owned_types
and (owned_stem := _owned_type_stem(relative)) is not None
and owned_stem in owned_types
):
leaks.append(f"{relative} (this instance's page type-spec; ship the .template)")
elif relative.startswith("instructions/dev/"):
+138 -3
View File
@@ -32,6 +32,27 @@ A sixth checks a *reference* rather than a copy: no document `dist export`
ships may cite an issue number, because the board those numbers live on
exists only in the origin repo.
A seventh checks the other half of the same reference problem: every relative
markdown link in a reference file - `toc.target_files()`'s scope, the same one
the table-of-contents check uses - must resolve to a file that actually
exists. A link with the wrong `../` count is invisible to every check above:
it is present, it names an existing command or contract by title, and nothing
renders it to notice the target is unreachable. The complementary half - that
`instructions/<name>/SKILL.md` never carries a relative markdown link at all,
because `instructions sync` copies it to a different depth than its links
assume - is `instructions verify`'s job, not this one, since that module
already owns the Skill/Instruction split (`skill_dirs()` vs
`instruction_files()`).
An eighth checks the type layer against its own schema: every file under
`types/` declaring `type: types/type-spec.md` must validate against
`types/type-spec.schema.yaml`. Before this check existed the schema had
already drifted behind two fields real type-specs carry (`root:`,
`capture_fields:`) while `additionalProperties: false` sat there describing a
contract nothing enforced - the exact "checked or absent" failure this file's
opening paragraph names, just one level up, for the schema that describes the
type layer instead of a copy the type layer's code produces (Gitea #105).
Everything here is a hard oracle: a set comparison or a regex, no judgment.
Content quality of the contracts themselves stays with the LLM.
"""
@@ -44,7 +65,7 @@ from typing import Optional
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._util import fail, rel_path, success
@@ -410,6 +431,37 @@ def check_stack_required_types() -> list[str]:
return issues
def check_type_spec_frontmatter() -> list[str]:
"""Every type-spec's own frontmatter must validate against
`types/type-spec.schema.yaml` - the schema that describes the type layer
gets the same enforcement any other type's schema gets (Gitea #105).
Before this check nothing ever called `validate_frontmatter` against a
type-spec's own frontmatter, so the schema had quietly drifted behind two
fields real type-specs actually carry (`root:`, `capture_fields:`)
without anything failing - `additionalProperties: false` described a
contract that bound nothing. `resolver.list_type_specs()` already reads
every file's frontmatter once for `wikitool types list`; reusing it here
means this check costs no second parse pass.
"""
from chemenu.type_resolver import resolver
issues: list[str] = []
for type_path, frontmatter in resolver.list_type_specs():
try:
resolver.validate_frontmatter(
frontmatter, "types/type-spec.md", source_file=config.ROOT / type_path
)
except ValueError as exc:
# `validate_frontmatter`'s own message names the type path it
# validated *against* (always `types/type-spec.md` here, since
# every type-spec is validated against the same schema) rather
# than the specific file that failed - prefix that file's own
# path so two failures in one run stay distinguishable.
issues.append(f"{type_path}: {exc}")
return issues
def check_legacy_type_blocks() -> list[str]:
issues = []
guarded = [
@@ -452,6 +504,83 @@ def check_toc_regions() -> list[str]:
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]:
"""Every README that must not carry a copy of the command table.
@@ -768,11 +897,12 @@ def check_breaking_change_for_boundary() -> list[str]:
@app.command("verify")
def verify():
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
"""Check the CLI/README command tables, contract presence, type-form drift, every type-spec's frontmatter against its own schema, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents."""
issues = (
check_cli_readme()
+ check_readmes_have_no_command_table()
+ check_collection_contracts()
+ check_type_spec_frontmatter()
+ check_legacy_type_blocks()
+ check_ignored_content()
+ check_version_changelog()
@@ -780,18 +910,23 @@ def verify():
+ check_breaking_change_for_boundary()
+ check_no_issue_references()
+ check_toc_regions()
+ check_reference_targets()
)
if issues:
fail("Documentation issues found:\n" + "\n".join(f"- {i}" for i in issues))
from chemenu.type_resolver import resolver
success(
f"Docs verified: {len(registered_commands())} command(s) documented, "
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
f"{len(resolver.list_type_specs())} type-spec(s) validating against their own schema, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"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"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
)
+62 -6
View File
@@ -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
than reporting an error when *every* copy is missing, because that is the
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
@@ -31,8 +44,8 @@ from pathlib import Path
import typer
import yaml
from chemenu import config
from chemenu.commands import dist_cmd
from chemenu import config, markdown_code
from chemenu.commands import dist_cmd, docs_verify
from chemenu.commands._util import fail, rel_path, success
from chemenu.type_resolver import resolver
@@ -313,6 +326,45 @@ def dev_only_forbidden_references(instructions_dir: Path | None = None) -> set[s
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")
def sync(
force: bool = typer.Option(
@@ -351,7 +403,7 @@ def sync(
@app.command("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()
instructions = instruction_files()
if not sources and not instructions:
@@ -398,7 +450,11 @@ def verify():
if not frontmatter.get("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.
expected = len(sources) * len(target_dirs())
missing = 0
@@ -419,7 +475,7 @@ def verify():
if missing and not bootstrap_needed:
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
# haystack: that instruction must not be linked from AGENTS.md or a
# 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."
)
# 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
# around in a distributed instance. See dev_only_forbidden_references's
# docstring for the dist:strip exemption.
+9 -1
View File
@@ -125,7 +125,15 @@ def is_exempt(command: str, args: list[str]) -> bool:
if command in SKIP_COMMANDS:
return True
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:
+69 -14
View File
@@ -35,7 +35,7 @@ from chemenu.search.service import (
sort_hits,
unreadable_pages,
)
from chemenu.search.types import Predicate, SearchHit, SearchQuery
from chemenu.search.types import DEFAULT_LIMIT, Predicate, SearchQuery, SearchResult
# Re-exported so `from chemenu.commands.search import run_search` keeps
# resolving. The core lives in `chemenu/search/service.py`, which imports no
@@ -49,32 +49,76 @@ __all__ = [
"search_command",
]
TITLE_WIDTH = 34
SUMMARY_WIDTH = 84
# One hit per line, ` | `-separated, in the order score, kind, title, path,
# summary. Three properties are load-bearing and should survive any edit here:
#
# 1. **The path is present.** It was not, and the instructions that drive this
# command tell an agent to "read only the pages the search points at" - which
# it could not do, because nothing here pointed anywhere. What a session did
# instead was run `grep -rl` over `kb/` for the filenames, a second search
# that can find no page this one missed (the backend *is* `rg` over `kb/`).
# 2. **Title and path are never truncated.** The title is the wiki's only
# identifier for a page (AGENTS.md invariant 2) and the argument `xref add`,
# `cite add` and `touch` all take; a title clipped to a column width is not
# one. The old fixed 34-char field clipped four of five hits in the report
# that prompted this. Only the summary is lossy, which is why it goes last.
# 3. **The separator is unambiguous.** A `|` cannot occur in a title - the
# wikilink syntax reserves it, so a page carrying one could not be linked at
# all - and a `|` in the summary is harmless, because the summary is the
# final field: split on " | " with maxsplit=4 and prose cannot shift a
# column.
#
# Column padding is gone with the widths: it aligned the table for an eye, and
# the reader here is an agent that pays for the spaces by the token.
SEPARATOR = " | "
def _truncate(text: str, width: int) -> str:
text = " ".join(text.split())
return text if len(text) <= width else text[: width - 1] + "\u2026"
def render_table(hits: list[SearchHit], show_matches: bool) -> str:
if not hits:
def _count_line(result: SearchResult) -> str:
"""The last line: how many hits, and whether that is all of them.
A bare `N result(s).` reads as the whole answer, so it is only used when it
is one. A capped search says what it capped, which is the number the caller
would otherwise have to run a second, unlimited search to learn.
"""
if not result.truncated:
return f"{len(result.hits)} result(s)."
return (
f"{len(result.hits)} of {result.total} result(s) - "
f"raise --limit (0 for all) or narrow the query."
)
def render_table(result: SearchResult, show_matches: bool) -> str:
if not result.hits:
return "No matches."
lines = []
for hit in hits:
for hit in result.hits:
kind = hit.kind or "?"
if hit.subtype:
kind = f"{kind}/{hit.subtype}"
lines.append(
f"{hit.score:6.1f} {_truncate(hit.title, TITLE_WIDTH):<{TITLE_WIDTH}} "
f"{kind:<18} {_truncate(hit.summary, SUMMARY_WIDTH)}"
SEPARATOR.join(
(
f"{hit.score:.1f}",
kind,
hit.title,
hit.path,
_truncate(hit.summary, SUMMARY_WIDTH),
)
)
)
if show_matches:
for match in hit.matches:
lines.append(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}")
lines.append(f" {hit.path}:{match.line}: {_truncate(match.text, 100)}")
lines.append("")
lines.append(f"{len(hits)} result(s).")
lines.append(_count_line(result))
return "\n".join(lines)
@@ -99,7 +143,11 @@ def search_command(
regex: bool = typer.Option(
False, "--regex", help="Treat the query as a regex. Off by default: terms are literal."
),
limit: int = typer.Option(20, "--limit", help="Maximum number of results. 0 for no limit."),
limit: int = typer.Option(
DEFAULT_LIMIT,
"--limit",
help="Maximum number of results. 0 for no limit. A capped result says so.",
),
sort: str = typer.Option(
None, "--sort", help="Sort by a result field; prefix with '-' to reverse, e.g. -modified."
),
@@ -146,7 +194,7 @@ def search_command(
pages = load_pages_by_path()
try:
hits = run_search(query, pages, backends)
result = run_search(query, pages, backends)
except PredicateError as exc:
fail(str(exc))
except RipgrepMissing as exc:
@@ -162,8 +210,15 @@ def search_command(
"query": text,
"predicates": [p.render() for p in predicates],
"backend": ",".join(b.name for b in backends),
"count": len(hits),
"results": [hit.as_dict() for hit in hits],
# `count` keeps its meaning - how many results are in this payload -
# so a consumer written against the old shape reads the same number
# it always did. `total`/`truncated`/`limit` are what it could not
# ask before.
"count": len(result.hits),
"total": result.total,
"truncated": result.truncated,
"limit": result.limit,
"results": [hit.as_dict() for hit in result.hits],
# Always present, usually empty. A caller that has to look for the
# key to learn whether it should worry will not look.
"unreadable": unreadable,
@@ -171,7 +226,7 @@ def search_command(
typer.echo(json.dumps(payload, indent=2))
return
typer.echo(render_table(hits, show_matches))
typer.echo(render_table(result, show_matches))
for entry in unreadable:
typer.echo(
f"WARN unreadable frontmatter: {entry['path']} ({entry['reason']}) - "
+15 -2
View File
@@ -17,6 +17,7 @@ import json
import typer
from chemenu import toc
from chemenu.commands._util import fail
from chemenu.types_core import UnknownType, describe_type, list_types
@@ -54,7 +55,10 @@ def describe_type_command(
"""Print one type's full contract: frontmatter fields (required/optional,
with enums where declared), its subtype field if any, and its authoring
body - the same information an LLM would otherwise gather by reading the
raw type-spec and `.schema.yaml` files directly."""
raw type-spec and `.schema.yaml` files directly. Where the type-spec
declares `guidance:`, that stack-owned file's prose is composed in ahead
of the type-spec's own body, so a `root: kb` type's contract still reads
as one answer even though it lives in two files (Gitea #104)."""
try:
described = describe_type(name)
except UnknownType as exc:
@@ -88,4 +92,13 @@ def describe_type_command(
typer.echo("")
typer.echo("## Authoring guidance")
typer.echo(described["body"])
# A type-spec (and its guidance file) over 100 lines carries a generated
# table-of-contents region (`chemenu/toc.py`), which serves whoever opens
# the file directly. Here it would be noise: this command already hands
# over the whole body, so there is nothing left for a navigation aid to
# navigate - only markers and a list of headings the reader is about to
# see anyway.
if described["guidance"]:
typer.echo(toc.strip_region(described["guidance"]))
typer.echo("")
typer.echo(toc.strip_region(described["body"]))
+127 -8
View File
@@ -11,7 +11,14 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
holds the two together.
- `version release` fixes that candidate: strips its `-beta.N` suffix and
closes its changelog entry. It is the only thing that turns a candidate into
a number a release actually consumes.
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.
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
@@ -20,6 +27,7 @@ number means, and `instructions/dev/version-parts.md` for the candidate model):
from __future__ import annotations
import json as _json
import re
from typing import Optional
import typer
@@ -196,7 +204,7 @@ def bump_command(
breaking: Optional[str] = typer.Option(
None,
"--breaking",
help="What stops working, for the bump that first escalates to a boundary crossing (recorded in CHANGES.md). Required there, refused on a bump that crosses nothing",
help="What stops working (recorded in CHANGES.md). Required on the bump that first escalates to a boundary crossing, optional on a later bump of the same crossing candidate - where it joins the reasons already recorded rather than replacing them. Refused on a bump that crosses nothing",
),
no_migration: Optional[str] = typer.Option(
None,
@@ -210,6 +218,13 @@ def bump_command(
"Requires a migration document already targeting the new base, and refuses when the entry "
"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"),
):
"""Raise or continue the running candidate, and open or update its
@@ -226,9 +241,16 @@ def bump_command(
version is not a drop-in replacement, whether or not any content moves -
requires `--breaking "<what stops working>"`, and on top of that either a
migration document for the new base or `--no-migration "<reason>"`. Both
lines are written into the entry once and then persist across every later
bump at the same stage: a follow-up bump need not repeat them, and passing
either on a bump that crosses nothing at all is refused.
are written into the entry and persist across every later bump at the same
stage, so a follow-up bump need not repeat them, and passing either on a
bump that crosses nothing at all is refused.
A candidate can cross the boundary more than once, and the two flags part
ways there. A further `--breaking` **joins** the reasons already recorded -
each crossing is its own thing an operator has to act on, and replacing
meant the second one silently deleted the first. A further
`--no-migration` **replaces**: whether content has to change is one
question about the candidate as a whole, not one per crossing.
A later bump of the same candidate that finds out `--no-migration` was
wrong after all retracts it with `--migration-required` - write the
@@ -242,6 +264,10 @@ def bump_command(
if not title.strip():
fail("--title must not be empty - it becomes the changelog entry's heading")
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:
current = version_mod.read_version()
@@ -351,13 +377,15 @@ def bump_command(
no_migration_reason=no_migration.strip() if no_migration else None,
breaking_reason=breaking.strip() if breaking else None,
migration_required=migration_required,
impact=chosen_impact,
),
encoding="utf-8",
)
impact_note = "" if impact is not None else f" (impact not given - assumed {chosen_impact})"
success(
f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and "
f"the {version_mod.CHANGES_FILENAME} entry - write its prose before publishing, and "
f"`version release` once the candidate is ready to ship."
f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before "
f"publishing, and `version release` once the candidate is ready to ship."
)
@@ -382,7 +410,12 @@ def release_command(
Commits nothing and pushes nothing (AGENTS.md invariant 5) - the following
`publish` moves `VERSION` onto `main` and is what `release.yml` reacts to.
Refuses when `VERSION` is already a release: there is no running candidate
to fix."""
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:
current = version_mod.read_version()
except VersionError as exc:
@@ -412,6 +445,18 @@ def release_command(
)
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
if dry_run:
@@ -428,3 +473,77 @@ def release_command(
f"{version_mod.CHANGES_FILENAME} entry - `publish` next, which moves VERSION onto main and "
"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.")
+5 -2
View File
@@ -56,6 +56,7 @@ from mcp.server.mcpserver.exceptions import ToolError
from chemenu import config, upload
from chemenu.api import Corpus
from chemenu.errors import ChemenuError
from chemenu.search.types import DEFAULT_LIMIT
from chemenu.telemetry import policy
SERVER_NAME = "chemenu"
@@ -158,14 +159,16 @@ def build_server(
"Find pages in kb/ by text, by frontmatter, or by both. Returns "
"title, path, kind and summary per hit, so a result can be judged "
"without fetching the page. Prefer this over listing files: the "
"answer is a few hundred tokens instead of a whole index."
"answer is a few hundred tokens instead of a whole index. "
"'count' is how many hits came back and 'total' how many matched; "
"when 'truncated' is true, raise 'limit' (0 for all) to see the rest."
),
)
def search(
query: str | None = None,
predicates: list[str] | None = None,
regex: bool = False,
limit: int = 20,
limit: int = DEFAULT_LIMIT,
sort: str | None = None,
) -> dict[str, Any]:
"""Search the wiki.
+14 -4
View File
@@ -28,7 +28,7 @@ from chemenu.search import filters
from chemenu.search.base import page_key
from chemenu.search.fuse import reciprocal_rank_fusion
from chemenu.search.ripgrep import build_hit
from chemenu.search.types import SearchHit, SearchQuery
from chemenu.search.types import SearchHit, SearchQuery, SearchResult
def load_pages_by_path(kb_dir: Path | None = None, root: Path | None = None) -> dict[str, Page]:
@@ -92,8 +92,13 @@ def run_search(
pages: dict[str, Page],
backends: list,
kb_dir: Path | None = None,
) -> list[SearchHit]:
"""Answer a query. Pure: no I/O beyond whatever a backend does."""
) -> SearchResult:
"""Answer a query. Pure: no I/O beyond whatever a backend does.
Returns the truncated hits *and* the number there were before the limit,
because the caller cannot recover the second from the first - see
`SearchResult`.
"""
filters.validate_fields(query.predicates, pages)
if query.text:
@@ -110,4 +115,9 @@ def run_search(
hits.sort(key=lambda h: h.title.lower())
hits = sort_hits(hits, query.sort)
return hits[: query.limit] if query.limit else hits
total = len(hits)
return SearchResult(
hits=hits[: query.limit] if query.limit else hits,
total=total,
limit=query.limit,
)
+44 -1
View File
@@ -4,6 +4,18 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
# How many hits a caller gets when it asks for no particular number. Defined
# once, here, because three adapters offer the same knob - the CLI's `--limit`,
# `api.search(limit=...)` and the MCP `search` tool - and three literals is how
# they start disagreeing about what "the default search" returns.
#
# 50 rather than a smaller number because the queries that actually hit the cap
# are the *structured* sweeps (`--field '!sources'`), which are ordered
# alphabetically rather than by relevance: truncating those throws away an
# arbitrary slice of the answer rather than its weakest tail. A capped result
# is only safe at all because it now says so - see `SearchResult.truncated`.
DEFAULT_LIMIT = 50
@dataclass(frozen=True)
class Predicate:
@@ -30,7 +42,7 @@ class SearchQuery:
text: Optional[str] = None
predicates: tuple[Predicate, ...] = ()
regex: bool = False
limit: int = 20
limit: int = DEFAULT_LIMIT
sort: Optional[str] = None
@@ -77,3 +89,34 @@ class SearchHit:
"backend": self.backend,
"matches": [m.as_dict() for m in self.matches],
}
@dataclass(frozen=True)
class SearchResult:
"""The hits a caller gets back, plus how many there were before the limit.
`run_search` used to return the truncated list alone, which made the
truncation impossible to report: every adapter counted `len(hits)` and
printed it as the answer, so `20 result(s).` on a query matching 182 pages
was indistinguishable from a query that really matched twenty. That is a
completeness claim none of them were in a position to make, and the only
way to find out was to ask again with `--limit 0` - a second full search to
learn a number the first one already knew.
So the total travels with the hits. Nothing here decides how to say it;
that belongs to each adapter (`render_table`, the `--json` payload,
`api.search`).
"""
hits: list[SearchHit]
total: int
limit: int
@property
def truncated(self) -> bool:
"""Whether the limit actually cut something off.
`limit=0` means "no limit", so it never truncates however large the
corpus is.
"""
return bool(self.limit) and self.total > len(self.hits)
+15
View File
@@ -47,6 +47,21 @@ def test_a_corpus_can_be_named_and_is_the_one_that_answers(foreign_corpus):
assert result["results"][0]["path"] == "kb/entities/Peregrine.md"
def test_a_capped_answer_says_how_much_it_left_out(foreign_corpus):
"""`count` keeps meaning "what is in this payload", so a consumer written
against the old shape reads the number it always did; `total` is what it
could not ask before, and without it a truncated answer is indistinguishable
from a complete one."""
corpus = Corpus(foreign_corpus)
whole = corpus.search("Peregrine")
assert (whole["count"], whole["total"], whole["truncated"]) == (1, 1, False)
assert whole["count"] == len(whole["results"])
capped = corpus.search("Peregrine", limit=0)
assert capped["truncated"] is False, "limit 0 means no limit, so it caps nothing"
def test_no_path_of_this_checkout_is_read_while_a_foreign_root_is_set(foreign_corpus):
"""The acceptance criterion, asserted rather than argued.
+24 -1
View File
@@ -81,7 +81,16 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
# the instance's; `instruction` writes into the repo and is the stack's.
(types_dir / "entity.md").write_text(
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n---\n\n# Entity\n",
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
encoding="utf-8",
)
# entity's guidance: stack-owned even though entity.md itself is
# instance-owned - the file `_owned_type_stem` must not mistake for
# entity's own `.md`/`.schema.yaml` despite sharing its stem (Gitea #104).
(types_dir / "entity.guidance.md").write_text(
"---\ntype: types/type-guidance.md\nname: entity\ndescription: When to use entity.\n"
"---\n\n# Entity Guidance\n",
encoding="utf-8",
)
(types_dir / "instruction.md").write_text(
@@ -394,6 +403,20 @@ def test_page_type_specs_ship_as_templates_and_stack_types_do_not(repo, monkeypa
assert "types/instruction.md.template" not in plan
def test_guidance_file_ships_verbatim_beside_a_templated_type_spec(repo, monkeypatch):
"""Gitea #104: `types/entity.guidance.md` is stack-owned even though
`types/entity.md` (same stem) is instance-owned - it must cross like
`types/instruction.md` above, never re-keyed as though it were the
type-spec's own `.template`."""
from chemenu.type_resolver import resolver
monkeypatch.setattr(resolver, "_repo_root", config.ROOT)
plan = dist_cmd.build_plan()
assert "types/entity.guidance.md" in plan
assert "types/entity.guidance.md.template" not in plan
def test_plan_creates_empty_raw_and_incoming_not_real_content(repo):
"""Both flat since Gitea #67: `raw/` addresses a file by its accept date,
never by a hand-picked type, so there is nothing left to seed per type."""
+50
View File
@@ -223,6 +223,56 @@ def test_seeded_once_paths_are_never_written_even_if_the_release_stamp_lists_the
assert not (instance / preserved).exists()
# --- a root:kb type-spec's guidance half upgrades like any other file -------
#
# Gitea #104: before the split, `types/<name>.md` carried both the
# instance-owned template and the stack-owned authoring prose in one file, so
# an instance that had adopted it (renamed the `.template`) never received a
# prose improvement again - `dist upgrade` only ever wrote the `.template`
# beside the adopted file, never the file itself. Splitting the prose into a
# sibling `.guidance.md` that ships verbatim (never `.template`-sourced) means
# it upgrades through the ordinary unchanged/new path below, even though the
# type-spec it documents is never in the stamp at all and therefore never
# touched.
def test_upgrade_writes_improved_guidance_prose_over_an_adopted_type_spec(instance, tmp_path):
(instance / "types").mkdir()
(instance / "types" / "entity.md").write_text(
# Adopted from `types/entity.md.template` at some earlier setup - this
# file was never part of any release stamp and `dist upgrade` must
# never touch it.
"---\ntype: types/type-spec.md\nname: entity\ndescription: d\n"
"schema: types/entity.schema.yaml\nbase_dir: entities\n"
"guidance: types/entity.guidance.md\n---\n\n# Entity\n",
encoding="utf-8",
)
(instance / "types" / "entity.guidance.md").write_text("old guidance\n", encoding="utf-8")
stamp = json.loads((instance / version_mod.RELEASE_STAMP_FILENAME).read_text())
stamp["files"]["types/entity.guidance.md"] = _digest("old guidance\n")
(instance / version_mod.RELEASE_STAMP_FILENAME).write_text(json.dumps(stamp), encoding="utf-8")
release = _release(
tmp_path, "release", "1.1.0",
{
"AGENTS.md": "core\n",
"tools/wikitool": "#!/bin/sh\n",
"types/entity.guidance.md": "improved guidance\n",
},
)
dist_cmd.run_upgrade(release)
assert (instance / "types" / "entity.guidance.md").read_text(encoding="utf-8") == (
"improved guidance\n"
)
# The adopted type-spec itself was never in either stamp, so it is
# completely untouched by the upgrade.
assert "guidance: types/entity.guidance.md" in (
instance / "types" / "entity.md"
).read_text(encoding="utf-8")
# --- migration chain: reported, never run -----------------------------------
+141 -1
View File
@@ -1,8 +1,9 @@
import pytest
import typer
from chemenu import config
from chemenu import config, type_resolver
from chemenu.commands import dist_cmd, docs_verify
from chemenu.type_resolver import TypeResolver
def test_every_registered_command_is_documented():
@@ -289,6 +290,51 @@ def test_an_absent_listed_doc_is_skipped_not_reported(tmp_path, monkeypatch):
assert docs_verify.check_readmes_have_no_command_table() == []
def test_this_repos_type_specs_validate_against_their_own_schema():
"""Regression guard for Gitea #105: types/type-spec.schema.yaml declared
`additionalProperties: false` while real type-specs already carried
`root:` and `capture_fields:`, and nothing ever validated a type-spec's
own frontmatter against it - so the mismatch shipped silently."""
assert docs_verify.check_type_spec_frontmatter() == []
def test_an_unknown_type_spec_field_is_reported(tmp_path, monkeypatch):
"""Once the schema is enforced, a type-spec frontmatter field its own
schema does not know about must fail loudly rather than validating
silently - the other direction of the #105 regression guard above."""
types_dir = tmp_path / "types"
types_dir.mkdir()
(types_dir / "type-spec.md").write_text(
"---\n"
"type: types/type-spec.md\n"
"name: type-spec\n"
"description: Authoring and validation contract for type specs\n"
"schema: types/type-spec.schema.yaml\n"
"---\n",
encoding="utf-8",
)
(types_dir / "type-spec.schema.yaml").write_text(
(config.ROOT / "types" / "type-spec.schema.yaml").read_text(encoding="utf-8"),
encoding="utf-8",
)
(types_dir / "widget.md").write_text(
"---\n"
"type: types/type-spec.md\n"
"name: widget\n"
"description: A type-spec with a field its own schema does not know\n"
"schema: null\n"
"not_a_real_field: true\n"
"---\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "TYPES_DIR", types_dir)
monkeypatch.setattr(type_resolver, "resolver", TypeResolver(repo_root=tmp_path))
issues = docs_verify.check_type_spec_frontmatter()
assert any("widget.md" in issue and "not_a_real_field" in issue for issue in issues)
def test_legacy_type_blocks_are_absent():
assert docs_verify.check_legacy_type_blocks() == []
@@ -533,6 +579,100 @@ def test_no_shipped_document_cites_an_issue():
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):
monkeypatch.setattr(
docs_verify,
@@ -341,6 +341,123 @@ def test_a_self_mention_does_not_count_as_a_reference(layer):
assert "gates.md" not in instructions_cmd.referenced_names()
# --- 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 ----------------------------------------------
+20 -1
View File
@@ -125,6 +125,24 @@ def test_the_four_tools_are_there_and_nothing_that_writes(corpus):
assert names.isdisjoint({"new", "touch", "xref", "cite", "publish", "migrate", "rm"})
def test_all_three_adapters_offer_the_same_default_limit(corpus):
"""The CLI's `--limit`, `api.search(limit=...)` and this tool are three
knobs on one search, and three literals is how they start disagreeing about
what "the default search" returns. They read one constant."""
import inspect
from chemenu.api import Corpus
from chemenu.commands.search import search_command
from chemenu.search.types import DEFAULT_LIMIT
server = build_server(corpus, check_trace=False)
tool = next(t for t in asyncio.run(server.list_tools()) if t.name == "search")
assert tool.input_schema["properties"]["limit"]["default"] == DEFAULT_LIMIT
assert inspect.signature(Corpus.search).parameters["limit"].default == DEFAULT_LIMIT
assert inspect.signature(search_command).parameters["limit"].default.default == DEFAULT_LIMIT
def test_no_tool_writes_anything_into_the_corpus_or_git(corpus):
server = build_server(corpus, check_trace=False)
before = _tree(corpus)
@@ -176,7 +194,8 @@ def test_the_wire_format_is_the_clis_json_form(corpus):
# `generated` is the CLI's date stamp and `commit`/`as_of` are the server's
# revision stamp - two answers to "when", neither of them a finding. What
# has to match is everything that describes the *corpus*.
shared = ("query", "predicates", "backend", "count", "results", "unreadable")
shared = ("query", "predicates", "backend", "count", "total", "truncated", "limit",
"results", "unreadable")
assert {key: served[key] for key in shared} == {key: from_cli[key] for key in shared}
+13
View File
@@ -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"])
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():
run_budget.record_and_check("new", ["entity", "--name", "X"], override=False)
with pytest.raises(typer.Exit):
+102 -6
View File
@@ -17,7 +17,14 @@ from chemenu.search.filters import PredicateError, parse_predicate
from chemenu.search.fuse import reciprocal_rank_fusion
from chemenu.search.registry import UnknownBackend, resolve
from chemenu.search.ripgrep import RipgrepBackend, build_argv
from chemenu.search.types import Match, Predicate, SearchHit, SearchQuery
from chemenu.search.types import (
DEFAULT_LIMIT,
Match,
Predicate,
SearchHit,
SearchQuery,
SearchResult,
)
from chemenu.frontmatter_io import write_page
@@ -32,8 +39,12 @@ def backend(kb_dir: Path, tmp_path: Path):
@pytest.fixture
def search(kb_dir: Path, pages):
"""Run a query against the fixture kb rather than the real one."""
def search_result(kb_dir: Path, pages):
"""Run a query against the fixture kb and return the whole `SearchResult`.
For the tests that care about the limit and the total; most only want the
hits, and use `search` below.
"""
def _search(query: SearchQuery, backends=()):
return run_search(query, pages, list(backends), kb_dir)
@@ -41,6 +52,16 @@ def search(kb_dir: Path, pages):
return _search
@pytest.fixture
def search(search_result):
"""The hits alone, for tests whose subject is ranking or filtering."""
def _search(query: SearchQuery, backends=()):
return search_result(query, backends).hits
return _search
def _titles(hits):
return [hit.title for hit in hits]
@@ -211,6 +232,20 @@ def test_limit_and_sort(search):
assert len(search(_q("kind=entity", limit=2))) == 2
def test_the_total_survives_the_limit_so_no_second_search_is_needed(search_result):
"""The caller cannot recover the total from a truncated list, and asking
again with `--limit 0` is a second full search to learn a number the first
one already had."""
capped = search_result(_q("kind=entity", limit=2))
assert len(capped.hits) == 2
assert capped.total == 3
assert capped.truncated is True
whole = search_result(_q("kind=entity"))
assert (whole.total, whole.truncated) == (3, False)
def test_sort_puts_missing_values_last():
hits = [
SearchHit(title="b", path="b", modified=None),
@@ -240,18 +275,79 @@ def test_resolve_defaults_to_rg_and_rejects_unknown():
# --- output -----------------------------------------------------------------
def _result(hits, total=None, limit=DEFAULT_LIMIT):
return SearchResult(hits=hits, total=total if total is not None else len(hits), limit=limit)
def test_render_table_is_compact_and_reports_the_count():
hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system",
summary="Server hosting DocStore", score=8.0, matches=[Match(3, "DocStore")])
out = render_table([hit], show_matches=False)
out = render_table(_result([hit]), show_matches=False)
assert "aurora" in out and "entity/system" in out
assert "kb/x.md:3" not in out
assert "1 result(s)." in out
assert "kb/x.md:3" in render_table([hit], show_matches=True)
assert "kb/x.md:3" in render_table(_result([hit]), show_matches=True)
def test_render_table_says_so_when_nothing_matched():
assert render_table([], show_matches=False) == "No matches."
assert render_table(_result([]), show_matches=False) == "No matches."
def test_every_hit_carries_the_path_so_the_page_can_be_opened():
"""The reason this format changed: the instructions tell an agent to read
the pages the search points at, and the table pointed nowhere. A session
that needed filenames ran `grep -rl` over kb/ for them - a second search
that can find no page this one missed."""
hit = SearchHit(title="aurora", path="kb/entities/systems/aurora.md", kind="entity")
assert "kb/entities/systems/aurora.md" in render_table(_result([hit]), show_matches=False)
def test_title_and_path_are_never_truncated_only_the_summary_is():
"""A clipped title is not an identifier. It is what `xref add`, `cite add`
and `touch` take as an argument, and the old fixed 34-char column cut four
of five hits in the report that prompted this."""
title = "Source - Pelletofenkondensator und Verkabelung Recherche"
path = f"kb/sources/llm-sessions/{title}.md"
hit = SearchHit(title=title, path=path, kind="source", summary="x" * 400)
line = render_table(_result([hit]), show_matches=False).splitlines()[0]
assert title in line
assert path in line
assert "" in line, "the summary is still the one lossy field"
def test_a_hit_line_parses_into_its_five_fields_even_with_prose_pipes():
"""The separator has to survive a summary that contains one. It does,
because the summary is last: a `|` there cannot shift a column. A `|` in a
title is impossible - the wikilink syntax reserves it."""
hit = SearchHit(title="aurora", path="kb/x.md", kind="entity", subtype="system",
summary="Runs `a | b` nightly", score=8.0)
line = render_table(_result([hit]), show_matches=False).splitlines()[0]
score, kind, title, path, summary = line.split(" | ", 4)
assert (score, kind, title, path) == ("8.0", "entity/system", "aurora", "kb/x.md")
assert summary == "Runs `a | b` nightly"
def test_a_capped_result_says_what_it_capped():
"""`20 result(s).` on a query matching 182 pages is a completeness claim
the output was in no position to make, and the only way to find the real
number was a second, unlimited search."""
hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(20)]
out = render_table(_result(hits, total=182, limit=20), show_matches=False)
assert "20 of 182 result(s)" in out
assert "--limit" in out
def test_an_uncapped_result_claims_nothing_about_a_limit():
hits = [SearchHit(title=f"p{i}", path=f"kb/p{i}.md") for i in range(3)]
assert "3 result(s)." in render_table(_result(hits), show_matches=False)
assert " of " not in render_table(_result(hits), show_matches=False)
def test_limit_zero_never_counts_as_truncated_however_large_the_corpus():
assert _result([SearchHit(title="a", path="a")] * 182, total=182, limit=0).truncated is False
def test_hit_serialises_for_json():
+35 -4
View File
@@ -150,9 +150,10 @@ def test_stale_regions_is_false_right_after_upsert():
def test_target_files_matches_the_documented_scope():
"""Integration check against the real repo: the scope is AGENTS.md, every
stage contract, kb/CONVENTIONS.md, every COLLECTION.md, and the flat
`instructions/**.md` form - never a `SKILL.md`."""
"""Integration check against the real repo: every agent-loaded category
AGENTS.md § File naming names - AGENTS.md itself, every stage contract,
kb/CONVENTIONS.md, every COLLECTION.md, the flat `instructions/**.md` form,
every type-spec, every `docs/` page - with `SKILL.md` the one exception."""
from chemenu import config
files = toc.target_files()
@@ -164,5 +165,35 @@ def test_target_files_matches_the_documented_scope():
assert "types/type-spec.md" in relatives
assert "instructions/CONTRACT.md" in relatives
assert "instructions/dev/version-parts.md" in relatives # flat, still instructions/**.md
assert "types/source.md" in relatives # a page type-spec, not only the stage contract
assert "docs/version-model.md" in relatives
assert not any(rel.endswith("SKILL.md") for rel in relatives)
assert not any(rel.startswith("types/") and rel != "types/type-spec.md" for rel in relatives)
def test_target_files_lists_type_spec_once_despite_two_sources():
"""`types/type-spec.md` is both a stage contract and a `types/*.md` file;
the set in `target_files` is what keeps that from being a special case."""
from chemenu import config
relatives = [f.relative_to(config.ROOT).as_posix() for f in toc.target_files()]
assert relatives.count("types/type-spec.md") == 1
def test_strip_region_removes_what_types_describe_would_otherwise_echo():
body = (
"# A type\n\n"
f"{blocks.open_marker(toc.REGION_NAME)}\n## Contents\n\n- [X](#x)\n"
f"{blocks.close_marker(toc.REGION_NAME)}\n\n## X\n\nProse.\n"
)
stripped = toc.strip_region(body)
assert toc.REGION_NAME not in stripped
assert "## Contents" not in stripped
assert "## X" in stripped and "Prose." in stripped
# The heading keeps the blank line the region used to sit in, rather than
# being welded to the line above it.
assert "# A type\n\n## X" in stripped
def test_strip_region_leaves_a_body_that_never_had_one_alone():
body = "# A type\n\n## X\n\nProse.\n"
assert toc.strip_region(body) == body
+49
View File
@@ -64,6 +64,54 @@ def test_get_page_ref_fields_defaults_to_empty():
assert resolver.get_page_ref_fields("types/type-spec.md") == []
def test_get_guidance_reads_the_linked_type_guidance_file():
"""Gitea #104: a root:kb type-spec's generic authoring prose lives in a
separate, stack-owned `<name>.guidance.md`, linked via `guidance:` -
unlike `schema:`, this one's absence is the common case (an instance
typed for itself), not an error."""
guidance = resolver.get_guidance("types/entity.md")
assert guidance is not None
assert "When to use" in guidance or "When NOT to use" in guidance
def test_get_guidance_is_none_when_the_type_spec_declares_none():
"""`instruction` and `lint-report` describe stack artifacts and have
never carried a `guidance:` field - this is the type with no linked
guidance at all, not a broken link."""
assert resolver.get_guidance("types/instruction.md") is None
assert resolver.get_guidance("types/lint-report.md") is None
def test_extract_template_reads_only_the_type_spec_never_the_guidance_file(tmp_path):
"""`wikitool new` must keep exactly one load path for its scaffold - the
first ```markdown block of `types/<name>.md` itself - even though the
type-spec now optionally links a second file. A ```markdown block placed
in the guidance file instead must never be picked up."""
from chemenu.type_resolver import TypeResolver
types_dir = tmp_path / "types"
types_dir.mkdir()
(types_dir / "widget.md").write_text(
# Self-referential `type:` (like the badtype fixture above), purely so
# this narrow fixture needs no real `types/type-spec.md` on disk -
# `extract_template` never reads the `type:` field at all.
"---\ntype: types/widget.md\nname: widget\ndescription: A widget type.\n"
"schema: null\nbase_dir: widgets\nguidance: types/widget.guidance.md\n---\n\n"
"# Widget\n\n## Template\n\n```markdown\n# {name}\n```\n",
encoding="utf-8",
)
(types_dir / "widget.guidance.md").write_text(
"---\ntype: types/type-guidance.md\nname: widget\ndescription: Guidance for widget.\n"
"---\n\n# Widget Guidance\n\n```markdown\nTHIS MUST NEVER BE THE SCAFFOLD\n```\n",
encoding="utf-8",
)
bad_resolver = TypeResolver(repo_root=tmp_path)
type_spec = bad_resolver.load_type_spec("types/widget.md")
template = bad_resolver.extract_template(type_spec)
assert template == "# {name}"
assert "THIS MUST NEVER BE THE SCAFFOLD" not in template
def test_get_capture_fields_reads_the_type_spec():
"""`fidelity`/`authority` are fixed once, at capture time (Gitea #67) -
`raw accept`, `new source` and `touch` all read the field list from here
@@ -221,6 +269,7 @@ def test_list_type_specs_finds_every_type_spec():
names = {fm.get("name") for _, fm in resolver.list_type_specs()}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
}
+37
View File
@@ -16,6 +16,7 @@ def test_types_list_finds_all_current_type_specs():
names = {row["name"] for row in rows}
assert names == {
"type-spec", "entity", "concept", "source", "comparison", "lint-report", "instruction",
"type-guidance",
}
@@ -52,6 +53,42 @@ def test_types_describe_entity_reports_schema_and_body():
assert "wikitool:footnotes" not in data["body"]
def test_types_describe_entity_composes_guidance_and_body_separately():
"""Gitea #104: the generic authoring prose (When to use / When NOT to
use) now lives in the stack-owned `entity.guidance.md`, reported under
its own JSON keys, while `body` stays exactly what it was - the
instance-owned type-spec's own text (frontmatter table + template)."""
result = runner.invoke(app, ["types", "describe", "entity", "--json"])
assert result.exit_code == 0, result.output
import json
data = json.loads(result.output)
assert data["guidance_path"] == "types/entity.guidance.md"
assert data["guidance"] is not None
assert "## When to use" in data["guidance"]
assert "## When to use" not in data["body"]
assert "## Kerndaten" in data["body"]
def test_types_describe_composes_guidance_ahead_of_body_in_text_output():
result = runner.invoke(app, ["types", "describe", "entity"])
assert result.exit_code == 0, result.output
guidance_at = result.output.index("## When to use")
template_at = result.output.index("## Kerndaten")
assert guidance_at < template_at
def test_types_describe_a_type_with_no_guidance_omits_it_cleanly():
"""`instruction` describes a stack artifact and has never carried a
`guidance:` field - this must read exactly as it did before the split
existed, not print an empty section."""
result = runner.invoke(app, ["types", "describe", "instruction", "--json"])
assert result.exit_code == 0, result.output
import json
data = json.loads(result.output)
assert data["guidance"] is None
assert data["guidance_path"] is None
def test_types_describe_unknown_name_fails_cleanly():
result = runner.invoke(app, ["types", "describe", "bogus"])
assert result.exit_code != 0
+368 -31
View File
@@ -34,6 +34,17 @@ def tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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 ----------------------------------------------------
@@ -318,13 +329,338 @@ def test_release_entry_can_replace_the_title():
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 bump: accumulating --breaking ----------------------------------
def test_a_single_breaking_reason_stays_on_the_marker_line(tree):
"""The shape every entry written before accumulation existed carries."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert f"{version_mod.BREAKING_CHANGE_MARKER} the feed moved" in changes
def test_a_second_breaking_reason_joins_the_first_instead_of_replacing_it(tree):
"""The defect this exists for: the second --breaking used to delete the
first, leaving the entry promising one break while shipping two."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First crossing",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second crossing",
breaking="the import name changed", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
assert "- the feed moved" in changes and "- the import name changed" in changes
# Still the line an operator acts on first, above a list that can run long.
assert changes.index(version_mod.BREAKING_CHANGE_MARKER) < changes.index(version_mod._BUMPS_OPEN)
def test_repeating_a_breaking_reason_verbatim_is_a_no_op(tree):
"""So a re-run after an interrupted bump converges instead of writing the
same sentence twice."""
for title in ("First", "Second"):
version_cmd.bump_command(
major=True, minor=False, patch=False, title=title,
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.2"))
assert version_mod.breaking_reasons(section) == ["the feed moved"]
def test_breaking_reasons_reads_a_pre_accumulation_single_line_back(tree):
"""An entry written by a build that predates accumulation round-trips: it
parses as one reason, and a later bump grows it to a list rather than
rewriting what was already there."""
section = "## 2.0.0-beta.1 - 2026-09-01 - T\n\n**Breaking Change:** the feed moved\n\n"
assert version_mod.breaking_reasons(section) == ["the feed moved"]
grown = version_mod._add_breaking_reason(section, "the import name changed")
assert version_mod.breaking_reasons(grown) == ["the feed moved", "the import name changed"]
assert f"{version_mod.BREAKING_CHANGE_MARKER}\n- the feed moved\n" in grown
def test_no_migration_still_replaces_rather_than_accumulating(tree):
"""It answers one yes/no question about the candidate as a whole, so a
second answer supersedes the first - unlike --breaking beside it."""
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second",
breaking=None, no_migration="still nothing to change", migration_required=False,
impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert changes.count(version_mod.MIGRATION_NONE_MARKER) == 1
assert "kb/ untouched" not in changes
assert "still nothing to change" in changes
def test_retracting_no_migration_leaves_an_accumulated_breaking_list_intact(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="First",
breaking="the feed moved", no_migration="kb/ untouched", migration_required=False,
impact=None, dry_run=False,
)
version_cmd.bump_command(
major=False, minor=True, patch=False, title="Second",
breaking="the import name changed", no_migration=None, migration_required=False,
impact=None, dry_run=False,
)
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Turns out it migrates",
breaking=None, no_migration=None, migration_required=True, impact=None, dry_run=False,
)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
section = version_mod.changes_section(changes, Version.parse("2.0.0-beta.3"))
assert version_mod.MIGRATION_NONE_MARKER not in changes
assert version_mod.breaking_reasons(section) == ["the feed moved", "the import name changed"]
# --- version regrade --------------------------------------------------------
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 ----------------------------------------------------------
def test_bump_writes_both_the_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
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"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -336,11 +672,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):
version_cmd.bump_command(
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(
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"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -350,7 +686,7 @@ def test_a_second_bump_continues_the_same_candidate_instead_of_opening_another(t
def test_bump_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=False, patch=True, title="Nope", breaking=None, no_migration=None, 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 "1.0.1" not in (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -363,7 +699,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
major, minor, patch = flags
with pytest.raises(typer.Exit):
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"
@@ -371,7 +707,7 @@ def test_bump_demands_exactly_one_part(tree, flags):
def test_bump_refuses_an_empty_title(tree):
with pytest.raises(typer.Exit):
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 +720,7 @@ def test_bump_refuses_when_version_and_changelog_disagree(tree):
)
with pytest.raises(typer.Exit):
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"
@@ -398,7 +734,7 @@ def test_a_boundary_crossing_bump_without_a_migration_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
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"
@@ -413,7 +749,7 @@ def test_a_boundary_crossing_bump_passes_with_a_migration_document(tree):
)
version_cmd.bump_command(
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"
@@ -430,11 +766,11 @@ def test_a_follow_up_bump_at_the_same_stage_need_not_repeat_breaking_or_migratio
)
version_cmd.bump_command(
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(
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"
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
@@ -446,7 +782,7 @@ def test_no_migration_records_the_reason_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Breaking",
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")
assert version_mod.MIGRATION_NONE_MARKER in changes
@@ -458,7 +794,7 @@ def test_no_migration_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
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 +815,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
one after all has no other way to take that statement back."""
version_cmd.bump_command(
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")
assert version_mod.MIGRATION_NONE_MARKER in changes
@@ -487,7 +823,7 @@ def test_migration_required_retracts_the_no_migration_line(tree):
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
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")
assert version_mod.MIGRATION_NONE_MARKER not in changes
@@ -498,12 +834,12 @@ def test_migration_required_retracts_the_no_migration_line(tree):
def test_migration_required_is_refused_without_a_migration_document(tree):
version_cmd.bump_command(
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):
version_cmd.bump_command(
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")
assert version_mod.MIGRATION_NONE_MARKER in changes # unchanged
@@ -513,12 +849,12 @@ def test_migration_required_is_refused_with_no_no_migration_line_to_retract(tree
_migration_document(tree, "2.0.0")
version_cmd.bump_command(
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):
version_cmd.bump_command(
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 +863,7 @@ def test_migration_required_is_refused_together_with_no_migration(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Contradiction",
breaking="the feed moved", no_migration="kb/ untouched",
migration_required=True, dry_run=False,
migration_required=True, impact=None, dry_run=False,
)
@@ -536,7 +872,7 @@ def test_migration_required_is_refused_without_a_running_candidate(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
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 +900,7 @@ def test_a_boundary_crossing_bump_without_breaking_is_refused(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_command(
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"
@@ -573,7 +909,7 @@ def test_breaking_records_what_stops_working_in_the_changelog(tree):
version_cmd.bump_command(
major=True, minor=False, patch=False, title="Renamed the feed",
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")
assert version_mod.BREAKING_CHANGE_MARKER in changes
@@ -590,7 +926,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
with pytest.raises(typer.Exit):
version_cmd.bump_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"
@@ -601,7 +937,7 @@ def test_breaking_is_refused_on_a_compatible_bump(tree):
def test_release_fixes_version_and_the_changelog_heading(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, 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)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0"
@@ -614,12 +950,13 @@ def test_release_fixes_version_and_the_changelog_heading(tree):
def test_release_can_replace_the_title(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
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, 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)
changes = (tree / "CHANGES.md").read_text(encoding="utf-8")
assert "## 1.1.0 - " in changes
@@ -632,7 +969,7 @@ def test_release_can_replace_the_title(tree):
def test_release_dry_run_writes_nothing(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, 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)
assert (tree / "VERSION").read_text(encoding="utf-8").strip() == "1.1.0-beta.1"
@@ -647,7 +984,7 @@ def test_release_refuses_when_version_is_already_a_release(tree):
def test_release_refuses_when_version_and_changelog_disagree(tree):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, 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")
with pytest.raises(typer.Exit):
@@ -665,11 +1002,11 @@ def test_notes_prints_the_entry_for_the_current_version(tree, capsys):
def test_notes_prints_a_running_candidates_full_entry(tree, capsys):
version_cmd.bump_command(
major=False, minor=True, patch=False, title="First bump",
breaking=None, no_migration=None, migration_required=False, dry_run=False,
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, dry_run=False,
breaking=None, no_migration=None, migration_required=False, impact=None, dry_run=False,
)
version_cmd.notes_command(version=None)
out = capsys.readouterr().out
+84 -11
View File
@@ -20,16 +20,49 @@ the stage/collection contracts, the flat `instructions/**.md` files) is a
precedent first.
**Scope is computed, never a hand-picked list** - the same principle that
governs `wikitool` itself. `target_files()` walks the file-naming categories
AGENTS.md's own table calls agent-loaded reference material: `AGENTS.md`,
every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, and
every flat `instructions/**.md` file (a `SKILL.md` is excluded - it is loaded
whole by the harness, not previewed at a second hop; `instructions/CONTRACT.md`
§ "How much reasoning a step may carry" already treats a checklist read once
as the table of contents it replaced). Human docs (`README.md`, `CHANGES.md`,
`EVALS.md`, `INSTALL.md`, `tools/README.md`) are excluded too: the file-naming
table says they are "Never loaded by an agent as instruction," so the preview
mechanic this exists to mitigate does not apply to them.
governs `wikitool` itself. `target_files()` walks every file-naming category
AGENTS.md's own table calls agent-loaded: `AGENTS.md`, every stage contract,
`kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md`
file, every `types/*.md` type-spec, and every `docs/` page. One rule, and
exactly one exception below it - which is the whole point, because a scope
carrying several unexplained absences reads as an accident rather than a
decision, and did: `docs/` and the page type-specs sat outside it for no
recorded reason at all.
`docs/` belongs in for the reason the threshold exists. AGENTS.md § File naming
calls it "Agents and humans | By link, or on explicit request" - agent-loaded,
at a second hop, which is precisely the preview mechanic
`instructions/CONTRACT.md` § "Reference depth" treats as real. The exclusion of
the human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`,
`tools/README.md`) rests on a sentence that does not stretch to cover it: those
are "Never loaded by an agent as instruction," while a `docs/` page is not
loaded *as instruction* but is very much loaded.
**The one exception is `SKILL.md`**, and the vendored skill-authoring guidance
is what puts it there rather than a judgment of ours. `commonplace/kb/work/
skill-creator-distillation/sources/claude-code-skill-creator/SKILL.md:88-99`
sets out three loading levels and places the SKILL.md body on the second - "In
context whenever skill triggers (<500 lines ideal)" - while aiming its own TOC
advice at the third, "large reference files (>300 lines)", i.e. bundled
resources. The Codex source agrees by placement: its TOC bullet sits directly
under "Keep references one level deep from SKILL.md. All reference files should
link directly from SKILL.md" (`.../codex-skill-creator/SKILL.md:221-222`).
Neither asks a skill body to carry a table of contents, because neither expects
one to be previewed. `instructions/CONTRACT.md` § "How much reasoning a step
may carry" arrives at the same place from the other side, treating a checklist
read once as the table of contents it replaced. (Our longest skill is 254
lines, so the <500 guidance costs us nothing either.)
A type-spec is loaded whole too - `tools/wikitool types describe` prints its
entire authoring body - but it is *also* read as a file, by whoever edits it,
and that is the reading the threshold is about. So it carries a region like any
other reference file, and `types_cmd` strips the region back out of what
`describe` prints: the command already hands over the whole body, so a
navigation aid into it would be noise in the output and nothing else.
The threshold's own provenance is worth recording, because the two sources
disagree: Codex says 100 lines, Claude Code says 300. This stack took the
stricter number.
"""
from __future__ import annotations
@@ -80,7 +113,13 @@ def target_files() -> list[Path]:
`instructions/<name>.md` form (`instructions/CONTRACT.md` says the `dev/`
split is orthogonal to Linked/Manual, not a different file shape), so
there is no separate rule for them to fall out of - and no hand-picking
for a future file under either to be missed.
for a future file under either to be missed. `types/` and `docs/` are
walked for the same reason: a type or a rationale page added later is in
scope by construction, not by someone remembering this function.
`types/type-spec.md` arrives twice - once as a stage contract, once from
the `types/` walk - and the set at the bottom is what makes that a
non-issue rather than something to special-case.
"""
files: list[Path] = [config.ROOT / "AGENTS.md"]
files += [config.ROOT / rel for rel in _STAGE_CONTRACTS]
@@ -96,6 +135,10 @@ def target_files() -> list[Path]:
for path in instructions_dir.rglob("*.md")
if path.name != "SKILL.md"
)
for subdir in ("types", "docs"):
directory = config.ROOT / subdir
if directory.is_dir():
files += sorted(directory.rglob("*.md"))
return sorted({f for f in files if f.is_file()})
@@ -116,6 +159,36 @@ def body_without_region(text: str) -> str:
return stripped.rstrip("\n") + "\n" if stripped else stripped
_REGION_WITH_PADDING_RE = re.compile(
r"\n*"
+ re.escape(blocks.open_marker(REGION_NAME))
+ r".*?"
+ re.escape(blocks.close_marker(REGION_NAME))
+ r"\n*",
re.DOTALL,
)
def strip_region(text: str) -> str:
"""`text` with the generated TOC region removed, for a caller that is
handing the whole body over anyway.
`types describe` is the one such caller: it prints a type-spec's entire
authoring body, so the region's markers and heading list would be noise in
its output rather than a way into anything. Distinct from
`body_without_region`, which exists to *measure* a body against the line
threshold and therefore also canonicalizes the trailing newline.
Takes the surrounding blank lines with it and puts one back, rather than
calling `blocks.strip`: that collapses the padding to a single newline,
which is the right answer when the result is about to be rebuilt from
scratch (`upsert` does exactly that) and the wrong one here, where the
output is printed as-is - it would leave the following `##` heading welded
to the paragraph above it.
"""
return _REGION_WITH_PADDING_RE.sub("\n\n", text)
def needs_toc(text: str) -> bool:
return len(body_without_region(text).splitlines()) > THRESHOLD
+27
View File
@@ -436,6 +436,33 @@ class TypeResolver:
type_spec = self.load_type_spec(type_path, source_file)
return type_spec['frontmatter'].get('title_prefix') or ""
def get_guidance(self, type_path: str, source_file: Path = None) -> Optional[str]:
"""Return the stack-owned guidance body a type-spec's `guidance:`
field points to, or None if it declares none - the common case for a
type an instance writes entirely for itself (Gitea #104).
The linked file is loaded as a type-spec-shaped document (`type:
types/type-guidance.md`, `name:`, `description:`) the same way
`schema:` is resolved, so a broken link or a malformed guidance file
fails the same way a broken `schema:` would rather than silently
returning nothing.
Args:
type_path: The type path to resolve, e.g. 'types/entity.md'
source_file: The source file path (for relative type resolution)
Raises:
ValueError: If the type path cannot be resolved, or `guidance:`
names a path that cannot be resolved or does not validate as
a type-guidance document.
"""
type_spec = self.load_type_spec(type_path, source_file)
guidance_path = type_spec['frontmatter'].get('guidance')
if not guidance_path:
return None
guidance_spec = self.load_type_spec(guidance_path, type_spec['path'])
return guidance_spec['body']
def get_page_ref_fields(self, type_path: str, source_file: Path = None) -> list:
"""Return the frontmatter fields whose entries are wiki page titles
(e.g. `['related', 'sources']` for an entity), as declared by the
+7
View File
@@ -59,6 +59,8 @@ def describe_type(name: str) -> Dict[str, Any]:
type_spec = resolver.load_type_spec(type_path)
frontmatter = type_spec["frontmatter"]
schema = resolver.get_schema(type_path)
guidance_path = frontmatter.get("guidance")
guidance_body = resolver.get_guidance(type_path) if guidance_path else None
fields: list[Dict[str, Any]] = []
if schema is not None:
@@ -86,5 +88,10 @@ def describe_type(name: str) -> Dict[str, Any]:
"base_dir": frontmatter.get("base_dir"),
"title_prefix": frontmatter.get("title_prefix"),
"fields": fields,
# `body` stays the type-spec's own body, unchanged - additive fields
# below it keep the MCP wire contract readable for an older client
# that has never heard of the guidance split (Gitea #104).
"body": type_spec["body"].strip(),
"guidance_path": guidance_path,
"guidance": guidance_body.strip() if guidance_body is not None else None,
}
+221 -32
View File
@@ -459,39 +459,213 @@ def changes_section(text: str, version: Version) -> Optional[str]:
return None
def _bumps_block(titles: list[str]) -> str:
lines = "\n".join(f"- {title}" for title in titles)
return f"{_BUMPS_OPEN}\n{lines}\n{_BUMPS_CLOSE}"
# Gitea #95: a long-running candidate's bump list grew to 20 chronological,
# ungraded titles (v5.0.0, ~1440 lines) - unreadable as a release announcement.
# 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)
if not match:
return []
return [
line[2:].strip()
for line in match.group(1).strip("\n").splitlines()
if line.strip().startswith("- ")
entries: list[tuple[str, str]] = []
current = DEFAULT_IMPACT
for line in match.group(1).strip("\n").splitlines():
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:
"""Add or replace the one-line `marker ...` paragraph in `section`.
Used for the breaking-change and no-migration lines, which - unlike the
bumps list - are not accumulated: a later bump that repeats `--breaking`
restates it rather than growing a list nobody would read as history.
Used for the no-migration line, which - unlike the bumps list and unlike
the breaking-change paragraph below - is **not** accumulated: it answers
one yes/no question about the candidate as a whole ("does content have to
change?"), so a second answer replaces the first rather than joining it,
and `_clear_marker_line` is its retraction path.
Anchored just above the bumps region (not below it, as before Gitea #95):
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)
if pattern.search(section):
return pattern.sub(line, section, count=1)
anchor = section.find(_BUMPS_CLOSE)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
insert_at = section.find("\n", anchor)
insert_at = insert_at + 1 if insert_at != -1 else len(section)
else:
insert_at = len(section)
return section[:insert_at] + f"\n{line}\n" + section[insert_at:]
return section[:anchor] + f"{line}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{line}\n"
# The breaking-change paragraph, matched whole: the marker line plus any `- `
# bullets under it. `_set_marker_line`'s `^marker.*$` reaches the first line
# only, which is exactly wrong for a form that can carry bullets beneath it.
_BREAKING_PARAGRAPH_RE = re.compile(
rf"^{re.escape(BREAKING_CHANGE_MARKER)}.*(?:\n-[ \t].*)*$", re.MULTILINE
)
def breaking_reasons(section: str) -> list[str]:
"""The breaking-change paragraph parsed back into one reason per crossing,
in written order.
Two shapes read the same way, which is what lets an entry written before
accumulation existed round-trip untouched: `**Breaking Change:** <reason>`
is one reason, and a bare `**Breaking Change:**` followed by `- ` bullets
is one reason per bullet. Same "flat while there is only one of them"
trick `_bumps_block` plays with its impact groups, and for the same
reason - the common case keeps the shape it always had.
"""
match = _BREAKING_PARAGRAPH_RE.search(section)
if not match:
return []
lines = match.group(0).splitlines()
head = lines[0][len(BREAKING_CHANGE_MARKER):].strip()
reasons = [head] if head else []
reasons += [line.strip()[2:].strip() for line in lines[1:]]
return [reason for reason in reasons if reason]
def _breaking_paragraph(reasons: list[str]) -> str:
"""Render the breaking-change paragraph from one reason per crossing.
One reason stays on the marker line - byte-identical to what every entry
written before accumulation carries. Two or more move to bullets under a
bare marker, because a single line holding two unrelated breakages reads
as one run-on sentence and an operator has to act on each separately.
"""
if len(reasons) == 1:
return f"{BREAKING_CHANGE_MARKER} {reasons[0]}"
bullets = "\n".join(f"- {reason}" for reason in reasons)
return f"{BREAKING_CHANGE_MARKER}\n{bullets}"
def _add_breaking_reason(section: str, reason: str) -> str:
"""Append `reason` to the breaking-change paragraph, or start one.
Accumulates rather than replaces: a candidate can cross the compatibility
boundary more than once (this is the normal shape of a long-running one),
and each crossing is a separate thing the operator of an existing instance
has to act on. Replacing meant the second `--breaking` silently deleted
the first - the entry then promised a single break while shipping two.
Repeating a reason verbatim is a no-op, so a re-run after an interrupted
bump converges instead of writing the same sentence twice.
"""
existing = breaking_reasons(section)
if reason in existing:
return section
paragraph = _breaking_paragraph(existing + [reason])
if existing:
return _BREAKING_PARAGRAPH_RE.sub(lambda _m: paragraph, section, count=1)
anchor = section.find(_BUMPS_OPEN)
if anchor != -1:
return section[:anchor] + f"{paragraph}\n\n" + section[anchor:]
return section.rstrip() + f"\n\n{paragraph}\n"
def _clear_marker_line(section: str, marker: str) -> str:
@@ -525,10 +699,17 @@ def _update_open_candidate(
breaking_reason: Optional[str],
no_migration_reason: Optional[str],
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""Move the topmost entry's heading to `version`/`date`/`title`, append
`title` to its machine-managed bump list, and set the breaking/no-migration
lines only where this call supplies them - see `insert_changes_entry`.
`(impact, title)` to its machine-managed bump list, and record the
breaking/no-migration lines only where this call supplies them - see
`insert_changes_entry`.
The two are recorded differently on purpose: a `breaking_reason` **joins**
whatever crossings the candidate already recorded (`_add_breaking_reason`),
a `no_migration_reason` **replaces** the single line that answers whether
content has to change (`_set_marker_line`).
`migration_required` retracts an earlier `--no-migration` line instead of
setting one - the two are mutually exclusive on a single bump, enforced by
@@ -541,10 +722,12 @@ def _update_open_candidate(
raise VersionError(f"{CHANGES_FILENAME}'s topmost entry has no parseable version heading")
section = f"## {version} - {date} - {title}" + section[heading_match.end():]
section = _BUMPS_RE.sub(lambda _m: _bumps_block(_bump_titles(section) + [title]), section, count=1)
section = _BUMPS_RE.sub(
lambda _m: _bumps_block(bump_entries(section) + [(impact, title)]), section, count=1
)
if breaking_reason:
section = _set_marker_line(section, BREAKING_CHANGE_MARKER, f"{BREAKING_CHANGE_MARKER} {breaking_reason}")
section = _add_breaking_reason(section, breaking_reason)
if no_migration_reason:
section = _set_marker_line(section, MIGRATION_NONE_MARKER, f"{MIGRATION_NONE_MARKER} - {no_migration_reason}")
elif migration_required:
@@ -562,6 +745,7 @@ def insert_changes_entry(
no_migration_reason: Optional[str] = None,
breaking_reason: Optional[str] = None,
migration_required: bool = False,
impact: str = DEFAULT_IMPACT,
) -> str:
"""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
@@ -573,14 +757,19 @@ def insert_changes_entry(
the topmost heading still a pre-release" the right test for "is a
candidate still open" here.
A fresh entry gets the skeleton only: heading, date, author, the
machine-managed bump-title list (started with this one title, for a
candidate), and - when a compatibility boundary is crossed - the line
saying what breaks, plus the line saying no content has to change where
that applies. The break comes first: it is what an operator reading the
release notes has to act on, and the migration line only qualifies it. The
entry's actual prose is written afterwards by whoever made the change,
which is also why `bump` refuses to invent a title.
A fresh entry gets the skeleton only: heading, date, author, - when a
compatibility boundary is crossed - the line saying what breaks (one
crossing, so the flat one-line form; a candidate that crosses again
accumulates bullets there, see `_add_breaking_reason`), plus the
line saying no content has to change where that applies, and then the
machine-managed bump list (started with this one `(impact, title)` pair,
for a candidate). The break comes first, above the bump list rather than
below it (Gitea #95): it is what an operator reading the release notes has
to act on, the migration line only qualifies it, and neither should sit
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
candidate, so a fresh entry ignores it - there is no earlier
@@ -591,16 +780,16 @@ def insert_changes_entry(
return _update_open_candidate(
text, version, date, title,
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}", ""]
if version.is_prerelease:
lines += [_bumps_block([title]), ""]
if breaking_reason:
lines += [f"{BREAKING_CHANGE_MARKER} {breaking_reason}", ""]
if 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"
anchor = re.search(r"^## ", text, re.MULTILINE)
if anchor:
+41
View File
@@ -0,0 +1,41 @@
---
type: types/type-guidance.md
name: comparison
description: When to write a comparison page instead of a neighboring type, and how to write a conforming one - structure and analysis rules that hold regardless of what this instance is comparing
---
# Comparison Guidance
`comparison` is the type for pages that set several entities, tools, technologies or approaches
systematically against one another along fixed criteria. Comparison pages support decisions and
make the trade-offs between alternatives visible.
## When to use
- Comparing several technologies or frameworks for a concrete purpose
- Analysing the advantages and disadvantages of different tools or libraries
- Evaluating alternative ways of solving a problem
- Documenting a decision process with several options examined
## When NOT to use
- For documenting a single entity or concept (use `entity` or `concept` respectively)
- For summaries of source material (use `source`)
- For pages covering a single subject rather than comparing several
## Authoring guidance
- A title that names the comparison (e.g. "Go vs Rust", "Kubernetes vs Docker Swarm"); it follows the established names of the subjects compared, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
- State clearly what is being compared and why
- Use a comparison table with the criteria as rows
- An analysis that evaluates the table rather than repeating it
- A recommendation wherever one can be justified
---
Relevant Notes:
- Comparison pages support technology selection and architecture decisions
- The entities compared should all exist as wiki pages
- The criteria should fit the decision at hand
- A comparison stays focused on one concrete purpose or decision context
+21 -43
View File
@@ -1,50 +1,37 @@
---
type: types/type-spec.md
name: comparison
description: Strukturierter Typ für Vergleichsseiten, die mehrere Entities oder Ansätze gegenüberstellen
description: Structured type for comparison pages that set several entities or approaches against one another
schema: types/comparison.schema.yaml
base_dir: comparisons
page_ref_fields: [entities, related]
guidance: types/comparison.guidance.md
---
# Comparison
`comparison` ist der Typ für Seiten, die mehrere Entities, Werkzeuge, Technologien oder Ansätze systematisch anhand festgelegter Kriterien gegenüberstellen. Vergleichsseiten stützen Entscheidungen und machen Zielkonflikte zwischen Alternativen sichtbar.
## Wann zu verwenden
- Vergleich mehrerer Technologien oder Frameworks für einen konkreten Einsatzzweck
- Analyse der Vor- und Nachteile verschiedener Werkzeuge oder Bibliotheken
- Bewertung alternativer Lösungswege für ein Problem
- Dokumentation eines Entscheidungsprozesses mit mehreren geprüften Optionen
## Wann NICHT zu verwenden
- Für die Dokumentation einer einzelnen Entity oder eines Concepts (dann `entity` bzw. `concept`)
- Für Zusammenfassungen von Quellmaterial (dann `source`)
- Für Seiten, die einen einzelnen Gegenstand behandeln statt mehrere zu vergleichen
This instance's configuration for the `comparison` type: its frontmatter fields as this schema
requires them, and the page skeleton `wikitool new comparison` scaffolds. When to use this type
and how to write a conforming page is [types/comparison.guidance.md](comparison.guidance.md) -
`tools/wikitool types describe comparison` composes both into one answer.
## Frontmatter
| Feld | Pflicht | Verwendung |
| Field | Required | Use |
|---|---:|---|
| `type` | Ja | `types/comparison.md` |
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
| `entities` | Ja | Titel der verglichenen Entities |
| `related` | Nein | Deklarierte ausgehende Kanten - je Subjekt eine `compares-with`-Kante, geschrieben von `wikitool xref add` |
| `summary` | Ja | Einzeiler für `kb/index.md` |
## Autorenanweisungen
- Ein Titel, der den Vergleich benennt (z. B. "Go vs Rust", "Kubernetes vs Docker Swarm"); er folgt den etablierten Namen der verglichenen Gegenstände, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
- Klar darlegen, was verglichen wird und warum
- Eine Vergleichstabelle mit den Kriterien als Zeilen verwenden
- Eine Analyse, die die Tabelle auswertet statt sie zu wiederholen
- Eine Empfehlung, wo eine begründbar ist
| `type` | Yes | `types/comparison.md` |
| `tags` | No | Navigation tags for categorization |
| `created` | Yes | Creation date (YYYY-MM-DD) |
| `entities` | Yes | Titles of the entities compared |
| `related` | No | Declared outbound edges - one `compares-with` edge per subject, written by `wikitool xref add` |
| `summary` | Yes | One-liner for `kb/index.md` |
## Template
The block below is page material, so it is written in this instance's KB language
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
become the headings of every page `wikitool new comparison` scaffolds.
```markdown
# Comparison: {name}
@@ -67,16 +54,7 @@ TODO: Auswertung des Vergleichs. Welche Muster zeigen sich? Was sind die entsche
TODO: Falls möglich - was wann und für wen zu verwenden ist. Unter welchen Umständen ist welche Option die beste?
```
`# Comparison:` bleibt als Präfix stehen - anders als bei `source` ist es kein `title_prefix`,
sondern reine Template-Konvention, und der Seitentitel selbst (`Go vs Rust`) trägt es nicht.
Fügt `wikitool xref` eine Beziehung hinzu, entsteht der toolgeführte Querverweis-Abschnitt; wie
er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
---
Ergänzende Hinweise:
- Vergleichsseiten stützen Technologieauswahl und Architekturentscheidungen
- Die verglichenen Entities sollten alle als Wiki-Seiten existieren
- Die Kriterien sollten zur anstehenden Entscheidung passen
- Ein Vergleich bleibt auf einen konkreten Einsatzzweck oder Entscheidungskontext fokussiert
`# Comparison:` stays as a prefix - unlike `source` it is no `title_prefix` but a pure template
convention, and the page title itself (`Go vs Rust`) does not carry it. When `wikitool xref` adds
a relationship, the tool-managed cross-reference section appears; what it is called is the
instance's decision in `kb/CONVENTIONS.md` (`sections:`).
+43
View File
@@ -0,0 +1,43 @@
---
type: types/type-guidance.md
name: concept
description: When to write a concept page instead of a neighboring type, and how to write a conforming one - citation, linking and register rules that hold regardless of which concept_type values this instance chose
---
# Concept Guidance
`concept` is the type for abstract ideas, methodologies, patterns and architectural decisions.
Concepts provide the theoretical and methodological ground that entities implement or follow.
## When to use
- Documenting an architectural pattern or design approach
- Explaining a design pattern or a programming methodology
- Describing a communication protocol or standard
- Recording a workflow or process that recurs across projects
- Documenting an architectural decision (ADR) or design decision
- Analysing a recurring problem and its solutions
## When NOT to use
- For concrete things such as projects, systems or tools (use `entity`)
- For source documents or ingested material (use `source`)
- For comparative analyses of concrete subjects (use `comparison`)
## Authoring guidance
- The title is the concept's canonical name and follows the established term of art, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
- Open with a clear definition: what the concept is
- Give examples wherever they carry the understanding
- Link to the entities that implement or use the concept
- Under `provenance: sourced` or `mixed`, back hard facts inline with a `[^cite-id]` footnote -
`tools/wikitool cite add --page "<Title>" --source "Source - X"` mints the id and its definition
- Wikipedia register: factual, neutral, no marketing language
---
Relevant Notes:
- Concept pages carry the wiki's theoretical underpinning
- Each concept type stands for a different category of abstract knowledge
- Concepts should be linked to from the entities that implement or use them
+23 -48
View File
@@ -1,11 +1,12 @@
---
type: types/type-spec.md
name: concept
description: Basistyp für Concept-Seiten - Architekturen, Muster, Protokolle, Abläufe, Entscheidungen oder Problemstellungen
description: Base type for concept pages - architectures, patterns, protocols, workflows, decisions or problems
schema: types/concept.schema.yaml
subtype_field: concept_type
base_dir: concepts
page_ref_fields: [related, sources]
guidance: types/concept.guidance.md
layout:
architecture: {dir: architectures, title: Architekturen}
pattern: {dir: patterns, title: Muster}
@@ -17,49 +18,31 @@ layout:
# Concept
`concept` ist der Typ für abstrakte Ideen, Methodiken, Muster und Architekturentscheidungen. Concepts liefern die theoretische und methodische Grundlage, die Entities umsetzen oder befolgen.
## Wann zu verwenden
- Dokumentation eines Architekturmusters oder Entwurfsansatzes
- Erläuterung eines Entwurfsmusters oder einer Programmiermethodik
- Beschreibung eines Kommunikationsprotokolls oder Standards
- Festhalten eines Ablaufs oder Prozesses, der über Projekte hinweg wiederkehrt
- Dokumentation einer Architekturentscheidung (ADR) oder Entwurfsentscheidung
- Analyse eines wiederkehrenden Problems und seiner Lösungen
## Wann NICHT zu verwenden
- Für konkrete Dinge wie Projekte, Systeme oder Werkzeuge (dann `entity`)
- Für Quelldokumente oder eingelesenes Material (dann `source`)
- Für vergleichende Analysen konkreter Gegenstände (dann `comparison`)
This instance's configuration for the `concept` type: its frontmatter fields as this schema
requires them, and the page skeleton `wikitool new concept` scaffolds. When to use this type and
how to write a conforming page is [types/concept.guidance.md](concept.guidance.md) -
`tools/wikitool types describe concept` composes both into one answer.
## Frontmatter
| Feld | Pflicht | Verwendung |
| Field | Required | Use |
|---|---:|---|
| `type` | Ja | `types/concept.md` |
| `concept_type` | Ja | Eines von: architecture, pattern, protocol, workflow, decision, problem |
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
| `modified` | Ja | Datum der letzten Änderung (YYYY-MM-DD) |
| `related` | Nein | Titel verwandter Concepts und Entities |
| `sources` | Nein | Titel der Source-Seiten, die die Aussagen dieser Seite stützen |
| `provenance` | Ja | sourced, general oder mixed |
| `summary` | Ja | Einzeiler für `kb/index.md` |
## Autorenanweisungen
- Der Titel ist der kanonische Name des Concepts und folgt der etablierten Fachbezeichnung, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
- Mit einer klaren Definition beginnen: was das Concept ist
- Beispiele geben, wo sie das Verständnis tragen
- Auf Entities verlinken, die das Concept umsetzen oder verwenden
- Bei `provenance: sourced` oder `mixed` harte Fakten inline mit einer `[^cite-id]`-Fußnote belegen -
`tools/wikitool cite add --page "<Title>" --source "Source - X"` erzeugt Id und Definition
- Wikipedia-Tonfall: sachlich, neutral, keine Werbesprache
| `type` | Yes | `types/concept.md` |
| `concept_type` | Yes | One of: architecture, pattern, protocol, workflow, decision, problem |
| `tags` | No | Navigation tags for categorization |
| `created` | Yes | Creation date (YYYY-MM-DD) |
| `modified` | Yes | Date last changed (YYYY-MM-DD) |
| `related` | No | Titles of related concepts and entities |
| `sources` | No | Titles of the source pages backing this page's claims |
| `provenance` | Yes | sourced, general or mixed |
| `summary` | Yes | One-liner for `kb/index.md` |
## Template
The block below is page material, so it is written in this instance's KB language
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
become the headings of every page `wikitool new concept` scaffolds.
```markdown
# {name}
@@ -94,15 +77,7 @@ TODO: Anti-Muster, Warnungen oder Situationen, in denen es fehl am Platz ist
- [[Related Concept 2]]
```
Der Wert hinter `**Typ:**` bleibt der englische Enum-Wert - danach filtert `search --field`.
Fügt `wikitool xref` eine Beziehung hinzu, entstehen zusätzlich die beiden toolgeführten
Abschnitte für Beziehungen und Querverweise; wie sie heißen, entscheidet die Instanz in
The value behind `**Typ:**` stays the English enum value - that is what `search --field` filters
on. When `wikitool xref` adds a relationship, the two tool-managed sections for relationships and
cross-references appear as well; what they are called is the instance's decision in
`kb/CONVENTIONS.md` (`sections:`).
---
Ergänzende Hinweise:
- Concept-Seiten tragen den theoretischen Unterbau des Wikis
- Jeder Concept-Typ steht für eine andere Kategorie abstrakten Wissens
- Concepts sollten von den Entities aus verlinkt sein, die sie umsetzen oder verwenden
+41
View File
@@ -0,0 +1,41 @@
---
type: types/type-guidance.md
name: entity
description: When to write an entity page instead of a neighboring type, and how to write a conforming one - citation, linking and register rules that hold regardless of which entity_type values this instance chose
---
# Entity Guidance
`entity` is the type for concrete things: projects, systems, tools, technologies or people.
Entities are the primary building blocks of the knowledge graph.
## When to use
- Representing a software project, an initiative or a piece of work
- Documenting a running system, service or infrastructure component
- Describing a CLI tool, utility or software library
- Recording information about a language, a framework or a protocol
- Documenting a person, a team or an organization
## When NOT to use
- For abstract concepts, patterns or methodologies (use `concept`)
- For source documents or ingested material (use `source`)
- For comparative analyses (use `comparison`)
## Authoring guidance
- The title is the entity's canonical name and follows the subject's established designation, not the KB language (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" and "Language")
- The main description goes near the top
- Link to related entities and concepts wherever relationships exist
- Under `provenance: sourced` or `mixed`, back hard facts inline with a `[^cite-id]` footnote -
`tools/wikitool cite add --page "<Title>" --source "Source - X"` mints the id and its definition
- Wikipedia register: factual, neutral, no marketing language
---
Relevant Notes:
- Entity pages form the concrete inventory of the knowledge base
- Every entity type (project, system, tool and so on) is a variant of this base type
- The relationships between entities carry the knowledge graph's interconnection
+24 -47
View File
@@ -1,11 +1,12 @@
---
type: types/type-spec.md
name: entity
description: Basistyp für Entity-Seiten - Projekte, Systeme, Werkzeuge, Technologien oder Personen
description: Base type for entity pages - projects, systems, tools, technologies or people
schema: types/entity.schema.yaml
subtype_field: entity_type
base_dir: entities
page_ref_fields: [related, sources]
guidance: types/entity.guidance.md
layout:
project: {dir: projects, title: Projekte}
system: {dir: systems, title: Systeme}
@@ -16,47 +17,31 @@ layout:
# Entity
`entity` ist der Typ für konkrete Dinge: Projekte, Systeme, Werkzeuge, Technologien oder Personen. Entities sind die primären Bausteine des Wissensgraphen.
## Wann zu verwenden
- Darstellung eines Softwareprojekts, einer Initiative oder eines Arbeitsvorhabens
- Dokumentation eines betriebenen Systems, Dienstes oder einer Infrastrukturkomponente
- Beschreibung eines CLI-Werkzeugs, Hilfsprogramms oder einer Programmbibliothek
- Festhalten von Informationen zu einer Sprache, einem Framework oder einem Protokoll
- Dokumentation einer Person, eines Teams oder einer Organisation
## Wann NICHT zu verwenden
- Für abstrakte Konzepte, Muster oder Methodiken (dann `concept`)
- Für Quelldokumente oder eingelesenes Material (dann `source`)
- Für vergleichende Analysen (dann `comparison`)
This instance's configuration for the `entity` type: its frontmatter fields as this schema
requires them, and the page skeleton `wikitool new entity` scaffolds. When to use this type and
how to write a conforming page is [types/entity.guidance.md](entity.guidance.md) -
`tools/wikitool types describe entity` composes both into one answer.
## Frontmatter
| Feld | Pflicht | Verwendung |
| Field | Required | Use |
|---|---:|---|
| `type` | Ja | `types/entity.md` |
| `entity_type` | Ja | Eines von: project, system, tool, technology, person |
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
| `created` | Ja | Erstellungsdatum (YYYY-MM-DD) |
| `modified` | Ja | Datum der letzten Änderung (YYYY-MM-DD) |
| `related` | Nein | Titel verwandter Seiten (Entities, Concepts, Sources) |
| `sources` | Nein | Titel der Source-Seiten, die die Aussagen dieser Seite stützen |
| `provenance` | Ja | sourced, general oder mixed |
| `summary` | Ja | Einzeiler für `kb/index.md` |
## Autorenanweisungen
- Der Titel ist der kanonische Name der Entity und folgt der etablierten Bezeichnung des Gegenstands, nicht der KB-Sprache (`kb/CONTRACT.md` § "Titles are identifiers", `kb/CONVENTIONS.md` §§ "Naming" und "Language")
- Die Hauptbeschreibung steht weit oben
- Auf verwandte Entities und Concepts verlinken, wo Beziehungen bestehen
- Bei `provenance: sourced` oder `mixed` harte Fakten inline mit einer `[^cite-id]`-Fußnote belegen -
`tools/wikitool cite add --page "<Title>" --source "Source - X"` erzeugt Id und Definition
- Wikipedia-Tonfall: sachlich, neutral, keine Werbesprache
| `type` | Yes | `types/entity.md` |
| `entity_type` | Yes | One of: project, system, tool, technology, person |
| `tags` | No | Navigation tags for categorization |
| `created` | Yes | Creation date (YYYY-MM-DD) |
| `modified` | Yes | Date last changed (YYYY-MM-DD) |
| `related` | No | Titles of related pages (entities, concepts, sources) |
| `sources` | No | Titles of the source pages backing this page's claims |
| `provenance` | Yes | sourced, general or mixed |
| `summary` | Yes | One-liner for `kb/index.md` |
## Template
The block below is page material, so it is written in this instance's KB language
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
become the headings of every page `wikitool new entity` scaffolds.
```markdown
# {name}
@@ -84,15 +69,7 @@ TODO: Ausführliche Informationen, nach sinnvollen Abschnitten gegliedert
- [{today}] - Page created via wikitool
```
Der Beziehungsabschnitt steht bewusst **nicht** im Template: er ist eine generierte Region, die
`wikitool xref` beim ersten Kanteneintrag zwischen Markern anlegt und aus `related:` neu
rendert. Ein Autor schreibt dort nie hinein. Der Wert hinter `**Typ:**` bleibt der englische
Enum-Wert - danach filtert `search --field`.
---
Ergänzende Hinweise:
- Entity-Seiten bilden den konkreten Bestand der Wissensbasis
- Jeder Entity-Typ (project, system, tool usw.) ist eine Ausprägung dieses Basistyps
- Die Beziehungen zwischen Entities tragen die Vernetzung des Wissensgraphen
The relationships section is deliberately **not** in the template: it is a generated region that
`wikitool xref` creates between markers on the first edge entry and re-renders from `related:`.
An author never writes into it. The value behind `**Typ:**` stays the English enum value - that is
what `search --field` filters on.
+1 -1
View File
@@ -65,4 +65,4 @@ Relevant Notes:
- This type declares no `page_ref_fields:`: a report references pages in prose
only, never through frontmatter.
- It also declares no `base_dir:`, which is what keeps `reports/` out of `kb/`
and out of the index.
and out of the index.
+63
View File
@@ -0,0 +1,63 @@
---
type: types/type-guidance.md
name: source
description: When to write a source page instead of a neighboring type, and how to write a conforming one - capture-field, provenance and not-extracted rules that hold regardless of which source_type values this instance chose
---
# Source Guidance
`source` is the type for pages that summarize and catalogue ingested raw material. Source pages
are the bridge between the `raw/` layer (immutable source files) and the `kb/` layer (compiled
knowledge). One source page stands for **one logical source**, which may span several raw files.
## When to use
- Summarizing a single external article, document or specification
- Recording several related notes or meeting records as one source
- Documenting an ingested PDF, manual or other document
- Capturing information about an image or a diagram
## When NOT to use
- For compiled knowledge (use `entity` or `concept`)
- For comparative analyses (use `comparison`)
- For original wiki content not derived from raw material
## Authoring guidance
- `source_type` has **no default** - `wikitool new source` refuses without an explicit value. Where the category is unclear, set `unclassified` rather than guessing; that is a visible catalog slot with an advisory `lint` finding, not a dumping ground. What separates `analysis` from `document`, and which area holds which value, is a call this instance's own collection contract makes - see `kb/sources/COLLECTION.md`
- `raw_files` lists every raw file this source covers (one source page per logical source, not per file)
- `fidelity` and `authority` are **capture fields** (`capture_fields:` on this type): recorded at the drop point and not freely changeable afterwards. `wikitool raw accept --fidelity <value> --authority <value>` refuses without both; without `--page` it instead prints the ready-made `new source --set fidelity=... --set authority=...` line, which `new source` in turn refuses without both values. `wikitool touch --set fidelity=<value>` only writes while the field is absent - where a value already stands, it refuses and points at `raw accept --replaces` as the one correction path (a corrected capture is a new edition, not an edit). `unknown` is backfill-only: only `wikitool touch` may write it, never `raw accept` or `new source` - the same construction as `source_language` uses for pages that predate this rule. What the values mean and how they differ: [raw/CONTRACT.md](../raw/CONTRACT.md#getting-a-file-in-incoming)
- For external articles, always set `source_url` to the origin URL
- Set `source_language` to the raw material's language, not the page's
- The page is written in the KB language, whatever language the source is in; verbatim passages are quoted in the original (`kb/CONVENTIONS.md` § "Language")
- Summarize the key claims in the summary section
- Put anything actionable in the action items section
- Put deliberate omissions in the not-extracted section - see below
- Link the entities and concepts mentioned under related entities/concepts
## Not Extracted
The decision that material should *not* be taken over cannot be reconstructed: nothing in the
repository can re-derive it, and `sources coverage` only knows whether a raw file is claimed by
some source page - never whether anyone decided about its contents. Left unwritten, the same
source is renegotiated on every later pass.
- Record every deliberate omission with a **reason**, not just a filename.
- Mandatory where the ingest ran through `instructions/ingest-large-tree.md` - on both axes: for
a tree ingest the section records what was not taken from the tree; for a thematically broad
single source, which named subjects got no page of their own, and why. Optional for a single
small file - but an empty section still beats a missing one.
- Belongs on the source page, not in `kb/log.md`: it is a statement about *this* source, and the
log is chronological rather than per-source.
---
Relevant Notes:
- Source pages are the authoritative catalogue of what raw material has been ingested
- **One raw file, one owner.** A raw file appears in exactly one `raw_files:` - that page is responsible for keeping it summarized. Any number of pages may **cite** it via `[^cite-id]`; a citation is reuse, `raw_files:` is a maintenance responsibility. With two claimants it is undefined which page has to be brought up to date when the raw file changes - and then both rot quietly
- Source pages make knowledge traceable back to the original raw material
- `raw_files:` holds concrete existing file paths, never directories
- A `raw_files:` list beyond roughly 15 entries indicates the cut was too coarse - the source should have been split into several source pages via `instructions/ingest-large-tree.md`
- `entities:` plus `concepts:` beyond roughly 20 entries is the counterpart on the other axis: not cut too little, but compiled too much at once. Such a source is not split - a raw file has one owner - it needed the extract pass from `instructions/ingest-large-tree.md` § "A broad source is not cut", so that not every named subject gets a page
+30 -78
View File
@@ -1,13 +1,14 @@
---
type: types/type-spec.md
name: source
description: Strukturierter Typ für Source-Seiten, die eingelesenes Rohmaterial erfassen und zusammenfassen
description: Structured type for source pages that record and summarize ingested raw material
schema: types/source.schema.yaml
subtype_field: source_type
base_dir: sources
title_prefix: "Source - "
page_ref_fields: [entities, concepts]
capture_fields: [fidelity, authority]
guidance: types/source.guidance.md
layout:
transcript: {dir: transcripts, title: Transkripte}
analysis: {dir: analyses, title: Analysen}
@@ -20,71 +21,39 @@ layout:
# Source
`source` ist der Typ für Seiten, die eingelesenes Rohmaterial zusammenfassen und katalogisieren. Source-Seiten sind die Brücke zwischen der `raw/`-Schicht (unveränderliche Quelldateien) und der `kb/`-Schicht (kompiliertes Wissen). Eine Source-Seite steht für **eine logische Quelle**, die mehrere Raw-Dateien umfassen kann.
## Wann zu verwenden
- Zusammenfassung eines einzelnen externen Artikels, Dokuments oder einer Spezifikation
- Erfassung mehrerer zusammengehöriger Notizen oder Gesprächsprotokolle als eine Quelle
- Dokumentation eines eingelesenen PDFs, Handbuchs oder sonstigen Dokuments
- Festhalten von Informationen zu einem Bild oder Diagramm
## Wann NICHT zu verwenden
- Für kompiliertes Wissen (dann `entity` oder `concept`)
- Für vergleichende Analysen (dann `comparison`)
- Für originären Wiki-Inhalt, der nicht aus Rohmaterial abgeleitet ist
This instance's configuration for the `source` type: its frontmatter fields as this schema
requires them, and the page skeleton `wikitool new source` scaffolds. When to use this type and
how to write a conforming page is [types/source.guidance.md](source.guidance.md) -
`tools/wikitool types describe source` composes both into one answer.
## Frontmatter
| Feld | Pflicht | Verwendung |
| Field | Required | Use |
|---|---:|---|
| `type` | Ja | `types/source.md` |
| `source_type` | Ja | Eines von: transcript, analysis, article, document, notes, tracker, unclassified - kein Default, siehe unten |
| `author` | Ja | Urheber des Quellmaterials |
| `raw_files` | Ja | Raw-Dateipfade, die diese Quelle **besitzt** - siehe "Eine Raw-Datei, ein Besitzer" unten |
| `fidelity` | Ja (im Werkzeug, nicht im Schema) | Wie treu die *Erfassung* ist: `verbatim`, `published`, `secondhand`, `nontextual` - Capture-Feld, siehe unten |
| `authority` | Ja (im Werkzeug, nicht im Schema) | Was das Material über seinen *Gegenstand* behaupten darf: `normative`, `reporting`, `opinion` - Capture-Feld, siehe unten |
| `source_url` | Nein | Ursprungs-URL bei externen Quellen |
| `source_language` | Nein | ISO-639-1-Code der Sprache des Rohmaterials, z. B. `de`, `en`, `fr` |
| `date` | Ja | Veröffentlichungs- oder Erstellungsdatum (YYYY-MM-DD) |
| `tags` | Nein | Navigations-Tags zur Kategorisierung |
| `entities` | Nein | Titel der in dieser Quelle erwähnten Entities |
| `concepts` | Nein | Titel der in dieser Quelle erwähnten Concepts |
| `summary` | Ja | Einzeiler für `kb/index.md` |
| `type` | Yes | `types/source.md` |
| `source_type` | Yes | One of: transcript, analysis, article, document, notes, tracker, unclassified - no default, see below |
| `author` | Yes | Originator of the source material |
| `raw_files` | Yes | Raw file paths this source **owns** - see "One raw file, one owner" below |
| `fidelity` | Yes (in the tool, not in the schema) | How faithful the *capture* is: `verbatim`, `published`, `secondhand`, `nontextual` - a capture field, see below |
| `authority` | Yes (in the tool, not in the schema) | What the material may claim about its *subject*: `normative`, `reporting`, `opinion` - a capture field, see below |
| `source_url` | No | Origin URL for external sources |
| `source_language` | No | ISO 639-1 code of the raw material's language, e.g. `de`, `en`, `fr` |
| `date` | Yes | Publication or creation date (YYYY-MM-DD) |
| `tags` | No | Navigation tags for categorization |
| `entities` | No | Titles of the entities mentioned in this source |
| `concepts` | No | Titles of the concepts mentioned in this source |
| `summary` | Yes | One-liner for `kb/index.md` |
## Autorenanweisungen
## Authoring guidance
- Der Titel beginnt mit "Source - ", gefolgt vom Namen der Quelle
- `source_type` hat **keinen Default** - `wikitool new source` verweigert ohne einen expliziten Wert. Ist die Kategorie unklar, `unclassified` setzen statt zu raten; das ist ein sichtbares Katalogfach mit beratendem `lint`-Befund, kein Sammelbecken. Was `analysis` von `document` trennt und welche Area welchen Wert hält: [kb/sources/COLLECTION.md](../kb/sources/COLLECTION.md)
- `raw_files` listet jede Raw-Datei, die diese Quelle abdeckt (eine Source-Seite pro logischer Quelle, nicht pro Datei)
- `fidelity` und `authority` sind **Capture-Felder** (`capture_fields:` oben): am Drop-Punkt erhoben, danach nicht mehr frei änderbar. `wikitool raw accept --fidelity <wert> --authority <wert>` verweigert ohne beide; ohne `--page` druckt es stattdessen die fertige `new source --set fidelity=... --set authority=...`-Zeile, die `new source` seinerseits ohne beide Werte verweigert. `wikitool touch --set fidelity=<wert>` schreibt nur, solange das Feld fehlt - steht bereits ein Wert, verweigert es und verweist auf `raw accept --replaces` als einzigen Korrekturweg (eine korrigierte Erfassung ist eine neue Edition, keine Bearbeitung). `unknown` ist backfill-only: nur `wikitool touch` darf es schreiben, nie `raw accept` oder `new source` - dieselbe Konstruktion wie bei `source_language` für Seiten, die vor dieser Regel entstanden sind. Was die Werte bedeuten und wie sie sich unterscheiden: [raw/CONTRACT.md](../raw/CONTRACT.md#getting-a-file-in-incoming)
- Bei externen Artikeln immer `source_url` auf die Ursprungs-URL setzen
- `source_language` auf die Sprache des Rohmaterials setzen, nicht auf die der Seite
- Die Seite wird in der KB-Sprache geschrieben, unabhängig von der Sprache der Quelle; wörtliche Passagen werden im Original zitiert (`kb/CONVENTIONS.md` § "Language")
- Kernaussagen im Abschnitt Summary zusammenfassen
- Handlungsbedarf in den Abschnitt Action Items
- Bewusst Weggelassenes in den Abschnitt Not Extracted - siehe unten
- Erwähnte Entities und Concepts unter Related Entities/Concepts verlinken
## Not Extracted
Die Entscheidung, dass Material *nicht* übernommen werden soll, ist nicht rekonstruierbar: nichts
im Repository kann sie neu herleiten, und `sources coverage` weiß nur, ob eine Raw-Datei von
irgendeiner Source-Seite beansprucht wird - nie, ob jemand über ihren Inhalt entschieden hat.
Bleibt das unaufgeschrieben, wird dieselbe Quelle bei jedem späteren Durchgang neu verhandelt.
- Jede bewusste Auslassung mit **Begründung** festhalten, nicht nur mit Dateinamen.
- Pflicht, wenn der Ingest über `instructions/ingest-large-tree.md` lief - auf beiden Achsen:
beim Tree-Ingest hält der Abschnitt fest, was aus dem Baum nicht übernommen wurde, bei einer
thematisch breiten Einzelquelle, welche genannten Gegenstände keine eigene Seite bekommen
haben und warum. Optional bei einer einzelnen kleinen Datei - aber ein leerer Abschnitt ist
immer noch besser als ein fehlender.
- Gehört auf die Source-Seite, nicht in `kb/log.md`: es ist eine Aussage über *diese* Quelle,
und das Log ist chronologisch, nicht quellenbezogen.
- The title starts with "Source - ", followed by the name of the source
## Template
The block below is page material, so it is written in this instance's KB language
(`kb/CONVENTIONS.md` `language:`) rather than in the control plane's English - its headings
become the headings of every page `wikitool new source` scaffolds.
```markdown
# Source: {name}
@@ -121,24 +90,7 @@ TODO: 2-3 Absätze zu den Kernaussagen des Quellmaterials.
{concepts|bullets}
```
`# Source:` bleibt als Präfix stehen - es spiegelt den `title_prefix` und damit den Titel, unter
dem die Seite verlinkt und zitiert wird. Der Wert hinter `**Typ:**` bleibt der englische
Enum-Wert. Fügt `wikitool cite` ein Zitat hinzu, entsteht am Seitenende der toolgeführte
Fußnoten-Block; wie er heißt, entscheidet die Instanz in `kb/CONVENTIONS.md` (`sections:`).
---
Ergänzende Hinweise:
- Source-Seiten sind der maßgebliche Katalog dessen, was an Rohmaterial eingelesen wurde
- **Eine Raw-Datei, ein Besitzer.** Eine Raw-Datei steht in genau einem `raw_files:` - diese Seite ist dafür verantwortlich, sie zusammengefasst zu halten. Beliebig viele Seiten dürfen sie per `[^cite-id]` **zitieren**; ein Zitat ist Wiederverwendung, `raw_files:` ist eine Wartungszuständigkeit. Bei zwei Anspruchstellern ist undefiniert, welche Seite bei einer Änderung der Raw-Datei nachgezogen werden muss - dann verrotten beide still
- Source-Seiten machen Wissen bis zum ursprünglichen Rohmaterial rückverfolgbar
- `raw_files:` enthält konkrete existierende Dateipfade, nie Verzeichnisse
- Eine `raw_files:`-Liste jenseits von etwa 15 Einträgen zeigt an, dass der Schnitt zu grob war -
die Quelle hätte per `instructions/ingest-large-tree.md` in mehrere Source-Seiten geteilt
werden müssen
- `entities:` plus `concepts:` jenseits von etwa 20 Einträgen ist das Gegenstück auf der anderen
Achse: nicht zu wenig geschnitten, sondern zu viel auf einmal kompiliert. Geteilt wird eine
solche Quelle nicht - eine Raw-Datei hat einen Besitzer -, sie hätte den Extract-Pass aus
`instructions/ingest-large-tree.md` § "A broad source is not cut" gebraucht, damit nicht jeder
genannte Gegenstand eine Seite bekommt
`# Source:` stays as a prefix - it mirrors the `title_prefix` and with it the title the page is
linked and cited under. The value behind `**Typ:**` stays the English enum value. When
`wikitool cite` adds a citation, the tool-managed footnote block appears at the end of the page;
what it is called is the instance's decision in `kb/CONVENTIONS.md` (`sections:`).
+65
View File
@@ -0,0 +1,65 @@
---
type: types/type-spec.md
name: type-guidance
description: "Stack-owned authoring prose for a root:kb page type - the machine-independent half a type-spec's optional guidance: field links to"
schema: types/type-guidance.schema.yaml
---
# Type Guidance
`type-guidance` describes the shape of one `types/<name>.guidance.md` file: the stack-owned half
of a `root: kb` page type's contract. A page type-spec ([types/type-spec.md](type-spec.md) §
"Who owns a type-spec") splits at the audience line - what an agent needs to know to write a
conforming page, versus what this instance's schema and template concretely are - and a guidance
file is the machine-independent half of that split, linked from the type-spec it documents via
that file's own `guidance:` frontmatter field.
## When to Use
- Writing or improving the generic authoring rules for a `root: kb` type: when to choose it, when
not to, and mechanism-level advice that holds regardless of which enum values, directory names
or template text this instance chose - citation mechanics, provenance rules, what a conforming
page does or does not claim
- Anything a later stack release would want every adopting instance to receive automatically,
the way a fix to `tools/` already does
## When NOT to Use
- Enum values, `layout:` titles, directory names or template text. Those name *this* instance's
choices and stay in the type-spec itself - the file `guidance:` points *from*, never the one it
points *to*
- A type an instance writes entirely for itself. `guidance:` is optional; a type-spec that omits
it is described from its own body alone, exactly as every type-spec worked before this type
existed
## Frontmatter
| Field | Required | Use |
|---|---:|---|
| `type` | Yes | `types/type-guidance.md` |
| `name` | Yes | The page type this documents, e.g. `entity` - matches that type-spec's own `name:` |
| `description` | Yes | Retrieval description for this guidance document |
## Conventions
- Linked from the type-spec it documents via that file's `guidance:` frontmatter field - a
repo-relative path, resolved the same way `schema:` is
- `tools/wikitool types describe <name>` composes both halves into one answer; an agent asking
for a type's contract never needs to know it comes from two files
- Written in the control plane's English ([AGENTS.md](../AGENTS.md) § File naming), like a
type-spec's own authoring prose - only the type-spec's `## Template` block and its `layout:`
titles are page material
---
Relevant Notes:
- Ships verbatim with every distribution; the type-spec it documents ships only as a `.template`
where `root: kb` - see `docs/ownership-and-templates.md` § "Where the file boundary used to
strain"
- No `## Template` section here: a guidance file's body is prose an author reads, not a body
structure `wikitool new` would scaffold. `type-guidance` declares no `base_dir:` for the same
reason `lint-report` does, and `wikitool new type-guidance` refuses for the same reason
- An instance that adopted its type-specs before this type existed sees nothing change until it
takes the offered migration that adds `guidance:` to each one - see
`instructions/migrations/` for the document
+23
View File
@@ -0,0 +1,23 @@
# YAML Schema for type-guidance documents
type: object
properties:
type:
type: string
const: "types/type-guidance.md"
description: Must reference the type-guidance type-spec
name:
type: string
description: >-
The page type this guidance documents, e.g. 'entity' - matches that
type-spec's own `name:` field
minLength: 1
pattern: "^[a-z][a-z0-9-]*$"
description:
type: string
description: Retrieval description for this guidance document
minLength: 10
required:
- type
- name
- description
additionalProperties: false
+46 -10
View File
@@ -33,6 +33,7 @@ A type-spec doc is the authoring and validation contract for one Chemenu artifac
- `description`: retrieval description for the type contract.
- `schema`: repo-relative path to the `.schema.yaml` file that validates artifacts of this type, or `null` when the type has no schema.
- `subtype_field` (optional): the frontmatter field name on instances of this type that carries a subtype/category (e.g. `entity_type`), or omitted if this type has no such field.
- `guidance` (optional): repo-relative path to a `types/type-guidance.md`-shaped document holding this type's stack-owned authoring prose - see § "Anatomy of a type" below. Meaningful only on a `root: kb` type-spec; omit for a type an instance writes entirely for itself.
## Validation Contract
@@ -43,6 +44,8 @@ Two systems check conformance to a type:
Do not restate a schema rule in body text. The validator already enforces it, so the restatement adds no protection — it only spends the reviewer's judgment re-confirming something already guaranteed, instead of on the properties only a reviewer can check.
A type-spec's own frontmatter is checked the same way a page's is: `docs verify` validates every file under `types/` declaring `type: types/type-spec.md` against `types/type-spec.schema.yaml`. A frontmatter field used here needs a matching line in that schema, or `docs verify` fails.
## Writing Shape
- Write the body as a **content contract**: checkable properties of a conforming instance, in a form the type-conformance gate can apply directly. Prefer "what a conforming instance contains or claims" over "how to produce one" — production process is invisible to the reviewer, so it belongs in a skill or instruction, linked rather than embedded.
@@ -65,13 +68,41 @@ frontmatter before anyone drew it:
| Type-spec | Describes | Owned by | Ships as |
|---|---|---|---|
| `root: kb` (`entity`, `concept`, `source`, `comparison`) | A page **this instance** writes | The instance | `types/<name>.md.template` plus its `.schema.yaml.template`, adopted by a rename |
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec` itself | A stack artifact | The stack | Verbatim |
| `root: repo` (`instruction`), no `base_dir` (`lint-report`), and `type-spec`/`type-guidance` themselves | A stack artifact | The stack | Verbatim |
A page type-spec's prose, its `## Template` body and its language are therefore the instance's
to rewrite — an instance writing its pages in another language simply translates the file, and
an upgrade does not take that back. Improvements to a shipped default reach it as an *offered*
migration ([instructions/CONTRACT.md](../instructions/CONTRACT.md#instructionsmigrations)),
never by overwriting.
A page type-spec's frontmatter configuration and its `## Template` body are therefore the
instance's to rewrite, and an upgrade does not take that back. Its generic authoring prose is the
opposite: where the type-spec declares `guidance:`, that prose lives in a separate, stack-owned
`types/<name>.guidance.md` (§ "Anatomy of a type" below) that ships verbatim and is overwritten
by an upgrade like any other machinery file - the type-spec it documents does not have to be
re-adopted, or even touched, for that improvement to arrive. A type-spec that declares no
`guidance:` (a type an instance writes entirely for itself) has no stack half to receive; it is
described from its own body alone, exactly as every type-spec worked before this split existed.
An *existing* instance that adopted its type-specs before `guidance:` existed takes this as an
*offered* migration ([instructions/CONTRACT.md](../instructions/CONTRACT.md#instructionsmigrations))
rather than something an upgrade applies on its own - see `instructions/migrations/` for the
document.
**Ownership and language still do not line up with each other**, and that stays true even though
the authoring prose now has its own file rather than sharing one with the template. Owning the
type-spec means the instance may rewrite its frontmatter and its `## Template` body; a guidance
file it links is never the instance's to rewrite at all - it is the stack's, the same way
`tools/CONTRACT.md` is. What each part is *written in* still follows what the line is *for*, per
[AGENTS.md § File naming](../AGENTS.md#file-naming), independent of which file it lives in or who
owns that file:
| Part of a `root: kb` type's contract | Lives in | Audience | Language |
|---|---|---|---|
| When to use / not to use, authoring guidance | The `guidance:` file, where declared | An agent writing a page | The control plane's — English |
| The frontmatter table documenting this instance's own fields | The type-spec itself | An agent writing a page | The control plane's — English |
| The `## Template` body, and the `layout:` titles that head a catalog section | The type-spec itself | The page itself | The instance's KB language (`kb/CONVENTIONS.md` `language:`) |
| Field names, enum values, `dir:` values, `type:` paths | Either file's frontmatter | The machine | Neither — identifiers, never translated |
That is the same prose/identifier cut [kb/CONTRACT.md](../kb/CONTRACT.md#language-and-identifiers)
makes *inside* a page, applied one level up. It binds a type-spec an instance writes for itself
exactly as it binds the four shipped ones: a new page type is instance-owned end to end, which
settles who may change it, not which language each half is written in - and it may declare its
own `guidance:` file if it wants the same shape, though nothing requires it to.
**What the stack still requires of the type layer is one line.** There must be a type-spec
declaring `name: source` whose schema requires `raw_files:` — the whole `raw/``kb/`
@@ -87,14 +118,19 @@ Python. Adding a type must require no code change.
### Anatomy of a type
Each type is two files:
Each type is at least two files, and a `root: kb` type may be three:
| File | Owns |
|------|------|
| `types/<name>.md` | The authoring contract: when to use the type, when not to, guidance, and the `## Template` block used to scaffold new pages |
| `types/<name>.md` | This instance's configuration: its frontmatter fields as this schema requires them, and the `## Template` block used to scaffold new pages. For a type with no `guidance:` (below), its own prose also carries the authoring contract - when to use the type, when not to |
| `types/<name>.schema.yaml` | The machine-checkable half: fields, types, enums, defaults, required-ness, `additionalProperties: false` |
| `types/<name>.guidance.md` (optional, `root: kb` only) | The stack-owned authoring contract: when to use the type, when not to, and mechanism-level advice that holds regardless of this instance's own enum values or template text - linked from the type-spec's own `guidance:` field. `types/type-guidance.md` is its contract |
This file is the self-referential root contract that both are validated against.
This file is the self-referential root contract every type-spec is validated against, and
`type-guidance.md` is validated against it the same way `lint-report.md` is - itself a
non-instantiable, contract-only type. `tools/wikitool types describe <name>` composes all of a
type's files into one answer regardless of how many there are; an agent asking for a type's
contract never needs to know it came from more than one file.
### Placement frontmatter
@@ -175,7 +211,7 @@ replace anyway. See `tools/chemenu/blocks.py`.
| Frontmatter fields, enums, defaults, required-ness | Provenance and citation mechanics | Quality goal and tone |
| Directory placement and title prefix | Linking mechanics and the orphan check | Naming conventions |
| Body skeleton (template) | The prose/identifier rule | Relationship vocabulary |
| When to use / not use this type | — | The KB language and its section-heading names |
| When to use / not use this type | — | The KB language value, and its section-heading names |
If a rule would be identical for every type *and* every instance, it belongs in
`kb/CONTRACT.md`. If every instance would answer it differently, it belongs in
+27
View File
@@ -69,6 +69,33 @@ properties:
items:
type: string
pattern: "^[a-z][a-z0-9_]*$"
capture_fields:
type: array
description: >-
Frontmatter fields on instances of this type that are fixed at capture
time and never correctable afterwards except by re-capturing the source
(e.g. `fidelity`, `authority` for a source page). `raw accept`,
`new source` and `touch --set` read this instead of a hardcoded field
list. Omit for types with no capture-time fields.
items:
type: string
pattern: "^[a-z][a-z0-9_]*$"
root:
type: string
description: >-
Which root a `base_dir:` resolves against - `kb` (the default, so
omitting this field is equivalent to `kb`) for a page this instance
writes, or `repo` for a type whose artifacts are legitimately not
knowledge (e.g. `instruction`).
enum: [kb, repo]
guidance:
type: string
description: >-
Repo-relative path to a `types/type-guidance.md`-shaped document
holding this type's stack-owned authoring prose. Meaningful only on a
`root: kb` type-spec; omit for a type an instance writes entirely for
itself.
pattern: "^types/.*\\.md$"
required:
- type
- name