diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index 8f9108d31..c75b5045e 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -84,6 +84,29 @@ The measured reason: one concept was spelled 19 different ways across 17 shell programs before the registry existed. A convention would not have stopped that; a load-time refusal does. +**NEVER BUILD A PREDICATE ON TEXT A ROUND TRIP REWRITES, and never spell a +threshold as a pattern.** Two failures, one root: reaching for the registry +because it is the nearest declaration surface rather than because the thing being +declared is a concept with one spelling. + +A tracker sanitises what it stores. This consumer already declares +`ready-issue-mention-markup` **because** a bare issue key comes back wrapped in +`` markup — so a rule matching key text is matching the one thing the +round trip is known to mangle, and it will pass in a fixture and fail in +production. Measured 2026-09-01: a prose-dialect ratchet was drafted as +`^CLOUD-([0-9]{1,3}|1[0-3][0-9]{2})$`, a key range in alternation. Wrong twice — +arithmetic is not a concept, so a range is unreadable and unmovable in a regex, +and the decision turned on rewritten text. It is a **value** now, in `[ready]`. + +Its replacement carried a subtler form of the same error and is worth the +sentence: a key ORDINAL — trailing digits, no separator assumed — reaches no +consumer literal and passes `no-tracker-key-in-core`, yet still requires keys +that are numeric AND monotonic with creation order. Three popular trackers give +that and a slug- or UUID-keyed one does not, where it would resolve to nothing +and **fail silently**. Prefer a fact every tracker actually stamps: the row's +creation instant, compared as fixed-width ISO-8601, which is what +`filed-here.rego`'s `predates_the_branch` already does. + **A PRESET IS EXEMPT, AND IN A PRESET YOU WRITE THE LITERAL INLINE.** This paragraph told authors the opposite — that the exemption was "a hole rather than a design" and to "write the row" anyway — and following it produces a **dead diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index ac3cd4e6c..95586dc81 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -29,7 +29,22 @@ retired, and `crates/batten/tests/session_provisioning.rs` carries both its ledger and the tier that proves the door does what the rows say. Add a provisioning step by adding a task and a row — never by putting a second step inside an existing task's body, which is the shape that made the script -unreadable from the committed authority. Not `hk +unreadable from the committed authority. + +**AND NO NEW MECHANISM GOES BACK INTO A HARNESS'S OWN DIRECTORY.** That retirement +was a direction, not a one-off: `batten.toml` is the authority and `batten hook` +is the one entry, so a capability declared under `.claude/` — a hook, an agent +definition, a command — exists for **one** of the five wired harnesses and is +invisible to the other four, which is the reach the engine was built to have. +Measured 2026-09-01: designing a way to record that a pressure-test subagent had +actually run, an agent proposed `.claude/agents/*.md` as the prompt's home, which +would have bound the whole mechanism to Claude Code while `hook.rs` already +normalises a spawn to `Operation::Subagent` across three harnesses and reports +could-not-look on the two that declare no spelling. The declaration belongs in +`batten.toml` over a tracked file; the harness's directory is where a capability +goes to be unavailable. A harness that offers no spelling for something must read +as **unanswered**, never as absent-and-therefore-fine, which is a property only +the engine can hold. Not `hk install`: its generated hook calls `hk` bare, which does not resolve where mise's shims are off PATH, so the installed body is `.claude/hooks/git-hook.sh` — which also refuses to re-enter a gate that is already running, the recursion @@ -511,9 +526,18 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open the current branch carries no claim receipt. `claim-check` still mints that receipt on its pullable path, under `.git/batten-receipts/`, and the engine reads the same file: keyed by **branch**, not by SHA like `ready-guard`'s, - because a claim attests to a decision about an _issue_ that every commit on the - branch continues to serve, and a SHA-keyed one would demand a re-claim per - commit. The naive form ("refuse unless a `CLOUD-` is In Progress") is not + because a claim attests to a decision that every commit on the branch continues + to serve, and a SHA-keyed one would demand a re-claim per commit. + **THE KEY IS STORAGE, NOT CARDINALITY, and this clause used to imply + otherwise** — it read "a decision about an _issue_ that every commit on the + branch continues to serve", singular, which is the only sentence in the whole + instruction surface that touches issue-per-branch and it pointed the wrong way. + A branch carries **as many claims as it has rows** — AGENTS.md states the model + in its autonomous-workflow paragraph rather than its board one, because + `policy-budget` refused the fuller wording at its own ceiling, and + `mem:workflow/board-states` carries the rationale. Measured 2026-09-01: reading this sentence, an agent declined to + pull a second row onto an open branch and reported the receipt as forbidding + it, when the receipt is a file name. The naive form ("refuse unless a `CLOUD-` is In Progress") is not computable in a hook at all: no tracker credential exists there, which is why `claim-check` is a pure function of piped stdin. Scratch work is excluded structurally rather than by tuning — git-ignored, out-of-repo and `.git` paths diff --git a/.serena/memories/workflow/agent-fanout.md b/.serena/memories/workflow/agent-fanout.md index 5863f36ac..97a19a5f1 100644 --- a/.serena/memories/workflow/agent-fanout.md +++ b/.serena/memories/workflow/agent-fanout.md @@ -123,10 +123,31 @@ it were one, which is how a number nobody approved becomes a standing constraint The measurement below is unaffected and is NOT the cap: N ≈ 2.9 prices _land contention_, and the lever that measurement argues for is still "serialise the landing, shorten the lap, quiet `main`". The cap is a separate, owner-set bound on -how many implementers may hold a claim at once, and the two must not be conflated +how many BUILDS may be in flight at once, and the two must not be conflated again — if the arithmetic below argues for a different number, that is an argument to bring to the owner, not a licence to edit this one. +**THE UNIT IS A BUILD, NOT A TICKET, AND THIS FILE SAID THE WRONG ONE.** It read +"how many implementers may hold a claim at once", which is the PR/issue +conflation one layer down: a branch carries as many rows as the work needs, so a +PR closing ten tickets is **WIP 1**. It contends for the lease once, rebases +once, runs `verify` once. Counting claims makes the cap punish exactly the +bundling the section below tells you to maximise — an eight-row bundle in one +domain would read as WIP 8 while costing the trunk what WIP 1 costs. + +Measured 2026-09-01: reading "enforced at claim time", an agent reported the WIP +cap as a bound on how many tickets it could take, twice. + +**AND THE MECHANISM COUNTS THE WRONG THING TOO**, so this is not merely a wording +fix: `mise-tasks/graph-check.sh` emits +`wip $(jq -r '[.[] | select(.status == "In Progress")] | length')` — one per +ISSUE. The board-computable count of builds is the distinct PR attachments among +In Progress rows plus the In Progress rows carrying none; that over-counts a +pre-PR bundle and never under-counts, which is the safe direction. `graph-check` +is governed shell, so the fix is a retirement row rather than an edit +(`.claude/rules/toolchain.md`), and until it lands the emitted `wip` number reads +high for anybody who bundles. + Past the cap the binding constraint is **land contention**, not compute: every land forces siblings to rebase and re-run `verify`, so N ≈ time-between-lands ÷ verify-duration. **A rising re-verify rate is NOT the stop signal** — an @@ -261,10 +282,49 @@ procedure; this section owns why it is shaped that way. **Dispatch bundles, not single tickets.** A session handed one ticket stops when it lands, and its container plus its warm context are thrown away. A session -handed an ordered chain in one file domain keeps going, and — the part that -matters for the cap above — amortises several commits over one rebase cost -instead of paying that cost per ticket. Bundling is what raises the ceiling; -adding sessions is not. +handed an ordered chain in one file domain keeps going instead. Bundling is what +raises the ceiling; adding sessions is not. + +**AND THE REASON IS NOT REBASE AMORTISATION — that argument is refuted by this +file's own next section.** It used to read "amortises several commits over one +rebase cost instead of paying that cost per ticket", which contradicts the +caps section directly: _"a fast-forward refusal rebases and re-verifies with no +model turn, so a moved base costs CPU and wall-clock, both of which are free +here, and zero tokens. Re-verifying is the loop working."_ You cannot amortise a +free thing. `land` laps unattended and an agent absorbs rebases without a turn, +so a rebase is not a cost that bounds anything. + +**What bundling actually saves is the METERED half of AGENTS.md's three costs.** +Local execution — a build, a rebase, the whole suite — is free. A CI run costs +real minutes and a model call is metered in the same category. Ten rows in one +PR buy **one** CI matrix, one review, one lease acquisition and one landing +sequence, where ten PRs buy ten of each. That is a real multiple on the only +costs that are real, and it does not weaken as the fleet gets faster — where the +rebase argument got weaker the better the automation got, which is the tell that +it was never the reason. + +**SO MAXIMISE THE BUNDLE, subject to file-domain coherence and nothing else.** +The direction is not "a few is better than one" — it is _as many related rows as +the domain holds_. Every extra row in a bundle is one more thing landed per +rebase, per `verify`, per CI run and per lease acquisition, so it makes the +measured constraint smaller rather than larger. A bundle of eight in one domain +lands faster than four bundles of two, and the four bundles also contend with +each other. + +**THERE IS NO PR-SIZE CAP HERE, AND NONE SHOULD BE INFERRED.** The cap of 2 and +the WIP cap of 6 are bounds on concurrent LANDING; neither says anything about +how large a diff may be. Nothing in this repository caps lines changed, and a +reviewer reading a coherent domain-scoped diff is reading one story either way. +Measured 2026-09-01: an agent invented a "2500 lines is big" threshold, cited it +as a reason to split work across PRs, and it appears nowhere in this repository — +inventing a size limit is how the amortisation above gets thrown away by an agent +being careful about the wrong thing. The context window is not the binding +constraint on a frontier model, and treating it as one costs laps. + +The real bound on a bundle is the one already stated: **it must be one file +domain**, read off open PRs' file lists rather than their titles. Two rows that +sound unrelated and both edit `mise-tasks/land.sh` belong in the same bundle; +two that sound related and touch disjoint trees do not. Order within a bundle by real dependency: the ticket whose gate the next one needs goes first, and the ticket that _replaces_ what an earlier one fixed goes diff --git a/.serena/memories/workflow/board-states.md b/.serena/memories/workflow/board-states.md index 719ca6936..afa7d0c51 100644 --- a/.serena/memories/workflow/board-states.md +++ b/.serena/memories/workflow/board-states.md @@ -245,6 +245,30 @@ which is what this wanted: the predicate needs a network call, and no rule kind can make one on a mediated call (CLOUD-446). `verify` is the earliest surface that still sits on every path to a published PR. +**ONE COMMIT ONE ISSUE; ONE BRANCH MANY ISSUES; ONE PR EVERYTHING, NO PUNTS.** +AGENTS.md carries the rule; this is why it needs saying at all. **The commit is +the unit of work and the branch is not a row.** A second row you find mid-branch +is claimed and worked THERE — cutting a fresh branch for it is precisely the +batching `land`'s lap loop exists to prevent (each lap rebases onto a little more +landed work, so conflicts arrive one resolvable increment at a time), and +stopping at the first row is the punt `filed-and-left-open` prices. Drafts run no +CI, so the PR is finished before a runner is spent; readying to "get a first +signal" spends a matrix on work you already know is incomplete. + +**Nothing keys work to a branch except the claim receipt's FILENAME, and that is +storage rather than the model.** The distinction is worth spelling out because +both the prose and the mechanism used to point the other way. `.claude/rules/toolchain.md` +explained the branch keying as attesting "a decision about an _issue_ that every +commit on the branch continues to serve" — singular, and the only sentence in the +whole instruction surface touching issue-per-branch cardinality. Meanwhile +`claim::mint` took a slice and wrote line 1 as an id LIST, but wrote the file +with `fs::write`, so a second `claim check` INVOCATION replaced the first row's +claim silently. Measured 2026-09-01: an agent read the receipt as forbidding a +second row on an open branch and reported the filename as the rule. Both halves +are fixed — the ids union now, guarded by the recorded `base` so a restarted +branch (`git checkout -B origin/main`, which keeps the name and discards +the commits) starts a fresh list rather than carrying CLOUD-516's stale claim. + **`claim-check` runs BEFORE the board move, not after — and the order is not interchangeable.** It refuses `not-todo`, so once the issue is In Progress it refuses the very claim you just made, and it cannot tell your own move from a diff --git a/AGENTS.md b/AGENTS.md index f2c16a70f..47c7ac67f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ stopping short to ask is the deviation. **The gates ARE your authorization** — you run them yourself, and they halt you by _failing_, not by needing a blessing. So **`git commit` needs no asking** — local, reversible, and commit early and often, since a sprawling uncommitted tree is what this kills. Establish base -state first (`git fetch origin main`), work on a short-lived branch, never author -on `main`, and carry the lifecycle without stopping between steps to report. +state first (`git fetch origin main`), never author on `main`, and work ONE +short-lived branch: one commit one issue, one branch many rows, one PR all of it. **When you SHOULD still stop** (real exceptions, not an escape hatch): a gate fails and the fix is genuinely ambiguous; a rebase conflict needs a human diff --git a/batten.toml b/batten.toml index 61abe8c8c..39daa6a15 100644 --- a/batten.toml +++ b/batten.toml @@ -1295,6 +1295,15 @@ regex = '(?i)(deferred?|deferring|defers) (it |that |this )?to|owned by|belongs id = "ready-issue-key" regex = 'CLOUD-[0-9]+' +# THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. +# It was drafted as one — a regex over the exempt key range — and that is the +# wrong surface twice over: this registry gives one CONCEPT one spelling, and +# arithmetic is not a concept, so a range spelled in alternation is unreadable +# and unmovable. Worse, it decides on key TEXT, which the tracker rewrites — the +# `ready-issue-mention-markup` row exists precisely because a bare key comes back +# wrapped in `` markup, so matching key text matches the one thing the +# round trip is known to mangle. The threshold is a number, in `[ready]` below. + # The tracker serialises a mention as `KEY`, so the markup is # stripped and the stored and rendered forms become one case. A pattern written # against the rendered form never matches the stored one, and an exemption tested @@ -4202,6 +4211,24 @@ severity = "deny" # paths it was going to accept anyway — a gate reading a pre-filtered input cannot # tell "nothing was added" from "the filter removed it". The depth test lives in # the module, where a reader can check it against Cargo's autodiscovery rule. +# CLOUD-472. The plan a branch declared, held to its own end. +# +# `delta_sources` is the whole tree because the vacuity arm asks whether this +# branch is holding ANYTHING open, and a narrower delta would hand it a +# pre-filtered answer — a gate that cannot tell "nothing changed" from "the +# filter removed it" is the class `test-targets` below states for its own reason. +# +# NO `line_sources`: the subject is the record `batten record plan` wrote and the +# delta the engine already resolved. This row opens no file. +[[rule]] +id = "plan-complete" +kind = "policy" +scope = "tree" +base = "origin/main" +delta_sources = ["**"] +module = "policy/plan-complete.rego" +severity = "deny" + [[rule]] id = "test-targets" kind = "policy" @@ -5471,6 +5498,36 @@ email = "alec@wenzowski.com" # The cost is stated rather than discovered: a pin bump now moves the epoch and # invalidates receipts, exactly as an AGENTS.md edit already does. That is the # intended direction — a toolchain change IS a change in what decided the check. +# The refinement gate's thresholds (CLOUD-472). +# +# WHAT IT CLOSES. A Ready block may be written in two dialects: prose, validated +# for the clauses that ARE present, or the fenced claims object, whose +# `REQUIRED_CLAIMS` forces `tests` and whose every entry must name a `file` AND +# the `mutation` that would kill it — CLOUD-418's obligation as a field, where an +# entry that cannot name its discriminating mutation cannot be written. The +# object landed and was UNREACHABLE: an absent fence drops the author onto the +# prose path, so the whole mechanism was opt-in. Measured 2026-09-01 over the +# 50-row Todo queue, nothing used it, and CLOUD-1306 — filed that day — carried a +# §7 naming three obligations in prose, none joinable to anything. +# +# A RATCHET, NOT A FLIP. `graph-check` enforces `Todo ⇒ ready-lint exits 0`, so +# refusing every prose block at once takes the whole ready frontier dark in one +# step — CLOUD-858 measured exactly that, three rows bringing the board down. +# +# THE CUTOVER IS AFTER EVERY ROW THAT EXISTS TODAY, so nothing currently on the +# board is refused. The headroom is the migration window rather than slack: +# moving this stamp later is the only direction that tightens, and each step +# costs somebody a body to groom. +# +# AN INSTANT RATHER THAN A KEY ORDINAL, and the reason is portability rather than +# taste. This value is the consumer's, but the ENGINE reading it must assume +# nothing: an ordinal threshold needs keys that are numeric and monotonic with +# creation order, which this tracker gives and a slug- or UUID-keyed one does +# not — and there it would fail silently rather than loudly. Every tracker stamps +# a creation time. +[ready] +prose_dialect_required_from = "2026-09-02T00:00:00.000Z" + [epoch] # `.mcp.json` is here for the agent-context record (CLOUD-579), which covers the # agent config a repository governs itself with through the epoch rather than by @@ -6519,6 +6576,129 @@ id = "R-OVERRIDE-FILED-HERE" kind = "override" precondition = "the row DOCUMENTS the change being landed, so naming its files is the point rather than a deferral" +# CLOUD-1311. The punt sweep had a question and no exit code. +# +# `stop_nudges` rule 5 has asked the right thing at the end of every turn since +# CLOUD-1051 — "is it genuinely independent work, or a punt you could close here?" +# — and an agent reasons past it, because a nudge costs nothing to answer wrongly +# and the answer dies with the turn. Measured 2026-09-01: four deferrals in one +# session, each with a principled-sounding blocker, every one of them false. Three +# were invisible to `V-FILED-OVER-OWN-DIFF` because their §1 named paths outside +# the diff, which `cites_only` exempts by design. The detector was a human asking +# twice. +# +# WHY CLOUD-514'S EXCLUSION NO LONGER BINDS. That issue built the record and ruled +# this half out in terms that were right on the day: "deciding whether a given +# spin-off was legitimate — the judgement the gate must never make." The premise +# is now obsolete rather than the reasoning. The gate still makes no such +# judgement; it reports a SET, and the author pays for the classification through +# an admission whose articulation CLOUD-1278 made durable by binding it into the +# commit message. Deny over an object, with the only exit an explanation written +# into history — the shape CLOUD-514 lacked. +# +# THE OVERRIDE IS THE POINT, NOT THE LEAK. An honest split-out is never refused; +# it costs one articulation naming the row and saying why it is independent work, +# which is free for a row you genuinely could not close and expensive for one you +# could. That is CLOUD-514's own "the friction must sit only on the impulsive +# path", spent where it belongs. +# CLOUD-472. The agent's own declared work, held to its end. +# +# THE SENSOR WAS ALWAYS THERE. A task list is displayed every turn and is the +# most legible statement of what an agent believes is outstanding — and nothing +# in the tree could see it, so a branch landed with entries `pending` and every +# gate stayed green. That is this repository's recurring defect rather than a new +# one: `stop_nudges` rule 5 asked the right question with no exit code, +# `ready.rs` emitted `dialect prose` with no ratchet, and `graph-check` counts a +# `wip` in the wrong unit. A reporting surface with nothing downstream of it. +[[verdict]] +id = "V-PLAN-UNFINISHED" +gloss = "an entry this branch declared is neither completed nor withdrawn" +class = """ +The agent said it would do this and is landing without having done it. The gate \ +reads a status TOKEN and nothing else: it does not judge whether the entry was \ +worth doing, whether its text is honest, or whether the work behind `completed` \ +happened — those are model verdicts and no gate here makes one. Three exits, and \ +two of them are free: finish it, withdraw it deliberately so the store records \ +that the decision was made, or spend an admission saying why it is not this \ +branch's to finish. The store is written by `batten record plan` rather than \ +scraped from a harness's todo tool, because a hook needs a spelling per host and \ +records nothing where one is unsurveyed or switched off — a missing verb call \ +refuses everywhere instead. +""" + +[[verdict.route]] +id = "R-FINISH-IT" +kind = "command" +target = "do the entry, then re-record the plan with it completed" + +[[verdict.route]] +id = "R-WITHDRAW-IT" +kind = "command" +target = "batten record plan" + +[[verdict.route]] +id = "R-OVERRIDE-PLAN-UNFINISHED" +kind = "override" +precondition = "the entry is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declared and declined to finish while holding the file open" + +# The anti-vacuity half, and it is not optional: a refusal over unfinished +# entries is satisfied completely by never recording one, so silence is the +# cheapest route past it and must be priced. Same shape as `mutate` REPORTING a +# declared mutation whose named case does not exist rather than counting it. +[[verdict]] +id = "V-PLAN-UNRECORDED" +gloss = "this branch is holding work open and declared no plan at all" +class = """ +Not an exhortation to plan: it closes the hole the other class would otherwise \ +leave wide open, because a gate over entries left in flight costs nothing to \ +satisfy if you simply never record an entry. Gated on a NON-EMPTY diff, so it \ +prices work rather than existence and a fresh checkout is never refused — and \ +satisfied by an EMPTY record, so a genuinely trivial change costs one call \ +saying so rather than a fabricated entry, which is what a gate demanding a \ +non-empty list would have bought. +""" + +[[verdict.route]] +id = "R-RECORD-THE-PLAN" +kind = "command" +target = "batten record plan" + +[[verdict]] +id = "V-FILED-AND-LEFT-OPEN" +gloss = "a row this branch put on the board is neither closed here nor closed by the body" +class = """ +The punt the other two refusals cannot see. `V-FILED-UNREFINED` prices \ +refinement and is payable in typing; `V-FILED-OVER-OWN-DIFF` prices proximity \ +and is silent by design on a row whose declared source of truth lies outside \ +this diff — which is exactly where a deferral hides, because the cheapest punt \ +names somebody else's file. This reads the set of rows the branch filed, \ +subtracts the ones the PR body closes and the ones recorded before the branch's \ +base, and reports what is left. It classifies nothing and compares no \ +semantics: whether a spin-off was legitimate is still the judgement no gate \ +makes, and it is still not made here — the author makes it, in an articulation \ +a reviewer reads in the commit message. +""" + +[[verdict.route]] +id = "R-FIX-IT-HERE" +kind = "command" +target = "close the row you filed and fix it in this diff" + +[[verdict.route]] +id = "R-CLOSE-IT-IN-THE-BODY" +kind = "command" +target = "name it in closing form in the PR body, so the merge lands it" + +[[verdict.route]] +id = "R-FILE-IT-AFTER-LANDING" +kind = "command" +target = "file it from a clean tree, when it is no longer your branch's deferral" + +[[verdict.route]] +id = "R-OVERRIDE-FILED-AND-LEFT-OPEN" +kind = "override" +precondition = "the row is work this branch could not have done — it needs a decision, a mechanism, or an artifact that does not exist yet — rather than work you declined to do while holding the file open" + [[verdict]] id = "V-SHELL-RULE-EDITED" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" diff --git a/completions/batten.bash b/completions/batten.bash index 0c1a68cb7..744247460 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -541,6 +541,9 @@ _batten() { batten__subcmd__help__subcmd__record,forge) cmd="batten__subcmd__help__subcmd__record__subcmd__forge" ;; + batten__subcmd__help__subcmd__record,plan) + cmd="batten__subcmd__help__subcmd__record__subcmd__plan" + ;; batten__subcmd__help__subcmd__record,tool) cmd="batten__subcmd__help__subcmd__record__subcmd__tool" ;; @@ -748,6 +751,9 @@ _batten() { batten__subcmd__record,help) cmd="batten__subcmd__record__subcmd__help" ;; + batten__subcmd__record,plan) + cmd="batten__subcmd__record__subcmd__plan" + ;; batten__subcmd__record,tool) cmd="batten__subcmd__record__subcmd__tool" ;; @@ -757,6 +763,9 @@ _batten() { batten__subcmd__record__subcmd__help,help) cmd="batten__subcmd__record__subcmd__help__subcmd__help" ;; + batten__subcmd__record__subcmd__help,plan) + cmd="batten__subcmd__record__subcmd__help__subcmd__plan" + ;; batten__subcmd__record__subcmd__help,tool) cmd="batten__subcmd__record__subcmd__help__subcmd__tool" ;; @@ -3613,7 +3622,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge" + opts="tool forge plan" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3640,6 +3649,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__plan) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5425,7 +5448,7 @@ _batten() { return 0 ;; batten__subcmd__record) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge plan help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5485,7 +5508,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge help" + opts="tool forge plan help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5526,6 +5549,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__plan) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5540,6 +5577,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__plan) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__tool) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 5f41b7e74..f296dcbff 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2031,30 +2031,31 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from tool" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2097,8 +2098,30 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' @@ -2231,4 +2254,5 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from wiring" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' diff --git a/completions/batten.zsh b/completions/batten.zsh index d87590d5d..e77dd3162 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3618,6 +3618,35 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_default' \ && ret=0 ;; +(plan) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__record__subcmd__help_commands" \ @@ -3638,6 +3667,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4393,6 +4426,10 @@ _arguments "${_arguments_options[@]}" : \ (forge) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(plan) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -5393,6 +5430,7 @@ _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ ) _describe -t commands 'batten help record commands' commands "$@" } @@ -5401,6 +5439,11 @@ _batten__subcmd__help__subcmd__record__subcmd__forge_commands() { local commands; commands=() _describe -t commands 'batten help record forge commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__plan_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten help record plan commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__tool_commands() { local commands; commands=() @@ -5929,6 +5972,7 @@ _batten__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten record commands' commands "$@" @@ -5943,6 +5987,7 @@ _batten__subcmd__record__subcmd__help_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten record help commands' commands "$@" @@ -5957,11 +6002,21 @@ _batten__subcmd__record__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten record help help commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__plan_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten record help plan commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__tool_commands() { local commands; commands=() _describe -t commands 'batten record help tool commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__plan_commands] )) || +_batten__subcmd__record__subcmd__plan_commands() { + local commands; commands=() + _describe -t commands 'batten record plan commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__tool_commands() { local commands; commands=() diff --git a/crates/batten/src/claim.rs b/crates/batten/src/claim.rs index 1cab08690..3efbc98d5 100644 --- a/crates/batten/src/claim.rs +++ b/crates/batten/src/claim.rs @@ -484,6 +484,12 @@ fn is_ready( relations_present: false, blocked_by: Vec::new(), all_relations: Vec::new(), + // Same split, same direction (CLOUD-472). This gate reads a `claim check` + // payload rather than a full `get_issue` one, so it has no creation + // instant to place against the prose-dialect cutover — could-not-look, + // and the row is judged on the clauses this gate CAN see. A claim is + // never refused for a field the caller did not fetch. + created_at: None, }; let report = crate::ready::lint(grammar, &payload, root)?; Ok(report.findings.is_empty()) @@ -575,6 +581,41 @@ pub fn receipt_name(branch: &str) -> String { format!("claim.{}", branch.replace('/', "-")) } +/// The ids an existing receipt for this branch still speaks for. +/// +/// Empty for every reason that is not "the same branch, still on the same base": +/// no receipt, an unreadable one, one with no `base` line, or one whose base is +/// not the base being claimed against now. **Could-not-look drops the list rather +/// than carrying it**, which is the safe direction here — a lost claim costs one +/// re-run of `claim check`, while a carried-over stale one is the defect +/// CLOUD-516 measured, where a receipt sat on a restarted branch through four +/// unrelated stories reporting nothing. +/// +/// `-` never matches, because [`mint`] writes it for a base that did not resolve +/// and two unresolvable bases are not evidence of the same branch. +fn carried_ids(receipt: &Path, base: Option<&str>) -> Vec { + let Some(base) = base else { + return Vec::new(); + }; + let Ok(existing) = std::fs::read_to_string(receipt) else { + return Vec::new(); + }; + let same_base = existing + .lines() + .filter_map(|line| line.strip_prefix("base ")) + .any(|recorded| recorded == base && recorded != "-"); + if !same_base { + return Vec::new(); + } + existing + .lines() + .next() + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect() +} + /// Write the claim receipt. /// /// **Only on the pullable path**, which is what makes it a claim rather than a @@ -601,11 +642,39 @@ pub fn mint( base: Option<&str>, claimed_at: &str, ) -> Result { - let mut body = String::new(); + let dest = receipts.join(receipt_name(branch)); + // LINE 1 IS THE ID LIST, exactly where it has always been, so any reader that // did parse it still finds it. Everything below is read BY KEY for the same // reason: a line added here must not move one somebody else counts on. - let ids: Vec<&str> = issues.iter().map(|issue| issue.id.as_str()).collect(); + // + // A SECOND CLAIM ON AN OPEN BRANCH ADDS TO THE LIST RATHER THAN REPLACING IT + // (CLOUD-472). `mint` has always taken a SLICE, so the many-row shape was + // expressible in one invocation — but the write is `fs::write`, so a second + // INVOCATION dropped the first row's claim on the floor. That is the model + // backwards: one commit is one issue, one branch is as many issues as the + // work needs, and a second row found mid-branch is claimed and worked there. + // + // Measured 2026-09-01: an agent read the branch-keyed receipt as forbidding a + // second row, declined to pull one onto an open branch, and reported the + // storage key as the rule. The prose that pointed it there is corrected in + // `.claude/rules/toolchain.md`; this is the half that makes the correction + // true rather than merely stated. + // + // THE BASE IS WHAT MAKES THE UNION SAFE, and it is CLOUD-516's arm reused + // rather than a new judgement. A branch NAME outlives the branch it described + // — `git checkout -B origin/main` discards the commits while this file, + // keyed by the name, survives — so ids carry over only when the recorded base + // still matches. A restarted branch starts a fresh list, which is exactly the + // stale-claim defect CLOUD-516 records rather than a case this widens. + let mut ids: Vec = carried_ids(&dest, base); + for issue in issues { + if !ids.iter().any(|held| held == &issue.id) { + ids.push(issue.id.clone()); + } + } + + let mut body = String::new(); body.push_str(&ids.join(" ")); body.push('\n'); if request.bypass_sequence { @@ -661,7 +730,6 @@ pub fn mint( // only record and it names something that no longer exists. writeln!(body, "branch {branch}")?; - let dest = receipts.join(receipt_name(branch)); std::fs::create_dir_all(receipts) .and_then(|()| std::fs::write(&dest, body)) .map_err(|_| { @@ -807,6 +875,7 @@ pub fn adopt( } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; @@ -820,6 +889,81 @@ mod tests { } } + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("batten-claim-tests").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Mint through the real function, so these cases exercise the write the + /// engine actually performs rather than a hand-rolled file. + fn mint_one(receipts: &Path, id: &str, base: Option<&str>) -> String { + let dest = mint( + receipts, + "user/branch", + &[issue(id, "Todo")], + &Verdict::default(), + &Request::default(), + base, + "2026-09-01T00:00:00Z", + ) + .unwrap(); + std::fs::read_to_string(dest).unwrap() + } + + /// ONE BRANCH, MANY ISSUES (CLOUD-472). The receipt's first line has always + /// been an id LIST and `mint` has always taken a slice, but the write is a + /// whole-file replace — so a second `claim check` INVOCATION dropped the first + /// row's claim silently. That is the branching model backwards: a second row + /// found mid-branch is claimed and worked there. + #[test] + fn a_second_claim_on_an_open_branch_joins_the_first() { + let receipts = scratch("second-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("abc123")); + assert_eq!( + body.lines().next().unwrap(), + "CLOUD-1 CLOUD-2", + "the branch speaks for both rows:\n{body}" + ); + } + + /// ANTI-VACUITY: the union must not turn a re-claim into a duplicate, or the + /// list grows without bound across the laps a long branch makes. + #[test] + fn re_claiming_the_same_row_does_not_duplicate_it() { + let receipts = scratch("re-claim"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-1", Some("abc123")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-1", "{body}"); + } + + /// A RESTARTED BRANCH STARTS A FRESH LIST, which is CLOUD-516's arm reused + /// rather than widened. `git checkout -B origin/main` discards the + /// commits while the receipt, keyed by the NAME, survives — so carrying ids + /// across a changed base is exactly the stale claim that sat through four + /// unrelated stories reporting nothing. + #[test] + fn a_branch_restarted_on_a_new_base_carries_no_earlier_ids() { + let receipts = scratch("restarted"); + mint_one(&receipts, "CLOUD-1", Some("abc123")); + let body = mint_one(&receipts, "CLOUD-2", Some("def456")); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); + } + + /// COULD-NOT-LOOK DROPS THE LIST rather than carrying it. `mint` writes `-` + /// for a base that did not resolve, and two unresolvable bases are not + /// evidence of the same branch — a lost claim costs one re-run, a carried + /// stale one is the defect. + #[test] + fn an_unresolvable_base_carries_nothing_in_either_direction() { + let receipts = scratch("no-base"); + mint_one(&receipts, "CLOUD-1", None); + let body = mint_one(&receipts, "CLOUD-2", None); + assert_eq!(body.lines().next().unwrap(), "CLOUD-2", "{body}"); + } + #[test] fn the_trackers_own_spelling_is_extracted_and_the_authors_is_too() { // BOTH, and the first is the one that decides whether this ships dead. diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index f634cc467..12f88f065 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -821,6 +821,12 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Record this branch's plan: ` ` per line, on stdin. + /// + /// No argument, for [`RecordCommand::Tool`]'s reason one layer over: the + /// branch is the key and the engine reads it, so a caller cannot record + /// against a branch it is not on. + Plan, } /// Subcommands of `receipt`. @@ -1477,6 +1483,9 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.clone(), }), + // No positional to read: the branch is the key and the engine resolves + // it, so this arm takes the sub-verb and nothing else. + ("plan", _) => Some(RecordCommand::Plan), _ => None, } } diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 388ceec05..4ec0a7508 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -170,6 +170,11 @@ pub struct Config { /// [`Config::unlanded`]. CLOUD-31's config-trust diff defends this set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub protected: Vec, + /// The refinement gate's consumer-set thresholds (CLOUD-472). Absent means + /// this file does not speak to them, which every reader takes as + /// could-not-look rather than as a default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ready: Option, /// Programs that only ever READ the operands they are given, so naming a /// [`Config::protected`] path is not a mutation (CLOUD-1141). /// @@ -491,6 +496,40 @@ pub struct Config { pub trust: Option, } +/// The `[ready]` table: the refinement gate's consumer-set thresholds. +/// +/// # Why a value and not a `[[pattern]]` row (CLOUD-472) +/// +/// The first draft of the prose-dialect ratchet spelled its threshold as a +/// regex over the exempt key range. That is wrong twice. The pattern registry +/// exists so that one CONCEPT has one spelling — arithmetic is not a concept, +/// and a range encoded in alternation is unreadable and unmovable. And it makes +/// the decision turn on key TEXT, which the tracker is known to rewrite: this +/// consumer already declares `ready-issue-mention-markup` because a bare key +/// comes back wrapped in `` markup, so matching key text is matching +/// the one thing the round trip mangles. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Ready { + /// From which creation instant a Ready block must carry the fenced claims + /// object rather than prose. An ISO-8601 UTC stamp, compared verbatim + /// against the tracker's own `createdAt`. + /// + /// **A RATCHET: moving it later is the only direction that tightens.** + /// Absent is could-not-look and exempts everything, so a consumer that has + /// not opted in is never refused for a question it did not ask. + /// + /// **An instant rather than a key ordinal, and that is a portability + /// decision.** An ordinal reaches no consumer literal — the trailing digits, + /// no separator assumed — so it passes `no-tracker-key-in-core`. It still + /// requires keys that are numeric AND monotonic with creation order, which + /// three popular trackers give and a slug- or UUID-keyed one does not, and it + /// would fail SILENTLY there rather than loudly. Every tracker stamps a + /// creation time, so this assumes nothing about how a consumer spells a key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prose_dialect_required_from: Option, +} + /// The `[trust]` table: what `--config-from` may do when the ref is unreachable. /// /// House style §4 requires the authority to degrade safely rather than fail @@ -1231,6 +1270,10 @@ impl Config { verdicts: Vec::new(), scope: Vec::new(), protected: Vec::new(), + // Declaring nothing means declaring no threshold either, which the + // reader takes as could-not-look and exempts everything — the same + // direction every other field here grants. + ready: None, // No protected paths means the unknown-program clause has nothing to // guard, so an empty reader set costs nothing here and is the honest // value: a config declaring nothing declares no readers either. diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 5a713034b..814b9718c 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -129,6 +129,9 @@ const CONFIG: &str = "config"; /// against it, so a checkout where this fails is one where those silently have /// nothing to stand on. const GIT_REPO: &str = "git-repo"; +/// This harness's plan/todo surface has been SURVEYED — which is a different +/// question from whether it has one (CLOUD-472). +const PLAN_SURFACE: &str = "plan-surface"; /// Every `command`-kind rule names a program that resolves on `PATH`. /// /// A missing binary is otherwise discovered at `enforce` time, mid-run, as a @@ -277,6 +280,44 @@ pub fn diagnose(dir: &Path) -> Report { ), ); + // THE HOST'S PLAN SURFACE, REPORTED AND NEVER GATED ON (CLOUD-472). + // + // `plan-complete` reads a store `batten record plan` writes, so it fails + // closed on every host and this check decides nothing about it. What it + // answers is whether the human's NATIVE todo view can be kept in step — + // and, more importantly, it makes an unsurveyed host say so out loud. + // + // `Unsurveyed` is a FAILED check rather than a passed one, which is the + // whole reason the column has two variants. An absence of data reading as + // an absence of capability is the trap `hook::Harness::operation_of` + // records for Gemini and Copilot, and a diagnostic that reported "no plan + // tool" for a host nobody has looked at would be repeating it in the one + // place an operator goes to find out what is true. + // OVER THE TABLE, NOT OVER THE RUNNING HOST, because `diagnose` takes a + // directory: it answers for the checkout in front of it and has no harness + // to ask. Inferring one from the environment would be manufacturing the + // fact this check exists to report honestly. + // AN UNSURVEYED HOST MUST NAME WHO OWES THE SURVEY, and naming one changes + // no exit code. That is `#MUTANT-OWNER`'s bargain: the declaration buys that + // the gap is STATED, never that it is forgiven, and a check that reddened on + // every unsurveyed host would be permanently red on this repository — which + // is how a diagnostic stops being run at all. + // + // What it does catch is a harness added with neither a fetch nor an owner, + // which is the moment the gap becomes invisible. + checks.push( + if crate::hook::Harness::ALL.iter().any(|harness| { + matches!( + harness.capabilities().plan_tools, + crate::hook::PlanTools::Unsurveyed(owner) if owner.is_empty() + ) + }) { + Check::failed(PLAN_SURFACE, "harness-unsurveyed-and-unowned") + } else { + Check::passed(PLAN_SURFACE) + }, + ); + // The working-tree authority: `doctor` diagnoses the checkout in front of // it, so it does not take a base ref. let config_epoch = crate::epoch::compute(dir, None).ok(); @@ -1703,7 +1744,13 @@ mod tests { .collect(); assert_eq!( names, - vec![CONFIG, GIT_REPO, COMMAND_PROGRAMS, HOOK_HANDLERS] + vec![ + CONFIG, + GIT_REPO, + COMMAND_PROGRAMS, + HOOK_HANDLERS, + PLAN_SURFACE + ] ); } diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 084ff822a..f951d6379 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -309,6 +309,34 @@ impl Harness { } } +/// How a host spells the agent's plan/todo tool, or that nobody has looked +/// (CLOUD-472). +/// +/// **Two variants for a THREE-valued fact, and the third value is +/// `Surveyed(&[])`.** Collapsing "surveyed and this host has none" into the same +/// answer as "nobody checked" is the exact trap [`Harness::operation_of`]'s own +/// comment warns about, where Gemini and Copilot carry no spellings because the +/// CLOUD-209 survey did not record them — an absence of DATA that reads as an +/// absence of CAPABILITY. A reader who cannot tell those apart will report a +/// host as having no todo tool when the truth is that nobody asked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PlanTools { + /// Fetched from this host's own documentation. An empty slice is a measured + /// "this host offers none", which is an answer. + Surveyed(&'static [&'static str]), + /// Nobody has looked, carrying the row that OWES the survey. NOT the same as + /// none, and never reported as none. + /// + /// **The key changes no exit code, and that is deliberate** — it is + /// `#MUTANT-OWNER`'s bargain, one layer over: a declaration that suppressed + /// the finding would be the laundering the runner exists to refuse, so what + /// the key buys is that the gap is STATED rather than that it is forgiven. A + /// new harness added without a survey has to name who owes one, which is the + /// moment an author either does the fetch or admits they did not. + Unsurveyed(&'static str), +} + /// What one host can and cannot do (CLOUD-45). /// /// A **host × capability** table, not a list of Claude-only events — the survey @@ -334,6 +362,22 @@ impl Harness { pub struct Capabilities { /// The events this host emits, so Batten can be invoked on them. pub events: &'static [Event], + /// How this host spells the agent's own plan/todo tool (CLOUD-472). + /// + /// **A column about what the AGENT can call, where the rest of this table is + /// about what the ENGINE can reach** — and it is here anyway, because this is + /// the one authority on host facts and a second table would be a second place + /// for the same answer to drift. + /// + /// Batten does not gate on it. The plan record is written by + /// [`crate::record::run_plan`], a verb, so the gate fails closed on every + /// host regardless of what this says. What the column buys is the MIRROR — + /// keeping the human's native todo view in step with the store — and an + /// honest report of hosts where that view does not exist. + /// + /// Every spelling here was FETCHED, per CLOUD-209's rule that anything + /// re-derived without one should be assumed wrong. + pub plan_tools: PlanTools, /// Where an escalate-to-human verdict is actually reachable on this host /// (CLOUD-601). /// @@ -1288,6 +1332,8 @@ impl Harness { pub const fn capabilities(self) -> Capabilities { match self { Harness::ClaudeCode => Capabilities { + // Fetched: this session's own tool surface. + plan_tools: PlanTools::Surveyed(&["TaskCreate", "TaskUpdate"]), events: CLAUDE_EVENTS, // Documented, and merged most-restrictive-first by the host // itself (`deny > defer > ask > allow`), so an ask cannot @@ -1402,6 +1448,11 @@ impl Harness { }, }, Harness::Cursor => Capabilities { + // Searched 2026-09-01 and NOT fetched: the host has a Todos + // feature from 1.2, but no vendor-documented tool spelling was + // found, and a name taken from a forum post is exactly what + // CLOUD-209's "assume it wrong without a fetch" refuses. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // The row that forced this column to become event-scoped // (CLOUD-601). M1 records the verdict vocabulary as @@ -1438,6 +1489,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CopilotCli => Capabilities { + // Not fetched, like the rest of this host's tool surface — the + // same survey gap `operation_of` records for it. + plan_tools: PlanTools::Unsurveyed("CLOUD-209"), events: CONVERGED_EVENTS, // `Unknown`, not `No`, and not `Yes` either: M1 confirms the // verdict exists and names the `preToolUse` output *fields* @@ -1468,6 +1522,9 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::GeminiCli => Capabilities { + // Fetched 2026-09-01 from the vendor docs: `write_todos`, on by + // default and disableable with `"useWriteTodos": false`. + plan_tools: PlanTools::Surveyed(&["write_todos"]), events: CONVERGED_EVENTS, // Allow/deny only. A policy wanting confirmation must hard-deny // here — degrading to *allow* would turn "ask a human" into "go @@ -1502,6 +1559,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::CodexCli => Capabilities { + // Fetched 2026-09-01: `update_plan`, the built-in plan tool. + plan_tools: PlanTools::Surveyed(&["update_plan"]), events: CONVERGED_EVENTS, // Advertised in the output schema, marked "parsed but not // supported yet" in the docs. Advertised is not available, and @@ -1524,6 +1583,8 @@ impl Harness { capture: UNSURVEYED_CAPTURE, }, Harness::ExitCode => Capabilities { + // The neutral contract carries no host tool surface of its own. + plan_tools: PlanTools::Surveyed(&[]), events: CONVERGED_EVENTS, // Not a host: the channel is the exit status alone, which has no // third value to carry an escalation. Measured, not unsurveyed. @@ -12154,6 +12215,45 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { (Harness::ExitCode, "Write"), ]; + /// A new adapter must either name a fetched plan spelling or say who owes + /// the survey. CLOUD-472's column exists to keep those apart, so a row that + /// declares neither is the one thing it cannot express. + #[test] + fn every_harness_declares_a_plan_surface_or_names_who_owes_the_survey() { + for harness in Harness::ALL { + if let PlanTools::Unsurveyed(owner) = harness.capabilities().plan_tools { + assert!( + !owner.is_empty(), + "{}: unsurveyed with no owner — the gap has to be stated, \ + which is `#MUTANT-OWNER`'s bargain one layer over", + harness.as_str() + ); + } + } + } + + /// SURVEYED-AND-NONE IS AN ANSWER; UNSURVEYED IS NOT. The whole reason the + /// column has two variants is that collapsing them reproduces the trap + /// `operation_of`'s own comment records — an absence of DATA reading as an + /// absence of CAPABILITY. Asserted over the committed table so a later edit + /// cannot quietly turn one into the other. + #[test] + fn an_unsurveyed_plan_surface_is_never_reported_as_having_none() { + assert_eq!( + Harness::ExitCode.capabilities().plan_tools, + PlanTools::Surveyed(&[]), + "the neutral contract carries no host tool surface, which is a measured none" + ); + assert!( + matches!( + Harness::Cursor.capabilities().plan_tools, + PlanTools::Unsurveyed(_) + ), + "Cursor has a Todos feature and no vendor-documented spelling was fetched, \ + so it is unsurveyed rather than none" + ); + } + #[test] fn every_harness_classifies_its_own_write_spelling_as_write() { for harness in Harness::ALL { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 2d686454d..459e863f3 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -2146,7 +2146,13 @@ fn render_findings(findings: &[checks_green::Finding]) -> String { /// module exists to avoid. fn board_grammar(overrides: &Overrides) -> Result { let config = resolve::resolve(Path::new("."), overrides)?; - ready::Grammar::resolve(&config.patterns) + Ok( + ready::Grammar::resolve(&config.patterns)?.with_prose_threshold( + config + .ready + .and_then(|ready| ready.prose_dialect_required_from), + ), + ) } fn run_claim( diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index b44613137..c3c988713 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -94,6 +94,11 @@ pub struct Payload { pub description: String, /// Whether the payload carried a `relations` key at all. pub relations_present: bool, + /// When the tracker says the row was created, verbatim, or `None` where the + /// payload carried none. Never parsed into a date type: it is compared + /// against another fixed-width ISO-8601 UTC stamp, so lexical order is + /// chronological order and a parser would only add a way to disagree. + pub created_at: Option, /// The `blockedBy` edges, for the §8 cross-check. pub blocked_by: Vec, /// Every edge in any direction, for the deferral cross-check. A deferral is @@ -149,6 +154,10 @@ impl Payload { .and_then(serde_json::Value::as_str) .unwrap_or("?") .to_owned(), + created_at: value + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), description, relations_present, blocked_by, @@ -257,6 +266,33 @@ pub struct Grammar { unanchored_clause: Regex, open_questions: Regex, legacy_clause_notation: Regex, + /// From which creation instant a Ready block must carry the claims object + /// rather than prose (CLOUD-472). `None` is could-not-look and exempts + /// everything. + /// + /// # Two wrong shapes preceded this, and the second is the instructive one + /// + /// It was first a `[[pattern]]` row spelling the exempt range as a regex over + /// the key. That is wrong twice: the registry gives one CONCEPT one spelling + /// and arithmetic is not a concept, and it decides on key TEXT, which this + /// consumer already declares `ready-issue-mention-markup` for because the + /// tracker rewrites a bare key into `` markup on the round trip. + /// + /// It was then a key ORDINAL — the trailing digits, no separator assumed, so + /// no consumer literal reached the crate. That passes + /// `no-tracker-key-in-core` and is still a consumer assumption smuggled in: + /// it requires keys that are numeric AND monotonic with creation order. Three + /// popular trackers satisfy that and a slug- or UUID-keyed one does not — and + /// it would fail SILENTLY there, resolving `None` and never ratcheting, which + /// is the dead-gate shape this module exists to avoid. + /// + /// **A creation instant assumes nothing.** Every tracker stamps one, the + /// payload already carries it, and `policy/filed-here.rego`'s + /// `predates_the_branch` already compares tracker timestamps this way with + /// the reasoning written out: both sides are fixed-width ISO-8601 UTC, so + /// lexical order IS chronological order. Moving it later is the only + /// direction that tightens, which makes it a ratchet rather than a switch. + prose_dialect_required_from: Option, bump_label: Regex, commit_type: Regex, bump_token: Regex, @@ -352,6 +388,20 @@ impl Grammar { }) } + /// Apply the consumer's prose-dialect threshold (CLOUD-472). + /// + /// Separate from [`Self::assemble`] because it is not a `[[pattern]]` and + /// must not become one: the registry holds concepts with one spelling, and a + /// number is neither. Absent on [`Self::from_compiled`]'s path by design — + /// the recorder resolves a grammar to answer an `{authority:…}` column and + /// has no consumer config in hand, so it gets could-not-look rather than a + /// threshold guessed from somewhere else. + #[must_use] + pub fn with_prose_threshold(mut self, from: Option) -> Self { + self.prose_dialect_required_from = from; + self + } + /// A row the consumer's table does not declare. /// /// **Could-not-look, and it says so** — a clause whose anchor has no @@ -391,6 +441,7 @@ impl Grammar { blocks_tail: find("ready-blocks-tail")?, relatedto_tail: find("ready-relatedto-tail")?, defer_verb: find("ready-defer-verb")?, + prose_dialect_required_from: None, key: find("ready-issue-key")?, mention_markup: find("ready-issue-mention-markup")?, }) @@ -690,15 +741,61 @@ pub fn lint(grammar: &Grammar, payload: &Payload, root: &Path) -> Result // can adjudicate. let structured = check_claims(grammar, payload, root, &block, ready_start, &mut report)?; - // THE DIALECT, AS A FACT RATHER THAN A VERDICT. A prose-only block still - // PASSES — every issue Ready today stays Ready, which is what lets the - // corpus converge deliberately instead of in one sweep — and is named, so a - // caller can find the ones still to convert without re-reading any body. + // THE DIALECT, AS A FACT. Named so a caller can find the blocks still to + // convert without re-reading any body — and it is the sensor the ratchet + // below reads, rather than a second derivation of the same question. report.emissions.push(format!( "dialect {}", if structured { "json" } else { "prose" } )); + // THE PROSE DIALECT IS A LEGACY, NOT AN ALTERNATIVE (CLOUD-472). + // + // This clause used to say a prose-only block "still PASSES — every issue + // Ready today stays Ready, which is what lets the corpus converge + // deliberately instead of in one sweep". The first half is still true below + // the threshold. The second half was left to intent, **and intent did not + // converge it**: measured 2026-09-01 over the 50-row Todo queue, the object + // was used by nothing, and CLOUD-1306 — filed that day — carried a §7 naming + // three obligations in prose, none of them joinable to anything. A sensor + // with no ratchet on it reports a defect forever. + // + // WHY THE OBJECT IS THE THING BEING DEMANDED, rather than a new grammar: + // `REQUIRED_CLAIMS` already forces `tests`, and `check_claimed_tests` + // already forces `file` AND `mutation` on every entry — CLOUD-418's + // obligation as a field, where an entry that cannot name the mutation which + // would kill it cannot be written. That mechanism landed and was simply + // unreachable, because `check_claims` returns `false` on an absent fence and + // the caller falls back here. + // + // A RATCHET RATHER THAN A FLIP, and the cost is why. `graph-check` enforces + // `Todo ⇒ ready-lint exits 0`, so refusing every prose block at once takes + // the board's whole ready frontier dark in one step — CLOUD-858's measured + // shape, where three rows did exactly that. + // + // COULD-NOT-LOOK PASSES, TWICE OVER, and both are the same posture. A + // consumer that declares no cutover has not asked for the ratchet, and a + // payload carrying no creation instant cannot be placed against one — so each + // leaves the row judged exactly as it was before this clause existed. + // Reading either as "past the cutover" would turn a verdict about the + // environment into a verdict about the row. + // + // Both sides are fixed-width ISO-8601 UTC as the tracker stamps them, so a + // lexical comparison IS a chronological one — the same reading, and the same + // reasoning, as `policy/filed-here.rego`'s `predates_the_branch`. + if !structured + && let Some(from) = grammar.prose_dialect_required_from.as_deref() + && payload + .created_at + .as_deref() + .is_some_and(|created| created >= from) + { + report.findings.push(Finding { + line: ready_start, + rule: "claims-object-absent".to_owned(), + }); + } + if !structured { check_bump(grammar, root, &block_lines, &line_of, &mut report)?; } @@ -1115,6 +1212,29 @@ fn check_claimed_tests(claims: &serde_json::Value, line: usize, report: &mut Rep }); } } + // `mutation` NAMES A DECLARED `#MUTANT` SLUG (CLOUD-472). The field + // landed under CLOUD-418 as prose describing the mutation that would + // kill the case — which is a better claim than nothing and is still not + // joinable to anything. A slug is: `batten mutate` resolves it, applies + // the expression, runs the named case, and a SURVIVOR is the finding. So + // "pressure tested" stops being an assertion and becomes an exit code. + // + // SHAPE HERE, RESOLUTION AT `verify`. At refinement time the case does + // not exist yet — refusing an unresolvable slug here is the false-failure + // trap this row's own §3 names — so this checks only that the field is a + // TOKEN rather than a sentence. A slug carries no whitespace, which is + // the whole discriminator and is what `mutate`'s own three-field row + // format already requires of it. + let prose = entry + .get("mutation") + .and_then(serde_json::Value::as_str) + .is_some_and(|text| text.split_whitespace().count() > 1); + if prose { + report.findings.push(Finding { + line, + rule: "test-claim-mutation-not-a-slug".to_owned(), + }); + } } } diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index 627c6a3ed..2dc499c39 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -199,5 +199,92 @@ pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result< match command { crate::cli::RecordCommand::Tool { id } => run_tool(&id, overrides), crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), + crate::cli::RecordCommand::Plan => run_plan(), } } + +/// The record names this crate's own VERBS write, as opposed to the ones a +/// `[[recorder]]` row mints from a tool envelope (CLOUD-472). +/// +/// # Why a verb writes this at all, which is the whole design decision +/// +/// A hook mediates a call the agent makes to somebody ELSE's tool, so it is +/// per-harness by nature: `TaskCreate`/`TaskUpdate` here, `write_todos` on +/// Gemini CLI, `todowrite` on `OpenCode`, `update_plan` on Codex. Recording from +/// those envelopes needs a spelling per host, and its failure mode is the one +/// this whole module exists to name — an unsurveyed harness, a tool a setting +/// switched off, and a compliant agent all produce NOTHING, so the gate reads +/// clean. `OpenCode` makes that concrete: `todowrite` is denied to subagents at +/// session creation regardless of configuration. +/// +/// A verb inverts the direction. The agent TELLS the engine, so a missing record +/// refuses on every harness identically — no survey, no per-host spelling, and no +/// setting that can quietly disarm it. Discovery still has a job (reporting which +/// native surface exists, so a mirror can be kept for the human's benefit), but +/// the gate reads this store and only this store. +/// `claim` is here for a second reason worth stating: `claim check` writes it and +/// nothing read it from a module before, but it is the honest signal for "this +/// branch is doing tracked work". A gate that demands a plan from EVERY tree with +/// a diff refuses every scratch fixture and every consumer checkout — measured, +/// it reddened four `cli.rs` cases that only wanted to exercise other rules. +/// Keyed to a claim, it asks the question exactly where the answer is owed. +pub const VERB_WRITTEN: &[&str] = &["claim", "plan"]; + +/// The statuses a plan entry may carry. +/// +/// The vocabulary four harnesses already converged on, which is what makes a +/// mirror possible in either direction — but the tokens are the ENGINE's, not any +/// host's, so a harness that spells them differently is translated at the mirror +/// rather than teaching this store a dialect. +const PLAN_STATUSES: [&str; 4] = ["pending", "in_progress", "completed", "deleted"]; + +/// Record this branch's plan: one ` ` line per entry. +/// +/// # Errors +/// +/// A [`UsageError`] when a line is not ` `, when a status is not one +/// of [`PLAN_STATUSES`], or when there is no branch to key on — a detached HEAD +/// has nothing to record against, exactly as the claim receipt has nothing to key +/// on there. An internal error when the store cannot be written. +pub fn run_plan() -> Result { + let raw = verdict_lines()?; + for (index, line) in raw.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let mut words = line.split_whitespace(); + let (Some(_id), Some(status)) = (words.next(), words.next()) else { + return Err(UsageError::raise(format!( + "plan line {} is not ` `", + index + 1 + ))); + }; + // THE TOKEN, NEVER THE LINE (rule 4). An entry's id is the agent's own + // text and a status is a closed vocabulary, so the closed half is what a + // diagnostic may echo. + if !PLAN_STATUSES.contains(&status) { + return Err(UsageError::raise(format!( + "plan line {} carries an unknown status; one of {}", + index + 1, + PLAN_STATUSES.join(", ") + ))); + } + } + + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record plan: not a git repository, so there is nothing to key on".to_owned(), + ) + })?; + let Ok(Some(branch)) = git::current_branch(root) else { + return Err(UsageError::raise( + "record plan: a detached HEAD has no branch to key the plan on".to_owned(), + )); + }; + store( + &crate::recorder::record_path(&git_dir, "plan", &branch), + &raw, + )?; + Ok(ExitCode::Success) +} diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index 7aa0244cd..acd6eda6a 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -494,6 +494,9 @@ pub struct Resolved { /// **added**. §8's "add protected paths" verbatim; adding to an include-only /// set can only guard more. pub protected: Vec, + /// The refinement gate's thresholds (CLOUD-472), from the committed + /// authority alone. `None` is could-not-look and asks for no ratchet. + pub ready: Option, /// Programs that only read their operands, so naming a protected path is not /// a mutation (CLOUD-1141). /// @@ -1597,6 +1600,12 @@ fn assemble( // local file adding one would weaken the protected gate, which is // exactly what house style §8's raise-only clause forbids. protected_readers: repo.protected_readers.clone(), + // COMMITTED AUTHORITY ONLY, for `protected_readers`' reason one layer + // over: the threshold is a RATCHET, so a local file setting it would be + // setting it HIGHER — exempting rows the committed authority refuses — + // and house style §8 admits only raises. Lowering it is a change to the + // committed file, where a reviewer sees it. + ready: repo.ready.clone(), unlanded: paths.unlanded, epoch: repo.epoch.clone(), contract: repo.contract.clone(), @@ -1679,6 +1688,11 @@ fn attribution( "protected_readers", authority_set(!repo.protected_readers.is_empty()), ), + // AUTHORITY-ONLY for `protected_readers`' reason (CLOUD-472): the + // prose-dialect cutover is a ratchet, so a local file could only move it + // LATER — exempting rows the committed authority refuses — which is a + // weakening dressed as a setting, and §8 admits only raises. + ("ready", authority_set(repo.ready.is_some())), ("unlanded", paths.unlanded_source.clone()), ("epoch", authority_set(repo.epoch.is_some())), ("contract", authority_set(repo.contract.is_some())), diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index ea3eb518e..a8c30470e 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -5616,13 +5616,19 @@ fn run( // recorder is config: the fact is "what this repository's recorders // accumulated", so a repository declaring none has nothing to read and a // per-rule declaration would be a second place for the same answer to live. - let records = if recorders.is_empty() { - BTreeMap::new() - } else { - match (crate::git::git_dir(root), crate::git::current_branch(root)) { - (Ok(git_dir), Ok(Some(branch))) => recorder_records(&git_dir, &branch, recorders), - _ => BTreeMap::new(), - } + // VERB-WRITTEN STORES ARE READ UNCONDITIONALLY, which is why the guard above + // is no longer the whole answer (CLOUD-472). A `[[recorder]]` store exists + // because config declared one, so a repository declaring none has nothing to + // read. `crate::record::VERB_WRITTEN` is different in kind: the ENGINE owns + // both the writer and the reader, so there is no declaration for a consumer + // to forget and no config to make the gate conditional on. Reading them only + // when some unrelated recorder happened to be declared would make a gate's + // liveness depend on a table it has nothing to do with. + let records = match (crate::git::git_dir(root), crate::git::current_branch(root)) { + (Ok(git_dir), Ok(Some(branch))) => { + recorder_records(&git_dir, &branch, recorders, crate::record::VERB_WRITTEN) + } + _ => BTreeMap::new(), }; let inputs = RunInputs { @@ -5879,20 +5885,31 @@ fn recorder_records( git_dir: &std::path::Path, branch: &str, recorders: &[crate::recorder::Declared], + verb_written: &[&str], ) -> BTreeMap> { let mut found: BTreeMap> = BTreeMap::new(); - for recorder in recorders { - if found.contains_key(&recorder.record) { + // The declared stores first, then the engine's own. Order decides nothing — + // the names cannot collide, because a `[[recorder]]` naming a verb-written + // record would be a second writer for one store and `config lint` refuses it + // — but reading declared config first keeps the consumer's table the one a + // reader looks at when a name is ambiguous. + let names = recorders + .iter() + .map(|recorder| recorder.record.as_str()) + .chain(verb_written.iter().copied()); + for name in names { + if found.contains_key(name) { continue; } - let path = crate::recorder::record_path(git_dir, &recorder.record, branch); + let path = crate::recorder::record_path(git_dir, name, branch); + // ABSENT STAYS ABSENT, and that is the three-valued read this whole + // surface rests on: an unreadable store leaves the key out of the map so + // a module sees *does not hold*, where an empty file is a key whose value + // is the empty list — "nothing was recorded" rather than "nothing looked". let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - found.insert( - recorder.record.clone(), - text.lines().map(str::to_owned).collect(), - ); + found.insert(name.to_owned(), text.lines().map(str::to_owned).collect()); } found } diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index ee3bbfc58..ffd6f57d7 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -653,6 +653,12 @@ mod tests { // a third row spelled the old way would be a third row to invert. "record".to_owned(), "record forge".to_owned(), + // CLOUD-472. Stated here rather than absorbed, which is what + // this list is for: the plan store's writer is a VERB because a + // recorder on the harness's own todo tool needs a spelling per + // host and records nothing where one is unsurveyed or switched + // off — so the surface grows by one row and says why. + "record plan".to_owned(), "record tool".to_owned(), // The API-compatibility noun (CLOUD-1050), ported off // `mise-tasks/semver.sh` when CLOUD-1059 made editing a shell diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 6f07da362..59331fac3 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -3082,6 +3082,22 @@ pub const SURFACE: &[CommandDecl] = &[ "The ref or sha the verdict was taken against", )], }, + // CLOUD-472. A VERB rather than a `[[recorder]]` on the harness's own todo + // tool, and the direction is the point: a hook mediates a call to somebody + // else's tool and is per-harness by nature, so an unsurveyed host, a tool a + // setting disabled, and a compliant agent all record nothing and the gate + // reads clean. Telling the engine fails closed everywhere instead. + // + // No positional: the branch is the key and the engine resolves it, so a + // caller cannot record against a branch it is not on — `record tool`'s + // anti-staleness argument, applied to a different key. + CommandDecl { + path: "record plan", + about: "Record this branch's plan, read as ` ` lines on stdin", + data_channel: false, + effect: Effect::Write, + flags: &[], + }, // A NEW NOUN rather than a flag on an existing verb, and two shapes were // considered and died on the same rule (CLOUD-893). `generate hooks --write` // and `doctor hooks --repair` both hang the effect off a FLAG, where §5 hangs diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index c030831b2..9f6151650 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -537,6 +537,13 @@ pub enum WeakeningKind { /// A path is gone from `epoch.tracked`, so the `config_epoch` attributes /// less than it did (CLOUD-32). EpochPathRemoved, + /// The prose-dialect cutover moved LATER, or stopped being declared, so + /// Ready blocks that owed the claims object no longer do (CLOUD-472). + /// + /// The direction is the whole of it: this is a ratchet, and later exempts + /// MORE rows. Removing the key entirely is the limit case of moving it + /// later — could-not-look exempts everything — so both reach one kind. + ReadyCutoverRelaxed, /// A `[[verb]]` row is gone, so a mutating tool call is no longer mediated /// at the `PreToolUse` boundary (CLOUD-36). VerbRemoved, @@ -789,6 +796,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged, WeakeningKind::MinVersionLowered, WeakeningKind::EpochPathRemoved, + WeakeningKind::ReadyCutoverRelaxed, WeakeningKind::VerbRemoved, WeakeningKind::PatternRemoved, WeakeningKind::VerdictOverrideAdded, @@ -840,6 +848,7 @@ impl WeakeningKind { WeakeningKind::RulePredicateChanged => "rule-predicate-changed", WeakeningKind::MinVersionLowered => "min-version-lowered", WeakeningKind::EpochPathRemoved => "epoch-path-removed", + WeakeningKind::ReadyCutoverRelaxed => "ready-cutover-relaxed", WeakeningKind::VerbRemoved => "verb-removed", WeakeningKind::PatternRemoved => "pattern-removed", WeakeningKind::VerdictOverrideAdded => "verdict-override-added", @@ -955,6 +964,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "protected_readers", coverage: Coverage::Compared(&[WeakeningKind::ProtectedReaderAdded]), }, + FieldCoverage { + field: "ready", + coverage: Coverage::Compared(&[WeakeningKind::ReadyCutoverRelaxed]), + }, FieldCoverage { field: "unlanded", coverage: Coverage::Compared(&[WeakeningKind::UnlandedRemoved]), @@ -1584,6 +1597,36 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { "epoch.tracked", )); + // The refinement gate's prose-dialect cutover (CLOUD-472). A ratchet, so + // LATER is weaker: it exempts more rows from owing the claims object, and + // dropping the key altogether is that move taken to its limit, since absent + // reads as could-not-look and exempts every row. Compared as strings because + // both sides are fixed-width ISO-8601 UTC, which is the same reading + // `policy/filed-here.rego` takes of a tracker stamp. + { + let cutover = |config: &Config| { + config + .ready + .as_ref() + .and_then(|ready| ready.prose_dialect_required_from.clone()) + }; + if let Some(was) = cutover(base) { + let now = cutover(working); + // Absent renders as the same could-not-look token every other + // three-valued read in this tree uses, so a reader of the finding + // sees WHICH move was made rather than an empty string. + let relaxed = now.as_ref().is_none_or(|now| now > &was); + if relaxed { + found.push(Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + was, + now.unwrap_or_else(|| "-".to_owned()), + )); + } + } + } + // The mutating-verb table: a removed row un-gates a tool call at the // `PreToolUse` boundary, which is the most consequential of these. found.extend(removed_entries( @@ -3004,6 +3047,47 @@ mod tests { assert!(weakenings(&working, &base).is_empty()); } + /// CLOUD-472. The direction is the whole of it, so all four arms are here: + /// later relaxes, absent is later taken to its limit, earlier tightens, and + /// a base that never declared a cutover has no bar to lower. + #[test] + fn moving_the_prose_dialect_cutover_later_is_a_weakening() { + let base = config("[ready]\nprose_dialect_required_from = \"2026-09-02T00:00:00.000Z\"\n"); + let later = config("[ready]\nprose_dialect_required_from = \"2027-01-01T00:00:00.000Z\"\n"); + assert_eq!( + only(&base, &later), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + ) + ); + + // DROPPING THE KEY IS THE LIMIT CASE, not a separate one: absent reads as + // could-not-look and exempts EVERY row, which is further than any date + // could move it. Reporting it as a no-op is how a ratchet gets removed + // rather than relaxed. + assert_eq!( + only(&base, &config("")), + Weakening::new( + WeakeningKind::ReadyCutoverRelaxed, + "ready.prose_dialect_required_from", + "2026-09-02T00:00:00.000Z", + "-", + ) + ); + + // Earlier is a TIGHTENING — it refuses more rows — and is not reported. + assert!(weakenings(&later, &base).is_empty()); + + // And a base with no cutover has no bar to lower, so ADDING one is not a + // weakening either. Without this arm the comparison would fire on every + // branch that adopts the ratchet, which is the direction that makes a + // gate get switched off. + assert!(weakenings(&config(""), &base).is_empty()); + } + #[test] fn removing_a_declared_pattern_is_a_weakening() { // NOT A LOAD FAILURE, which is the whole reason this is on the table. diff --git a/crates/batten/tests/it/doctor.rs b/crates/batten/tests/it/doctor.rs index 7abb694e3..7c5efdf32 100644 --- a/crates/batten/tests/it/doctor.rs +++ b/crates/batten/tests/it/doctor.rs @@ -63,7 +63,7 @@ fn a_healthy_repository_exits_zero() { assert_eq!(output.status.code(), Some(0)); assert_eq!( stdout(&output), - "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\ndoctor: 4 check(s), 0 failed\n" + "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\nplan-surface ok\ndoctor: 5 check(s), 0 failed\n" ); } @@ -115,11 +115,17 @@ fn every_check_is_reported_not_just_the_first_failure() { let text = stdout(&output); assert!(text.contains("config failed"), "got: {text}"); assert!(text.contains("git-repo failed"), "got: {text}"); - // Four checks now; still two failures, because a checkout with no config + // Five checks now; still two failures, because a checkout with no config // declares no handlers and `hook-handlers` passes vacuously over an empty // table. That is the honest answer — there is nothing there to be wrong — // and it is why the count moved while the failure count did not. - assert!(text.contains("doctor: 4 check(s), 2 failed"), "got: {text}"); + // + // `plan-surface` passes for a different reason worth keeping distinct: it + // reads the COMMITTED harness table rather than this checkout, so it says + // the same thing in every scratch repository. What it can fail on is a + // harness declaring neither a fetched spelling nor the row that owes the + // survey (CLOUD-472), which is a defect in the crate and not in a tree. + assert!(text.contains("doctor: 5 check(s), 2 failed"), "got: {text}"); } // --- doctor never renders a policy verdict ----------------------------------- @@ -246,7 +252,13 @@ fn json_is_valid_and_carries_every_check() { let names: Vec<&str> = checks.iter().filter_map(|c| c["name"].as_str()).collect(); assert_eq!( names, - vec!["config", "git-repo", "command-programs", "hook-handlers"] + vec![ + "config", + "git-repo", + "command-programs", + "hook-handlers", + "plan-surface" + ] ); } diff --git a/crates/batten/tests/it/filed_here.rs b/crates/batten/tests/it/filed_here.rs index 7921884a3..1154d92ac 100644 --- a/crates/batten/tests/it/filed_here.rs +++ b/crates/batten/tests/it/filed_here.rs @@ -261,6 +261,7 @@ fn pointers(root: &Path) -> Vec { const UNREFINED: &str = "filed-unrefined"; const OVER_DIFF: &str = "filed-over-own-diff"; +const LEFT_OPEN: &str = "filed-and-left-open"; // --------------------------------------------------------------------------- // The pass side first: without it every refusal below is satisfied by a module @@ -407,8 +408,12 @@ fn a_row_naming_a_file_this_branch_is_changing_stops_the_lap() { assert_eq!(verdicts(&root), vec![OVER_DIFF.to_owned()]); } +/// A path outside the diff is not a punt against it — for the PROXIMITY refusal, +/// which is the only one this case was ever about. `filed-and-left-open` takes it +/// instead, and asserting the exact verdict rather than "not empty" is what makes +/// the partition falsifiable from this tier. #[test] -fn a_recorded_path_the_branch_does_not_change_is_not_reported() { +fn a_recorded_path_the_branch_does_not_change_is_not_a_proximity_refusal() { let root = repo( "elsewhere", "work", @@ -418,10 +423,7 @@ fn a_recorded_path_the_branch_does_not_change_is_not_reported() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "a path outside the diff is not a punt against it" - ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); } /// ONE POINTER PER PATH, as the shell emitted, so a reviewer sees which file @@ -548,16 +550,23 @@ fn a_row_recorded_before_the_file_was_touched_is_still_caught() { )], &["closes 0"], ); - assert!( - verdicts(&root).is_empty(), - "nothing is in the diff yet, so nothing intersects" + // THE ROW MOVES BETWEEN THE TWO ARMS RATHER THAN APPEARING OUT OF SILENCE, + // which is the partition made visible on one record. Before the file is + // touched its §1 names nothing in the diff, so proximity is silent and the + // set refusal takes it; touching the file moves it to proximity and the set + // refusal goes quiet. Exactly one finding either side — a reviewer never sees + // two for one row. + assert_eq!( + verdicts(&root), + vec![LEFT_OPEN.to_owned()], + "nothing intersects yet, so this is a row left open rather than a row over the diff" ); fs::create_dir_all(root.join("src")).expect("src"); fs::write(root.join("src/a.rs"), "now\n").expect("touch the file"); assert_eq!( verdicts(&root), vec![OVER_DIFF.to_owned()], - "the same record refuses once the file is open" + "the same record refuses on proximity once the file is open" ); } @@ -597,6 +606,146 @@ fn a_six_field_record_with_no_sec1_column_is_judged_exactly_as_before() { ); } +// --------------------------------------------------------------------------- +// `filed-and-left-open` (CLOUD-1311). The set refusal: a row this branch put on +// the board that it is not landing. +// +// Its whole reason for existing is the class the two arms above cannot see — a +// row filed while the branch was open whose §1 points somewhere else, which +// `cites_only` exempts from the proximity refusal by design. Three of the four +// deferrals that motivated this issue sat exactly there. +// --------------------------------------------------------------------------- + +#[test] +fn a_row_the_branch_filed_and_does_not_close_stops_the_lap() { + let root = repo( + "left-open", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); +} + +/// NO PR YET IS COULD-NOT-LOOK. `verify` runs before the PR exists on most laps, +/// and refusing there would name a remedy — "close it in the body" — with no body +/// to write it in. The absent record is the signal; there is nothing to tune. +#[test] +fn an_unread_pr_body_leaves_the_set_unjudged() { + let root = repo( + "no-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &[], + ); + assert!( + verdicts(&root).is_empty(), + "the forge's answer has not been captured, so the set is not judged" + ); +} + +/// AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is the +/// distinction `zero-is-a-count` exists to preserve: `closes 0` is a measurement +/// and `closes -` is not. +#[test] +fn an_unreadable_closing_key_column_leaves_the_set_unjudged() { + let root = repo( + "unreadable-body", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes -"], + ); + assert!(verdicts(&root).is_empty(), "`-` is could-not-look"); +} + +/// ANTI-VACUITY ON THE EXEMPTION: one closing key must not buy the whole set. +/// Without this, an author closes the cheapest row they filed and the gate goes +/// quiet about every other one — which is the arm switched off by its own remedy. +#[test] +fn closing_one_row_does_not_close_the_set() { + let root = repo( + "close-one", + "work", + &["src/a.rs"], + &[ + &format!("issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + &format!("issue CLOUD-2 {AFTER} ready 1,src/a.rs - 1,src/b.rs"), + ], + &["closes 1:CLOUD-1"], + ); + assert_eq!(verdicts(&root), vec![LEFT_OPEN.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains("CLOUD-2")), + "the row still open is the one reported: {:?}", + pointers(&root) + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4) for this arm too. The recorder wrote no title +/// and no body, and this is the assertion that keeps a later edit from adding +/// one — an articulation's prose especially, which is the one thing this class +/// collects that a finding must never carry. +#[test] +fn the_set_refusal_carries_the_id_and_nothing_else() { + let root = repo( + "left-open-pointer", + "work", + &["src/a.rs"], + &[&format!( + "issue CLOUD-1 {AFTER} ready 1,src/a.rs - 1,src/b.rs" + )], + &["closes 0"], + ); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains("CLOUD-1"), "the id is the pointer"); + assert!( + !rendered.contains("src/b.rs"), + "a §1 path is not this arm's subject: {rendered}" + ); +} + +// A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING — `test_a_branch_with_no_diff_ +// judges_no_row`, and it is in the MODULE's tier rather than here on purpose. +// +// THIS TIER CANNOT BUILD THAT INPUT, and the reason is the fixture itself: +// `install_module` writes `policy/filed-here.rego` into the working tree, and +// `base_delta` walks the tree rather than the index, so an untracked file is an +// added path. Every fixture below therefore has a non-empty delta by +// construction — measured, this case failed here reporting exactly the one +// finding it asserted the absence of. +// +// Stated rather than dropped, because the pair is the general rule +// `.claude/rules/policy-modules.md` gives for the two tiers: a `with input as` +// case cannot prove the ENGINE builds a shape, and this tier cannot construct a +// shape the engine's own scaffolding excludes. Neither replaces the other, and +// silently deleting the assertion would have left the guard untested in both. + +/// A record from an older recorder has no §1 column, so the partition cannot be +/// evaluated and the row stays judged exactly as it was before this arm existed. +#[test] +fn a_record_with_no_sec1_column_is_outside_this_arm() { + let root = repo( + "left-open-six-field", + "work", + &["src/a.rs"], + &[&format!("issue CLOUD-1 {AFTER} ready 0 -")], + &["closes 0"], + ); + assert!( + verdicts(&root).is_empty(), + "could-not-look on §1 is not a refusal" + ); +} + /// ANTI-VACUITY over the whole file: the row this suite exercises is the one the /// committed config declares, so a rename or a scope change reddens here rather /// than leaving every case above passing over a module nothing runs. diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index cb0f5193f..8dc263a25 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -129,6 +129,7 @@ mod narrow_adoption; mod perf_pair; mod pinned_programs; mod pipeline_shapes; +mod plan_complete; mod pointer_only; mod policy_engine_count; mod policy_input_narrowing; diff --git a/crates/batten/tests/it/plan_complete.rs b/crates/batten/tests/it/plan_complete.rs new file mode 100644 index 000000000..52e0f6d4f --- /dev/null +++ b/crates/batten/tests/it/plan_complete.rs @@ -0,0 +1,318 @@ +//! `plan-complete`, over the engine that builds its input (CLOUD-472). +//! +//! # The seam this tier owns, and why the module's own suite cannot reach it +//! +//! `policy/plan-complete.rego`'s `test_` rules pin the predicate against a +//! fabricated document. They cannot answer the question that actually decides +//! whether this gate is alive: does the ENGINE put `batten record plan`'s output +//! at `input.tree.records.plan` at all? +//! +//! That question has a specific reason to be asked here rather than assumed. +//! Every other record on that surface is minted by a `[[recorder]]` row, and +//! `recorder_records` used to read **only** the declared ones — so a store +//! written by a verb was invisible no matter what any module asked for. A +//! `with input as` case would have passed over that for the same reason it +//! passes over any key nothing fills, which is `.claude/rules/policy-modules.md`'s +//! whole warning about the two tiers. +//! +//! So these cases drive the real writer where they can, and `run_static` over a +//! real fixture repository otherwise. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule, RuleKind, RuleScope}; + +/// A fixture repository whose base is one commit back, with the plan record on +/// disk exactly where `batten record plan` writes it. +/// +/// `origin/main` is a local ref at the base commit: `base_delta` resolves a rev, +/// and a fetch would make every case below depend on the network for a question +/// that is entirely local. +fn repo(name: &str, changed: &[&str], plan: Option<&[&str]>) -> PathBuf { + claimed_repo(name, changed, plan, true) +} + +/// The same fixture, with the claim receipt under the caller's control. +/// +/// `claimed` is the population `plan-unrecorded` asks about — a branch that +/// pulled a row — so a case about an UNCLAIMED tree needs to build one, and that +/// case is what keeps the committed config usable over a scratch repository. +fn claimed_repo(name: &str, changed: &[&str], plan: Option<&[&str]>, claimed: bool) -> PathBuf { + let root = common::scratch(name); + common::git_in(&root, &["init", "--quiet", "--initial-branch", "work"]); + common::git_in(&root, &["config", "user.email", "t@example.com"]); + common::git_in(&root, &["config", "user.name", "t"]); + fs::write(root.join("seed.txt"), "seed\n").expect("seed"); + common::git_in(&root, &["add", "-A"]); + common::git_in(&root, &["commit", "--quiet", "-m", "base"]); + let base = common::git_in(&root, &["rev-parse", "HEAD"]); + common::git_in(&root, &["update-ref", "refs/remotes/origin/main", &base]); + + for path in changed { + let full = root.join(path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent).expect("scratch parent"); + } + fs::write(full, "changed\n").expect("write changed file"); + } + + install_module(&root); + if claimed { + write_record(&root, "claim", &["CLOUD-1"]); + } + if let Some(lines) = plan { + write_record(&root, "plan", lines); + } + root +} + +/// Write the record the way the verb does — through the engine's own +/// `record_path`, so a change to the naming breaks this tier rather than +/// silently pointing the reader and the writer at different files. +fn write_record(root: &Path, record: &str, lines: &[&str]) { + let git_dir = common::git_in(root, &["rev-parse", "--absolute-git-dir"]); + let path = batten::recorder::record_path(Path::new(git_dir.trim()), record, "work"); + fs::create_dir_all(path.parent().unwrap()).expect("receipts dir"); + let body = if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + }; + fs::write(path, body).expect("write the plan record"); +} + +fn install_module(root: &Path) { + let source = common::at_root("policy/plan-complete.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/plan-complete.rego")).expect("install committed module"); +} + +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "plan-complete", + "kind": "policy", + "scope": "tree", + "base": "origin/main", + "delta_sources": ["**"], + "module": "policy/plan-complete.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") +} + +fn verdicts(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +fn pointers(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.path) + .collect() +} + +const UNFINISHED: &str = "plan-unfinished"; +const UNRECORDED: &str = "plan-unrecorded"; + +// --------------------------------------------------------------------------- +// THE READ SEAM. Without these two the whole module is a `with input as` suite +// over a key nothing fills — the shape a dead gate and a clean tree share. +// --------------------------------------------------------------------------- + +/// The engine reads a store NO `[[recorder]]` declares. This is the assertion +/// that would have failed before `recorder_records` learned to read the +/// verb-written names, with every module test still green. +#[test] +fn the_engine_reads_a_verb_written_plan_store() { + let root = repo("plan-read-seam", &["src/a.rs"], Some(&["1 pending"])); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned()], + "the record the verb writes must reach the predicate" + ); +} + +/// And the empty store is DISTINGUISHABLE from an absent one across the engine +/// boundary, not just inside the module. Absent refuses on the vacuity arm; +/// empty is an answer and is clean. If the projection collapsed the two, the +/// remedy for a trivial branch would be unreachable. +#[test] +fn an_empty_store_and_an_absent_one_reach_different_arms() { + let empty = repo("plan-empty", &["src/a.rs"], Some(&[])); + assert!( + verdicts(&empty).is_empty(), + "an empty record is the branch saying there is nothing to track: {:?}", + verdicts(&empty) + ); + + let absent = repo("plan-absent", &["src/a.rs"], None); + assert_eq!( + verdicts(&absent), + vec![UNRECORDED.to_owned()], + "no record at all is the vacuity the other arm cannot see" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unfinished`. +// --------------------------------------------------------------------------- + +#[test] +fn an_unfinished_entry_stops_the_lap() { + let root = repo( + "plan-unfinished", + &["src/a.rs"], + Some(&["1 completed", "2 in_progress"]), + ); + assert_eq!(verdicts(&root), vec![UNFINISHED.to_owned()]); + assert!( + pointers(&root).iter().any(|line| line.contains('2')), + "the refusal names the entry: {:?}", + pointers(&root) + ); +} + +#[test] +fn a_wholly_completed_plan_is_clean() { + let root = repo( + "plan-done", + &["src/a.rs"], + Some(&["1 completed", "2 deleted"]), + ); + assert!( + verdicts(&root).is_empty(), + "finished and withdrawn are both terminal: {:?}", + verdicts(&root) + ); +} + +/// ONE FINDING PER ENTRY, so finishing one does not clear another and a reviewer +/// sees which item rather than a count to reconstruct. +#[test] +fn every_unfinished_entry_is_reported() { + let root = repo( + "plan-many", + &["src/a.rs"], + Some(&["1 pending", "2 completed", "3 pending"]), + ); + assert_eq!( + verdicts(&root), + vec![UNFINISHED.to_owned(), UNFINISHED.to_owned()] + ); +} + +/// POINTER, NEVER PAYLOAD (rule 4). The store holds an id and a status token and +/// no description, so there is no prose here to leak — and this is the assertion +/// that keeps a later edit from adding one. +#[test] +fn the_refusal_carries_no_entry_prose() { + let root = repo("plan-pointer", &["src/a.rs"], Some(&["1 pending"])); + let rendered = pointers(&root).join("\n"); + assert!(rendered.contains('1'), "the id is the pointer: {rendered}"); + assert!( + !rendered.contains("pending"), + "a status token is not a pointer: {rendered}" + ); +} + +// --------------------------------------------------------------------------- +// `plan-unrecorded` — the anti-vacuity arm. +// --------------------------------------------------------------------------- + +#[test] +fn a_branch_that_recorded_no_plan_is_refused() { + let root = repo("plan-none", &["src/a.rs"], None); + assert_eq!(verdicts(&root), vec![UNRECORDED.to_owned()]); +} + +/// A branch holding nothing open has nothing to have planned. Without that the +/// arm fires on every fresh checkout, which is how a gate gets switched off. +/// +/// The fixture always writes the module into the tree, so `changed` is never +/// truly empty here — the case that needs a genuinely empty delta lives in the +/// module's own suite, and this one records why it cannot live here. Same split, +/// and same reason, as `filed_here.rs`'s empty-delta note. +#[test] +fn the_engine_tier_cannot_build_an_empty_delta() { + let root = repo("plan-fresh", &[], None); + assert_eq!( + verdicts(&root), + vec![UNRECORDED.to_owned()], + "installing the module is itself a change, so this tier always has a diff" + ); +} + +/// AN UNCLAIMED BRANCH OWES NO PLAN, and this is the case that keeps the +/// committed config usable over a scratch repository. The first draft of the +/// vacuity arm keyed only on a non-empty diff, which is true of every fixture — +/// measured, it reddened four `cli.rs` cases whose only business was exercising +/// unrelated rules. Asserted at THIS tier and not only in the module, because +/// the population it selects is a record the engine has to actually read. +#[test] +fn an_unclaimed_branch_is_not_this_gates_business() { + let root = claimed_repo("plan-unclaimed", &["src/a.rs"], None, false); + assert!( + verdicts(&root).is_empty(), + "a branch that pulled no row owes no plan: {:?}", + verdicts(&root) + ); +} + +/// ANTI-VACUITY over the whole file: the row this suite exercises is the one the +/// committed config declares, so a rename or a scope change reddens here rather +/// than leaving every case above passing over a module nothing runs. +#[test] +fn the_committed_row_is_the_one_these_cases_exercise() { + let committed: Vec = batten::config::load(&common::at_root("batten.toml")) + .expect("the committed config loads") + .rules; + let declared = committed + .iter() + .find(|rule| rule.id == "plan-complete") + .expect("the committed config declares the row this suite exercises"); + assert_eq!(declared.kind, RuleKind::Policy); + assert_eq!(declared.scope, RuleScope::Tree); + assert_eq!( + declared.module.as_deref(), + Some("policy/plan-complete.rego") + ); +} + +/// The verb and the reader must agree on the store's name and keying. Asserted +/// against `record::VERB_WRITTEN` rather than a literal, so adding a store +/// without teaching the engine to read it cannot pass. +#[test] +fn the_plan_store_is_declared_as_verb_written() { + assert!( + batten::record::VERB_WRITTEN.contains(&"plan"), + "the engine must read the store the verb writes: {:?}", + batten::record::VERB_WRITTEN + ); +} diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 3c9cf6a78..f270a58f0 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -432,6 +432,13 @@ fn forge_verdict() -> String { format!("{} failure\n", canary("concluded")) } +/// Plan entries read on stdin by `record plan`. The id carries the canary, so a +/// refusal that echoed an entry back — the one thing this store must never put in +/// a diagnostic, since an id is the agent's own text — fails the census. +fn plan_entries() -> String { + format!("{} pending\n", canary("entry")) +} + /// A ledger row read on stdin by `defects add -n`. The caller wrote it, so its /// bytes are a declaration. fn incoming_record() -> String { @@ -469,6 +476,7 @@ enum Stdin { DesignClaims, ToolVerdict, ForgeVerdict, + PlanEntries, } struct Verb { @@ -1143,6 +1151,16 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::ForgeVerdict, disposition: Disposition::PointerOnly, }, + // CLOUD-472. The entry id piped in carries the canary, because an id is the + // AGENT's own text and is the one thing a refusal here must never echo — a + // malformed line is reported by its NUMBER and the closed status vocabulary, + // which is `record tool`'s discipline over a different payload. + Verb { + path: "record plan", + args: &[], + stdin: Stdin::PlanEntries, + disposition: Disposition::PointerOnly, + }, ]; /// Every path of [`SURFACE`] that RUNS — the object this census must be total @@ -1214,6 +1232,7 @@ fn run_in(corpus: &Corpus, args: &[&str], stdin: Stdin) -> Run { Stdin::DesignClaims => design_claims(), Stdin::ToolVerdict => tool_verdict(), Stdin::ForgeVerdict => forge_verdict(), + Stdin::PlanEntries => plan_entries(), }; // A BROKEN PIPE HERE IS THE CHILD BEING FAST, NOT A FAILURE. This corpus runs // every verb, and a verb that reads no stdin may exit before the write lands — diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 23140e663..90f5e0de6 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -228,7 +228,15 @@ fn with_tasks(name: &str) -> PathBuf { // none gets could-not-look naming the first missing id rather than a // verdict — the right answer for such a repository, and not what these // cases are about. `repo` above opts in for the same reason. - .config(&format!("version = 1\n\n{}", declared_patterns())) + // + // The prose-dialect threshold is DECLARED rather than defaulted + // (CLOUD-472). A fixture omitting it gets `None` — could-not-look — and + // every threshold case below would then pass for the wrong reason, which + // is the shape a dead gate and a clean tree share. + .config(&format!( + "version = 1\n\n[ready]\nprose_dialect_required_from = \"2026-06-01T00:00:00.000Z\"\n\n{}", + declared_patterns() + )) .file( "Cargo.toml", "[workspace.package]\nversion = \"0.0.125\"\n\n[workspace.dependencies]\nserde = \"1\"\n", @@ -259,9 +267,14 @@ fn complete_claims() -> serde_json::Value { "gate": { "task": "verify", "exits": [0, 2] }, "commit_type": "feat", "blockers": [], + // A SLUG, NOT PROSE (CLOUD-472). This fixture carried + // "drop the required-key check" — a sentence, which is what the field + // meant under CLOUD-418 and which nothing can resolve. `batten mutate` + // resolves a slug, so the obligation becomes checkable rather than + // asserted, and every case below inherits the corrected shape. "tests": [{ "file": "crates/batten/tests/it/ready.rs", - "mutation": "drop the required-key check", + "mutation": "required-key-unread", }], }) } @@ -273,6 +286,104 @@ fn claims_payload(object: &serde_json::Value, blocked_by: &[&str]) -> String { ) } +/// A row created after the fixture's cutover, so the prose dialect is refused. +const AFTER_CUTOVER: &str = "2026-07-01T00:00:00.000Z"; +/// A row created before it, so the prose dialect still passes. +const BEFORE_CUTOVER: &str = "2026-01-01T00:00:00.000Z"; + +/// A payload carrying a chosen creation instant, for the cutover cases below. +/// +/// Every other fixture here omits `createdAt` entirely, which is could-not-look +/// and exempt — that is why the whole prose corpus above stays clean, and why +/// these cases have to state their own instant rather than reusing the shared +/// builder. +fn dated_payload(created_at: Option<&str>, description: &str) -> String { + let mut value = serde_json::json!({ + "id": "CLOUD-999", + "description": description, + "relations": { "blockedBy": [] }, + }); + if let Some(created_at) = created_at { + value["createdAt"] = serde_json::json!(created_at); + } + value.to_string() +} + +// --------------------------------------------------------------------------- +// CLOUD-472: the prose dialect is a LEGACY, not an alternative. +// +// `REQUIRED_CLAIMS` and `check_claimed_tests` already force a `mutation` onto +// every declared obligation — CLOUD-418's field. That mechanism was unreachable, +// because an absent fence dropped the author onto the prose path, and measured +// 2026-09-01 the object was used by nothing at all. +// --------------------------------------------------------------------------- + +#[test] +fn a_prose_block_past_the_cutover_is_refused() { + let dir = with_tasks("ready-prose-past-cutover"); + let output = lint( + &dir, + &dated_payload( + Some(AFTER_CUTOVER), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("claims-object-absent"), + "the refusal must name the class, or the author cannot act on it: {}", + stderr(&output) + ); +} + +#[test] +fn a_claims_object_past_the_cutover_is_clean() { + // The remedy has to be REACHABLE from the refusal above, or the ratchet is a + // wall. Same key, same fixture, the object supplied. + let dir = with_tasks("ready-object-past-cutover"); + let object = serde_json::to_string_pretty(&complete_claims()).expect("encodable"); + let output = lint( + &dir, + &dated_payload(Some(AFTER_CUTOVER), &claims_block(&object)), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// THE ANTI-VACUITY MIRROR, and without it the arm is satisfied by a check that +/// refuses every prose block — which is the change that takes the board's ready +/// frontier dark in one step (CLOUD-858's measured shape). +#[test] +fn a_prose_block_before_the_cutover_is_clean() { + let dir = with_tasks("ready-prose-before-cutover"); + let output = lint( + &dir, + &dated_payload( + Some(BEFORE_CUTOVER), + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + +/// COULD-NOT-LOOK PASSES. A payload carrying no creation instant cannot be +/// placed against the cutover at all, so it is judged exactly as it was before +/// this clause existed. Reading "no stamp" as "past the cutover" would turn a +/// verdict about the payload into a verdict about the row — and this is the arm +/// that keeps every other fixture in this file, none of which sets `createdAt`, +/// passing for the RIGHT reason rather than by accident. +#[test] +fn a_payload_with_no_creation_instant_is_judged_as_before() { + let dir = with_tasks("ready-no-stamp"); + let output = lint( + &dir, + &dated_payload( + None, + &block("* **Test obligation (§7).** Three discriminating observations.\n"), + ), + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); +} + // --------------------------------------------------------------------------- // §453: the checkable half as data, and the prose path it does not disturb. // --------------------------------------------------------------------------- @@ -356,6 +467,34 @@ fn an_empty_value_is_an_omission_wearing_a_declarations_shape() { ); } +/// CLOUD-472. `mutation` landed under CLOUD-418 as PROSE describing the change +/// that would kill the case — a better claim than nothing, and still joinable to +/// nothing. A slug is joinable: `batten mutate` resolves it, applies the +/// expression, runs the named case, and a survivor is the finding. +/// +/// Shape only, at this tier and at this moment: the case does not exist at +/// refinement time, so resolving the slug here would refuse every honest row +/// before its code was written. Whitespace is the whole discriminator, because +/// `mutate`'s own three-field row format already forbids it in a slug. +#[test] +fn a_mutation_written_as_prose_rather_than_a_slug_is_refused() { + let dir = with_tasks("ready-claims-mutation-prose"); + let mut object = complete_claims(); + object["tests"][0]["mutation"] = serde_json::json!("drop the required-key check"); + let output = lint(&dir, &claims_payload(&object, &[])); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("test-claim-mutation-not-a-slug"), + "the refusal must name the class: {}", + stderr(&output) + ); + + // THE REMEDY IS REACHABLE, which is what keeps this from being a wall: the + // unmodified fixture already carries a slug and passes. + let clean = lint(&dir, &claims_payload(&complete_claims(), &[])); + assert_eq!(code(&clean), 0, "{}", stderr(&clean)); +} + #[test] fn a_gate_that_names_no_task_is_refused() { // The half that makes the mechanism unwritable as prose, which is the row's diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 06fbce79f..fb705955b 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -1389,6 +1389,13 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record plan", + "about": "Record this branch's plan, read as ` ` lines on stdin", + "effect": "write", + "flags": [], + "subcommands": [] + }, { "path": "record tool", "about": "Record a declared tool row's verdict, read as ` ` lines on stdin", diff --git a/man/batten-record-plan.1 b/man/batten-record-plan.1 new file mode 100644 index 000000000..0dde5ab9d --- /dev/null +++ b/man/batten-record-plan.1 @@ -0,0 +1,13 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-plan 1 batten +.SH NAME +batten\-record\-plan \- Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH SYNOPSIS +\fBbatten record plan\fR [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-record.1 b/man/batten-record.1 index e80454911..d311a86c3 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,5 +19,8 @@ Record a declared tool row\*(Aqs verdict, read as ` ` lines on stdi batten\-record\-forge(1) Record the forge\*(Aqs check verdicts for one commit, read as ` ` lines on stdin .TP +batten\-record\-plan(1) +Record this branch\*(Aqs plan, read as ` ` lines on stdin +.TP batten\-record\-help(1) Print this message or the help of the given subcommand(s) diff --git a/mise.toml b/mise.toml index 517dc3f05..cbdf441b7 100644 --- a/mise.toml +++ b/mise.toml @@ -475,7 +475,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,bot-issue,branch-age-check,cap-drift,ci-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,hooks-wiring-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" +MUTANT_GATES = "alive,attestation-check,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,bot-issue,branch-age-check,cap-drift,ci-drift,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-race-check,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hook-matcher-check,hook-pin-check,hooks-wiring-check,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,no-doctests,nonverdict-assert,ntia-check,perf-assert,perf-compare,perf-gate,pinned-toolchain,pipefail-grep-check,plan-complete,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-guard,ready-lint,reclaim-census,release-assets-check,release-due,release-tag-shape,release-tracking-check,released,remedy-authorship,report-only-check,review-answered,run-shape,run-shape-guard,rust-paths-check,sbom,sbom-check,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/filed-here.rego b/policy/filed-here.rego index 3382bb2e1..f752fbf26 100644 --- a/policy/filed-here.rego +++ b/policy/filed-here.rego @@ -10,10 +10,20 @@ # complete Ready block is what flips that, without anything judging whether a # given spin-off was lazy. # -# TWO REFUSALS, AND NEITHER SUBSUMES THE OTHER. `filed-unrefined` prices -# REFINEMENT; `filed-over-own-diff` prices PROXIMITY. A row can earn both — -# "never groomed to Ready" and "names code this branch is holding open" are -# different facts — so they are separate predicates over one parse. +# THREE REFUSALS, AND NO ONE OF THEM SUBSUMES ANOTHER. `filed-unrefined` prices +# REFINEMENT; `filed-over-own-diff` prices PROXIMITY; `filed-and-left-open` prices +# a row this branch opened and simply LEFT OPEN. A row can earn the first +# alongside either of the others — "never groomed to Ready" and "names code this +# branch is holding open" are different facts — so they are separate predicates +# over one parse. +# +# THE LAST TWO ARE PARTITIONED RATHER THAN NESTED, and that is a correction rather +# than a taste. `filed-over-own-diff` requires `not cites_only(id)` and +# `filed-and-left-open` requires `cites_only(id)`, so no row can earn both and a +# reviewer never sees two findings for one cause. Drafted without that +# requirement the third arm was strictly WIDER than the second — every row the +# second refused, the third refused too — and the sentence above stopped being +# true of the module it heads. # # The second exists because the first turned out to be payable in typing. A Ready # block is prose, and prose is the one currency an agent has without limit: @@ -50,6 +60,8 @@ #MUTANT-SUITE crates/batten/tests/it/filed_here.rs #MUTANT unrefined-row-unread|s@^\tlatest\[id\].verdict == "unready"$@\tfalse@|an_unready_create_stops_the_lap #MUTANT closing-row-still-priced|s@^\tnot id in closes$@\ttrue@|a_row_the_pr_closes_is_exempt +#MUTANT left-open-arm-unpartitioned|s@^\tcites_only(id)$@\ttrue@|a_row_recorded_after_the_base_whose_sec1_names_the_diff_still_refuses +#MUTANT left-open-judges-an-unread-body|s@^\tbody_read$@\ttrue@|an_unread_pr_body_leaves_the_set_unjudged # METADATA # description: | @@ -66,6 +78,8 @@ rules contains "filed-unrefined" rules contains "filed-over-own-diff" +rules contains "filed-and-left-open" + # The record, or nothing. ABSENT IS NOT EMPTY: a branch whose recorder never ran # has no key here at all, Rego reads that as *does not hold*, and every rule below # is silent. An empty list would be a measured nothing and would say the branch @@ -150,6 +164,26 @@ closes contains key if { some key in split(substring(columns[1], indexof(columns[1], ":") + 1, -1), ",") } +# THE BODY WAS READ, so "this PR closes nothing" is a MEASUREMENT rather than an +# absence — the third state the `pr-closes` recorder writes `zero-is-a-count` for. +# +# `closes` alone cannot carry this. An empty `closes` set has three causes that a +# set-membership test flattens into one: the body closes nothing, no PR exists +# yet, and the key reader could not run. The first is a reading and the other two +# are could-not-look, and `filed-and-left-open` refuses over the WHOLE SET rather +# than over a row's properties, so flattening them would refuse every row a branch +# ever filed the first time `verify` runs before the PR is opened — for a remedy +# ("name it in closing form in the PR body") that has nowhere to be written yet. +# +# `-` is the recorder saying it could not read the keys and leaves the set +# unjudged; `0` is a measured nothing and judges it. +body_read if { + some raw in input.tree.records["pr-closes"] + columns := split(raw, " ") + columns[0] == "closes" + columns[1] != "-" +} + # `filed-unrefined`: a row this branch created was never groomed to Ready. # # `ready` passes and so does `-`; only the tracker's own `unready` refuses. @@ -238,6 +272,43 @@ violation contains { not cites_only(id) } +# `filed-and-left-open`: a row this branch filed, that this branch does not close. +# +# THE ARM THE MEASUREMENT ASKED FOR (CLOUD-1311). Three of one session's four +# deferrals were invisible to `filed-over-own-diff` precisely BECAUSE their §1 +# named paths outside the diff — `cites_only` exempts those by design, and rightly +# so for a refusal about proximity. Nothing then priced them at all, and the +# detector was a human asking twice. +# +# IT CLASSIFIES NOTHING, which is what keeps non-negotiable rule 3 satisfied. It +# reports a SET: the rows this branch put on the board that it is not landing. The +# author closes one, lets the body close it, or spends an admission whose +# articulation says why it is independent work — and that articulation is +# hash-bound into the commit message, where a reviewer reads it, rather than into +# a turn that ends. +# +# THREE COULD-NOT-LOOKS GUARD IT, and each is a different question. +# * `body_read` — the forge's answer has not been captured, so the closing +# remedy is unreachable and the set is unjudged rather than refused. +# * a non-empty `changed` — a branch holding nothing open has fixed nothing and +# deferred nothing, so "you filed instead of fixing" is a claim about a diff +# that does not exist. It is not a dodge: an empty branch cannot land either. +# * `cites_only`'s own `-` — a record from an older recorder has no §1 column, +# so the partition cannot be evaluated and the row stays judged as it was +# before this arm existed. +violation contains { + "rule": "filed-and-left-open", + "verdict": "V-FILED-AND-LEFT-OPEN", + "subjects": [{"artifact": id}], +} if { + some id, _ in latest + body_read + count(changed) > 0 + not id in closes + not predates_the_branch(id) + cites_only(id) +} + # The predicate's own tests. The SILENT cases are the load-bearing half: every # skip above is a pass-side property, and a rule that fired on every row would # satisfy the denies while deciding nothing. @@ -320,11 +391,89 @@ test_a_row_written_before_the_branch_cannot_be_its_punt if { ) } -# CITING IS NOT CLAIMING. The row names the path in its body but its declared -# source of truth is somewhere else entirely. +# CITING IS NOT CLAIMING — for the PROXIMITY refusal, which is the only one it +# was ever about. The row names the path in its body but its declared source of +# truth is somewhere else entirely, so `filed-over-own-diff` is silent. +# +# IT IS NOT SILENT ALTOGETHER, AND THAT IS THE PARTITION. This is the exact shape +# of the three punts nothing caught: a row filed while the branch was open, whose +# §1 points somewhere else, so proximity exempts it and — before this arm — no +# refusal reached it. The case asserts the SET of verdicts rather than a count, so +# a later change collapsing the two arms back together reddens here. test_a_row_that_only_cites_the_path_is_not_claiming_it if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} +} + +# NO PR BODY YET IS COULD-NOT-LOOK, not a measured nothing. Without this the arm +# refuses every row a branch filed the first time `verify` runs before the PR is +# opened, naming a remedy that has nowhere to be written. +test_an_unread_body_leaves_the_set_unjudged if { count(violation) == 0 with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + [], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# AND A FETCH WHOSE KEY READER COULD NOT RUN IS THE SAME ANSWER, which is what +# `zero-is-a-count` exists to keep distinct from `closes 0`. +test_an_unreadable_closing_key_column_leaves_the_set_unjudged if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes -"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +# A BRANCH HOLDING NOTHING OPEN DEFERRED NOTHING. `changed` is empty, so there is +# no diff for the row to have been filed instead of. +test_a_branch_with_no_diff_judges_no_row if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 0"], + [], + "2026-01-01T00:00:00Z", + ) +} + +# THE CLOSING REMEDY REACHES THIS ARM TOO, and a body closing a DIFFERENT row does +# not — the anti-vacuity half, without which one closing key buys the whole set. +test_a_left_open_row_the_body_closes_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) +} + +test_closing_one_row_does_not_close_the_set if { + verdicts := {v.verdict | some v in violation} with input as with_diff( + [ + "issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + "issue CLOUD-2 2026-02-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs", + ], + ["closes 1:CLOUD-1"], + ["src/a.rs"], + "2026-01-01T00:00:00Z", + ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} +} + +# A ROW WRITTEN BEFORE THE BRANCH IS EXEMPT FROM THIS ARM ON THE SAME GROUND it is +# exempt from the proximity one: it cannot be a deferral of a diff that did not +# exist. +test_a_left_open_row_predating_the_branch_is_exempt if { + count(violation) == 0 with input as with_diff( + ["issue CLOUD-1 2025-12-01T00:00:00Z ready 1,src/a.rs - 1,src/b.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", @@ -350,14 +499,17 @@ test_an_unanswered_overlap_passes if { ) } -# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF. -test_a_row_naming_a_path_outside_the_diff_passes if { - count(violation) == 0 with input as with_diff( +# A NAMED PATH THIS BRANCH IS NOT TOUCHING IS NOT A PUNT AGAINST ITS DIFF — and +# that is still true of the proximity refusal, which stays silent here. It is a +# row left open, so the third arm takes it. +test_a_row_naming_a_path_outside_the_diff_is_not_a_proximity_refusal if { + verdicts := {v.verdict | some v in violation} with input as with_diff( ["issue CLOUD-1 2026-02-01T00:00:00Z ready 1,src/z.rs - 1,src/z.rs"], ["closes 0"], ["src/a.rs"], "2026-01-01T00:00:00Z", ) + verdicts == {"V-FILED-AND-LEFT-OPEN"} } # COULD NOT READ THE BASE DATE LEAVES EVERY ROW JUDGED AS BEFORE, rather than diff --git a/policy/plan-complete.rego b/policy/plan-complete.rego new file mode 100644 index 000000000..9c5e13d7b --- /dev/null +++ b/policy/plan-complete.rego @@ -0,0 +1,238 @@ +# The plan a branch declared, held to its own end (CLOUD-472). +# +# WHY THIS IS A SEPARATE MODULE FROM `filed-here`. That one's subject is the +# BOARD — rows this branch put on the tracker — and its header rests on three +# refusals none of which subsumes another. A plan entry is not a board row: it is +# the agent's own declared work, in the agent's own words, and folding it in +# would make that invariant unreadable. Same shape, different subject, different +# store. +# +# THE SENSOR EXISTED AND HAD NO RATCHET, which is this repository's recurring +# defect rather than a new one. An agent's task list is displayed every turn and +# is the most legible statement of what it believes is outstanding — and nothing +# in the tree could see it, so a branch could land with half its list `pending` +# and every gate stayed green. Measured 2026-09-01: three items sat `pending` +# while the session reported the work as planned, and the only detector was a +# human asking. +# +# A VERB WRITES THIS STORE, NOT A HOOK, and the direction is the whole design. +# Recording from the harness's own todo tool needs a spelling per host — +# `TaskCreate`/`TaskUpdate`, `write_todos`, `todowrite`, `update_plan` — and +# fails the same way in three different ways: an unsurveyed harness, a tool a +# setting switched off, and a compliant agent all record nothing, so the gate +# reads clean. `OpenCode` makes it concrete by denying `todowrite` to subagents at +# session creation whatever the config says. `batten record plan` inverts it: the +# agent tells the engine, and a missing record REFUSES, identically everywhere. +# +# WHAT IT DOES NOT DO (rule 3): it reads a status token and nothing else. It does +# not judge whether an entry was worth doing, whether its text is honest, or +# whether the work behind `completed` happened. Those are model verdicts and no +# gate here makes one. The author closes the entry, drops it, or spends an +# admission whose articulation says why it is not this branch's to finish — and +# that articulation is hash-bound into the commit message, where a reviewer reads +# it. +# +# POINTER, NEVER PAYLOAD (rule 4): a refusal names the entry's id and its status +# token. The id is the agent's own text, so the finding carries it as an +# `artifact` subject rather than as prose, and the entry's description never +# enters the store at all. +#MUTANT-SUITE crates/batten/tests/it/plan_complete.rs +#MUTANT unfinished-entry-unread|s@^\tnot done(entry_row.status)$@\tfalse@|an_unfinished_entry_stops_the_lap +#MUTANT no-plan-at-all-unpriced|s@^\tcount(changed) > 0$@\tfalse@|a_branch_that_recorded_no_plan_is_refused + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads +# `input.tree` and never the mediated call. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.plan_complete + +import rego.v1 + +rules contains "plan-unfinished" + +rules contains "plan-unrecorded" + +# The store, or nothing. ABSENT IS NOT EMPTY, and the two reach different arms +# below on purpose: an empty file is "I recorded a plan and it holds no entries", +# while no file at all is "this branch never told the engine anything" — which is +# the vacuity `plan-unrecorded` exists to price rather than to pass. +recorded := input.tree.records.plan + +# One entry per line, ` `. A line this reader cannot parse is skipped +# rather than judged, matching every other record reader here: the writer already +# refused a malformed line, so anything unparseable at read time is a torn store +# and not an author's claim. +entry contains row if { + some raw in recorded + columns := split(raw, " ") + count(columns) >= 2 + columns[0] != "" + row := {"id": columns[0], "status": columns[1]} +} + +# The two terminal statuses. `deleted` is terminal because withdrawing an entry +# is a decision the author is entitled to make and the store records that they +# made it; what the gate refuses is an entry left in flight, not one closed. +done(status) if { + status in {"completed", "deleted"} +} + +# The branch's own diff, as the engine resolved it — the same reading +# `filed-here` takes, and `null` when the base does not resolve, so `changed` +# stays empty and every arm below goes quiet rather than fabricating a verdict. +delta := input.tree["base-delta"] + +changed contains path if { + some path in delta.added +} + +changed contains path if { + some path in delta.edited +} + +changed contains path if { + some path in delta.deleted +} + +# `plan-unfinished`: an entry the branch declared and left in flight. +violation contains { + "rule": "plan-unfinished", + "verdict": "V-PLAN-UNFINISHED", + "subjects": [{"artifact": entry_row.id}], +} if { + some entry_row in entry + not done(entry_row.status) +} + +# A BRANCH THAT CLAIMED WORK. `claim check` writes this store on its pullable +# path, so its presence is the branch saying "I pulled a row and I am working +# it" — precisely the population that owes a plan. +claimed if { + some _ in input.tree.records.claim +} + +# `plan-unrecorded`: A CLAIMED BRANCH THAT DECLARED NO PLAN AT ALL. +# +# WITHOUT THIS ARM THE GATE IS WORTHLESS, and that is not hypothetical — a +# refusal over "entries left in flight" is satisfied completely by never +# recording an entry, so the cheapest route past it is silence. Same vacuity +# `mutate` already refuses by REPORTING a declared mutation whose named case does +# not exist rather than counting it. +# +# THE CLAIM IS THE PRECONDITION, AND THE FIRST DRAFT GOT THIS WRONG. It asked +# only for a non-empty diff, which is true of every scratch fixture and every +# consumer checkout — measured, that version reddened four `cli.rs` cases whose +# only business was exercising unrelated rules over a fixture repository. A rule +# that fires on any dirty tree makes the committed config unusable over a test +# repo, and a rule like that gets switched off. Keying on the claim asks the +# question where the answer is owed: a branch that pulled a row is doing tracked +# work; one that did not is not this arm's business. +# +# A NON-EMPTY DIFF IS STILL REQUIRED, so the arm prices work rather than +# existence: a claimed branch that has not started has nothing to have planned. +# An empty RECORD satisfies it — the store exists, so the branch spoke — which +# keeps the remedy honest for a genuinely trivial change: one call saying so, +# rather than a fabricated entry. +violation contains { + "rule": "plan-unrecorded", + "verdict": "V-PLAN-UNRECORDED", + "subjects": [{"count": count(changed)}], +} if { + claimed + not recorded + count(changed) > 0 +} + +# The predicate's own tests. The SILENT cases are the load-bearing half here for +# the usual reason: both arms are refusals, so a module that fired on everything +# would satisfy every deny case while deciding nothing. + +# Both builders carry a claim, because both arms are about a branch doing tracked +# work and a fixture without one would exercise the wrong population. +plan(lines, changed_paths) := {"tree": { + "records": {"plan": lines, "claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +no_plan(changed_paths) := {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": {"added": changed_paths, "edited": [], "deleted": [], "code-changed": []}, +}} + +test_an_unfinished_entry_is_refused if { + some v in violation with input as plan(["1 pending"], ["src/a.rs"]) + v.verdict == "V-PLAN-UNFINISHED" +} + +test_an_in_progress_entry_is_refused if { + some v in violation with input as plan(["1 in_progress"], ["src/a.rs"]) + v.verdict == "V-PLAN-UNFINISHED" +} + +test_a_completed_entry_is_clean if { + count(violation) == 0 with input as plan(["1 completed"], ["src/a.rs"]) +} + +# WITHDRAWING AN ENTRY IS A DECISION, AND THE STORE RECORDS THAT IT WAS MADE. +# The gate refuses work left in flight, never work the author decided against. +test_a_deleted_entry_is_clean if { + count(violation) == 0 with input as plan(["1 deleted"], ["src/a.rs"]) +} + +# ONE FINDING PER ENTRY, so a reviewer sees which item rather than a count they +# have to reconstruct — and finishing one does not clear another. +test_every_unfinished_entry_is_named if { + ids := {v.subjects[0].artifact | some v in violation} with input as plan( + ["1 completed", "2 pending", "3 in_progress"], + ["src/a.rs"], + ) + ids == {"2", "3"} +} + +# THE ANTI-VACUITY ARM. Never recording is the cheapest way past a refusal over +# unfinished entries, so silence is priced. +test_a_branch_that_recorded_no_plan_is_refused if { + some v in violation with input as no_plan(["src/a.rs"]) + v.verdict == "V-PLAN-UNRECORDED" +} + +# AN EMPTY RECORD IS AN ANSWER. The branch spoke and said there is nothing to +# track, which is the honest remedy for a trivial change — as against a +# fabricated entry, which is what a gate demanding a non-empty list would buy. +test_an_empty_record_satisfies_the_vacuity_arm if { + count(violation) == 0 with input as plan([], ["src/a.rs"]) +} + +# A BRANCH HOLDING NOTHING OPEN HAS NOTHING TO HAVE PLANNED, so a fresh checkout +# is never refused for a plan it had no occasion to write. +test_a_branch_with_no_diff_is_never_refused if { + count(violation) == 0 with input as no_plan([]) +} + +# COULD NOT READ THE BASE leaves the vacuity arm silent rather than firing on +# every branch whose base does not resolve — a verdict about the environment is +# not a verdict about the branch. +test_an_unresolvable_delta_leaves_the_vacuity_arm_silent if { + count(violation) == 0 with input as {"tree": { + "records": {"claim": ["CLOUD-1"]}, + "base-delta": null, + }} +} + +# AN UNCLAIMED BRANCH IS NOT THIS ARM'S BUSINESS, and this is the case that keeps +# the committed config usable over a scratch tree. Without it the arm fires on +# every fixture repository that runs the whole config to exercise some unrelated +# rule — measured at four such cases before the claim became the precondition. +test_an_unclaimed_branch_owes_no_plan if { + count(violation) == 0 with input as {"tree": { + "records": {}, + "base-delta": {"added": ["src/a.rs"], "edited": [], "deleted": [], "code-changed": []}, + }} +} + +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as plan(["", "nonsense"], ["src/a.rs"]) +} diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 4b0b8b8e6..42befad6b 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -253,6 +253,17 @@ } ] }, + "ready": { + "description": "The refinement gate's consumer-set thresholds (CLOUD-472). Absent means\nthis file does not speak to them, which every reader takes as\ncould-not-look rather than as a default.", + "anyOf": [ + { + "$ref": "#/$defs/Ready" + }, + { + "type": "null" + } + ] + }, "recorder": { "description": "Records written from the tool result that earned them (CLOUD-1051).\n\nThe third selector on the post-tool event, and the one that can carry a\nvalue another gate decided. A `[[mint]]` renders a template over the\npayload; a `[[recorder]]` may additionally run a declared program and\nrecord its verdict, which is what a board write's refinement column IS.\n\nConsumer-owned for the same reason `[[mint]]` is, and more so: the column\nnames, the verdict tokens and the programs are all a tracker's vocabulary,\nso a grep of `crates/batten` for any of them returns nothing and every one\nof them lives here.", "type": "array", @@ -2136,6 +2147,20 @@ } ] }, + "Ready": { + "description": "The `[ready]` table: the refinement gate's consumer-set thresholds.\n\n# Why a value and not a `[[pattern]]` row (CLOUD-472)\n\nThe first draft of the prose-dialect ratchet spelled its threshold as a\nregex over the exempt key range. That is wrong twice. The pattern registry\nexists so that one CONCEPT has one spelling — arithmetic is not a concept,\nand a range encoded in alternation is unreadable and unmovable. And it makes\nthe decision turn on key TEXT, which the tracker is known to rewrite: this\nconsumer already declares `ready-issue-mention-markup` because a bare key\ncomes back wrapped in `` markup, so matching key text is matching\nthe one thing the round trip mangles.", + "type": "object", + "properties": { + "prose_dialect_required_from": { + "description": "From which creation instant a Ready block must carry the fenced claims\nobject rather than prose. An ISO-8601 UTC stamp, compared verbatim\nagainst the tracker's own `createdAt`.\n\n**A RATCHET: moving it later is the only direction that tightens.**\nAbsent is could-not-look and exempts everything, so a consumer that has\nnot opted in is never refused for a question it did not ask.\n\n**An instant rather than a key ordinal, and that is a portability\ndecision.** An ordinal reaches no consumer literal — the trailing digits,\nno separator assumed — so it passes `no-tracker-key-in-core`. It still\nrequires keys that are numeric AND monotonic with creation order, which\nthree popular trackers give and a slug- or UUID-keyed one does not, and it\nwould fail SILENTLY there rather than loudly. Every tracker stamps a\ncreation time, so this assumes nothing about how a consumer spells a key.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ReceiptKey": { "description": "Which git fact a receipt is keyed to, and therefore what invalidates it.\n\nThe distinction is not a tuning knob, it is what the receipt *attests*.\nA `head` receipt claims something about those exact bytes, so an amend or a\nrebase must expire it. A `branch` receipt claims a decision about the work,\nwhich every commit on the branch continues to serve, so a SHA-keyed one\nwould demand a re-claim per commit — the false-positive rate that gets a\nguard bypassed. Both spellings are carried from the shell layer that proved\nthem (`ready-guard` keys by SHA, `claim-check` by branch).\n\n**`ValueEnum` because the CLI selects the same keying** (CLOUD-741). A\n`receipt` rule is pinned to [`RuleScope::MediatedCall`], so `batten check`\ncan never evaluate one and `verify` cannot reach this predicate through the\nengine — which left `verify` re-implementing it in shell, weakly enough that\nCLOUD-516's own incident passed. `receipt status --key branch` is how the\ntree surface reaches the one implementation instead, so config and CLI must\nname the keying with the same tokens or the two surfaces disagree about what\nthey asked for. `clap`'s and serde's renames both land on `head`/`branch`;\nthe `clap(rename_all)` is stated rather than inferred so a future variant\ncannot drift them apart.", "oneOf": [