Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
63caf53
feat(fetch): pair the HTTP method with its body, so PATCH becomes exp…
wenzowski Sep 1, 2026
85cbafb
feat(carry): decide whether a licence-carry branch is derivable, offline
wenzowski Sep 1, 2026
c947f87
feat(claim): attest a licence-carry branch, so the lane needs no fake…
wenzowski Sep 1, 2026
49c4bec
revert(fetch): drop the PATCH payload, whose premise did not survive …
wenzowski Sep 1, 2026
47dff8f
refactor(ci)!: retire bot-issue onto the engine, and wire the carry r…
wenzowski Sep 1, 2026
9cad860
test(bot): gate the bot lane suite on unix, where its stub can run
wenzowski Sep 1, 2026
2759da7
feat(rules): let a receipt row accept any one of several receipts
wenzowski Sep 1, 2026
2c7361d
test(rules): drive the receipt alternation over the compiled binary
wenzowski Sep 1, 2026
94d2695
refactor(ci): admit a task-name span in the retirement repointing arm
wenzowski Sep 1, 2026
2b3de57
fix(ci): report the reclaim verdict once per boot, not once per session
wenzowski Sep 1, 2026
6d77ebb
fix(ci): keep the reclaim body inline-shaped and annotate its spawns
wenzowski Sep 1, 2026
9ed38ed
docs(ci): declare the receipt-alternation weakening in its landable form
wenzowski Sep 1, 2026
b3c822f
fix(rules): declare `checks_any`'s fact, and split the two long asser…
wenzowski Sep 1, 2026
9192b4e
fix(ci): refresh the prune basis count to the tree that exists
wenzowski Sep 1, 2026
0e089a9
chore(bench): re-measure the corpus over the rebased tree
wenzowski Sep 1, 2026
554eedd
feat(ci): let the licence-carry lane land itself on green
wenzowski Sep 1, 2026
605e944
feat(policy): the punt sweep gets an exit code
wenzowski Sep 1, 2026
c5e4442
test(policy): the engine tier cannot build an empty delta, so say so
wenzowski Sep 1, 2026
0ab9061
fix(claim): one branch carries as many claims as it has rows
wenzowski Sep 1, 2026
67ad70a
feat(ready): the prose dialect becomes a legacy, on a declared cutover
wenzowski Sep 1, 2026
e3062fe
feat(policy): the plan a branch declared, held to its own end
wenzowski Sep 1, 2026
8af710e
fix(policy): the plan mutation named a variable the arm does not bind
wenzowski Sep 1, 2026
faf853a
feat(hook): a host's plan surface is surveyed, unsurveyed, or measure…
wenzowski Sep 2, 2026
7a9e12f
feat(ready): a test obligation's mutation names a slug, not a sentence
wenzowski Sep 2, 2026
76b14cb
feat(facts): one definition of an issue key, and a gate on the twenty…
wenzowski Sep 1, 2026
b45e374
fix(claim): a carried row keeps the weakening it groomed
wenzowski Sep 2, 2026
81242ab
feat(policy): a declared obligation names a case, or it is not landing
wenzowski Sep 2, 2026
d60ed4b
feat(record): a verb writes the closes record, so the exemption can fire
wenzowski Sep 2, 2026
c9b2e3c
fix(policy): the obligation mutations named a variable the arms do no…
wenzowski Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions .claude/rules/commits.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,36 @@ ci-drift` polices `batten.toml`'s `[ci]` projection of it against the live
writing the honest type — the changelog marker and the history depend on it,
and the arrows start firing at `0.1.0` — but do not promise a bump in an
issue's Ready block that the tool will not produce.
- Keep PRs small and focused; rebase on `main` before opening. Reference the
relevant `CLOUD-*` issue — scope lookups to the **Batten** project, since the
board spans others.
- Rebase on `main` before opening. Reference the relevant `CLOUD-*` issue —
scope lookups to the **Batten** project, since the board spans others.
- **A PR is bounded by what the work coherently needs, and a fix you can make is
part of that work rather than a follow-up row.** This bullet used to open
_"Keep PRs small and focused"_, and that clause is deleted rather than softened
because it was read exactly as it looks: as licence to stop at a diff size and
file the rest. AGENTS.md already settles this — _"a punt is any deferral you
could have closed... Can do it, do it; can't, file it"_ — and a style note
sitting one directory away must not read as an exception to it. Where the two
seem to disagree, the anti-punt directive wins, and the disagreement is a bug
in this file.

Measured on CLOUD-1295 (2026-09-01), which is why the clause is gone rather
than qualified. Retiring `bot-issue` surfaced three rows — CLOUD-1297,
CLOUD-1299, CLOUD-1301. Two were closeable with the change in hand and the
third became closeable mid-session when `main` deleted the governed suite that
had blocked it. All three were filed instead, and this bullet was cited as the
reason. The real reason was that the PR was nearly landed after four rebases;
the citation was a route to the same outcome with less of the rule applied,
which is the laundering AGENTS.md's override section names.

**Feedforward only, and deliberately so — no gate is implied.** Non-negotiable
rule 2 asks a new rule to ship a mechanism; this is the REMOVAL of a licence,
and the directive it was overriding already exists and already binds. A reader
who wants the mechanism should look at what actually catches this — `land`'s
own refusal to ready an unfinished branch, and `deferral-check`, which holds a
deferral to naming the row that owns it. Neither can decide whether a deferral
was closeable, because that is a judgement and non-negotiable rule 3 forbids a
gate resolving to one.

- **The FIRST key of a `Refs:` trailer is the row the commit SERVED; the rest are
citations.** So `Refs: CLOUD-658, CLOUD-593, CLOUD-105` says this commit did
CLOUD-658's work and cites the other two as evidence, prior measurement or
Expand Down
23 changes: 23 additions & 0 deletions .claude/rules/policy-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<issue …>` 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
Expand Down
62 changes: 58 additions & 4 deletions .claude/rules/toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,58 @@ 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
that hung a commit when `doctor` first tried to execute a hook from inside the
gate.

