The runner leg reads the newest run ID before dispatching and only
judges a run with a different ID — workflow_dispatch takes seconds to
materialize a run, and the previous run's 'completed' was one poll away
from being read as ours. --help's sed range stops where the header does.
(ceremony flow: issue #105)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drill/README.md is the repeatable procedure #107's second checkbox asks
for: prerequisites (the throwaway machine, the tagged key, the fork's
drill workflow, the pins), the invocation, what each leg asserts, where
the record lands. drills/README.md's harness disclaimer flips to point
at the instrument, its legs list and example record match what
drill.sh actually runs and emits, and CONTRIBUTING's drill sentence
names the script. Changelog entry under Unreleased.
(ceremony flow: issue #105)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/drill.sh awk-extracts the harness's decision functions (the
release.sh pattern) and drives them against fixtures: the ref refusal
names both refs, a loud skip never classifies as a pass, the idempotence
verdict is a real diff that goes non-empty when convergence is broken —
demonstrated mechanically on every CI run — and the record emitter
cannot produce a clean-sweep reading over a skipped leg. CI runs it in
the check job.
The tests caught three real harness bugs before any reviewer could:
printf eating a '- '-leading format as options (a silently empty Failed
section — the exact lie the record exists to prevent), tree_of trusting
GNU readlink -f's exit 0 on a dangling final component, and the arg
refusals sitting behind the root check in violation of the repo's own
validated-before-root doctrine.
(ceremony flow: issue #105)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Box's shape, rig's legs: the reporting verbs and set -u-only discipline
(a failing check is data, not a crash), a fatal INSTALLED_FROM assertion
on BOTH pinned refs before anything is believed, convergence asserted on
effective state, idempotence decided by a mechanical capture-and-diff,
db driven through test/db-integration.sh with its loud-skip contract
kept, the runner lifecycle against a fork, a pinned coolify install, and
a record emitter that writes drills/<version>.md in the schema the
drill-recorded gate reads — skips counted and named, never folded into
passes.
The --host yes leg stops at 'the pinned box installed and its host stack
stands', in as many words in the output: the isolation boundary is box's
drill's assertion, joined to this record by the shared run ID.
(ceremony flow: issue #105)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ceremony conversion (#112) extracted the panel= line verbatim from the
pre-ceremony labels-reconcile.sh BOTS array, which predates kimi-bot joining
the family bench. A name missing from that line is a reviewer the machinery
never waits for: the handoff would report a full panel while one verdict
short — the exact defect cast@2612967 fixed after cast#143 shipped it.
labels.conf is the source of truth the reconciler reads; CONTRIBUTING
mirrors it for humans requesting reviewers by hand. Both move together.
Closes#120
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root LABELS.md was rig's own copy of the family taxonomy doc; the
vendored .ceremony/LABELS.md is the driftless replacement, so the copy
goes. drills/README.md's gate pointer follows the guard upstream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
changelog-armed returns (rig#44's revert, now version-keyed upstream);
docs-sync guards the doctrine mirror the next commit vendors.
test/release.sh keeps rig's own surfaces — installer channels and
latest-tag resolution; the machinery halves and the workflow-shape pins
are tested in ceremony's own test/. test/labels-reconcile.sh goes whole:
it drove the deleted reconciler.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The conf format takes no comment lines: every non-blank, non-panel line
must parse as name|color|description, so the file is data only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fourth agent tenant, through the one parameterized mechanism
(lib/tenant-config.sh + bootstrap-tenant.sh) — never a fifth
hand-maintained copy. The official installer (code.kimi.com/install.sh)
is uv-managed and lands kimi in ~/.local/bin, run as the tenant user
(grok's lesson: a root-owned install under a 0700 home is a CLI that
exists and cannot run); no node, uv brings its own Python. The context
file lands at ~/.kimi/AGENTS.md — the <dotdir>/AGENTS.md convention —
with an honest note that kimi documents only project-level AGENTS.md
today.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drill records move from sections inside drill/RUNS.md to one file per
version at drills/<version>.md.
The old guard had to parse headings: an em-dash prefix match, an optional
" — DATE" tail, a whole-version comparison so 0.3.0-rc1 could not satisfy
0.3.0, and a separate non-blank-body rule. All of it existed only because
records shared one file, and both sibling repos shipped a defect out of
that complexity in review — a `sed '/./,$!d'` extractor where `.` matches
a space (box#149, cast#138), and heading-grammar drift. One file per
version makes nearly all of it unrepresentable: 0.3.0.md and 0.3.0-rc1.md
are different files, and the filesystem does the whole-version comparison.
The awk drill_section() machinery is gone. What survives is the one rule
splitting the files does not make unrepresentable: a file of only
whitespace is not a record.
Plain drills/, NOT .drills/ — a dot-directory is invisible to globs
without dotglob, the cause of #70 here and box#116/box#118.
drill/RUNS.md is deleted; it was created in this same unmerged PR, held no
real records, and its useful reasoning moves to drills/README.md. (box
keeps ITS drill/RUNS.md — that one is a genuine harness log.)
Also corrects the ordering framing in CONTRIBUTING and the new README: the
three repos' drills are INDEPENDENT, run in any order. What makes that safe
is that each pins the same fixed set of candidate refs, so box and rig
measure the same pair — that, not sequencing, is what dissolves the mutual
recursion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CONTRIBUTING has always required a real-hardware drill on a release, and
nothing enforced it — so no release in this family has ever carried one.
Every other ceremony step is checked by a script; the one that costs an
afternoon was checked by a reviewer remembering. A reviewer bot finally
blocked on it.
- drill/RUNS.md: rig's own run log, starting EMPTY of records. rig has no
drill harness of its own yet; the harness lives in box's drill/ and this
file is the record, not the instrument.
- .github/scripts/drill-recorded.sh: a -dev tree asserts nothing; a bare
VERSION requires a non-empty '## Release drill — X.Y.Z' section, version
matched WHOLE so an -rc1 record is not evidence for the final.
- Per-repo on purpose. A cross-repo lookup into box fails on a token, a fork
checkout or a network blip, and all of those degrade to 'pass' on precisely
the tree that ships — the UNREADABLE-vs-NONE shape #90 fixed.
- It asks for a RECORD, not a RESULT, so a maintainer waiver stays possible
but has to be written down under that version.
- Fixtures carry their own VERSION and RUNS.md (heavy-duty/box#146: fixtures
reading the repo's real VERSION exercised only the -dev branch and went red
first while cutting a release).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monotonic step is the LAST step of its job, so stopping only at the next
`- name:` ran the block into the job below and swallowed that job level `if:`.
Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was
meant to fix, moved from "any step in the file" to "this step plus the head of
the next job".
Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.
Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the
identical awk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.
That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned, plus a negative that the two do not collapse.
The "an untouched branch passes" case turned out to be this exact shape — its
fixture never commits on 'work', so it was asserting containment on a
self-comparison. Its assertion moved to uniqueness's count, and a companion
case with a real base now carries the containment wording it used to claim.
Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide grep it forbade any FUTURE step from being pull_request-gated and
would have failed citing #98 when one legitimately was. A companion check
keeps the awk extractor from matching nothing and going tautological.
Ported from heavy-duty/box#144 (box@94d830c).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.
The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.
That inverted the two halves. Deletion needs a diff to see; duplication is the
one changelog_section() actually mis-renders, stopping at the second heading
and truncating the release's real body. The half with the live extraction bug
behind it had the most ways to silently not run.
Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main. The script is also now
100755, matching cast's copy of the same file.
Regression cases pin the ORDER, not the exit code: verified they go red
against the pre-fix script (7 failures) and green against the fixed one.
Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which
rig's copy of this script was ported from. Found by claude-bot-andresmgsl and
codex-bot-andresmgsl reviewing #99.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Landed in all three repos together" line pointed at the sibling
tracking issues rather than the sibling PRs. The entry already opens with
its own issue ref, so a reader following "landed together" was sent to more
issues and never reached the actual sibling changes.
All three PRs carried it identically because the three entries came from one
generator that took sibling references from its issue-number map, and
expanded them into the sentence without re-wrapping — which is also why the
line ran to 108 columns in a file that wraps at 83.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The arming rule (#66) guards ONE heading — does the top section agree with
VERSION? — and is silent about the rest of the file. The failure that lives
there is an author adding an entry under `## Unreleased` who replaces the
shipped heading below it instead of inserting above it. git merges the
one-line edit cleanly, `changelog_armed()` stays green (correctly: the top
section is still right), and the shipped release loses its section entirely.
It surfaces a whole release later, when release.yml refuses to publish a
section `changelog_section()` can no longer find by heading.
"A heading disappeared" is a property of a DIFF, not of a tree, so this is
its own script rather than a clause in the arming check — which is also
driven from test/release.sh against constructed non-git VERSION + CHANGELOG
pairs that could not express it. The rule needs no tuning: release headings
are append-only, so SUPERSET is exact, and the ceremony's stamp passes by
construction because `Unreleased` fails the version shape.
Ported from heavy-duty/box#122, with box's second half intact: containment
cannot catch a DUPLICATED heading, since the duplicate is head-side surplus
and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted
alongside it. rig's symptom differs from box's and the comments say so:
box's extractor re-arms on every `## ` line and ABSORBS what sits between
the copies, while rig's `changelog_section()` has `if (found) exit` and
TRUNCATES at the second copy, dropping the release's real body.
Wired on pull requests only (on a push to main the merge base is HEAD, so
the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0
so a checkout that cannot reach the base ref fails rather than skipping
quietly forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A review landing was never a trigger for the labels workflow, so the exact
moment `state:needs-human` became true — the third bot approving — fired
nothing, and the label waited on the `*/15` cron. That cron does not run at
its declared rate: measured across box, rig and cast over a two-hour window
on 2026-07-20, one scheduled run each against the eight `*/15` implies.
The obvious fix does not work. There is no `pull_request_review_target`, and
on fork PRs — all of them here — `pull_request_review` runs with a read-only
token and cannot label anything.
So the handoff wakes the sweep itself:
- `pull_request_target` also fires on `labeled`/`unlabeled`
- the author sets `state:needs-human` at handoff, as the third act after the
round summary and the review request
The author's own label write fires the sweep that validates it — an
optimistic write, not a transfer of ownership. The reconciler confirms or
corrects it seconds later, and the cron falls back to a last resort. It
cannot loop: the reconciler writes with GITHUB_TOKEN, which does not create
workflow runs; agent writes use a PAT, which does.
`labels-reconcile.sh` is unchanged — it already recomputes every open PR
from scratch on every run, which is what makes the optimistic write safe.
The `scope` job is skipped on label events, where no path can have changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The who-sets-what table is the day-to-day answer to "can I move this by
hand", and it never mentioned blocker:* -- a whole machine-owned family
added when state:needs-rebase was retired. merge-next was missing too, and
that is the one label whose ownership actually needs saying, because it is
the only one in the machine's vocabulary the machine deliberately does not
set.
Step 6 also read as though requesting the maintainer is sufficient to flip
state:needs-human. It is not: needs-human requires zero blockers, so the
request does nothing on a conflicted or red PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 2 review (claude-bot, codex-bot — both raised this, independently).
The `return` added in round 1 aborted all of reconcile_pr, not just the
label edit. Everything below it is independent of the state:* taxonomy:
`merge-next` clearing and the stale sweep both stopped running. So a
cold-start repo left `merge-next` claiming "merge this one next" on a PR
the board had moved to the agent — the original false-invitation bug,
reintroduced inside the very fix meant to survive a cold start.
It was also a regression against main, not just a missed improvement:
the old code failed the `gh issue edit`, logged, and fell through to both
blocks. Round 1 turned a per-edit failure into a per-PR abort.
Now a `skip_edit` flag skips only the edit and control reaches the rest.
Also from review: drop the dead `"$desired"` term from the filter loop
(it was appended and then unconditionally continued past), and turn
`[ -n "$missing" ] && log` into a proper `elif` rather than an
&&-as-statement under `set -e`.
Adds the first four fixtures that exercise reconcile_pr itself, stubbing
run/gh to probe a cold-start repo against a bootstrapped one. Everything
before this tested pure functions, which is exactly why a per-PR return
got through: nothing could see it.
Fixtures 68 -> 72.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`sshd -t` folds two questions into one exit code — is the merged config
parseable, and is the privilege-separation directory there. Both call
sites ran it as `sshd -t 2>/dev/null` and read any non-zero exit as the
first question's answer, discarding the line that named the second.
Bootstrap aborted with "sshd rejects the merged config", a verdict sshd
never reached, and sent the operator to audit /etc/ssh files that were
never broken. /run is a tmpfs and /run/sshd is ssh.service's
RuntimeDirectory, so it is legitimately absent under socket activation
on a box whose SSH door is serving connections normally.
Classification is now a pure, sourceable sshd_privsep_gap: the status is
the verdict, the text only classifies a failure, so a passing sshd -t is
never diverted. sshd_config_ok repairs the gap with an idempotent
install -d and retests once. A genuine parse refusal still refuses and
the rollback is untouched.
Refusals now carry sshd's own stderr. users-close-root had the identical
three lines and now reaches the shared judgement through lib/sshd.sh
instead of keeping a second copy of it.
Fixes#92
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round 1 review (claude-bot, codex-bot — both raised 1 and 2).
1. `gh issue edit --add-label` rejects the WHOLE call on one unknown
label name, and the blocker:* labels are created only by the
dispatch-only bootstrap. So the first sweep after this lands would
have converged NOTHING on exactly the PRs this change exists to heal,
surfacing only as a WARNING in a cron log. Batching state and blockers
into one edit for anti-flicker is what widened that blast radius.
Every label about to be ADDED is now filtered against the repo's real
label set, read once per sweep; removals need no filter because they
are built from has_label. An unreadable label set does not filter, so
a failed read cannot silently strip the board.
2. blocker:unrequested fired only on MISSING, so a round whose approvals
all staled behind a push — with nothing re-requested — carried no
blocker at all, though the agent owes exactly the same ask. Now
MISSING or STALE: both mean this head has no verdict from that
reviewer.
3. LABELS.md: restore the substantive "Leaves when" text for
state:addressing, and widen the blocker:unrequested row to name both
shapes now that (2) changes what the label means.
Fixtures 66 -> 68.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>