Files
chemenu/tools/CONTRACT.md
T
torben 1b0158fc8d
CI / verify (push) Successful in 50s
Release / release (push) Successful in 38s
stack: Changelog-Eintrag geschichtet - Impact-Gruppierung, version regrade, Zusammenfassungspflicht (schliesst #95)
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

467 lines
76 KiB
Markdown

# tools/ - Compiler Contract
`wikitool` is a deterministic CLI for Chemenu, used by agents (and humans) so
mechanical wiki operations - frontmatter, index statistics, cross-references, log
formatting, git publishing - never have to be re-derived by
an LLM. The root [`AGENTS.md`](../AGENTS.md) holds the invariants that say when
using these commands is mandatory; this file is the full reference. Changes to
`wikitool` itself are tracked in the repo root [`CHANGES.md`](../CHANGES.md),
not here.
**Look up the row you need; do not read this file end to end.** Nearly all of it
is two tables carrying one row per command, and every row is a single line - so
one grep returns a command's whole contract, what it does (§ Commands) and how
it fails (§ Error contracts), and nothing else:
```bash
grep -n '^| `raw accept' tools/CONTRACT.md
```
Both tables carry the same `###` groups in the same order, so the two halves of
a group line up; read a whole group when working across it. Reading the file end
to end is for changing the CLI itself.
<!-- wikitool:toc -->
## Contents
- [Setup (one time)](#setup-one-time)
- [Usage](#usage)
- [Commands](#commands)
- [Pages](#pages)
- [Links and citations](#links-and-citations)
- [Catalog and log](#catalog-and-log)
- [Finding and checking](#finding-and-checking)
- [Provenance](#provenance)
- [Raw material and uploads](#raw-material-and-uploads)
- [Git](#git)
- [Workshop runs and session budget](#workshop-runs-and-session-budget)
- [Types, instructions and docs](#types-instructions-and-docs)
- [Telemetry](#telemetry)
- [Distribution and versioning](#distribution-and-versioning)
- [Content migrations](#content-migrations)
- [Private instances](#private-instances)
- [Instance health](#instance-health)
- [Design notes](#design-notes)
- [Tests](#tests)
- [Error contracts](#error-contracts)
- [Pages](#pages-1)
- [Links and citations](#links-and-citations-1)
- [Catalog and log](#catalog-and-log-1)
- [Finding and checking](#finding-and-checking-1)
- [Provenance](#provenance-1)
- [Raw material and uploads](#raw-material-and-uploads-1)
- [Git](#git-1)
- [Workshop runs and session budget](#workshop-runs-and-session-budget-1)
- [Types, instructions and docs](#types-instructions-and-docs-1)
- [Telemetry](#telemetry-1)
- [Distribution and versioning](#distribution-and-versioning-1)
- [Content migrations](#content-migrations-1)
- [Private instances](#private-instances-1)
- [Instance health](#instance-health-1)
- [Every command](#every-command)
- [Maintenance schedule](#maintenance-schedule)
- [Future considerations (not implemented)](#future-considerations-not-implemented)
<!-- /wikitool:toc -->
## Setup (one time)
Full bootstrap for a fresh clone - including publishing the skills, which are not
committed - is [`instructions/bootstrap.md`](../instructions/bootstrap.md). The
environment alone:
```bash
cd tools
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
## Usage
Run from the repo root:
```bash
tools/wikitool <command> --help
```
## Commands
### Pages
| Command | Purpose |
|---------|---------|
| `new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, defaults, directory (`base_dir`/`layout`), title prefix, and template - `--set` is repeatable, and comma-separated values fill array fields. An element that itself contains a comma is written `\,`, or passed as its own repeated `--set` for that field - repeating an array field appends. See `types list`/`types describe`. |
| `new entity --name "<Name>" --set entity_type=<t> [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities/<subdir>/<Name>.md` |
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--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` 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), 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 leaves no leftover directory behind. A destination already occupied (a pre-existing duplicate-stem collision) is refused rather than silently skipped |
### Links and citations
| Command | Purpose |
|---------|---------|
| `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 |
| `links show --page "<Title>" [--json]` | The declared graph around one page in both directions: the edges it asserts (from its own `related:`, with labels) and the edges other pages assert about it (computed across the corpus). The inbound half is derived rather than stored - that is what makes it complete, and it is the answer authored directional edges would otherwise have nowhere to come from. Read-only, exempt from the Iteration Budget Gate |
| `cite id --title "Source - X" [--file <qualifier>]` | Print the deterministic footnote id `cite add` would use for this (title, file) pair. Read-only, exempt from the Iteration Budget Gate |
| `cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]` | Upsert a `[^cite-id]: [[Source - X]]` definition in the page's generated footnotes region, creating it between `<!-- wikitool:footnotes -->` markers if absent (reusing the id if the page already cites this exact source/file pair) and add `Source - X` to frontmatter `sources:`. Prints the `[^cite-id]` marker - pasting it into the prose is still a manual, editorial step |
| `cite sync [--page "<Title>" \| --all] [--dry-run]` | Reconcile each page's footnotes region against its actual `[^id]` references: prune definitions nothing references any more, re-render the region in first-reference order, and report any `[^id]` reference left with no definition. A page still carrying the pre-4.0.0 undelimited block is converted to a marked region in the same pass - the marker carries the region's identity now, so re-rendering it under this instance's heading is a repair rather than a rename |
### Catalog and log
| Command | Purpose |
|---------|---------|
| `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 |
### Finding and checking
| 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** |
### Provenance
| Command | Purpose |
|---------|---------|
| `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 material and uploads
| Command | Purpose |
|---------|---------|
| `raw accept <file> [<file> ...] --fidelity <v> --authority <v> [--page "<Title>"] [--dry-run]` | Promote one or more files from `incoming/` into `raw/<YYYY>/<MM>/`, computed from the accept date rather than chosen by hand (`raw/CONTRACT.md` "Getting a file in"): a subdirectory under `incoming/` is tolerated and ignored, not inspected - `raw/` no longer addresses by type. One file promoted alone lands with no directory of its own; several files in one call nest under `raw/<YYYY>/<MM>/<stem>/`, named after the first file's stem. `--fidelity`/`--authority` are required here (see `types describe source`; `unknown` is refused, backfill-only) - the one moment both are knowable. `--page "<Title>"` additionally extends that existing source page's `raw_files:` in the same call and writes both capture fields onto it (refused if it already carries a different value - a capture field is fixed once); if that raises the page past one file, its already-promoted file is folded into a bundle at *its own* parent directory, not today's shard, so a bundle never mixes an old and a new capture date, after checking it has no other owner (`provenance.duplicate_raw_file_owners`). The set of names occupied anywhere under `raw/` - file stems and bundle directory names alike, old type directories and date shards together - 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> [--fidelity <v>] [--authority <v>] [--dry-run]` | The one sanctioned way past that uniqueness rule, and the one sanctioned way to correct an already-set capture field: overwrites `<raw-path>` in place with the single incoming file (same filename required; there is no type directory left to match), leaving every page's `raw_files:` untouched and writing no `kb/` page - the previous edition survives only in `git log --follow <raw-path>`. `--fidelity`/`--authority` are optional here, and passing one overwrites the owning page's already-set value - the one path fill-once does not block, because a corrected capture is a new edition of the source, not an edit of the page describing it. 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 |
| `upload list [--json]` | List every MCP submission currently waiting in the quarantine (`mcp-upload/`), oldest id first - id, filename, size, submitter. Only ever non-empty when `.wikitool-upload.json` opts a checkout into the MCP server's `submit` tool (see the MCP read server design note below) |
| `upload show <id> [--json]` | Print one submission's manifest in full - filename, size, sha256, submitter, submitter source (the header name, not a claim the header was honest), submission time. What a reviewer reads before `accept` |
| `upload accept <id> [--confirm TOKEN]` | **Upload Review Gate:** promote a submission's file from `mcp-upload/<id>/` into `incoming/`, delete the quarantine directory, and append an `accepted` event to `mcp-upload/ledger.jsonl`. Without a matching `--confirm`, exits **42** and prints the manifest in full plus the exact re-run line - the same shape as the Mass-Update Gate's clearance, one submission at a time. The token digests id/filename/size/sha256/submitter, so an edited or superseded manifest invalidates it. Refuses (without the gate - these are ordinary validation errors) when `incoming/<filename>` already exists |
| `upload reject <id> --reason "<why>"` | Delete a submission's material, keeping only its ledger trail: an append-only `rejected` event naming the reason and the sha256 of what was declined. No gate - rejecting needs no clearance, only accepting does |
### Git
| Command | Purpose |
|---------|---------|
| `sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` | Fetch `<remote>/<branch>` and bring the local branch up to date with it: fast-forward when the remote is simply ahead, rebase local commit(s) on top when both sides moved but touch disjoint files (a content conflict is then impossible by construction), and exit **42** for review when they touch the same file (the **rebase-review gate** - see `publish` below). Never commits, never pushes, never force-anything - no remote configured, or one that cannot be reached, is reported and skipped, not a failure. Meant to run once at the start of a writing session (`instructions/session-setup.md`) so the rest of it works against a current tree instead of discovering the drift at the final `publish` |
| `publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` | Reconcile with `<remote>/<branch>` exactly like `sync` (skipped for `--no-push`), then stage all changes, commit, and push. Refuses before staging anything when the push target is not the checked-out branch, so a `git push <branch>` cannot quietly publish a ref other than the commit just made; the *unborn* branch of a fresh `git init -b main` counts as checked out, which is what lets the first publish of a new instance work (`instructions/setup-instance.md` step 14), while a genuine detached HEAD is still refused. If the reconcile step found a still-unpushed local commit and there is nothing new to stage, that commit is pushed anyway - a previous `publish` whose push failed no longer strands it, and neither does a branch the remote has never seen (a newly created, empty remote repository). A remote that cannot be reached at all is deliberately not read that way: it keeps reporting "Nothing to commit" on a clean tree rather than attempting a push, so an offline or local-only instance is unaffected. If the push is rejected despite the pre-check (a genuine race - something landed on the remote in between), one more reconcile-and-retry is attempted before giving up; never more than one. **Mass-Update Gate:** when >= `--threshold` (default 10) *counted* files would be committed, exits **42 (`EXIT_NEEDS_CLEARANCE`)** instead of publishing - a third outcome distinct from success (0) and a validation error (1) - and prints a review report: a scale line (file count, total lines added/removed, status breakdown), only-what-applies attention notes (deletions by name, control-plane and harness-config touches, published pages, the largest single change, binaries), and every counted path grouped by area with its status and churn, generated files split out as needing no review. The token digests each counted path **and its contents** plus the publish target, so a clearance carries neither to a different file list nor to edited contents; a wrong, invented or superseded token exits 42 again with the current state. Two kinds of path are committed but never counted and never shown for approval: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`) - each is recomputable from the tree, so approving it decides nothing, and a routine ingest rebuilds five or six of them. The refusal line accounts for both, by reason. The gate is evaluated *before* anything is staged, so a refused publish leaves the working tree untouched. **Publish-Remote Gate:** when this checkout carries a `.wikitool-remotes.json` and the resolved push URL of `--remote` is not listed in it, exits **42** before the reconcile step even fetches - the URL is read from `git remote get-url --push`, so a repointed remote does not pass on its name. Unlike the other two gates it has **no token and no flag**: the way past it is the user adding the URL to that file, and an agent editing it to get past a refusal is opening a gate on its own initiative. Absent file means unrestricted; a malformed one is an error, not permission. See [instructions/gates.md](../instructions/gates.md) `--yes`/`-y` are gone and now fail with an explicit error. `--path` (repeatable) scopes the whole operation - gate count, staging, and commit - to a subtree. **Stack-machinery note:** after a successful commit/push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md`, or a path ending in `CONTRACT.md` - roughly the scope a stack version bump covers, deliberately a shade broader than CI's version gate, which matches only a `CONTRACT.md` one segment deep - prints one reminder line that the phase past this point (an issue-body rewrite, `docs/` staleness, a changelog entry's accuracy) is not covered by `docs verify`, `instructions verify` or `pytest`. Not a gate: no exit code change, nothing to clear, silent for an ordinary content publish |
### Workshop runs and session budget
| Command | Purpose |
|---------|---------|
| `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) |
| `work close --run-key <name> [--yes] [--dry-run]` | Delete a finished workshop. Lists what would be lost and requires `--yes`, because nothing in it is recoverable from the rest of the repo - the durable conclusions must already be in `kb/` |
| `budget status` | Show the current session's `wikitool` call count and recent command history (never counted against the budget) |
| `budget reset --yes [--all]` | Clear the current session's (or every session's) iteration budget state. Requires `--yes`: clearing the counter is itself a way around the gate, so it needs the same explicit human approval |
### Types, instructions and docs
| 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 |
| `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 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 |
### Telemetry
| Command | Purpose |
|---------|---------|
| `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) |
### Distribution and versioning
| Command | Purpose |
|---------|---------|
| `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), the two flat anchors `raw/.gitkeep` and `incoming/.gitkeep` (both roots are flat now that a file's location under `raw/` is a date shard rather than a hand-picked type, so a fresh export no longer creates any type subdirectories under either root; `incoming/.gitkeep` is trackable and survives becoming a git repository, so a plain clone gets the directory without any bootstrap step re-creating it), `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 `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 |
| `version bump --major\|--minor\|--patch --title "<...>" [--impact high\|medium\|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` | Raise or continue the **one running candidate** between two releases - `VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both lines are written once, anchored just above the bump list, and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly |
| `version 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
| Command | Purpose |
|---------|---------|
| `migrate list [--json]` | List every migration document under `instructions/migrations/`, oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate** |
| `migrate status [--json]` | Show the migrations this instance still owes, in the order they must run: every **required** document whose `migrates_to` lies in `(kb_version, VERSION]`. `offered` documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by `kb_version` - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in `.wikitool-release.json`), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when `.wikitool-kb.json` is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate |
| `migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` | Compare `kb/` against a git revision on the invariants a content migration must not change: wikilink and citation **counts** (not sets), footnote definitions, H1, structural frontmatter, and the **count of generated-region marker pairs** - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Pages are matched by **title**, not path, so a page `wikitool move` (or `move --reconcile`) relocated compares as itself - reported separately as `moved` - rather than as a removed-and-added pair. Reports added/removed pages without failing on them. `--expect-body-change` additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question `lint` cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate |
| `migrate 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 |
### Private instances
| Command | Purpose |
|---------|---------|
| `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 `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` |
### Instance health
| Command | Purpose |
|---------|---------|
| `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, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), `WIKITOOL_SESSION_ID`, and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). 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
- All commands operate on the real repo, so they can be run from any working
directory. The root is resolved by precedence - an explicit argument, then
`$CHEMENU_ROOT`, then a walk up from the package's own location - and the
walk-up is the default, so `tools/wikitool` with no variable set behaves
exactly as it always has. Nothing under the root is bound at import time:
`KB_DIR`, `RAW_DIR` and the rest follow whatever `ROOT` currently is, which
is what makes the half-repointed state (a moved `ROOT` with a stale `KB_DIR`)
unconstructible rather than merely discouraged.
- **The library boundary.** `chemenu.api.Corpus` is the in-process entry point:
it takes a corpus root, returns the same structures the `--json` forms print,
and raises `ChemenuError` where the CLI prints `ERROR` and exits 1. It is
read-only *structurally* - nothing under `chemenu.commands` is imported from
it, so `new`, `publish` and the rest are not reachable, rather than filtered.
The cores it calls (`search/service.py`, `lint_core.py`, `types_core.py`)
import no `typer` and no `rich`; the modules under `commands/` are the
terminal adapters over them. A second consumer is therefore a second adapter,
not a second implementation.
- **The MCP server** (`chemenu/mcp/`) is that second adapter: `search`,
`types`, `describe_type`, `lint` and `status` over `chemenu.api`, on `stdio`
or `streamable-http`. Its dependency is optional and lives in
`requirements-mcp.txt`, so a CLI-only instance does not install it. Five of
its tools are structurally read-only - nothing under
`commands/` is importable from the server, so `new`/`touch`/`xref`/`cite`/
`publish`/`migrate` are unreachable rather than filtered - and every
response carries the commit it was computed from. The one exception is
`submit` (opt-in via `.wikitool-upload.json` - absent means the
tool is not registered at all): the server may write, but by a **positive
list** rather than an absence - `chemenu.upload._write_atomic_within`
resolves every target and refuses anything outside `mcp-upload/`, the one
directory the process may touch. The *reviewer* commands that move a
submission out of that quarantine (`upload accept`/`upload reject`) keep
the original absence property: they live under `commands/`, not reachable
from the server, same as every other write command. Running the server,
and keeping its checkout current, is
[instructions/mcp-read-server.md](../instructions/mcp-read-server.md);
reviewing a submission is
[instructions/ingest-queue.md](../instructions/ingest-queue.md).
Authentication and rate limiting are middleware in front of the process,
not code here; the Iteration Budget Gate is deliberately not applied to any
of the six tools, because it bounds an agent session rather than a user.
- `new`/`xref`/`log append` only produce structurally-correct frontmatter and
body skeletons/edits - the prose (Description, Summary, judgment calls
about relationships) is still written by the LLM afterwards.
- `lint` only reports what's mechanically verifiable. Contradictions, staleness
judgment, and "what's worth writing next" remain the LLM's job; `lint`
produces a markdown skeleton with a "Semantic Review" section for that.
- **Iteration Budget Gate / Loop-Breaker** (see the root `AGENTS.md` "Gates"
section): every invocation is recorded and checked in `main()` (`cli.py`)
before Typer dispatches to any subcommand, so it applies uniformly without
each command needing its own opt-in. State lives in the gitignored
`tools/.wikitool_session/budget.json`, keyed by `WIKITOOL_SESSION_ID` (or
the caller's parent process id as a fallback), so a new terminal/session
starts with a clean budget. Default ceiling: 60 calls/session, or 3
identical calls in a row (whichever trips first). A call that left through
`_util.fail()` - a rejected argument, or a read-only check reporting
findings - is refunded: it declined instead of acting, and the contract's own
answer to a rejected argument is "retry once", which would otherwise cost two
slots for one operation. The call stays in the loop-breaker's history. `budget status` is exempt
so the situation stays reportable after the gate trips; `budget reset` is
not, and additionally requires `--yes`. Bypass only with
`--override-budget`, and only after explicit human approval - never on the
agent's own initiative.
## Tests
```bash
cd tools
.venv/bin/pip install pytest pytest-cov # one time; pytest-cov is optional
.venv/bin/python -m pytest -q # add --cov for a coverage report
```
Neither is in `requirements.txt`, and `pytest-cov` is CI-only by intent - see
[README.md](README.md#tests) and [EVALS.md](../EVALS.md).
## Error contracts
Every call has exactly three outcomes - success, a validation error (exit 1 with
an `ERROR` line), or an unexpected failure. That model, and the universal
escalation rule, are in the root [`AGENTS.md`](../AGENTS.md#tool-error-contract).
What follows is the per-command detail: what exit 1 means, whether the command
is atomic, and whether a retry is safe.
### Pages
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
| `move` | Neither or both of `--page`/`--reconcile` given, the named page not found, it has no `type:` to compute a placement from, or the destination already exists | `--page`: yes, a single file move. `--reconcile`: no - one file move per page, each idempotent | Safe to retry once as-is; a page already at its computed location is reported and left alone, and `--reconcile` only re-moves what is still misplaced. Use `--dry-run` first to see the blast radius. Never choose a directory by hand instead |
### Links and citations
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `xref add` | Page A or B not found, or a page's type declares no `related:` field | No - writes A then B, but both edits are idempotent, and both refusals happen before either write | Safe to retry once as-is; re-running never duplicates a link. Never create the missing page just to force the link through, and never hand-write a reference field the type does not declare |
| `xref remove` | Page A not found (B is allowed not to exist) | No - writes A then B, both idempotent | Safe to retry freely; removing an absent link is a no-op |
| `xref link-source` | Source page not found, an entity in `--entities` doesn't exist, or the source page itself could not be written after its targets were | No - one write per entity plus one for the source page, idempotent per page | Use `--dry-run` first; safe to retry. `sources trace --page "<Title>"` shows who was already linked |
| `links show` | Page not found | Read-only | Check the exact title with `search`; a wikilink target is not always the page's stem |
| `cite id` | Never fails | Read-only | Safe to retry freely |
| `cite add` | Page or source not found | Yes - single file write | Safe to retry; upserting the same (page, source, file) pair twice reuses the existing id and changes nothing the second time |
| `cite sync` | Neither or both of `--page`/`--all` given, or page not found | No - one write per page, each idempotent | Safe to retry freely. An undefined-reference report is not a failure - fix the reference (or run `cite add`) and re-run |
### Catalog and log
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `index rebuild` | Rare I/O error only | No - each catalog file (the map plus one shard per collection/area) is rewritten independently, then stale shards are removed; an interruption can leave some regenerated and others not | Safe to retry freely - the plan is always recomputed from the pages currently on disk, so a re-run converges |
| `log append` | Invalid `--op` or unreadable `--body-file` | Yes - single append | **Not idempotent.** If the previous run's outcome is uncertain, check the tail of `kb/log.md` before retrying |
| `log status` | Never fails (reports 0 if `kb/log.md` is missing or empty) | Read-only | Safe to retry freely |
### Finding and checking
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `lint` | Only with `--fail-on-error`: hard findings exist | Writes one report file (single atomic write) unless `--json` | Safe to retry freely, but re-run it to re-*measure*, never to re-read: the printed path holds the full report. Exit 1 means "act on the findings", not "the tool is broken" |
| `search` | `rg` is not installed or did not finish within 30 s, a malformed `--field` predicate, an unknown field name, or an unknown `--backend` | Read-only | Fix the argument and retry. A timeout is a pathological pattern or an unresponsive corpus directory, not a slow answer - narrow the query or drop `--regex` rather than retrying it unchanged. An unknown field name is reported with the list of fields that do exist - it is never answered with an empty result, because that would read as "no such pages" |
### Provenance
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `sources coverage` | Never fails | Read-only | Safe to retry freely |
| `sources trace` | Neither or both of `--raw`/`--page` given, `--raw` names a file no source page covers (reported as a plain finding plus exit 1, not the usual `ERROR`-prefixed rejection), or `--page` names an unknown page | Read-only | Fix the argument and retry |
| `sources rebuild-index` | Rare I/O error only | Yes - the single provenance file is regenerated from scratch | Safe to retry freely |
### Raw material and uploads
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `raw accept` | A file does not exist, is not under `incoming/`, or is nested more than one level below it, two files in one call share a filename, a target path already exists, `--fidelity`/`--authority` is missing (unless `--replaces`) or names `unknown` or a value outside the schema's enum, the target name is already occupied anywhere under `raw/` by something the call does not own, `--page` names an unknown page or one with no `raw_files:` yet, an existing `raw_files:` entry is missing on disk, a file to be moved has more than one owning page, or `--page` would overwrite an already-set `fidelity`/`authority` with a different value | No - one filesystem move per file, then (with `--page`) one page write | Fix the named argument and retry once. Safe to retry as-is once the cause is fixed: a file already at its computed destination is what "already exists" reports, not a partial prior run to resume. A stem-occupied refusal is not fixed by retrying at all - it names `--replaces` and renaming in `incoming/` as the two routes and neither is the tool's to pick. Never choose the destination by hand instead - that is the decision this command exists to take away |
| `raw accept --replaces` | More than one incoming file, `--page` also given, the incoming file does not exist or is not under `incoming/` (or is nested more than one level below it), its filename differs from the target's, the target does not lie under `raw/` or does not exist, `--fidelity`/`--authority` names `unknown` or a value outside the schema's enum, or the target has more than one owning source page | No - one `unlink()` + one `rename()`, plus (if `--fidelity`/`--authority` was given) one 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 |
| `upload list` | Never fails - a submission directory with a corrupt manifest is silently skipped | Read-only | Safe to retry freely |
| `upload show` | Unknown or malformed submission id | Read-only | Fix the id (see `upload list`) and retry |
| `upload accept` | Unknown or malformed submission id, the submission's file is missing from `mcp-upload/<id>/`, or `incoming/<filename>` already exists. **Exit 42, not 1**, when `--confirm` is absent or does not match the manifest's current token - the Upload Review Gate, not a validation error | No - one filesystem move, one directory delete, one ledger append; the gate check runs first, before any of them | For exit 42: show the user the full manifest and the exact `--confirm <token>` re-run line printed, and stop - the same rule as every other exit-42 gate (AGENTS.md invariant 6). For the three exit-1 cases: fix the named argument and retry once; an occupied `incoming/<filename>` is not fixed by retrying unchanged - rename or clear it first |
| `upload reject` | Unknown or malformed submission id, or an empty `--reason` | No - one ledger append, then one recursive delete; the ledger write happens first, so an interruption still leaves the reason on record | Fix the argument and retry once. Not idempotent against a second call with the same id: the first call already deleted the submission, so a retry reports "unknown id" - that is confirmation, not a failure |
### Git
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `sync` | The automatic rebase hit a real conflict (git failed) | No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure | For a conflict: **do not retry, do not force** - resolve manually and re-run. **Exit 42, not 1**, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with `--confirm-rebase <token>` clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped |
| `publish` | git failed, the push target is not the checked-out branch (including a real detached HEAD - but *not* the unborn branch of a fresh `git init`, which is a normal first publish), **or** `--yes`/`-y` was passed. **Exit 42, not 1**, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile `sync` performs), or the Publish-Remote Gate refuses | No - sequential git operations, but both gates run before staging | For git failures: **do not retry, do not force** - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the `--confirm <token>` or `--confirm-rebase <token>` line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it |
### Workshop runs and session budget
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `work new` | Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists | Yes - one directory with two files | A collision is not transient: resume the existing run instead, or pass `--again` if the tree itself changed. Never create a numbered variant by hand |
| `work close` | Unknown run key, or `--yes` was not passed | No - a recursive delete | For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` |
| `budget status` | Never fails | Read-only | Safe to retry freely |
| `budget reset` | `--yes` not passed | Read/rewrite of one JSON file (or its deletion, with `--all`) | Get the user's approval, then re-run with `--yes` |
### Types, instructions and docs
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `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 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 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
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `eval sessions` | Never fails; an empty list is a valid answer | Read-only | - |
| `eval score` | No trace exists for the named session | Read-only, apart from the files `--save`/`--markdown` write | Run `eval sessions` to see which ids exist. A session records nothing when telemetry is off - `WIKI_TRACE=0`, or a distributed instance with no opt-in (`wikitool doctor` says which) - so an absent trace is not necessarily a fault. Safe to retry |
### Distribution and versioning
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `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` 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`, 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
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `migrate list` | Never fails | Read-only | Safe to retry freely |
| `migrate status` | `.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is unreadable | Read-only | For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise |
| `migrate verify` | Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository | Read-only | Exit 1 from `--fail-on-error` means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it |
| `migrate done` | Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain | Yes - single file write | **Not idempotent** for a required migration: it advances the chain. For "not the next link", run `migrate status` and apply them in the order it prints - never force the order. Recording an `offered` migration *is* idempotent and safe to repeat |
| `migrate baseline` | Unparseable version, or a declaration already exists and `--force` was not passed | Yes - single file write | Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted |
### Private instances
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `upstream merge` | Dirty working tree, a merge already in progress, the remote does not resolve, git refused to open the merge at all (unrelated histories), or a real conflict remains in `tools/`/`types/`/`instructions/` after the content stages and stack-owned paths were restored | **No** - can leave an open, uncommitted merge behind on refusal after fetching | **Not idempotent, and not safe to retry unchanged.** For a dirty tree or an in-progress merge: fix the named precondition and retry once. For a real conflict: **do not retry, do not force** - resolve the named paths by hand (take the upstream side, or re-file the local change as an issue against the public repo per `instructions/private-instance.md`) and either `git commit --no-edit` yourself or `git merge --abort`. If the postcheck after commit finds a leak, the merge commit already exists and is **not** rolled back automatically - inspect it by hand; this is a bug report, not a retry |
| `upstream verify` | A leak was found (content changed under a content stage through a path that is not stack-owned), or `--since`/`--until` is not a revision in this repository | Read-only | A finding is not fixed by re-running - it names the paths that leaked. Fix the revision argument and retry for the second case |
### Instance health
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| `doctor` | At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) | Read-only | Each finding names its own fix command; re-run after applying it |
### Every command
| Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------|
| *(any command)* - Iteration Budget Gate / Loop-Breaker | Session call limit exceeded, or the last 3 calls were identical | N/A - pre-dispatch check, the command never ran | **Not** safe to retry as-is; retrying is the failure mode being prevented. Stop and escalate |
## Maintenance schedule
Run by the LLM through the skills, on this cadence:
| Task | Frequency | Command |
|------|-----------|---------|
| Log append | Every operation | `log append --op <type> --title "..."` |
| Index rebuild | Every page change | `index rebuild` |
| Provenance index rebuild | After any source/citation change | `sources rebuild-index` |
| Publish | After any change worth persisting | `publish --message "<op>: <desc>"` |
| Full lint | Every 10 sources (checked via `log status`), or on request | `lint`, then carry the semantic findings into `kb/log.md` via `log append --op lint` - the report itself is gitignored |
| Raw coverage check | Every 10 sources | `sources coverage` |
| Docs/instruction verification | After changing the CLI, a contract, or an instruction | `docs verify`, `instructions verify` |
| Budget check | Any time a session feels long | `budget status` |
| Retention review | Every 90 days | Manual |
## Future considerations (not implemented)
- MCP server wrapper exposing these same commands as native tool calls for
MCP-capable agents, instead of shell invocation.
- A pre-commit hook running `wikitool lint --fail-on-error` before every
`wikitool publish`. CI already runs it on every push
(`.gitea/workflows/ci.yml`), which catches it after the fact rather than
before.
- `dist upgrade`: apply a newer release to an instance that already has
content. `version check` detects that one exists and says whether it crosses
a compatibility boundary; applying it is the manual procedure in
[INSTALL.md](../INSTALL.md) § "Eine Instanz aktualisieren". The `files` block
of `.wikitool-release.json` is the groundwork - it records what the machinery
looked like at install time, which is the only way to tell a file the
instance edited from one it merely received.