Files
chemenu/instructions/gates.md
T
torben 686c08bb14
CI / verify (push) Successful in 57s
Release / release (push) Successful in 35s
feat: wikitool upstream merge/verify - code procedure for taking a stack update (4.5.0-beta.1, #30)
Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- instructions/gates.md
- instructions/private-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/run_budget.py
- tools/chemenu/commands/upstream_cmd.py
- tools/chemenu/ownership.py
- tools/chemenu/tests/test_upstream_cmd.py
2026-09-04 07:08:49 +02:00

11 KiB

type, name, description
type name description
types/instruction.md gates What to do when wikitool refuses a call - exit 42 (user clearance required) on publish, 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.

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.

Read the exit code first - it says which of these applies:

Exit Meaning What to do
42 User clearance required Reproduce the command's output in your reply, stop. See below.
1 Validation error, or a budget/loop refusal Read the ERROR line; fix and retry once, or stop and escalate.

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. Three 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), and the Publish-Remote Gate (publish, on a push to a target this checkout has not declared) - but 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 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, 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 possible by construction, so it rebases automatically). Judge whether the incoming change conflicts logically with what you are about to publish, summarize that judgment, not just the diff, to the user, and only then re-run with the --confirm-rebase <token> the refusal prints.

A command's output is not visible to the user. On most harnesses stdout goes to the agent's context, not to the user's screen, so the tool having printed something and the user having seen it are different events. "See the output above", a summary, a file count, or a description of the change all leave the user approving something they never read. The one thing that discharges this is the content itself, restated in the reply.

The output says what would change, lists the evidence, and carries the exact line that proceeds once the user has approved. Nothing more about the procedure lives here on purpose: a recipe written down in the instruction layer is one an agent can perform start-to-finish without a human ever being involved, which is exactly the failure this replaced.

Paths under work/ are committed but never counted - the gate protects published knowledge, and a workshop is working state deleted when its run closes (work/CONTRACT.md). Not a gate you may widen: the prefix list is a constant in the tool. --path <dir> (repeatable) scopes a large change into reviewable batches, which is a legitimate alternative to one big clearance.

Background: Mass-Update Gate (kb/concepts/Mass-Update Gate.md).

Publish-Remote Gate

The Mass-Update Gate asks whether a change is too large to publish. This one asks the question underneath it: whether this is the right repository to publish to at all.

A checkout that holds private content usually has two remotes - its own, and the public upstream it takes stack updates from. Git does not distinguish them at push time, so one wrong --remote puts a private corpus on a public repository. That is not cheaply reversible: a force-push moves the branch, but the objects stay fetchable by SHA until someone expires the server's reflogs and runs git gc --prune=now on the bare repo.

.wikitool-remotes.json names the push URLs a checkout permits:

{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net:22/you/your-wiki.git"] }

It pins URLs, not remote names - a name-based list would wave through a publish whose origin had been repointed, which is the failure it exists to catch. It reads the remote's pushurl when one is set, because that is where git push actually writes.

The file is per-checkout and gitignored, for the same reason ENVIRONMENT.md is: two clones push to two different places, so a committed copy would tell a private clone that the public upstream is a legitimate target for its own content. Absent means unrestricted - a single-remote checkout with nothing private in it has nothing to protect, and doctor reports which state a checkout is in, WARNing only when there is more than one remote and no allowlist. A malformed file is an error rather than "no restriction": a corrupted safeguard must not read as a disabled one.

This gate has no --confirm token, on purpose. The other two clear with a token because the question they ask ("is this change right?") is one the agent can put to the user and the user can answer for that one changeset. This one asks "does this content belong to that repository?", which is a standing property of the checkout, not a per-push judgment. The way past it is for the user to add the URL to the file. An agent must never edit .wikitool-remotes.json to get past a refusal - that is opening a gate on your own initiative, which AGENTS.md invariant 6 forbids.

The setup this gate exists for - a private instance that takes stack updates from a public upstream - is private-instance.md. Step 4 there arms it, deliberately before the first publish: added afterwards it leaves open exactly the window it closes.

Mass-Update Gate blind spot: upstream merge

upstream merge (a private instance taking a stack update - see private-instance.md) can update or delete dozens of stack-owned paths in one commit, and the Mass-Update Gate does not see any of it. The gate counts working-tree changes before publish stages them; by the time upstream merge commits, the change is already history, and the commit it made is not what a later publish would be staging - that publish sees only whatever this session adds on top. A merge touching 200 files therefore goes out ungated the moment it is pushed.

This is not a hole to patch by making upstream merge route through the gate: the gate's question ("is this too much to publish?") does not apply to a change that only ever touches stack-owned paths that are, by definition, not this instance's own content. The check that actually matters here is upstream merge's own postcheck - it re-verifies the merge commit against upstream verify's logic immediately after committing, and refuses loudly (uncommitted state is not rolled back; the commit is left for a human to look at) if anything landed outside a stack-owned path. That is the safeguard for this command, not the Mass-Update Gate.

Iteration Budget Gate and loop-breaker

Every wikitool call is counted per session. Calls are refused past 60 in a session, or after 3 identical calls in a row - whichever trips first. The check runs before the command dispatches, so the command never ran.

Calibration: roughly 5-15 calls for a simple task, 20-35 for a complex multi-tool workflow such as an ingest or a full lint pass. The ceiling sits well clear of that band on purpose - it is not a target but the point past which a session is presumed stuck, and a real workflow carries overhead the band does not describe. A session that spends 60 calls on one task has a decomposition problem regardless of what the individual calls were.

The upper band is measured here, not inherited. It read 15-25 until 2026-08-31, taken from an industry rule of thumb; four consecutive real ingests then measured 24, 26, 29 and 30 calls, every one at or above that ceiling while doing nothing unusual. A guideline the normal case exceeds teaches an agent that the numbers are decorative, which is the opposite of what a calibration is for. Re-measure it the same way when the workflows change: tools/.wikitool_session/budget.json holds the per-session counts.

A call that declined is refunded. A rejected argument, or a read-only check reporting findings, exits 1 having changed nothing - and the tool error contract answers a rejected argument with "fix it and retry once", so charging for the rejection would make the prescribed response cost two slots for one operation. The call still enters the loop-breaker's history: repeating the same broken invocation is exactly what that instrument is for.

When it trips:

  1. Stop. Retrying is the failure mode the gate exists to prevent.
  2. Summarise progress and the blocker to the user. tools/wikitool budget status stays readable at all times and is never counted.
  3. Wait for explicit approval.

budget reset is not the escape hatch. It is deliberately counted like any other call, so at exactly the limit it is refused too. The only way past is --override-budget on the command you actually need to run, and only with the user's approval.

wikitool search is exempt from this budget entirely: retrieval is reading, not iterating.

Taking a new session id

The budget is scoped by WIKITOOL_SESSION_ID (session-setup.md), so a new id is a new budget. That is legitimate for a task made of several planned units - a tree ingest publishes one unit at a time - and is not legitimate as a way past a refusal.

A new session id may only be taken at a unit boundary written down in the run's plan.md, never in response to a gate refusal. The plan is the human approval the gate would otherwise have to ask for; a refusal means that approval has not been given yet. If you are tempted to re-export the variable after an ERROR line, that is the gate working.

Background: Iteration and Cost Limits (kb/concepts/Iteration and Cost Limits.md).

Scope

This covers refusals by gates. An ordinary validation error (a bad argument, a missing page, a duplicate title) is not a gate: fix the input and retry once, per the error contract in tools/CONTRACT.md.