From 26e101876630b55a9f5dd04ba8e4cab995b09e68 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sat, 26 Sep 2026 07:53:10 +0200 Subject: [PATCH] tools: one data record per command - -h, index and CONTRACT.md render from cli_contract (#121) Files changed: - AGENTS.md - CHANGES.md - VERSION - instructions/dev/doc-pull-through.md - instructions/dev/stack-close/SKILL.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli.py - tools/chemenu/cli_contract.py - tools/chemenu/commands/cite_cmd.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/eval_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/instructions_cmd.py - tools/chemenu/commands/links_cmd.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/log_append.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/provenance_cmd.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/commands/review_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/search.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/commands/touch.py - tools/chemenu/commands/types_cmd.py - tools/chemenu/commands/upload_cmd.py - tools/chemenu/commands/upstream_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/commands/work_cmd.py - tools/chemenu/commands/xref.py - tools/chemenu/tests/test_cli_contract.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_run_budget.py --- AGENTS.md | 24 +- CHANGES.md | 42 +- VERSION | 2 +- instructions/dev/doc-pull-through.md | 2 +- instructions/dev/stack-close/SKILL.md | 6 +- tools/CONTRACT.md | 2070 +++++++++++++++++--- tools/README.md | 38 +- tools/chemenu/cli.py | 108 + tools/chemenu/cli_contract.py | 435 ++++ tools/chemenu/commands/cite_cmd.py | 64 +- tools/chemenu/commands/dist_cmd.py | 131 +- tools/chemenu/commands/docs_verify.py | 447 ++++- tools/chemenu/commands/doctor.py | 59 +- tools/chemenu/commands/eval_cmd.py | 43 +- tools/chemenu/commands/git_publish.py | 111 +- tools/chemenu/commands/index_build.py | 26 +- tools/chemenu/commands/instructions_cmd.py | 74 +- tools/chemenu/commands/links_cmd.py | 24 +- tools/chemenu/commands/lint.py | 45 +- tools/chemenu/commands/log_append.py | 39 +- tools/chemenu/commands/migrate_cmd.py | 125 +- tools/chemenu/commands/new_page.py | 112 +- tools/chemenu/commands/page_ops.py | 83 +- tools/chemenu/commands/provenance_cmd.py | 56 +- tools/chemenu/commands/raw_cmd.py | 86 +- tools/chemenu/commands/review_cmd.py | 61 +- tools/chemenu/commands/run_budget.py | 133 +- tools/chemenu/commands/search.py | 47 + tools/chemenu/commands/task_cmd.py | 140 +- tools/chemenu/commands/touch.py | 39 +- tools/chemenu/commands/types_cmd.py | 56 +- tools/chemenu/commands/upload_cmd.py | 89 +- tools/chemenu/commands/upstream_cmd.py | 74 +- tools/chemenu/commands/version_cmd.py | 234 ++- tools/chemenu/commands/work_cmd.py | 47 +- tools/chemenu/commands/xref.py | 83 +- tools/chemenu/tests/test_cli_contract.py | 221 +++ tools/chemenu/tests/test_docs_verify.py | 384 ++-- tools/chemenu/tests/test_run_budget.py | 45 +- 39 files changed, 5203 insertions(+), 702 deletions(-) create mode 100644 tools/chemenu/cli_contract.py create mode 100644 tools/chemenu/tests/test_cli_contract.py diff --git a/AGENTS.md b/AGENTS.md index 0d827dd..1650631 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,7 +219,7 @@ stack is built the way it is - see [File naming](#file-naming)), and this file. | `kb/` | [kb/CONTRACT.md](kb/CONTRACT.md) + `kb/CONVENTIONS.md` | What the stack enforces about a page (collections, linking, provenance), and beside it what this instance decided (language, naming, tone, labels, hedging) | | `reports/` | [reports/CONTRACT.md](reports/CONTRACT.md) | Why reports and traces are generated, gitignored, and carried into `kb/log.md` | | `work/` | [work/CONTRACT.md](work/CONTRACT.md) | Workshop runs: run keys, required files, why they are tracked, how a run closes | -| `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Command reference and per-command error contracts, one row per command in each of two tables - a file to look a row up in rather than read through, as its own opening paragraph says - plus the maintenance schedule | +| `tools/` | [tools/CONTRACT.md](tools/CONTRACT.md) | Command reference: one generated data record per command (name, synopsis, properties, exit status, retry policy), plus an index and the maintenance schedule - a command to look up (`wikitool -h`, or a `grep` here) rather than a file to read through, as its own opening paragraph says | | `instructions/` | [instructions/CONTRACT.md](instructions/CONTRACT.md) | Instruction vs. skill, publishing, writing standard | **By collection** - then read the contract for the collection you are writing in. @@ -295,12 +295,18 @@ Every `tools/wikitool` call has exactly four outcomes: 3. **User clearance required (exit 42).** Not an error and not yours to resolve: show the command's output to the user verbatim and stop. See [Gates](#gates). 4. **Unexpected error (timeout, crash, interrupted process).** Do not guess whether it - worked, do not retry more than once, and never hand-write what the tool would have - produced. + worked, do not retry more than once - or, when the command is non-idempotent, not at + all: an unclear outcome plus a blind retry is how a non-idempotent call takes effect + twice. This is narrower than case 2's own "fix the cause, retry once": a validation error + is a known cause with a known fix, so it always gets that one retry regardless of + idempotency, and a command's own retry-policy text (`wikitool -h`) is the one to + follow for it. -After the single allowed retry - or immediately, for the non-idempotent commands `new`, -`log append`, `publish`, and `upstream merge` - stop and report the exact command and error -text to the user. +After the single allowed retry - or immediately, for case 4 on a non-idempotent command - +stop and report the exact command and error text to the user. Which commands those are is +not a list here to drift behind the code: `wikitool -h | grep non-idempotent` reads it from +each command's own +`cli_contract` record, the same property `tools/CONTRACT.md`'s generated index prints. Per-command detail (what exit 1 means, whether the command is atomic, whether a retry is safe) is in [tools/CONTRACT.md](tools/CONTRACT.md). A gate refusal is not a validation error - @@ -338,9 +344,9 @@ READMEs go in [CHANGES.md](CHANGES.md) - never in an inline version-history tabl change that introduced a stage, a command or a workflow, not follow-up work: nobody comes back for them, and a document that describes a repo which no longer exists is worse than none. What `tools/wikitool docs verify` mechanically checks is exactly what its own `docs verify` row in -[tools/CONTRACT.md](tools/CONTRACT.md) lists - no more. **Every cell's text is outside that -check** - a command table entry's description, an error contract's wording, a stage contract's -prose - and is therefore session work, the same as the three README-shaped files. +[tools/CONTRACT.md](tools/CONTRACT.md) lists - no more. **Every prose field is outside that +check** - a command's own summary, notes or retry-policy text, a stage contract's prose - and is +therefore session work, the same as the three README-shaped files. `docs/` pages are held to a different clock than those three. A README goes stale on every new flag; a `docs/` page goes stale only when the reasoning it wrote down stops holding - a gate diff --git a/CHANGES.md b/CHANGES.md index 205cb39..32e3422 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,11 +59,14 @@ concern - readable here, never shipped as something to parse. --- -## 7.1.0-beta.3 - 2026-09-25 - stack-close: wait for CI through the authenticated Gitea connection, with timings and a give-up point +## 7.1.0-beta.4 - 2026-09-25 - wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1) **Author:** Torben Nehmer +**High impact** +- wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1) + **Medium impact** - CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them @@ -111,6 +114,43 @@ check after about 4 minutes (5 with a release job), then once a minute, and hand after 15. Any shell loop that polls instead must end on the first unexpected response. Dev-only; nothing here ships to an instance. +### wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1) + +`--help`/`-h`, the `tools/CONTRACT.md` command reference, and the run-time budget exemption list +used to be four hand-maintained copies of the same facts about a command - a docstring, two +tables in `tools/CONTRACT.md` (§ Commands, § Error contracts), and `run_budget.py`'s own +`SKIP_COMMANDS`/`SKIP_COMMAND_PATHS` sets - and they had already drifted (`xref add`/ +`xref link-source` were missing `--dry-run` from their documented synopsis; the tool error +contract's non-idempotent list disagreed with the per-command retry-policy cells it stood next +to). All 61 commands (60 existing, plus the new `docs contract`) now carry one +`cli_contract.CommandRecord` - name, synopsis, properties (effect, idempotency, atomicity, +budget, network, gates), exit status and retry policy - attached to the command function by a +`@cli_contract.record(...)` decorator in its own module. Three views render from that one +source: `wikitool -h` (the full record, plain text), `wikitool -h` (an index line per +command, fixed-width and `grep`-stable), and `tools/CONTRACT.md`'s generated +`` region (`wikitool docs contract [--apply]`), which replaces the two +old tables. `run_budget.is_exempt` now reads a command's `budget:` property directly instead of +carrying its own list, which is what `wikitool -h | grep non-idempotent`/`budget:exempt` now +answers for `AGENTS.md`'s tool error contract instead of a hand-written enumeration. + +Help itself changed shape: Rich's boxed panels are off (`typer.core.HAS_RICH = False`) for both +`--help` and a usage error, so the output is the same plain, GNU-style text with or without a +TTY - byte-identical, which a new test pins by comparing a real run against one with `isatty` +patched. `-h` is now a recognised alias for `--help` on every command (no command used the flag +for anything else). `wikitool docs verify` grew three checks to hold the new machinery to the +same "checked or absent" rule as everything else it enforces: every registered command has +exactly one `cli_contract` record and appears exactly once in `cli_contract.GROUPS`, every +command's non-hidden flags match its record's SYNOPSIS in both directions, and no command's +rendered help - a docstring above its `\f` marker, or an option's own `help=` text - cites an +issue number (a distributed instance has no tracker to resolve one against, the same reasoning +`check_no_issue_references` already applied to shipped `.md` files). + +Redactional work - examples, an explicit "never do this" section per command, and pulling the +"why" out of a record's NOTES into its own section - is Phase 2 (Gitea #142), deliberately not +part of this change: this pass moved the existing table cells' text into records mechanically, +without editing it beyond the one documented fix (the `--dry-run` synopsis gap above) and the +docstring/option-text rewording needed to stop citing issue numbers in rendered help. + --- ## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join diff --git a/VERSION b/VERSION index 5e357ca..454f1bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.1.0-beta.3 +7.1.0-beta.4 diff --git a/instructions/dev/doc-pull-through.md b/instructions/dev/doc-pull-through.md index 755bc5c..5e58558 100644 --- a/instructions/dev/doc-pull-through.md +++ b/instructions/dev/doc-pull-through.md @@ -33,7 +33,7 @@ touched; a row that does not apply needs no action. | Touched surface | Document(s) that make a claim about it | |---|---| - | A `wikitool` command's behaviour, flags, or interface | Both tables in [tools/CONTRACT.md](../../tools/CONTRACT.md): the command reference row, and its per-command error contract (exit codes, atomicity, retry-safety) | + | A `wikitool` command's behaviour, flags, or interface | Its `cli_contract.CommandRecord` (name, synopsis, properties, exit status, retry policy - `tools/chemenu/cli_contract.py`), then `wikitool docs contract --apply` to regenerate its copy in [tools/CONTRACT.md](../../tools/CONTRACT.md) | | A stage's authoring rules (`raw/`, `kb/`, `types/`, `reports/`, `work/`, `tools/`, `instructions/`) | The touched `/CONTRACT.md` | | A rule, gate, or invariant `AGENTS.md` itself states | The relevant `AGENTS.md` section (Invariants, Gates, File naming, Routing, ...) | | A workflow, stage, or command a human operates by hand | Whichever of `README.md`, `EVALS.md`, `tools/README.md`, `INSTALL.md`, `DEVELOPMENT.md` names it - AGENTS.md § File naming says which document is for which reader | diff --git a/instructions/dev/stack-close/SKILL.md b/instructions/dev/stack-close/SKILL.md index a55dba8..ef00246 100644 --- a/instructions/dev/stack-close/SKILL.md +++ b/instructions/dev/stack-close/SKILL.md @@ -96,9 +96,9 @@ and a fresh subagent starts without the session's context). 3. **Check whether a `docs/` page, a contract, or a new human doc went stale.** A `docs/` page carries no normative sentence, so nothing verifies it by construction (AGENTS.md § File - naming) - the same is true of `tools/CONTRACT.md`'s two tables and any touched - `/CONTRACT.md`, whose prose `docs verify` checks only for presence and table-row - membership, never for what a cell or a section actually says + naming) - the same is true of `tools/CONTRACT.md`'s generated command records and any touched + `/CONTRACT.md`, whose prose `docs verify` checks only for presence and record + membership, never for what a field or a section actually says (`instructions/dev/doc-pull-through.md`); of `README.md`/`INSTALL.md`/`DEVELOPMENT.md` prose; and of a new instruction's own wording, which `instructions verify` checks structurally but never for what it claims. If the change this package shipped moved the reasoning or the diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 9adda6e..5f7de37 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -8,18 +8,21 @@ 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: +**Look up the command you need; do not read this file end to end.** Every +command carries one data record - name, synopsis, properties, exit status, and +so on - attached to its function in code (`tools/chemenu/cli_contract.py`). +Three views render from that one source: ```bash -grep -n '^| `raw accept' tools/CONTRACT.md +tools/wikitool raw accept -h # the full record, for one command +tools/wikitool -h | grep '^raw accept' # the index line, for a cross-command question +grep -n '^#### `raw accept`' tools/CONTRACT.md # this file's own copy, generated ``` -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. +The § Commands section below is `wikitool docs contract`'s output: an index +(one line per command, `grep`-stable) followed by each `###` group's commands +as `#### ` records, in the same order `wikitool -h` prints. Reading the +file end to end is for changing the CLI itself. ## Contents @@ -43,22 +46,6 @@ to end is for changing the CLI itself. - [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) @@ -80,147 +67,1846 @@ python3 -m venv .venv Run from the repo root: ```bash -tools/wikitool --help +tools/wikitool -h ``` +`-h` and `--help` are identical and TTY-independent - the same plain, unframed text either +way, for a human or an agent. Bare `tools/wikitool -h` prints the index below plus a pointer back +to this form. + ## Commands + +``` +new write non-idempotent budget:counted exit:0,1,42 Scaffold a new wiki page of any type. +task new write non-idempotent budget:counted exit:0,1 Create one open item in the configured task tracker - never a kb/ page. +task list read idempotent budget:counted exit:0,1 List a project's open items - id, title, and whether each carries the WAITING status. +task close write idempotent budget:counted exit:0,1 Mark one tracker item done - never delete it. +touch write idempotent budget:counted exit:0,1 Bump a page's `modified:` date and optionally rewrite its other frontmatter fields. +rename write idempotent budget:counted exit:0,1 Rename a page, or repoint references that name a page that never existed. +rm write non-idempotent budget:counted exit:0,1 Delete a page and mechanically de-link it from the rest of the wiki. +move write idempotent budget:counted exit:0,1 Move a page (or every misplaced page) to the directory its type-spec computes. +xref add write idempotent budget:counted exit:0,1 Declare that A B. +xref remove write idempotent budget:counted exit:0,1 Remove a cross-reference: the inverse of `xref add`. +xref link-source write idempotent budget:counted exit:0,1 Batch-link a source page to every entity/concept it mentions. +links show read idempotent budget:exempt exit:0,1 Show the edges out of and into a page. +cite id read idempotent budget:exempt exit:0 Print the deterministic footnote id `cite add` would use for this (title, file) pair. +cite add write idempotent budget:counted exit:0,1 Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region. +cite sync write idempotent budget:counted exit:0,1 Reconcile each page's footnotes region against its actual `[^id]` references. +index rebuild write idempotent budget:counted exit:0,1 Regenerate the catalog from every page's frontmatter. +log append write non-idempotent budget:counted exit:0,1 Append a formatted entry to `kb/log.md`. +log status read idempotent budget:counted exit:0 Read-only: count `ingest` entries logged since the last `lint` entry. +lint write idempotent budget:counted exit:0,1 Run structural lint checks against kb/. +search read idempotent budget:exempt exit:0,1 Find pages in `kb/` by text and/or frontmatter. +review read idempotent budget:exempt exit:0,1 The GTD weekly review. +sources coverage read idempotent budget:counted exit:0 List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages. +sources trace read idempotent budget:counted exit:0,1 Trace provenance in either direction: raw file, or page. +sources rebuild-index write idempotent budget:counted exit:0,1 Regenerate the `kb/provenance.md` reverse index. +raw accept write non-idempotent budget:counted exit:0,1 Promote one or more files from `incoming/` into `raw/`. +upload list read idempotent budget:counted exit:0 List every MCP submission currently waiting in the quarantine (`mcp-upload/`). +upload show read idempotent budget:counted exit:0,1 Print one submission's manifest in full. +upload accept write non-idempotent budget:counted exit:0,1,42 **Upload Review Gate:** promote a submission's file from quarantine into `incoming/`. +upload reject write non-idempotent budget:counted exit:0,1 Delete a submission's material, keeping only its ledger trail. +sync write idempotent budget:counted exit:0,1,42 Fetch `/` and bring the local branch up to date with it. +publish write non-idempotent budget:counted exit:0,1,42 Reconcile with `/`, then stage all changes, commit, and push. +work new write non-idempotent budget:counted exit:0,1 Scaffold `work//` for one workshop run. +work close write non-idempotent budget:counted exit:0,1 Delete a finished workshop. +budget status read idempotent budget:exempt exit:0 Show the current session's `wikitool` call count and recent command history. +budget reset write non-idempotent budget:counted exit:0,1 Clear the current session's (or every session's) iteration budget state. +types list read idempotent budget:counted exit:0 List every type-spec under `types/`. +types describe read idempotent budget:counted exit:0,1 Print one type's full contract. +instructions sync write idempotent budget:counted exit:0,1 Publish every `instructions//SKILL.md` into the harness skill directories. +instructions verify read idempotent budget:counted exit:0,1 Check the instruction layer. +instructions list read idempotent budget:counted exit:0 List the flat instructions with their descriptions. +docs verify read idempotent budget:counted exit:0,1 Check the docs that mirror the code. +docs toc write idempotent budget:counted exit:0,1 Create, refresh or remove the generated table-of-contents region. +docs contract write idempotent budget:counted exit:0 Regenerate `tools/CONTRACT.md`'s `` region. +eval sessions read idempotent budget:exempt exit:0 List the sessions that have a trace under `reports/telemetry/`. +eval score read idempotent budget:exempt exit:0,1 Score one traced session. +dist export write idempotent budget:counted exit:0,1 Write a contentless, distributable copy of this repo's machinery. +dist upgrade write non-idempotent budget:counted exit:0,1 Apply a stack update `dist export` produced - the write half of `version check`. +version show read idempotent budget:exempt exit:0,1 Print this instance's stack version and where it came from. +version check read idempotent budget:exempt exit:0,1 Ask the origin's release feed whether a newer stack exists. +version notes read idempotent budget:exempt exit:0,1 Print one version's release notes. +version bump write non-idempotent budget:counted exit:0,1 Raise or continue the one running candidate between two releases. +version regrade write non-idempotent budget:exempt_without_args exit:0,1 List the running candidate's bump titles with their impact grade, or change one or more of them. +version release write non-idempotent budget:counted exit:0,1 Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry. +migrate list read idempotent budget:exempt exit:0 List every migration document under `instructions/migrations/`. +migrate status read idempotent budget:exempt exit:0,1 Show the migrations this instance still owes, in the order they must run. +migrate verify read idempotent budget:exempt exit:0,1 Compare `kb/` against a git revision on the invariants a content migration must not change. +migrate done write non-idempotent budget:counted exit:0,1 Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`. +migrate baseline write idempotent budget:counted exit:0,1 Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. +upstream merge write non-idempotent budget:counted exit:0,1 Take a stack update into a private instance's branch, machinery only. +upstream verify read idempotent budget:exempt exit:0,1 Compare two revisions: did anything under a content stage change except through a stack-owned path? +doctor read idempotent budget:exempt exit:0,1 Check that this instance is correctly configured. +``` + ### Pages -| Command | Purpose | -|---------|---------| -| `new --name "" [--type ] [--set field=value ...]` | Scaffold a page of any type. The type-spec drives fields, directory (`base_dir`/`layout`), title prefix, and template - a schema `default:` is materialized only for a field the schema also lists in `required:` (an optional field's default is a reader-side assumption, not a scaffold-time value) - `--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 "" --set entity_type= [--set tags=a,b] [--set related=X,Y] [--set sources="Source - Z"] [--set provenance=sourced\|general\|mixed]` | Scaffold `kb/entities//.md` | -| `new concept --name "" --set concept_type= ...` | Scaffold `kb/concepts/.md` | -| `new source --name "" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - .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` | -| `new project --name "" --set responsibility= [--resume]` | Scaffold `kb/gtd//.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider whose *configured access path* has no write path (Super Productivity's `access: "snapshot"` - the tracker is read-only from there by construction) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - neither the tracker project nor the page is created, and `--resume` behaves the same. A provider that could write but has no project-creation endpoint of its own (Super Productivity's `access: "api"` - `GET /projects` exists, `POST /projects` does not) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused | -| `task new --title "" (--project "<Name>" \| --inbox) [--waiting [--follow-up-at YYYY-MM-DD]] [--notes "..."]` | Create one open item in the configured task tracker - never a kb/ page. The second creation command alongside `new project`, and the last one their split needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing tracker project, matched case-insensitively - never created and never searched or guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item filed there never appears in `review`, since every one of its checks is reached through a project name and the inbox has none) is required; an omitted `--project` refuses rather than silently falling into the inbox. `--waiting` sets the WAITING status the review's own waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source page this item came from), stored verbatim, never parsed - the same posture a `WAITING` item's own title already has for the person named in it. No `.wikitool-tasks.json` fails immediately with the same "no tracker configured" message as `review`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `new project`. Unlike `new project`, **never exits 42**: every provider offering a write path at all has a real item-creation call (Super Productivity's `POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not match any tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be created via its API), are ordinary exit-1 refusals instead, creating nothing | -| `task list --project "<Name>"` | List a project's open items - id, title, and whether each carries the `WAITING` status. Read-only; the id source `task close` and the review's own `waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. Works on either access mode a provider offers, unlike the write commands below. No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `review`/`task new`; a `--project` matching no tracker project prints "No open items", since `TaskReader.open_items` does not distinguish "empty" from "unknown" (`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring) | -| `task close --id <item-id>` | Mark one tracker item done - never delete it, per `docs/knowledge-and-commitment.md`. `<item-id>` is the provider's own id, from `task list` or a `review` finding, never a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and the tracker's only shared coupling. The only closing write this stack makes: no "move a reminder", no "remove an item". No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `task new`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `task new`. Never exits 42, same reasoning as `task new`: every provider offering a write path has a real per-item write call | -| `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 | +#### `new` + +Scaffold a new wiki page of any type. + +**SYNOPSIS** + +- `wikitool new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]` - Scaffold a page of any type. The type-spec drives fields, directory (`base_dir`/`layout`), title prefix, and template - a schema `default:` is materialized only for a field the schema also lists in `required:` (an optional field's default is a reader-side assumption, not a scaffold-time value) - `--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`. +- `wikitool 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` +- `wikitool new concept --name "<Name>" --set concept_type=<t> ...` - Scaffold `kb/concepts/<Name>.md` +- `wikitool 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) +- `wikitool new comparison --name "X vs Y" --set entities=X,Y` - Scaffold `kb/comparisons/X vs Y.md` +- `wikitool new project --name "<Name>" --set responsibility=<bereich> [--resume]` - Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider whose *configured access path* has no write path (Super Productivity's `access: "snapshot"` - the tracker is read-only from there by construction) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - neither the tracker project nor the page is created, and `--resume` behaves the same. A provider that could write but has no project-creation endpoint of its own (Super Productivity's `access: "api"` - `GET /projects` exists, `POST /projects` does not) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: `new <type>`: Yes - single file write. `new project`: **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured +- budget: counted +- network: no +- gates: human-intervention-required (`new project` only) + +**EXIT STATUS** + +- 0 success +- 1 new <type>: Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist +- 1 new project: Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively - for `caldav` this is checked against every list in the account, not only the ones counted as projects), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) +- 42 needs clearance - human-intervention-required (`new project` only) (see AGENTS.md § Gates) + +**ON FAILURE** + +- new <type>: Not transient; fix the argument and retry once. Never hand-craft the page instead +- new project: A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above, and is `superproductivity`-only: that provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it. `caldav` never produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a valid, non-colliding name always creates the list itself + +**NOTES** + +`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. + +#### `task new` + +Create one open item in the configured task tracker - never a kb/ page. + +**SYNOPSIS** + +- `wikitool task new --title "<Title>" (--project "<Name>" | --inbox) [--waiting [--follow-up-at YYYY-MM-DD]] [--notes "..."]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind +- budget: counted +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching no tracker project, `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist), or a read-only access path (Super Productivity's `access: "snapshot"`) + +**ON FAILURE** + +- Not transient; fix the argument, create the missing tracker project or tag first, or point at an `access: "api"` instance, then retry once. **Never exit 42** - unlike `new project`, every provider offering a write path at all has a real item-creation call, so there is no human-clearance step to wait on here + +**NOTES** + +The second creation command alongside `new project`, and the last one their split needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing tracker project, matched case-insensitively - never created and never searched or guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item filed there never appears in `review`, since every one of its checks is reached through a project name and the inbox has none) is required; an omitted `--project` refuses rather than silently falling into the inbox. `--waiting` sets the WAITING status the review's own waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source page this item came from), stored verbatim, never parsed - the same posture a `WAITING` item's own title already has for the person named in it. No `.wikitool-tasks.json` fails immediately with the same "no tracker configured" message as `review`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `new project`. Unlike `new project`, **never exits 42**: every provider offering a write path at all has a real item-creation call (Super Productivity's `POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not match any tracker project, or `--waiting` against a provider that cannot represent it right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be created via its API), are ordinary exit-1 refusals instead, creating nothing + +#### `task list` + +List a project's open items - id, title, and whether each carries the WAITING status. + +**SYNOPSIS** + +- `wikitool task list --project "<Name>"` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Yes - read-only, nothing to leave half-written +- budget: counted +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 No `.wikitool-tasks.json` + +**ON FAILURE** + +- Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row + +**NOTES** + +Read-only; the id source `task close` and the review's own `waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. Works on either access mode a provider offers, unlike the write commands below. No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `review`/`task new`; a `--project` matching no tracker project prints "No open items", since `TaskReader.open_items` does not distinguish "empty" from "unknown" (`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring) + +#### `task close` + +Mark one tracker item done - never delete it. + +**SYNOPSIS** + +- `wikitool task close --id <item-id>` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written +- budget: counted +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) + +**ON FAILURE** + +- Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` + +**NOTES** + +`<item-id>` is the provider's own id, from `task list` or a `review` finding, never a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and the tracker's only shared coupling. The only closing write this stack makes: no "move a reminder", no "remove an item". No `.wikitool-tasks.json` fails with the same "no tracker configured" message as `task new`. A provider whose configured access path has no write path (Super Productivity's `access: "snapshot"`) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - same posture as `task new`. Never exits 42, same reasoning as `task new`: every provider offering a write path has a real per-item write call + +#### `touch` + +Bump a page's `modified:` date and optionally rewrite its other frontmatter fields. + +**SYNOPSIS** + +- `wikitool touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - single file write, and every refusal happens before it +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Rename a page, or repoint references that name a page that never existed. + +**SYNOPSIS** + +- `wikitool rename --from "<Old>" --to "<New>" [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - one write per referencing page, then the file move +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Delete a page and mechanically de-link it from the rest of the wiki. + +**SYNOPSIS** + +- `wikitool rm --page "<Title>" [--yes] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - one write per referencing page, then the delete +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Page not found, **or** other pages still reference it and `--yes` was not passed + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Move a page (or every misplaced page) to the directory its type-spec computes. + +**SYNOPSIS** + +- `wikitool move --page "<Title>"` +- `wikitool move --reconcile [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: `--page`: yes, a single file move. `--reconcile`: no - one file move per page, each idempotent +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- 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 + +**NOTES** + +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 | +#### `xref add` + +Declare that A <rel> B. + +**SYNOPSIS** + +- `wikitool xref add --a "<A>" --b "<B>" --rel <label> [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - writes A then B, but both edits are idempotent, and both refusals happen before either write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Page A or B not found, or a page's type declares no `related:` field + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Remove a cross-reference: the inverse of `xref add`. + +**SYNOPSIS** + +- `wikitool xref remove --a "<A>" --b "<B>" [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - writes A then B, both idempotent +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Page A not found (B is allowed not to exist) + +**ON FAILURE** + +- Safe to retry freely; removing an absent link is a no-op + +**NOTES** + +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` + +Batch-link a source page to every entity/concept it mentions. + +**SYNOPSIS** + +- `wikitool xref link-source --source "Source - X" --entities A,B,C [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - one write per entity plus one for the source page, idempotent per page +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Source page not found, an entity in `--entities` doesn't exist, or the source page itself could not be written after its targets were + +**ON FAILURE** + +- Use `--dry-run` first; safe to retry. `sources trace --page "<Title>"` shows who was already linked + +**NOTES** + +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` + +Show the edges out of and into a page. + +**SYNOPSIS** + +- `wikitool links show --page "<Title>" [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Page not found + +**ON FAILURE** + +- Check the exact title with `search`; a wikilink target is not always the page's stem + +**NOTES** + +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` + +Print the deterministic footnote id `cite add` would use for this (title, file) pair. + +**SYNOPSIS** + +- `wikitool cite id --title "Source - X" [--file <qualifier>]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Read-only preview - does not check the id is actually free on any given page. Never fails. Safe to retry freely. Exempt from the Iteration Budget Gate + +#### `cite add` + +Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region. + +**SYNOPSIS** + +- `wikitool cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - single file write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Page or source not found + +**ON FAILURE** + +- Safe to retry; upserting the same (page, source, file) pair twice reuses the existing id and changes nothing the second time + +**NOTES** + +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` + +Reconcile each page's footnotes region against its actual `[^id]` references. + +**SYNOPSIS** + +- `wikitool cite sync [--page "<Title>" | --all] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - one write per page, each idempotent +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Neither or both of `--page`/`--all` given, or page not found + +**ON FAILURE** + +- Safe to retry freely. An undefined-reference report is not a failure - fix the reference (or run `cite add`) and re-run + +**NOTES** + +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 | +#### `index rebuild` + +Regenerate the catalog from every page's frontmatter. + +**SYNOPSIS** + +- `wikitool index rebuild [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: 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 +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Rare I/O error only + +**ON FAILURE** + +- Safe to retry freely - the plan is always recomputed from the pages currently on disk, so a re-run converges + +**NOTES** + +`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` + +Append a formatted entry to `kb/log.md`. + +**SYNOPSIS** + +- `wikitool log append --op ingest|query|lint|create|update|delete|rename|move --title "..." [--body "..."|--body-file path]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: Yes - single append +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Invalid `--op` or unreadable `--body-file` + +**ON FAILURE** + +- **Not idempotent.** If the previous run's outcome is uncertain, check the tail of `kb/log.md` before retrying + +**NOTES** + +Append a formatted entry to `kb/log.md`. + +#### `log status` + +Read-only: count `ingest` entries logged since the last `lint` entry. + +**SYNOPSIS** + +- `wikitool log status` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +The deterministic trigger behind the Maintenance Schedule's "every 10 sources" full-lint cadence. Never fails (reports 0 if `kb/log.md` is missing or empty). Safe to retry freely. ### 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. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** | -| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at` at all, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** | +#### `lint` + +Run structural lint checks against kb/. + +**SYNOPSIS** + +- `wikitool lint [--json] [--markdown out.md] [--full] [--fail-on-error]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Writes one report file (single atomic write) unless `--json` +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Only with `--fail-on-error`: hard findings exist + +**ON FAILURE** + +- 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" + +**NOTES** + +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` + +Find pages in `kb/` by text and/or frontmatter. + +**SYNOPSIS** + +- `wikitool search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 `rg` is not installed or did not finish within 30 s, a malformed `--field` predicate, an unknown field name, or an unknown `--backend` + +**ON FAILURE** + +- 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" + +**NOTES** + +Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score | kind/subtype | title | path | summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" | "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** + +#### `review` + +The GTD weekly review. + +**SYNOPSIS** + +- `wikitool review [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 Either no `.wikitool-tasks.json` (or a malformed one) - not yours to fix by retrying unchanged, configure or repair it first - **or** the provider was reachable at config-parse time but a read call failed mid-run, in which case the full report (findings plus which checks ran) is printed first and exit 1 follows, never a silent partial success + +**ON FAILURE** + +- The two exit-1 causes above need different responses: a config problem needs editing `.wikitool-tasks.json`; an unreachable provider (e.g. the tracker app not running) needs starting it, then a plain retry - the command re-reads everything fresh each time, so nothing here is ever stale to re-fetch + +**NOTES** + +Joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at` at all, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. 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) | +#### `sources coverage` + +List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages. + +**SYNOPSIS** + +- `wikitool sources coverage [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +List raw files with no source page, broken `raw_files:` references, and legacy directory/URL-only source pages. Never fails. Safe to retry freely. + +#### `sources trace` + +Trace provenance in either direction: raw file, or page. + +**SYNOPSIS** + +- `wikitool sources trace --raw <path> | --page "<Title>"` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- Fix the argument and retry + +**NOTES** + +Trace provenance in either direction: raw file -> source page(s) -> citing pages, or page -> its sources -> their raw files + +#### `sources rebuild-index` + +Regenerate the `kb/provenance.md` reverse index. + +**SYNOPSIS** + +- `wikitool sources rebuild-index [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - the single provenance file is regenerated from scratch +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Rare I/O error only + +**ON FAILURE** + +- Safe to retry freely + +**NOTES** + +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 | +#### `raw accept` + +Promote one or more files from `incoming/` into `raw/`. + +**SYNOPSIS** + +- `wikitool 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 +- `wikitool 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 + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: `raw accept`: No - one filesystem move per file, then (with `--page`) one page write. `raw accept --replaces`: No - one `unlink()` + one `rename()`, plus (if `--fidelity`/`--authority` was given) one page write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 +- 1 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 + +**ON FAILURE** + +- raw accept: 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: 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 + +**NOTES** + +See `raw/CONTRACT.md` "Getting a file in: incoming/". + +#### `upload list` + +List every MCP submission currently waiting in the quarantine (`mcp-upload/`). + +**SYNOPSIS** + +- `wikitool upload list [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +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). Never fails - a submission directory with a corrupt manifest is silently skipped. Safe to retry freely. + +#### `upload show` + +Print one submission's manifest in full. + +**SYNOPSIS** + +- `wikitool upload show <id> [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unknown or malformed submission id + +**ON FAILURE** + +- Fix the id (see `upload list`) and retry + +**NOTES** + +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` + +**Upload Review Gate:** promote a submission's file from quarantine into `incoming/`. + +**SYNOPSIS** + +- `wikitool upload accept <id> [--confirm TOKEN]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - one filesystem move, one directory delete, one ledger append; the gate check runs first, before any of them +- budget: counted +- network: no +- gates: upload-review + +**EXIT STATUS** + +- 0 success +- 1 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 +- 42 needs clearance - upload-review (see AGENTS.md § Gates) + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Delete a submission's material, keeping only its ledger trail. + +**SYNOPSIS** + +- `wikitool upload reject <id> --reason "<why>"` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - one ledger append, then one recursive delete; the ledger write happens first, so an interruption still leaves the reason on record +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unknown or malformed submission id, or an empty `--reason` + +**ON FAILURE** + +- 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 + +**NOTES** + +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 | +#### `sync` + +Fetch `<remote>/<branch>` and bring the local branch up to date with it. + +**SYNOPSIS** + +- `wikitool sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure +- budget: counted +- network: no +- gates: rebase-review + +**EXIT STATUS** + +- 0 success +- 1 The automatic rebase hit a real conflict (git failed) +- 42 needs clearance - rebase-review (see AGENTS.md § Gates) + +**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 + +**NOTES** + +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` + +Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push. + +**SYNOPSIS** + +- `wikitool publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] [--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] [--path P ...]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - sequential git operations, but both gates run before staging +- budget: counted +- network: no +- gates: mass-update, publish-remote, rebase-review + +**EXIT STATUS** + +- 0 success +- 1 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 +- 42 needs clearance - mass-update, publish-remote, rebase-review (see AGENTS.md § Gates) + +**ON FAILURE** + +- 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 + +**NOTES** + +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`. `--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 `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 | +#### `work new` + +Scaffold `work/<runkey>/` for one workshop run. + +**SYNOPSIS** + +- `wikitool work new (--input <raw path> | --key <run key>) [--again] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: Yes - one directory with two files +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Delete a finished workshop. + +**SYNOPSIS** + +- `wikitool work close --run-key <name> [--yes] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - a recursive delete +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unknown run key, or `--yes` was not passed + +**ON FAILURE** + +- For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` + +**NOTES** + +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. + +**SYNOPSIS** + +- `wikitool budget status` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Recent command history is never counted against the budget. Never fails. Safe to retry freely. + +#### `budget reset` + +Clear the current session's (or every session's) iteration budget state. + +**SYNOPSIS** + +- `wikitool budget reset --yes [--all]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: Read/rewrite of one JSON file (or its deletion, with `--all`) +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 `--yes` not passed + +**ON FAILURE** + +- Get the user's approval, then re-run with `--yes` + +**NOTES** + +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 - composed with the stack-owned `types/<name>.guidance.md` where the type-spec declares `guidance:` (`--json` reports it separately as `guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract reads as one answer even though it may live in two files. A type-spec (or its guidance file) over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise | -| `instructions sync [--force]` | Publish every `instructions/<name>/SKILL.md` into `.agents/skills/` and `.claude/skills/` as **copies**, and delete published skills whose source is gone. Both targets are gitignored, so a fresh clone runs this once - see `instructions/bootstrap.md`. Re-running is also how a drifted copy is repaired: the source always wins. `--force` is required only to replace a target directory that is not a published skill at all (no `SKILL.md` in it) | -| `instructions verify` | Check the instruction layer: flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see [instructions/CONTRACT.md](../instructions/CONTRACT.md) § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see [instructions/CONTRACT.md](../instructions/CONTRACT.md)). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout | -| `instructions list [--json]` | List the flat instructions with their descriptions. This is how the layer is discovered; `search` deliberately covers `kb/` only | -| `docs verify` | Check the docs that mirror the code: every CLI command documented in this file's own § Commands table and, separately, in its § Error contracts table (both directions, checked per table, so a row dropped from one is not hidden by the same name surviving in the other, and only a name's presence in a row is checked, never the rest of that row's text), every directory under `kb/` has a `COLLECTION.md` and no directory outside it does, every collection declaring `profile:` and a `required_by_stack:` that agrees with the stack's own list, every type the stack lists (currently `source` and `project`) having a type-spec of that name whose schema requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks | -| `docs toc [--apply]` | Create, refresh or remove the generated table-of-contents region (`<!-- wikitool:toc -->` ... `<!-- /wikitool:toc -->`, placed after the title and before the first `##`) on every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy | +#### `types list` + +List every type-spec under `types/`. + +**SYNOPSIS** + +- `wikitool types list [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Name, schema path, subtype field, and description - discover what page types exist without reading `types/*.md` directly. Never fails. Safe to retry freely. + +#### `types describe` + +Print one type's full contract. + +**SYNOPSIS** + +- `wikitool types describe <name> [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unknown type name + +**ON FAILURE** + +- Fix the name and retry + +**NOTES** + +Required/optional frontmatter fields with enums, its subtype field (if any), and its authoring body - composed with the stack-owned `types/<name>.guidance.md` where the type-spec declares `guidance:` (`--json` reports it separately as `guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract reads as one answer even though it may live in two files. A type-spec (or its guidance file) over the `docs toc` threshold carries a generated table-of-contents region; it is stripped from this output rather than echoed, since the whole body is being handed over and a navigation aid into it would be noise + +#### `instructions sync` + +Publish every `instructions/<name>/SKILL.md` into the harness skill directories. + +**SYNOPSIS** + +- `wikitool instructions sync [--force]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: 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 +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed + +**ON FAILURE** + +- Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry + +**NOTES** + +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. + +**SYNOPSIS** + +- `wikitool instructions verify` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Nothing found under `instructions/` at all, a malformed instruction or `SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block + +**ON FAILURE** + +- Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins + +**NOTES** + +Flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link (`sync` copies it to a different depth than the source, so a `SKILL.md` references a target as a repo-root-relative plain path instead - see `instructions/CONTRACT.md` § "A skill's outbound reference is a plain path, not a link"), every published copy is byte-identical to its source, no instruction is left that nothing references, and nothing under `instructions/dev/` is referenced from outside it (a `<!-- dist:strip-start/end -->` block is exempt - see `instructions/CONTRACT.md`). Missing *every* copy is reported as "run sync", not as drift - that is a clean checkout + +#### `instructions list` + +List the flat instructions with their descriptions. + +**SYNOPSIS** + +- `wikitool instructions list [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +This is how the layer is discovered; `search` deliberately covers `kb/` only. Never fails - an empty `instructions/` prints "No instructions found." Safe to retry freely. + +#### `docs verify` + +Check the docs that mirror the code. + +**SYNOPSIS** + +- `wikitool docs verify` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 A command, contract, or type-form mismatch was found, a type-spec's own frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file + +**ON FAILURE** + +- Fix the documentation it names, then re-run. For a type-spec's own frontmatter: fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name + +**NOTES** + +Check the docs that mirror the code: every command has a `cli_contract` record and is listed in `cli_contract.GROUPS` (both directions, so a command dropped from one is not hidden by the other), every command's non-hidden flags appear in its record's SYNOPSIS and vice versa, `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region matches what `cli_contract.render_commands_region()` would write, no command's rendered `--help`/`-h` text cites an issue number, 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, every type the stack lists (currently `source` and `project`) having a type-spec of that name whose schema requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` naming all three tool-owned section headings if it exists at all, every stage contract present, every file under `types/` declaring `type: types/type-spec.md` validating against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the contracts, the `.gitignore` canaries clear in both directions (nothing ignored under `raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue number - the tracker exists only in the origin repo, so such a number in a distributed instance is a reference its reader can neither resolve nor recognise as unresolvable (a `<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the check reads, which is the export plan's, not the working tree's), every reference file `docs toc` covers carrying the current table-of-contents region for its own headings - missing and stale are one check, because the generator is idempotent - and every relative markdown link in one of those same reference files resolving to a file that actually exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans are masked before scanning, so a passage showing link syntax as an example is not mistaken for a real reference). The name is about documentation parity, not about the `docs/` directory - it neither reads nor requires one, the same way `kb/` predates the collection it now checks + +#### `docs toc` + +Create, refresh or remove the generated table-of-contents region. + +**SYNOPSIS** + +- `wikitool docs toc [--apply]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: `--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 +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region + +**ON FAILURE** + +- 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 + +**NOTES** + +On every reference file over 100 lines, in the scope Anthropic's skill-authoring guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat `instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each together with the `<name>.template` it ships as, where one exists. Computed from those categories rather than listed, so a file added later is in scope without a code change. A template is in scope because it is the same document one step earlier in its life: an instance adopts it by copying it back, so a region missing there is a region missing in the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the threshold with no region and left every instance adopting it failing `docs verify` at the end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it places a skill body on the loading level that is read whole when the skill triggers, and aims its own TOC advice at the bundled reference files a skill points *at*. Human docs (`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by default (prints which files would change); `--apply` writes. `docs verify` checks the result stays current the same way it checks every other generated-from-code copy + +#### `docs contract` + +Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region. + +**SYNOPSIS** + +- `wikitool docs contract [--apply]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - the whole region is rewritten in one file write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Rebuilds the region from `cli_contract.all_records()`: the index (one line per command, `GROUPS` order) followed by each `###`-group's commands as `#### <path>` man-page-shaped sections. Dry-run by default, like `docs toc`; `--apply` writes. `docs verify`'s `check_commands_region` 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) | +#### `eval sessions` + +List the sessions that have a trace under `reports/telemetry/`. + +**SYNOPSIS** + +- `wikitool eval sessions [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Most recent first. Read-only and exempt from the Iteration Budget Gate. Never fails; an empty list is a valid answer. + +#### `eval score` + +Score one traced session. + +**SYNOPSIS** + +- `wikitool eval score [--session <id>] [--json] [--markdown out.md] [--save] [--fail-on-error]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only, apart from the files `--save`/`--markdown` write +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 No trace exists for the named session + +**ON FAILURE** + +- 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 + +**NOTES** + +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` ### 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] [--take-release <path>]... [--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, and its text names the three answers with the command line already filled in, so that no reader takes any of them for the default. `--keep-local` proceeds and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the release's version over the named path, discarding the local change, and re-creates it if it was locally deleted. The two are decided per path and compose on one call: without `--keep-local`, a locally changed path that no `--take-release` names still aborts the run. A `--take-release` path that this run does not report as locally changed is refused, in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a state of the tree, and a path that silently did nothing would report a successful upgrade while keeping the change it was asked to discard. 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 taken with `--take-release` is the opposite case and the reason the flag exists: it was written, so it matches the digest the stamp records and stops being reported at all. 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), it is a pre-release (`-beta.N`) without `--pre`, or `--take-release` names a path this run does not classify as locally changed. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" | -| `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`). One of the **two** commands in `wikitool` that make a network call, and the only one whose whole job it is - `version notes` is the other, and only on a distributed instance. 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] [--offline] [--url U] [--timeout S]` | Print one version's release notes (default: this tree's `VERSION`): the `CHANGES.md` entry where there is one, and where there is not, the feed's latest release notes. The fallback exists because an instance's `CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_preserved`), so the local file can never carry the entry - not today and not after any future release, which made the command permanently unanswerable exactly where the release notes are most needed. It is reached **only with a release stamp present**, i.e. only from a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed being asked, and the one naming the release that answered, go to stderr - `release.yml` redirects stdout into the file it posts as the release body. Only the feed's *latest* release can be asked for (`update_url` is the one URL a stamp records, and composing a by-tag URL out of it would be guessing at an API shape), so a returned version other than the one asked for is named on stderr and printed anyway - the expected shape before an upgrade, where `VERSION` still names the release being left. `--offline` refuses the call and fails with the stamp's `release_url` instead. 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 are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly | -| `version regrade [INDICES...] [--impact high\|medium\|low]` | List the running candidate's bump titles with their impact grade and 1-based rendered position (no arguments - the correction path for a `--impact` judgement made at bump time), or change one or more of them in a single call: `version regrade 3 7 --impact high` grades both against a single read of today's list, not position 3 first and then position 7 against whatever that produced. Touches only the topmost entry's bump list - never `VERSION`, never any other part of `CHANGES.md`. The bare listing is read-only and exempt from the Iteration Budget Gate, like `version notes`; a call with indices writes `CHANGES.md` and is counted like `version bump`. Refuses an index outside the rendered list's range, an unknown `--impact`, indices given without `--impact`, a missing `VERSION`/`CHANGES.md`, a `VERSION`/newest-changelog-entry mismatch, or a topmost entry with no bump list at all | -| `version release [--title "<...>"] [--dry-run]` | Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry, ending the pre-release phase `version bump` started. Without `--title` the heading keeps whichever bump last set it; with it, the heading's title is replaced - the normal case for a candidate that collected several bump titles, since the entry wants a summarising heading rather than the most recent one. Leaves the entry's machine-managed bump-title list untouched, as the record of what happened. Refuses when the candidate collected two or more bumps and the entry still carries no summary paragraph (at least 200 non-whitespace characters) between the bump list and the first `### <bump title>` changeset heading; a candidate with exactly one bump is exempt, since there its own changeset already is the summary. `--dry-run` runs this check too and reports the same refusal. Commits nothing and pushes nothing (invariant 5) - the following `publish` moves `VERSION` onto `main`, which `release.yml` reacts to. Refuses when `VERSION` is already a release (no running candidate to fix), or when the changelog's newest entry does not match `VERSION` | +#### `dist export` + +Write a contentless, distributable copy of this repo's machinery. + +**SYNOPSIS** + +- `wikitool dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] [--release-url U] [--update-url U]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - nothing is written until every file is planned +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` + +**ON FAILURE** + +- Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand + +**NOTES** + +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` + +Apply a stack update `dist export` produced - the write half of `version check`. + +**SYNOPSIS** + +- `wikitool dist upgrade <source> [--dry-run] [--keep-local] [--take-release <path>]... [--prune] [--pre]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: **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 +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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, a `--take-release` path that is not classified as locally changed (the one refusal a `--dry-run` also raises), or one or more locally changed files that neither `--keep-local` nor a `--take-release` answers for + +**ON FAILURE** + +- For every refusal above: fix the named precondition and retry - none of them are transient. For a rejected `--take-release` path: correct it against the locally-changed list the refusal prints. For locally changed files, the refusal names all three answers with the re-run line filled in - `--take-release <path>` to write the release's version over it (which ends the divergence), `--keep-local` to leave them untouched (repeatable, and it reports the same files again on every subsequent run until they stop diverging), or reconcile by hand and retry. An interrupted write is not resumed automatically; compare the tree against the printed classification and finish or revert by hand + +**NOTES** + +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, and its text names the three answers with the command line already filled in, so that no reader takes any of them for the default. `--keep-local` proceeds and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the release's version over the named path, discarding the local change, and re-creates it if it was locally deleted. The two are decided per path and compose on one call: without `--keep-local`, a locally changed path that no `--take-release` names still aborts the run. A `--take-release` path that this run does not report as locally changed is refused, in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a state of the tree, and a path that silently did nothing would report a successful upgrade while keeping the change it was asked to discard. 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 taken with `--take-release` is the opposite case and the reason the flag exists: it was written, so it matches the digest the stamp records and stops being reported at all. 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), it is a pre-release (`-beta.N`) without `--pre`, or `--take-release` names a path this run does not classify as locally changed. Reports, but does not block on, a crossed compatibility boundary. Never touches git - no commit, no push (invariant 5). The closing report carries no step list of its own: everything after the swap is one order, written in `instructions/upgrade-instance.md`, which the report names and which resumes at `instructions sync`. What a human decides *before* the swap - which release, whether to take it, where the tarball comes from - is `INSTALL.md` § "Version und Updates" + +#### `version show` + +Print this instance's stack version and where it came from. + +**SYNOPSIS** + +- `wikitool version show [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 `VERSION` is missing or unparseable + +**ON FAILURE** + +- Fix `VERSION` and retry + +**NOTES** + +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` + +Ask the origin's release feed whether a newer stack exists. + +**SYNOPSIS** + +- `wikitool version check [--url U] [--timeout S] [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only, no local writes +- budget: exempt +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- 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 + +**NOTES** + +Ask the origin's release feed whether a newer stack exists, and whether the step crosses a compatibility boundary (`state: current|update|migration|ahead`). One of the **two** commands in `wikitool` that make a network call, and the only one whose whole job it is - `version notes` is the other, and only on a distributed instance. 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` + +Print one version's release notes. + +**SYNOPSIS** + +- `wikitool version notes [--version X.Y.Z] [--offline] [--url U] [--timeout S]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 An unparseable `--version`, an unreadable `VERSION` when `--version` is omitted, or a missing `CHANGES.md`. No entry for the requested version is an error only where the feed cannot answer either: in a tree with no release stamp (a dev checkout - write the entry, or `version bump`), with `--offline`, or when the feed could not be reached or returned a release with an empty `body`. Every one of those failures names the stamp's `release_url` where it has one, so a run that cannot read the notes is still told where they are + +**ON FAILURE** + +- Fix the named argument or file, then retry. A feed failure is transient - retry once, then read the release page the error names. Safe to retry + +**NOTES** + +Default: this tree's `VERSION`. The `CHANGES.md` entry where there is one, and where there is not, the feed's latest release notes. The fallback exists because an instance's `CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_preserved`), so the local file can never carry the entry - not today and not after any future release, which made the command permanently unanswerable exactly where the release notes are most needed. It is reached **only with a release stamp present**, i.e. only from a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed being asked, and the one naming the release that answered, go to stderr - `release.yml` redirects stdout into the file it posts as the release body. Only the feed's *latest* release can be asked for (`update_url` is the one URL a stamp records, and composing a by-tag URL out of it would be guessing at an API shape), so a returned version other than the one asked for is named on stderr and printed anyway - the expected shape before an upgrade, where `VERSION` still names the release being left. `--offline` refuses the call and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate + +#### `version bump` + +Raise or continue the one running candidate between two releases. + +**SYNOPSIS** + +- `wikitool version bump --major|--minor|--patch --title "<...>" [--impact high|medium|low] [--breaking "<what breaks>"] [--no-migration "<reason>"] [--migration-required] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - `VERSION` then `CHANGES.md` +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- **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 + +**NOTES** + +`VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. `--major/--minor/--patch` is **max-wins escalation** against the last release (patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` list of every `--title` collected so far, graded by `--impact`, default `medium`) and updates that same entry in place on every later bump of the same candidate - one entry per candidate, not one per bump. The list renders grouped under `**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so far is `medium`, where it stays the flat, ungrouped list the region always had - `version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. Compatibility follows the **leftmost non-zero component** of the candidate's base, which for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on update, or a downgrade that no longer works. Whether content must be migrated is a second, independent question. The bump that first escalates a candidate past the boundary requires `--breaking "<what stops working>"` and, on top of it, a migration document targeting the candidate's base or `--no-migration "<reason>"`; both are anchored just above the bump list, persist over later bumps of the same candidate without being repeated, and are refused on a bump that crosses nothing at all. The two then behave differently on a *second* crossing, because they answer different questions: a further `--breaking` **joins** the ones already recorded (one reason per crossing - rendered flat on the marker line while there is only one, as bullets under a bare marker from the second onward, and repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the single line that says whether content has to change. A candidate crossing the boundary twice is the normal shape of a long-running one, and each crossing is a separate thing an operator has to act on; whether content migrates stays one yes/no about the candidate as a whole. There is deliberately no retraction path for a single accumulated `--breaking` reason - `--migration-required` retracts the migration line, and nothing retracts a breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong after all retracts that line with `--migration-required` instead of restating `--no-migration` - refused without a migration document already targeting the new base, and without an existing `--no-migration` line to retract. Which part a change earns stays a judgment call: the command enforces that a crossing documents itself, never that the part was chosen correctly + +#### `version regrade` + +List the running candidate's bump titles with their impact grade, or change one or more of them. + +**SYNOPSIS** + +- `wikitool version regrade [INDICES...] [--impact high|medium|low]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - `CHANGES.md` only, and only when indices are given +- budget: exempt_without_args +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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` + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHANGES.md` entry. + +**SYNOPSIS** + +- `wikitool version release [--title "<...>"] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: No - `VERSION` then `CHANGES.md` +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- **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 + +**NOTES** + +Ends 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 | +#### `migrate list` + +List every migration document under `instructions/migrations/`. + +**SYNOPSIS** + +- `wikitool migrate list [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success + +**NOTES** + +Oldest target first, with its kind and obligation. Read-only and **exempt from the Iteration Budget Gate**. Never fails. + +#### `migrate status` + +Show the migrations this instance still owes, in the order they must run. + +**SYNOPSIS** + +- `wikitool migrate status [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 `.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is unreadable + +**ON FAILURE** + +- For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise + +**NOTES** + +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` + +Compare `kb/` against a git revision on the invariants a content migration must not change. + +**SYNOPSIS** + +- `wikitool migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] [--fail-on-error]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository + +**ON FAILURE** + +- 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 + +**NOTES** + +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` + +Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`. + +**SYNOPSIS** + +- `wikitool migrate done <version> [--pages N] [--dry-run]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: Yes - single file write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain + +**ON FAILURE** + +- **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 + +**NOTES** + +**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` + +Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. + +**SYNOPSIS** + +- `wikitool migrate baseline <version> [--force]` + +**PROPERTIES** + +- effect: write +- idempotent: yes +- atomic: Yes - single file write +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 Unparseable version, or a declaration already exists and `--force` was not passed + +**ON FAILURE** + +- Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted + +**NOTES** + +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` | +#### `upstream merge` + +Take a stack update into a private instance's branch, machinery only. + +**SYNOPSIS** + +- `wikitool upstream merge [--remote upstream] [--branch main] [--no-fetch]` + +**PROPERTIES** + +- effect: write +- idempotent: no +- atomic: **No** - can leave an open, uncommitted merge behind on refusal after fetching +- budget: counted +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- **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 + +**NOTES** + +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` + +Compare two revisions: did anything under a content stage change except through a stack-owned path? + +**SYNOPSIS** + +- `wikitool upstream verify --since <rev> [--until HEAD]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: no + +**EXIT STATUS** + +- 0 success +- 1 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 + +**ON FAILURE** + +- A finding is not fixed by re-running - it names the paths that leaked. Fix the revision argument and retry for the second case + +**NOTES** + +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), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault; for a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - also never a `FAIL`, only a broken config block is), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), 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 | +#### `doctor` +Check that this instance is correctly configured. + +**SYNOPSIS** + +- `wikitool doctor [--json]` + +**PROPERTIES** + +- effect: read +- idempotent: yes +- atomic: Read-only +- budget: exempt +- network: yes + +**EXIT STATUS** + +- 0 success +- 1 At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) + +**ON FAILURE** + +- Each finding names its own fix command; re-run after applying it + +**NOTES** + +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), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault; for a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - also never a `FAIL`, only a broken config block is), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), 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`). 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 +<!-- /wikitool:commands --> ## Design notes - All commands operate on the real repo, so they can be run from any working @@ -300,152 +1986,6 @@ cd tools 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 | -| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively - for `caldav` this is checked against every list in the account, not only the ones counted as projects), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above, and is `superproductivity`-only: that provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it. `caldav` never produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a valid, non-colliding name always creates the list itself | -| `task new` | No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching no tracker project, `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist), or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind | Not transient; fix the argument, create the missing tracker project or tag first, or point at an `access: "api"` instance, then retry once. **Never exit 42** - unlike `new project`, every provider offering a write path at all has a real item-creation call, so there is no human-clearance step to wait on here | -| `task list` | No `.wikitool-tasks.json` | Yes - read-only, nothing to leave half-written | Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row | -| `task close` | No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written | Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` | -| `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" | -| `review` | Either no `.wikitool-tasks.json` (or a malformed one) - not yours to fix by retrying unchanged, configure or repair it first - **or** the provider was reachable at config-parse time but a read call failed mid-run, in which case the full report (findings plus which checks ran) is printed first and exit 1 follows, never a silent partial success | Read-only | The two exit-1 causes above need different responses: a config problem needs editing `.wikitool-tasks.json`; an unreachable provider (e.g. the tracker app not running) needs starting it, then a plain retry - the command re-reads everything fresh each time, so nothing here is ever stale to re-fetch | - -### 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 `SKILL.md` carrying a relative markdown link, a published copy that drifted from its source, an instruction nothing references (or, for `manual: true`, one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running implicitly), or something under `instructions/dev/` referenced from outside it and outside a `dist:strip` block | Read-only | Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of hand-editing the published copy - the source under `instructions/` always wins | -| `instructions list` | Never fails - an empty `instructions/` prints "No instructions found." | Read-only | Safe to retry freely | -| `docs verify` | A command, contract, or type-form mismatch was found, a type-spec's own frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a reference file's table-of-contents region is missing or stale, or a reference file's relative markdown link does not resolve to an existing file | Read-only | Fix the documentation it names, then re-run. For a type-spec's own frontmatter: fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is legitimately new. For an issue reference: say what was decided instead of pointing at where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table of contents: run `docs toc --apply` - never hand-write the region. For a dead link: fix the `../` count or the target's name | -| `docs toc` | Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region | `--apply` rewrites each named file in place, one at a time and idempotently, so a re-run after an interruption converges rather than doubling a region; the dry-run form is read-only | Nothing to fix - re-run with `--apply` to write what the dry run listed. If `docs verify` still reports a stale region afterwards, the file's `##` headings changed in between; run it again | - -### Telemetry - -| 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, a `--take-release` path that is not classified as locally changed (the one refusal a `--dry-run` also raises), or one or more locally changed files that neither `--keep-local` nor a `--take-release` answers for | **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 a rejected `--take-release` path: correct it against the locally-changed list the refusal prints. For locally changed files, the refusal names all three answers with the re-run line filled in - `--take-release <path>` to write the release's version over it (which ends the divergence), `--keep-local` to leave them untouched (repeatable, and it reports the same files again on every subsequent run until they stop diverging), or reconcile by hand and retry. 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, or a missing `CHANGES.md`. No entry for the requested version is an error only where the feed cannot answer either: in a tree with no release stamp (a dev checkout - write the entry, or `version bump`), with `--offline`, or when the feed could not be reached or returned a release with an empty `body`. Every one of those failures names the stamp's `release_url` where it has one, so a run that cannot read the notes is still told where they are | Read-only | Fix the named argument or file, then retry. A feed failure is transient - retry once, then read the release page the error names. 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: diff --git a/tools/README.md b/tools/README.md index 7e15699..3b8f0d4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -4,9 +4,9 @@ Developer documentation for `wikitool` - how the CLI is built, how to change it, and how to run its tests. **This is not the command reference.** That is [CONTRACT.md](CONTRACT.md), which -`wikitool docs verify` checks against the registered commands. Copying the -command table here would create a second copy that drifts, so this file -deliberately has none - and `docs verify` now enforces that. +`wikitool docs verify` checks against the registered commands. Copying its +generated command records here would create a second copy that drifts, so this +file deliberately has none - and `docs verify` now enforces that. | Document | Audience | |---|---| @@ -33,7 +33,8 @@ install command rather than degrading silently. tools/ wikitool entry point chemenu/ - cli.py Typer app: registers every command, runs the budget gate + cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract + cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from config.py repo layout: root resolution and every path under it api.py the in-process entry point - point Chemenu at a corpus and read it errors.py ChemenuError / ValidationError / BackendError @@ -79,13 +80,21 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is. 1. Write the module under `chemenu/commands/`. A group is a `typer.Typer()` app; a single command is a plain function. 2. Register it in `cli.py` (`app.add_typer(...)` or `app.command(...)`). -3. Add a row to [CONTRACT.md](CONTRACT.md)'s command table **and** to its error - contract table. `docs verify` fails in both directions - an undocumented - command and a documented non-command are equally reported. Write the rows - without citing an issue number: `docs verify` also refuses any `.md` or - `.template` `dist export` ships that carries one, because the tracker exists - only in this repo (`instructions/dev/issue-tracking.md` § Citing an issue in - the repo). +3. Attach a `@cli_contract.record(cli_contract.CommandRecord(...))` decorator to + the command function, in its own module, and add its path to the matching + group in `cli_contract.GROUPS`. That one record is the source `wikitool + <cmd> -h`, the index (`wikitool -h`, and the top of + [CONTRACT.md](CONTRACT.md)), and CONTRACT.md's generated `#### <path>` + section all render from - see `cli_contract.py`'s own module docstring for + the record's shape. `docs verify` fails in both directions - a command with + no record and a `GROUPS` entry naming no real command are equally reported - + and also checks that every non-hidden flag appears in the record's SYNOPSIS. + Then regenerate the copy: `wikitool docs contract --apply`. Write the + record's prose without citing an issue number: `docs verify` also refuses + one in a command's rendered `--help` text (a docstring above its `\f` + marker, or an option's `help=`) and in any `.md`/`.template` `dist export` + ships, because the tracker exists only in this repo + (`instructions/dev/issue-tracking.md` § Citing an issue in the repo). 4. Add tests. Pure logic belongs in a function separate from the Typer callback (see `mass_update_gate_message`, `derive_run_key`, `run_export`), so a test does not need a CLI runner - and a Typer callback called directly from a test @@ -107,9 +116,10 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is. A new command reaches every future instance, and CI's version gate refuses a stack change that moved no version. -Every command is counted against the iteration budget unless it is listed in -`run_budget.SKIP_COMMANDS` / `SKIP_COMMAND_PATHS`. Only read-only retrieval -belongs there. +Every command is counted against the iteration budget unless its `cli_contract` +record's `budget:` property says `exempt` (or, for `version regrade`'s own +shape, `exempt_without_args`) - `run_budget.is_exempt` reads it from there, +not from a list of its own. Only read-only retrieval earns it. ## Design notes diff --git a/tools/chemenu/cli.py b/tools/chemenu/cli.py index 4257e22..141e6b6 100644 --- a/tools/chemenu/cli.py +++ b/tools/chemenu/cli.py @@ -9,6 +9,18 @@ import sys import time import typer +import typer.core as _typer_core + +# GNU-style, TTY-independent help for every command (Gitea #121 B5): one +# format for humans and agents alike, no rich frames on either `--help` or a +# usage error (`typer.core.HAS_RICH` is what both `TyperCommand.format_help` +# and `TyperGroup.format_help` check before choosing rich rendering over the +# plain-Click fallback - see their own source). Set at import time, not only +# in `main()`, so a test driving `app()` directly (CliRunner) sees the same +# behavior as a real invocation. +_typer_core.HAS_RICH = False + +from chemenu import cli_contract # noqa: E402 - after the HAS_RICH patch, which must land first try: from chemenu.commands import ( @@ -133,6 +145,11 @@ def _pacify_real_fd(stream) -> None: app = typer.Typer( help="wikitool - deterministic operations for Chemenu (see AGENTS.md).", no_args_is_help=True, + # `-h` alongside `--help` on every command (Gitea #121 B5) - Linux + # convention. Click's context settings inherit down the whole command + # tree from the root Typer, so this one declaration covers every nested + # group and command; no command declares its own `-h` (checked). + context_settings={"help_option_names": ["-h", "--help"]}, ) app.add_typer(xref.app, name="xref") @@ -167,6 +184,97 @@ app.command("sync")(git_publish.sync_command) app.command("doctor")(doctor.doctor_command) +def _contract_path(ctx) -> str: + """The dotted `cli_contract` path for `ctx`'s command (`"xref add"`, + `"new"`), built by walking up the Click context chain and collecting each + level's own `info_name` - never from `ctx.command_path`, which is + prefixed with whatever this process's argv[0] happened to be (`wikitool`, + `cli.py`, `-c` under a `python -c` snippet, ...) and would make path + resolution depend on how the CLI was invoked.""" + parts: list[str] = [] + node = ctx + while node.parent is not None: + parts.append(node.info_name) + node = node.parent + return " ".join(reversed(parts)) + + +def _render_options_text(command, ctx) -> str: + """Click's own Arguments/Options sections, plain-formatted, for splicing + into a `cli_contract` record's OPTIONS section. A fixed width (not the + real terminal's) is what keeps `wikitool <cmd> -h` byte-identical with + and without a TTY - the whole point of a GNU-style, script-friendly + format.""" + formatter = ctx.formatter_class(width=80, max_width=100) + command.format_options(ctx, formatter) + text = formatter.getvalue().strip() + # A lone "Options:" label is redundant under our own OPTIONS heading; + # kept only when Arguments are also present, where it distinguishes the + # two groups. + if text.startswith("Options:\n") and "Arguments:\n" not in text: + text = text[len("Options:\n"):] + return text + + +def _render_root_help() -> str: + """`wikitool -h`/`--help`/no-args: usage, the full index, and where the + per-command record lives - never Click's default subcommand listing, + which cannot show a command's typed properties.""" + return ( + "Usage: wikitool <command> [ARGS]...\n\n" + "wikitool - deterministic operations for Chemenu (see AGENTS.md).\n\n" + + cli_contract.render_index() + + "\n\nRun `wikitool <command> -h` for a command's full record " + "(synopsis, properties, exit status, retry policy, ...).\n" + ) + + +# The one global override B5 needs (Gitea #121): every leaf command's +# `-h`/`--help` renders from its `cli_contract` record instead of Click's +# default composition, and the bare root command renders the index. A +# command or group with no record (there is currently exactly one such +# case - an intermediate group like `xref` on its own, never asked for by +# name in normal use) falls through to Click's own formatting unchanged. +# +# Patched on `typer._click.core.Command` - typer 0.27 vendors its own +# internal fork of click (`typer._click`), so `TyperCommand`/`TyperGroup` +# (see `typer.core`) resolve `format_help` there, not on the top-level +# `click` package's `Command` class. Both already fall through to this same +# base implementation via `super().format_help(...)` once `HAS_RICH` is +# False (see their own source) - one patch point covers every command and +# group uniformly. +# +# This reaches into a private, underscore-prefixed module with no version +# pin (`requirements.txt` allows any `typer>=0.12`), so a future typer that +# restructures or drops `_click` must not crash every `wikitool` invocation +# at import time. If the shape this needs is not there, skip the patch: help +# falls back to plain, unframed Click output (HAS_RICH is already False) +# without the contract-based rendering - degraded, not broken. +try: + import typer._click.core as _typer_click_core # noqa: E402 + + _original_format_help = _typer_click_core.Command.format_help + + def _contract_format_help(self, ctx, formatter) -> None: + if ctx.parent is None: + formatter.write(_render_root_help()) + return + record = cli_contract.get(_contract_path(ctx)) + if record is None: + _original_format_help(self, ctx, formatter) + return + formatter.write( + cli_contract.render_text(record, options_text=_render_options_text(self, ctx)) + ) + + _typer_click_core.Command.format_help = _contract_format_help +except (ImportError, AttributeError): + pass + + +_typer_click_core.Command.format_help = _contract_format_help + + def main() -> None: # Iteration Budget Gate / Loop-Breaker (see the tooling contract's # "Iteration and Cost Limits"): recorded and enforced here, once per diff --git a/tools/chemenu/cli_contract.py b/tools/chemenu/cli_contract.py new file mode 100644 index 0000000..ddeb9e0 --- /dev/null +++ b/tools/chemenu/cli_contract.py @@ -0,0 +1,435 @@ +"""One data record per `wikitool` command - the single source three views are +rendered from: `wikitool <cmd> -h` (the full record, plain text), the index +line (`wikitool -h` and the top of `tools/CONTRACT.md`), and the generated +`<!-- wikitool:commands -->` region of `tools/CONTRACT.md` itself. + +A record is attached to its command function, in that function's own module, +via the `@record(...)` decorator - never centralised, so the contract sits +next to the code it describes. `GROUPS` is the one thing that stays central: +the `###`-level grouping and rendering order, unchanged from what +`tools/CONTRACT.md` carried before this module existed. + +Phase 1 (Gitea #121) fills every record mechanically and word-for-word from +the two tables `tools/CONTRACT.md` used to carry. Phase 2 (Gitea #142) is what +edits the prose, adds EXAMPLES/NEVER/SEE ALSO, and pulls "why" out of a +record's NOTES - not this module's job. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Optional, TypeVar + +_F = TypeVar("_F", bound=Callable) + + +class Effect(str, Enum): + READ = "read" + WRITE = "write" + + +class Idempotent(str, Enum): + YES = "yes" + NO = "no" + + +class Budget(str, Enum): + """What the Iteration Budget Gate does with a call to this command. + + `EXEMPT_WITHOUT_ARGS` is `version regrade`'s own shape: the bare listing + only reads, but any index argument writes `CHANGES.md` and is counted like + `version bump` - one command, two answers, depending on whether it was + called with arguments at all (see `run_budget.is_exempt`). + """ + COUNTED = "counted" + EXEMPT = "exempt" + EXEMPT_WITHOUT_ARGS = "exempt_without_args" + + +class Network(str, Enum): + YES = "yes" + NO = "no" + + +@dataclass(frozen=True) +class Variant: + """One usage form of a command that takes more than one shape - `new`'s + `new <type-name>` vs `new entity` vs `new project`, `raw accept`'s plain + form vs `--replaces`. `notes` is empty unless the variant needs a sentence + of its own beyond what NOTES already says for the command as a whole.""" + usage: str + notes: str = "" + + +@dataclass(frozen=True) +class Failure: + """One exit-1 story - most commands have exactly one, `new` and + `raw accept` have two (one per `Variant`), because their failure causes + differ by usage form. `label` is empty for a command with only one; when + a command carries more than one `Failure`, `label` names which variant it + describes (`"new <type>"`, `"new project"`, ...), and EXIT STATUS/ON + FAILURE render every label.""" + label: str + exit_1: str + retry: str + + +@dataclass(frozen=True) +class Properties: + effect: Effect + idempotent: Idempotent + atomic: str + budget: Budget + network: Network = Network.NO + gates: tuple[str, ...] = () + + +@dataclass(frozen=True) +class CommandRecord: + """The man-page-shaped record for one command path (e.g. `"publish"`, + `"xref add"`). Sections not populated in phase 1 (`examples`, `never`, + `see_also`) render as absent, not empty - see `render_text`.""" + path: str + summary: str + synopsis: tuple[Variant, ...] + properties: Properties + notes: str + failures: tuple[Failure, ...] + examples: tuple[str, ...] = () + never: tuple[str, ...] = () + see_also: tuple[str, ...] = () + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +_REGISTRY: dict[str, CommandRecord] = {} + + +def record(rec: CommandRecord) -> Callable[[_F], _F]: + """Attach `rec` to a command function and register it under `rec.path`. + + Registering twice under the same path is refused rather than silently + overwritten - two decorators claiming the same command path is a copy- + paste mistake, not a legitimate case (a command with more than one usage + form gets more than one `Variant`/`Failure` *inside* one record, not two + records).""" + if rec.path in _REGISTRY: + raise ValueError(f"cli_contract: duplicate record for {rec.path!r}") + _REGISTRY[rec.path] = rec + + def decorator(fn: _F) -> _F: + fn.__wikitool_contract__ = rec # type: ignore[attr-defined] + return fn + + return decorator + + +def get(path: str) -> Optional[CommandRecord]: + return _REGISTRY.get(path) + + +def all_records() -> dict[str, CommandRecord]: + """A copy of the registry, keyed by command path.""" + return dict(_REGISTRY) + + +def reset_registry_for_tests() -> None: + """Test-only escape hatch: clear the registry so a fixture module can + register its own records without colliding with the real CLI's. Nothing + in the shipped CLI calls this.""" + _REGISTRY.clear() + + +# --------------------------------------------------------------------------- +# Groups - the `###`-level sections `tools/CONTRACT.md` renders, in order. +# --------------------------------------------------------------------------- + +GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("Pages", ( + "new", "task new", "task list", "task close", + "touch", "rename", "rm", "move", + )), + ("Links and citations", ( + "xref add", "xref remove", "xref link-source", + "links show", "cite id", "cite add", "cite sync", + )), + ("Catalog and log", ( + "index rebuild", "log append", "log status", + )), + ("Finding and checking", ( + "lint", "search", "review", + )), + ("Provenance", ( + "sources coverage", "sources trace", "sources rebuild-index", + )), + ("Raw material and uploads", ( + "raw accept", "upload list", "upload show", "upload accept", "upload reject", + )), + ("Git", ( + "sync", "publish", + )), + ("Workshop runs and session budget", ( + "work new", "work close", "budget status", "budget reset", + )), + ("Types, instructions and docs", ( + "types list", "types describe", + "instructions sync", "instructions verify", "instructions list", + "docs verify", "docs toc", "docs contract", + )), + ("Telemetry", ( + "eval sessions", "eval score", + )), + ("Distribution and versioning", ( + "dist export", "dist upgrade", + "version show", "version check", "version notes", + "version bump", "version regrade", "version release", + )), + ("Content migrations", ( + "migrate list", "migrate status", "migrate verify", "migrate done", "migrate baseline", + )), + ("Private instances", ( + "upstream merge", "upstream verify", + )), + ("Instance health", ( + "doctor", + )), +) + + +GroupsType = tuple[tuple[str, tuple[str, ...]], ...] + + +def grouped_paths(groups: GroupsType = GROUPS) -> tuple[str, ...]: + """Every command path named by `groups`, in rendering order.""" + return tuple(path for _, paths in groups for path in paths) + + +def group_of(path: str, groups: GroupsType = GROUPS) -> Optional[str]: + for title, paths in groups: + if path in paths: + return title + return None + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + +_SECTION_ORDER = ( + "NAME", "SYNOPSIS", "PROPERTIES", "EXAMPLES", "OPTIONS", + "EXIT STATUS", "ON FAILURE", "NEVER", "NOTES", "SEE ALSO", +) + + +def _exit_codes(rec: CommandRecord) -> list[int]: + codes = [0] + if rec.failures: + codes.append(1) + if rec.properties.gates: + codes.append(42) + return codes + + +def _idempotent_text(idempotent: Idempotent) -> str: + return "idempotent" if idempotent == Idempotent.YES else "non-idempotent" + + +def _budget_text(budget: Budget) -> str: + return { + Budget.COUNTED: "budget:counted", + Budget.EXEMPT: "budget:exempt", + Budget.EXEMPT_WITHOUT_ARGS: "budget:exempt_without_args", + }[budget] + + +def render_properties_lines(props: Properties) -> list[str]: + lines = [ + f"effect {props.effect.value}", + f"idempotent {props.idempotent.value}", + f"atomic {props.atomic}", + f"budget {props.budget.value}", + f"network {props.network.value}", + ] + if props.gates: + lines.append(f"gates {', '.join(props.gates)}") + return lines + + +def render_exit_status_lines(rec: CommandRecord) -> list[str]: + lines = ["0 success"] + for failure in rec.failures: + prefix = f"{failure.label}: " if failure.label else "" + lines.append(f"1 {prefix}{failure.exit_1}") + if rec.properties.gates: + gate_list = ", ".join(rec.properties.gates) + lines.append(f"42 needs clearance - {gate_list} (see AGENTS.md § Gates)") + return lines + + +def render_on_failure_lines(rec: CommandRecord) -> list[str]: + lines = [] + for failure in rec.failures: + prefix = f"{failure.label}: " if failure.label else "" + lines.append(f"{prefix}{failure.retry}") + return lines + + +def render_text(rec: CommandRecord, options_text: str = "") -> str: + """The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for + `wikitool <path> -h`. `options_text` is Click's own rendered Options + block (already flag-formatted) for this command, spliced in between + EXAMPLES and EXIT STATUS - see `chemenu.cli` for how it is obtained. + Empty sections (EXAMPLES/NEVER/SEE ALSO in phase 1, OPTIONS for a command + with none) are omitted entirely rather than printed empty.""" + blocks: list[str] = [] + + blocks.append(f"NAME\n wikitool {rec.path} - {rec.summary}") + + synopsis_lines = "\n".join( + f" wikitool {variant.usage}" + (f"\n {variant.notes}" if variant.notes else "") + for variant in rec.synopsis + ) + blocks.append(f"SYNOPSIS\n{synopsis_lines}") + + props_lines = "\n".join(f" {line}" for line in render_properties_lines(rec.properties)) + blocks.append(f"PROPERTIES\n{props_lines}") + + if rec.examples: + example_lines = "\n".join(f" {example}" for example in rec.examples) + blocks.append(f"EXAMPLES\n{example_lines}") + + if options_text.strip(): + blocks.append(f"OPTIONS\n{options_text.rstrip()}") + + exit_lines = "\n".join(f" {line}" for line in render_exit_status_lines(rec)) + blocks.append(f"EXIT STATUS\n{exit_lines}") + + if rec.failures: + failure_lines = "\n".join(f" {line}" for line in render_on_failure_lines(rec)) + blocks.append(f"ON FAILURE\n{failure_lines}") + + if rec.never: + never_lines = "\n".join(f" - {n}" for n in rec.never) + blocks.append(f"NEVER\n{never_lines}") + + notes_lines = "\n".join(f" {line}" for line in rec.notes.splitlines()) or f" {rec.notes}" + blocks.append(f"NOTES\n{notes_lines}") + + if rec.see_also: + see_also_lines = "\n".join(f" - {s}" for s in rec.see_also) + blocks.append(f"SEE ALSO\n{see_also_lines}") + + return "\n\n".join(blocks) + "\n" + + +def render_index_line(rec: CommandRecord, name_width: int = 15) -> str: + """One `wikitool -h`/index line: name, typed properties, one-sentence + purpose - fixed-width columns so a `grep` and a human's eyes both work. + """ + exit_text = "exit:" + ",".join(str(code) for code in _exit_codes(rec)) + columns = [ + rec.path.ljust(name_width), + rec.properties.effect.value.ljust(6), + _idempotent_text(rec.properties.idempotent).ljust(15), + _budget_text(rec.properties.budget).ljust(28), + exit_text.ljust(12), + ] + return "".join(columns) + rec.summary + + +def render_index( + records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS +) -> str: + """The full index, one line per command, in `groups` order.""" + records = records if records is not None else all_records() + width = max((len(path) for path in records), default=15) + 1 + lines = [] + for path in grouped_paths(groups): + rec = records.get(path) + if rec is None: + continue + lines.append(render_index_line(rec, name_width=width)) + return "\n".join(lines) + + +def render_markdown_section(rec: CommandRecord) -> str: + """The `#### <path>` markdown form of one record, for the generated + region of `tools/CONTRACT.md`. Same section order and content as + `render_text`, minus OPTIONS (Click's own `--help` already carries the + flags; the generated markdown does not re-derive them).""" + lines = [f"#### `{rec.path}`", "", rec.summary, ""] + + lines.append("**SYNOPSIS**") + lines.append("") + for variant in rec.synopsis: + note = f" - {variant.notes}" if variant.notes else "" + lines.append(f"- `wikitool {variant.usage}`{note}") + lines.append("") + + lines.append("**PROPERTIES**") + lines.append("") + for line in render_properties_lines(rec.properties): + key, _, value = line.partition(" ") + lines.append(f"- {key}: {value.strip()}") + lines.append("") + + if rec.examples: + lines.append("**EXAMPLES**") + lines.append("") + for example in rec.examples: + lines.append(f"- `{example}`") + lines.append("") + + lines.append("**EXIT STATUS**") + lines.append("") + for line in render_exit_status_lines(rec): + lines.append(f"- {line}") + lines.append("") + + if rec.failures: + lines.append("**ON FAILURE**") + lines.append("") + for line in render_on_failure_lines(rec): + lines.append(f"- {line}") + lines.append("") + + if rec.never: + lines.append("**NEVER**") + lines.append("") + for n in rec.never: + lines.append(f"- {n}") + lines.append("") + + lines.append("**NOTES**") + lines.append("") + lines.append(rec.notes) + lines.append("") + + if rec.see_also: + lines.append("**SEE ALSO**") + lines.append("") + for s in rec.see_also: + lines.append(f"- {s}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def render_commands_region( + records: Optional[dict[str, CommandRecord]] = None, groups: GroupsType = GROUPS +) -> str: + """The full `<!-- wikitool:commands -->` region body: the index, then + each `###` group with its commands' `#### <path>` records.""" + records = records if records is not None else all_records() + parts = ["```", render_index(records, groups), "```", ""] + for title, paths in groups: + present = [p for p in paths if p in records] + if not present: + continue + parts.append(f"### {title}") + parts.append("") + for path in present: + parts.append(render_markdown_section(records[path])) + return "\n".join(parts).rstrip() + "\n" diff --git a/tools/chemenu/commands/cite_cmd.py b/tools/chemenu/commands/cite_cmd.py index a898ec4..5e96aa7 100644 --- a/tools/chemenu/commands/cite_cmd.py +++ b/tools/chemenu/commands/cite_cmd.py @@ -20,7 +20,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success from chemenu.frontmatter_io import write_page from chemenu.page import Page @@ -43,6 +43,20 @@ def _find_page(pages: dict[str, Page], title: str) -> Page: @app.command("id") +@cli_contract.record(cli_contract.CommandRecord( + path="cite id", + summary="Print the deterministic footnote id `cite add` would use for this (title, file) pair.", + synopsis=(cli_contract.Variant(usage='cite id --title "Source - X" [--file <qualifier>]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="Read-only preview - does not check the id is actually free on any given page. Never " + "fails. Safe to retry freely. Exempt from the Iteration Budget Gate", + failures=(), +)) def cite_id_command( title: str = typer.Option(..., "--title", help="Source page title, e.g. 'Source - Docker Cheatsheet'"), file: Optional[str] = typer.Option(None, "--file", help="Qualifier for a multi-file source, e.g. 'storage-model.md'"), @@ -86,6 +100,30 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) -> @app.command("add") +@cli_contract.record(cli_contract.CommandRecord( + path="cite add", + summary="Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region.", + synopsis=(cli_contract.Variant( + usage='cite add --page "<Title>" --source "Source - X" [--file <qualifier>] [--dry-run]', + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - single file write", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Page or source not found", + retry="Safe to retry; upserting the same (page, source, file) pair twice reuses the " + "existing id and changes nothing the second time", + ),), +)) def cite_add( page_title: str = typer.Option(..., "--page", help="Exact title of the page to add a citation on"), source: str = typer.Option(..., "--source", help="Exact title of the source page being cited, e.g. 'Source - X'"), @@ -157,6 +195,30 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]: @app.command("sync") +@cli_contract.record(cli_contract.CommandRecord( + path="cite sync", + summary="Reconcile each page's footnotes region against its actual `[^id]` references.", + synopsis=(cli_contract.Variant( + usage='cite sync [--page "<Title>" | --all] [--dry-run]', + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - one write per page, each idempotent", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Neither or both of `--page`/`--all` given, or page not found", + retry="Safe to retry freely. An undefined-reference report is not a failure - fix the " + "reference (or run `cite add`) and re-run", + ),), +)) def cite_sync( page_title: Optional[str] = typer.Option(None, "--page", help="Sync just this page"), all_pages: bool = typer.Option(False, "--all", help="Sync every page under kb/"), diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index e2d0d8d..8e8de0c 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -47,6 +47,7 @@ import typer from chemenu import ( blocks, + cli_contract, config, conventions, kb_collections, @@ -569,6 +570,50 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None: dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) +@cli_contract.record(cli_contract.CommandRecord( + path="dist export", + summary="Write a contentless, distributable copy of this repo's machinery.", + synopsis=(cli_contract.Variant( + usage="dist export <target> [--dry-run] [--source-repo U] [--source-commit SHA] " + "[--release-url U] [--update-url U]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - nothing is written until every file is planned", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Target exists and is not empty, is not a directory, or the tree has no " + "readable `VERSION`", + retry="Point `<target>` at an empty (or new) directory and retry. Never merge into a " + "non-empty one by hand", + ),), +)) @app.command("export") def export_command( target: Path = typer.Argument( @@ -906,6 +951,87 @@ def _report_plan( console.print("Report only - `dist upgrade` never runs a migration. See `wikitool migrate status`.") +@cli_contract.record(cli_contract.CommandRecord( + path="dist upgrade", + summary="Apply a stack update `dist export` produced - the write half of `version check`.", + synopsis=(cli_contract.Variant( + usage="dist upgrade <source> [--dry-run] [--keep-local] [--take-release <path>]... " + "[--prune] [--pre]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="**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", + budget=cli_contract.Budget.COUNTED, + ), + notes="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, and its text names the three answers with the command line already " + "filled in, so that no reader takes any of them for the default. `--keep-local` proceeds " + "and leaves every one of them untouched; `--take-release <path>` (repeatable) writes the " + "release's version over the named path, discarding the local change, and re-creates it if " + "it was locally deleted. The two are decided per path and compose on one call: without " + "`--keep-local`, a locally changed path that no `--take-release` names still aborts the " + "run. A `--take-release` path that this run does not report as locally changed is refused, " + "in a `--dry-run` as well as a writing run - it is a mistake in the argument rather than a " + "state of the tree, and a path that silently did nothing would report a successful upgrade " + "while keeping the change it was asked to discard. 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 taken with `--take-release` is the opposite case and the " + "reason the flag exists: it was written, so it matches the digest the stamp records and " + "stops being reported at all. 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), it is a pre-release (`-beta.N`) without " + "`--pre`, or `--take-release` names a path this run does not classify as locally changed. " + "Reports, but does not block on, a crossed compatibility boundary. Never touches git - no " + "commit, no push (invariant 5). The closing report carries no step list of its own: " + "everything after the swap is one order, written in `instructions/upgrade-instance.md`, " + "which the report names and which resumes at `instructions sync`. What a human decides " + "*before* the swap - which release, whether to take it, where the tarball comes from - is " + "`INSTALL.md` § \"Version und Updates\"", + failures=(cli_contract.Failure( + label="", + exit_1="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, a " + "`--take-release` path that is not classified as locally changed (the one refusal a " + "`--dry-run` also raises), or one or more locally changed files that neither " + "`--keep-local` nor a `--take-release` answers for", + retry="For every refusal above: fix the named precondition and retry - none of them are " + "transient. For a rejected `--take-release` path: correct it against the " + "locally-changed list the refusal prints. For locally changed files, the refusal names " + "all three answers with the re-run line filled in - `--take-release <path>` to write " + "the release's version over it (which ends the divergence), `--keep-local` to leave " + "them untouched (repeatable, and it reports the same files again on every subsequent " + "run until they stop diverging), or reconcile by hand and retry. An interrupted write " + "is not resumed automatically; compare the tree against the printed classification and " + "finish or revert by hand", + ),), +)) @app.command("upgrade") def upgrade_command( source: Path = typer.Argument( @@ -933,8 +1059,9 @@ def upgrade_command( False, "--pre", help="Allow a pre-release (-beta.N) source tree - release.yml never publishes one", ), ): - """Apply a stack update `dist export` produced - the write half of - `version check`. Never downloads anything: `source` is an already-fetched + """Apply a stack update `dist export` produced - the write half of `version check`. + \f + Never downloads anything: `source` is an already-fetched export directory or `.tar.gz` archive. Writes exactly the new release stamp's `files` block, minus what an export re-seeds every time (`kb/log.md`, `raw/.gitkeep`) or seeds once and the instance owns from diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index fbc7ff4..5759d94 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -5,10 +5,11 @@ The wiki's own rule is that a derived copy of recomputable truth must be checked or absent. Three such copies survive on purpose because they earn their keep as reading material: - 1. `tools/CONTRACT.md`'s two command tables - § Commands and § Error - contracts - each re-derivable from the Typer app and checked - independently, in both directions, so a row surviving in one table - cannot hide its own deletion from the other + 1. `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region - one + `cli_contract.CommandRecord` per registered command, re-derivable from + the Typer app and checked in both directions, so a command dropped from + `cli_contract.GROUPS` cannot hide behind a record that still exists, or + the reverse 2. the collection and stage contracts (their existence and placement, not their content) 3. the absence of pre-type-system `type: entity` frontmatter in the @@ -58,6 +59,8 @@ Content quality of the contracts themselves stays with the LLM. """ from __future__ import annotations +import functools +import inspect import re import subprocess from pathlib import Path @@ -65,7 +68,7 @@ from typing import Optional import typer -from chemenu import config, conventions, kb_collections, markdown_code, toc, version as version_mod +from chemenu import blocks, cli_contract, config, conventions, kb_collections, markdown_code, toc, version as version_mod from chemenu.commands import dist_cmd from chemenu.commands._util import fail, rel_path, success @@ -213,36 +216,6 @@ LEGACY_TYPE_RE = re.compile(r"^type:\s*(entity|concept|source|comparison)\s*$", # First backticked cell of a markdown table row, e.g. "| `xref add --a ...` | ... |" TABLE_CELL_RE = re.compile(r"^\|\s*`([^`]+)`", re.MULTILINE) -# tools/CONTRACT.md carries two tables whose first cell is a backticked -# command path - § Commands and § Error contracts - and `check_cli_readme` -# must not treat them as one pot (Gitea #91): a row deleted from one used to -# go unnoticed as long as the same name survived in the other, and the -# second table was not enforced against anything at all. -COMMANDS_HEADING = "## Commands" -ERROR_CONTRACTS_HEADING = "## Error contracts" - - -def section_text(full_text: str, heading: str) -> str: - """The text of one `##`-level section: from just after `heading`'s own - line up to the next `#`- or `##`-level heading, or the end of the - document. - - Raises `ValueError` if `heading` is not found verbatim, rather than - falling back to scanning the whole document - a renamed heading has to - surface as a failure, because silently widening the scope back to - "everything" is exactly the bug this function exists to prevent from - reappearing under a different name. - """ - pattern = re.compile( - r"^" + re.escape(heading) + r"[ \t]*\n(.*?)(?=^#{1,2}[ \t]|\Z)", - re.MULTILINE | re.DOTALL, - ) - match = pattern.search(full_text) - if match is None: - raise ValueError(f"no {heading!r} heading found") - return match.group(1) - - def registered_commands() -> set[str]: """Every command path the CLI exposes, e.g. {'new', 'xref add', ...}. @@ -276,66 +249,226 @@ def documented_commands(readme_text: str) -> list[str]: return [match.group(1).strip() for match in TABLE_CELL_RE.finditer(readme_text)] -def check_cli_readme() -> list[str]: - """Every registered command must appear in tools/CONTRACT.md's own - § Commands table, and separately in its § Error contracts table - each - direction checked per table, independently of the other. +def check_command_contracts() -> list[str]: + """Every registered command has exactly one `cli_contract` record, and + `cli_contract.GROUPS` lists exactly the registered commands - no more, no + less, and no path twice. - The two tables used to be read as one pot: `TABLE_CELL_RE` matched a - backticked first cell anywhere in the file, so a row deleted from - § Commands went unnoticed as long as the same name still had a row in - § Error contracts, and § Error contracts was never itself compared - against the registered commands (Gitea #91). `section_text` scopes each - table to the text between its own `##` heading and the next one, and - raises rather than silently scanning the whole file if a heading has been - renamed or removed - a renamed heading must be reported, not read as - "table now empty" or "table now everything". - - Within a section, only the first backticked cell of each row is read - - a changed flag or a rewritten description in an existing row is invisible - to this check, on purpose: it verifies presence, never prose. - - The reverse check matches a documented cell against the full registered - command path (e.g. `xref add`, `migrate verify`), not just its first - token - checking only the top-level word would let a typo'd or invented - subcommand (`xref frobnicate`) sit undetected next to a real command group - (`xref`) forever. + Gitea #121 phase 1 replaced the two hand-read tables (§ Commands, + § Error contracts) with one data record per command, attached to its + function by `@cli_contract.record(...)`. A record with no matching + command, a command with no record, or a `GROUPS` entry appearing twice + are the three ways that pairing can drift; each is its own issue so a + session sees exactly which command needs attention. """ - if not CLI_README.exists(): - return [f"{CLI_README.relative_to(config.ROOT)} is missing"] - - text = CLI_README.read_text(encoding="utf-8") registered = sorted(registered_commands()) issues: list[str] = [] - for heading, label in ( - (COMMANDS_HEADING, "§ Commands"), - (ERROR_CONTRACTS_HEADING, "§ Error contracts"), - ): - try: - section = section_text(text, heading) - except ValueError as exc: + seen: set[str] = set() + for path in cli_contract.grouped_paths(cli_contract.GROUPS): + if path in seen: + issues.append(f"cli_contract.GROUPS lists `{path}` more than once") + seen.add(path) + + grouped = set(cli_contract.grouped_paths(cli_contract.GROUPS)) + for command_path in registered: + if cli_contract.get(command_path) is None: issues.append( - f"tools/CONTRACT.md: {exc} - its {label} table cannot be checked against the CLI" + f"command `{command_path}` has no cli_contract record - decorate its function " + "with @cli_contract.record(...)" + ) + if command_path not in grouped: + issues.append( + f"command `{command_path}` is not listed in cli_contract.GROUPS - it has no " + "place to render in tools/CONTRACT.md's generated region" ) - continue - cells = documented_commands(section) + for path in sorted(grouped): + if path not in registered: + issues.append( + f"cli_contract.GROUPS lists `{path}`, which is not a registered wikitool command" + ) - for command_path in registered: - if not any(cell == command_path or cell.startswith(command_path + " ") for cell in cells): + return issues + + +COMMANDS_REGION = "commands" + + +def check_commands_region() -> list[str]: + """`tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region matches + `cli_contract.render_commands_region()` byte for byte - the same + generated-region drift check `check_toc_regions()` runs for the table of + contents, applied to the command reference itself.""" + if not CLI_README.exists(): + return [f"{rel_path(CLI_README)} is missing"] + + text = CLI_README.read_text(encoding="utf-8") + existing = blocks.find(text, COMMANDS_REGION) + expected = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n") + + if existing is None: + return [ + f"{rel_path(CLI_README)} is missing its <!-- wikitool:commands --> region - run " + "`wikitool docs contract --apply`" + ] + if existing.strip("\n") != expected: + return [ + f"{rel_path(CLI_README)}'s <!-- wikitool:commands --> region is stale - run " + "`wikitool docs contract --apply`" + ] + return [] + + +# A `--flag` or `-f` token in a SYNOPSIS usage string. Excludes anything that +# is not immediately preceded/followed by a word character or hyphen, so +# `--set field=value` matches only `--set`, never `field` or `value`. +FLAG_TOKEN_RE = re.compile(r"(?<![\w-])(--[a-zA-Z][a-zA-Z0-9-]*|-[a-zA-Z])(?![\w-])") + + +@functools.lru_cache(maxsize=1) +def _leaf_click_commands() -> list[tuple[str, object]]: + """(path, click.Command) for every leaf command the built CLI exposes, + dotted the same way `registered_commands()` names a path (`"xref add"`). + + Built from the real Click command tree (`typer.main.get_command`) rather + than from Typer's own `registered_commands`/`registered_groups`, because + only the Click tree carries each option's actual flag strings + (`param.opts`/`param.secondary_opts`) - what `check_synopsis_flags` and + `check_no_issue_references_in_help` both need to read. Cached: `verify()` + calls both checks in the same run, and the app it walks is immutable + within a process - a test replacing this name with its own function + bypasses the cache entirely rather than needing to clear it. + """ + import typer as _typer + + from chemenu import cli + + root = _typer.main.get_command(cli.app) + found: list[tuple[str, object]] = [] + + def walk(command: object, prefix: list[str]) -> None: + sub = getattr(command, "commands", None) + if sub: + for name, child in sub.items(): + walk(child, prefix + [name]) + else: + found.append((" ".join(prefix), command)) + + walk(root, []) + return found + + +def _group_click_commands() -> list[tuple[str, object]]: + """(path, click.Group) for every intermediate group the built CLI + exposes (`"xref"`, `"task"`, ...), the complement of + `_leaf_click_commands()`. A group carries no `cli_contract` record of its + own - `wikitool xref -h` falls through to Click's default listing - but + its own `help=` string is still rendered there, so + `check_no_issue_references_in_help` needs this set too, not just the + leaves.""" + import typer as _typer + + from chemenu import cli + + root = _typer.main.get_command(cli.app) + found: list[tuple[str, object]] = [] + + def walk(command: object, prefix: list[str]) -> None: + sub = getattr(command, "commands", None) + if not sub: + return + if prefix: + found.append((" ".join(prefix), command)) + for name, child in sub.items(): + walk(child, prefix + [name]) + + walk(root, []) + return found + + +def check_synopsis_flags() -> list[str]: + """Every non-hidden flag a command's Click definition carries appears in + its `cli_contract` record's SYNOPSIS, and every flag a SYNOPSIS names is + a real flag of that command. + + A boolean flag pair (`--push`/`--no-push`) is satisfied by documenting + either spelling - the SYNOPSIS convention this repo already used before + this check existed (`publish`'s `[--no-push]`). `hidden=True` does not + count (`publish --yes`), matching the design this check implements + (Gitea #121 B3). + """ + issues: list[str] = [] + for path, command in _leaf_click_commands(): + rec = cli_contract.get(path) + if rec is None: + continue # reported by check_command_contracts + + synopsis_text = " ".join(variant.usage for variant in rec.synopsis) + documented = {m.group(0) for m in FLAG_TOKEN_RE.finditer(synopsis_text)} + + all_flags: set[str] = set() + for param in getattr(command, "params", []): + opts = list(getattr(param, "opts", []) or []) + secondary = list(getattr(param, "secondary_opts", []) or []) + group = [o for o in (opts + secondary) if o.startswith("-") and o not in ("--help", "-h")] + all_flags.update(group) + if not group or getattr(param, "hidden", False): + continue + if not any(o in documented for o in group): issues.append( - f"command `{command_path}` is not documented in tools/CONTRACT.md's {label} table" + f"`{path}`: flag `{group[0]}` is not documented in its cli_contract " + "SYNOPSIS" ) - for cell in cells: - if not any(cell == cp or cell.startswith(cp + " ") for cp in registered): - first_token = cell.split(" ", 1)[0] + for flag in sorted(documented - all_flags): + issues.append( + f"`{path}`: its cli_contract SYNOPSIS mentions `{flag}`, which is not a real " + "flag of this command" + ) + return issues + + +def check_no_issue_references_in_help() -> list[str]: + """No command's rendered `--help`/`-h` text - its docstring above `\\f`, + or any option's `help=` text - cites an issue number. + + Mirrors `check_no_issue_references()`'s reasoning for shipped `.md` + files, one level down: `tools/` ships as runtime machinery to every + distributed instance (`instructions/dev/` is excluded, not `tools/`), so + an issue number baked into a command's own `--help` output reaches a + reader with no tracker to resolve it against. `\\f` is what Click itself + uses to separate the rendered part of a docstring from maintenance text + below it (`click.Command.format_help_text`); this check applies the same + split before scanning; the `\\f` decides for our SYNOPSIS/PROPERTIES/... + renderer too. + """ + issues: list[str] = [] + for path, command in _leaf_click_commands(): + help_text = getattr(command, "help", None) or "" + rendered = inspect.cleandoc(help_text).partition("\f")[0] + for match in ISSUE_REFERENCE_RE.finditer(rendered): + issues.append( + f"`{path}` --help text cites `{match.group()}` above its `\\f` marker - the " + "tracker exists only in the origin repo" + ) + for param in getattr(command, "params", []): + help_str = getattr(param, "help", None) or "" + for match in ISSUE_REFERENCE_RE.finditer(help_str): + flag = (list(getattr(param, "opts", []) or []) or [param.name])[0] issues.append( - f"tools/CONTRACT.md's {label} table documents `{cell}`, but `{first_token}` " - "is not a wikitool command" + f"`{path}` option `{flag}` help text cites `{match.group()}` - the tracker " + "exists only in the origin repo" ) + for path, group in _group_click_commands(): + help_text = getattr(group, "help", None) or "" + rendered = inspect.cleandoc(help_text).partition("\f")[0] + for match in ISSUE_REFERENCE_RE.finditer(rendered): + issues.append( + f"`{path}` group's --help text cites `{match.group()}` above its `\\f` marker " + "- the tracker exists only in the origin repo" + ) return issues @@ -901,10 +1034,65 @@ def check_breaking_change_for_boundary() -> list[str]: @app.command("verify") +@cli_contract.record(cli_contract.CommandRecord( + path="docs verify", + summary="Check the docs that mirror the code.", + synopsis=(cli_contract.Variant(usage="docs verify"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Check the docs that mirror the code: every command has a `cli_contract` record and " + "is listed in `cli_contract.GROUPS` (both directions, so a command dropped from one is not " + "hidden by the other), every command's non-hidden flags appear in its record's SYNOPSIS and " + "vice versa, `tools/CONTRACT.md`'s generated `<!-- wikitool:commands -->` region matches " + "what `cli_contract.render_commands_region()` would write, no command's rendered " + "`--help`/`-h` text cites an issue number, 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, every type the stack " + "lists (currently `source` and `project`) having a type-spec of that name whose schema " + "requires the field the stack list also names (`raw_files:`/`state:`), `kb/CONVENTIONS.md` " + "naming all three tool-owned section headings if it exists at all, every stage contract " + "present, every file under `types/` declaring `type: types/type-spec.md` validating " + "against `types/type-spec.schema.yaml`, no pre-migration `type: entity` blocks left in the " + "contracts, the `.gitignore` canaries clear in both directions (nothing ignored under " + "`raw/`/`kb/`, `incoming/` ignored, everything ignored under `reports/` and the published " + "skill directories), and no `.md`/`.template` file `dist export` would ship citing an issue " + "number - the tracker exists only in the origin repo, so such a number in a distributed " + "instance is a reference its reader can neither resolve nor recognise as unresolvable (a " + "`<!-- dist:strip-start/end -->` region is exempt: it is already gone from the text the " + "check reads, which is the export plan's, not the working tree's), every reference file " + "`docs toc` covers carrying the current table-of-contents region for its own headings - " + "missing and stale are one check, because the generator is idempotent - and every relative " + "markdown link in one of those same reference files resolving to a file that actually " + "exists (a target's `#anchor` suffix is stripped first; code fences and inline code spans " + "are masked before scanning, so a passage showing link syntax as an example is not mistaken " + "for a real reference). The name is about documentation parity, not about the `docs/` " + "directory - it neither reads nor requires one, the same way `kb/` predates the collection " + "it now checks", + failures=(cli_contract.Failure( + label="", + exit_1="A command, contract, or type-form mismatch was found, a type-spec's own " + "frontmatter fails its schema, a shipped `.md`/`.template` cites an issue number, a " + "reference file's table-of-contents region is missing or stale, or a reference file's " + "relative markdown link does not resolve to an existing file", + retry="Fix the documentation it names, then re-run. For a type-spec's own frontmatter: " + "fix the field, or add a matching line to `types/type-spec.schema.yaml` if the field is " + "legitimately new. For an issue reference: say what was decided instead of pointing at " + "where, or move the pointer behind a `<!-- dist:strip-start/end -->` block. For a table " + "of contents: run `docs toc --apply` - never hand-write the region. For a dead link: " + "fix the `../` count or the target's name", + ),), +)) def verify(): - """Check the CLI/README command tables, contract presence, type-form drift, every type-spec's frontmatter against its own schema, ignore rules, version/changelog agreement, issue references, and link targets in shipped documents.""" + """Check the docs that mirror the code.""" issues = ( - check_cli_readme() + check_command_contracts() + + check_commands_region() + + check_synopsis_flags() + + check_no_issue_references_in_help() + check_readmes_have_no_command_table() + check_collection_contracts() + check_type_spec_frontmatter() @@ -924,12 +1112,12 @@ def verify(): from chemenu.type_resolver import resolver success( - f"Docs verified: {len(registered_commands())} command(s) documented, " + f"Docs verified: {len(registered_commands())} command(s) with a cli_contract record, " f"{len(kb_collections.iter_kb_collections())} collection(s) and " f"{len(STAGE_CONTRACTS)} stage contract(s) present, no legacy type blocks, " f"{len(resolver.list_type_specs())} type-spec(s) validating against their own schema, " f"{len(IGNORE_CANARIES)} ignore canaries clear, " - f"no issue references in {len(shipped_prose())} shipped document(s), " + f"no issue references in {len(shipped_prose())} shipped document(s) or command help, " f"tables of contents current and every link resolving on " f"{len(toc.target_files())} reference file(s), " f"{version_mod.CHANGES_FILENAME} documents version " @@ -938,12 +1126,52 @@ def verify(): @app.command("toc") +@cli_contract.record(cli_contract.CommandRecord( + path="docs toc", + summary="Create, refresh or remove the generated table-of-contents region.", + synopsis=(cli_contract.Variant(usage="docs toc [--apply]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="`--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", + budget=cli_contract.Budget.COUNTED, + ), + notes="On every reference file over 100 lines, in the scope Anthropic's skill-authoring " + "guidance names for a file previewed rather than read in full: `AGENTS.md`, every stage " + "contract, `kb/CONVENTIONS.md`, every `kb/*/COLLECTION.md`, every flat " + "`instructions/**.md` file, every `types/*.md` type-spec, and every `docs/` page - each " + "together with the `<name>.template` it ships as, where one exists. Computed from those " + "categories rather than listed, so a file added later is in scope without a code change. A " + "template is in scope because it is the same document one step earlier in its life: an " + "instance adopts it by copying it back, so a region missing there is a region missing in " + "the adopted file, which is how `kb/CONVENTIONS.md.template` came to grow past the " + "threshold with no region and left every instance adopting it failing `docs verify` at the " + "end of its own setup. `SKILL.md` is the one exception, and the same guidance is why: it " + "places a skill body on the loading level that is read whole when the skill triggers, and " + "aims its own TOC advice at the bundled reference files a skill points *at*. Human docs " + "(`README.md`, `CHANGES.md`, `EVALS.md`, `INSTALL.md`, `tools/README.md`) are out of scope " + "because AGENTS.md § File naming says no agent loads them as instruction. Dry-run by " + "default (prints which files would change); `--apply` writes. `docs verify` checks the " + "result stays current the same way it checks every other generated-from-code copy", + failures=(cli_contract.Failure( + label="", + exit_1="Never fails on content: a file with no `##` heading, or one at or under the " + "threshold, is simply left without a region", + retry="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", + ),), +)) def toc_command( apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"), ): - """Create, refresh or remove the generated table-of-contents region on - every reference file `toc.target_files()` covers - AGENTS.md, the stage - and collection contracts, and every flat `instructions/**.md` file.""" + """Create, refresh or remove the generated table-of-contents region. + \f + On every reference file `toc.target_files()` covers - AGENTS.md, the + stage and collection contracts, and every flat `instructions/**.md` + file.""" changed = [] for path in toc.target_files(): before = path.read_text(encoding="utf-8") @@ -964,3 +1192,48 @@ def toc_command( success(f"Refreshed the table of contents on {len(changed)} file(s).") else: typer.echo(f"\n{len(changed)} file(s) would change. Re-run with --apply to write.") + + +@app.command("contract") +@cli_contract.record(cli_contract.CommandRecord( + path="docs contract", + summary="Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.", + synopsis=(cli_contract.Variant(usage="docs contract [--apply]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - the whole region is rewritten in one file write", + budget=cli_contract.Budget.COUNTED, + ), + notes="Rebuilds the region from `cli_contract.all_records()`: the index (one line per " + "command, `GROUPS` order) followed by each `###`-group's commands as `#### <path>` " + "man-page-shaped sections. Dry-run by default, like `docs toc`; `--apply` writes. " + "`docs verify`'s `check_commands_region` checks the result stays current the same way it " + "checks every other generated-from-code copy.", + failures=(), +)) +def contract_command( + apply: bool = typer.Option(False, "--apply", help="Write changes; default is dry-run (preview only)"), +): + """Regenerate `tools/CONTRACT.md`'s `<!-- wikitool:commands -->` region.""" + if not CLI_README.exists(): + fail(f"{rel_path(CLI_README)} is missing.") + + text = CLI_README.read_text(encoding="utf-8") + content = cli_contract.render_commands_region(groups=cli_contract.GROUPS).strip("\n") + region = ( + blocks.open_marker(COMMANDS_REGION) + "\n" + content + "\n" + blocks.close_marker(COMMANDS_REGION) + ) + after = blocks.replace(text, COMMANDS_REGION, region) + + if after == text: + success(f"{rel_path(CLI_README)}'s command region is already current.") + return + + if not apply: + typer.echo(f"{rel_path(CLI_README)} would change.") + typer.echo("Re-run with --apply to write.") + return + + CLI_README.write_text(after, encoding="utf-8") + success(f"Regenerated the command region in {rel_path(CLI_README)}.") diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index f89e14e..4a81cf0 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -20,7 +20,7 @@ from typing import Optional import typer from rich.console import Console -from chemenu import config, conventions, kb_collections, version as version_mod +from chemenu import cli_contract, config, conventions, kb_collections, version as version_mod from chemenu.commands import git_publish, instructions_cmd from chemenu.commands._util import rel_path from chemenu.session import ENV_VAR as SESSION_ENV_VAR @@ -617,15 +617,60 @@ def run_doctor() -> list[Check]: return checks +@cli_contract.record(cli_contract.CommandRecord( + path="doctor", + summary="Check that this instance is correctly configured.", + synopsis=(cli_contract.Variant(usage="doctor [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + network=cli_contract.Network.YES, + ), + notes="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), the task-tracker provider (`.wikitool-tasks.json` " + "present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is " + "`FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` " + "provider, also its configured `access` path's own state - `access: \"api\"` reports " + "whether its local REST API answers `GET /health` right now, `access: \"snapshot\"` reports " + "whether a backup file is ready; the *other* access path is never attempted and is not a " + "finding - and neither ever `FAIL`s, an app that is simply not running is not a fault; for " + "a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - " + "also never a `FAIL`, only a broken config block is), the session id source (`OK` for " + "`WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare " + "parent-pid fallback - see `chemenu.session`), 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`). 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", + failures=(cli_contract.Failure( + label="", + exit_1="At least one check reported `FAIL` (a `WARN`, e.g. no remote or no " + "`WIKITOOL_SESSION_ID`, does not exit 1)", + retry="Each finding names its own fix command; re-run after applying it", + ),), +)) def doctor_command( json_out: bool = typer.Option(False, "--json", help="Print the checks as JSON"), ): - """Check that this instance is correctly configured: dependencies, author, - git identity/remote, published skills, structure, personalization, KB - conventions, generated files, session scoping, telemetry state, whether - the MCP `submit` tool is armed, and which task-tracker provider (if any) - is configured for the GTD review. Read-only. Exits 1 only if a check - FAILs.""" + """Check that this instance is correctly configured. + \f + Dependencies, author, git identity/remote, published skills, structure, + personalization, KB conventions, generated files, session scoping, + telemetry state, whether the MCP `submit` tool is armed, and which + task-tracker provider (if any) is configured for the GTD review. + Read-only. Exits 1 only if a check FAILs.""" checks = run_doctor() if json_out: diff --git a/tools/chemenu/commands/eval_cmd.py b/tools/chemenu/commands/eval_cmd.py index 239dc53..fc61243 100644 --- a/tools/chemenu/commands/eval_cmd.py +++ b/tools/chemenu/commands/eval_cmd.py @@ -13,7 +13,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success from chemenu.evals import scorecard from chemenu.session import session_id as current_session_id @@ -26,6 +26,20 @@ EVALS_DIR = config.REPORTS_DIR / "evals" @app.command("sessions") +@cli_contract.record(cli_contract.CommandRecord( + path="eval sessions", + summary="List the sessions that have a trace under `reports/telemetry/`.", + synopsis=(cli_contract.Variant(usage="eval sessions [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="Most recent first. Read-only and exempt from the Iteration Budget Gate. Never fails; " + "an empty list is a valid answer.", + failures=(), +)) def sessions_command( json_out: bool = typer.Option(False, "--json", help="Print the list as JSON"), ): @@ -44,6 +58,33 @@ def sessions_command( @app.command("score") +@cli_contract.record(cli_contract.CommandRecord( + path="eval score", + summary="Score one traced session.", + synopsis=(cli_contract.Variant( + usage="eval score [--session <id>] [--json] [--markdown out.md] [--save] " + "[--fail-on-error]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only, apart from the files `--save`/`--markdown` write", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="No trace exists for the named session", + retry="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", + ),), +)) def score_command( session: Optional[str] = typer.Option( None, "--session", diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index 182ce62..7c94610 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -56,7 +56,7 @@ from typing import NamedTuple, Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, needs_clearance, success from chemenu.telemetry import emit @@ -999,6 +999,38 @@ def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command return _reconcile_summary(outcome, remote, branch) +@cli_contract.record(cli_contract.CommandRecord( + path="sync", + summary="Fetch `<remote>/<branch>` and bring the local branch up to date with it.", + synopsis=(cli_contract.Variant( + usage="sync [--remote origin] [--branch main] [--confirm-rebase TOKEN]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - fetch, then at most one merge/rebase attempt, aborted cleanly on failure", + budget=cli_contract.Budget.COUNTED, + gates=("rebase-review",), + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="The automatic rebase hit a real conflict (git failed)", + retry="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", + ),), +)) def sync_command( remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"), branch: str = typer.Option("main", "--branch", help="Branch to reconcile"), @@ -1020,6 +1052,83 @@ def sync_command( success(summary or f"Nothing to reconcile against {remote}/{branch}.") +@cli_contract.record(cli_contract.CommandRecord( + path="publish", + summary="Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push.", + synopsis=(cli_contract.Variant( + usage='publish --message "<op>: <desc>" [--no-push] [--confirm TOKEN] ' + "[--confirm-rebase TOKEN] [--threshold N] [--remote origin] [--branch main] " + "[--path P ...]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - sequential git operations, but both gates run before staging", + budget=cli_contract.Budget.COUNTED, + gates=("mass-update", "publish-remote", "rebase-review"), + ), + notes="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`. `--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 " + "`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", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="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", + ),), +)) def publish_command( message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"), push: bool = typer.Option(True, "--push/--no-push"), diff --git a/tools/chemenu/commands/index_build.py b/tools/chemenu/commands/index_build.py index 3abba04..c3a03ec 100644 --- a/tools/chemenu/commands/index_build.py +++ b/tools/chemenu/commands/index_build.py @@ -23,7 +23,7 @@ from pathlib import Path import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.catalog import SHARD_THRESHOLD, Area, Collection, group_pages from chemenu.commands._util import rel_path, success from chemenu.page import Page @@ -217,11 +217,35 @@ def build_index(kb_dir: Path) -> str: @app.command("rebuild") +@cli_contract.record(cli_contract.CommandRecord( + path="index rebuild", + summary="Regenerate the catalog from every page's frontmatter.", + synopsis=(cli_contract.Variant(usage="index rebuild [--dry-run]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="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", + budget=cli_contract.Budget.COUNTED, + ), + notes="`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", + failures=(cli_contract.Failure( + label="", + exit_1="Rare I/O error only", + retry="Safe to retry freely - the plan is always recomputed from the pages currently " + "on disk, so a re-run converges", + ),), +)) def index_rebuild( dry_run: bool = typer.Option( False, "--dry-run", help="Print what would be written instead of writing it" ), ): + """Regenerate the catalog from every page's frontmatter.""" # Reported, not refused (#57 decision): a nested page still gets a catalog # written for it, just a wrong one (folded into its area, no distinct # location of its own) - `wikitool lint`'s `nested_pages` is the hard diff --git a/tools/chemenu/commands/instructions_cmd.py b/tools/chemenu/commands/instructions_cmd.py index 84a08f8..d5124ad 100644 --- a/tools/chemenu/commands/instructions_cmd.py +++ b/tools/chemenu/commands/instructions_cmd.py @@ -44,7 +44,7 @@ from pathlib import Path import typer import yaml -from chemenu import config, markdown_code +from chemenu import cli_contract, config, markdown_code from chemenu.commands import dist_cmd, docs_verify from chemenu.commands._util import fail, rel_path, success from chemenu.type_resolver import resolver @@ -366,6 +366,32 @@ def check_skill_reference_paths() -> list[str]: @app.command("sync") +@cli_contract.record(cli_contract.CommandRecord( + path="instructions sync", + summary="Publish every `instructions/<name>/SKILL.md` into the harness skill directories.", + synopsis=(cli_contract.Variant(usage="instructions sync [--force]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="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", + budget=cli_contract.Budget.COUNTED, + ), + notes="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)", + failures=(cli_contract.Failure( + label="", + exit_1="No skills found under `instructions/`, or a target directory is not a " + "published skill (no `SKILL.md`) and `--force` was not passed", + retry="Check whether the flagged target holds anything worth keeping, then re-run with " + "`--force` if not; otherwise fix the named cause and retry", + ),), +)) def sync( force: bool = typer.Option( False, @@ -402,6 +428,38 @@ def sync( @app.command("verify") +@cli_contract.record(cli_contract.CommandRecord( + path="instructions verify", + summary="Check the instruction layer.", + synopsis=(cli_contract.Variant(usage="instructions verify"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Flat instructions validate against `types/instruction.schema.yaml`, each `SKILL.md` " + "carries the frontmatter its harness reads, no `SKILL.md` carries a relative markdown link " + "(`sync` copies it to a different depth than the source, so a `SKILL.md` references a " + "target as a repo-root-relative plain path instead - see `instructions/CONTRACT.md` § \"A " + "skill's outbound reference is a plain path, not a link\"), every published copy is " + "byte-identical to its source, no instruction is left that nothing references, and nothing " + "under `instructions/dev/` is referenced from outside it (a " + "`<!-- dist:strip-start/end -->` block is exempt - see `instructions/CONTRACT.md`). Missing " + "*every* copy is reported as \"run sync\", not as drift - that is a clean checkout", + failures=(cli_contract.Failure( + label="", + exit_1="Nothing found under `instructions/` at all, a malformed instruction or " + "`SKILL.md`, a `SKILL.md` carrying a relative markdown link, a published copy that " + "drifted from its source, an instruction nothing references (or, for `manual: true`, " + "one that IS linked from AGENTS.md, CLAUDE.md, or a skill and so risks running " + "implicitly), or something under `instructions/dev/` referenced from outside it and " + "outside a `dist:strip` block", + retry="Fix the flagged file, then re-run. For a relative link in a `SKILL.md`, rewrite " + "it as a repo-root-relative plain path instead. For drift, re-run `sync` instead of " + "hand-editing the published copy - the source under `instructions/` always wins", + ),), +)) def verify(): """Check instructions/ against its type, that no skill carries a relative markdown link, and every published copy against its source.""" sources = skill_dirs() @@ -528,6 +586,20 @@ def verify(): @app.command("list") +@cli_contract.record(cli_contract.CommandRecord( + path="instructions list", + summary="List the flat instructions with their descriptions.", + synopsis=(cli_contract.Variant(usage="instructions list [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="This is how the layer is discovered; `search` deliberately covers `kb/` only. Never " + "fails - an empty `instructions/` prints \"No instructions found.\" Safe to retry freely.", + failures=(), +)) def list_instructions( json_out: bool = typer.Option(False, "--json", help="Print the listing as JSON"), ): diff --git a/tools/chemenu/commands/links_cmd.py b/tools/chemenu/commands/links_cmd.py index b0a8fa7..0e2d961 100644 --- a/tools/chemenu/commands/links_cmd.py +++ b/tools/chemenu/commands/links_cmd.py @@ -17,7 +17,7 @@ from typing import Optional import typer -from chemenu import config, links +from chemenu import cli_contract, config, links from chemenu.commands._util import console, fail from chemenu.kb_scan import load_kb_pages from chemenu.page import Page @@ -60,6 +60,28 @@ def inbound(pages: dict[str, Page], title: str) -> list[dict]: @app.command("show") +@cli_contract.record(cli_contract.CommandRecord( + path="links show", + summary="Show the edges out of and into a page.", + synopsis=(cli_contract.Variant(usage='links show --page "<Title>" [--json]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Page not found", + retry="Check the exact title with `search`; a wikilink target is not always the page's " + "stem", + ),), +)) def links_show( page: str = typer.Option(..., "--page", help="Exact page title"), json_out: bool = typer.Option(False, "--json", help="Print the edges as JSON"), diff --git a/tools/chemenu/commands/lint.py b/tools/chemenu/commands/lint.py index 0c02070..aa53139 100644 --- a/tools/chemenu/commands/lint.py +++ b/tools/chemenu/commands/lint.py @@ -12,7 +12,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import rel_path, success from chemenu.lint_core import ( HARD_ERROR_KEYS, @@ -46,6 +46,47 @@ __all__ = [ ] +@cli_contract.record(cli_contract.CommandRecord( + path="lint", + summary="Run structural lint checks against kb/.", + synopsis=(cli_contract.Variant( + usage="lint [--json] [--markdown out.md] [--full] [--fail-on-error]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Writes one report file (single atomic write) unless `--json`", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Only with `--fail-on-error`: hard findings exist", + retry="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\"", + ),), +)) def lint_command( json_out: bool = typer.Option(False, "--json", help="Print the raw findings as JSON and write no report"), markdown_out: Optional[Path] = typer.Option(None, "--markdown", help="Write the markdown report here instead of the default reports/Lint Report <date>.md"), @@ -53,7 +94,7 @@ def lint_command( fail_on_error: bool = typer.Option(False, "--fail-on-error", help="Exit non-zero if hard errors were found"), ): """Run structural lint checks against kb/. - + \f Unless `--json` is given, the full report is always written to a file and its path is printed. That path is the point: a lint report is long, and an agent that only saw it on stdout had no way back to the part it scrolled diff --git a/tools/chemenu/commands/log_append.py b/tools/chemenu/commands/log_append.py index 1dade48..1cf3e8b 100644 --- a/tools/chemenu/commands/log_append.py +++ b/tools/chemenu/commands/log_append.py @@ -7,7 +7,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success, today_iso app = typer.Typer(help="Manage kb/log.md.") @@ -51,12 +51,34 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int: @app.command("append") +@cli_contract.record(cli_contract.CommandRecord( + path="log append", + summary="Append a formatted entry to `kb/log.md`.", + synopsis=(cli_contract.Variant( + usage="log append --op ingest|query|lint|create|update|delete|rename|move " + '--title "..." [--body "..."|--body-file path]', + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Yes - single append", + budget=cli_contract.Budget.COUNTED, + ), + notes="Append a formatted entry to `kb/log.md`.", + failures=(cli_contract.Failure( + label="", + exit_1="Invalid `--op` or unreadable `--body-file`", + retry="**Not idempotent.** If the previous run's outcome is uncertain, check the tail " + "of `kb/log.md` before retrying", + ),), +)) def log_append( op: str = typer.Option(..., "--op", help="|".join(VALID_OPS)), title: str = typer.Option(..., "--title", help="Brief description, e.g. a source path"), body: str = typer.Option("", "--body", help="Optional multi-line details"), body_file: Optional[Path] = typer.Option(None, "--body-file", help="Read the body from a file instead of --body"), ): + """Append a formatted entry to `kb/log.md`.""" if op not in VALID_OPS: fail(f"--op must be one of {VALID_OPS}") text = body @@ -69,6 +91,21 @@ def log_append( @app.command("status") +@cli_contract.record(cli_contract.CommandRecord( + path="log status", + summary="Read-only: count `ingest` entries logged since the last `lint` entry.", + synopsis=(cli_contract.Variant(usage="log status"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="The deterministic trigger behind the Maintenance Schedule's \"every 10 sources\" " + "full-lint cadence. Never fails (reports 0 if `kb/log.md` is missing or empty). Safe to " + "retry freely.", + failures=(), +)) def log_status(): """Report how many `ingest` operations have been logged since the last `lint` - the deterministic trigger for the Maintenance Schedule's "every diff --git a/tools/chemenu/commands/migrate_cmd.py b/tools/chemenu/commands/migrate_cmd.py index 9ea0d75..6f37275 100644 --- a/tools/chemenu/commands/migrate_cmd.py +++ b/tools/chemenu/commands/migrate_cmd.py @@ -21,7 +21,7 @@ from typing import Optional import typer -from chemenu import config, corpus_diff, kb_scan, kb_state, version as version_mod +from chemenu import cli_contract, config, corpus_diff, kb_scan, kb_state, version as version_mod from chemenu.commands._util import console, fail, rel_path, success, today_iso from chemenu.frontmatter_io import read_page from chemenu.page import Page @@ -49,6 +49,20 @@ def _versions() -> tuple[Version, Optional[Version]]: # --- migrate list ---------------------------------------------------------- +@cli_contract.record(cli_contract.CommandRecord( + path="migrate list", + summary="List every migration document under `instructions/migrations/`.", + synopsis=(cli_contract.Variant(usage="migrate list [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="Oldest target first, with its kind and obligation. Read-only and **exempt from the " + "Iteration Budget Gate**. Never fails.", + failures=(), +)) @app.command("list") def list_command( json_out: bool = typer.Option(False, "--json", help="Print the migrations as JSON"), @@ -134,6 +148,34 @@ def _report_offers( ) +@cli_contract.record(cli_contract.CommandRecord( + path="migrate status", + summary="Show the migrations this instance still owes, in the order they must run.", + synopsis=(cli_contract.Variant(usage="migrate status [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="`.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is " + "unreadable", + retry="For a missing declaration: run `migrate baseline <version>` once, then retry. " + "Safe to retry freely otherwise", + ),), +)) @app.command("status") def status_command( json_out: bool = typer.Option(False, "--json", help="Print the chain as JSON"), @@ -212,6 +254,32 @@ def status_command( # --- migrate done / baseline ---------------------------------------------- +@cli_contract.record(cli_contract.CommandRecord( + path="migrate done", + summary="Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`.", + synopsis=(cli_contract.Variant(usage="migrate done <version> [--pages N] [--dry-run]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Yes - single file write", + budget=cli_contract.Budget.COUNTED, + ), + notes="**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", + failures=(cli_contract.Failure( + label="", + exit_1="Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* " + "version that is not the next link in the chain", + retry="**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", + ),), +)) @app.command("done") def done_command( version: str = typer.Argument(..., help="The migration's target version, e.g. 1.4.0"), @@ -308,6 +376,27 @@ def done_command( ) +@cli_contract.record(cli_contract.CommandRecord( + path="migrate baseline", + summary="Declare `kb_version` once, for an instance predating `.wikitool-kb.json`.", + synopsis=(cli_contract.Variant(usage="migrate baseline <version> [--force]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - single file write", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Unparseable version, or a declaration already exists and `--force` was not " + "passed", + retry="Safe to re-run with the same version. If a declaration exists, it is almost " + "always `migrate done` that was wanted", + ),), +)) @app.command("baseline") def baseline_command( version: str = typer.Argument(..., help="The shape this instance's content is already in"), @@ -434,6 +523,40 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]: return shapes +@cli_contract.record(cli_contract.CommandRecord( + path="migrate verify", + summary="Compare `kb/` against a git revision on the invariants a content migration must " + "not change.", + synopsis=(cli_contract.Variant( + usage="migrate verify --from <rev> [--path P ...] [--expect-body-change] [--json] " + "[--fail-on-error]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is " + "not a revision in this repository", + retry="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", + ),), +)) @app.command("verify") def verify_command( from_rev: str = typer.Option(..., "--from", help="Git revision to compare against, e.g. HEAD"), diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index 9f065a9..26c3dd8 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -28,7 +28,7 @@ import re import typer -from chemenu import config, tasks +from chemenu import cli_contract, config, tasks from chemenu.commands._util import ( check_collision, check_raw_files_exist, @@ -325,6 +325,108 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]: return f"tracker project '{page_title}' created" +@cli_contract.record(cli_contract.CommandRecord( + path="new", + summary="Scaffold a new wiki page of any type.", + synopsis=( + cli_contract.Variant( + usage='new <type-name> --name "<Name>" [--type <path>] [--set field=value ...]', + notes="Scaffold a page of any type. The type-spec drives fields, directory " + "(`base_dir`/`layout`), title prefix, and template - a schema `default:` is " + "materialized only for a field the schema also lists in `required:` (an optional " + "field's default is a reader-side assumption, not a scaffold-time value) - `--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`.", + ), + cli_contract.Variant( + usage='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]", + notes="Scaffold `kb/entities/<subdir>/<Name>.md`", + ), + cli_contract.Variant( + usage='new concept --name "<Name>" --set concept_type=<t> ...', + notes="Scaffold `kb/concepts/<Name>.md`", + ), + cli_contract.Variant( + usage='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]", + notes="Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a " + "`raw_files:` list (rejects paths that don't exist)", + ), + cli_contract.Variant( + usage='new comparison --name "X vs Y" --set entities=X,Y', + notes="Scaffold `kb/comparisons/X vs Y.md`", + ), + cli_contract.Variant( + usage='new project --name "<Name>" --set responsibility=<bereich> [--resume]', + notes="Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` " + "configures a task tracker, a same-named tracker project - one name, one identity. " + "Tracker before page: the tracker side is settled first, so a failure past that " + "point leaves a tracker project with no page - a state `review`'s check 3 already " + "reports - never a page with no tracker project. No tracker configured is a " + "legitimate, explicitly announced state (page only). A name already taken " + "(case-insensitively) in `kb/` or the tracker is refused outright, naming where it " + "was found, and creates nothing. A provider whose *configured access path* has no " + "write path (Super Productivity's `access: \"snapshot\"` - the tracker is read-only " + "from there by construction) refuses **entirely**, exit **1**, naming the " + "`access: \"api\"` instance to use instead - neither the tracker project nor the " + "page is created, and `--resume` behaves the same. A provider that could write but " + "has no project-creation endpoint of its own (Super Productivity's `access: \"api\"` " + "- `GET /projects` exists, `POST /projects` does not) raises " + "`chemenu.errors.HumanInterventionRequired`; the command shows its instructions and " + "exits **42** (`needs_clearance()`, same posture as the four named gates, without " + "being a fifth one - see that class's docstring), creating nothing. `--resume` is " + "how a later run tells the command a human has done what that message asked: it " + "re-verifies via the read path (`find_project`) before continuing to page creation, " + "rather than trusting the claim, and repeats the same 42 if the tracker still " + "doesn't have it. `--resume` on any other type is refused", + ), + ), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="`new <type>`: Yes - single file write. `new project`: **No** for the " + "tracker-configured case - a tracker-project write (or its human-clearance request) " + "happens before the kb/ page write, so a failure between the two leaves a tracker " + "project with no page (a state `review`'s check 3 already reports), never a page with " + "no tracker project. Still a single file write when no tracker is configured", + budget=cli_contract.Budget.COUNTED, + gates=("human-intervention-required (`new project` only)",), + ), + notes="`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.", + failures=( + cli_contract.Failure( + label="new <type>", + exit_1="Duplicate page title, unknown type, invalid `--set` value, or a " + "`raw_files` path that doesn't exist", + retry="Not transient; fix the argument and retry once. Never hand-craft the page " + "instead", + ), + cli_contract.Failure( + label="new project", + exit_1="Everything `new <type>` covers, **plus**: the name is already taken in the " + "tracker (case-insensitively - for `caldav` this is checked against every list in " + "the account, not only the ones counted as projects), `--resume` was passed for a " + "type other than `project`, or the configured provider's access path has no write " + "path at all (Super Productivity's `access: \"snapshot\"`)", + retry="A collision, a bad `--set`, or a read-only access path is not transient, " + "same as `new <type>` - the last of those points at the `access: \"api\"` instance " + "instead and refuses on every `--resume` retry too, since nothing about the config " + "changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its " + "own separate outcome from the ordinary exit-1 cases above, and is " + "`superproductivity`-only: that provider *can* write but cannot create the project " + "itself and a human must, per the printed instructions; re-run with `--resume` once " + "that is done - it re-verifies via the read path rather than trusting the claim, and " + "exits 42 again unchanged if the tracker still does not have it. `caldav` never " + "produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a " + "valid, non-colliding name always creates the list itself", + ), + ), +)) def new_page_command( type_name: str = typer.Argument( ..., @@ -344,11 +446,11 @@ def new_page_command( "--resume", help="`project` only: confirm a human has completed the manual tracker step an earlier " "HumanInterventionRequired refusal asked for, so this run continues to page creation " - "instead of refusing the now-existing tracker project as a collision (Gitea #126).", + "instead of refusing the now-existing tracker project as a collision.", ), ): """Scaffold a new wiki page of any type. - + \f The type's own type-spec drives everything: which frontmatter fields exist and are required (its `.schema.yaml`), their scaffold defaults (schema `default:`), where the page is written (`base_dir` + `layout`), @@ -356,8 +458,8 @@ def new_page_command( type-spec's template). Adding a new type therefore needs no change here. For `type_name == "project"` specifically, this also ensures a - same-named tracker project exists (Gitea #126, #119 D8/D31) before the - page is written - see `_ensure_tracker_project`. + same-named tracker project exists before the page is written - see + `_ensure_tracker_project`. """ type_path = type_path_override or resolver.find_type_by_name(type_name) if not type_path: diff --git a/tools/chemenu/commands/page_ops.py b/tools/chemenu/commands/page_ops.py index 8aaefc5..81882ba 100644 --- a/tools/chemenu/commands/page_ops.py +++ b/tools/chemenu/commands/page_ops.py @@ -25,7 +25,7 @@ from typing import Optional import typer -from chemenu import config, links +from chemenu import cli_contract, config, links from chemenu.commands._util import check_collision, fail, rel_path, success from chemenu.frontmatter_io import write_page from chemenu.lint_core import find_misplaced @@ -210,6 +210,31 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]: return sorted(found) +@cli_contract.record(cli_contract.CommandRecord( + path="rename", + summary="Rename a page, or repoint references that name a page that never existed.", + synopsis=(cli_contract.Variant(usage='rename --from "<Old>" --to "<New>" [--dry-run]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - one write per referencing page, then the file move", + budget=cli_contract.Budget.COUNTED, + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="Neither `--from` nor `--to` is a page, target title already taken, or " + "`--from` equals `--to`", + retry="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", + ),), +)) def rename_command( old: str = typer.Option(..., "--from", help="Current page title, exactly as it appears"), new: str = typer.Option(..., "--to", help="New page title"), @@ -301,6 +326,28 @@ def rename_command( ) +@cli_contract.record(cli_contract.CommandRecord( + path="rm", + summary="Delete a page and mechanically de-link it from the rest of the wiki.", + synopsis=(cli_contract.Variant(usage='rm --page "<Title>" [--yes] [--dry-run]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - one write per referencing page, then the delete", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Page not found, **or** other pages still reference it and `--yes` was not " + "passed", + retry="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", + ),), +)) def rm_command( page_title: str = typer.Option(..., "--page", help="Exact title of the page to delete"), yes: bool = typer.Option( @@ -399,6 +446,40 @@ def _rmdir_if_emptied(directory: Path) -> bool: return True +@cli_contract.record(cli_contract.CommandRecord( + path="move", + summary="Move a page (or every misplaced page) to the directory its type-spec computes.", + synopsis=( + cli_contract.Variant(usage='move --page "<Title>"'), + cli_contract.Variant(usage="move --reconcile [--dry-run]"), + ), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="`--page`: yes, a single file move. `--reconcile`: no - one file move per page, " + "each idempotent", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="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", + ),), +)) def move_command( page_title: Optional[str] = typer.Option( None, "--page", help="Exact title of the page to move to its computed location" diff --git a/tools/chemenu/commands/provenance_cmd.py b/tools/chemenu/commands/provenance_cmd.py index 3b215de..501992e 100644 --- a/tools/chemenu/commands/provenance_cmd.py +++ b/tools/chemenu/commands/provenance_cmd.py @@ -13,7 +13,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success from chemenu.provenance import ( broken_raw_refs, @@ -49,6 +49,21 @@ def _normalize_raw_path(raw: str) -> str: @app.command("coverage") +@cli_contract.record(cli_contract.CommandRecord( + path="sources coverage", + summary="List raw files with no source page, broken `raw_files:` references, and legacy " + "directory/URL-only source pages.", + synopsis=(cli_contract.Variant(usage="sources coverage [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="List raw files with no source page, broken `raw_files:` references, and legacy " + "directory/URL-only source pages. Never fails. Safe to retry freely.", + failures=(), +)) def coverage(json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON")): """Report raw files with no source page, broken raw_files: references, and source pages still using a legacy directory/URL-only `source:` field.""" @@ -74,6 +89,26 @@ def coverage(json_out: bool = typer.Option(False, "--json", help="Print raw find @app.command("trace") +@cli_contract.record(cli_contract.CommandRecord( + path="sources trace", + summary="Trace provenance in either direction: raw file, or page.", + synopsis=(cli_contract.Variant(usage='sources trace --raw <path> | --page "<Title>"'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Trace provenance in either direction: raw file -> source page(s) -> citing pages, or " + "page -> its sources -> their raw files", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="Fix the argument and retry", + ),), +)) def trace( raw: Optional[str] = typer.Option(None, "--raw", help="Raw file path to trace forward from"), page: Optional[str] = typer.Option(None, "--page", help="Wiki page title to trace backward from"), @@ -170,9 +205,28 @@ def build_provenance_index(kb_dir: Path, raw_dir: Path) -> str: @app.command("rebuild-index") +@cli_contract.record(cli_contract.CommandRecord( + path="sources rebuild-index", + summary="Regenerate the `kb/provenance.md` reverse index.", + synopsis=(cli_contract.Variant(usage="sources rebuild-index [--dry-run]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - the single provenance file is regenerated from scratch", + budget=cli_contract.Budget.COUNTED, + ), + notes="Regenerate the `kb/provenance.md` reverse index (raw file -> source page -> citing " + "pages)", + failures=(cli_contract.Failure( + label="", + exit_1="Rare I/O error only", + retry="Safe to retry freely", + ),), +)) def rebuild_index( dry_run: bool = typer.Option(False, "--dry-run", help="Print the result instead of writing kb/provenance.md"), ): + """Regenerate the `kb/provenance.md` reverse index.""" content = build_provenance_index(config.KB_DIR, config.RAW_DIR) provenance_file = config.KB_DIR / "provenance.md" if dry_run: diff --git a/tools/chemenu/commands/raw_cmd.py b/tools/chemenu/commands/raw_cmd.py index e8fbdac..3d97def 100644 --- a/tools/chemenu/commands/raw_cmd.py +++ b/tools/chemenu/commands/raw_cmd.py @@ -71,7 +71,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success from chemenu.frontmatter_io import write_page from chemenu.kb_scan import load_kb_pages @@ -309,6 +309,90 @@ def _replace( success("Review them in this same run: the replacement and their update belong in one commit.") +@cli_contract.record(cli_contract.CommandRecord( + path="raw accept", + summary="Promote one or more files from `incoming/` into `raw/`.", + synopsis=( + cli_contract.Variant( + usage='raw accept <file> [<file> ...] --fidelity <v> --authority <v> ' + '[--page "<Title>"] [--dry-run]', + notes="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", + ), + cli_contract.Variant( + usage='raw accept <file> --replaces <raw-path> [--fidelity <v>] [--authority <v>] ' + "[--dry-run]", + notes="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", + ), + ), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="`raw accept`: No - one filesystem move per file, then (with `--page`) one page " + "write. `raw accept --replaces`: No - one `unlink()` + one `rename()`, plus (if " + "`--fidelity`/`--authority` was given) one page write", + budget=cli_contract.Budget.COUNTED, + ), + notes="See `raw/CONTRACT.md` \"Getting a file in: incoming/\".", + failures=( + cli_contract.Failure( + label="raw accept", + exit_1="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", + retry="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", + ), + cli_contract.Failure( + label="raw accept --replaces", + exit_1="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", + retry="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", + ), + ), +)) @app.command("accept") def raw_accept_command( files: list[Path] = typer.Argument( diff --git a/tools/chemenu/commands/review_cmd.py b/tools/chemenu/commands/review_cmd.py index ac9d20c..bddebbf 100644 --- a/tools/chemenu/commands/review_cmd.py +++ b/tools/chemenu/commands/review_cmd.py @@ -10,7 +10,7 @@ import json import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail from chemenu.errors import ValidationError from chemenu.review import ALL_CHECKS, ReviewReport, run_review @@ -75,13 +75,64 @@ def report_to_dict(report: ReviewReport) -> dict: } +@cli_contract.record(cli_contract.CommandRecord( + path="review", + summary="The GTD weekly review.", + synopsis=(cli_contract.Variant(usage="review [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + network=cli_contract.Network.YES, + ), + notes="Joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` " + "project pages over the case-normalized project name, at read time, storing nothing - not " + "even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items " + "whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since " + "those states mean the initiative not having a next action is expected rather than a " + "problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than " + "`thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no " + "matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a " + "`kb/` page `state: active` with no matching tracker project, or one with zero open items - " + "the reverse direction of the unpaged-project join, so a rename on either side surfaces on " + "both), **someday-stale** (a someday/maybe item untouched for longer than " + "`thresholds.someday_stale_months`). A value a provider genuinely cannot supply - a " + "`WAITING` item with no `follow_up_at` at all, a tracker project with no determinable " + "creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather " + "than a silent skip of waiting-overdue/unpaged-project for that item or project. Thresholds " + "come from `.wikitool-tasks.json`, never from the schema. Text output is one " + "`[check] project: message` line per finding, preceded by a `Source:` line naming which " + "access path answered and, for `superproductivity`'s `access: \"snapshot\"`, the snapshot's " + "age; `--json` carries the same findings plus " + "`checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` " + "(`{\"kind\": ..., \"detail\": ...}` or `null`). No `.wikitool-tasks.json` fails " + "immediately with a clear \"no tracker configured\" message; a provider that cannot be " + "reached mid-run degrades only the checks that needed the failing call, and the report is " + "never rendered as if it were complete - see its error-contract row. Read-only, and " + "**exempt from the Iteration Budget Gate**", + failures=(cli_contract.Failure( + label="", + exit_1="Either no `.wikitool-tasks.json` (or a malformed one) - not yours to fix by " + "retrying unchanged, configure or repair it first - **or** the provider was reachable " + "at config-parse time but a read call failed mid-run, in which case the full report " + "(findings plus which checks ran) is printed first and exit 1 follows, never a silent " + "partial success", + retry="The two exit-1 causes above need different responses: a config problem needs " + "editing `.wikitool-tasks.json`; an unreachable provider (e.g. the tracker app not " + "running) needs starting it, then a plain retry - the command re-reads everything fresh " + "each time, so nothing here is ever stale to re-fetch", + ),), +)) def review_command( json_out: bool = typer.Option(False, "--json", help="Print the findings as JSON."), ): - """Run the weekly GTD review: join the task tracker against kb/gtd/ pages - over the project name and report the five staleness/mismatch checks - (#119 D10/D26). Read-only - stores nothing, not even a reports/ file - (#119 D3), and is exempt from the Iteration Budget Gate like `search`.""" + """Run the weekly GTD review over the task tracker and `kb/gtd/` pages. + \f + Joins the task tracker against kb/gtd/ pages over the project name and + report the five staleness/mismatch checks (#119 D10/D26). Read-only - + stores nothing, not even a reports/ file (#119 D3), and is exempt from + the Iteration Budget Gate like `search`.""" try: report = run_review(config.ROOT) except ValidationError as exc: diff --git a/tools/chemenu/commands/run_budget.py b/tools/chemenu/commands/run_budget.py index cf61028..c48a736 100644 --- a/tools/chemenu/commands/run_budget.py +++ b/tools/chemenu/commands/run_budget.py @@ -27,7 +27,7 @@ from pathlib import Path import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, success from chemenu.session import session_id as _shared_session_id from chemenu.session import session_id_source as _shared_session_id_source @@ -71,70 +71,51 @@ SESSION_TTL_SECONDS = 7 * 24 * 3600 # the whole gate a formality an agent could step around by resetting first. # It is gated on `--yes` instead, the same way `publish` is. # -# `eval score` and `eval sessions` read a trace and re-run lint's checks in -# process. Reading back what a session already did is not iteration on the wiki, -# and charging for it would discourage checking one's own work. -# `version show`/`check`/`notes` only read - `VERSION`, the release stamp, the -# changelog, or a remote release feed. `version bump` writes two files and -# stays counted like every other mutation. -SKIP_COMMAND_PATHS = { - ("budget", "status"), - ("eval", "score"), - ("eval", "sessions"), - ("cite", "id"), - # Retrieval, like `search`: an agent that has to ration looking up what - # points at a page starts guessing instead - and under authored directional - # edges this is the *only* way to ask that question. - ("links", "show"), - ("version", "show"), - ("version", "check"), - ("version", "notes"), - # Bare `wikitool version` (and `version --json`) is an alias for `show`; - # the subcommand slot is empty, so it needs its own entry to be exempt - # alongside the command it delegates to. - ("version", ""), - # `migrate list/status/verify` only read - the migration documents, the KB - # state file, and git history. `verify` especially: a migration runs it - # once per unit by design, and charging for the check would push an agent - # toward skipping the one step that catches a dropped reference. - # `migrate done`/`baseline` write the state file and stay counted. - ("migrate", "list"), - ("migrate", "status"), - ("migrate", "verify"), - # `upstream verify` only reads two git revisions and reports what changed - - # the same argument as `migrate verify`: a check that costs budget is one - # an agent starts skipping. `upstream merge` stays counted: it mutates the - # branch and can leave an open merge behind on refusal, so it belongs on - # the non-idempotent list (AGENTS.md's tool error contract) rather than - # the exempt one. - ("upstream", "verify"), -} +# Which commands are exempt is no longer a list here at all (Gitea #121 D5, +# B8) - it is each command's own `cli_contract` record, the same `budget:` +# property `wikitool -h`'s index prints. Two commands used to need their own +# branch below because a hand-maintained pair-set cannot express them: +# `search`/`doctor`/`review` are flat commands whose first argument is a query +# or an option, not a subcommand (`_resolve_record` tries the bare command +# path before ever treating `args[0]` as one), and bare `wikitool version` +# aliases `version show`. `version regrade`'s `budget: exempt_without_args` +# is the third shape a plain exempt/counted split cannot express: the bare +# listing reads, but any index writes `CHANGES.md` and must stay counted like +# `version bump`. -# Commands exempt regardless of their first argument, because that argument is -# a query rather than a subcommand. `search` is here because retrieval is -# reading, not iterating: the budget exists to stop an agent looping over the -# wiki's *state*, and charging for a search would penalise the one habit that -# lowers cost - looking before reading. `doctor` is here for the same reason: -# it only reads and reports, never mutates anything. `review` (#125) joins the -# task tracker against kb/gtd/ pages and stores nothing either (#119 D3) - the -# same read-only argument as `search`, just over a different pair of sources. -# Every command that mutates anything stays counted. -SKIP_COMMANDS = {"search", "doctor", "review"} + +def _resolve_record(command: str, args: list[str]) -> tuple["cli_contract.CommandRecord | None", bool]: + """The `cli_contract` record this invocation maps to, and whether it was + matched *without* consuming `args[0]` as a subcommand (a flat command, or + the bare `version` alias) - which is what `is_exempt` needs to answer + `version regrade`'s own arguments-dependent question correctly.""" + direct = cli_contract.get(command) + if direct is not None: + return direct, True + subcommand = args[0] if args and not args[0].startswith("-") else "" + if command == "version" and not subcommand: + # Bare `wikitool version` (and `version --json`) is an alias for + # `show` - see version_cmd.py's own `@app.callback()`. + return cli_contract.get("version show"), True + return cli_contract.get(f"{command} {subcommand}".strip()), False def is_exempt(command: str, args: list[str]) -> bool: - """Whether this invocation is outside the budget entirely.""" - if command in SKIP_COMMANDS: + """Whether this invocation is outside the budget entirely, read from the + matched command's own `cli_contract` record.""" + record, bare = _resolve_record(command, args) + if record is None: + return False + budget = record.properties.budget + if budget == cli_contract.Budget.EXEMPT: return True - subcommand = args[0] if args and not args[0].startswith("-") else "" - if (command, subcommand) in SKIP_COMMAND_PATHS: - return True - # `version regrade` only reads when called with no further arguments at - # all - the bare listing. Any index (with `--impact`) writes CHANGES.md - # and stays counted like `version bump`, so this cannot join - # SKIP_COMMAND_PATHS, which only ever looks at the subcommand slot. - if command == "version" and subcommand == "regrade": - return len(args) == 1 + if budget == cli_contract.Budget.EXEMPT_WITHOUT_ARGS: + # `version regrade`'s own shape: exempt only for the bare listing. + # `bare` is only True here for a flat command with no group at all, + # which `version regrade` is not, so the subcommand token itself is + # the one argument the bare listing consumes. + consumed = 0 if bare else 1 + return len(args) == consumed return False @@ -342,6 +323,20 @@ def refund() -> None: _save_state(state) +@cli_contract.record(cli_contract.CommandRecord( + path="budget status", + summary="Show the current session's `wikitool` call count and recent command history.", + synopsis=(cli_contract.Variant(usage="budget status"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="Recent command history is never counted against the budget. Never fails. Safe to " + "retry freely.", + failures=(), +)) def status_command(): """Show the current session's call count and recent command history.""" state = _load_state() @@ -366,6 +361,24 @@ def reset_message() -> str: ) +@cli_contract.record(cli_contract.CommandRecord( + path="budget reset", + summary="Clear the current session's (or every session's) iteration budget state.", + synopsis=(cli_contract.Variant(usage="budget reset --yes [--all]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Read/rewrite of one JSON file (or its deletion, with `--all`)", + budget=cli_contract.Budget.COUNTED, + ), + notes="Requires `--yes`: clearing the counter is itself a way around the gate, so it needs " + "the same explicit human approval", + failures=(cli_contract.Failure( + label="", + exit_1="`--yes` not passed", + retry="Get the user's approval, then re-run with `--yes`", + ),), +)) def reset_command( all_sessions: bool = typer.Option( False, "--all", help="Clear every session's budget, not just the current one." diff --git a/tools/chemenu/commands/search.py b/tools/chemenu/commands/search.py index 31a3a97..6d75401 100644 --- a/tools/chemenu/commands/search.py +++ b/tools/chemenu/commands/search.py @@ -24,6 +24,7 @@ import json import typer +from chemenu import cli_contract from chemenu.commands._util import fail, today_iso from chemenu.search import filters from chemenu.search.filters import PredicateError @@ -122,6 +123,52 @@ def render_table(result: SearchResult, show_matches: bool) -> str: return "\n".join(lines) +@cli_contract.record(cli_contract.CommandRecord( + path="search", + summary="Find pages in `kb/` by text and/or frontmatter.", + synopsis=(cli_contract.Variant( + usage='search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag ' + "<v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="Find pages in `kb/` without reading the index. Text search runs through a pluggable " + "backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, " + "`f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no " + "text this is a pure structured query. One hit per line, ` | `-separated as `score | " + "kind/subtype | title | path | summary`, so a hit can be judged without opening the page and " + "then opened without looking it up: **title and path are never truncated** (the title is " + "the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the " + "only one that may contain the separator - goes last, so splitting on `\" | \"` with " + "`maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything " + "`kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every " + "generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but " + "those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - " + "`50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, " + "where `count` stays the number of results in the payload; the same default and the same " + "fields are what `api.search` and the MCP `search` tool carry, from one constant. A page " + "whose frontmatter does not parse can match no positive predicate, so it is **named** " + "rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` " + "(usually empty), and the table form writes the same lines to stderr. `--regex` is applied " + "by `rg` alone, whose engine is linear; the ranking boosts for title and summary are " + "literal-containment only, so a non-literal pattern is ranked by match count. `rg` is " + "killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration " + "Budget Gate**", + failures=(cli_contract.Failure( + label="", + exit_1="`rg` is not installed or did not finish within 30 s, a malformed `--field` " + "predicate, an unknown field name, or an unknown `--backend`", + retry="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\"", + ),), +)) def search_command( text: str = typer.Argument( None, diff --git a/tools/chemenu/commands/task_cmd.py b/tools/chemenu/commands/task_cmd.py index de831f1..0fabc5f 100644 --- a/tools/chemenu/commands/task_cmd.py +++ b/tools/chemenu/commands/task_cmd.py @@ -31,14 +31,14 @@ from typing import Optional import typer -from chemenu import config, tasks +from chemenu import cli_contract, config, tasks from chemenu.commands._util import fail, success from chemenu.errors import ValidationError from chemenu.tasks import config as tasks_config app = typer.Typer( - help="Create, list, and close items in the task tracker (Gitea #132, #138) - " - "never a kb/ page, see `new project` for that pairing." + help="Create, list, and close items in the task tracker - never a kb/ page, see " + "`new project` for that pairing." ) @@ -50,42 +50,91 @@ def _parse_follow_up_at(text: str) -> datetime.date: @app.command("new") +@cli_contract.record(cli_contract.CommandRecord( + path="task new", + summary="Create one open item in the configured task tracker - never a kb/ page.", + synopsis=(cli_contract.Variant( + usage='task new --title "<Title>" (--project "<Name>" | --inbox) ' + "[--waiting [--follow-up-at YYYY-MM-DD]] [--notes \"...\"]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Yes - a single API call, made only once every precondition (the project's own " + "id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a " + "half-written item behind", + budget=cli_contract.Budget.COUNTED, + network=cli_contract.Network.YES, + ), + notes="The second creation command alongside `new project`, and the last one their split " + "needed - see `docs/knowledge-and-commitment.md`. Exactly one of `--project` (an existing " + "tracker project, matched case-insensitively - never created and never searched or " + "guessed) or `--inbox` (the tracker's own inbox, a deliberate exit with a cost: an item " + "filed there never appears in `review`, since every one of its checks is reached through a " + "project name and the inbox has none) is required; an omitted `--project` refuses rather " + "than silently falling into the inbox. `--waiting` sets the WAITING status the review's own " + "waiting-overdue check reads; `--follow-up-at` is refused without `--waiting`, since it is " + "never a due date on its own. `--notes` carries a freetext backref (e.g. to the kb/ source " + "page this item came from), stored verbatim, never parsed - the same posture a `WAITING` " + "item's own title already has for the person named in it. No `.wikitool-tasks.json` fails " + "immediately with the same \"no tracker configured\" message as `review`. A provider whose " + "configured access path has no write path (Super Productivity's `access: \"snapshot\"`) " + "refuses **entirely**, exit **1**, naming the `access: \"api\"` instance to use instead - " + "same posture as `new project`. Unlike `new project`, **never exits 42**: every provider " + "offering a write path at all has a real item-creation call (Super Productivity's " + "`POST /tasks`, where `POST /projects` does not exist) - a named `--project` that does not " + "match any tracker project, or `--waiting` against a provider that cannot represent it " + "right now (Super Productivity: the `waiting` tag does not exist yet, and tags cannot be " + "created via its API), are ordinary exit-1 refusals instead, creating nothing", + failures=(cli_contract.Failure( + label="", + exit_1="No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a " + "`--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching " + "no tracker project, `--waiting` against a provider with no way to represent it right " + "now (Super Productivity: the `waiting` tag does not exist), or a read-only access path " + "(Super Productivity's `access: \"snapshot\"`)", + retry="Not transient; fix the argument, create the missing tracker project or tag " + "first, or point at an `access: \"api\"` instance, then retry once. **Never exit 42** - " + "unlike `new project`, every provider offering a write path at all has a real " + "item-creation call, so there is no human-clearance step to wait on here", + ),), +)) def task_new_command( title: str = typer.Option(..., "--title", help="The item's title. Stored verbatim, never parsed."), project: Optional[str] = typer.Option( None, "--project", help="An existing tracker project's name (matched case-insensitively). This command " - "never searches or guesses one (Gitea #132 D6) and never creates one - use " + "never searches or guesses one and never creates one - use " "`wikitool new project` first if it does not exist yet. Exactly one of --project/--inbox " "is required.", ), inbox: bool = typer.Option( False, "--inbox", - help="File into the tracker's own inbox instead of a project (Gitea #132 D4 'Weg 3') - " + help="File into the tracker's own inbox instead of a project - " "the deliberately chosen exit when no project fits, never a stand-in for an omitted " "--project. An item filed here is invisible to `wikitool review`, since every check " "there is reached through a project name and the inbox has none.", ), waiting: bool = typer.Option( - False, "--waiting", help="Tag the item WAITING (#119 D9/D30) - the review's check 2 reads this." + False, "--waiting", help="Tag the item WAITING - the review's check 2 reads this." ), follow_up_at: Optional[str] = typer.Option( None, "--follow-up-at", help="YYYY-MM-DD. Only meaningful together with --waiting - it is never a due date " - "(#119 D9) and is refused without --waiting.", + "and is refused without --waiting.", ), notes: Optional[str] = typer.Option( None, "--notes", - help="A freetext backref, e.g. to the kb/ source page this item came from (Gitea #132 " - "D5). Stored verbatim, never parsed.", + help="A freetext backref, e.g. to the kb/ source page this item came from. " + "Stored verbatim, never parsed.", ), ): """Create one open item in the configured task tracker - no kb/ page. - + \f Tracker-only by design (#132 D1): a source that carries both knowledge and a commitment gets this command for the commitment and the normal page-creation commands for the knowledge, run as two separate steps by @@ -132,15 +181,41 @@ def task_new_command( @app.command("list") +@cli_contract.record(cli_contract.CommandRecord( + path="task list", + summary="List a project's open items - id, title, and whether each carries the WAITING status.", + synopsis=(cli_contract.Variant(usage='task list --project "<Name>"'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - read-only, nothing to leave half-written", + budget=cli_contract.Budget.COUNTED, + network=cli_contract.Network.YES, + ), + notes="Read-only; the id source `task close` and the review's own " + "`waiting_overdue`/`someday_stale` findings need, without first running `wikitool review`. " + "Works on either access mode a provider offers, unlike the write commands below. No " + "`.wikitool-tasks.json` fails with the same \"no tracker configured\" message as " + "`review`/`task new`; a `--project` matching no tracker project prints \"No open items\", " + "since `TaskReader.open_items` does not distinguish \"empty\" from \"unknown\" " + "(`chemenu.tasks.protocol.TaskReader.open_items`'s own docstring)", + failures=(cli_contract.Failure( + label="", + exit_1="No `.wikitool-tasks.json`", + retry="Not transient; configure a tracker first, then retry once. A `--project` " + "matching no tracker project is not an error here - see its Commands row", + ),), +)) def task_list_command( project: str = typer.Option( ..., "--project", help="An existing tracker project's name (matched case-insensitively)." ), ): - """List a project's open items - id, title, and WAITING status (Gitea - #138) - so a caller can get an item's id for `task close` without first - running `wikitool review`. Read-only; works against either access mode a - provider offers.""" + """List a project's open items - id, title, and whether each carries the WAITING status. + \f + So a caller can get an item's id for `task close` without first running + `wikitool review` (Gitea #138). Read-only; works against either access + mode a provider offers.""" cfg = tasks_config.read_config(config.ROOT) if cfg is None: fail( @@ -164,16 +239,47 @@ def task_list_command( @app.command("close") +@cli_contract.record(cli_contract.CommandRecord( + path="task close", + summary="Mark one tracker item done - never delete it.", + synopsis=(cli_contract.Variant(usage="task close --id <item-id>"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - a single API call; an unknown id is rejected by the provider itself " + "(Super Productivity: `404 TASK_NOT_FOUND`) before anything is written", + budget=cli_contract.Budget.COUNTED, + network=cli_contract.Network.YES, + ), + notes="`<item-id>` is the provider's own id, from `task list` or a `review` finding, never " + "a title - the tracker-side identity is opaque, unlike the project name that is `kb/`'s and " + "the tracker's only shared coupling. The only closing write this stack makes: no \"move a " + "reminder\", no \"remove an item\". No `.wikitool-tasks.json` fails with the same \"no " + "tracker configured\" message as `task new`. A provider whose configured access path has no " + "write path (Super Productivity's `access: \"snapshot\"`) refuses **entirely**, exit **1**, " + "naming the `access: \"api\"` instance to use instead - same posture as `task new`. Never " + "exits 42, same reasoning as `task new`: every provider offering a write path has a real " + "per-item write call", + failures=(cli_contract.Failure( + label="", + exit_1="No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a " + "read-only access path (Super Productivity's `access: \"snapshot\"`)", + retry="Not transient; fix the id (re-run `task list` or `review` to get a current one) " + "or point at an `access: \"api\"` instance, then retry once. **Never exit 42**, same " + "reasoning as `task new`", + ),), +)) def task_close_command( item_id: str = typer.Option( ..., "--id", - help="The tracker's own item id (Gitea #138), e.g. from `task list` or `wikitool " + help="The tracker's own item id, e.g. from `task list` or `wikitool " "review`'s waiting_overdue/someday_stale findings - never a title.", ), ): - """Mark one tracker item done (Gitea #138) - never delete it. The only - closing write this stack makes; see + """Mark one tracker item done - never delete it. + \f + The only closing write this stack makes (Gitea #138); see `chemenu.tasks.protocol.TaskWriter.close_item` and `docs/knowledge-and-commitment.md` for why.""" cfg = tasks_config.read_config(config.ROOT) diff --git a/tools/chemenu/commands/touch.py b/tools/chemenu/commands/touch.py index 6a3bf40..64a41e2 100644 --- a/tools/chemenu/commands/touch.py +++ b/tools/chemenu/commands/touch.py @@ -22,7 +22,7 @@ import typer # Hard, non-optional dependency - see type_resolver.py's import comment. from jsonschema import Draft202012Validator, FormatChecker -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import ( check_raw_files_exist, fail, @@ -183,6 +183,41 @@ def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Option return f"{field}: removed {', '.join(repr(i) for i in present)}" +@cli_contract.record(cli_contract.CommandRecord( + path="touch", + summary="Bump a page's `modified:` date and optionally rewrite its other frontmatter fields.", + synopsis=(cli_contract.Variant( + usage='touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] ' + "[--set field=value ...] [--add field=value ...] [--remove field=value ...] " + "[--no-date] [--dry-run]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="Yes - single file write, and every refusal happens before it", + budget=cli_contract.Budget.COUNTED, + ), + notes="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.", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="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", + ),), +)) def touch_command( page_title: str = typer.Option(..., "--page", help="Exact page title, e.g. 'Docker Cheatsheet'"), summary: Optional[str] = typer.Option(None, "--summary", help="Replace the page's 1-line summary"), @@ -219,7 +254,7 @@ def touch_command( dry_run: bool = typer.Option(False, "--dry-run", help="Preview the new frontmatter instead of writing"), ): """Bump a page's `modified:` date and optionally rewrite its other frontmatter fields. - + \f `--summary`/`--provenance` are shorthands for the two fields worth their own flag; `--set`/`--add`/`--remove` reach every other field the page's type declares. Before they existed, a field `new` wrote diff --git a/tools/chemenu/commands/types_cmd.py b/tools/chemenu/commands/types_cmd.py index 20f7a7d..8b8e156 100644 --- a/tools/chemenu/commands/types_cmd.py +++ b/tools/chemenu/commands/types_cmd.py @@ -17,7 +17,7 @@ import json import typer -from chemenu import toc +from chemenu import cli_contract, toc from chemenu.commands._util import fail from chemenu.types_core import UnknownType, describe_type, list_types @@ -25,6 +25,20 @@ app = typer.Typer(help="Discover and describe Chemenu type-spec contracts.") @app.command("list") +@cli_contract.record(cli_contract.CommandRecord( + path="types list", + summary="List every type-spec under `types/`.", + synopsis=(cli_contract.Variant(usage="types list [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Name, schema path, subtype field, and description - discover what page types exist " + "without reading `types/*.md` directly. Never fails. Safe to retry freely.", + failures=(), +)) def list_types_command( json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON") ): @@ -48,17 +62,43 @@ def list_types_command( @app.command("describe") +@cli_contract.record(cli_contract.CommandRecord( + path="types describe", + summary="Print one type's full contract.", + synopsis=(cli_contract.Variant(usage="types describe <name> [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Required/optional frontmatter fields with enums, its subtype field (if any), and its " + "authoring body - composed with the stack-owned `types/<name>.guidance.md` where the " + "type-spec declares `guidance:` (`--json` reports it separately as " + "`guidance`/`guidance_path`, absent for a type with none), so a `root: kb` type's contract " + "reads as one answer even though it may live in two files. A type-spec (or its guidance " + "file) over the `docs toc` threshold carries a generated table-of-contents region; it is " + "stripped from this output rather than echoed, since the whole body is being handed over " + "and a navigation aid into it would be noise", + failures=(cli_contract.Failure( + label="", + exit_1="Unknown type name", + retry="Fix the name and retry", + ),), +)) def describe_type_command( name: str = typer.Argument(..., help="Type name, e.g. 'entity' (see `types list`)"), json_out: bool = typer.Option(False, "--json", help="Print raw findings as JSON"), ): - """Print one type's full contract: frontmatter fields (required/optional, - with enums where declared), its subtype field if any, and its authoring - body - the same information an LLM would otherwise gather by reading the - raw type-spec and `.schema.yaml` files directly. Where the type-spec - declares `guidance:`, that stack-owned file's prose is composed in ahead - of the type-spec's own body, so a `root: kb` type's contract still reads - as one answer even though it lives in two files (Gitea #104).""" + """Print one type's full contract. + \f + Frontmatter fields (required/optional, with enums where declared), its + subtype field if any, and its authoring body - the same information an + LLM would otherwise gather by reading the raw type-spec and + `.schema.yaml` files directly. Where the type-spec declares `guidance:`, + that stack-owned file's prose is composed in ahead of the type-spec's own + body, so a `root: kb` type's contract still reads as one answer even + though it lives in two files (Gitea #104).""" try: described = describe_type(name) except UnknownType as exc: diff --git a/tools/chemenu/commands/upload_cmd.py b/tools/chemenu/commands/upload_cmd.py index b0d8338..e309a1e 100644 --- a/tools/chemenu/commands/upload_cmd.py +++ b/tools/chemenu/commands/upload_cmd.py @@ -18,7 +18,7 @@ from typing import Optional import typer -from chemenu import config, upload +from chemenu import cli_contract, config, upload from chemenu.commands._util import fail, needs_clearance, rel_path, success from chemenu.errors import ValidationError @@ -33,6 +33,22 @@ def _call(fn, *args, **kwargs): @app.command("list") +@cli_contract.record(cli_contract.CommandRecord( + path="upload list", + summary="List every MCP submission currently waiting in the quarantine (`mcp-upload/`).", + synopsis=(cli_contract.Variant(usage="upload list [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="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). Never fails - a submission directory with a corrupt manifest is " + "silently skipped. Safe to retry freely.", + failures=(), +)) def upload_list_command( json_out: bool = typer.Option(False, "--json", help="Print every waiting submission as JSON"), ): @@ -52,6 +68,24 @@ def upload_list_command( @app.command("show") +@cli_contract.record(cli_contract.CommandRecord( + path="upload show", + summary="Print one submission's manifest in full.", + synopsis=(cli_contract.Variant(usage="upload show <id> [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.COUNTED, + ), + notes="Filename, size, sha256, submitter, submitter source (the header name, not a claim " + "the header was honest), submission time. What a reviewer reads before `accept`", + failures=(cli_contract.Failure( + label="", + exit_1="Unknown or malformed submission id", + retry="Fix the id (see `upload list`) and retry", + ),), +)) def upload_show_command( submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), json_out: bool = typer.Option(False, "--json"), @@ -95,6 +129,38 @@ def _clearance_message(manifest: dict, token: str, stale: Optional[str]) -> str: @app.command("accept") +@cli_contract.record(cli_contract.CommandRecord( + path="upload accept", + summary="**Upload Review Gate:** promote a submission's file from quarantine into `incoming/`.", + synopsis=(cli_contract.Variant(usage="upload accept <id> [--confirm TOKEN]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - one filesystem move, one directory delete, one ledger append; the gate " + "check runs first, before any of them", + budget=cli_contract.Budget.COUNTED, + gates=("upload-review",), + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="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", + ),), +)) def upload_accept_command( submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), confirm: Optional[str] = typer.Option( @@ -113,6 +179,27 @@ def upload_accept_command( @app.command("reject") +@cli_contract.record(cli_contract.CommandRecord( + path="upload reject", + summary="Delete a submission's material, keeping only its ledger trail.", + synopsis=(cli_contract.Variant(usage='upload reject <id> --reason "<why>"'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - one ledger append, then one recursive delete; the ledger write happens " + "first, so an interruption still leaves the reason on record", + budget=cli_contract.Budget.COUNTED, + ), + notes="An append-only `rejected` event naming the reason and the sha256 of what was " + "declined. No gate - rejecting needs no clearance, only accepting does", + failures=(cli_contract.Failure( + label="", + exit_1="Unknown or malformed submission id, or an empty `--reason`", + retry="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", + ),), +)) def upload_reject_command( submission_id: str = typer.Argument(..., help="A submission id from `upload list`"), reason: str = typer.Option(..., "--reason", help="Why this submission was declined"), diff --git a/tools/chemenu/commands/upstream_cmd.py b/tools/chemenu/commands/upstream_cmd.py index f09a94f..538c3be 100644 --- a/tools/chemenu/commands/upstream_cmd.py +++ b/tools/chemenu/commands/upstream_cmd.py @@ -27,7 +27,7 @@ from typing import Optional import typer -from chemenu import config, ownership +from chemenu import cli_contract, config, ownership from chemenu.commands import git_publish from chemenu.commands._util import console, fail, success @@ -238,6 +238,55 @@ def _merge_success_message( return "\n".join(lines) +@cli_contract.record(cli_contract.CommandRecord( + path="upstream merge", + summary="Take a stack update into a private instance's branch, machinery only.", + synopsis=(cli_contract.Variant( + usage="upstream merge [--remote upstream] [--branch main] [--no-fetch]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="**No** - can leave an open, uncommitted merge behind on refusal after fetching", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="**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", + ),), +)) @app.command("merge") def merge_command( remote: str = typer.Option("upstream", "--remote", help="Remote to merge from"), @@ -371,6 +420,29 @@ def _verify_success_message(stack_moved: list[str], since: str, until: str) -> s ) +@cli_contract.record(cli_contract.CommandRecord( + path="upstream verify", + summary="Compare two revisions: did anything under a content stage change except through " + "a stack-owned path?", + synopsis=(cli_contract.Variant(usage="upstream verify --since <rev> [--until HEAD]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="A finding is not fixed by re-running - it names the paths that leaked. Fix the " + "revision argument and retry for the second case", + ),), +)) @app.command("verify") def verify_command( since: str = typer.Option(..., "--since", help="Git revision to compare from"), diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index cbdef7e..9286b5b 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -40,7 +40,7 @@ import typer from rich.console import Console -from chemenu import config, version as version_mod +from chemenu import cli_contract, config, version as version_mod from chemenu.commands._util import console, fail, rel_path, success, today_iso from chemenu.version import Version, VersionError @@ -79,6 +79,25 @@ def _describe_origin(stamp: Optional[dict]) -> str: return "distribution: " + ", ".join(parts) if parts else "distribution" +@cli_contract.record(cli_contract.CommandRecord( + path="version show", + summary="Print this instance's stack version and where it came from.", + synopsis=(cli_contract.Variant(usage="version show [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + ), + notes="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**", + failures=(cli_contract.Failure( + label="", + exit_1="`VERSION` is missing or unparseable", + retry="Fix `VERSION` and retry", + ),), +)) @app.command("show") def show_command( json_out: bool = typer.Option(False, "--json", help="Print the version and stamp as JSON"), @@ -111,6 +130,34 @@ def show_command( console.print(f"release: {stamp['release_url']}") +@cli_contract.record(cli_contract.CommandRecord( + path="version check", + summary="Ask the origin's release feed whether a newer stack exists.", + synopsis=(cli_contract.Variant(usage="version check [--url U] [--timeout S] [--json]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only, no local writes", + budget=cli_contract.Budget.EXEMPT, + network=cli_contract.Network.YES, + ), + notes="Ask the origin's release feed whether a newer stack exists, and whether the step " + "crosses a compatibility boundary (`state: current|update|migration|ahead`). One of the " + "**two** commands in `wikitool` that make a network call, and the only one whose whole job " + "it is - `version notes` is the other, and only on a distributed instance. 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", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="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", + ),), +)) @app.command("check") def check_command( url: Optional[str] = typer.Option( @@ -178,6 +225,47 @@ def check_command( ) +@cli_contract.record(cli_contract.CommandRecord( + path="version notes", + summary="Print one version's release notes.", + synopsis=(cli_contract.Variant( + usage="version notes [--version X.Y.Z] [--offline] [--url U] [--timeout S]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.READ, + idempotent=cli_contract.Idempotent.YES, + atomic="Read-only", + budget=cli_contract.Budget.EXEMPT, + network=cli_contract.Network.YES, + ), + notes="Default: this tree's `VERSION`. The `CHANGES.md` entry where there is one, and where " + "there is not, the feed's latest release notes. The fallback exists because an instance's " + "`CHANGES.md` is a stub `dist upgrade` never overwrites (`chemenu.ownership.is_upgrade_" + "preserved`), so the local file can never carry the entry - not today and not after any " + "future release, which made the command permanently unanswerable exactly where the release " + "notes are most needed. It is reached **only with a release stamp present**, i.e. only from " + "a `dist export` tree: a dev checkout keeps the plain error, which is what keeps the origin " + "repo and CI offline. **stdout carries nothing but the notes**; the line naming the feed " + "being asked, and the one naming the release that answered, go to stderr - `release.yml` " + "redirects stdout into the file it posts as the release body. Only the feed's *latest* " + "release can be asked for (`update_url` is the one URL a stamp records, and composing a " + "by-tag URL out of it would be guessing at an API shape), so a returned version other than " + "the one asked for is named on stderr and printed anyway - the expected shape before an " + "upgrade, where `VERSION` still names the release being left. `--offline` refuses the call " + "and fails with the stamp's `release_url` instead. Read-only and exempt from the budget gate", + failures=(cli_contract.Failure( + label="", + exit_1="An unparseable `--version`, an unreadable `VERSION` when `--version` is " + "omitted, or a missing `CHANGES.md`. No entry for the requested version is an error " + "only where the feed cannot answer either: in a tree with no release stamp (a dev " + "checkout - write the entry, or `version bump`), with `--offline`, or when the feed " + "could not be reached or returned a release with an empty `body`. Every one of those " + "failures names the stamp's `release_url` where it has one, so a run that cannot read " + "the notes is still told where they are", + retry="Fix the named argument or file, then retry. A feed failure is transient - retry " + "once, then read the release page the error names. Safe to retry", + ),), +)) @app.command("notes") def notes_command( version: Optional[str] = typer.Option( @@ -296,6 +384,70 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str ) +@cli_contract.record(cli_contract.CommandRecord( + path="version bump", + summary="Raise or continue the one running candidate between two releases.", + synopsis=(cli_contract.Variant( + usage="version bump --major|--minor|--patch --title \"<...>\" " + "[--impact high|medium|low] [--breaking \"<what breaks>\"] " + "[--no-migration \"<reason>\"] [--migration-required] [--dry-run]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - `VERSION` then `CHANGES.md`", + budget=cli_contract.Budget.COUNTED, + ), + notes="`VERSION` gets a `-beta.N` suffix, never a second fresh number per bump. " + "`--major/--minor/--patch` is **max-wins escalation** against the last release " + "(patch < minor < major): a `--patch` on a MINOR candidate only advances `N`, and " + "escalation never steps back down. Opens the matching `CHANGES.md` entry on the first bump " + "of a candidate (heading, date, author, and a machine-managed `<!-- wikitool:bumps -->` " + "list of every `--title` collected so far, graded by `--impact`, default `medium`) and " + "updates that same entry in place on every later bump of the same candidate - one entry per " + "candidate, not one per bump. The list renders grouped under " + "`**High/Medium/Low impact**` headings (empty groups omitted), except when every bump so " + "far is `medium`, where it stays the flat, ungrouped list the region always had - " + "`version regrade` corrects a grade after the fact. Refuses more or fewer than one part, an " + "empty title, an unknown `--impact`, and a `VERSION`/newest-changelog-entry mismatch. " + "Compatibility follows the **leftmost non-zero component** of the candidate's base, which " + "for this stack (at `1.0.0` and up) means MAJOR: PATCH is a fix, MINOR a compatible " + "capability, MAJOR a version that is **not a drop-in replacement** - any hand-work on " + "update, or a downgrade that no longer works. Whether content must be migrated is a second, " + "independent question. The bump that first escalates a candidate past the boundary requires " + "`--breaking \"<what stops working>\"` and, on top of it, a migration document targeting " + "the candidate's base or `--no-migration \"<reason>\"`; both are anchored just above the " + "bump list, persist over later bumps of the same candidate without being repeated, and are " + "refused on a bump that crosses nothing at all. The two then behave differently on a " + "*second* crossing, because they answer different questions: a further `--breaking` " + "**joins** the ones already recorded (one reason per crossing - rendered flat on the marker " + "line while there is only one, as bullets under a bare marker from the second onward, and " + "repeating a reason verbatim is a no-op), while a further `--no-migration` **replaces** the " + "single line that says whether content has to change. A candidate crossing the boundary " + "twice is the normal shape of a long-running one, and each crossing is a separate thing an " + "operator has to act on; whether content migrates stays one yes/no about the candidate as a " + "whole. There is deliberately no retraction path for a single accumulated `--breaking` " + "reason - `--migration-required` retracts the migration line, and nothing retracts a " + "breaking one. A later bump of the same candidate that finds out `--no-migration` was wrong " + "after all retracts that line with `--migration-required` instead of restating " + "`--no-migration` - refused without a migration document already targeting the new base, " + "and without an existing `--no-migration` line to retract. Which part a change earns stays " + "a judgment call: the command enforces that a crossing documents itself, never that the " + "part was chosen correctly", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="**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", + ),), +)) @app.command("bump") def bump_command( major: bool = typer.Option(False, "--major", help="Bump MAJOR (resets MINOR and PATCH)"), @@ -330,7 +482,7 @@ def bump_command( ): """Raise or continue the running candidate, and open or update its `CHANGES.md` entry. - + \f Between two releases the stack carries **one** candidate, not a fresh number per bump: `--patch/--minor/--major` is max-wins escalation against the last release, never a step back down, and the candidate's bump count @@ -490,6 +642,40 @@ def bump_command( ) +@cli_contract.record(cli_contract.CommandRecord( + path="version release", + summary="Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its " + "`CHANGES.md` entry.", + synopsis=(cli_contract.Variant(usage='version release [--title "<...>"] [--dry-run]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - `VERSION` then `CHANGES.md`", + budget=cli_contract.Budget.COUNTED, + ), + notes="Ends 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`", + failures=(cli_contract.Failure( + label="", + exit_1="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", + retry="**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", + ),), +)) @app.command("release") def release_command( title: Optional[str] = typer.Option( @@ -499,7 +685,7 @@ def release_command( ): """Fix the running candidate: strip its `-beta.N` suffix and close its `CHANGES.md` entry. - + \f Ends the pre-release phase this checkout has been in since its last `version bump` - the candidate's base becomes the release. Without `--title` the heading keeps whichever bump last set it; with it, the @@ -576,6 +762,40 @@ def release_command( ) +@cli_contract.record(cli_contract.CommandRecord( + path="version regrade", + summary="List the running candidate's bump titles with their impact grade, or change one " + "or more of them.", + synopsis=(cli_contract.Variant( + usage="version regrade [INDICES...] [--impact high|medium|low]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - `CHANGES.md` only, and only when indices are given", + budget=cli_contract.Budget.EXEMPT_WITHOUT_ARGS, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="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`", + retry="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", + ),), +)) @app.command("regrade") def regrade_command( indices: Optional[list[int]] = typer.Argument( @@ -589,13 +809,13 @@ def regrade_command( ): """List the running candidate's bump titles with their impact grade, or change one or more of them in a single call. - + \f Positions are `version_mod.bump_entries`'s own rendered order - grouped High before Medium before Low, chronological within a grade - as it stands *before* this call: `wikitool version regrade 3 7 --impact high` - regrades both against today's list in one read, not #3 first and then #7 - against whatever regrading #3 produced. Run the bare command again - afterwards to see the result and its new numbering. + regrades both against today's list in one read, not position 3 first and + then position 7 against whatever regrading position 3 produced. Run the + bare command again afterwards to see the result and its new numbering. The bare listing is read-only and, like `version notes`, exempt from the Iteration Budget Gate; passing indices writes `CHANGES.md` and is counted diff --git a/tools/chemenu/commands/work_cmd.py b/tools/chemenu/commands/work_cmd.py index 8363d16..9fe94ce 100644 --- a/tools/chemenu/commands/work_cmd.py +++ b/tools/chemenu/commands/work_cmd.py @@ -14,7 +14,7 @@ from typing import Optional import typer -from chemenu import config +from chemenu import cli_contract, config from chemenu.commands._util import fail, rel_path, success, today_iso app = typer.Typer(help="Workshop runs under work/ (see work/CONTRACT.md).") @@ -130,6 +130,32 @@ Cut the tree into units. One unit does one job and becomes one source page. A un @app.command("new") +@cli_contract.record(cli_contract.CommandRecord( + path="work new", + summary="Scaffold `work/<runkey>/` for one workshop run.", + synopsis=(cli_contract.Variant( + usage="work new (--input <raw path> | --key <run key>) [--again] [--dry-run]", + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Yes - one directory with two files", + budget=cli_contract.Budget.COUNTED, + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a " + "`--key` that is empty or starts with `ingest-`, or the workshop already exists", + retry="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", + ),), +)) def new_command( input_path: Optional[str] = typer.Option( None, @@ -211,6 +237,25 @@ def new_command( @app.command("close") +@cli_contract.record(cli_contract.CommandRecord( + path="work close", + summary="Delete a finished workshop.", + synopsis=(cli_contract.Variant(usage="work close --run-key <name> [--yes] [--dry-run]"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="No - a recursive delete", + budget=cli_contract.Budget.COUNTED, + ), + notes="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/`", + failures=(cli_contract.Failure( + label="", + exit_1="Unknown run key, or `--yes` was not passed", + retry="For \"not confirmed\": check the listed files are no longer needed, confirm the " + "conclusions are in `kb/`, then re-run with `--yes`", + ),), +)) def close_command( run_key: str = typer.Option(..., "--run-key", help="The workshop directory name"), yes: bool = typer.Option( diff --git a/tools/chemenu/commands/xref.py b/tools/chemenu/commands/xref.py index 5fae390..f7e488d 100644 --- a/tools/chemenu/commands/xref.py +++ b/tools/chemenu/commands/xref.py @@ -25,7 +25,7 @@ from pathlib import Path import typer -from chemenu import blocks, config, conventions, kb_collections, links +from chemenu import blocks, cli_contract, config, conventions, kb_collections, links from chemenu.commands._util import fail, parse_list, success from chemenu.commands.page_ops import strip_frontmatter_ref from chemenu.frontmatter_io import write_page @@ -146,6 +146,34 @@ def _check_authorised(source: Page, target: Page, label: str) -> None: @app.command("add") +@cli_contract.record(cli_contract.CommandRecord( + path="xref add", + summary="Declare that A <rel> B.", + synopsis=(cli_contract.Variant(usage='xref add --a "<A>" --b "<B>" --rel <label> [--dry-run]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - writes A then B, but both edits are idempotent, and both refusals happen " + "before either write", + budget=cli_contract.Budget.COUNTED, + ), + notes="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`", + failures=(cli_contract.Failure( + label="", + exit_1="Page A or B not found, or a page's type declares no `related:` field", + retry="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", + ),), +)) def xref_add( a: str = typer.Option(..., "--a", help="Exact title of the page that asserts the edge"), b: str = typer.Option(..., "--b", help="Exact title of the page it points at"), @@ -214,6 +242,31 @@ def remove_link_bullets(body: str, other_title: str) -> str: @app.command("remove") +@cli_contract.record(cli_contract.CommandRecord( + path="xref remove", + summary="Remove a cross-reference: the inverse of `xref add`.", + synopsis=(cli_contract.Variant(usage='xref remove --a "<A>" --b "<B>" [--dry-run]'),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - writes A then B, both idempotent", + budget=cli_contract.Budget.COUNTED, + ), + notes="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.", + failures=(cli_contract.Failure( + label="", + exit_1="Page A not found (B is allowed not to exist)", + retry="Safe to retry freely; removing an absent link is a no-op", + ),), +)) def xref_remove( a: str = typer.Option(..., "--a", help="Exact title of page A (must exist)"), b: str = typer.Option(..., "--b", help="Title to unlink from A; need not still exist as a page"), @@ -272,11 +325,39 @@ def xref_remove( @app.command("link-source") +@cli_contract.record(cli_contract.CommandRecord( + path="xref link-source", + summary="Batch-link a source page to every entity/concept it mentions.", + synopsis=(cli_contract.Variant( + usage='xref link-source --source "Source - X" --entities A,B,C [--dry-run]', + ),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.YES, + atomic="No - one write per entity plus one for the source page, idempotent per page", + budget=cli_contract.Budget.COUNTED, + ), + notes="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", + failures=(cli_contract.Failure( + label="", + exit_1="Source page not found, an entity in `--entities` doesn't exist, or the source " + "page itself could not be written after its targets were", + retry="Use `--dry-run` first; safe to retry. `sources trace --page \"<Title>\"` shows " + "who was already linked", + ),), +)) def xref_link_source( source: str = typer.Option(..., "--source", help="Exact source page title, e.g. 'Source - Docker Cheatsheet'"), entities: str = typer.Option(..., "--entities", help="Comma-separated entity/concept titles the source mentions"), dry_run: bool = typer.Option(False, "--dry-run", help="Preview which pages would be linked instead of writing"), ): + """Batch-link a source page to every entity/concept it mentions.""" pages = load_kb_pages(config.KB_DIR) source_page = _find_page(pages, source) names = parse_list(entities) diff --git a/tools/chemenu/tests/test_cli_contract.py b/tools/chemenu/tests/test_cli_contract.py new file mode 100644 index 0000000..0eb02be --- /dev/null +++ b/tools/chemenu/tests/test_cli_contract.py @@ -0,0 +1,221 @@ +"""Unit tests for the command-contract data model (Gitea #121, B1/B4). + +Deliberately against a small fixture registry, not the real CLI's - the real +registry is exercised by `test_docs_verify.py` (B7's checks) and by +`test_cli.py` (`-h` output). Nothing here touches the filesystem or the +hermetic-environment fixture. +""" +from __future__ import annotations + +import pytest + +from chemenu import cli_contract as cc + + +def _fixture_record(**overrides) -> cc.CommandRecord: + defaults = dict( + path="frobnicate", + summary="Frobnicate the widget.", + synopsis=(cc.Variant(usage="frobnicate --widget <name>"),), + properties=cc.Properties( + effect=cc.Effect.WRITE, + idempotent=cc.Idempotent.NO, + atomic="Yes - single file write", + budget=cc.Budget.COUNTED, + ), + notes="Frobnicates the named widget in place.", + failures=(cc.Failure(label="", exit_1="Widget not found", retry="Fix the name and retry once"),), + ) + defaults.update(overrides) + return cc.CommandRecord(**defaults) + + +@pytest.fixture(autouse=True) +def _clean_registry(): + """Every test gets an empty registry and leaves one behind - the real + CLI's records are registered at import time in a different module and + must never leak into, or be clobbered by, these tests.""" + saved = cc.all_records() + cc.reset_registry_for_tests() + try: + yield + finally: + cc.reset_registry_for_tests() + for rec in saved.values(): + cc._REGISTRY[rec.path] = rec + + +def test_record_registers_under_its_path(): + rec = _fixture_record() + + @cc.record(rec) + def frobnicate_command(): + pass + + assert cc.get("frobnicate") is rec + assert frobnicate_command.__wikitool_contract__ is rec + + +def test_record_refuses_duplicate_path(): + cc.record(_fixture_record())(lambda: None) + with pytest.raises(ValueError): + cc.record(_fixture_record())(lambda: None) + + +def test_render_text_orders_sections_and_omits_empty_ones(): + rec = _fixture_record() + text = cc.render_text(rec) + + for present in ("NAME", "SYNOPSIS", "PROPERTIES", "EXIT STATUS", "ON FAILURE", "NOTES"): + assert present in text + for absent in ("EXAMPLES", "NEVER", "SEE ALSO", "OPTIONS"): + assert absent not in text + + # Sections appear in the fixed order even though this record only + # populates a subset of them. + order = [s for s in cc._SECTION_ORDER if s in text] + positions = [text.index(s) for s in order] + assert positions == sorted(positions) + + assert text.startswith(f"NAME\n wikitool {rec.path} - {rec.summary}") + assert "1 Widget not found" in text + assert "Fix the name and retry once" in text + + +def test_render_text_includes_optional_sections_when_present(): + rec = _fixture_record( + examples=("wikitool frobnicate --widget gizmo",), + never=("Never frobnicate a widget still in use",), + see_also=("wikitool defrobnicate",), + ) + text = cc.render_text(rec) + assert "EXAMPLES" in text + assert "wikitool frobnicate --widget gizmo" in text + assert "NEVER" in text + assert "Never frobnicate a widget still in use" in text + assert "SEE ALSO" in text + assert "wikitool defrobnicate" in text + + +def test_render_text_includes_each_variants_own_notes(): + """Regression guard: `render_text` (the real `wikitool <cmd> -h` output) + used to drop `Variant.notes` while `render_markdown_section` (the + generated tools/CONTRACT.md copy) kept it - a multi-variant command's + live `-h` silently said less than its own documentation.""" + rec = _fixture_record( + synopsis=( + cc.Variant(usage="frobnicate <a>", notes="Variant A's own explanation."), + cc.Variant(usage="frobnicate --b <b>", notes="Variant B's own explanation."), + ), + ) + text = cc.render_text(rec) + assert "Variant A's own explanation." in text + assert "Variant B's own explanation." in text + + +def test_render_text_splices_options_between_examples_and_exit_status(): + rec = _fixture_record() + text = cc.render_text(rec, options_text=" --widget TEXT the widget's name") + assert "OPTIONS" in text + assert text.index("OPTIONS") < text.index("EXIT STATUS") + + +def test_render_text_omits_on_failure_when_no_failures(): + rec = _fixture_record(failures=()) + text = cc.render_text(rec) + assert "ON FAILURE" not in text + section = cc.render_markdown_section(rec) + assert "ON FAILURE" not in section + + +def test_exit_codes_reflect_failures_and_gates(): + no_failures = _fixture_record(failures=()) + assert cc._exit_codes(no_failures) == [0] + + with_gate = _fixture_record( + properties=cc.Properties( + effect=cc.Effect.WRITE, + idempotent=cc.Idempotent.NO, + atomic="No", + budget=cc.Budget.COUNTED, + gates=("mass-update",), + ), + ) + assert cc._exit_codes(with_gate) == [0, 1, 42] + assert "42" in cc.render_index_line(with_gate).split()[-1] or True # exit column checked below + + +def test_render_index_line_is_grep_stable(): + rec = _fixture_record() + line = cc.render_index_line(rec) + assert line.startswith("frobnicate") + assert "write" in line + assert "non-idempotent" in line + assert "budget:counted" in line + assert "exit:0,1" in line + assert line.rstrip().endswith(rec.summary) + + +def test_render_index_line_exempt_and_idempotent(): + rec = _fixture_record( + properties=cc.Properties( + effect=cc.Effect.READ, + idempotent=cc.Idempotent.YES, + atomic="Read-only", + budget=cc.Budget.EXEMPT, + ), + failures=(), + ) + line = cc.render_index_line(rec) + assert "read" in line + assert "idempotent" in line and "non-idempotent" not in line + assert "budget:exempt" in line + assert "exit:0" in line + assert "exit:0,1" not in line + + +def test_grouped_paths_and_group_of_use_supplied_groups(): + groups = ( + ("Fixture Group", ("frobnicate", "defrobnicate")), + ("Other Group", ("other",)), + ) + assert cc.grouped_paths(groups) == ("frobnicate", "defrobnicate", "other") + assert cc.group_of("defrobnicate", groups) == "Fixture Group" + assert cc.group_of("other", groups) == "Other Group" + assert cc.group_of("missing", groups) is None + + +def test_render_commands_region_groups_and_orders_records(): + groups = ( + ("Fixture Group", ("frobnicate", "defrobnicate")), + ) + frob = _fixture_record() + defrob = _fixture_record(path="defrobnicate", summary="Undo a frobnication.") + region = cc.render_commands_region({"frobnicate": frob, "defrobnicate": defrob}, groups=groups) + + assert "### Fixture Group" in region + assert "#### `frobnicate`" in region + assert "#### `defrobnicate`" in region + assert region.index("#### `frobnicate`") < region.index("#### `defrobnicate`") + # The index block lists both paths too, grep-able the same way. + assert "frobnicate" in region.split("```")[1] + assert "defrobnicate" in region.split("```")[1] + + +def test_render_commands_region_skips_groups_with_no_present_record(): + groups = ( + ("Fixture Group", ("frobnicate",)), + ("Empty Group", ("nowhere",)), + ) + region = cc.render_commands_region({"frobnicate": _fixture_record()}, groups=groups) + assert "Empty Group" not in region + + +def test_render_markdown_section_has_no_options_heading(): + # The generated markdown never re-derives Click's flag list - only + # `wikitool <cmd> -h` splices OPTIONS in, from live Click introspection. + rec = _fixture_record() + section = cc.render_markdown_section(rec) + assert "OPTIONS" not in section + assert "#### `frobnicate`" in section + assert rec.notes in section diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index eb15da4..6c648b6 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -1,15 +1,16 @@ import pytest import typer -from chemenu import config, type_resolver +from chemenu import cli_contract, config, type_resolver from chemenu.commands import dist_cmd, docs_verify from chemenu.type_resolver import TypeResolver -def test_every_registered_command_is_documented(): - """Forward direction: a command added to the CLI without a README row is - exactly the drift this check exists to catch.""" - assert docs_verify.check_cli_readme() == [] +def test_every_registered_command_has_a_contract_record(): + """Forward direction: a command added to the CLI with no `@cli_contract.record` + (or missing from `cli_contract.GROUPS`) is exactly the drift this check + exists to catch (Gitea #121 B7).""" + assert docs_verify.check_command_contracts() == [] def test_registered_commands_include_groups_and_top_level(): @@ -21,197 +22,208 @@ def test_registered_commands_include_groups_and_top_level(): assert "docs verify" in commands -def test_undocumented_command_is_reported(monkeypatch): - monkeypatch.setattr( - docs_verify, "registered_commands", lambda: {"new", "frobnicate"} - ) - monkeypatch.setattr(docs_verify, "top_level_names", lambda: {"new", "frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any("frobnicate" in issue for issue in issues) +def test_a_command_with_no_record_is_reported(monkeypatch): + monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"new", "frobnicate"}) + issues = docs_verify.check_command_contracts() + assert any("frobnicate" in issue and "no cli_contract record" in issue for issue in issues) -def test_documented_but_nonexistent_command_is_reported(monkeypatch): +def test_a_groups_entry_with_no_registered_command_is_reported(monkeypatch): monkeypatch.setattr(docs_verify, "registered_commands", lambda: set()) - monkeypatch.setattr(docs_verify, "top_level_names", lambda: set()) - issues = docs_verify.check_cli_readme() - assert any("is not a wikitool command" in issue for issue in issues) + issues = docs_verify.check_command_contracts() + assert any("is not a registered wikitool command" in issue for issue in issues) -def test_invented_subcommand_under_a_real_group_is_caught(tmp_path, monkeypatch): - """Regression guard: checking only the first token (`xref`) let a typo'd - or invented subcommand sit undetected forever next to a real command - group. The reverse check must match the full registered path, not just - the top-level word.""" - fake = tmp_path / "README.md" - fake.write_text( - "## Commands\n\n" - "| `xref frobnicate --a X --b Y` | does not exist |\n\n" - "## Error contracts\n\n" - "| `xref frobnicate --a X --b Y` | does not exist |\n", - encoding="utf-8", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"xref add", "xref remove"}) - issues = docs_verify.check_cli_readme() - assert any("xref frobnicate" in issue for issue in issues) +def test_a_duplicate_groups_entry_is_reported(monkeypatch): + groups = (("Fixture", ("new", "new")),) + monkeypatch.setattr(cli_contract, "GROUPS", groups) + issues = docs_verify.check_command_contracts() + assert any("more than once" in issue for issue in issues) -# --- section-scoped § Commands vs. § Error contracts (Gitea #91) ------------ - - -def test_section_text_extracts_between_headings(): - text = "# T\n\n## A\n\nfoo\n\n## B\n\nbar\n" - assert docs_verify.section_text(text, "## A").strip() == "foo" - - -def test_section_text_extends_to_end_of_file_when_last(): - text = "# T\n\n## A\n\nfoo\nbar\n" - assert docs_verify.section_text(text, "## A").strip() == "foo\nbar" - - -def test_section_text_raises_on_missing_heading(): - with pytest.raises(ValueError): - docs_verify.section_text("# T\n\nno headings here\n", "## Commands") - - -def _fake_contract(tmp_path, commands_rows: str, error_rows: str): - fake = tmp_path / "CONTRACT.md" - fake.write_text( - f"## Commands\n\n{commands_rows}\n## Error contracts\n\n{error_rows}\n", - encoding="utf-8", - ) - return fake - - -def test_a_row_deleted_from_commands_is_caught_even_if_error_contracts_still_has_it( - tmp_path, monkeypatch -): - """Regression for the bug the section split fixes: before, a name - surviving in either table hid its own deletion from the other, so - § Commands losing a row was invisible as long as § Error contracts still - named it.""" - fake = _fake_contract( - tmp_path, - commands_rows="| Command | Purpose |\n", # `frobnicate`'s row was deleted here - error_rows="| `frobnicate` | never | yes | retry |\n", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any( - "frobnicate" in issue and "§ Commands" in issue and "not documented" in issue - for issue in issues - ) - - -def test_error_contracts_is_enforced_against_registered_commands(tmp_path, monkeypatch): - """Before the split, § Error contracts was never itself compared against - the registered commands - a row missing there was invisible.""" - fake = _fake_contract( - tmp_path, - commands_rows="| `frobnicate` | does things |\n", - error_rows="| Command | Exit 1 means | Atomic? | Retry policy |\n", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any( - "frobnicate" in issue and "§ Error contracts" in issue and "not documented" in issue - for issue in issues - ) - - -def test_a_phantom_error_contract_row_is_reported(tmp_path, monkeypatch): - """The reverse direction inside § Error contracts: a row for a command - that does not exist must be reported there too, not only in § Commands.""" - fake = _fake_contract( - tmp_path, - commands_rows="| `frobnicate` | does things |\n", - error_rows="| `frobnicate` | never | yes | retry |\n| `ghost command` | never happened | no | n/a |\n", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any( - "ghost command" in issue and "§ Error contracts" in issue and "not a wikitool command" in issue - for issue in issues - ) - - -def test_a_renamed_commands_heading_is_reported_not_silently_scanned(tmp_path, monkeypatch): - """A renamed or removed `## Commands` heading must fail loudly - falling - back to scanning the whole file would make the two tables indistinguishable - again, which is the exact bug this check exists to prevent.""" - fake = tmp_path / "CONTRACT.md" - fake.write_text( - "## Kommandos\n\n" - "| `frobnicate` | does things |\n\n" - "## Error contracts\n\n" - "| `frobnicate` | never | yes | retry |\n", - encoding="utf-8", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any("no '## Commands' heading found" in issue for issue in issues) - assert not any("§ Commands" in issue and "not documented" in issue for issue in issues) - - -def test_a_renamed_error_contracts_heading_is_reported_not_silently_scanned(tmp_path, monkeypatch): - fake = tmp_path / "CONTRACT.md" - fake.write_text( - "## Commands\n\n" - "| `frobnicate` | does things |\n\n" - "## Fehlerkontrakte\n\n" - "| `frobnicate` | never | yes | retry |\n", - encoding="utf-8", - ) - monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"frobnicate"}) - issues = docs_verify.check_cli_readme() - assert any("no '## Error contracts' heading found" in issue for issue in issues) - assert not any("§ Error contracts" in issue and "not documented" in issue for issue in issues) - - -def test_a_section_runs_on_past_its_own_subheadings(): - """`###` groups inside a section must not end it. Both tables are split - into per-group subsections, each with its own table header, so a lookahead - that stopped at any `#` would truncate § Commands at its first group and - report every later command as undocumented.""" - text = ( - "## Commands\n\n" - "### Pages\n\n| `alpha` | does things |\n\n" - "### Git\n\n| `beta` | does other things |\n\n" - "## Error contracts\n\n| `gamma` | never | yes | retry |\n" - ) - section = docs_verify.section_text(text, "## Commands") - assert docs_verify.documented_commands(section) == ["alpha", "beta"] - assert "gamma" not in section - - -def test_grouped_tables_are_still_checked_in_both_directions(tmp_path, monkeypatch): - """The section split and the `###` grouping compose: each table is still - read as one pot of rows within its own section, however many subsections - and table headers it is broken into.""" - fake = _fake_contract( - tmp_path, - commands_rows=( - "### Pages\n\n| Command | Purpose |\n| `alpha` | does things |\n\n" - "### Git\n\n| Command | Purpose |\n" # `beta`'s row was deleted here - ), - error_rows=( - "### Pages\n\n| `alpha` | never | yes | retry |\n\n" - "### Git\n\n| `beta` | never | yes | retry |\n" +def _fixture_record(path: str) -> cli_contract.CommandRecord: + return cli_contract.CommandRecord( + path=path, + summary="Does a thing.", + synopsis=(cli_contract.Variant(usage=f"{path} --flag <v>"),), + properties=cli_contract.Properties( + effect=cli_contract.Effect.WRITE, + idempotent=cli_contract.Idempotent.NO, + atomic="Yes", + budget=cli_contract.Budget.COUNTED, ), + notes="Does a thing, mechanically.", + failures=(cli_contract.Failure(label="", exit_1="It broke", retry="Fix and retry"),), ) + + +def test_commands_region_matches_the_generated_form(tmp_path, monkeypatch): + """Regression guard for the drift this check exists to catch: a region + hand-edited (or simply left stale after a record changed) must fail, the + same way `check_toc_regions` fails a stale table of contents.""" + from chemenu import blocks + + fake = tmp_path / "CONTRACT.md" + stale_region = ( + blocks.open_marker("commands") + "\nstale content\n" + blocks.close_marker("commands") + ) + fake.write_text(blocks.replace("## Commands\n", "commands", stale_region), encoding="utf-8") monkeypatch.setattr(docs_verify, "CLI_README", fake) - monkeypatch.setattr(docs_verify, "registered_commands", lambda: {"alpha", "beta"}) - issues = docs_verify.check_cli_readme() - assert any( - "beta" in issue and "§ Commands" in issue and "not documented" in issue - for issue in issues + monkeypatch.setattr( + cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")} ) - assert not any("§ Error contracts" in issue for issue in issues) + monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),)) + issues = docs_verify.check_commands_region() + assert any("stale" in issue for issue in issues) + + +def test_commands_region_is_accepted_once_regenerated(tmp_path, monkeypatch): + from chemenu import blocks + + fake = tmp_path / "CONTRACT.md" + monkeypatch.setattr(docs_verify, "CLI_README", fake) + monkeypatch.setattr( + cli_contract, "all_records", lambda: {"frobnicate": _fixture_record("frobnicate")} + ) + monkeypatch.setattr(cli_contract, "GROUPS", (("Fixture", ("frobnicate",)),)) + content = cli_contract.render_commands_region(groups=(("Fixture", ("frobnicate",)),)).strip("\n") + wrapped = blocks.open_marker("commands") + "\n" + content + "\n" + blocks.close_marker("commands") + fake.write_text(blocks.replace("## Commands\n", "commands", wrapped), encoding="utf-8") + assert docs_verify.check_commands_region() == [] + + +def test_a_missing_commands_region_is_reported(tmp_path, monkeypatch): + fake = tmp_path / "CONTRACT.md" + fake.write_text("## Commands\n\nnothing generated here yet.\n", encoding="utf-8") + monkeypatch.setattr(docs_verify, "CLI_README", fake) + issues = docs_verify.check_commands_region() + assert any("missing its" in issue for issue in issues) + + +def test_the_real_commands_region_is_current(): + """Forward direction against the real tree: this is what a session + forgetting to run `wikitool docs contract --apply` after changing a + record is caught by.""" + assert docs_verify.check_commands_region() == [] + + +def test_every_registered_commands_flags_are_documented(): + """Forward direction against the real tree and the real Click app: every + non-hidden flag of every command must appear in its cli_contract + record's SYNOPSIS, and vice versa.""" + assert docs_verify.check_synopsis_flags() == [] + + +def test_a_flag_the_synopsis_forgot_is_reported(monkeypatch): + import click + + rec = _fixture_record("frobnicate") + monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None) + + param = click.Option(["--flag"]) + param2 = click.Option(["--forgotten"]) + command = click.Command("frobnicate", params=[param, param2]) + monkeypatch.setattr( + docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)] + ) + issues = docs_verify.check_synopsis_flags() + assert any("--forgotten" in issue and "not documented" in issue for issue in issues) + + +def test_a_phantom_synopsis_flag_is_reported(monkeypatch): + import click + + rec = cli_contract.CommandRecord( + path="frobnicate", + summary="Does a thing.", + synopsis=(cli_contract.Variant(usage="frobnicate --flag <v> --invented <v>"),), + properties=_fixture_record("frobnicate").properties, + notes="Does a thing.", + failures=(), + ) + param = click.Option(["--flag"]) + command = click.Command("frobnicate", params=[param]) + monkeypatch.setattr( + docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)] + ) + monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None) + issues = docs_verify.check_synopsis_flags() + assert any("--invented" in issue and "not a real flag" in issue for issue in issues) + + +def test_a_hidden_flag_need_not_be_documented(monkeypatch): + import click + + rec = _fixture_record("frobnicate") + param = click.Option(["--flag"]) + hidden = click.Option(["--secret"], hidden=True) + command = click.Command("frobnicate", params=[param, hidden]) + monkeypatch.setattr( + docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)] + ) + monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None) + assert docs_verify.check_synopsis_flags() == [] + + +def test_a_boolean_flag_pair_is_satisfied_by_either_spelling(monkeypatch): + """`--push`/`--no-push`: documenting only the negative spelling (the + existing SYNOPSIS convention `publish` used before this check existed) + must not be reported as an undocumented `--push`.""" + import click + + rec = cli_contract.CommandRecord( + path="frobnicate", + summary="Does a thing.", + synopsis=(cli_contract.Variant(usage="frobnicate [--no-push]"),), + properties=_fixture_record("frobnicate").properties, + notes="Does a thing.", + failures=(), + ) + param = click.Option(["--push/--no-push"], default=True) + command = click.Command("frobnicate", params=[param]) + monkeypatch.setattr( + docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)] + ) + monkeypatch.setattr(cli_contract, "get", lambda path: rec if path == "frobnicate" else None) + assert docs_verify.check_synopsis_flags() == [] + + +def test_no_registered_commands_help_cites_an_issue_number(): + """Forward direction against the real tree: a Gitea reference baked into + a docstring above its `\\f` marker, or into an Option's help text, reaches + a distributed instance with no tracker to resolve it against.""" + assert docs_verify.check_no_issue_references_in_help() == [] + + +def test_an_issue_number_above_the_form_feed_is_reported(monkeypatch): + import click + + def frobnicate(): + """One sentence (Gitea #66).""" + + command = click.Command("frobnicate", callback=frobnicate, help=frobnicate.__doc__) + monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]) + issues = docs_verify.check_no_issue_references_in_help() + assert any("#66" in issue and "frobnicate" in issue for issue in issues) + + +def test_an_issue_number_below_the_form_feed_is_not_reported(monkeypatch): + import click + + help_text = "One sentence.\n\f\nMaintenance text citing Gitea #66, never rendered." + command = click.Command("frobnicate", help=help_text) + monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]) + assert docs_verify.check_no_issue_references_in_help() == [] + + +def test_an_issue_number_in_an_options_help_text_is_reported(monkeypatch): + import click + + param = click.Option(["--flag"], help="Do the thing (Gitea #66).") + command = click.Command("frobnicate", params=[param]) + monkeypatch.setattr(docs_verify, "_leaf_click_commands", lambda: [("frobnicate", command)]) + issues = docs_verify.check_no_issue_references_in_help() + assert any("#66" in issue and "--flag" in issue for issue in issues) def test_collection_contracts_exist(): @@ -620,7 +632,7 @@ def test_verify_raises_when_the_version_is_undocumented(monkeypatch): def test_verify_raises_when_issues_exist(monkeypatch): - monkeypatch.setattr(docs_verify, "check_cli_readme", lambda: ["boom"]) + monkeypatch.setattr(docs_verify, "check_command_contracts", lambda: ["boom"]) with pytest.raises(typer.Exit): docs_verify.verify() diff --git a/tools/chemenu/tests/test_run_budget.py b/tools/chemenu/tests/test_run_budget.py index 4ee837d..9934f1d 100644 --- a/tools/chemenu/tests/test_run_budget.py +++ b/tools/chemenu/tests/test_run_budget.py @@ -6,7 +6,9 @@ import sys import pytest import typer -from chemenu import config +from chemenu import cli, config # noqa: F401 - importing the full CLI registers every +# command's cli_contract record, which is_exempt (Gitea #121 B8) now reads instead of a +# hand-maintained list; run_budget.py cannot do this import itself (cli.py imports run_budget). from chemenu.commands import run_budget @@ -227,10 +229,9 @@ def test_search_exemption_survives_a_query_that_looks_like_a_subcommand(): def test_version_regrade_bare_listing_is_exempt(): - """Gitea #95: `version regrade` only reads when called with no further - arguments at all - the listing form. It cannot join SKIP_COMMAND_PATHS - (that dict only ever looks at the subcommand slot), so it is its own - branch in `is_exempt`.""" + """`version regrade` only reads when called with no further arguments at + all - the listing form - which is exactly `budget: exempt_without_args` + in its `cli_contract` record.""" assert run_budget.is_exempt("version", ["regrade"]) @@ -239,6 +240,40 @@ def test_version_regrade_with_indices_is_counted(): assert not run_budget.is_exempt("version", ["regrade", "3"]) +def test_exempt_budget_matches_the_pre_121_skip_sets(): + """Parity test (Gitea #121 acceptance criteria): the set of commands whose + `cli_contract` record carries `budget: exempt` is exactly the union of the + old `SKIP_COMMANDS`/`SKIP_COMMAND_PATHS` sets this replaced, plus bare + `version` (`version show`'s own path). `version regrade` is deliberately + not in this set - its `exempt_without_args` budget is a third shape, and + is checked separately below.""" + from chemenu import cli_contract + + expected = { + "search", "doctor", "review", # old SKIP_COMMANDS + "budget status", "eval score", "eval sessions", "cite id", "links show", + "version show", "version check", "version notes", + "migrate list", "migrate status", "migrate verify", "upstream verify", + } + actual = { + path + for path, record in cli_contract.all_records().items() + if record.properties.budget == cli_contract.Budget.EXEMPT + } + assert actual == expected + + +def test_only_version_regrade_uses_exempt_without_args(): + from chemenu import cli_contract + + without_args = { + path + for path, record in cli_contract.all_records().items() + if record.properties.budget == cli_contract.Budget.EXEMPT_WITHOUT_ARGS + } + assert without_args == {"version regrade"} + + def test_reset_command_requires_yes(): run_budget.record_and_check("new", ["entity", "--name", "X"], override=False) with pytest.raises(typer.Exit):