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>
The cast-twin blocker (claude-bot): a declared permissions: block zeroes
unspecified scopes, so the label read and the bump fallback's gh pr
create could only 403 — every genuine ceremony would end red at the
label check. pull-requests: write added, consumers named. CONTRIBUTING
step 3 and the changelog entry now tell the shipped story: push-to-main
door, event.before interlock, self-re-arm, manual-path bump stays the
operator's.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rig twin of heavy-duty/box#96, from the release-ceremony retro: the
tag was a separate, manual, silent-when-forgotten step, and a forgotten
tag produces no red X — the worst failure shape. The ship decision
already lives in the release PR; merging it is "ship". After that,
tagging is transcription, and transcription belongs to machines.
release.yml now also fires on pull_request closed into main, gated on
merged AND the `release` label. The job asserts in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in THIS PR (base vs merge — the interlock that fails a
mislabeled ordinary PR); the changelog section for that version extracts
non-empty via the existing changelog_section from release-lib.sh; and no
tag or release exists yet. Then, in the same job, it API-creates the tag
at the merge commit and publishes the release with the extracted notes.
Same-job is load-bearing: a GITHUB_TOKEN-created tag does not fire the
tag-push trigger, so the publish must live next to the tag and the
fallback job cannot double-publish; the nothing-exists assert covers a
manual race. The tag-push path survives verbatim as the documented
manual fallback and backfill, and CONTRIBUTING's Releasing section now
reads merge-is-ship with the manual tag as fallback.
test/release.sh pins the merge path in the house grep-pin style: the
merged+labeled gate, the four asserts, the same-job tag+publish (awk
from release-on-merge: to EOF), the asserts-precede-the-tag ordering,
and the surviving tag-push trigger.
Fixes#47
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR-then-tag half of box#83's flow, rig's side. CHANGELOG.md starts
with an Unreleased section (feature PRs land their entry as part of the
PR — box's convention, now written into CONTRIBUTING alongside the
release ritual). On a tag push, release.yml asserts the bare tag equals
the tree's own VERSION — a mismatch fails loudly and creates nothing —
then creates the GitHub release with that version's changelog section as
the body, extracted by changelog_section in .github/scripts/release-lib.sh:
one function, sourced by the workflow and driven by test/release.sh
against fixtures and the shipped CHANGELOG.md itself. No assets — for a
pure-bash tree, the tag's source tarball IS the package.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
durable markers (Verdict: Approve / I agree with everything / leading ✅) —
the gate to needs-human can actually close. Formal verdicts remain the
contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
PR in addressing (agent owes re-request) instead of promoting unreviewed
code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
edits warn instead of wedging; the self-heal claim now matches reality
(dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
test/labels-reconcile.sh proves 14 fixture transitions — comment-only
agreement, stale approval, comment-without-verdict, human precedence and
human-block — wired into CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer rule: a comment-only review is a non-verdict the state machine
(and the board) cannot read. Verdict carries blockingness only; nits ride an
approval, blockers — including verdict-gating questions — are request-changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>