tools: command records, Git group - NOTES as bullets, one exit line per cause, examples and prohibitions (#142)
CI / verify (push) Successful in 1m15s
Release / release (push) Successful in 37s

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
This commit is contained in:
torben committed 2026-09-26 08:25:41 +02:00
1 parent 2a60f3a265
commit fd0f60b2e7
34 files changed
+601 -285

No files matched your search

+113 -38
View File
@@ -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:
+4 -4
View File
@@ -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",
),),
))
+4 -4
View File
@@ -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 "
+4 -4
View File
@@ -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",
),),
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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",
+156 -80
View File
@@ -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'"),
+2 -2
View File
@@ -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",
),),
))
+4 -4
View File
@@ -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",
),),
+2 -2
View File
@@ -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",
),),
))
+2 -2
View File
@@ -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\"",
),),
+2 -2
View File
@@ -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",
),),
))
+8 -8
View File
@@ -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",
),),
+4 -4
View File
@@ -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 "
+6 -6
View File
@@ -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",
),),
+4 -4
View File
@@ -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(
+4 -4
View File
@@ -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",
),
),
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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 "
+6 -6
View File
@@ -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`",
),),
+2 -2
View File
@@ -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",
),),
))
+2 -2
View File
@@ -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(
+6 -6
View File
@@ -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",
),),
+4 -4
View File
@@ -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",
),),
))
+12 -12
View File
@@ -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",
+4 -4
View File
@@ -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`",
),),
))
+6 -6
View File
@@ -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",
),),
))
+79 -1
View File
@@ -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)
+1 -1
View File
@@ -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"),),
)