**AND THE SUBJECT DECIDES BETWEEN A HOOK AND A VERB, WHICH IS THE WIDER HALF OF
THE SAME RULE.** The directory rule above catches one of the three instances
measured that day; the other two were proposed for `batten.toml` and would still
have been dead. A hook MEDIATES A CALL TO SOMEBODY ELSE'S TOOL, so its subject is
an envelope the host chose to send — and where the host spells the tool
differently, switches it off, or was never surveyed, the envelope never arrives
and the gate passes silently. A verb's subject is a RECORD THE AGENT MINTED, so a
missing one refuses identically on all five. **The rule: whenever the subject is
the agent's own conduct rather than a call it is making, land a batten verb and
read its store — never a hook over the harness's own tool.**

The three instances, because each reaches the wrong answer by a different route:
the pressure-test subagent (its prompt proposed as `.claude/agents/*.md`, the
directory half); the todo gate (proposed as a hook over `TaskUpdate`, whose
spelling is `write_todos` on one host, `todowrite` on another, `update_plan` on a
third, and unknown on two); and `ExitPlanMode`, which only one harness sends at
all. `batten record plan` is what landed instead, and `policy/plan-complete.rego`
refuses a branch that recorded no plan while its diff is non-empty — the arm that
makes an empty store a finding rather than a pass.

**DISABLEABILITY IS UNIVERSAL AND IS NOT THE ARGUMENT.** That reasoning was
offered first and is wrong: this harness's own todo tools can be switched off,
and Gemini CLI documents `useWriteTodos: false` for exactly that, so "the others
let you turn it off" discriminates nothing. The argument is that **absence must
be a reading, not silence** — `hook::PlanTools` therefore distinguishes
`Surveyed(&[])`, a measured none, from `Unsurveyed(owner)`, where nobody has
looked and the row names who owes the survey (CLOUD-209), and `doctor` fails on
an unsurveyed surface that names no owner. A host offering no spelling reads as
unanswered, which is a property only the engine can hold.

## Touching a governed gate: two landable shapes, and there is no third

**Read this before you open a `mise-tasks/*.sh` or a `tests/**/\*.bats`.** The
Expand Down Expand Up @@ -511,9 +556,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-<n>` 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-<n>` 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
Expand Down
38 changes: 38 additions & 0 deletions .github/bot-lane-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
**Why**

A bot proposed this change and no human refined it, which is exactly the case
CLOUD-693 exists for: the row is derived from the pull request's own manifest
diff so the merge moves the board like any other landing. Nothing here was
authored by an agent, and nothing here is a judgement.

