From fd0f60b2e750006afeef09f4754f4b8e5971e61b Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sat, 26 Sep 2026 08:25:41 +0200 Subject: [PATCH] tools: command records, Git group - NOTES as bullets, one exit line per cause, examples and prohibitions (#142) Files changed: - CHANGES.md - VERSION - tools/CONTRACT.md - tools/README.md - 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 --- CHANGES.md | 24 ++- VERSION | 2 +- tools/CONTRACT.md | 182 ++++++++++------ tools/README.md | 5 +- tools/chemenu/cli_contract.py | 151 +++++++++---- tools/chemenu/commands/cite_cmd.py | 8 +- tools/chemenu/commands/dist_cmd.py | 8 +- tools/chemenu/commands/docs_verify.py | 8 +- tools/chemenu/commands/doctor.py | 4 +- tools/chemenu/commands/eval_cmd.py | 4 +- tools/chemenu/commands/git_publish.py | 236 ++++++++++++++------- tools/chemenu/commands/index_build.py | 4 +- tools/chemenu/commands/instructions_cmd.py | 8 +- tools/chemenu/commands/links_cmd.py | 4 +- tools/chemenu/commands/lint.py | 4 +- tools/chemenu/commands/log_append.py | 4 +- tools/chemenu/commands/migrate_cmd.py | 16 +- tools/chemenu/commands/new_page.py | 8 +- tools/chemenu/commands/page_ops.py | 12 +- tools/chemenu/commands/provenance_cmd.py | 8 +- tools/chemenu/commands/raw_cmd.py | 8 +- tools/chemenu/commands/review_cmd.py | 4 +- tools/chemenu/commands/run_budget.py | 4 +- tools/chemenu/commands/search.py | 4 +- tools/chemenu/commands/task_cmd.py | 12 +- tools/chemenu/commands/touch.py | 4 +- tools/chemenu/commands/types_cmd.py | 4 +- tools/chemenu/commands/upload_cmd.py | 12 +- tools/chemenu/commands/upstream_cmd.py | 8 +- tools/chemenu/commands/version_cmd.py | 24 +-- tools/chemenu/commands/work_cmd.py | 8 +- tools/chemenu/commands/xref.py | 12 +- tools/chemenu/tests/test_cli_contract.py | 80 ++++++- tools/chemenu/tests/test_docs_verify.py | 2 +- 34 files changed, 601 insertions(+), 285 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 021627d..3012a4f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 7.1.0-beta.6 - 2026-09-26 - dist export no longer cuts the dist export record out of the shipped tools/CONTRACT.md +## 7.1.0-beta.7 - 2026-09-26 - Command records, Git group: NOTES as bullets, one exit line per cause, examples and prohibitions **Author:** Torben Nehmer @@ -75,6 +75,7 @@ concern - readable here, never shipped as something to parse. - version bump no longer points at version release in its output - stack-close: wait for CI through the authenticated Gitea connection, with timings and a give-up point - wikitool: usage lines name wikitool, and the -h acceptance checks become tests +- Command records, Git group: NOTES as bullets, one exit line per cause, examples and prohibitions ### CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them @@ -174,6 +175,27 @@ shipped copy against its source until the region became generated. The record no markers without spelling out the pair, and a new test pins that the export plan ships `tools/CONTRACT.md` byte-identical to the working tree. +### Command records, Git group: NOTES as bullets, one exit line per cause, examples and prohibitions + +First pass of the editorial rewrite of the command records, starting with `sync` and `publish`. +Text only - no command behaves differently. The record model grew what the rewrite needs: +`notes` takes a tuple of present-tense bullets (a plain string, the old form, still renders as +one paragraph until every group is done), and `Failure` is now one cause with its own reaction +and exit code (`cause`, `reaction`, `code` 0/1/42) instead of one lumped "exit 1 means"/"retry +policy" pair per command. EXIT STATUS lists one line per cause; ON FAILURE repeats the cause +next to its reaction so each line reads on its own; an explicit exit-42 cause replaces the +generic gate line, and a record declaring one without a gate is refused at import. The field +rename is mechanical across all records, so every not-yet-rewritten command's ON FAILURE line now +reads ` -> `. + +`sync` and `publish` now carry copyable EXAMPLES (including the re-run after exit 42), a NEVER +section (do not retry or force a failed git step, do not pass an unapproved token, do not edit +`.wikitool-remotes.json` past a refusal), SEE ALSO, and self-contained NOTES in place of "exactly +like `sync`". Sentences that only explained *why* left the records; each was already a comment +at the code that implements it. `publish`'s `atomic` property says "every gate" instead of +"both gates" - it has three, and all run before staging. How a record's prose is written is now +stated once, in the `CommandRecord` docstring, and `tools/README.md` points there. + --- ## 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 8d618bc..b576c90 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.1.0-beta.6 +7.1.0-beta.7 diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index 0cee91a..a6a960f 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -174,8 +174,8 @@ Scaffold a new wiki page of any type. **ON FAILURE** -- new : 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 ` - 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 +- new : Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist -> Not transient; fix the argument and retry once. Never hand-craft the page instead +- new project: Everything `new ` 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"`) -> A collision, a bad `--set`, or a read-only access path is not transient, same as `new ` - 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** @@ -204,7 +204,7 @@ Create one open item in the configured task tracker - never a kb/ page. **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 +- 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"`) -> 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** @@ -233,7 +233,7 @@ List a project's open items - id, title, and whether each carries the WAITING st **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 +- No `.wikitool-tasks.json` -> 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** @@ -262,7 +262,7 @@ Mark one tracker item done - never delete it. **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` +- No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) -> 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** @@ -291,7 +291,7 @@ Bump a page's `modified:` date and optionally rewrite its other frontmatter fiel **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 +- 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 -> 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** @@ -320,7 +320,7 @@ Rename a page, or repoint references that name a page that never existed. **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 +- Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` -> 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** @@ -349,7 +349,7 @@ Delete a page and mechanically de-link it from the rest of the wiki. **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 +- Page not found, **or** other pages still reference it and `--yes` was not passed -> 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** @@ -379,7 +379,7 @@ Move a page (or every misplaced page) to the directory its type-spec computes. **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 +- 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 -> 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** @@ -410,7 +410,7 @@ Declare that A B. **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 +- Page A or B not found, or a page's type declares no `related:` field -> 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** @@ -439,7 +439,7 @@ Remove a cross-reference: the inverse of `xref add`. **ON FAILURE** -- Safe to retry freely; removing an absent link is a no-op +- Page A not found (B is allowed not to exist) -> Safe to retry freely; removing an absent link is a no-op **NOTES** @@ -468,7 +468,7 @@ Batch-link a source page to every entity/concept it mentions. **ON FAILURE** -- Use `--dry-run` first; safe to retry. `sources trace --page ""` shows who was already linked +- Source page not found, an entity in `--entities` doesn't exist, or the source page itself could not be written after its targets were -> Use `--dry-run` first; safe to retry. `sources trace --page "<Title>"` shows who was already linked **NOTES** @@ -497,7 +497,7 @@ Show the edges out of and into a page. **ON FAILURE** -- Check the exact title with `search`; a wikilink target is not always the page's stem +- Page not found -> Check the exact title with `search`; a wikilink target is not always the page's stem **NOTES** @@ -550,7 +550,7 @@ Upsert a `[^cite-id]: [[Source - X]]` definition in a page's footnotes region. **ON FAILURE** -- Safe to retry; upserting the same (page, source, file) pair twice reuses the existing id and changes nothing the second time +- Page or source not found -> Safe to retry; upserting the same (page, source, file) pair twice reuses the existing id and changes nothing the second time **NOTES** @@ -579,7 +579,7 @@ Reconcile each page's footnotes region against its actual `[^id]` references. **ON FAILURE** -- Safe to retry freely. An undefined-reference report is not a failure - fix the reference (or run `cite add`) and re-run +- Neither or both of `--page`/`--all` given, or page not found -> Safe to retry freely. An undefined-reference report is not a failure - fix the reference (or run `cite add`) and re-run **NOTES** @@ -610,7 +610,7 @@ Regenerate the catalog from every page's frontmatter. **ON FAILURE** -- Safe to retry freely - the plan is always recomputed from the pages currently on disk, so a re-run converges +- Rare I/O error only -> Safe to retry freely - the plan is always recomputed from the pages currently on disk, so a re-run converges **NOTES** @@ -639,7 +639,7 @@ Append a formatted entry to `kb/log.md`. **ON FAILURE** -- **Not idempotent.** If the previous run's outcome is uncertain, check the tail of `kb/log.md` before retrying +- Invalid `--op` or unreadable `--body-file` -> **Not idempotent.** If the previous run's outcome is uncertain, check the tail of `kb/log.md` before retrying **NOTES** @@ -694,7 +694,7 @@ Run structural lint checks against kb/. **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" +- Only with `--fail-on-error`: hard findings exist -> 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** @@ -723,7 +723,7 @@ Find pages in `kb/` by text and/or frontmatter. **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" +- `rg` is not installed or did not finish within 30 s, a malformed `--field` predicate, an unknown field name, or an unknown `--backend` -> 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** @@ -752,7 +752,7 @@ The GTD weekly review. **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 +- 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 -> 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** @@ -807,7 +807,7 @@ Trace provenance in either direction: raw file, or page. **ON FAILURE** -- Fix the argument and retry +- 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 -> Fix the argument and retry **NOTES** @@ -836,7 +836,7 @@ Regenerate the `kb/provenance.md` reverse index. **ON FAILURE** -- Safe to retry freely +- Rare I/O error only -> Safe to retry freely **NOTES** @@ -869,8 +869,8 @@ Promote one or more files from `incoming/` into `raw/`. **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 +- 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 -> 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 -> 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** @@ -923,7 +923,7 @@ Print one submission's manifest in full. **ON FAILURE** -- Fix the id (see `upload list`) and retry +- Unknown or malformed submission id -> Fix the id (see `upload list`) and retry **NOTES** @@ -954,7 +954,7 @@ Filename, size, sha256, submitter, submitter source (the header name, not a clai **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 +- 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 -> 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** @@ -983,7 +983,7 @@ Delete a submission's material, keeping only its ledger trail. **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 +- Unknown or malformed submission id, or an empty `--reason` -> 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** @@ -1008,19 +1008,41 @@ Fetch `<remote>/<branch>` and bring the local branch up to date with it. - network: no - gates: rebase-review +**EXAMPLES** + +- `tools/wikitool sync` +- `tools/wikitool sync --confirm-rebase <token> # re-run after exit 42, once the user approved` + **EXIT STATUS** - 0 success -- 1 The automatic rebase hit a real conflict (git failed) -- 42 needs clearance - rebase-review (see AGENTS.md § Gates) +- 0 No remote configured, or the remote cannot be reached - reported and skipped, not a failure +- 1 The automatic rebase hit a real conflict (git failed); it is aborted cleanly +- 42 Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff **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 +- The automatic rebase hit a real conflict (git failed); it is aborted cleanly -> Do not retry and do not force - resolve the conflict manually, then re-run +- Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff -> Show the user the command's full output verbatim and stop. Once they have approved it, run the re-run line the output prints, which carries `--confirm-rebase <token>`. Without that token, or with a wrong, invented or superseded one, it exits 42 again with the current state + +**NEVER** + +- Never retry a conflict unchanged, and never force past it. +- Never pass a `--confirm-rebase` token the user has not seen and approved. **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` +- Fetches `<remote>/<branch>`, then: fast-forwards when only the remote moved; rebases the local commits on top when both sides moved but touched disjoint files; exits 42 (rebase-review gate) when both sides touched the same file. +- A refused call performs no rebase attempt and leaves the branch where it was. +- The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides; either one moving makes it stale. +- Makes no commit, no push, and no forced operation of any kind. +- Run it once at the start of a writing session. + +**SEE ALSO** + +- `wikitool publish` - runs the same reconcile before it commits and pushes +- `instructions/session-setup.md` - where a session runs `sync` +- `instructions/gates.md` - the gate procedure #### `publish` @@ -1034,24 +1056,64 @@ Reconcile with `<remote>/<branch>`, then stage all changes, commit, and push. - effect: write - idempotent: no -- atomic: No - sequential git operations, but both gates run before staging +- atomic: No - sequential git operations, but every gate runs before staging - budget: counted - network: no - gates: mass-update, publish-remote, rebase-review +**EXAMPLES** + +- `tools/wikitool publish --message "ingest: docker-cheatsheet"` +- `tools/wikitool publish --confirm <token> --message "ingest: docker-cheatsheet" # re-run after a Mass-Update exit 42, once the user approved` +- `tools/wikitool publish --confirm-rebase <token> --message "ingest: docker-cheatsheet" # re-run after a rebase-review exit 42, once the user approved` + **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) +- 1 git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase +- 1 The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case +- 1 `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error +- 1 `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list +- 42 Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset +- 42 Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff +- 42 Publish-Remote Gate: `.wikitool-remotes.json` exists and the push URL of `--remote` is not listed in it, or `--remote` resolves to no push URL **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 +- git failed - `git add`, `git commit`, `git push`, or the reconcile's automatic rebase -> Do not retry and do not force - report and ask the user. `publish` has already made its one retry of a rejected push itself, where a reconcile resolved the rejection +- The push target (`--branch`) is not the checked-out branch, or HEAD is detached; the unborn branch of a fresh `git init` is not this case -> Check out the branch you mean to publish, or pass `--branch <checked-out branch>`, then retry once +- `--yes`/`-y` was passed - the flag does not exist and fails with an explicit error -> Drop it. The Mass-Update Gate is cleared only with `--confirm <token>` from the gate's own refusal output +- `.wikitool-remotes.json` is unreadable or has no usable `allowed_push_urls` list -> Show the error to the user and stop - a malformed file is not permission, and fixing or deleting it is theirs to do +- Mass-Update Gate: `--threshold` (default 10) or more counted files would be committed, or the `--confirm` token does not match this changeset -> Show the user the command's full output verbatim and stop. Once they have approved it, run the re-run line the output prints, which carries `--confirm <token>`. Without that token, or with a wrong, invented or superseded one, it exits 42 again with the current state +- Rebase-review gate: `<remote>/<branch>` moved and both sides changed the same file; the output lists the upstream commits, the overlapping files and their diff -> Show the user the command's full output verbatim and stop. Once they have approved it, run the re-run line the output prints, which carries `--confirm-rebase <token>`. Without that token, or with a wrong, invented or superseded one, it exits 42 again with the current state +- Publish-Remote Gate: `.wikitool-remotes.json` exists and the push URL of `--remote` is not listed in it, or `--remote` resolves to no push URL -> Show the user the push URL it names and the allowed ones, and stop. This gate has no token and no flag: only the user resolves it, by adding the URL to that file + +**NEVER** + +- Never retry a failed git step unchanged, and never force (`--force`, `--force-with-lease`). +- Never pass a `--confirm` or `--confirm-rebase` token the user has not seen and approved. +- Never edit `.wikitool-remotes.json` to get past a Publish-Remote refusal - that is opening a gate on your own initiative. **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 +- Order: branch check and Publish-Remote Gate, then the reconcile with `<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. `--no-push` skips all but the Mass-Update Gate and the commit. +- Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, rebases the local commits on top when both sides moved but touched disjoint files, and exits 42 (rebase-review gate) when both sides touched the same file. A refused reconcile performs no rebase attempt. The `--confirm-rebase` token covers the exact upstream state and the set of files touched on both sides. +- The push target must be the checked-out branch; this is checked before anything is staged. The unborn branch of a fresh `git init -b main` counts as checked out, so the first publish of a new instance works; a real detached HEAD is refused. +- With nothing new to stage, a local commit the remote lacks is still pushed: one left behind by an earlier publish whose push failed, or every commit when the remote answers but does not have the branch yet (a new, empty remote repository). +- A remote that cannot be reached is not read as lacking the branch: on a clean tree `publish` reports "Nothing to commit" and attempts no push. +- A rejected push gets exactly one more reconcile-and-push; never more than one. +- Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at `--threshold` (default 10) or more, and prints a review report - a scale line (file count, total lines added/removed, status breakdown), attention notes where they apply (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. The gate is evaluated before anything is staged, so a refused publish leaves the working tree untouched. +- Never counted and never shown for approval, but committed like everything else: anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, `kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for both, by reason. +- The `--confirm` token covers each counted path, its contents and the publish target: a different file list or edited contents need a new clearance. +- Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. The URL is read with `git remote get-url --push`, so a repointed remote does not pass on its name. An absent file means unrestricted; a malformed one is an error, not permission. +- `--path` (repeatable) scopes the whole operation - gate count, staging and commit - to that subtree. +- After a successful commit or push whose changed files include `tools/`, `types/`, `instructions/`, `AGENTS.md` or a path ending in `CONTRACT.md`, prints one reminder line: 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`. It is not a gate: no exit code change, nothing to clear, and silent for an ordinary content publish. + +**SEE ALSO** + +- `wikitool sync` - the same reconcile on its own, without committing or pushing +- `instructions/gates.md` - the gate procedure +- `instructions/setup-instance.md` - the first publish of a new instance ### Workshop runs and session budget @@ -1078,7 +1140,7 @@ Scaffold `work/<runkey>/` for one workshop run. **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 +- Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a `--key` that is empty or starts with `ingest-`, or the workshop already exists -> 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** @@ -1107,7 +1169,7 @@ Delete a finished workshop. **ON FAILURE** -- For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` +- Unknown run key, or `--yes` was not passed -> For "not confirmed": check the listed files are no longer needed, confirm the conclusions are in `kb/`, then re-run with `--yes` **NOTES** @@ -1160,7 +1222,7 @@ Clear the current session's (or every session's) iteration budget state. **ON FAILURE** -- Get the user's approval, then re-run with `--yes` +- `--yes` not passed -> Get the user's approval, then re-run with `--yes` **NOTES** @@ -1215,7 +1277,7 @@ Print one type's full contract. **ON FAILURE** -- Fix the name and retry +- Unknown type name -> Fix the name and retry **NOTES** @@ -1244,7 +1306,7 @@ Publish every `instructions/<name>/SKILL.md` into the harness skill directories. **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 +- No skills found under `instructions/`, or a target directory is not a published skill (no `SKILL.md`) and `--force` was not passed -> Check whether the flagged target holds anything worth keeping, then re-run with `--force` if not; otherwise fix the named cause and retry **NOTES** @@ -1273,7 +1335,7 @@ Check the instruction layer. **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 +- 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 -> 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** @@ -1326,7 +1388,7 @@ Check the docs that mirror the code. **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 +- 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 -> 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** @@ -1355,7 +1417,7 @@ Create, refresh or remove the generated table-of-contents 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 +- Never fails on content: a file with no `##` heading, or one at or under the threshold, is simply left without a region -> 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** @@ -1434,7 +1496,7 @@ Score one traced 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 +- No trace exists for the named session -> 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** @@ -1465,7 +1527,7 @@ Write a contentless, distributable copy of this repo's machinery. **ON FAILURE** -- Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand +- Target exists and is not empty, is not a directory, or the tree has no readable `VERSION` -> Point `<target>` at an empty (or new) directory and retry. Never merge into a non-empty one by hand **NOTES** @@ -1494,7 +1556,7 @@ Apply a stack update `dist export` produced - the write half of `version check`. **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 +- 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 -> 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** @@ -1523,7 +1585,7 @@ Print this instance's stack version and where it came from. **ON FAILURE** -- Fix `VERSION` and retry +- `VERSION` is missing or unparseable -> Fix `VERSION` and retry **NOTES** @@ -1552,7 +1614,7 @@ Ask the origin's release feed whether a newer stack exists. **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 +- 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 -> 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** @@ -1581,7 +1643,7 @@ Print one version's release notes. **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 +- 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 -> 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** @@ -1610,7 +1672,7 @@ Raise or continue the one running candidate between two releases. **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 +- 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 -> **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** @@ -1639,7 +1701,7 @@ List the running candidate's bump titles with their impact grade, or change one **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 +- 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` -> 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** @@ -1668,7 +1730,7 @@ Fix the running candidate: strip `VERSION`'s `-beta.N` suffix and close its `CHA **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 +- 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 -> **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** @@ -1723,7 +1785,7 @@ Show the migrations this instance still owes, in the order they must run. **ON FAILURE** -- For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise +- `.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is unreadable -> For a missing declaration: run `migrate baseline <version>` once, then retry. Safe to retry freely otherwise **NOTES** @@ -1752,7 +1814,7 @@ Compare `kb/` against a git revision on the invariants a content migration must **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 +- Only with `--fail-on-error`: an invariant changed. Also exits 1 if `--from` is not a revision in this repository -> 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** @@ -1781,7 +1843,7 @@ Record one migration as applied, advancing `kb_version` in `.wikitool-kb.json`. **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 +- Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* version that is not the next link in the chain -> **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** @@ -1810,7 +1872,7 @@ Declare `kb_version` once, for an instance predating `.wikitool-kb.json`. **ON FAILURE** -- Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted +- Unparseable version, or a declaration already exists and `--force` was not passed -> Safe to re-run with the same version. If a declaration exists, it is almost always `migrate done` that was wanted **NOTES** @@ -1841,7 +1903,7 @@ Take a stack update into a private instance's branch, machinery only. **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 +- 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 -> **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** @@ -1870,7 +1932,7 @@ Compare two revisions: did anything under a content stage change except through **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 +- 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 -> 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** @@ -1901,7 +1963,7 @@ Check that this instance is correctly configured. **ON FAILURE** -- Each finding names its own fix command; re-run after applying it +- At least one check reported `FAIL` (a `WARN`, e.g. no remote or no `WIKITOOL_SESSION_ID`, does not exit 1) -> Each finding names its own fix command; re-run after applying it **NOTES** diff --git a/tools/README.md b/tools/README.md index 3b8f0d4..886d01d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -86,7 +86,10 @@ bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is. <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 + the record's shape, and the `CommandRecord` docstring for how its prose is + written (NOTES as present-tense bullets, one `Failure` per cause with its + reaction, copyable EXAMPLES, NEVER, SEE ALSO, and no "why" - that goes into + a comment next to the code). `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 diff --git a/tools/chemenu/cli_contract.py b/tools/chemenu/cli_contract.py index ddeb9e0..91a7276 100644 --- a/tools/chemenu/cli_contract.py +++ b/tools/chemenu/cli_contract.py @@ -9,10 +9,13 @@ 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. +Phase 1 (Gitea #121) filled every record mechanically and word-for-word from +the two tables `tools/CONTRACT.md` used to carry. Phase 2 (Gitea #142) +rewrites them group by group: NOTES into present-tense bullets, one +`Failure` per cause, EXAMPLES/NEVER/SEE ALSO filled, and "why" moved out to +a code comment where the behaviour is implemented. A sentence several +records carry verbatim lives here once (`token_gate_reaction`, ...), so the +output repeats it and the source does not. """ from __future__ import annotations @@ -63,15 +66,27 @@ class Variant: @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 + """One cause of a non-success exit, and what the caller does about it. + + EXIT STATUS renders one `<code> <cause>` line per entry, ON FAILURE one + `<cause> -> <reaction>` line - the cause is repeated on purpose, so each + ON FAILURE line reads on its own. `code` is 1 (validation error) or 42 + (a gate needs clearance; only on a command whose `Properties.gates` is + non-empty), or 0 for an outcome a caller could mistake for a failure but + that is not one (an unreachable remote reported and skipped). An empty + `reaction` renders the EXIT STATUS line only. + + `label` names the usage form a cause belongs to (`"new project"`) and is + rendered as a `<label>: ` prefix on both lines; empty when the command + has one form, or when the cause already says it.""" + cause: str + reaction: str + code: int = 1 + label: str = "" + + def __post_init__(self) -> None: + if self.code not in (0, 1, 42): + raise ValueError(f"cli_contract: Failure.code must be 0, 1 or 42, not {self.code}") @dataclass(frozen=True) @@ -87,18 +102,63 @@ class Properties: @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`.""" + `"xref add"`). Empty `examples`, `never` and `see_also` render as absent, + not empty - see `render_text`. + + How the prose is written - a record is read on its own, by an agent that + asked for exactly this command: + + - `notes`: one bullet per behaviour, present tense. What the command does, + not why it was built that way - a "why" goes into a comment where the + behaviour is implemented, and history into `CHANGES.md` or nowhere. + - `failures`: one entry per cause, each with its own reaction. A rule + ("do not retry", "show the output and stop") belongs in the reaction or + in `never`, never only in `notes`. + - `examples`: one to three copyable calls, the most common first; a gated + command also shows its re-run after exit 42. + - `never`: the prohibitions for the caller, one per line. + - `see_also`: related commands and the instruction that uses this one. + It is the only place another command may be named for context - a + behaviour this command shares with another is stated here in full, + not as "same as `X`". + + A plain `str` for `notes` is still accepted and renders as one paragraph: + the phase-1 form, kept only until every group has been rewritten into + bullets (Gitea #142).""" path: str summary: str synopsis: tuple[Variant, ...] properties: Properties - notes: str + notes: tuple[str, ...] | str failures: tuple[Failure, ...] examples: tuple[str, ...] = () never: tuple[str, ...] = () see_also: tuple[str, ...] = () + def __post_init__(self) -> None: + if not self.properties.gates and any(f.code == 42 for f in self.failures): + raise ValueError( + f"cli_contract: {self.path!r} lists an exit-42 cause but declares no gate" + ) + + +# --------------------------------------------------------------------------- +# Shared sentences - text more than one record carries word for word. +# --------------------------------------------------------------------------- + + +def token_gate_reaction(flag: str) -> str: + """The ON FAILURE reaction to an exit-42 gate that is cleared by a token + (`--confirm`, `--confirm-rebase`): every such gate prints its evidence and + the exact re-run line, and refuses a token that does not match the state + it was issued for.""" + return ( + "Show the user the command's full output verbatim and stop. Once they have approved " + f"it, run the re-run line the output prints, which carries `{flag} <token>`. Without " + "that token, or with a wrong, invented or superseded one, it exits 42 again with the " + "current state" + ) + # --------------------------------------------------------------------------- # Registry @@ -224,12 +284,22 @@ _SECTION_ORDER = ( def _exit_codes(rec: CommandRecord) -> list[int]: - codes = [0] - if rec.failures: - codes.append(1) + codes = {0} | {failure.code for failure in rec.failures} if rec.properties.gates: - codes.append(42) - return codes + codes.add(42) + return sorted(codes) + + +def _labelled(failure: Failure, text: str) -> str: + return f"{failure.label}: {text}" if failure.label else text + + +def _notes_lines(notes: tuple[str, ...] | str) -> list[str]: + """NOTES as rendered lines: one `- ` bullet per entry, or the phase-1 + paragraph unchanged.""" + if isinstance(notes, str): + return [notes] + return [f"- {note}" for note in notes] def _idempotent_text(idempotent: Idempotent) -> str: @@ -258,22 +328,24 @@ def render_properties_lines(props: Properties) -> list[str]: def render_exit_status_lines(rec: CommandRecord) -> list[str]: + """`0 success` first, then one line per `Failure` in code order (stable + within a code). A command with gates but no explicit exit-42 cause gets + one generic 42 line naming them.""" 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: + for failure in sorted(rec.failures, key=lambda f: f.code): + lines.append(f"{str(failure.code).ljust(4)} {_labelled(failure, failure.cause)}") + if rec.properties.gates and not any(f.code == 42 for f in rec.failures): 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 + return [ + _labelled(failure, f"{failure.cause} -> {failure.reaction}") + for failure in sorted(rec.failures, key=lambda f: f.code) + if failure.reaction + ] def render_text(rec: CommandRecord, options_text: str = "") -> str: @@ -281,8 +353,9 @@ def render_text(rec: CommandRecord, options_text: str = "") -> str: `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.""" + Empty sections (EXAMPLES/NEVER/SEE ALSO, ON FAILURE with no reaction to + give, 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}") @@ -306,15 +379,16 @@ def render_text(rec: CommandRecord, options_text: str = "") -> str: 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)) + on_failure = render_on_failure_lines(rec) + if on_failure: + failure_lines = "\n".join(f" {line}" for line in on_failure) 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}" + notes_lines = "\n".join(f" {line}" for line in _notes_lines(rec.notes)) blocks.append(f"NOTES\n{notes_lines}") if rec.see_also: @@ -388,10 +462,11 @@ def render_markdown_section(rec: CommandRecord) -> str: lines.append(f"- {line}") lines.append("") - if rec.failures: + on_failure = render_on_failure_lines(rec) + if on_failure: lines.append("**ON FAILURE**") lines.append("") - for line in render_on_failure_lines(rec): + for line in on_failure: lines.append(f"- {line}") lines.append("") @@ -404,7 +479,7 @@ def render_markdown_section(rec: CommandRecord) -> str: lines.append("**NOTES**") lines.append("") - lines.append(rec.notes) + lines.extend(_notes_lines(rec.notes)) lines.append("") if rec.see_also: diff --git a/tools/chemenu/commands/cite_cmd.py b/tools/chemenu/commands/cite_cmd.py index 5e96aa7..915383f 100644 --- a/tools/chemenu/commands/cite_cmd.py +++ b/tools/chemenu/commands/cite_cmd.py @@ -119,8 +119,8 @@ def upsert_citation(page: Page, source_title: str, qualifier: Optional[str]) -> "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 " + cause="Page or source not found", + reaction="Safe to retry; upserting the same (page, source, file) pair twice reuses the " "existing id and changes nothing the second time", ),), )) @@ -214,8 +214,8 @@ def sync_page(page: Page) -> tuple[str, bool, list[str], list[str]]: "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 " + cause="Neither or both of `--page`/`--all` given, or page not found", + reaction="Safe to retry freely. An undefined-reference report is not a failure - fix the " "reference (or run `cite add`) and re-run", ),), )) diff --git a/tools/chemenu/commands/dist_cmd.py b/tools/chemenu/commands/dist_cmd.py index c0d6905..99af6a8 100644 --- a/tools/chemenu/commands/dist_cmd.py +++ b/tools/chemenu/commands/dist_cmd.py @@ -608,9 +608,9 @@ def _write_plan(target: Path, plan: dict[str, PlannedFile]) -> None: "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 " + cause="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 " + reaction="Point `<target>` at an empty (or new) directory and retry. Never merge into a " "non-empty one by hand", ),), )) @@ -1014,14 +1014,14 @@ def _report_plan( "`INSTALL.md` § \"Version und Updates\"", failures=(cli_contract.Failure( label="", - exit_1="Missing local `VERSION`/`.wikitool-release.json`(`files`)/`.wikitool-kb.json`, " + cause="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 " + reaction="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 " diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index 5759d94..9e42061 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -1074,11 +1074,11 @@ def check_breaking_change_for_boundary() -> list[str]: "it now checks", failures=(cli_contract.Failure( label="", - exit_1="A command, contract, or type-form mismatch was found, a type-spec's own " + cause="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: " + reaction="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 " @@ -1157,9 +1157,9 @@ def verify(): "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 " + cause="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 " + reaction="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", ),), diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index 4a81cf0..d4e8e8f 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -656,9 +656,9 @@ def run_doctor() -> list[Check]: "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 " + cause="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", + reaction="Each finding names its own fix command; re-run after applying it", ),), )) def doctor_command( diff --git a/tools/chemenu/commands/eval_cmd.py b/tools/chemenu/commands/eval_cmd.py index fc61243..a8b036d 100644 --- a/tools/chemenu/commands/eval_cmd.py +++ b/tools/chemenu/commands/eval_cmd.py @@ -78,8 +78,8 @@ def sessions_command( "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 " + cause="No trace exists for the named session", + reaction="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", diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index 7c94610..208fed3 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -1012,24 +1012,48 @@ def apply_reconcile(outcome: ReconcileOutcome, remote: str, branch: str, command 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", - ),), + notes=( + "Fetches `<remote>/<branch>`, then: fast-forwards when only the remote moved; rebases " + "the local commits on top when both sides moved but touched disjoint files; exits 42 " + "(rebase-review gate) when both sides touched the same file.", + "A refused call performs no rebase attempt and leaves the branch where it was.", + "The `--confirm-rebase` token covers the exact upstream state and the set of files " + "touched on both sides; either one moving makes it stale.", + "Makes no commit, no push, and no forced operation of any kind.", + "Run it once at the start of a writing session.", + ), + failures=( + cli_contract.Failure( + cause="No remote configured, or the remote cannot be reached - reported and " + "skipped, not a failure", + reaction="", + code=0, + ), + cli_contract.Failure( + cause="The automatic rebase hit a real conflict (git failed); it is aborted cleanly", + reaction="Do not retry and do not force - resolve the conflict manually, then re-run", + ), + cli_contract.Failure( + cause="Rebase-review gate: `<remote>/<branch>` moved and both sides changed the " + "same file; the output lists the upstream commits, the overlapping files and their " + "diff", + reaction=cli_contract.token_gate_reaction("--confirm-rebase"), + code=42, + ), + ), + examples=( + "tools/wikitool sync", + "tools/wikitool sync --confirm-rebase <token> # re-run after exit 42, once the user approved", + ), + never=( + "Never retry a conflict unchanged, and never force past it.", + "Never pass a `--confirm-rebase` token the user has not seen and approved.", + ), + see_also=( + "`wikitool publish` - runs the same reconcile before it commits and pushes", + "`instructions/session-setup.md` - where a session runs `sync`", + "`instructions/gates.md` - the gate procedure", + ), )) def sync_command( remote: str = typer.Option("origin", "--remote", help="Git remote to reconcile against"), @@ -1063,71 +1087,123 @@ def sync_command( 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", + atomic="No - sequential git operations, but every gate runs 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", - ),), + notes=( + "Order: branch check and Publish-Remote Gate, then the reconcile with " + "`<remote>/<branch>`, then the Mass-Update Gate, then `git add -A`, commit and push. " + "`--no-push` skips all but the Mass-Update Gate and the commit.", + "Reconcile: fetches `<remote>/<branch>`, fast-forwards when only the remote moved, " + "rebases the local commits on top when both sides moved but touched disjoint files, " + "and exits 42 (rebase-review gate) when both sides touched the same file. A refused " + "reconcile performs no rebase attempt. The `--confirm-rebase` token covers the exact " + "upstream state and the set of files touched on both sides.", + "The push target must be the checked-out branch; this is checked before anything is " + "staged. The unborn branch of a fresh `git init -b main` counts as checked out, so the " + "first publish of a new instance works; a real detached HEAD is refused.", + "With nothing new to stage, a local commit the remote lacks is still pushed: one left " + "behind by an earlier publish whose push failed, or every commit when the remote " + "answers but does not have the branch yet (a new, empty remote repository).", + "A remote that cannot be reached is not read as lacking the branch: on a clean tree " + "`publish` reports \"Nothing to commit\" and attempts no push.", + "A rejected push gets exactly one more reconcile-and-push; never more than one.", + "Mass-Update Gate: counts the files that would be committed, refuses with exit 42 at " + "`--threshold` (default 10) or more, and prints a review report - a scale line (file " + "count, total lines added/removed, status breakdown), attention notes where they apply " + "(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. The gate is evaluated before anything is staged, so a refused " + "publish leaves the working tree untouched.", + "Never counted and never shown for approval, but committed like everything else: " + "anything under `work/`, and the files `wikitool` generates itself (`kb/index.md`, " + "`kb/log.md`, `kb/provenance.md`, every `INDEX.md`). The refusal line accounts for " + "both, by reason.", + "The `--confirm` token covers each counted path, its contents and the publish target: " + "a different file list or edited contents need a new clearance.", + "Publish-Remote Gate: when the checkout carries `.wikitool-remotes.json` and the push " + "URL of `--remote` is not listed in it, exits 42 before the reconcile fetches anything. " + "The URL is read with `git remote get-url --push`, so a repointed remote does not pass " + "on its name. An absent file means unrestricted; a malformed one is an error, not " + "permission.", + "`--path` (repeatable) scopes the whole operation - gate count, staging and commit - " + "to that subtree.", + "After a successful commit or push whose changed files include `tools/`, `types/`, " + "`instructions/`, `AGENTS.md` or a path ending in `CONTRACT.md`, prints one reminder " + "line: 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`. It is not a gate: no exit code change, nothing to clear, and silent for an " + "ordinary content publish.", + ), + failures=( + cli_contract.Failure( + cause="git failed - `git add`, `git commit`, `git push`, or the reconcile's " + "automatic rebase", + reaction="Do not retry and do not force - report and ask the user. `publish` has " + "already made its one retry of a rejected push itself, where a reconcile resolved " + "the rejection", + ), + cli_contract.Failure( + cause="The push target (`--branch`) is not the checked-out branch, or HEAD is " + "detached; the unborn branch of a fresh `git init` is not this case", + reaction="Check out the branch you mean to publish, or pass `--branch <checked-out " + "branch>`, then retry once", + ), + cli_contract.Failure( + cause="`--yes`/`-y` was passed - the flag does not exist and fails with an " + "explicit error", + reaction="Drop it. The Mass-Update Gate is cleared only with `--confirm <token>` " + "from the gate's own refusal output", + ), + cli_contract.Failure( + cause="`.wikitool-remotes.json` is unreadable or has no usable " + "`allowed_push_urls` list", + reaction="Show the error to the user and stop - a malformed file is not " + "permission, and fixing or deleting it is theirs to do", + ), + cli_contract.Failure( + cause="Mass-Update Gate: `--threshold` (default 10) or more counted files would be " + "committed, or the `--confirm` token does not match this changeset", + reaction=cli_contract.token_gate_reaction("--confirm"), + code=42, + ), + cli_contract.Failure( + cause="Rebase-review gate: `<remote>/<branch>` moved and both sides changed the " + "same file; the output lists the upstream commits, the overlapping files and their " + "diff", + reaction=cli_contract.token_gate_reaction("--confirm-rebase"), + code=42, + ), + cli_contract.Failure( + cause="Publish-Remote Gate: `.wikitool-remotes.json` exists and the push URL of " + "`--remote` is not listed in it, or `--remote` resolves to no push URL", + reaction="Show the user the push URL it names and the allowed ones, and stop. This " + "gate has no token and no flag: only the user resolves it, by adding the URL to " + "that file", + code=42, + ), + ), + examples=( + 'tools/wikitool publish --message "ingest: docker-cheatsheet"', + 'tools/wikitool publish --confirm <token> --message "ingest: docker-cheatsheet" ' + "# re-run after a Mass-Update exit 42, once the user approved", + 'tools/wikitool publish --confirm-rebase <token> --message "ingest: docker-cheatsheet" ' + "# re-run after a rebase-review exit 42, once the user approved", + ), + never=( + "Never retry a failed git step unchanged, and never force (`--force`, " + "`--force-with-lease`).", + "Never pass a `--confirm` or `--confirm-rebase` token the user has not seen and " + "approved.", + "Never edit `.wikitool-remotes.json` to get past a Publish-Remote refusal - that is " + "opening a gate on your own initiative.", + ), + see_also=( + "`wikitool sync` - the same reconcile on its own, without committing or pushing", + "`instructions/gates.md` - the gate procedure", + "`instructions/setup-instance.md` - the first publish of a new instance", + ), )) def publish_command( message: str = typer.Option(..., "--message", help="Commit message summary, e.g. 'ingest: docker-cheatsheet'"), diff --git a/tools/chemenu/commands/index_build.py b/tools/chemenu/commands/index_build.py index c3a03ec..ade9554 100644 --- a/tools/chemenu/commands/index_build.py +++ b/tools/chemenu/commands/index_build.py @@ -235,8 +235,8 @@ def build_index(kb_dir: Path) -> str: "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 " + cause="Rare I/O error only", + reaction="Safe to retry freely - the plan is always recomputed from the pages currently " "on disk, so a re-run converges", ),), )) diff --git a/tools/chemenu/commands/instructions_cmd.py b/tools/chemenu/commands/instructions_cmd.py index d5124ad..e9995e6 100644 --- a/tools/chemenu/commands/instructions_cmd.py +++ b/tools/chemenu/commands/instructions_cmd.py @@ -386,9 +386,9 @@ def check_skill_reference_paths() -> list[str]: "`SKILL.md` in it)", failures=(cli_contract.Failure( label="", - exit_1="No skills found under `instructions/`, or a target directory is not a " + cause="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 " + reaction="Check whether the flagged target holds anything worth keeping, then re-run with " "`--force` if not; otherwise fix the named cause and retry", ),), )) @@ -449,13 +449,13 @@ def sync( "*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 " + cause="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 " + reaction="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", ),), diff --git a/tools/chemenu/commands/links_cmd.py b/tools/chemenu/commands/links_cmd.py index 0e2d961..f65e6ad 100644 --- a/tools/chemenu/commands/links_cmd.py +++ b/tools/chemenu/commands/links_cmd.py @@ -77,8 +77,8 @@ def inbound(pages: dict[str, Page], title: str) -> list[dict]: "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 " + cause="Page not found", + reaction="Check the exact title with `search`; a wikilink target is not always the page's " "stem", ),), )) diff --git a/tools/chemenu/commands/lint.py b/tools/chemenu/commands/lint.py index aa53139..985537e 100644 --- a/tools/chemenu/commands/lint.py +++ b/tools/chemenu/commands/lint.py @@ -81,8 +81,8 @@ __all__ = [ "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 " + cause="Only with `--fail-on-error`: hard findings exist", + reaction="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\"", ),), diff --git a/tools/chemenu/commands/log_append.py b/tools/chemenu/commands/log_append.py index 1cf3e8b..dffdcca 100644 --- a/tools/chemenu/commands/log_append.py +++ b/tools/chemenu/commands/log_append.py @@ -67,8 +67,8 @@ def ingests_since_last_lint(entries: list[tuple[str, str, str]]) -> int: 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 " + cause="Invalid `--op` or unreadable `--body-file`", + reaction="**Not idempotent.** If the previous run's outcome is uncertain, check the tail " "of `kb/log.md` before retrying", ),), )) diff --git a/tools/chemenu/commands/migrate_cmd.py b/tools/chemenu/commands/migrate_cmd.py index 6f37275..d2aad22 100644 --- a/tools/chemenu/commands/migrate_cmd.py +++ b/tools/chemenu/commands/migrate_cmd.py @@ -170,9 +170,9 @@ def _report_offers( "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 " + cause="`.wikitool-kb.json` is missing (content version undeclared), or `VERSION` is " "unreadable", - retry="For a missing declaration: run `migrate baseline <version>` once, then retry. " + reaction="For a missing declaration: run `migrate baseline <version>` once, then retry. " "Safe to retry freely otherwise", ),), )) @@ -273,9 +273,9 @@ def status_command( "an error", failures=(cli_contract.Failure( label="", - exit_1="Unknown version, no `.wikitool-kb.json`, nothing outstanding, or a *required* " + cause="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 " + reaction="**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", ),), @@ -391,9 +391,9 @@ def done_command( "way around it", failures=(cli_contract.Failure( label="", - exit_1="Unparseable version, or a declaration already exists and `--force` was not " + cause="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 " + reaction="Safe to re-run with the same version. If a declaration exists, it is almost " "always `migrate done` that was wanted", ),), )) @@ -550,9 +550,9 @@ def _shapes_now(wanted: set[str]) -> dict[str, corpus_diff.PageShape]: "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 " + cause="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 " + reaction="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", ),), diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index 26c3dd8..12c25f7 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -401,19 +401,19 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]: failures=( cli_contract.Failure( label="new <type>", - exit_1="Duplicate page title, unknown type, invalid `--set` value, or a " + cause="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 " + reaction="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 " + cause="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, " + reaction="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 " diff --git a/tools/chemenu/commands/page_ops.py b/tools/chemenu/commands/page_ops.py index 81882ba..53085d0 100644 --- a/tools/chemenu/commands/page_ops.py +++ b/tools/chemenu/commands/page_ops.py @@ -229,9 +229,9 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]: "the page is `Act Runner`", failures=(cli_contract.Failure( label="", - exit_1="Neither `--from` nor `--to` is a page, target title already taken, or " + cause="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` " + reaction="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", ),), )) @@ -341,9 +341,9 @@ def rename_command( "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 " + cause="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 " + reaction="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", ),), @@ -473,9 +473,9 @@ def _rmdir_if_emptied(directory: Path) -> bool: "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 " + cause="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 " + reaction="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", ),), diff --git a/tools/chemenu/commands/provenance_cmd.py b/tools/chemenu/commands/provenance_cmd.py index 501992e..c8d0d5f 100644 --- a/tools/chemenu/commands/provenance_cmd.py +++ b/tools/chemenu/commands/provenance_cmd.py @@ -103,10 +103,10 @@ def coverage(json_out: bool = typer.Option(False, "--json", help="Print raw find "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 " + cause="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", + reaction="Fix the argument and retry", ),), )) def trace( @@ -219,8 +219,8 @@ def build_provenance_index(kb_dir: Path, raw_dir: Path) -> str: "pages)", failures=(cli_contract.Failure( label="", - exit_1="Rare I/O error only", - retry="Safe to retry freely", + cause="Rare I/O error only", + reaction="Safe to retry freely", ),), )) def rebuild_index( diff --git a/tools/chemenu/commands/raw_cmd.py b/tools/chemenu/commands/raw_cmd.py index 3d97def..998c019 100644 --- a/tools/chemenu/commands/raw_cmd.py +++ b/tools/chemenu/commands/raw_cmd.py @@ -366,7 +366,7 @@ def _replace( failures=( cli_contract.Failure( label="raw accept", - exit_1="A file does not exist, is not under `incoming/`, or is nested more than " + cause="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 " @@ -374,7 +374,7 @@ def _replace( "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 " + reaction="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 " @@ -383,12 +383,12 @@ def _replace( ), cli_contract.Failure( label="raw accept --replaces", - exit_1="More than one incoming file, `--page` also given, the incoming file does " + cause="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 " + reaction="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", ), ), diff --git a/tools/chemenu/commands/review_cmd.py b/tools/chemenu/commands/review_cmd.py index bddebbf..e6da08b 100644 --- a/tools/chemenu/commands/review_cmd.py +++ b/tools/chemenu/commands/review_cmd.py @@ -113,12 +113,12 @@ def report_to_dict(report: ReviewReport) -> dict: "**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 " + cause="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 " + reaction="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", diff --git a/tools/chemenu/commands/run_budget.py b/tools/chemenu/commands/run_budget.py index c48a736..bca47e1 100644 --- a/tools/chemenu/commands/run_budget.py +++ b/tools/chemenu/commands/run_budget.py @@ -375,8 +375,8 @@ def reset_message() -> str: "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`", + cause="`--yes` not passed", + reaction="Get the user's approval, then re-run with `--yes`", ),), )) def reset_command( diff --git a/tools/chemenu/commands/search.py b/tools/chemenu/commands/search.py index 6d75401..54ff215 100644 --- a/tools/chemenu/commands/search.py +++ b/tools/chemenu/commands/search.py @@ -160,9 +160,9 @@ def render_table(result: SearchResult, show_matches: bool) -> str: "Budget Gate**", failures=(cli_contract.Failure( label="", - exit_1="`rg` is not installed or did not finish within 30 s, a malformed `--field` " + cause="`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 " + reaction="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 " diff --git a/tools/chemenu/commands/task_cmd.py b/tools/chemenu/commands/task_cmd.py index 0fabc5f..bfd9b9c 100644 --- a/tools/chemenu/commands/task_cmd.py +++ b/tools/chemenu/commands/task_cmd.py @@ -88,12 +88,12 @@ def _parse_follow_up_at(text: str) -> datetime.date: "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 " + cause="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 " + reaction="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", @@ -201,8 +201,8 @@ def task_new_command( "(`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` " + cause="No `.wikitool-tasks.json`", + reaction="Not transient; configure a tracker first, then retry once. A `--project` " "matching no tracker project is not an error here - see its Commands row", ),), )) @@ -262,9 +262,9 @@ def task_list_command( "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 " + cause="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) " + reaction="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`", ),), diff --git a/tools/chemenu/commands/touch.py b/tools/chemenu/commands/touch.py index 64a41e2..b7ed8a1 100644 --- a/tools/chemenu/commands/touch.py +++ b/tools/chemenu/commands/touch.py @@ -211,10 +211,10 @@ def _apply_remove(frontmatter: Dict[str, Any], field: str, value: Any) -> Option "explicitly.", failures=(cli_contract.Failure( label="", - exit_1="Page not found; an invalid value for a field it writes; a field owned by " + cause="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 " + reaction="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", ),), )) diff --git a/tools/chemenu/commands/types_cmd.py b/tools/chemenu/commands/types_cmd.py index 8b8e156..5fdadf7 100644 --- a/tools/chemenu/commands/types_cmd.py +++ b/tools/chemenu/commands/types_cmd.py @@ -82,8 +82,8 @@ def list_types_command( "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", + cause="Unknown type name", + reaction="Fix the name and retry", ),), )) def describe_type_command( diff --git a/tools/chemenu/commands/upload_cmd.py b/tools/chemenu/commands/upload_cmd.py index e309a1e..b1ffc62 100644 --- a/tools/chemenu/commands/upload_cmd.py +++ b/tools/chemenu/commands/upload_cmd.py @@ -82,8 +82,8 @@ def upload_list_command( "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", + cause="Unknown or malformed submission id", + reaction="Fix the id (see `upload list`) and retry", ),), )) def upload_show_command( @@ -150,11 +150,11 @@ def _clearance_message(manifest: dict, token: str, stale: Optional[str]) -> str: "`incoming/<filename>` already exists", failures=(cli_contract.Failure( label="", - exit_1="Unknown or malformed submission id, the submission's file is missing from " + cause="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>` " + reaction="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 " @@ -194,8 +194,8 @@ def upload_accept_command( "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 " + cause="Unknown or malformed submission id, or an empty `--reason`", + reaction="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", ),), diff --git a/tools/chemenu/commands/upstream_cmd.py b/tools/chemenu/commands/upstream_cmd.py index 538c3be..21a11d5 100644 --- a/tools/chemenu/commands/upstream_cmd.py +++ b/tools/chemenu/commands/upstream_cmd.py @@ -273,11 +273,11 @@ def _merge_success_message( "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, " + cause="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 " + reaction="**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 " @@ -437,9 +437,9 @@ def _verify_success_message(stack_moved: list[str], since: str, until: str) -> s "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 " + cause="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 " + reaction="A finding is not fixed by re-running - it names the paths that leaked. Fix the " "revision argument and retry for the second case", ),), )) diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index 9286b5b..ea11046 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -94,8 +94,8 @@ def _describe_origin(stamp: Optional[dict]) -> str: "Iteration Budget Gate**", failures=(cli_contract.Failure( label="", - exit_1="`VERSION` is missing or unparseable", - retry="Fix `VERSION` and retry", + cause="`VERSION` is missing or unparseable", + reaction="Fix `VERSION` and retry", ),), )) @app.command("show") @@ -151,9 +151,9 @@ def show_command( "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`. " + cause="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 " + reaction="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", ),), @@ -255,14 +255,14 @@ def check_command( "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 " + cause="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 " + reaction="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", ),), )) @@ -436,7 +436,7 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str "part was chosen correctly", failures=(cli_contract.Failure( label="", - exit_1="More or fewer than one of `--major/--minor/--patch`, an empty `--title`, an " + cause="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`, " @@ -444,7 +444,7 @@ def _no_entry_message(wanted: Version, has_stamp: bool, release_url: str) -> str "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 " + reaction="**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", ),), )) @@ -668,10 +668,10 @@ def bump_command( "`VERSION`", failures=(cli_contract.Failure( label="", - exit_1="A missing `VERSION`/`CHANGES.md`, `VERSION` already a release (no running " + cause="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 " + reaction="**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", ),), @@ -787,10 +787,10 @@ def release_command( "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 " + cause="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 " + reaction="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", diff --git a/tools/chemenu/commands/work_cmd.py b/tools/chemenu/commands/work_cmd.py index 9fe94ce..2481781 100644 --- a/tools/chemenu/commands/work_cmd.py +++ b/tools/chemenu/commands/work_cmd.py @@ -150,9 +150,9 @@ Cut the tree into units. One unit does one job and becomes one source page. A un "`work/CONTRACT.md`", failures=(cli_contract.Failure( label="", - exit_1="Neither or both of `--input`/`--key` given, `--input` outside `raw/`, a " + cause="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 " + reaction="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", ),), )) @@ -251,8 +251,8 @@ def new_command( "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 " + cause="Unknown run key, or `--yes` was not passed", + reaction="For \"not confirmed\": check the listed files are no longer needed, confirm the " "conclusions are in `kb/`, then re-run with `--yes`", ),), )) diff --git a/tools/chemenu/commands/xref.py b/tools/chemenu/commands/xref.py index f7e488d..68dcda5 100644 --- a/tools/chemenu/commands/xref.py +++ b/tools/chemenu/commands/xref.py @@ -168,8 +168,8 @@ def _check_authorised(source: Page, target: Page, label: str) -> None: "`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 " + cause="Page A or B not found, or a page's type declares no `related:` field", + reaction="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", ),), @@ -263,8 +263,8 @@ def remove_link_bullets(body: str, other_title: str) -> str: "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", + cause="Page A not found (B is allowed not to exist)", + reaction="Safe to retry freely; removing an absent link is a no-op", ),), )) def xref_remove( @@ -346,9 +346,9 @@ def xref_remove( "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 " + cause="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 " + reaction="Use `--dry-run` first; safe to retry. `sources trace --page \"<Title>\"` shows " "who was already linked", ),), )) diff --git a/tools/chemenu/tests/test_cli_contract.py b/tools/chemenu/tests/test_cli_contract.py index 0eb02be..b028c58 100644 --- a/tools/chemenu/tests/test_cli_contract.py +++ b/tools/chemenu/tests/test_cli_contract.py @@ -24,7 +24,7 @@ def _fixture_record(**overrides) -> cc.CommandRecord: 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"),), + failures=(cc.Failure(label="", cause="Widget not found", reaction="Fix the name and retry once"),), ) defaults.update(overrides) return cc.CommandRecord(**defaults) @@ -219,3 +219,81 @@ def test_render_markdown_section_has_no_options_heading(): assert "OPTIONS" not in section assert "#### `frobnicate`" in section assert rec.notes in section + + +def test_notes_tuple_renders_one_bullet_per_entry(): + rec = _fixture_record(notes=("Frobnicates in place.", "Leaves the widget's name alone.")) + text = cc.render_text(rec) + assert "NOTES\n - Frobnicates in place.\n - Leaves the widget's name alone." in text + section = cc.render_markdown_section(rec) + assert "**NOTES**\n\n- Frobnicates in place.\n- Leaves the widget's name alone." in section + + +def test_one_exit_status_and_on_failure_line_per_cause(): + rec = _fixture_record( + properties=cc.Properties( + effect=cc.Effect.WRITE, + idempotent=cc.Idempotent.NO, + atomic="No", + budget=cc.Budget.COUNTED, + gates=("mass-update",), + ), + failures=( + cc.Failure(cause="Mass-Update Gate: too many files", reaction="Show the output and stop", code=42), + cc.Failure(cause="Widget not found", reaction="Fix the name and retry once"), + cc.Failure(cause="Widget locked", reaction="Report to the user"), + cc.Failure(cause="No remote configured - reported and skipped", reaction="", code=0), + ), + ) + status = cc.render_exit_status_lines(rec) + # Sorted by code, stable within a code; the explicit 42 cause replaces + # the generic gate line. + assert status == [ + "0 success", + "0 No remote configured - reported and skipped", + "1 Widget not found", + "1 Widget locked", + "42 Mass-Update Gate: too many files", + ] + # A cause without a reaction has no ON FAILURE line. + assert cc.render_on_failure_lines(rec) == [ + "Widget not found -> Fix the name and retry once", + "Widget locked -> Report to the user", + "Mass-Update Gate: too many files -> Show the output and stop", + ] + assert cc._exit_codes(rec) == [0, 1, 42] + + +def test_generic_gate_line_stays_without_an_explicit_42_cause(): + rec = _fixture_record( + properties=cc.Properties( + effect=cc.Effect.WRITE, + idempotent=cc.Idempotent.NO, + atomic="No", + budget=cc.Budget.COUNTED, + gates=("mass-update",), + ), + ) + assert cc.render_exit_status_lines(rec)[-1].startswith("42 needs clearance - mass-update") + + +def test_label_prefixes_both_lines(): + rec = _fixture_record(failures=(cc.Failure(cause="Bad name", reaction="Fix it", label="frobnicate --b"),)) + assert "1 frobnicate --b: Bad name" in cc.render_exit_status_lines(rec) + assert cc.render_on_failure_lines(rec) == ["frobnicate --b: Bad name -> Fix it"] + + +def test_on_failure_omitted_when_no_cause_has_a_reaction(): + rec = _fixture_record(failures=(cc.Failure(cause="Nothing to do", reaction="", code=0),)) + assert "ON FAILURE" not in cc.render_text(rec) + assert "ON FAILURE" not in cc.render_markdown_section(rec) + + +def test_exit_42_cause_without_a_gate_is_refused(): + with pytest.raises(ValueError, match="no gate"): + _fixture_record(failures=(cc.Failure(cause="Gate", reaction="Stop", code=42),)) + + +def test_failure_code_outside_0_1_42_is_refused(): + with pytest.raises(ValueError): + cc.Failure(cause="Crash", reaction="Report", code=2) diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index bb203a3..d1971c4 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -53,7 +53,7 @@ def _fixture_record(path: str) -> cli_contract.CommandRecord: budget=cli_contract.Budget.COUNTED, ), notes="Does a thing, mechanically.", - failures=(cli_contract.Failure(label="", exit_1="It broke", retry="Fix and retry"),), + failures=(cli_contract.Failure(label="", cause="It broke", reaction="Fix and retry"),), )