Files
chemenu/tools/CONTRACT.md
T
torben 368438e48c
CI / verify (push) Successful in 54s
Release / release (push) Successful in 34s
docs: dist upgrade - Stamp-Semantik nach --keep-local, drei Eigentumsklassen in docs/ (#7)
Files changed:
- CHANGES.md
- VERSION
- docs/ownership-and-templates.md
- tools/CONTRACT.md
2026-09-04 12:41:50 +02:00

54 KiB

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, confidence decay, git publishing - never have to be re-derived by an LLM. The root 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, not here.

Setup (one time)

Full bootstrap for a fresh clone - including publishing the skills, which are not committed - is instructions/bootstrap.md. The environment alone:

cd tools
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

Usage

Run from the repo root:

tools/wikitool <command> --help

Commands

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 confidence=0.7] [--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>] [--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
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
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 --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, 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), 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)
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 --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
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 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 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). 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/, everything ignored under reports/ and the published skill directories). The name is about documentation parity, not about the docs/ directory - it neither reads nor requires one, the same way kb/ predates the collection it now checks
eval sessions [--json] List the sessions that have a trace under reports/telemetry/, most recent first. Read-only and exempt from the Iteration Budget Gate
eval 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
dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U] Write a contentless, distributable copy of this repo's machinery into an empty <target> directory: AGENTS.md/README.md/EVALS.md with any <!-- dist:strip-start -->...<!-- dist:strip-end --> region removed, instructions/ (minus instructions/dev/), types/ (the root: kb page type-specs and their schemas re-keyed as .template, the stack's own verbatim), docs/ verbatim, tools/ (no venv/caches), the .github/hooks/+.vibe/ session-tracing config plus .claude/settings.json, kb/CONTRACT.md (no pages, no areas), empty raw/{articles,documents,notes,assets}/, VERSION, USER.md.template/SOUL.md.template plus kb/CONVENTIONS.md.template and each collection's contract re-keyed as kb/<name>/COLLECTION.md.template (the templates ship; the filled USER.md/SOUL.md/kb/CONVENTIONS.md/kb/<name>/COLLECTION.md/types/<page-type>.md never do - all of them bind their instance and none are the stack's to decide, and find_leaks refuses a plan carrying one), and a generated .wikitool-release.json stamp (version, export date, origin, and a sha256 per exported file - the base a later upgrade would compare against). The four origin options only fill stamp fields: export never calls git and cannot discover them. Refuses a non-empty target, and a tree with no VERSION. See instructions/setup-instance.md. One-way: there is no command that reconstructs a distributed instance into a dev instance - work on the stack in the origin repo (or a new dev instance exported from it) instead
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"
version show [--json] Print this instance's stack version and where it came from (development tree, or a distribution with its export date and origin). Bare wikitool version is an alias for this. Read-only, offline, and exempt from the Iteration Budget Gate
version check [--url U] [--timeout S] [--json] Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (state: current|update|migration|ahead). The only command in wikitool that makes a network call - never reached implicitly from another command, needs no key, times out, and reports an unreachable feed as an error rather than as "up to date". The feed is $WIKITOOL_UPDATE_URL, else the release stamp's, else the built-in origin; $WIKITOOL_UPDATE_TOKEN is only needed if that feed is not readable anonymously. Read-only and exempt from the budget gate
version notes [--version X.Y.Z] Print one version's CHANGES.md entry, for use as release notes (default: this tree's VERSION). Read-only and exempt from the budget gate
version bump --major|--minor|--patch --title "<...>" [--breaking "<what breaks>"] [--no-migration "<reason>"] [--dry-run] Raise or continue the one running candidate between two releases - VERSION gets a -beta.N suffix, never a second fresh number per bump. --major/--minor/--patch is max-wins escalation against the last release (patch < minor < major): a --patch on a MINOR candidate only advances N, and escalation never steps back down. Opens the matching CHANGES.md entry on the first bump of a candidate (heading, date, author, and a machine-managed <!-- wikitool:bumps --> list of every --title collected so far) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. Refuses more or fewer than one part, an empty title, and a VERSION/newest-changelog-entry mismatch. Compatibility follows the leftmost non-zero component of the candidate's base, which for this stack (at 1.0.0 and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is not a drop-in replacement - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires --breaking "<what stops working>" and, on top of it, a migration document targeting the candidate's base or --no-migration "<reason>"; both lines are written once and persist over later bumps of the same candidate without being repeated, and both are refused on a bump that crosses nothing at all. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly
version release [--title "<...>"] [--dry-run] Fix the running candidate: strip VERSION's -beta.N suffix and close its CHANGES.md entry, ending the pre-release phase version bump started. Without --title the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Commits nothing and pushes nothing (invariant 5) - the following publish moves VERSION onto main, which release.yml reacts to. Refuses when VERSION is already a release (no running candidate to fix), or when the changelog's newest entry does not match VERSION
migrate list [--json] List every migration document under instructions/migrations/, oldest target first, with its kind and obligation. Read-only and exempt from the Iteration Budget Gate
migrate status [--json] Show the migrations this instance still owes, in the order they must run: every required document whose migrates_to lies in (kb_version, VERSION]. offered documents are listed separately above the chain and never block, never count as owed, and are bounded by the applied ledger rather than by kb_version - taking one deliberately does not move the version, so the version cannot say whether it was taken. When a release stamp is present, also reports which shipped files this instance has since edited (from the per-file sha256 in .wikitool-release.json), which is what says whether an offer may be copied over or has to be reconciled by hand; without a stamp that question is reported as unanswerable rather than answered. Exits 1 only when .wikitool-kb.json is missing - the content's shape is a question the tool refuses to answer by guessing. Read-only and exempt from the budget gate
migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error] Compare kb/ against a git revision on the invariants a content migration must not change: wikilink and citation counts (not sets), footnote definitions, H1, structural frontmatter, and the count of generated-region marker pairs - a page that went from one links region to two has the same set of region names and a different count, and a lost marker turns a generated region into prose the next write appends a second one beside. Reports added/removed pages without failing on them. --expect-body-change additionally flags a page whose body did not change at all. Not migration-specific - worth running after any bulk rewrite, and the one question lint cannot answer, since it reads a single revision and so cannot see that something went missing. Read-only and exempt from the budget gate
migrate 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
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

  • 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 read 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. There is no write tool - nothing under commands/ is importable from it, so the write functions are unreachable rather than filtered - and every response carries the commit it was computed from. Running it, and keeping its checkout current, is instructions/mcp-read-server.md. Authentication and rate limiting are middleware in front of the process, not code here; the Iteration Budget Gate is deliberately not applied, 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.
  • confidence decay applies a linear 1%/month reduction to confidence_base since modified/date/created, floored at 0.2, and writes the result to the derived confidence field. Keeping the undecayed anchor separate is what makes repeated runs idempotent: decaying the stored confidence in place (the pre-2026-08-13 behavior) compounded on every run, because the elapsed-months factor kept growing while the multiplicand had already shrunk. Pages with no confidence_base are skipped rather than guessed at - run confidence init-base --apply once to backfill them.
  • 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

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 and 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. What follows is the per-command detail: what exit 1 means, whether the command is atomic, and whether a retry is safe.

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:, confidence:, 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
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
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
index rebuild / sources rebuild-index Rare I/O error only Yes - the file is regenerated from scratch Safe to retry freely
log status Never fails (reports 0 if kb/log.md is missing or empty) Read-only Safe to retry freely
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"
confidence decay --apply / init-base --apply Rare I/O error mid-loop No - one write per page Safe to retry freely; both recompute from confidence_base and never compound
sync The automatic rebase hit a real conflict (git failed) No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure For a conflict: do not retry, do not force - resolve manually and re-run. Exit 42, not 1, when the rebase-review gate needs clearance: show the user the command's full output verbatim (upstream commits, the overlapping files, their diff) and stop; re-running with --confirm-rebase <token> clears it, and a wrong, invented, or superseded token exits 42 again with the current state. No remote configured, or one that cannot be reached, is not a failure - reported and skipped
publish git failed, or --yes/-y was passed. Exit 42, not 1, when the Mass-Update Gate, the rebase-review gate (raised by the same reconcile sync performs), or the Publish-Remote Gate refuses No - sequential git operations, but both gates run before staging For git failures: do not retry, do not force - report and ask the user (the reconcile step already retried the push once on its own, if a rebase resolved the rejection). For exit 42: show the user the command's full output verbatim and stop; it names the evidence and the --confirm <token> or --confirm-rebase <token> line to re-run, and re-running without it exits 42 again. The Publish-Remote Gate is the exception with no such line: it names the push URL that would have been written to and the ones this checkout allows, and only the user resolves it
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
sources coverage / sources trace Bad arguments (e.g. neither or both of --raw/--page) Read-only Fix the argument and retry
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
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
version check The feed could not be reached, answered non-JSON, or carried no tag_name. Never answers "up to date" for a question it could not ask Read-only, no local writes A network failure is transient - retry once, then report it. HTTP 401/403 names $WIKITOOL_UPDATE_TOKEN; 404 means no release exists yet or the URL points at the wrong repo
version bump More or fewer than one of --major/--minor/--patch, an empty --title, a missing VERSION/CHANGES.md, VERSION and the changelog's newest entry naming different versions, an escalation to a boundary crossing without --breaking or with neither a migration document nor --no-migration, or --breaking/--no-migration on a bump that crosses nothing No - VERSION then CHANGES.md Not idempotent: a second run escalates or continues the candidate again. If the outcome is uncertain, read VERSION and the top of CHANGES.md before retrying
version release A missing VERSION/CHANGES.md, VERSION already a release (no running candidate), or VERSION and the changelog's newest entry naming different versions No - VERSION then CHANGES.md Not idempotent: a second run fails outright once the suffix is gone. If the outcome is uncertain, read VERSION before retrying - a release-shaped VERSION means it already ran
links show Page not found Read-only Check the exact title with search; a wikilink target is not always the page's stem
migrate list / migrate status list never fails; status exits 1 when .wikitool-kb.json is missing or unreadable, or VERSION is Read-only For a missing declaration: run migrate baseline <version> once, then retry. Safe to retry freely otherwise
migrate verify Only with --fail-on-error: an invariant changed. Also exits 1 if --from is not a revision in this repository Read-only Exit 1 from --fail-on-error means "act on the findings", not "the tool is broken". A finding is never fixed by re-running - it names a page and what changed on it
migrate 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
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
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
budget status / budget reset reset without --yes; status never fails Read/rewrite of one JSON file status is safe to retry. For reset: get the user's approval, then re-run with --yes
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 WIKI_TRACE=0, so an absent trace is not necessarily a fault. Safe to retry
(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
Confidence decay Every 30 days confidence decay --apply
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 § "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.