From e9f47597272f5a56b1122511641fbb4b9568c05a Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Wed, 7 Oct 2026 10:10:48 +0200 Subject: [PATCH] =?UTF-8?q?AGENTS.md=20=C2=A7=20Gates=20is=20the=20one=20g?= =?UTF-8?q?ate=20list;=20cli=5Fcontract.GATES=20and=20docs=20verify=20hold?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate list contradicted itself across AGENTS.md, instructions/gates.md and the command records' gate names. The Rebase-Review Gate is now listed, `new project`'s exit 42 is no longer a pseudo-gate, gates.md and the explanation page link to the one list instead of counting, and a register plus a docs verify check keep the three in step. Files changed: - AGENTS.md - CHANGES.md - VERSION - docs/explanation/why-gates-are-code.md - docs/reference/commands/pages.md - docs/reference/commands/types-instructions-and-docs.md - docs/reference/exit-codes.md - instructions/gates.md - tools/CONTRACT.md - tools/chemenu/cli_contract.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/new_page.py - tools/chemenu/errors.py - tools/chemenu/mcp/server.py - tools/chemenu/tests/test_cli_contract.py - tools/chemenu/tests/test_docs_verify.py - tools/chemenu/tests/test_git_publish.py - tools/chemenu/tests/test_reference_doc.py Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2 --- AGENTS.md | 10 +- CHANGES.md | 3 +- VERSION | 2 +- docs/explanation/why-gates-are-code.md | 28 ++++-- docs/reference/commands/pages.md | 1 - .../commands/types-instructions-and-docs.md | 2 + docs/reference/exit-codes.md | 4 - instructions/gates.md | 21 ++--- tools/CONTRACT.md | 4 +- tools/chemenu/cli_contract.py | 38 ++++++-- tools/chemenu/commands/docs_verify.py | 66 ++++++++++++++ tools/chemenu/commands/doctor.py | 2 +- tools/chemenu/commands/git_publish.py | 2 +- tools/chemenu/commands/new_page.py | 1 - tools/chemenu/errors.py | 4 +- tools/chemenu/mcp/server.py | 2 +- tools/chemenu/tests/test_cli_contract.py | 34 ++++++- tools/chemenu/tests/test_docs_verify.py | 91 +++++++++++++++++++ tools/chemenu/tests/test_git_publish.py | 2 +- tools/chemenu/tests/test_reference_doc.py | 4 +- 20 files changed, 269 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 121b0f7..a31ce8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,7 +162,7 @@ instance comes only from a release), [docs/explanation/language-boundaries.md](docs/explanation/language-boundaries.md) (why the control plane is English everywhere and the KB language is a value, and why the axis is the reader rather than the owner), -[docs/explanation/why-gates-are-code.md](docs/explanation/why-gates-are-code.md) (why the five +[docs/explanation/why-gates-are-code.md](docs/explanation/why-gates-are-code.md) (why the gates in [Gates](#gates) are code rather than instruction), [docs/explanation/version-model.md](docs/explanation/version-model.md) (why a version number answers a compatibility question and a migration question separately), and @@ -278,8 +278,8 @@ that take a title. A result cut short by `--limit` says so and names the total. ## Gates -Five limits are enforced in code rather than by instruction, because a prompt-level limit is -one an agent can talk itself past. +These limits are enforced in code rather than by instruction, because a prompt-level limit is +one an agent can talk itself past. This section is the one list of them. - **Mass-Update Gate.** `publish` exits **42** on a change touching too many files, printing the file list and the `--confirm ` line that publishes it once the user approves. The @@ -287,6 +287,10 @@ one an agent can talk itself past. - **Publish-Remote Gate.** `publish` exits **42** on a push to a URL this checkout has not declared in `.wikitool-remotes.json`. It has no token and no flag: the way past it is a deliberate edit by the user, never by an agent. +- **Rebase-Review Gate.** `sync` and `publish` exit **42** on a rebase whose incoming commits + touch a file this session is also changing, printing the incoming commits, the overlapping + files, their diff and the `--confirm-rebase ` line that rebases once the user has + judged that the two changes do not collide logically. - **Upload Review Gate.** `upload accept` exits **42** on an MCP `submit` tool submission nobody has cleared yet, printing its manifest and the `--confirm ` line that promotes it once the user approves - same shape as the Mass-Update Gate, one submission at a time. diff --git a/CHANGES.md b/CHANGES.md index 2cd069a..771dd3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model. --- -## 9.0.0-beta.8 - 2026-10-07 - Die Formen der vier handgeschriebenen .wikitool-*.json stehen je in einem Datensatz (config_shapes), aus dem Lader, doctor und die Konfigurationsreferenz lesen +## 9.0.0-beta.9 - 2026-10-07 - Gate-Liste: AGENTS.md § Gates ist die eine Liste (mit Rebase-Review Gate), cli_contract.GATES hält sie, docs verify vergleicht beide **Author:** Torben Nehmer @@ -33,6 +33,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model. - docs/: how-to guides, configuration reference and two explanation pages carved out of README.md, INSTALL.md, INSTALL-MCP.md and EVALS.md; the last two retired - INSTALL.md is an English installation tutorial; new tutorial from a first source to a first answer with a shipped sample; README.md a landing page - Die Formen der vier handgeschriebenen .wikitool-*.json stehen je in einem Datensatz (config_shapes), aus dem Lader, doctor und die Konfigurationsreferenz lesen +- Gate-Liste: AGENTS.md § Gates ist die eine Liste (mit Rebase-Review Gate), cli_contract.GATES hält sie, docs verify vergleicht beide **Low impact** - version bump: Abschlussmeldung nennt Stichpunkt oder Themenabsatz statt pauschal Prosa diff --git a/VERSION b/VERSION index db200ba..a717d86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.0.0-beta.8 +9.0.0-beta.9 diff --git a/docs/explanation/why-gates-are-code.md b/docs/explanation/why-gates-are-code.md index e33dc68..0928391 100644 --- a/docs/explanation/why-gates-are-code.md +++ b/docs/explanation/why-gates-are-code.md @@ -1,10 +1,8 @@ # Why gates are code -Chemenu has five hard limits - the Mass-Update Gate, the Publish-Remote Gate, the Upload Review -Gate, the Guideline Push Gate, and the Iteration Budget Gate - and all five live inside -`tools/wikitool`, not in a -paragraph of instructions an agent reads and follows. The rules themselves, and what to do when -one trips, are in [AGENTS.md § Gates](../../AGENTS.md#gates) and +Chemenu's hard limits - the gates [AGENTS.md § Gates](../../AGENTS.md#gates) lists - live inside +`tools/wikitool`, not in a paragraph of instructions an agent reads and follows. The rules +themselves, and what to do when one trips, are in [AGENTS.md § Gates](../../AGENTS.md#gates) and [instructions/gates.md](../../instructions/gates.md). This page is only about the design choice underneath them: why code, and why these mechanisms in particular. @@ -34,8 +32,8 @@ well-written instruction - it's that code doesn't get talked into anything. ## Why different mechanisms, not one -The five gates ask four different kinds of question, and each one's shape follows from what kind -of question it is. +The gates ask different kinds of question, and each one's shape follows from what kind of +question it is. The Mass-Update Gate asks *is this change too large to publish unreviewed* - a judgment that varies changeset by changeset, so it clears with a `--confirm` token tied to the specific @@ -51,7 +49,7 @@ editing `.wikitool-remotes.json` directly, outside the gate's own flow. The Upload Review Gate asks the Mass-Update Gate's own question - *is this change right?* - at the opposite end of its size range: one file from a stranger instead of a changeset from the session's own work. That similarity is exactly why it reuses the same shape (a `--confirm` token -digesting the thing being approved) rather than inventing a fourth one: the two gates differ in +digesting the thing being approved) rather than inventing a new one: the two differ in *who* produced the change and *how much* of it there is, not in what kind of question either one is answering, so nothing about the mechanism needed to change - only the boundary it sits behind did, since the submission lives in a quarantine the ordinary pipeline never reads at all rather @@ -68,14 +66,24 @@ standing, committed declaration - the capture manifests under `raw/` - and askin URLs in an allowlist as well would be a second declaration of one fact. What is left to ask is the per-run question, and that is the one a token answers. -The Iteration Budget Gate asks a fourth kind of question - not "is this instance correct" but +The Rebase-Review Gate asks the one question the others cannot: *does what is arriving collide +logically with what I am about to publish?* `sync` and `publish` reconcile with the remote by +rebasing, and when the incoming commits touch a file this session is also changing, git can +produce a clean textual result out of two changes that contradict each other. Whether they do is +a question of meaning, not of text, so the tool cannot answer it - it can only refuse, and show +the incoming commits, the overlapping files and their diff to whoever can. The answer is different +for every rebase, which is why it takes the token shape: the token digests the exact upstream +state and the overlapping files, so a remote that moved again before the re-run is a new +question, asked again. + +The Iteration Budget Gate asks a different kind of question - not "is this instance correct" but "has this session stopped making progress." That's read from the shape of the call history itself (call count, repeated identical calls), not from anything about the content of any one call. ## Exit 42 is a posture, and it outgrew the gates -Those five are the named gates, and they are not the only thing that exits 42 any more. When the +Those are the named gates, and they are not the only thing that exits 42 any more. When the task-tracker provider layer arrived, it brought a case that looks like a gate from the outside and is not one: a provider whose API cannot create a project (Super Productivity's local REST API reads projects but does not write them) raises `HumanInterventionRequired`, and the command prints diff --git a/docs/reference/commands/pages.md b/docs/reference/commands/pages.md index 58ec193..c56c697 100644 --- a/docs/reference/commands/pages.md +++ b/docs/reference/commands/pages.md @@ -85,7 +85,6 @@ tools/wikitool new project --name "Homelab migration" --set responsibility=infra - Atomic: `new `: Yes - single file write. `new project`: **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured - Counts toward the session call budget: yes - Can reach the network: no -- Gates: human-intervention-required (`new project` only) - see [exit codes and gates](../exit-codes.md) **Exit codes** diff --git a/docs/reference/commands/types-instructions-and-docs.md b/docs/reference/commands/types-instructions-and-docs.md index 1e21834..1d23f3b 100644 --- a/docs/reference/commands/types-instructions-and-docs.md +++ b/docs/reference/commands/types-instructions-and-docs.md @@ -288,6 +288,7 @@ tools/wikitool docs verify - Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check. - Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference. - Every file under `docs/` sits in one of its areas - `tutorials/`, `how-to/`, `reference/`, `explanation/` or `dev/` - never directly in `docs/` or in another directory. +- `AGENTS.md` § Gates names exactly the gates in `cli_contract.GATES` (the bold entry names, `- **.**`), and every gate in that register that ends in exit 42 is declared by at least one command record. A record naming a gate outside the register fails when `wikitool` starts, not here. - If `docs/` exists: every generated region of the human reference under `docs/reference/` matches what `docs reference` would write, every `cli_contract.GROUPS` entry has its page under `docs/reference/commands/`, and no page there belongs to no group. That includes the four configuration regions in `docs/reference/configuration.md`. The skill catalogue and the page types are checked only in the origin repo (no `.wikitool-release.json`): they describe the stack's shipped defaults, which an instance's own type-specs and instructions may differ from. - `INSTALL.md` carries one generated `` region per platform value of `tools/prerequisites.txt` (`prerequisites-` for a platform-specific one), each current; and the `` markers in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a question the agent asks is never one the human guide leaves out, nor the reverse. - `VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that crosses the compatibility boundary from the last release carries `**Breaking Change:**`, has a migration document or `**Migration:** none required`, and its `**Migration:**` line names every required migration document targeting it. @@ -316,6 +317,7 @@ tools/wikitool docs verify - `1` - A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group - `1` - A reference file's table-of-contents region is missing or stale - `1` - A file sits directly in `docs/`, or in a directory there that is not one of its five areas +- `1` - `AGENTS.md` § Gates and `cli_contract.GATES` disagree, or a register gate that ends in exit 42 is declared by no command record - `1` - A reference file's relative markdown link does not resolve to an existing file - `1` - An `INSTALL.md` prerequisites region is stale - `1` - An `INSTALL.md` prerequisites region is missing, or names a platform no tool has diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md index 0ce0e3b..a67fee0 100644 --- a/docs/reference/exit-codes.md +++ b/docs/reference/exit-codes.md @@ -12,10 +12,6 @@ What each exit code means, and how a caller reacts to it, is [AGENTS.md § Tool - [`export guidelines`](commands/guideline-export.md#export-guidelines) -### human-intervention-required (`new project` only) - -- [`new`](commands/pages.md#new) - ### mass-update - [`publish`](commands/git.md#publish) diff --git a/instructions/gates.md b/instructions/gates.md index ca12c4c..d48c2a5 100644 --- a/instructions/gates.md +++ b/instructions/gates.md @@ -1,13 +1,14 @@ --- type: types/instruction.md name: gates -description: What to do when wikitool refuses a call - exit 42 (user clearance required) on publish, upload accept and export guidelines --push, and the Iteration Budget Gate and loop-breaker on every command. +description: What to do when wikitool refuses a call - exit 42 (user clearance required) on publish, sync, upload accept and export guidelines --push, and the Iteration Budget Gate and loop-breaker on every command. --- # When a gate refuses a call -Two limits are enforced in code rather than by instruction, because a prompt-level limit is one -an agent can talk itself past. +The limits listed in [AGENTS.md § Gates](../AGENTS.md#gates) are enforced in code rather than by +instruction, because a prompt-level limit is one an agent can talk itself past. This file holds +the procedure for each; it does not repeat the list. **Never open a gate on your own initiative.** Not `--override-budget`, not `budget reset`, not a `--confirm` token the user has not actually seen and approved. @@ -34,24 +35,18 @@ Read the exit code first - it says which of these applies: ## Exit 42: user clearance required A `wikitool` command that exits **42** is not reporting an error. It is refusing to act until a -human has *read its output*. Five gates use it today - the Mass-Update Gate (`publish`, on a -change touching 10 or more counted files), the rebase-review gate (`sync` and `publish`, on -a rebase whose incoming commits touch a file this session is also changing, generated files -aside), the -Publish-Remote Gate (`publish`, on a push to a target this checkout has not declared), the -Upload Review Gate (`upload accept`, on a submission nobody has cleared yet), and the Guideline -Push Gate (`export guidelines --push`, before a generated `GUIDELINES.md` goes into any captured -repository) - but the rule is about the exit code, not the command: +human has *read its output*. Which gates use it is [AGENTS.md § Gates](../AGENTS.md#gates); the +rule is about the exit code, not the command: > **Copy the command's output into your reply - the substance of it, not a description of it - > and stop.** Run no further commands in that turn. -For the Mass-Update Gate that substance is the grouped file breakdown: the area headings, every +For the Mass-Update Gate (`publish`, on a change touching 10 or more counted files) that substance is the grouped file breakdown: the area headings, every path, and the sizes. It is already ordered for a reader - what a bad publish damages most comes first, and the mechanically-regenerated files come last - so reproducing it in order is both the cheapest and the most useful thing to do with it. -For the rebase-review gate the substance is different: the commits arriving from the remote, +For the Rebase-Review Gate (`sync` and `publish`) the substance is different: the commits arriving from the remote, the files they touch that this session is also touching, and a diff of those files. Read it - this is the check `sync`/`publish` cannot perform themselves, since a rebase between two commit ranges that touch disjoint files never reaches this gate at all (no content collision is diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index e3b2aef..802b671 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -187,7 +187,6 @@ Scaffold a new wiki page of any type. - atomic: `new `: Yes - single file write. `new project`: **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured - budget: counted - network: no -- gates: human-intervention-required (`new project` only) **EXAMPLES** @@ -2520,6 +2519,7 @@ Check the docs that mirror the code. - 1 A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group - 1 A reference file's table-of-contents region is missing or stale - 1 A file sits directly in `docs/`, or in a directory there that is not one of its five areas +- 1 `AGENTS.md` § Gates and `cli_contract.GATES` disagree, or a register gate that ends in exit 42 is declared by no command record - 1 A reference file's relative markdown link does not resolve to an existing file - 1 An `INSTALL.md` prerequisites region is stale - 1 An `INSTALL.md` prerequisites region is missing, or names a platform no tool has @@ -2538,6 +2538,7 @@ Check the docs that mirror the code. - A page of the human reference is missing or lacks its marker pair, or a page under `docs/reference/commands/` belongs to no group -> Create the page with a title, a one-sentence head and the marker pair the message names (or add the pair), then run `docs reference --apply`; `git mv` or `git rm` a page left over from a renamed or removed group - A reference file's table-of-contents region is missing or stale -> Run `docs toc --apply`, then re-run - A file sits directly in `docs/`, or in a directory there that is not one of its five areas -> Move it (`git mv`) into the area its kind of documentation belongs to, and fix the links that pointed at the old path +- `AGENTS.md` § Gates and `cli_contract.GATES` disagree, or a register gate that ends in exit 42 is declared by no command record -> Make § Gates list exactly the register's display names, or register the gate in `cli_contract.GATES`; declare an exit-42 gate on the command that raises it - A reference file's relative markdown link does not resolve to an existing file -> Fix the `../` count or the target's name - An `INSTALL.md` prerequisites region is stale -> Run `docs prerequisites --apply`, then re-run - An `INSTALL.md` prerequisites region is missing, or names a platform no tool has -> Add the marker pair where that list belongs (or remove the orphaned region and its introducing prose), then run `docs prerequisites --apply` @@ -2559,6 +2560,7 @@ Check the docs that mirror the code. - Every reference file `docs toc` covers carries the current table-of-contents region for its own headings - missing and stale are one check. - Every relative markdown link in one of those reference files resolves to an existing file. A target's `#anchor` suffix is stripped first, and code fences and inline code spans are masked before scanning, so link syntax shown as an example is not mistaken for a real reference. - Every file under `docs/` sits in one of its areas - `tutorials/`, `how-to/`, `reference/`, `explanation/` or `dev/` - never directly in `docs/` or in another directory. +- `AGENTS.md` § Gates names exactly the gates in `cli_contract.GATES` (the bold entry names, `- **.**`), and every gate in that register that ends in exit 42 is declared by at least one command record. A record naming a gate outside the register fails when `wikitool` starts, not here. - If `docs/` exists: every generated region of the human reference under `docs/reference/` matches what `docs reference` would write, every `cli_contract.GROUPS` entry has its page under `docs/reference/commands/`, and no page there belongs to no group. That includes the four configuration regions in `docs/reference/configuration.md`. The skill catalogue and the page types are checked only in the origin repo (no `.wikitool-release.json`): they describe the stack's shipped defaults, which an instance's own type-specs and instructions may differ from. - `INSTALL.md` carries one generated `` region per platform value of `tools/prerequisites.txt` (`prerequisites-` for a platform-specific one), each current; and the `` markers in `instructions/setup-instance.md` and `INSTALL.md` name the same set of keys, so a question the agent asks is never one the human guide leaves out, nor the reverse. - `VERSION` parses and the newest versioned `CHANGES.md` entry names it. An entry that crosses the compatibility boundary from the last release carries `**Breaking Change:**`, has a migration document or `**Migration:** none required`, and its `**Migration:**` line names every required migration document targeting it. diff --git a/tools/chemenu/cli_contract.py b/tools/chemenu/cli_contract.py index afe7fb6..994fe28 100644 --- a/tools/chemenu/cli_contract.py +++ b/tools/chemenu/cli_contract.py @@ -83,8 +83,8 @@ class Failure: EXIT STATUS renders one ` ` line per entry, ON FAILURE one ` -> ` 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 + (a human must see the output before anything proceeds - a declared gate, or + a case like `new project`'s that is no gate), 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. @@ -107,6 +107,28 @@ class Failure: raise ValueError(f"cli_contract: Failure.code must be 0, 1 or 42, not {self.code}") +@dataclass(frozen=True) +class GateSpec: + """One gate as `AGENTS.md` § Gates names it: `display` is the bold entry name + there, `exits_42` is whether it ends in a human clearance (the Iteration Budget + Gate refuses with exit 1 instead).""" + display: str + exits_42: bool + + +# The one list of gates, keyed by the name a record declares in `Properties.gates`. +# `docs verify` holds AGENTS.md § Gates to exactly these display names. +GATES: dict[str, GateSpec] = { + "mass-update": GateSpec("Mass-Update Gate", True), + "publish-remote": GateSpec("Publish-Remote Gate", True), + "rebase-review": GateSpec("Rebase-Review Gate", True), + "upload-review": GateSpec("Upload Review Gate", True), + "guideline-push": GateSpec("Guideline Push Gate", True), + # Applies to every command, so no record declares it. + "iteration-budget": GateSpec("Iteration Budget Gate / Loop-Breaker", False), +} + + @dataclass(frozen=True) class Properties: effect: Effect @@ -116,6 +138,14 @@ class Properties: network: Network = Network.NO gates: tuple[str, ...] = () + def __post_init__(self) -> None: + unknown = [gate for gate in self.gates if gate not in GATES] + if unknown: + raise ValueError( + f"cli_contract: unknown gate {', '.join(repr(g) for g in unknown)} - " + f"declare it in cli_contract.GATES first (known: {', '.join(sorted(GATES))})" + ) + @dataclass(frozen=True) class CommandRecord: @@ -154,10 +184,6 @@ class CommandRecord: raise ValueError( f"cli_contract: {self.path!r} notes must be a tuple of bullets, not one string" ) - 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" - ) # --------------------------------------------------------------------------- diff --git a/tools/chemenu/commands/docs_verify.py b/tools/chemenu/commands/docs_verify.py index 8e75384..8b3e9bb 100644 --- a/tools/chemenu/commands/docs_verify.py +++ b/tools/chemenu/commands/docs_verify.py @@ -62,6 +62,11 @@ documentation, plus the unshipped `docs/dev/`. A page dropped straight into `docs/` belongs to no kind, and its reader cannot tell from the path what it is for. +A tenth checks a list that three documents once each carried in their own form: the +gates. `AGENTS.md` § Gates is the one list, `cli_contract.GATES` the register the +command records are held to, and the check keeps the two equal and every exit-42 gate +raised by some command. + Everything here is a hard oracle: a set comparison or a regex, no judgment. Content quality of the contracts themselves stays with the LLM. """ @@ -773,6 +778,55 @@ def check_docs_layout() -> list[str]: return issues +GATES_SECTION_RE = re.compile(r"^## Gates[ \t]*\n(.*?)(?=^## |\Z)", re.MULTILINE | re.DOTALL) +GATE_ENTRY_RE = re.compile(r"^- \*\*(.+?)\.\*\*", re.MULTILINE) + + +def check_gates_list() -> list[str]: + """`AGENTS.md` § Gates names exactly the gates `cli_contract.GATES` knows, and + every gate that ends in exit 42 is declared by at least one command record. + + § Gates is the one list of gates (invariant 8); the register is what the + records are held to at import (`Properties` refuses a name outside it). This + closes the loop in both directions: an entry the register lacks, a register + entry § Gates never mentions, and a gate that clears with a human but that no + command actually raises. An entry is a bullet whose bold run ends in a full + stop (`- **Mass-Update Gate.**`). + """ + from chemenu import cli # noqa: F401 - importing it registers every record + + agents = config.ROOT / "AGENTS.md" + if not agents.is_file(): + return [f"{rel_path(agents)} is missing - it carries the list of gates"] + section = GATES_SECTION_RE.search(agents.read_text(encoding="utf-8")) + if section is None: + return [f"{rel_path(agents)} has no `## Gates` section"] + + listed = set(GATE_ENTRY_RE.findall(section.group(1))) + known = {spec.display for spec in cli_contract.GATES.values()} + issues = [ + f"{rel_path(agents)} § Gates does not list `{name}`, which cli_contract.GATES knows - " + "add its `- **.**` entry" + for name in sorted(known - listed) + ] + issues += [ + f"{rel_path(agents)} § Gates lists `{name}`, which cli_contract.GATES does not know - " + "register it there or drop the entry" + for name in sorted(listed - known) + ] + + declared = { + gate for rec in cli_contract.all_records().values() for gate in rec.properties.gates + } + issues += [ + f"cli_contract.GATES lists `{key}` as ending in exit 42, but no command record declares " + "it in `Properties.gates` - declare it on the command that raises it, or drop the entry" + for key, spec in sorted(cli_contract.GATES.items()) + if spec.exits_42 and key not in declared + ] + return issues + + def check_toc_regions() -> list[str]: """Every reference file over the line threshold carries a current TOC. @@ -1293,6 +1347,10 @@ def check_breaking_change_for_boundary() -> list[str]: "Every file under `docs/` sits in one of its areas - `tutorials/`, `how-to/`, " "`reference/`, `explanation/` or `dev/` - never directly in `docs/` or in another " "directory.", + "`AGENTS.md` § Gates names exactly the gates in `cli_contract.GATES` (the bold entry " + "names, `- **.**`), and every gate in that register that ends in exit 42 is " + "declared by at least one command record. A record naming a gate outside the register " + "fails when `wikitool` starts, not here.", "If `docs/` exists: every generated region of the human reference under " "`docs/reference/` matches what `docs reference` would write, every `cli_contract.GROUPS` " "entry has its page under `docs/reference/commands/`, and no page there belongs to no " @@ -1373,6 +1431,12 @@ def check_breaking_change_for_boundary() -> list[str]: reaction="Move it (`git mv`) into the area its kind of documentation belongs to, and " "fix the links that pointed at the old path", ), + cli_contract.Failure( + cause="`AGENTS.md` § Gates and `cli_contract.GATES` disagree, or a register gate " + "that ends in exit 42 is declared by no command record", + reaction="Make § Gates list exactly the register's display names, or register the " + "gate in `cli_contract.GATES`; declare an exit-42 gate on the command that raises it", + ), cli_contract.Failure( cause="A reference file's relative markdown link does not resolve to an existing " "file", @@ -1430,6 +1494,7 @@ def verify(): + check_changelog_budget() + check_no_issue_references() + check_docs_layout() + + check_gates_list() + check_reference_regions() + check_toc_regions() + check_reference_targets() @@ -1454,6 +1519,7 @@ def verify(): f"{len(IGNORE_CANARIES)} ignore canaries clear, " f"no issue references in {len(shipped_prose())} shipped document(s) or command help, " f"every docs/ file in one of its {len(DOCS_AREAS)} areas, " + f"AGENTS.md § Gates naming the {len(cli_contract.GATES)} registered gates, " f"the reference under {reference_doc.REFERENCE_DIR}/ current{reference_scope}, " f"tables of contents current and every link resolving on " f"{len(toc.target_files())} reference file(s), " diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index f127920..94b9746 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -416,7 +416,7 @@ def check_publish_remotes() -> Check: stops being a typo and starts being a disclosure. Both absent states say **armed** or **not armed** rather than only naming - the file. AGENTS.md lists this among the three limits enforced in code, so a + the file. AGENTS.md lists this among the limits enforced in code, so a line that reports the file's absence and leaves the reader to infer what that means about the gate is how a checkout ends up trusting a safeguard that is not running - which is worse than having none. diff --git a/tools/chemenu/commands/git_publish.py b/tools/chemenu/commands/git_publish.py index 62afc11..8f787a8 100644 --- a/tools/chemenu/commands/git_publish.py +++ b/tools/chemenu/commands/git_publish.py @@ -103,7 +103,7 @@ def _run(args: list[str], input: Optional[str] = None, # public repository, where a force-push does not take it back: the objects stay # fetchable by SHA until someone expires the server's reflogs. # -# Like the other two gates this refuses with exit 42 and has **no flag that +# Like the other token-cleared gates this refuses with exit 42 and has **no flag that # opens it**. The way past it is to name the URL in the file, which is an edit # the user makes deliberately rather than something an agent can decide mid-run. diff --git a/tools/chemenu/commands/new_page.py b/tools/chemenu/commands/new_page.py index 7e43210..6810fbf 100644 --- a/tools/chemenu/commands/new_page.py +++ b/tools/chemenu/commands/new_page.py @@ -379,7 +379,6 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]: "project with no page (a state `review`'s check 3 already reports), never a page with " "no tracker project. Still a single file write when no tracker is configured", budget=cli_contract.Budget.COUNTED, - gates=("human-intervention-required (`new project` only)",), ), notes=( "A title becomes a file name, so it must be valid and unique on Windows and macOS as " diff --git a/tools/chemenu/errors.py b/tools/chemenu/errors.py index c75cdba..6bfd9bc 100644 --- a/tools/chemenu/errors.py +++ b/tools/chemenu/errors.py @@ -41,11 +41,11 @@ class HumanInterventionRequired(ChemenuError): one write `chemenu.tasks.protocol.TaskWriter` asks of it. The CLI adapter (`wikitool new project`, Gitea #126) renders this the same - way it renders the four named gates in AGENTS.md's Gates section: + way it renders the named gates in AGENTS.md's Gates section: `commands._util.needs_clearance(str(exc))`, exit code 42 - "a human must see the command's output before anything proceeds" applies here for the same reason it applies to a mass update, just for a different cause. It is - not a fifth *named* gate (no threshold, no `--confirm` token to compute), + not a *named* gate (no threshold, no `--confirm` token to compute), but the same exit code and the same posture: show the message verbatim, stop, and do not improvise a workaround (AGENTS.md invariant 7). diff --git a/tools/chemenu/mcp/server.py b/tools/chemenu/mcp/server.py index 75a12ee..5c98aa6 100644 --- a/tools/chemenu/mcp/server.py +++ b/tools/chemenu/mcp/server.py @@ -82,7 +82,7 @@ def check_trace_destination(root: Path) -> None: dev checkout - so this guard resolves the same policy rather than reading `WIKI_TRACE` a second time, which is how it used to drift from the writer (AGENTS.md invariant 8). Today nothing on this path emits - the writer is - wired into `cli.main()` and the two gates, none of which run here - so + wired into `cli.main()` and the gates, none of which run here - so this is a guard against the future rather than a fix for the present. It is worth having anyway: the sync that keeps this checkout current is `git reset --hard`, so a trace written into the tree is both a per-request diff --git a/tools/chemenu/tests/test_cli_contract.py b/tools/chemenu/tests/test_cli_contract.py index 347367c..8af1f5b 100644 --- a/tools/chemenu/tests/test_cli_contract.py +++ b/tools/chemenu/tests/test_cli_contract.py @@ -289,9 +289,37 @@ def test_on_failure_omitted_when_no_cause_has_a_reaction(): 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_exit_42_cause_without_a_gate_is_allowed(): + """`new project`'s exit 42 is a human-intervention stop, not a gate: it needs no + register entry, and it is still listed as an exit 42 cause.""" + rec = _fixture_record( + failures=(cc.Failure(cause="A human must act", reaction="Show it and stop", code=42),) + ) + assert rec.properties.gates == () + assert cc._exit_codes(rec) == [0, 42] + assert cc.exit_status_entries(rec)[-1] == (42, "A human must act") + + +def test_a_gate_name_outside_the_register_is_refused(): + with pytest.raises(ValueError, match="unknown gate 'made-up'"): + cc.Properties( + effect=cc.Effect.WRITE, + idempotent=cc.Idempotent.NO, + atomic="No", + budget=cc.Budget.COUNTED, + gates=("mass-update", "made-up"), + ) + + +def test_every_registered_gate_is_a_usable_name(): + for name in cc.GATES: + cc.Properties( + effect=cc.Effect.READ, + idempotent=cc.Idempotent.YES, + atomic="Read-only", + budget=cc.Budget.COUNTED, + gates=(name,), + ) def test_failure_code_outside_0_1_42_is_refused(): diff --git a/tools/chemenu/tests/test_docs_verify.py b/tools/chemenu/tests/test_docs_verify.py index 3e23ba8..5e37b3c 100644 --- a/tools/chemenu/tests/test_docs_verify.py +++ b/tools/chemenu/tests/test_docs_verify.py @@ -958,6 +958,97 @@ def test_verify_raises_when_a_docs_file_is_outside_every_area(monkeypatch): docs_verify.verify() +# --- AGENTS.md § Gates against cli_contract.GATES ---------------------------- + + +def _agents_with_gates(tmp_path, monkeypatch, names: list[str], section: str = "## Gates") -> None: + monkeypatch.setattr(config, "ROOT", tmp_path) + entries = "".join(f"- **{name}.** Does something.\n" for name in names) + (tmp_path / "AGENTS.md").write_text( + f"# AGENTS\n\n{section}\n\nThese are enforced in code.\n\n{entries}\n" + "## Next\n\n- **Not A Gate.** Outside the section.\n", + encoding="utf-8", + ) + + +def _all_display_names() -> list[str]: + return [spec.display for spec in cli_contract.GATES.values()] + + +def test_this_repos_gates_section_matches_the_register(): + assert docs_verify.check_gates_list() == [] + + +def test_a_gates_section_naming_exactly_the_register_passes(tmp_path, monkeypatch): + _agents_with_gates(tmp_path, monkeypatch, _all_display_names()) + assert docs_verify.check_gates_list() == [] + + +def test_a_register_gate_missing_from_the_section_is_reported(tmp_path, monkeypatch): + names = [n for n in _all_display_names() if n != "Rebase-Review Gate"] + _agents_with_gates(tmp_path, monkeypatch, names) + issues = docs_verify.check_gates_list() + assert len(issues) == 1 + assert "does not list `Rebase-Review Gate`" in issues[0] + + +def test_a_section_entry_the_register_does_not_know_is_reported(tmp_path, monkeypatch): + _agents_with_gates(tmp_path, monkeypatch, [*_all_display_names(), "Invented Gate"]) + issues = docs_verify.check_gates_list() + assert len(issues) == 1 + assert "lists `Invented Gate`" in issues[0] + + +def test_a_bullet_outside_the_gates_section_is_not_an_entry(tmp_path, monkeypatch): + _agents_with_gates(tmp_path, monkeypatch, _all_display_names()) + assert "Not A Gate" not in " ".join(docs_verify.check_gates_list()) + + +def test_a_tree_with_no_gates_section_is_reported(tmp_path, monkeypatch): + _agents_with_gates(tmp_path, monkeypatch, [], section="## Elsewhere") + issues = docs_verify.check_gates_list() + assert len(issues) == 1 and "no `## Gates` section" in issues[0] + + +def test_a_tree_with_no_agents_md_is_reported(tmp_path, monkeypatch): + monkeypatch.setattr(config, "ROOT", tmp_path) + issues = docs_verify.check_gates_list() + assert len(issues) == 1 and "AGENTS.md is missing" in issues[0] + + +def test_an_exit_42_gate_no_record_declares_is_reported(tmp_path, monkeypatch): + _agents_with_gates(tmp_path, monkeypatch, _all_display_names()) + records = { + path: rec + for path, rec in cli_contract.all_records().items() + if "upload-review" not in rec.properties.gates + } + monkeypatch.setattr(cli_contract, "all_records", lambda: records) + issues = docs_verify.check_gates_list() + assert len(issues) == 1 + assert "`upload-review`" in issues[0] and "no command record declares it" in issues[0] + + +def test_the_budget_gate_needs_no_declaring_record(tmp_path, monkeypatch): + """It refuses with exit 1 and applies to every command, so no record names it.""" + _agents_with_gates(tmp_path, monkeypatch, _all_display_names()) + declared = {g for r in cli_contract.all_records().values() for g in r.properties.gates} + assert "iteration-budget" not in declared + assert docs_verify.check_gates_list() == [] + + +def test_new_declares_no_gate_but_still_lists_its_exit_42(): + rec = cli_contract.get("new") + assert rec.properties.gates == () + assert any(f.code == 42 and f.label == "new project" for f in rec.failures) + + +def test_verify_raises_when_the_gates_list_disagrees(monkeypatch): + monkeypatch.setattr(docs_verify, "check_gates_list", lambda: ["disagree"]) + with pytest.raises(typer.Exit): + docs_verify.verify() + + # The rewrite instructions/migrations/9.0.0-docs-diataxis.md tells an instance # to run, as the regex it spells out for `sed -E`. _DIATAXIS_NAMES = ( diff --git a/tools/chemenu/tests/test_git_publish.py b/tools/chemenu/tests/test_git_publish.py index 1d795c7..4f83fd3 100644 --- a/tools/chemenu/tests/test_git_publish.py +++ b/tools/chemenu/tests/test_git_publish.py @@ -1406,7 +1406,7 @@ def test_empty_allowlist_refuses_everything(repo): def test_gate_has_no_flag_that_opens_it(repo): - """The other two gates clear with a token; this one deliberately does not, + """The other gates clear with a token; this one deliberately does not, because the right fix is a deliberate edit by the user.""" import inspect diff --git a/tools/chemenu/tests/test_reference_doc.py b/tools/chemenu/tests/test_reference_doc.py index 8dedbef..5acf38e 100644 --- a/tools/chemenu/tests/test_reference_doc.py +++ b/tools/chemenu/tests/test_reference_doc.py @@ -268,7 +268,7 @@ def _page_type(description: str = "A fixture type.") -> reference_doc.PageType: def tree(tmp_path, monkeypatch): real_root = config.ROOT monkeypatch.setattr(config, "ROOT", tmp_path) - records = {"frobnicate": _record("frobnicate"), "gate keep": _record("gate keep", ("keep",))} + records = {"frobnicate": _record("frobnicate"), "gate keep": _record("gate keep", ("mass-update",))} monkeypatch.setattr(cli_contract, "all_records", lambda: dict(records)) monkeypatch.setattr(cli_contract, "GROUPS", GROUPS) monkeypatch.setattr(cli, "options_texts", lambda paths: {p: " --flag A flag." for p in paths}) @@ -416,7 +416,7 @@ def test_exit_codes_lists_each_gate_and_its_exit_42_causes(tree): root, _ = tree runner.invoke(app, ["docs", "reference", "--apply"]) page = (root / "docs/reference/exit-codes.md").read_text(encoding="utf-8") - assert "### keep\n\n- [`gate keep`](commands/other.md#gate-keep)" in page + assert "### mass-update\n\n- [`gate keep`](commands/other.md#gate-keep)" in page assert "- [`gate keep`](commands/other.md#gate-keep): Needs a person" in page assert "frobnicate" not in page