docs: ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
CI / verify (push) Successful in 59s
Release / release (push) Successful in 37s

Files changed:
- .gitignore
- CHANGES.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- docs/pipeline-rationale.md
- instructions/CONTRACT.md
- instructions/bootstrap.md
- instructions/dev/issue-tracking.md
- instructions/evolve-subtypes.md
- instructions/kb-profiles.md
- instructions/mcp-read-server.md
- instructions/wiki-ingest/SKILL.md
- kb/CONTRACT.md
- kb/concepts/COLLECTION.md
- kb/sources/COLLECTION.md
- raw/CONTRACT.md
- tools/.coveragerc
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/tests/test_docs_verify.py
- types/source.schema.yaml
- types/type-spec.md
This commit is contained in:
2026-09-09 18:52:34 +02:00
parent 5820924ffd
commit a51d7a322f
25 changed files with 388 additions and 68 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ npm-debug.log*
/.agents/skills/
/.claude/skills/
# Ingest inbox (see raw/CONTRACT.md and Gitea #58, flattened by #67). A human
# Ingest inbox (see raw/CONTRACT.md "Getting a file in"). A human
# drops a file here - no subdirectory carries any meaning any more, an old
# one is merely tolerated and ignored; `wikitool raw accept` promotes it into
# `raw/`, computing the date-sharded directory and any bundle from what was
+89 -1
View File
@@ -35,7 +35,7 @@ dev-checkout concern - readable here, never shipped as something to parse.
---
## 4.8.0-beta.11 - 2026-09-09 - SKILL.md-Sanierung: Checklisten, Kommandolisten, wiki-status-Hard-Rule, wiki-query-Filingpruefung (#70, #74, #75, #78)
## 4.8.0-beta.12 - 2026-09-09 - Ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
**Author:** Torben Nehmer
@@ -52,6 +52,7 @@ dev-checkout concern - readable here, never shipped as something to parse.
- source_type ist Instanzsache: Profilkatalog, Setup-Frage, evolve-subtypes-Instruction (#68)
- instructions/CONTRACT.md: Skill-H1, Referenztiefe und Begruendungsprosa praezisiert (#71, #72, #79)
- SKILL.md-Sanierung: Checklisten, Kommandolisten, wiki-status-Hard-Rule, wiki-query-Filingpruefung (#70, #74, #75, #78)
- Ausgelieferte Doku zitiert keine Issue-Nummern mehr, docs verify prueft es (schliesst #77)
<!-- /wikitool:bumps -->
Das Label `status/incoming` gibt es seit heute in Gitea: der Mensch legt einen
@@ -752,6 +753,93 @@ instruction, neuer Unterabschnitt „When a skill carries a copy-in checklist").
Schließt #70, #74, #75 und #78.
**Issue-Nummern in ausgelieferter Doku** (#77): `dist export` lieferte
Dateien aus, die im Fließtext auf Issue-Nummern dieses Trackers verwiesen —
„flat since Gitea #67", „`new source` refuses without it (Gitea #66)". In einer
verteilten Instanz zeigt das auf nichts. Der Leser kann den Verweis weder
auflösen noch als unauflösbar erkennen, und eine Regel sieht damit so aus, als
stütze sie sich auf einen Beleg, den niemand beibringen kann. Das Board liegt
im Ursprungs-Repo, und `instructions/dev/issue-tracking.md` — die einzige Datei,
die das überhaupt sagt — wird von `dist export` mit dem Rest von
`instructions/dev/` weggeschnitten. Gegenprobe zum eigenen Anspruch aus
`instructions/CONTRACT.md` § „Writing an instruction": „self-contained enough
for an agent with no prior context".
Gemessen statt geschätzt: ein Export in ein leeres Verzeichnis, `grep -rn
'#[0-9]'`, ergab **43 Treffer in 16 Dateien** außerhalb von `tools/**/*.py` —
`raw/CONTRACT.md` allein acht. Das Issue hatte zehn gelistet.
Aufgelöst wurde nicht durch eine Markierung, sondern durch Umformulierung:
**die Nummer fällt weg, die Datierung geht in Worte.** Aus „flat since Gitea
#67" wird „flat since the addressing scheme dropped type directories", aus
„**Pre-#67 files are not moved**" wird „**Files promoted under the old type
directories are not moved**". Der Satz trägt sich damit selbst — es gibt keine
repoweite Notation zu definieren und an genau einer Stelle zu halten
(Invariante 8), und kein Leser von `README.md` muss `AGENTS.md` geladen haben,
um sie aufzulösen. Rückverfolgbar bleibt es hier über `git blame` → Commit-
Message; die tragen die Nummern ohnehin.
Zwei Stellen, an denen der Zeiger *der ganze Wert* des Satzes war und in Worten
nichts übrig geblieben wäre, stehen jetzt in einem
`<!-- dist:strip-start/end -->`-Block: in `instructions/CONTRACT.md` (was ein
Test der Kontextfenster-Behauptung kosten würde) und in `EVALS.md` (wo die
Coverage-Lücken geschlossen werden). Im Dev-Repo sichtbar, im Export weg — die
bestehende Konvention aus `instructions/CONTRACT.md` § `instructions/dev/`, hier
zum zweiten Mal angewandt statt neu erfunden.
**`docs verify` prüft es jetzt** — die offene Frage des Issues, mit Ja
beantwortet. `check_no_issue_references` liest nicht den Arbeitsbaum, sondern
den Text, den `dist_cmd.build_plan()` schreiben würde: dort leben `ROOT_FILES`,
der `instructions/dev/`-Ausschluss und das `.template`-Rekeying schon, und der
Text hat seine Marker-Blöcke bereits verloren. Deshalb ist ein Strip-Block
automatisch exemptiert, ohne dass der Check ihn kennen müsste.
Der Einwand aus `instructions/dev/issue-tracking.md` § „What no tool checks" —
`wikitool` soll den Tracker nicht kennen — trägt hier nicht, und das ist die
Grenze, die der Abschnitt jetzt selbst zieht: `re.compile(r"#\d+")` hat keinen
Client, keine URL und keinen Begriff vom Zustand eines Issues. Der Check sieht
eine Eigenschaft des *Dokuments*, nicht des Boards. Gemessen: null False
Positives über den gesamten Export, weil Markdown-Anker aus Wortzeichen
bestehen (`](#gates)` matcht nicht). Der erste Fund war prompt der Satz, den
diese Sitzung selbst in `tools/CONTRACT.md` geschrieben hatte, um die Regel zu
erklären.
**`tools/**/*.py` bleibt bewusst außen vor**, mit ~90 Treffern in Docstrings und
Kommentaren. Ein Code-Kommentar adressiert, wer die Zeile editiert, und das
passiert ausschließlich im Ursprungs-Repo: `dist export` schneidet den
`stack-dev`-Skill mit `instructions/dev/` weg. Ein ausgeliefertes `tools/` ist
Laufzeit-Maschinerie, keine Lektüre. `.gitignore` und `tools/.coveragerc` sind
aus demselben Grund nicht im Check — von Hand mitgezogen wurden sie trotzdem,
sodass der Export heute in *keiner* Datei außerhalb `.py` eine Nummer trägt.
**MINOR**, geprüft gegen den Drop-in-Test: kein Kommando, kein Flag, kein
Dateiformat, keine Umbenennung; der Rückweg funktioniert unverändert, die alte
Version führt den Check schlicht nicht aus. Kein `--breaking`, kein
Migrationsdokument. Eine Konsequenz ist zu kennen: der Check liest auch die
instanzeigenen `kb/CONVENTIONS.md` und `kb/<collection>/COLLECTION.md`, weil ein
Export sie als `.template` mitnimmt. Eine Instanz, die dort ihre eigene
Ticket-Nummer zitiert, bekommt beim nächsten `docs verify` ein Finding. Das ist
kein Fehlalarm — ein Export dieser Instanz würde den Verweis weitergeben —
aber es ist neu.
Geändert: `tools/chemenu/commands/docs_verify.py` (neuer Check plus
`shipped_prose()`), `tools/chemenu/tests/test_docs_verify.py` (sechs Tests:
sauberer Baum, präparierte Datei, Anker-Nicht-Treffer, `.py` außerhalb des
Scans, Strip-Block unsichtbar, `verify` bricht ab), `tools/CONTRACT.md`
(Kommandotabelle und Fehlerkontrakt-Zeile), `tools/README.md`,
`instructions/dev/issue-tracking.md` (neuer § Citing an issue in the repo, und
§ What no tool checks zieht die Grenze zwischen „was dieses Repo über den
Tracker schreibt" und „dem Tracker selbst"), sowie die 16 Doku-Dateien:
`raw/CONTRACT.md`, `kb/CONTRACT.md`, `tools/CONTRACT.md`, `types/type-spec.md`,
`types/source.schema.yaml`, `kb/sources/COLLECTION.md`,
`kb/concepts/COLLECTION.md`, `instructions/wiki-ingest/SKILL.md`,
`instructions/evolve-subtypes.md`, `instructions/bootstrap.md`,
`instructions/kb-profiles.md`, `instructions/mcp-read-server.md`,
`instructions/CONTRACT.md`, `README.md`, `EVALS.md`, `INSTALL.md`,
`docs/pipeline-rationale.md`, `.gitignore`, `tools/.coveragerc`.
Schließt #77.
---
## 4.7.4 - 2026-09-04 - bootstrap.md nennt den session-id-WARN nach frischem Bootstrap explizit als erwartet
+8 -4
View File
@@ -258,8 +258,8 @@ does not see it. Do not re-derive this, and do not read the empty list as a fail
It is enforced at a floor of **85%** (`fail_under` in `tools/.coveragerc`), which is what a red
suite from this axis means: coverage actually fell, not that a wrapper was added. The floor was
set only after the number had been watched - Gitea #10 held it back for exactly that, and the
two points between 85 and the measured 87.0% are the room the taxonomy below asks for. A
set only after the number had been watched - it was deliberately held back for exactly that, and
the two points between 85 and the measured 87.0% are the room the taxonomy below asks for. A
threshold at the measured number goes red on the next thin Typer wrapper, and a threshold that
goes red for a non-reason gets lowered rather than earned.
@@ -290,8 +290,12 @@ low, and three kinds have to be told apart before any of it turns into work:
(44%), `migrate_cmd.py` (65%), `type_resolver.py` (79%). This is the list worth reading, and
the only one of the three that has not moved while everything around it did:
`provenance_cmd.py` sits where it sat, and `migrate_cmd.py` fell from 71% because it grew and
its new lines arrived untested. The floor freezes this; it does not close it. Closing it is
Gitea #51.
its new lines arrived untested. The floor freezes this; it does not close it.
<!-- dist:strip-start -->
Closing it is Gitea #51. (Kept behind a strip marker: the pointer resolves in the origin repo
and nowhere else.)
<!-- dist:strip-end -->
## Scoring a session
+1 -1
View File
@@ -262,7 +262,7 @@ sind. Einer Instanz, die älter ist als `.wikitool-kb.json`, fehlt die Datei gan
**Fallstricke.** Eine Instanz ohne lokale `.wikitool-release.json` (oder eine ohne `files`-Block,
aus der Zeit vor `4.5.0`) hat für `dist upgrade` keine Basis, gegen die es eine lokale Änderung
erkennen könnte, und verweigert den Tausch - dafür gibt es heute keine Reparatur (siehe Gitea #7).
erkennen könnte, und verweigert den Tausch - dafür gibt es heute keine Reparatur.
Der Befehl lädt selbst nichts herunter: `<tarball-oder-verzeichnis>` muss vorher aus Weg A
geholt werden, und ein Tarball muss genau ein Top-Level-Verzeichnis enthalten - die Form, in der
`.gitea/workflows/release.yml` es baut.
+7 -6
View File
@@ -79,7 +79,7 @@ chemenu/
├── raw/ # INPUT: immutable, untrusted source material
│ ├── CONTRACT.md # Date shard, capture fields, immutability, untrusted content
│ ├── 2026/09/ # Where `raw accept` puts a file: the month it was accepted
│ ├── articles/ # Pre-#67 type directories: still valid paths, never moved,
│ ├── articles/ # The old type directories: still valid paths, never moved,
│ ├── documents/ # but nothing new is ever routed into them again
│ ├── notes/
│ └── assets/
@@ -152,11 +152,12 @@ when the split would not actually help. `kb/comparisons/` is the worked example
that stays flat - it has no subtype field for a `layout:` to key on at all. A *lopsided* subtype
field is a different case and is fixed rather than left flat: `kb/sources/` looked lopsided only
because `source_type` had a schema `default:` that the compiler applied whenever nobody chose a
value, and once that was removed and the pages reclassified it split into six real areas
(Gitea #66). `COLLECTION.md` never
appears outside `kb/` - the other layers carry a `CONTRACT.md` or a root type-spec instead. A stage may carry
both a `README.md` and a `CONTRACT.md`: they have different readers. The README is for humans
working *on* that layer, the contract is what binds an agent working *with* it.
value, and once that was removed and the pages reclassified it split into six real areas.
`COLLECTION.md` never appears outside `kb/` - the other layers carry a `CONTRACT.md` or a root
type-spec instead. A stage may carry both a `README.md` and a `CONTRACT.md`: they have different
readers. The README is for humans working *on* that layer, the contract is what binds an agent
working *with* it.
## How to Use
+1 -1
View File
@@ -1 +1 @@
4.8.0-beta.11
4.8.0-beta.12
+1 -1
View File
@@ -14,7 +14,7 @@ thing later claims get checked against is no longer the thing that was actually
immutable `raw/` means a citation always resolves to the original, not to somebody's tidied
memory of it. It also draws a trust boundary in one place instead of scattering it - everything
past `raw/` can be treated as reviewed, because nothing upstream of it silently already was.
`incoming/` (Gitea #58) sits entirely on the near side of that boundary: a file waiting there is
`incoming/` sits entirely on the near side of that boundary: a file waiting there is
not yet reviewed and not yet a citation target, so its being gitignored and readable by an
ingest session does not weaken anything - the boundary is the promotion into `raw/` itself, not
the moment a human happened to drop a file somewhere.
+6 -1
View File
@@ -215,7 +215,12 @@ the second. Whether the mechanic bites here is not something this repo can curre
the L2 trajectory scorers read `wikitool` calls, gates and publishes, never an agent's file
reads, and no tool hook is wired on the primary harness at all - so a `head -100` leaves nothing
to score. The other half of the claim, what ended up in the context window, produces no event
anywhere by construction. Gitea #72 records what a test would cost and why it was not bought.
anywhere by construction.
<!-- dist:strip-start -->
Gitea #72 records what such a test would cost and why it was not bought. (Kept behind a strip
marker: the pointer is worth having in the origin repo and resolves nowhere else.)
<!-- dist:strip-end -->
### When a skill carries a copy-in checklist
+1 -1
View File
@@ -30,7 +30,7 @@ they are published: the agent harness will not offer `wiki-ingest`, `wiki-query`
2. **Create the ingest inbox.** `incoming/` (raw/CONTRACT.md "Getting a file in") is gitignored,
so a fresh clone does not have it at all - unlike `raw/` itself, which is committed and
present immediately. It is flat since Gitea #67 (no type subdirectory to create):
present immediately. It is flat - there is no type subdirectory to create:
```bash
mkdir -p incoming
+41
View File
@@ -319,6 +319,37 @@ second pass), and an old body can carry private infrastructure detail into
what is now a public tracker - both found in the same issue, both worth the
same look.
## Citing an issue in the repo
**No file `dist export` ships may cite an issue number.** The board is reachable only from the
origin repo, and this very file - the only one that says where it lives - is pruned along with
the rest of `instructions/dev/`. A "#66" that survives into a distributed instance is therefore
worse than a dead link: the reader cannot resolve it *and* cannot tell that it is unresolvable,
so a rule appears to rest on evidence nobody can produce. `instructions/CONTRACT.md` § "Writing
an instruction" asks the opposite ("self-contained enough for an agent with no prior context"),
and an issue number is the exact counter-example to it.
Which is the same wayfinder/evidence split as in the section below, applied one layer out - but
both halves land in the same place here:
- **A wayfinder** ("see #66 for the reasoning") is resolved: the reasoning goes into the text,
and the number goes.
- **A piece of evidence** ("removed in #66") is dated in words instead - "removed when the
schema default was dropped". The sentence carries itself, and the number stays reachable
through `git blame` -> the commit message, which names the issue anyway.
Where a pointer is genuinely worth having *here* and would leave nothing behind in words, keep
it in a `<!-- dist:strip-start/end -->` block ([instructions/CONTRACT.md](../CONTRACT.md)
§ `instructions/dev/`): visible in this repo, removed on export. Two passages use it today.
`tools/**/*.py` is deliberately outside all of this. A code comment addresses whoever edits that
line, and that only ever happens in the origin repo, because `dist export` prunes the
`stack-dev` skill together with this directory; a distributed `tools/` tree is runtime
machinery, not reading material. The same holds for `.gitignore` and `tools/.coveragerc` -
config, not documentation.
`docs verify` enforces the rule (below).
## What no tool checks
`wikitool` does not know this tracker exists, and should not learn. It ships to
@@ -336,6 +367,16 @@ session following this file, or not at all - which is the argument for the
sequence in step 7 being explicit about the order (body first, then close),
rather than leaving it to be inferred from step 2.
The one rule here that *is* checked is § Citing an issue in the repo, and it is
worth being clear about why that is not a contradiction. `docs verify`'s
`check_no_issue_references` compiles `#\d+` and reads the text
`dist_cmd.build_plan()` would write. It has no client, no URL and no notion of
an issue's state - it cannot tell an open issue from a closed one, or a real
number from an invented one. What it knows is that a shipped document is making
a reference its reader cannot follow, which is a property of the *document*, not
of the board. That is the line: a check may look at what this repo writes about
the tracker; none may look at the tracker.
## Decision points
- **Issue or changelog?** An issue is work that is *not done*. `CHANGES.md` is
+5 -5
View File
@@ -17,7 +17,7 @@ procedure for changing that list once it is running, not for choosing it the fir
The tooling for this loop already exists end to end; this file only names the sequence and the
two rules that keep it from repeating what created `kb/sources/`'s old `notes` catch-all in the
first place (Gitea #66): a schema `default:` that the compiler applied whenever nobody
first place: a schema `default:` that the compiler applied whenever nobody
disagreed, silently turning the least specific value into the collection point for everything
unclear.
@@ -60,10 +60,10 @@ choice of an instance's starting vocabulary - that is
2. **Apply the ≥3-page admission threshold.** A value is admitted *after* it has proven
itself against real material, never in expectation of some. `spec` and `image` are the
cautionary case: both were added to `source_type` ahead of any matching page, both sat at
zero for a year, and both were removed in Gitea #66. A smaller count is only ever an
operator's explicit, named exception (`tracker` at two pages in #66, kept because the
corpus was expected to grow into it from ongoing issue ingests) - never a reason to lower
the threshold itself.
zero for a year, and both were eventually removed again unused. A smaller count is only
ever an operator's explicit, named exception (`tracker` survived that same cleanup at two
pages, kept because the corpus was expected to grow into it from ongoing issue ingests) -
never a reason to lower the threshold itself.
3. Add the enum value in the type-spec's schema (`types/<t>.schema.yaml`) and its `layout:`
entry (`types/<t>.md`) in the same edit - a value with no `layout:` line has nowhere to be
moved to.
+1 -1
View File
@@ -167,7 +167,7 @@ Adopting one means copying the value list into `types/source.schema.yaml`'s enum
value a `layout:` line in `types/source.md` - the same "copy the text in, do not point at this
page" rule as every other profile here. Keep the visible catch-all value (`unclassified` above)
in whatever list is adopted: a subtype field without one silently reintroduces the old
`default:`-driven collection point that Gitea #66 removed the moment nobody names an edge case.
`default:`-driven collection point this stack removed, the moment nobody names an edge case.
Growing the list later, or draining the catch-all, is
[evolve-subtypes.md](evolve-subtypes.md).
+1 -2
View File
@@ -93,8 +93,7 @@ everything an operator needs that is *true of the software* rather than of one i
- **Asked to expose a write tool?** There is none, and the way to add one is not a flag. The
server imports nothing under `chemenu.commands`, so `new`, `touch`, `xref`, `cite`, `publish`
and `migrate` are unreachable from it rather than filtered out of a list. Submitting documents
from outside is a different design with a quarantine in it - Gitea #32 - not a tool added
here.
from outside is a different design, with a quarantine in it - not a tool added here.
- **Asked to rate-limit inside the server?** Rate limiting belongs in the middleware in front of
the process, next to authentication. Not the Iteration Budget Gate: that exists to stop an
agent *session* from iterating unnoticed over the wiki's state, which is why retrieval is
+5 -5
View File
@@ -61,7 +61,7 @@ validator complains - and the ticked list is the only record that they happened.
`incoming/<type>/` habit) is tolerated and ignored - it carries no meaning any more.
**If this refuses because the name is already claimed** (a file stem or a bundle directory
already occupies the name anywhere under `raw/`, Gitea #64/#67), that is not this session's
already occupies the name anywhere under `raw/`), that is not this session's
call to make: whether the incoming file is a later edition of the existing source or a second,
separate one is a judgment about the world, and the command's message names both routes -
`--replaces` and renaming in `incoming/` - without recommending either. Show the message to
@@ -109,7 +109,7 @@ validator complains - and the ticked list is the only record that they happened.
--set entities=A,B,C --set concepts=D,E
```
`source_type` has no default - `new source` refuses without it (Gitea #66). Pick from what
`source_type` has no default - `new source` refuses without it. Pick from what
`tools/wikitool types describe source` lists, based on what the material *is*, not what it is
about: a session transcript is `transcript` regardless of subject, an LLM's own analysis is
`analysis` even when it reads like an article. Genuinely unclear after reading the source?
@@ -118,9 +118,9 @@ validator complains - and the ticked list is the only record that they happened.
it later without moving or renaming the page.
`fidelity` and `authority` have no default either, and `new source` refuses without them the
same way (Gitea #67) - but here there is no catalog slot to fall back on, for the reason step
1 gives. If step 1 already ran `raw accept` without `--page`, its success message printed the
exact `--set fidelity=... --set authority=...` pair to reuse here verbatim; if it did not (the
same way - but here there is no catalog slot to fall back on, for the reason step 1 gives.
If step 1 already ran `raw accept` without `--page`, its success message printed the exact
`--set fidelity=... --set authority=...` pair to reuse here verbatim; if it did not (the
file was already in `raw/`), ask the user, rather than inferring an answer from the file's
content now. Never pass `unknown` here - that value is backfill-only, written only by
`wikitool touch` on a page predating this rule.
+4 -3
View File
@@ -50,7 +50,7 @@ resolves against it by name.
`kb/<collection>/<area>/<page>.md` are the two depths a page may sit at; nothing goes a level
deeper. A further subdirectory is not a second-level area - it is invisible to the generated
catalog, which reads exactly two path segments below `kb/` and folds anything past them into
the area's own table silently, with no location of its own (Gitea #57). That is why
the area's own table silently, with no location of its own. That is why
`wikitool lint`'s `nested_pages` finding is a hard error rather than an advisory one like
`misplaced_pages`: a misplaced page still catalogs correctly from the wrong place, a nested
one makes the catalog itself wrong. A grouping axis that does not come from a type-spec's
@@ -268,7 +268,7 @@ The rubric already asks for an authority judgment ("+0.1 for official documentat
reputable secondary source") without anywhere to record it - source count lives in `sources:`,
recency in `modified:`, agreement is readable from the prose itself, but the authority term is
thought, applied, and forgotten; the next person re-derives it from scratch. `raw/CONTRACT.md`'s
capture fields (`fidelity`, `authority`, Gitea #67) make it a recorded fact instead: `lint`
capture fields (`fidelity`, `authority`) make it a recorded fact instead: `lint`
compares a page's `confidence_base` against the weakest capture standing of its cited sources and
reports one when the page claims more than its sources can carry.
@@ -294,7 +294,8 @@ that one page, rather than reconfiguring the rubric:
The effective ceiling is the minimum of both columns. `unknown` never triggers a finding on its
own axis: a backfilled "we don't know" is not a claim about the source, and firing on it would
report every page citing a pre-#67 source at once, which is noise, not a finding.
report every page citing a source captured before these two fields existed at once, which is
noise, not a finding.
## What does not belong here
+1 -1
View File
@@ -48,7 +48,7 @@ Eine Seite, die anderswo liegt, meldet `wikitool lint` als *misplaced*;
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 nie — mit 80 Seiten in einer einzigen
Tabelle war die Schwelle hier ein toter Wert (Gitea #59). Keine der sechs
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.
+4 -3
View File
@@ -27,7 +27,7 @@ renamed or dropped - its authoring rules below are the instance's, its existence
`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** (Gitea #66): `wikitool new source` verweigert ohne einen expliziten Wert.
Default**: `wikitool new source` verweigert ohne einen expliziten Wert.
| Area | Hält |
|------|------|
@@ -53,8 +53,9 @@ artikelförmig der Text wirkt. Ein Handbuch, eine Spezifikation, eine Hersteller
verantwortet. Die Frage ist also "wer haftet für die Behauptung", nicht "wie liest sich der
Text".
Vor #66 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.
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.
+14 -12
View File
@@ -13,8 +13,8 @@ from `kb/` is what makes that boundary visible.
## Directory routing: a date shard, not a type
`raw/` addresses a file by **when it was accepted**, never by what kind of document it is
(Gitea #67). A promotion lands under `raw/<YYYY>/<MM>/`, computed from the calendar month of the
`raw/` addresses a file by **when it was accepted**, never by what kind of document it is.
A promotion lands under `raw/<YYYY>/<MM>/`, computed from the calendar month of the
`raw accept` call that promoted it - a pure function of something immutable, so it can never
rebalance: an overflowing bucket would move files and break every `[^cite-id]` anchor pointing at
them, and only a function of a fixed, past fact (the accept date) rules that out entirely. The
@@ -39,19 +39,20 @@ choice into `source_type:` by hand - which is exactly how a bias took hold. `raw
files that landed there, 16 turned out to be transcripts, 4 LLM analyses, 2 tracker exports, and
only 3 actual notes. The catch-all formed in the raw layer and was carried straight into `kb/`.
**Pre-#67 files are not moved.** `raw/`'s directory layout was never versioned anywhere -
`.wikitool-kb.json` describes `kb/`'s shape, and `corpus_diff.py` compares `raw_files:` as a
*value*, never as directory structure - so there is no "two corpus forms at once" to reconcile,
**Files promoted under the old type directories are not moved.** `raw/`'s directory layout was
never versioned anywhere - `.wikitool-kb.json` describes `kb/`'s shape, and `corpus_diff.py`
compares `raw_files:` as a *value*, never as directory structure - so there is no "two corpus
forms at once" to reconcile,
only a shape that was simply never described. `raw/articles/`, `raw/documents/`, `raw/notes/` and
`raw/assets/` keep holding whatever they already held, indefinitely: `raw accept --replaces`
writes back to a file's existing location (the path is the identifier, Gitea #64), so a legacy
writes back to a file's existing location (the path is the identifier), so a legacy
directory stays a valid promotion target as long as anything still lives there. `sources coverage`
walks `raw/` recursively and works unchanged either way.
## Getting a file in: `incoming/`
`raw/` is never chosen by hand. A file to be ingested is dropped into the gitignored top-level
`incoming/` - flat, since Gitea #67: no subdirectory carries any classification any more. A
`incoming/` - flat: no subdirectory carries any classification any more. A
subdirectory is still tolerated if one is used out of habit or by an older script (so an upgrade
never has to touch a caller), but it is **ignored**, never inspected:
@@ -81,12 +82,13 @@ capture date is whatever it always was, and a bundle mixing an old and a new sha
single correct address.
**The names occupied anywhere under `raw/` - file stems and bundle directory names alike - are
unique** (Gitea #64, widened globally by #67 once type directories stopped bounding the check).
unique** - a rule that used to hold only within one type directory, and went global once those
directories stopped bounding it.
Within a bundle, `handbuch.pdf` and `handbuch.md` sit side by side as always; the rule bites one
level up, so a second, unrelated source cannot promote quietly into a bundle it does not belong to
just because its own filename happens not to collide - nor into a same-named bundle sitting in a
different shard, or in a pre-#67 type directory. A promote whose target name is already occupied
is refused, naming both sanctioned ways past it without recommending either:
different shard, or in one of the old type directories. A promote whose target name is already
occupied is refused, naming both sanctioned ways past it without recommending either:
```
ERROR raw/documents/cluster.md already claims the stem "cluster" under raw/.
@@ -125,7 +127,7 @@ of the same subject is `secondhand` + `opinion`; official system documentation a
about the same system are both `published`, but `normative` against `reporting`.
Both are **required, with no default**, at the point a source page first exists - the same
no-guessing posture `source_type:` has (Gitea #66), but without that field's escape hatch: there
no-guessing posture `source_type:` has, but without that field's escape hatch: there
is no `unclassified` catalog slot for a capture field, because a guessed value here would not read
as "unknown", it would read as a claim about the capture that cannot be corrected later (the
knowledge exists only at the drop point). `raw accept --fidelity <value> --authority <value>`
@@ -167,7 +169,7 @@ value nobody ever thought about.
edition is not kept as a file: it is overwritten, and `git log --follow <path>` is the
archive - no `-2026-09-05` suffix, no content-hash filename, no version field, because
`raw_files:` is an identifier (invariant 2) and Git already answers "what did this used to
say" losslessly (Gitea #64).
say" losslessly.
- **Binary and image files still get ingested**, noting their presence and what they show,
even when their content cannot be read directly.
- **Every file is expected to be covered** by some source page, and one source page may cover
+4 -4
View File
@@ -15,8 +15,8 @@
# step, so the number sits next to the reasoning that produced it and applies to
# any `--cov` run, not just the one CI happens to write.
#
# 85, against a measured 87.0% (CI run 163, 6498 statements, 975 tests). Gitea
# #10 held this back until the number had been watched: the first measurement
# 85, against a measured 87.0% (CI run 163, 6498 statements, 975 tests). The
# floor was held back until the number had been watched: the first measurement
# was 86.9% of 5105 statements over 730 tests (CI run 87), and between the two
# the measured code grew by a quarter and the suite by a third while the quota
# moved a tenth of a point. That stability is what the threshold rests on.
@@ -26,8 +26,8 @@
# having got worse, because its logic is tested beside it (see EVALS.md § "How
# much of the stack the suite reaches"). A threshold at the measured number
# would go red on exactly that commit, and a threshold that goes red for a
# non-reason gets lowered rather than earned - which is the failure mode #10
# existed to avoid, arriving from the other side.
# non-reason gets lowered rather than earned - which is the failure mode that
# holding the floor back existed to avoid, arriving from the other side.
#
# What this number does *not* do is close the genuine gaps - provenance_cmd.py,
# migrate_cmd.py, type_resolver.py. It freezes the state that was reached; the
+8 -8
View File
@@ -40,7 +40,7 @@ tools/wikitool <command> --help
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--confidence-base <n>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance`/`--confidence-base` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), `confidence:` (derived - set `--confidence-base`), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
| `move --page "<Title>"` \| `move --reconcile` `[--dry-run]` | Move a page to the directory its type-spec computes for its current frontmatter (`base_dir` + `layout` - the same rule `new` places a page by, via `TypeResolver.compute_target_dir`), never a hand-chosen destination - there is no `--to <dir>`. `--reconcile` applies it corpus-wide: every misplaced page moves in one call, and a second run reports nothing left to do (`lint`'s `Misplaced Pages` finding is the advisory that this fixes, and its `Nested Pages` finding the hard one - see `lint`). Neither mode touches a body or a frontmatter field, and the page's title (its only identity in the wiki) never changes - only the file moves. A directory a move empties is removed along with it, so a page that was nested below its area (#57) leaves no leftover directory behind. A destination already occupied (a pre-existing duplicate-stem collision) is refused rather than silently skipped |
| `move --page "<Title>"` \| `move --reconcile` `[--dry-run]` | Move a page to the directory its type-spec computes for its current frontmatter (`base_dir` + `layout` - the same rule `new` places a page by, via `TypeResolver.compute_target_dir`), never a hand-chosen destination - there is no `--to <dir>`. `--reconcile` applies it corpus-wide: every misplaced page moves in one call, and a second run reports nothing left to do (`lint`'s `Misplaced Pages` finding is the advisory that this fixes, and its `Nested Pages` finding the hard one - see `lint`). Neither mode touches a body or a frontmatter field, and the page's title (its only identity in the wiki) never changes - only the file moves. A directory a move empties is removed along with it, so a page that was nested below its area leaves no leftover directory behind. A destination already occupied (a pre-existing duplicate-stem collision) is refused rather than silently skipped |
| `xref add --a "<A>" --b "<B>" --rel <label>` | Declare **one** edge: `A <label> B`, written into A's `related:` as `- <label>: B` and rendered into A's generated links region. B is not touched and does not point back - its inbound view is rendered from the graph. Idempotent, and re-running with a different label *relabels* rather than appending, since one page asserts one thing about another. Refuses before writing when the type does not declare `related:` (a source page declares `entities:`/`concepts:` - the refusal names them and points at `link-source`), and when `<label>` is not authorised by the source collection's `outbound:` block for the target's collection; that refusal lists the authorised set and points at `instructions/link-taxonomy.md` |
| `xref remove --a "<A>" --b "<B>" [--dry-run]` | Clears the reference in **both** directions - it is the cleanup command for a deleted or hand-renamed page rather than the strict inverse of a one-directional `add`. Clears `<B>` from every page-ref frontmatter field `<A>`'s type declares (`related:`, `sources:`, `entities:`, `concepts:`) plus the matching bullets. It also sweeps a field the type does *not* declare but some other type does, and drops that key outright once empty - a leftover written before the check above existed has to stay repairable, or the page is a dead end. `--b` need not still exist as a page, so this is how a reference left by a hand-deleted or hand-renamed page gets cleared without hand-editing frontmatter. Idempotent. |
| `xref link-source --source "Source - X" --entities A,B,C` | Batch-link a source page to every entity/concept it mentions: each target gets `sources:`, and the source page records each target in its own `entities:`/`concepts:`. No body bullet is written on either side - `sources:` *is* the record, and the See Also bullet this used to add was the reciprocal half of a model that no longer exists. Which of the two is chosen follows the target's collection (`kb/entities/` -> `entities:`), so a new collection needs no code change here. A target whose collection matches no reference field the source type declares is linked one-way and named in the output. Idempotent in both directions |
@@ -51,15 +51,15 @@ tools/wikitool <command> --help
| `index rebuild [--dry-run]` | Regenerate the catalog from every page's frontmatter: `kb/index.md` becomes a map (statistics, one row per collection and per area, links to the shards) and the page tables are written to a generated `INDEX.md` in each collection. An area past 50 rows gets its own shard. Stale shards from removed collections/areas are deleted in the same pass |
| `log append --op ingest\|query\|lint\|create\|update\|delete\|rename\|move --title "..." [--body "..."\|--body-file path]` | Append a formatted entry to `kb/log.md` |
| `log status` | Read-only: count `ingest` entries logged since the last `lint` entry - the deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence |
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it, see #57), 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, #59; 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, #66, 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 |
| `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/confidence so a hit can be judged without opening the page. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
| `confidence decay [--apply]` | Recompute every page's derived `confidence` as `confidence_base * (1 - 0.01/month)`, floored at 0.2; dry-run by default |
| `confidence init-base [--apply]` | One-time backfill: set `confidence_base` from the current `confidence` on pages that predate the derived-confidence model |
| `sources coverage [--json]` | List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages |
| `sources trace --raw <path>` \| `--page "<Title>"` | Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files |
| `sources rebuild-index [--dry-run]` | Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing pages) |
| `raw accept <file> [<file> ...] [--page "<Title>"] [--dry-run]` | Promote one or more files from `incoming/<type>/` into `raw/<type>/`, computing the destination instead of taking it as an argument (`raw/CONTRACT.md` "Getting a file in", Gitea #58): the type subdirectory comes from where the file sits under `incoming/`, a bundle directory (`raw/<type>/<stem>/`, named after the first file's stem) forms only from the second file on, and one file promoted alone gets none. `--page "<Title>"` additionally extends that existing source page's `raw_files:` in the same call; if that raises the page past one file, its already-promoted file is folded into the new bundle alongside the one(s) just accepted, after checking it has no other owner (`provenance.duplicate_raw_file_owners`) - moving a file another page also claims would break that page's `raw_files:` unconsulted. The set of names occupied at `raw/<type>/` level - file stems and bundle directory names alike - must stay unique (Gitea #64): a promote whose target name already belongs to something this call does not itself own is refused, naming both `--replaces` and renaming-in-`incoming/` without recommending either |
| `raw accept <file> --replaces <raw-path> [--dry-run]` | The one sanctioned way past that uniqueness rule: overwrites `<raw-path>` in place with the single incoming file (same filename, same type directory required), leaving every page's `raw_files:` untouched and writing no `kb/` page - the previous edition survives only in `git log --follow <raw-path>` (Gitea #64 decision 3). Refuses if the target has more than one owning source page; if it has none, replaces anyway and says so. Cannot be combined with `--page` or with more than one incoming file - a replacement is one file for one file. Prints the source page (if any) and its citing pages, so their update lands in the same commit as the replacement |
| `raw accept <file> [<file> ...] [--page "<Title>"] [--dry-run]` | Promote one or more files from `incoming/<type>/` into `raw/<type>/`, computing the destination instead of taking it as an argument (`raw/CONTRACT.md` "Getting a file in"): the type subdirectory comes from where the file sits under `incoming/`, a bundle directory (`raw/<type>/<stem>/`, named after the first file's stem) forms only from the second file on, and one file promoted alone gets none. `--page "<Title>"` additionally extends that existing source page's `raw_files:` in the same call; if that raises the page past one file, its already-promoted file is folded into the new bundle alongside the one(s) just accepted, after checking it has no other owner (`provenance.duplicate_raw_file_owners`) - moving a file another page also claims would break that page's `raw_files:` unconsulted. The set of names occupied at `raw/<type>/` level - file stems and bundle directory names alike - must stay unique: a promote whose target name already belongs to something this call does not itself own is refused, naming both `--replaces` and renaming-in-`incoming/` without recommending either |
| `raw accept <file> --replaces <raw-path> [--dry-run]` | The one sanctioned way past that uniqueness rule: overwrites `<raw-path>` in place with the single incoming file (same filename, same type directory required), leaving every page's `raw_files:` untouched and writing no `kb/` page - the previous edition survives only in `git log --follow <raw-path>`. Refuses if the target has more than one owning source page; if it has none, replaces anyway and says so. Cannot be combined with `--page` or with more than one incoming file - a replacement is one file for one file. Prints the source page (if any) and its citing pages, so their update lands in the same commit as the replacement |
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
| `work new (--input <raw path> \| --key <run key>) [--again] [--dry-run]` | Scaffold `work/<runkey>/` for one workshop run: refuses a collision instead of suffixing it, and writes the required `README.md` + `plan.md`. `--input` derives the run key from the path below `raw/` (an ingest); `--key` names it outright for a run with no raw input - a migration or a sweep across `kb/` - and may not start with `ingest-`, which stays reserved for derived keys. Exactly one of the two. `--again` opens a dated second pass over a tree that has itself changed. See [work/CONTRACT.md](../work/CONTRACT.md) |
@@ -71,14 +71,14 @@ tools/wikitool <command> --help
| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) |
| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout |
| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only |
| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, and the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
| `docs verify` | Check the docs that mirror the code: every CLI command documented here (and vice versa), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, no pre-migration `type: entity` blocks left in the contracts, 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). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks |
| `eval sessions [--json]` | List the sessions that have a trace under `reports/telemetry/`, most recent first. Read-only and exempt from the Iteration Budget Gate |
| `eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` | Score one traced session: structural state from `lint`'s own checks (L1) plus trajectory rules over the trace (L2) - was a refused call repeated unchanged, was a gate flag passed without that gate having refused anything, did a publish of `kb/` pages go unlogged. Defaults to the current session. `--save` writes `reports/evals/<date>/<session>.{json,md}`. Read-only over `kb/` and exempt from the budget; see [../EVALS.md](../EVALS.md) |
| `dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` | Write a contentless, distributable copy of this repo's machinery into an empty `<target>` directory: `AGENTS.md`/`README.md`/`EVALS.md` with any `<!-- dist:strip-start -->...<!-- dist:strip-end -->` region removed, `instructions/` (minus `instructions/dev/`), `types/` (the `root: kb` page type-specs and their schemas re-keyed as `.template`, the stack's own verbatim), `docs/` verbatim, `tools/` (no venv/caches), the `.github/hooks/`+`.vibe/` session-tracing config plus `.claude/settings.json`, `kb/CONTRACT.md` (no pages, no areas), empty `raw/{articles,documents,notes,assets}/` and the
matching `incoming/{articles,documents,notes,assets}/` (gitignored again the moment the export
becomes a git repository, so `instructions/bootstrap.md` re-creates it for a plain clone that
never ran this step), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/CONVENTIONS.md.template` and each collection's contract re-keyed as `kb/<name>/COLLECTION.md.template` (the templates ship; the filled `USER.md`/`SOUL.md`/`kb/CONVENTIONS.md`/`kb/<name>/COLLECTION.md`/`types/<page-type>.md` never do - all of them bind their instance and none are the stack's to decide, and `find_leaks` refuses a plan carrying one), and a generated `.wikitool-release.json` stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: `export` never calls git and cannot discover them. Refuses a non-empty target, and a tree with no `VERSION`. See `instructions/setup-instance.md`. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead |
| `dist upgrade <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/*/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). See Gitea #7 and `INSTALL.md` § "Eine Instanz aktualisieren" |
| `dist upgrade <source> [--dry-run] [--keep-local] [--prune] [--pre]` | Apply a stack update `dist export` produced - the write half of `version check`. Never downloads anything: `<source>` is an already-fetched export directory or `.tar.gz` release archive (verified against a sibling `.sha256` if one is present; WARNs, does not block, if it is absent), which must unpack to exactly one top-level directory - the shape `.gitea/workflows/release.yml` packs. The write set is exactly the *new* `.wikitool-release.json`'s `files` block, minus what an export re-seeds from a blank template every time (`kb/log.md`, `raw/*/.gitkeep` - `chemenu.ownership.is_export_stub`) or seeds once and the instance owns from then on (`.wikitool-kb.json`, `CHANGES.md` - `chemenu.ownership.is_upgrade_preserved`), plus the stamp itself, always rewritten. Every candidate path is classified against the *local* `.wikitool-release.json`'s recorded digest for it: unchanged is overwritten silently, absent from the old stamp is created, and locally modified or locally deleted is **never** silently overwritten - the run aborts with the full list unless `--keep-local` says to proceed and leave every one of them untouched. After a `--keep-local` run the new stamp is still written whole, so it records the release's digest for files that were deliberately *not* written: the stamp is the baseline for the next comparison, not a literal inventory of what is on disk. That is what keeps a skipped file diverging - and therefore reported - on every later run, rather than quietly reading as current once it has been skipped once. A path in the old stamp but not the new one is reported as no longer part of the release and left alone unless `--prune` is passed, which removes it only if it is still unchanged since installation. Reports the migration chain the new machinery would owe (`chemenu.kb_state.chain` over the *new* tree's `instructions/migrations/`, read via a `directory` argument to `load_migrations`) but never runs any of it - there is no `migrate run`. Refuses before touching the source at all when: `VERSION` or `.wikitool-release.json` (with a `files` block) is missing locally, `.wikitool-kb.json` is missing, a migration is already outstanding against the *installed* machinery, or the working tree is dirty (not being a git repository at all is a WARN, not a refusal). Refuses after reading the source when: it carries no `VERSION`/`.wikitool-release.json`/`files` block, its version is older than or equal to the installed one (equal is a no-op success), or it is a pre-release (`-beta.N`) without `--pre`. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). See `INSTALL.md` § "Eine Instanz aktualisieren" |
| `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 |
@@ -90,7 +90,7 @@ never ran this step), `VERSION`, `USER.md.template`/`SOUL.md.template` plus `kb/
| `migrate done <version> [--pages N] [--dry-run]` | Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json` to its target. **Refuses any version that is not the next link in the chain** - skipping one leaves the corpus in a shape no version describes, and an interrupted multi-step upgrade has to be resumable rather than guessable. An `offered` migration is recorded in the applied ledger *without* moving `kb_version` and with no ordering rule applied: it is not a link in the chain, so there is nothing to skip, and requiring the chain first would make an unrelated file upgrade wait on it. Re-recording one already in the ledger is a no-op, not an error |
| `migrate baseline <version> [--force]` | Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. Refuses to overwrite an existing declaration without `--force`: advancing after a migration is `done`, which checks the chain, and this command must not become the quiet way around it |
| `upstream merge [--remote upstream] [--branch main] [--no-fetch]` | Take a stack update into a private instance's branch, machinery only - the code procedure behind `instructions/private-instance.md` § "Taking a stack update". Refuses on a dirty working tree, a merge already in progress, or a remote that does not resolve; WARNs (does not block) when `.wikitool-remotes.json` is absent, pointing at the setup step that arms it. Fetches `<remote>/<branch>` (unless `--no-fetch`) and reports "already up to date" if nothing new exists. Otherwise opens `git merge --no-commit --no-ff <remote>/<branch>` - and stops, untouched, if git refused to open a merge at all (unrelated histories), since without a `MERGE_HEAD` every stack path would read as "the upstream deleted it". Then forces every content stage (`kb/`, `raw/`, `work/`, `reports/`) back to the local side by removing **only the paths tracked in either tree** and checking `HEAD`'s back out - never the stage directory wholesale, because `reports/` is gitignored apart from its contract and holds local, non-recomputable data (telemetry traces `eval score` reads, saved eval and lint reports) that no merge has business deleting. Then restores from the upstream side exactly the paths `chemenu.ownership.is_stack_owned` recognises as machinery (`<stage>/CONTRACT.md`, and anything ending `.template` under a content stage) - including a deletion, if the upstream removed one. A real conflict left in `tools/`, `types/` or `instructions/` after that leaves the merge open, uncommitted, and exits 1 rather than guessing. Commits with `git commit --no-edit`, then re-checks the resulting range with the same logic as `upstream verify`; a finding there is a loud, uncommitted-nothing-rolled-back error, because the merge commit already exists and needs a human's eyes, not an automatic repair. Never pushes. Not idempotent - see the tool error contract below |
| `upstream verify --since <rev> [--until HEAD]` | Compare two revisions: did anything under a content stage (`kb/`, `raw/`, `work/`, `reports/`) change except through a stack-owned path? Shares its check with `upstream merge`'s own postcheck, so a hand-resolved merge conflict, or a future `dist upgrade` (#7), can be verified the same way. Exit 1 with the offending paths if anything leaked; otherwise reports which stack-owned paths legitimately moved. Read-only and exempt from the Iteration Budget Gate, like `migrate verify` |
| `upstream verify --since <rev> [--until HEAD]` | Compare two revisions: did anything under a content stage (`kb/`, `raw/`, `work/`, `reports/`) change except through a stack-owned path? Shares its check with `upstream merge`'s own postcheck, so a hand-resolved merge conflict, or a `dist upgrade`, can be verified the same way. Exit 1 with the offending paths if anything leaked; otherwise reports which stack-owned paths legitimately moved. Read-only and exempt from the Iteration Budget Gate, like `migrate verify` |
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, and `WIKITOOL_SESSION_ID`. Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
## Design notes
@@ -200,7 +200,7 @@ is atomic, and whether a retry is safe.
| `raw accept --replaces` | More than one incoming file, `--page` also given, the incoming filename differs from the target's, the target does not lie under `raw/` or does not exist, the incoming file sits under a different type directory than the target, or the target has more than one owning source page | No - one `unlink()` + one `rename()`, no page write | Fix the named argument and retry once. Every check runs before the filesystem is touched, so a refusal leaves both files exactly as they were |
| `types list` / `types describe` | Unknown type name | Read-only | Fix the name and retry |
| `instructions sync` / `verify` / `list` | Nothing found under `instructions/`, 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 or a skill and so risks running implicitly), something under `instructions/dev/` referenced from outside it and outside a dist:strip block, or (sync) a target directory that is not a published skill and `--force` was not passed | `sync` rewrites one directory per target (idempotent); `verify`/`list` are read-only | Fix the flagged file, then re-run. For drift, re-run `sync`: the source under `instructions/` always wins, and a published copy is never edited directly |
| `docs verify` | A command, contract, or type-form mismatch was found | Read-only | Fix the documentation it names, then re-run |
| `docs verify` | A command, contract, or type-form mismatch was found, or a shipped `.md`/`.template` cites an issue number | 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 |
| `dist export` | Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` | Yes - nothing is written until every file is planned | Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand |
| `dist upgrade` | Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, a migration already outstanding against the installed machinery, a dirty working tree, a source with no `VERSION`/stamp/`files` block, a source version that is older than, equal to, or (without `--pre`) a pre-release relative to the installed one, or one or more locally changed files without `--keep-local` | **Yes for the refusal cases above - nothing is written.** Once writing starts it is a plain sequential file copy with no partial-state cleanup: an interruption mid-copy (killed process, disk full) can leave the tree part-old, part-new | For every refusal above: fix the named precondition and retry - none of them are transient. For locally changed files: reconcile them by hand and retry, or re-run with `--keep-local` to proceed and leave them untouched (repeatable - it reports the same files again on every subsequent run until they stop diverging). An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand |
| `version show` / `version notes` | `VERSION` is missing or unparseable; for `notes`, no `CHANGES.md` entry names the version asked for | Read-only | Fix `VERSION`, or write the changelog entry (`version bump` writes its heading). Safe to retry |
+5 -1
View File
@@ -79,7 +79,11 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
2. Register it in `cli.py` (`app.add_typer(...)` or `app.command(...)`).
3. Add a row to [CONTRACT.md](CONTRACT.md)'s command table **and** to its error
contract table. `docs verify` fails in both directions - an undocumented
command and a documented non-command are equally reported.
command and a documented non-command are equally reported. Write the rows
without citing an issue number: `docs verify` also refuses any `.md` or
`.template` `dist export` ships that carries one, because the tracker exists
only in this repo (`instructions/dev/issue-tracking.md` § Citing an issue in
the repo).
4. Add tests. Pure logic belongs in a function separate from the Typer callback
(see `mass_update_gate_message`, `derive_run_key`, `run_export`), so a test
does not need a CLI runner - and a Typer callback called directly from a test
+80 -1
View File
@@ -25,6 +25,10 @@ either, but it is the one number a release stamps into every distributed
instance, and a version raised without a changelog entry ships release notes
that describe the previous release.
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.
Everything here is a hard oracle: a set comparison or a regex, no judgment.
Content quality of the contracts themselves stays with the LLM.
"""
@@ -38,6 +42,7 @@ from typing import Optional
import typer
from chemenu import config, conventions, kb_collections, version as version_mod
from chemenu.commands import dist_cmd
from chemenu.commands._util import fail, rel_path, success
app = typer.Typer(help="Verify documentation that mirrors the code or repo layout.")
@@ -383,6 +388,78 @@ def check_readmes_have_no_command_table() -> list[str]:
return issues
# An issue-number citation in a shipped document points at a board no
# distributed instance can reach. The tracker lives in the origin repo, and
# `instructions/dev/issue-tracking.md` - the only file that says so - is pruned
# by `dist export` along with the rest of `instructions/dev/`, so the receiving
# reader gets a reference they can neither resolve nor recognise as unresolvable.
# The fix a session applies is to say what was decided instead of pointing at
# where it was decided; `git blame` -> commit message keeps the number reachable
# for whoever is standing in the repo that has one.
#
# The pattern knows nothing about Gitea - no client, no URL, no issue state -
# which is what keeps `instructions/dev/issue-tracking.md` § "What no tool
# checks" intact. It is a character pattern over shipped text, and `wikitool`
# stays as ignorant of the board as it was. Markdown anchors are word
# characters (`](#gates)`), so a link never matches.
ISSUE_REFERENCE_RE = re.compile(r"#\d+")
# What counts as shipped prose: Markdown, plus the `.template` files an instance
# renames into place during setup. `tools/**/*.py` is deliberately outside it.
# A code comment addresses whoever edits that line, and that only ever happens
# in the origin repo - `dist export` prunes the `stack-dev` skill together with
# the rest of `instructions/dev/`, so a distributed `tools/` tree is runtime
# machinery, not reading material. `.gitignore` and `tools/.coveragerc` are out
# for the same reason: config, not documentation.
SHIPPED_PROSE_SUFFIXES = (".md", ".template")
def shipped_prose() -> dict[str, str]:
"""Destination path -> the text `dist export` would write, for every prose
file in the export.
Read off the export plan rather than the working tree on purpose. The plan
is where `ROOT_FILES`, the `instructions/dev/` exclusion and the `.template`
re-keying already live, so this check cannot drift from what actually
ships - and the plan's text has its `<!-- dist:strip-start/end -->` regions
already removed, which is what makes a marker the sanctioned way to keep a
pointer that is worth having here and meaningless anywhere else.
"""
plan = dist_cmd.build_plan()
return {
path: planned.content
for path, planned in plan.items()
if path.endswith(SHIPPED_PROSE_SUFFIXES) and isinstance(planned.content, str)
}
def check_no_issue_references() -> list[str]:
"""No shipped document may cite an issue number."""
try:
prose = shipped_prose()
except typer.Exit:
# `build_plan` refuses outright when the export would ship code without
# its licence. That is a real defect and `dist export` reports it in
# full; here it only means this one check could not run, and saying so
# beats letting another command's error end the whole verify run.
return [
"the export plan could not be built, so shipped documents were not checked for "
"issue references - run `tools/wikitool dist export --dry-run` for the reason"
]
issues = []
for path in sorted(prose):
for line_number, line in enumerate(prose[path].splitlines(), start=1):
for match in ISSUE_REFERENCE_RE.finditer(line):
issues.append(
f"{path}:{line_number} cites `{match.group()}`, but `dist export` ships this "
"file to instances that have no issue tracker - say what was decided instead "
"of pointing at where, or keep the pointer behind a "
"`<!-- dist:strip-start/end -->` block"
)
return issues
def _git(args: list[str], stdin: Optional[str] = None) -> Optional[subprocess.CompletedProcess]:
"""Run a git command in the repo root, or return None if git is unavailable
or this is not a checkout. Returning None (rather than raising) keeps
@@ -587,7 +664,7 @@ 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, and version/changelog agreement."""
"""Check the CLI/README command tables, contract presence, type-form drift, ignore rules, version/changelog agreement, and issue references in shipped documents."""
issues = (
check_cli_readme()
+ check_readmes_have_no_command_table()
@@ -597,6 +674,7 @@ def verify():
+ check_version_changelog()
+ check_migration_for_boundary()
+ check_breaking_change_for_boundary()
+ check_no_issue_references()
)
if issues:
@@ -607,6 +685,7 @@ def verify():
f"{len(kb_collections.iter_kb_collections())} collection(s) and "
f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, "
f"{len(IGNORE_CANARIES)} ignore canaries clear, "
f"no issue references in {len(shipped_prose())} shipped document(s), "
f"{version_mod.CHANGES_FILENAME} documents version "
f"{(config.ROOT / version_mod.VERSION_FILENAME).read_text(encoding='utf-8').strip()}."
)
+96 -1
View File
@@ -2,7 +2,7 @@ import pytest
import typer
from chemenu import config
from chemenu.commands import docs_verify
from chemenu.commands import dist_cmd, docs_verify
def test_every_registered_command_is_documented():
@@ -348,3 +348,98 @@ def test_verify_raises_when_content_is_ignored(monkeypatch):
monkeypatch.setattr(docs_verify, "check_ignored_content", lambda: ["swallowed"])
with pytest.raises(typer.Exit):
docs_verify.verify()
# --- issue references in shipped documents -----------------------------------
def test_no_shipped_document_cites_an_issue():
"""Forward direction, against the real tree: a `#42` in a file `dist export`
ships points at a board only the origin repo has, and the reader of a
distributed instance can neither resolve it nor tell that it is
unresolvable."""
assert docs_verify.check_no_issue_references() == []
def test_a_cited_issue_number_is_reported(monkeypatch):
monkeypatch.setattr(
docs_verify,
"shipped_prose",
lambda: {"instructions/example.md": "A rule.\nRemoved in Gitea #66.\n"},
)
issues = docs_verify.check_no_issue_references()
assert len(issues) == 1
assert "instructions/example.md:2" in issues[0]
assert "#66" in issues[0]
def test_a_markdown_anchor_is_not_an_issue_reference(monkeypatch):
"""The reason this check can be a bare pattern at all: a link anchor and a
heading are word characters, so neither collides with `#<digits>`."""
monkeypatch.setattr(
docs_verify,
"shipped_prose",
lambda: {
"AGENTS.md": (
"# Heading\n"
"See [Gates](#gates) and [Collections](kb/CONTRACT.md#collections).\n"
"Exit code 42 means a human must look.\n"
)
},
)
assert docs_verify.check_no_issue_references() == []
def test_python_source_is_out_of_scope():
"""The scope decision, pinned: `tools/` ships as runtime machinery, and a
code comment addresses whoever edits that line - which only ever happens in
the origin repo, because `dist export` prunes the `stack-dev` skill with the
rest of `instructions/dev/`. So a `.py` file is in the export plan and out
of the scanned set."""
plan = dist_cmd.build_plan()
scanned = docs_verify.shipped_prose()
assert "tools/chemenu/commands/raw_cmd.py" in plan
assert not [path for path in scanned if path.endswith(".py")]
# ...while the prose beside it is scanned, templates included.
assert "tools/CONTRACT.md" in scanned
assert "instructions/CONTRACT.md" in scanned
assert "types/source.schema.yaml.template" in scanned
def test_a_strip_marked_pointer_never_reaches_the_check():
"""The sanctioned way to keep a pointer that is worth having here and
meaningless anywhere else. `shipped_prose` reads the export plan, whose text
already has its marker regions removed, so a number inside a marker block is
present in the working tree and absent from what ships.
Asserted over whichever files carry a marker today rather than a named one,
so retiring any single passage does not fail this for an unrelated reason -
what is pinned is that the mechanism works, not where it is used.
"""
shipped = docs_verify.shipped_prose()
# Destination paths only coincide with source paths where nothing was
# re-keyed (`types/*.md` ships as `.template`), so the ones that do not
# exist in the tree are simply not this test's subject.
sources = {
path: source.read_text(encoding="utf-8")
for path in shipped
if (source := config.ROOT / path).is_file()
}
hidden = {
path: source
for path, source in sources.items()
if "dist:strip-start" in source and docs_verify.ISSUE_REFERENCE_RE.search(source)
}
assert hidden, "no shipped document keeps an issue number behind a strip marker"
for path, source in hidden.items():
assert docs_verify.ISSUE_REFERENCE_RE.search(source)
assert not docs_verify.ISSUE_REFERENCE_RE.search(shipped[path]), path
def test_verify_raises_when_a_shipped_document_cites_an_issue(monkeypatch):
monkeypatch.setattr(docs_verify, "check_no_issue_references", lambda: ["cited"])
with pytest.raises(typer.Exit):
docs_verify.verify()
+3 -3
View File
@@ -10,7 +10,7 @@ properties:
enum: [transcript, analysis, article, document, notes, tracker, unclassified]
description: >-
The category of source material. No default - the machine must not pick
the vaguest value on the author's behalf (Gitea #66). Use `unclassified`
the vaguest value on the author's behalf. Use `unclassified`
when the category is genuinely unclear; it is a visible, advisory-lint
catalog slot, not a silent fallback.
author:
@@ -29,7 +29,7 @@ properties:
description: >-
How faithful the capture is to what was actually said or shown. No
default - a capture field, fixed at `raw accept`/`new source` time and
never guessed (Gitea #67). `unknown` is backfill-only: `raw accept` and
never guessed. `unknown` is backfill-only: `raw accept` and
`new source` refuse it, only `wikitool touch` (on a page predating this
rule) may write it. Deliberately absent from `required:` so an existing
instance's source pages keep validating without it.
@@ -39,7 +39,7 @@ properties:
description: >-
What the material is entitled to claim about its subject - independent
of `fidelity`, which asks how faithfully it was captured. Same capture,
no-default, backfill-only-`unknown` rules as `fidelity` (Gitea #67).
no-default, backfill-only-`unknown` rules as `fidelity`.
Deliberately absent from `required:` for the same reason.
source_url:
type: [string, "null"]
+1 -1
View File
@@ -112,7 +112,7 @@ in once it grows past the shard threshold, and whether a `layout:` is already de
split it recommends.
A lopsided field is a reason to fix the field, not a reason to leave `layout` off forever -
`source` was exactly this case (Gitea #66): 25 of 29 pages carried one value because the schema
`source` was exactly this case: 25 of 29 pages carried one value because the schema
handed out that value as a *default* whenever nobody chose one, not because the material was
genuinely that uniform. Once the default was removed and the pages reclassified by what they
actually were, the field split into six areas with none of them small. Declare `layout` for