Commit graph

247 commits

Author SHA1 Message Date
Daniel Marin
c6a3c23869
Merge pull request #101 from dan-claude-bot/release/0.3.0
release: 0.3.0
2026-07-21 19:06:51 +01:00
dan-claude-bot
9b7cb57005 docs: record the 0.3.0 drill as waived, with what that leaves unproven 2026-07-21 17:43:28 +00:00
dan-claude-bot
c77bc1a9bf release: 0.3.0 2026-07-21 17:43:28 +00:00
Daniel Marin
6b012ab548
Merge pull request #104 from dan-claude-bot/refactor/drills-per-version
refactor: one drill record per version, in drills/
2026-07-21 18:41:34 +01:00
dan-claude-bot
186d976686 fix: the guard's comment misstated set -e, and the form now matches its twins 2026-07-21 17:12:54 +00:00
dan-claude-bot
dbe04c7c36 test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:41 +00:00
dan-claude-bot
11dfeb2ecd refactor: one drill record per version, in drills/
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>
2026-07-21 16:35:51 +00:00
Daniel Marin
750d93deda
Merge pull request #102 from dan-claude-bot/feat/drill-gate
feat: CI refuses a release PR with no drill record
2026-07-21 17:23:34 +01:00
dan-claude-bot
b234f48e68 test: pin that whitespace is not a drill record 2026-07-21 15:59:57 +00:00
dan-claude-bot
77a9a1ad76 feat: CI refuses a release PR with no drill record
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>
2026-07-21 15:24:42 +00:00
Daniel Marin
b8ce8e5e1c
Merge pull request #100 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
2026-07-21 15:44:03 +01:00
dan-claude-bot
d69ecd1104 docs(changelog): one line per entry, and a pass over the whole file 2026-07-21 13:56:39 +00:00
Daniel Marin
9c82d56230
Merge pull request #93 from claude-bot-andresmgsl/fix/sshd-privsep-verdict
fix: don't read a missing /run/sshd as a broken sshd config
2026-07-21 14:07:35 +01:00
Daniel Marin
9adf45a229
Merge pull request #99 from dan-claude-bot/fix/changelog-monotonic
fix: catch a deleted or duplicated release heading in CHANGELOG.md
2026-07-21 00:36:37 +01:00
dan-claude-bot
1db7779b03 test: terminate the ci.yml step block at the job boundary too
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>
2026-07-20 20:56:57 +00:00
dan-claude-bot
c199e8fd04 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
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>
2026-07-20 20:49:02 +00:00
Daniel Marin
27a5fa282e
Merge pull request #97 from dan-claude-bot/fix/labels-sweep-on-labeled
fix(labels): sweep on `labeled` so the handoff is immediate
2026-07-20 21:32:44 +01:00
dan-claude-bot
7ffc30bacc fix(changelog-monotonic): check uniqueness before anything base-side (#98)
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>
2026-07-20 20:22:43 +00:00
dan-claude-bot
0865ce6c02 fix(changelog): cite the sibling PRs, not the tracking issues
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>
2026-07-20 20:07:58 +00:00
dan-claude-bot
b2e7febf08 fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98)
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>
2026-07-20 20:00:48 +00:00
dan-claude-bot
23cca65855 fix(labels): sweep on labeled so the handoff is immediate
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>
2026-07-20 19:44:07 +00:00
Daniel Marin
ac1bb3bf76
Merge pull request #94 from dan-claude-bot/docs/contributing-blocker-axis
docs(contributing): document the blocker axis and merge-next ownership
2026-07-20 20:27:32 +01:00
Daniel Marin
9ba16946fd
Merge pull request #91 from codex-bot-andresmgsl/fix/issue-89-readme-channel
docs: align README quick start with main
2026-07-20 19:41:12 +01:00
dan-claude-bot
8b2ddbdd2d docs(contributing): document the blocker axis and merge-next ownership
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>
2026-07-20 18:40:12 +00:00
Daniel Marin
88d83a1aba
Merge pull request #90 from dan-claude-bot/fix/labels-two-axis
refactor(labels): split PR labels into state and blocker axes
2026-07-20 19:30:07 +01:00
dan-claude-bot
5eddf2e2ed fix(labels): a missing state label skips the edit, not the whole PR
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>
2026-07-20 18:02:03 +00:00
claude-bot-andresmgsl
2dc47112f2 fix: don't read a missing /run/sshd as a broken sshd config
`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>
2026-07-20 17:58:10 +00:00
codex-bot-andresmgsl
491055c4cf docs: point stable installs at matching README 2026-07-20 17:50:18 +00:00
dan-claude-bot
b07e734fc0 fix(labels): survive a repo that lacks the new blocker labels
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>
2026-07-20 17:49:44 +00:00
dan-claude-bot
4f40cfabf2 fix(labels): distinguish an unreadable check rollup from no checks
When `gh pr view` failed, the fallback left the `statusCheckRollup` key
absent, and `(.statusCheckRollup // [])` collapsed that into the same
NONE as a PR with genuinely no checks. NONE blocks nothing, so an API
hiccup presented the PR as mergeable by a human — an unknown certified
as green, the shape #87 exists to stop, in the one place it never looked.

checks_state now returns UNREADABLE for an absent key versus NONE for a
present-but-empty array, and the sweep leaves an UNREADABLE PR alone
rather than recomputing on facts it did not read. Not a blocker on
purpose: blocking would flap the board on one bad call.

Fixtures 64 -> 66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:38:32 +00:00
dan-claude-bot
4ea3579c2e docs(changelog): record that CI never ran the label fixtures
The CI wiring is independently valuable from the two-axis refactor and
was missing its own entry: rig's label state machine gates every PR and
its fixtures had never executed in CI, including for #88 which merged
today reporting 51 passing fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:34:21 +00:00
codex-bot-andresmgsl
b88495105d test: tighten README channel contract 2026-07-20 17:32:43 +00:00
dan-claude-bot
3f6f05766f refactor(labels): split PR labels into state and blocker axes
Retires `state:needs-rebase` in favour of two independent axes: `state:*`
(whose ball, exactly one) and `blocker:*` (what is in the way, additive).
One rule joins them: `state:needs-human` requires zero blockers.

The single-label design projected independent facts — mergeability, check
status, review round — onto one totally-ordered value, so one always won
and the rest vanished. Every precedence bug this machine has had lived on
that ordering. Blockers are a set, so there is no precedence between them
to get wrong.

`state:bots-reviewing` tightens to mean strictly "a request is live"; a
ready PR nobody was asked to review is now `state:addressing` +
`blocker:unrequested`. The reconciler strips the retired
`state:needs-rebase` on sight via a RETIRED array.

Also wires test/labels-reconcile.sh into CI, where it had never run.

Fixtures 51 -> 64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:30:53 +00:00
codex-bot-andresmgsl
32e17246a2 docs: align README quick start with main 2026-07-20 17:30:11 +00:00
Daniel Marin
616ada76b9
Merge pull request #88 from dan-claude-bot/fix/labels-mergeability-aware
fix(labels): `state:needs-human` means a human could merge it right now
2026-07-20 18:02:57 +01:00
dan-claude-bot
108a944e1c chore(labels): converge reconciler and fixtures on the canonical text
box, rig and cast carry the same reconciler, and the three had drifted to
functionally identical but textually different versions of checks_state
after the same fix was reached independently in each repo. Comment prose
and one fixture helper name differed, so the files were no longer
byte-identical and the convergence check had nothing to compare.

Standardised on the cast wording for checks_state and on the canonical
test/labels-reconcile.sh, which carries the same 51 fixtures under
overlap_() where this repo had named the helper drained_(). No behaviour
change: the diff to the reconciler is comments only, and the suite is
unchanged at 51 passed, 0 failed.

Only this repo's own scope:* rows are preserved as local content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:44:19 +00:00
dan-claude-bot
57e37363f4 fix(labels): order check runs by one consistent quantity — when they began
The dating expression took the newest stamp each run carries. That reads
`completedAt` for a finished run and `startedAt` for a live one, so the
comparison comes down to "when this one ended" against "when that one
began" — which is not an ordering on runs at all.

A run cancelled by the concurrency group does not stop the instant its
replacement starts: the runner has to receive the signal and wind down. So
`predecessor.completedAt > successor.startedAt` is the ordinary case, not a
corner. On the box#137 tip that motivated the supersede rule the window was
13s wide — the superseding run started 15:19:38, the run it cancelled did
not finish until 15:19:51 — and for that whole window the dying predecessor
out-dated its own live replacement, so `last` discarded the replacement and
judged the corpse.

Both round-3 failure modes came back inside that window, narrowed rather
than closed: a draining CANCELLED predecessor reported FAILURE and sent the
agent to fix nothing, and a draining SUCCESS predecessor reported SUCCESS —
mergeable, all bots approve, state:needs-human — over a tree whose merge
button branch protection had already disabled. #136 again, one field over.

Dated by `first` of the preference-ordered stamps rather than `max` of
them: start time if the run recorded one, falling back only if it did not.
The sentinel filtering is unchanged, and finished runs still date by
completion when that is all they carry, so the supersede rule keeps the
case it exists for.

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
No existing fixture could express it — `run_()` carries no startedAt, so
every supersede fixture spaced the predecessor's completion safely before
the successor's start, the same blind spot as round 3 one field over. New
`drained_()` helper pins both directions; fixtures 48 -> 51 (with the
reverse-direction in-flight fixture ported from cast#128).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:53 +00:00
dan-claude-bot
800571fdfb fix(labels): date a check run by when it started, not by a zero completion
The supersede collapse added in the previous commit dated each run by
`.completedAt // .startedAt // .createdAt`. A run still in flight has no
completion, but `gh` does not omit the field: its Go struct marshals the
zero time as the string "0001-01-01T00:00:00Z", and jq's `//` falls
through null and false only. The sentinel was therefore taken as the sort
key, and it sorts before every real timestamp — so the LIVE re-run became
the oldest entry in its context, `last` discarded it, and the run it
superseded was judged instead.

That restored #136 through the fix for it: a green context with a
replacement mid-flight reported SUCCESS, so a PR read mergeable, green,
all bots approve — state:needs-human — while branch protection had the
merge button disabled. It also narrowed rather than removed the flap the
supersede rule exists to prevent: between "run A cancelled by the
concurrency group" and "run B finishes", the PR reported FAILURE and the
agent was sent to fix something that was not broken.

Runs are now dated by the newest timestamp they actually carry, with both
spellings of absent discarded (null, and the zero sentinel). Entries that
carry no usable timestamp sort LAST rather than first: something we cannot
date is most likely the thing just created, and treating it as newest
keeps an undateable in-flight run from being discarded in favour of a
stale success. Every ambiguity resolves toward "not settled".

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
The fixtures could not have caught it: `run_()` always emits a real
completedAt, so every supersede fixture was a race between two finished
runs, and the bug lived in the one shape the helper could not express.
New `inflight_()` helper covers it; fixtures 44 -> 48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:18:50 +00:00
dan-claude-bot
a0951eacf8 fix(labels): unrecognised check outcomes block, and STALE outranks MISSING
Round 2 review found two ways the "a human could merge this right now"
invariant still leaked, both of which let state:needs-human land on a PR
the button would refuse.

The check-rollup classifier enumerated the outcomes that block and
defaulted the rest to SUCCESS, so ERROR, CANCELLED and STALE fell through
into green. Inverted to an allow-list of the outcomes that DON'T block
(SUCCESS, NEUTRAL, SKIPPED, plus the pending set); everything else,
including an outcome neither enum has today, blocks. The rollup mixes
CheckRun.conclusion with StatusContext.state and an outcome the list
forgets is one we cannot certify as mergeable — a false FAILURE parks the
PR on the agent, a false SUCCESS invites a bad merge. The classifier also
moved out of main() into checks_state(), which is why no fixture caught
this: it was inline in the fetch loop and the jq itself was untestable.

Once CANCELLED blocks, superseded runs must be dropped first — a re-run
does not evict the run it replaced, and judging every entry would strand
every re-run PR in needs-rebase. Each context now collapses to its newest
entry, keyed on workflow + job name because a bare job name is only
unique within its workflow.

decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read: a round that was
both unfinished and staled came out needs-human with nothing bound to the
head. The whole round is now collected before any precedence is applied,
STALE ahead of MISSING. The MISSING-yields-to-an-explicit-human-request
rule is untouched.

Fixtures 29 -> 44, pinning the whole check-outcome enum, the supersede
rule in both orders, and the mixed round at both ends of BOTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:05:39 +00:00
dan-claude-bot
ef798f302c fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change, and remains so after -- only the scope:* taxonomy
differs, correctly.

decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.

This repo paid for it directly. During the ten-PR batch merged today, every
merge re-conflicted the PRs below it through CHANGELOG.md, and each kept its
state:needs-human label throughout -- inviting merges that could not happen.
It was caught only by opening them one at a time, which is the work the label
exists to save.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.

Also adds merge-next: queue order is intent, so the reconciler never sets it,
only CLEARS it once the PR stops being mergeable-by-a-human.

Fixtures 19 -> 29, including that UNKNOWN does not trigger needs-rebase and a
draft outranks a conflict. No live dry-run evidence here -- this repo has no
open PRs right now -- so the fixtures and box's live dry-run are the proof.

Closes #87

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:26:54 +00:00
Daniel Marin
762cc9653e
Merge pull request #84 from dan-claude-bot/feat/manifest-provenance
feat: /etc/rig/manifest — which rig converged this machine, and when
2026-07-20 14:06:01 +01:00
dan-claude-bot
d2e03ce993 fix: read a manifest's final line when the file has no trailing newline
manifest_value, manifest_has and manifest_foreign each read the file with a
bare `while read`, which stops at EOF without ever handing over a populated
partial line. An unterminated final line therefore read as ABSENT — and
absent is exactly the input both convergence rules key off, so the file's
last line was the one least able to survive the miss.

Three failures, in descending order of how much they cost:

- A file truncated mid-write ends AT bootstrapped_at, so the unreadable line
  is the birth stamp itself. Rule 1 saw no at-stamp and regenerated the pair
  as now() — overwriting the one field that can never be reconstructed. A
  fixture born 2020-01-01 came back stamped with the current clock.
- A whole file whose last line is converged_at read as empty, so Rule 2's
  one-time repair re-fired on EVERY run: the render stopped being a function
  of (existing file, running version) and the clock reached the file after
  all. This is the crux property of the feature, broken by a missing byte.
- manifest_foreign dropped an unterminated foreign line entirely, so the
  writer ate a later command's provenance — the exact preservation contract
  the function exists to keep.

The idiom is the repo's own: lib/users-config.sh:49 reads
`|| [ -n "$line" ]` for the same reason.

Reading the line correctly also repairs the file, since the rewritten copy
is newline-terminated — asserted as "the source plus the newline it was
missing, and nothing else", because a plain ends-in-newline check stays
green on an implementation that drops the final line.

7 tests, each observed RED against the unfixed reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:48:39 +00:00
dan-claude-bot
5c40e906a1 feat: /etc/rig/manifest — which rig converged this machine, and when
A rig-managed machine recorded nothing about its own provenance. The entire
durable output of a bootstrap run was one line in /etc/rig/role, and that line
says what the box IS, never what built it. VERSION was read in exactly one
place (bin/rig:9, for --version) and reports the currently INSTALLED tree, not
the one that ran; there was no timestamp anywhere in the codebase.

bootstrap now stamps a second file beside the marker: schema=1, a birth pair
(bootstrapped_by/_at, pinned forever) and a latest pair (converged_by/_at).
key=value, one per line, 0644 — the one file that must stay readable on the
most broken machine in the fleet, where there is no YAML parser and no jq.
`rig manifest [<key>]` reads it back.

Only DECIDED facts go in, which is what keeps bootstrap.sh:3's convergence
contract intact: bootstrapped_* is first-write-wins, and converged_* updates
only when the version actually differs — it is the time the converging version
last changed, not the time of the last run. The renderer is pure, so a re-run
by the same rig is byte-identical no matter where the clock is, and the
cmp-guard stays silent. OBSERVED facts (cores, RAM, disk, kernel) stay out:
they go stale on their own and belong to `rig platform` (#64).

/etc/rig/role is untouched.

Closes #61

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:48:39 +00:00
Daniel Marin
d6e8cdda06
Merge pull request #74 from dan-claude-bot/feat/platform
feat: rig platform — what is this machine, computed not stored
2026-07-20 13:46:00 +01:00
dan-claude-bot
e11247b467 docs: the provenance comment matches #61's pinned contract, not the old story
Review found `commands/platform.sh` still telling future readers that a
missing converged_* pair is a legitimate state on a freshly bootstrapped box.
It is not, and the rest of this PR already says so: #61's writer records both
pairs equally at bootstrap, so no writer produces that manifest -- its absence
means partial or hand-edited. README and the fixtures were corrected in an
earlier commit of this series; the comment beside the code was not, which
left the one artifact a future change actually reads while editing this block
still arguing the retired position.

Also corrects two examples that went stale under this PR rather than in it.
#76 and #77 merged while this was in review, so the marker example in the
same file -- and the sample `rig platform` output in README -- still showed
`role=dev class=human host=yes join=authkey`, a spelling bootstrap no longer
writes. Now `role=dev-server root-door=closed host=yes join=authkey`, with a
note that the renderer prints whatever fields a marker carries, so a
pre-rename box still shows its own class= line unchanged.

Comments and docs only; no behaviour change. Same defect class both times --
prose that contradicts the contract the tests pin -- which is why the sweep
went wider than the one site reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:41:30 +00:00
Daniel Marin
92cdb327b9
Merge pull request #81 from dan-claude-bot/test/at-risk-gate-pin
test: pin the at-risk gate's floor at one operator
2026-07-20 13:36:55 +01:00
Daniel Marin
b5e01296ee
Merge pull request #73 from dan-claude-bot/fix/users-apply-empty-file
fix: gate 'users apply' on an empty file that would revoke everyone
2026-07-20 13:36:35 +01:00
dan-claude-bot
d3577b9954 docs: a fresh bootstrap writes both pairs equal, not a missing converged_*
The previous commit's test and README described a freshly bootstrapped
machine as lacking converged_* and rendering 'not recorded'. That pins the
wrong contract: #61 states plainly that "on a fresh machine both pairs are
written with equal values", and rule 2 only suppresses converged_* churn on
a later same-version re-run.

So two identical lines mean bootstrapped-and-never-re-converged, and a
manifest missing the pair is partial or hand-edited rather than fresh --
no writer produces it. The graceful degradation stays for that case, still
without backfilling from birth, which would invent a convergence that never
happened.

The reader itself was already correct; this fixes the fixture and the prose
that claimed otherwise.

Found in review of #74.

Refs #64
2026-07-20 12:29:02 +00:00
dan-claude-bot
91a8905a71 fix: platform reads #61's actual manifest schema, not invented keys
The reader asked for `version` and `bootstrapped`. #61 specifies `schema`,
`bootstrapped_by`/`bootstrapped_at` and `converged_by`/`converged_at` — so
no writer would ever have produced the keys being read, and the day #61
landed this command would have rendered 'unknown' with the timestamp
omitted, forever, with nothing to say why.

Keyed to #61's spelling, with fixtures carrying that schema verbatim so
the contract is pinned rather than assumed. Birth and latest are reported
separately and neither is inferred from the other: under #61 rule 2
converged_* is written only when the version differs, so its absence is a
legitimate state on a freshly bootstrapped box, printed 'not recorded'
rather than backfilled from birth. A manifest whose schema this rig does
not know is named as such instead of being half-read in silence.

Found in review of #74.

Refs #64
2026-07-20 12:28:51 +00:00
dan-claude-bot
3cfbb8921c fix: platform reads a manifest whose last line lacks a newline
`while IFS='=' read -r k v` drops an unterminated final line: read returns
1 at EOF even having filled k and v. A manifest ending `bootstrapped=...Z`
with no trailing \n rendered `RIG 1.2.3` with the timestamp silently gone
— the version read fine, so nothing looked wrong.

Guarded with `|| [ -n "$k" ]`, the same shape parse_users_file already
uses (lib/users-config.sh:47). #61's writer should not have to know
whether this reader tolerates a missing newline.

Also make human_b fall back like human_kb. With numfmt absent, memory
degraded to a raw number while disk printed 'unknown' beside it, from data
already in hand.

Both found in review of #74.

Refs #64
2026-07-20 12:27:39 +00:00