Pull request: #{{pr}} (`{{branch}}`, opened by `{{login}}`).

Manifests touched:

{{manifests}}

**Refinement — Ready**

_Refinement gate: Definition of Ready & Done. This body carries only specializations._

- **Source of truth (§1).** The manifest diff on #{{pr}}. It is the one
description of this change that cannot disagree with the change, which is why
nothing here re-types the versions it carries.
- **Computable predicate (§2).** Every required check green on the head SHA,
decided by `mise run checks-green` — the same predicate that gates every other
landing, asked of the SHA that fast-forwards.
- **Effect (§3).** No command-surface change: a dependency or toolchain bump
moves no verb, no flag and no effect row.
- **Output & exit (§5).** Unchanged — this row proposes no new output.
- **Commit / bump (§6).** `{{type}}` → no bump.
- **Test obligation (§7).** The existing suite, unchanged and unskipped: a bump
whose breakage this repo covers reds CI, and one it does not is a coverage gap
to file rather than a reason to hold the bump.
- **Blockers (§8).** None.

**Acceptance**

- #{{pr}} lands on `main` by fast-forward with every required check green,
through `auto-bot-land.yml` and with no human in the loop.
- This row moves to In Review by the merge, from the `Closes` key in the pull
request body.
75 changes: 56 additions & 19 deletions .github/workflows/auto-bot-land.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ name: auto-bot-land
#
# AND THE BOARD MOVES WITH THE WORK (CLOUD-693). A bot proposes with no issue and
# no session, so every lifecycle gate refuses it by construction and a merge moves
# nothing. `mise run bot-issue ensure` is the missing step: it derives an issue
# nothing. `batten pr ensure` is the missing step: it derives an issue
# from the manifest diff, files it, and writes `Closes CLOUD-<n>` into the PR body
# so the merge moves the row like any other landing. It runs on every tick, before
# anything is readied, so the row exists while the PR is still a draft.
Expand Down Expand Up @@ -99,7 +99,12 @@ on:
# was 46% of all workflow runs, and at ~3100 inserted runs a day the global run
# list shifts page boundaries mid-walk, so paginating it is not stable. The
# `if:` below stays as defence in depth; the two answer different failure modes.
branches: ["renovate/**"]
#
# TWO LANES SINCE CLOUD-1213, and the second is the licence-carry one
# `sbom-actions-currency` opens. The scope is per-lane rather than a wildcard
# for the same reason it exists at all: a broad filter puts every completion
# in the repository back on this workflow's run list.
branches: ["renovate/**", "sbom-actions/**"]
# THE ONLY TRIGGER THAT CAN START THE LANE, and the reason this workflow has a
# clock at all. A draft PR completes no workflow, so `workflow_run` never fires
# for one — the `workflow_run` path above is the free ride that lands a head
Expand Down Expand Up @@ -187,14 +192,30 @@ jobs:
(github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
startsWith(github.event.workflow_run.head_branch, 'renovate/'))
(startsWith(github.event.workflow_run.head_branch, 'renovate/') ||
startsWith(github.event.workflow_run.head_branch, 'sbom-actions/')))
runs-on: ubuntu-latest
timeout-minutes: 3 # budget: p95=52s x3 measured=2026-08-19
env:
REPO: ${{ github.repository }}
# The author half of the same test, applied in the resolve step below where
# the PR object is in hand. Named here so both arms read one constant.
BOT_LOGIN: renovate[bot]
# the PR object is in hand. Named here so both arms read one table.
#
# A TABLE OF PAIRS, NOT TWO INDEPENDENT LISTS (CLOUD-1213). The two lanes
# have different authors — Renovate opens its own PRs, and the licence-carry
# lane is opened by `sbom-actions-currency` under the default token — so a
# single login no longer answers. Pairing them is what keeps the widening
# honest: matching any-prefix against any-login would admit
# `renovate[bot]` on a `sbom-actions/` branch and the workflow's own token
# on a `renovate/` one, neither of which either lane can produce. A PR must
# satisfy ONE row whole.
#
# This does not touch CLOUD-867's origin test. `head_repository.full_name`
# is still what a fork cannot forge and is still checked separately; the
# prefix remains a filter, never the trust boundary.
LANES: >-
[{"prefix":"renovate/","login":"renovate[bot]"},
{"prefix":"sbom-actions/","login":"github-actions[bot]"}]
# The freeze `renovate.json5` declares as `stopUpdatingLabel` (CLOUD-1207).
# The two must agree by string or the freeze is a label nothing reads, so
# this is the one place either is spelled in a workflow.
Expand Down Expand Up @@ -235,17 +256,24 @@ jobs:
# above, so a failing `gh` still fails the step.
if [ "$EVENT" = "schedule" ] || [ "$EVENT" = "workflow_dispatch" ]; then
pr=$(gh api "repos/$REPO/pulls?state=open&per_page=100" |
jq -c --arg repo "$REPO" --arg bot "$BOT_LOGIN" \
'[.[] | select(.head.repo.full_name == $repo)
| select(.user.login == $bot)
| select(.head.ref | startswith("renovate/"))] | .[0] // {}')
[ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open renovate PR; nothing to land"
jq -c --arg repo "$REPO" --argjson lanes "$LANES" \
'[.[] | select(.head.repo.full_name == $repo)]
| map(select(. as $p
| any($lanes[]; . as $l
| $p.user.login == $l.login
and ($p.head.ref | startswith($l.prefix)))))
| .[0] // {}')
[ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open bot PR; nothing to land"
else
pr=$(gh api "repos/$REPO/commits/$RUN_SHA/pulls" |
jq -c --arg repo "$REPO" --arg bot "$BOT_LOGIN" \
jq -c --arg repo "$REPO" --argjson lanes "$LANES" \
'map(select(.state == "open")
| select(.head.repo.full_name == $repo)
| select(.user.login == $bot)) | .[0] // {}')
| select(.head.repo.full_name == $repo))
| map(select(. as $p
| any($lanes[]; . as $l
| $p.user.login == $l.login
and ($p.head.ref | startswith($l.prefix)))))
| .[0] // {}')
[ -n "$(jq -r '.number // empty' <<<"$pr")" ] || echo "no open PR for $RUN_SHA; skipping"
fi
num=$(jq -r '.number // empty' <<<"$pr")
Expand Down Expand Up @@ -368,10 +396,16 @@ jobs:
PR_NUM: ${{ steps.target.outputs.num }}
run: |
set +e
mise run bot-issue ensure "$PR_NUM"
batten pr ensure "$PR_NUM"
verdict=$?
# THE ENGINE'S EXIT TABLE, which is not the retired program's
# (CLOUD-1295). `bot-issue` used 1 for "refused, not this lane's" and 2
# for "could not look"; under house style section 7 a refusal is 2 and a
# could-not-look is 3, with 1 reserved for a usage error. A pull request
# this lane declines to file for is still an ordinary outcome and passes;
# everything else fails the run.
case "$verdict" in
0 | 1) exit 0 ;;
0 | 2) exit 0 ;;
*) exit "$verdict" ;;
esac
# IS THIS HEAD LANDABLE AT ALL? `compare/main...SHA` answers in one read:
Expand Down Expand Up @@ -583,9 +617,9 @@ jobs:
# small; it does not close it, and a landing inside it is silent — `main`
# advances, the bump ships, and the row never leaves Backlog.
#
# Exit 1 "closes nothing" is an ORDINARY outcome, like the `main`-moved
# Exit 2 "closes nothing" is an ORDINARY outcome, like the `main`-moved
# refusal below: the next tick re-runs `ensure`, the key comes back, and it
# lands then. Only exit 2, "could not look", fails the run.
# lands then. Only exit 3, "could not look", fails the run.
- name: Does the body still close its row?
if: steps.checks.outputs.verdict == 'green'
id: closes
Expand All @@ -594,11 +628,14 @@ jobs:
PR_NUM: ${{ steps.target.outputs.num }}
run: |
set +e
mise run bot-issue closes "$PR_NUM"
batten pr closes "$PR_NUM"
verdict=$?
# The engine's table, as one step up: 2 is the refusal and 3 is the
# could-not-look that must fail the run rather than read as "closes
# nothing" and hold a landable head forever.
case "$verdict" in
0) echo "linked=true" >> "$GITHUB_OUTPUT" ;;
1) echo "linked=false" >> "$GITHUB_OUTPUT" ;;
2) echo "linked=false" >> "$GITHUB_OUTPUT" ;;
*) exit "$verdict" ;;
esac
- name: Fast-forward main to the tested SHA
Expand Down
Loading