Compare commits

..

No commits in common. "84bb1a424dfe467358e99d4c6ab4e76ac89cc328" and "d9c5b92dd1616fc66bf0c6c2dbc14fefc6b0001d" have entirely different histories.

21 changed files with 280 additions and 682 deletions

View file

@ -16,5 +16,5 @@ to the issue for triage to amend, not silently unshipped. -->
## Round log
<!-- The engine appends each whole-round reply here, newest last: what
changed and what was verified. Builders write the reply, not this section. -->
<!-- Append each round's summary here: what changed, what was verified.
Rounds are answered whole — one reply covering every point. -->

View file

@ -10,14 +10,11 @@ name: labels
# and reconcile checks out the BASE branch only. Keep it that way.
#
# There is no pull_request_review_target, so a review landing cannot wake this
# workflow directly — which is why the caller's cron is load-bearing, not a
# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The
# cron is the sweep's only discovery path for every transition no subscribed
# event carries: a verdict landing, blocker:ci-red set/cleared, a
# blocker:conflict when another PR merges under this one, and the time-based
# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct —
# the handoff sets state:needs-human and the caller's `labeled` event confirms
# or corrects that optimistic write within seconds.
# workflow directly — and the */15 cron is advisory: GitHub deprioritises
# short intervals hard enough that a quiet repo goes hours between ticks. The
# handoff wakes the sweep itself: the author sets state:needs-human, and the
# caller's `labeled` event confirms or corrects that optimistic write within
# seconds. The cron stays as the last resort for a forgotten handoff.
#
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
@ -28,7 +25,7 @@ on:
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.4.0"
CEREMONY_SELF_REF: "0.3.0"
jobs:
scope:

View file

@ -129,7 +129,7 @@ env:
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:`
# references.
CEREMONY_SELF_REF: "0.4.0"
CEREMONY_SELF_REF: "0.3.0"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:

View file

@ -5,40 +5,11 @@ name: labels
# unpinned — correct only for the repo that IS the source). Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's ONLY
# wake for four transition classes — a review verdict landing (there is no
# pull_request_review trigger here), blocker:ci-red set or cleared (no
# check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR
# merges under this one, and the time-based stale / 48h claim-reclaim. The
# events below carry the rest in seconds. Hourly trades ≤1h of latency on
# those four while cutting nominal scheduled sweeps from four an hour to one
# at GitHub's 1-minute billing floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
workflow_dispatch:
# Narrowed (#199) to the actions that carry a queue-state change the hourly
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
# unassigned, which feed validation and the 48h claim clock (caught within one
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
# `reopened` (a closed issue re-entering the queue wearing labels derived when
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
# on the schedule when an event could have carried it", so edited/reopened stay
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
# this does not touch the handoff.
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
issues:
types: [opened, closed, edited, reopened]
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
pull_request_target:
# Every PR arrives from a fork, so these carry the head/draft/review facts
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
# the author's optimistic state:needs-human write, confirmed or corrected
# here in seconds (#11); synchronize re-derives on every push;
# review_requested/review_request_removed wake the sweep that clears (or
# restores) blocker:unrequested — without them the one event that makes
# the label false could not clear it, and a quiet repo wore the red flag

View file

@ -11,27 +11,6 @@ triage bug, and the move is to say so on the issue, not to guess.
- Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work.
- **Your own red head outranks a new claim.** A failing check at the head
of a PR you authored is picked up **before claiming another issue**
repairing your own red PR comes ahead of new work, which is why the
engine's duty order evaluates ci-red between resume and build (crew#17:
ceremony#163 sat with full-panel approvals at its head, mergeable, and
stranded on an HTTP 429 in a job that never ran the PR's code, because no
wake covered a red head that owed no round and had no conflict). Red and
green here are the ruled terms of the review round below: a cancelled or
stale check is not a green head; a skipped or neutral one is. The
recovery path (crew#17): inspect the check at the head and record the
failing check and its failure class; rerun a clearly retryable
infrastructure failure without changing code; when the failure belongs to
the branch, return to the normal fix-round and worklog discipline; leave
visible evidence when a rerun cannot be started or the cause is
uncertain; never repeatedly rerun a deterministic branch failure without
a corrective commit; and proceed to handoff once the check is green and
current-head approvals stand. A PR of yours with a red head is **not
parked** — the next move is yours, whatever the round's verdict state
says (shape 2 below carves this out explicitly). How the engine detects a red
head — its ledger, its quiet rules, the rollup's node shapes — is crew's
to describe, not this file's.
- **One build at a time.** You hold at most one issue on which you are
writing or revising a deliverable — finish or release that work before
starting new work. The rule counts build work in flight, not claims: a
@ -41,16 +20,9 @@ triage bug, and the move is to say so on the issue, not to guess.
its `Blocked:` line stops the remaining work;
2. the deliverable is in a review round where every outstanding verdict
belongs to someone else — either the round is awaiting its first
verdicts, or it was answered whole and the owed re-requests posted —
by head, not by verdict: every panelist after a push, the
non-approvers alone at an unchanged head (the review round, steps
12). This is the *live* round; shape 4 is
the *passed* one — they are sequential and do not overlap. A red
check at the current head takes the deliverable **out of this
shape**: mid-round CI going red is exactly the state that reads as
"waiting on the panel" and is not — the next move is yours (the
red-head rule above), and reading it as parked is what strands the
PR;
verdicts, or it was answered whole and the non-approvers re-requested
(the review round, steps 12). This is the *live* round; shape 4 is
the *passed* one — they are sequential and do not overlap;
3. every remaining acceptance criterion is operator-owned, stated as such
by triage on the issue;
4. the deliverable is **handed off** — the round passed, no `blocker:*`
@ -81,8 +53,7 @@ triage bug, and the move is to say so on the issue, not to guess.
that, or, if the events genuinely do not resolve it, say so on the
issue and pick the next `ready` issue rather than idling on this one.
Not parked — these are what the rule defends against: waiting on
yourself, waiting on CI (a red head is your own work, above; a pending
one resolves without you), or waiting for a good moment. An issue you have
yourself, waiting on CI, or waiting for a good moment. An issue you have
simply stopped working on is not parked either — that is abandonment,
and its move is unchanged: unassign and restore `ready` (Claiming,
below).
@ -107,12 +78,12 @@ triage bug, and the move is to say so on the issue, not to guess.
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
already guard — a parked claim nobody can name is an abandoned one.
Shape 4 alone is exempt from the separate comment: the factual handoff
comment plus the `state:needs-human` write *is* its declaration — both
Shape 4 alone is exempt from the separate comment: the handoff round
summary plus the `state:needs-human` write *is* its declaration — both
halves are already there, what the claim waits on (the merge) and who
owns the next move (the human), and both are visible to any scan as a
`labeled` event with the comment beside it. No second comment is owed on
the issue. Every other shape still declares as above.
`labeled` event with the summary beside it. No second comment is owed
on the issue. Every other shape still declares as above.
Declared once, the declaration **stands** until the park's facts change:
a resumption that finds nothing changed posts nothing — the standing
declaration is the record, and silence while parked is compliant, not
@ -208,9 +179,9 @@ triage bug, and the move is to say so on the issue, not to guess.
## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo:
repo-specific facts such as the panel roster live in that repo's own
CONTRIBUTING; the shared flow lives here and is not restated there.)
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel**. The panel is the roster
of the repo the **PR** is in, minus you — never the roster of the repo the
@ -223,56 +194,9 @@ CONTRIBUTING; the shared flow lives here and is not restated there.)
does not become required. On rig#112 this distinction mattered: requesting
codex and grok was correct for rig's panel even though ceremony's bench was
larger, and the doctrine had not said which roster governed.
**A review request requires a green check at the head.** A red check is
the author's own signal, not the panel's work: if the check is red, that
is your next task, not the panel's — fix it and push, then request. This
binds *you*, whether or not any engine enforces it. "My local suite
passed" is evidence about your machine; the check at the head is the
shared artifact the panel actually reads, and a reviewer's first act is
to read it. The one exception is a failure genuinely outside the PR — a
runner outage, a flaky dependency, a failure already present on the
default branch — and it is an exception only if the request says so
explicitly and names the evidence (e.g. "the same job fails identically
on `origin/main` at `<sha>`"). Silence about a red check is what is
prohibited; an argued exception shifts the burden to the author.
*Green* is a ruled term (operator, 2026-07-27): a **cancelled or
stale** check is not a green head — the rollup is scoped to the current
head, so what survives there is same-head cancellation, not
supersession by a newer push — while a **skipped or neutral** one *is*
green: those are deliberate "passed / not applicable" conclusions, and
reddening them would red every conditional job the fleet skips on
purpose. The costs behind the line are asymmetric: a false green spends
a three-reviewer round; a false red spends one author session.
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point and stating what changed and what was verified.
That reply is the written round record: the engine mirrors it under the
PR body's **Round log**, newest last, so the builder owes the reply and
no separate body edit. At re-request time the engine takes the author's
comments posted after the newest verdict in the round and appends them
with `<!-- round:<head-sha> -->`; an existing marker makes a retry a
no-op. If the builder posted no reply, the engine records that the round
passed without one and never blocks handoff on the omission. Then push
the fixes, then re-request **by head, not by verdict**: if answering the
round pushed any commit, every
panelist's approval is now stale — an approval is of a specific tree,
and the handoff predicate counts only approvals at the current head —
so **every panelist is re-requested, the approvers included**; a
panelist left un-re-requested after a push can never approve the tree
you shipped, and the PR sits looking finished with a full set of
verdicts and nothing owed by anyone, the same silent-stall shape as
[#26](https://github.com/heavy-duty/ceremony/issues/26)/[#39](https://github.com/heavy-duty/ceremony/issues/39).
Only when the head did not move — the round was answered with argument
or evidence and nothing was pushed — do you re-request just the
non-approvers: a standing approval already covers this exact head, and
the engine absorbs a re-request at an unchanged head (the re-request
rule, [#94](https://github.com/heavy-duty/ceremony/issues/94); its
mechanism is crew's to describe). **The re-request carries the same
green-check-at-head precondition as the first request**, argued
exception included. This is where the measured cost landed: crew#40
burned two consecutive heads and four reviewer-rounds, every one
relaying a CI failure already visible in the job log (crew#45). A fix
push whose check comes up red is not ready to go back to the panel; it
is your next fix. Prefer verification over argument: when a
covering every point, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
@ -339,19 +263,14 @@ item to its flow in the same comment ([LABELS.md](LABELS.md)).
When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — the engine performs these mechanical steps on the
builder's behalf, in order:
this is a release PR) — hand it to the human, in order:
1. request the human's review;
2. set `state:needs-human`;
3. post the engine-rendered handoff comment: approvals at the current head,
the head SHA, and a pointer to the PR body's **Round log**.
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
The builder composes no new summary at handoff: the authored record already
lives in the Round log, mirrored mechanically from each whole-round reply as
specified above. The label write is optimistic — the reconciler validates
it, and takes it back if the PR is not actually mergeable-right-now. Then
stop: the PR is the human's. The claim is now parked as shape 4 (Picking,
above) — the handoff you just posted is its declaration, and your build slot
is free. Address what comes back (`state:addressing`) and re-hand-off the
same way.
The label write is optimistic — the reconciler validates it, and takes it
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
human's. The claim is now parked as shape 4 (Picking, above) — the handoff
you just posted is its declaration, and your build slot is free. Address
what comes back (`state:addressing`) and re-hand-off the same way.

View file

@ -9,102 +9,6 @@ Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112).
## 0.4.0 — 2026-07-29
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).
### Changed
- Labels automation docs now make sweep cadence a consumer-owned tradeoff,
retain hourly as the engine-less default, and document manual dispatch as
the operator's immediate full-board sweep (#203).
- `labels` — the reconcile cron relaxes from `*/15` to hourly (#199), cutting a
private consumer's schedule-triggered full-board sweeps ~4× at GitHub's
1-minute billing floor.
- `labels` — the hourly cron is the sweep's only wake for transitions no
subscribed event carries — a verdict landing, blocker:ci-red, a
blocker:conflict when another PR merges, the time-based stale/reclaim — so it
bounds their latency to ≤1h, delaying no event-carried transition (#199).
- `labels` — the caller's `issues:` trigger narrows to
`[opened, closed, edited, reopened]` (#199), the actions that carry a
queue-state change the cron cannot wait a cadence for. The churn/validation
actions — labeled/unlabeled/assigned/unassigned — come off; the PR handoff
wake is unaffected.
- `labels` — each caller trigger now carries a comment saying why it is
subscribed, and reconcile keeps `cancel-in-progress: false` (#199) —
cancelling a sweep mid-board is the race that guard exists to prevent.
- `CONTRIBUTING.md` now points to `BUILDER.md` for the shared PR flow instead
of restating doctrine that can drift, while retaining ceremony's roster and
other repo-specific facts (#198).
- Builder doctrine makes each whole-round reply the durable Round log record
mirrored by the engine, leaving handoff as a mechanical facts-only step
instead of a newly composed summary (#196).
- `FLEET.md` removes its duplicate bench roster, records crew as a general
operator-configured tool, and advances its whole-file audit stamp to
`crew@eaeb302` with every surviving crew link re-pinned (#193).
- `FLEET.md` keeps the registry's authorization rule and its crew#16/crew#66
provenance, while replacing duplicated mechanism and path claims with a
pinned pointer to crew's registry header (#192).
- `BUILDER.md` gates both review-request points on a green check at the
head, carries crew#45's argued exception for failures outside the PR,
and states the ruled classification: cancelled and stale are not a
green head; skipped and neutral are (#189).
- `BUILDER.md` documents CI-red recovery in pickup precedence: a red head
of your own PR is picked up before claiming another issue, is never a
parked claim, and follows crew#17's recovery path (#189).
- `FLEET.md` writes the ci-red wake into the duty order between resume
and build, now as deployed engine rather than on paper: the
reconciliation stamp advances to the crew SHA carrying crew#64 (#189).
- `FLEET.md` describes the build wake's check gate as the engine
implements it: a green head, or one with no checks configured, opens a
round; a red head and an unfinished one are held and reported
separately (#189).
- `FLEET.md` corrects the attention wake to the crew#66 ruling: the query
is cross-repo, the action is registry-bounded, and an out-of-scope
demand is reported and escalated to the operator rather than worked. It
no longer claims attention is exempt from the registry (#189).
- `FLEET.md` distinguishes an attention session that dies before acking,
which relaunches, from one that completes without acking, which is a
decline a ledger keeps from re-firing (#189).
- `BUILDER.md` re-requests by head, not by verdict: a push while
answering a round stales every approval, so every panelist is
re-requested; only an unchanged head re-requests the non-approvers
alone (#190).
- FLEET.md's duty-loop mechanism is a pointer to crew's shared engine; the
wake lists follow the engine's duty order, the roster keeps the as-built
bench beside `fleet.roster`'s target, and the reconciliation stamp names
crew@`01fb49c` (#187).
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).
### Fixed
- FLEET.md no longer says a review request outside the registry is
authorization: `repos.txt` is the scope for the review queue, out-of-scope
requests are logged and never acted on, and the attention wake is stated
as the one registry-independent exception, by design (#187).
- `blocked_reference_records` unions every `Blocked by` clause in the body
instead of binding to the first marker occurrence — a repeated declaration
no longer promotes on its first sentence alone, and earlier prose that
merely mentions being blocked no longer hijacks the parse (#184).
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).
## 0.3.0 — 2026-07-24
- Make `changelog-armed` reject fragment shape drift on the PR that introduces it.

View file

@ -35,9 +35,43 @@ Who may set which label is [LABELS.md](LABELS.md)'s contract.
## The PR flow
PRs move through review rounds that builders answer whole, and only a human
merges. [BUILDER.md](BUILDER.md) is the shared flow contract; this file names
only ceremony-specific facts such as the roster and code conventions.
The same flow the sibling repos run, and the part of this pipeline that is
already proven:
1. **One issue, one PR**, opened as a **draft** while building, with
`Closes #N` in the body — its exceptions (cross-repo work, a post-merge
criterion) live in [BUILDER.md](BUILDER.md). Drafts are invisible to the
reviewer panel on purpose. Every behavior change writes one fragment,
`changelog.d/<issue>.md` — the exact prose to publish, nothing else
(cross-repo work names it `<repo>-<issue>.md`; a grouped repo puts its
`### Added` / `### Changed` / `### Fixed` headings inside the fragment).
Never edit `CHANGELOG.md` for an entry — the release PR assembles the
section from the fragments (#112).
The sole exception is the release PR: it writes no fragment. It consumes
the directory and stamps the section, so a fragment it created would be
absent from
[`changelog-assembled`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-assembled/changelog-assembled.sh)'s
merge-base replay if consumed, or refused by
[`changelog-armed`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-armed/changelog-armed.sh)
if left to survive into the next release. A change that must ship inside
the release PR therefore ships without an entry. If it can wait and wants
an entry, land it as an ordinary PR before the release PR, then rebase and
re-assemble the release.
2. **When it's ready**: mark ready-for-review and request the whole panel.
3. **Rounds are answered whole.** Wait until every reviewer has a verdict in,
then answer the entire round in a **single reply**, push the fixes, and
re-request the reviewers that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
4. **Reviews end in a verdict** — approve or request-changes, never a bare
comment. The verdict carries blockingness only; the body carries the
feedback. ([REVIEWER.md](REVIEWER.md) for why a comment-only review stalls
the machine.)
5. **Handoff**: when the round passes — every panel verdict is an approval of
the current head and no `blocker:*` label stands — the author posts the
round summary, requests the human's review, and sets `state:needs-human`.
The label write is optimistic; the reconciler validates it within seconds.
6. **A human merges.** Nothing else merges.
### Roster

312
FLEET.md
View file

@ -1,67 +1,54 @@
# FLEET.md — the fleet shape, and how it actually runs
# FLEET.md — the roster, and how it actually runs
> **Status:** descriptive snapshot, not doctrine. This file records how the
> heavy-duty operator fleet is wired *today*. It is **not** part of the
> vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
> repos. [Crew](https://github.com/heavy-duty/crew) is a general tool: its
> repository ships the engine, while the fleet definition belongs to the
> operator; heavy-duty is one operator of it. Membership, repository scope,
> agent-profile overrides and doctrine paths belong to that definition;
> membership itself lives outside every checkout. Crew's shipped defaults
> name heavy-duty's AGENTS.md, TRIAGE.md, BUILDER.md and REVIEWER.md, but
> those are compatibility defaults, not vocabulary compiled into the engine
> — operator `doctrine.conf` values can replace them.
> The *mechanism* lives with crew and this file points at it. Last reconciled
> against the merged engine at
> [`heavy-duty/crew@eaeb302`](https://github.com/heavy-duty/crew/tree/eaeb3022aa47d90e797f2b9e007b831df7ca8406),
> 2026-07-28 — a descriptive file with no reconciliation stamp gives the next
> reader nothing to diff, which is exactly how the #149 drift went unnoticed.
> agent fleet that builds this repo is wired *today*, so the setup can later be
> solidified into a replicable fleet-management solution. It is **not** part of
> the vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
> repos. The doctrine files (AGENTS.md, TRIAGE.md, BUILDER.md, REVIEWER.md,
> LABELS.md, CONTRIBUTING.md) say what roles *must* do; this file says how the
> current bench *physically* does it. Last reconciled against the deployed
> duty scripts at
> [`heavy-duty/crew@b2fd864`](https://github.com/heavy-duty/crew/tree/b2fd8642e7f7aa8dc9de6b44edadbe1dc557b140)
> (private to the org; the fleet can read it), 2026-07-24 — a descriptive
> file with no reconciliation stamp gives the next reader nothing to diff,
> which is exactly how the #149 drift went unnoticed.
## Fleet shape
## The roster
One box (an isolated, disposable VM) per GitHub identity. Boxes are credential
boundaries; sessions inside a box are role boundaries. No box has an inbound
network path — GitHub is the only queue. Fleet membership is the operator's
definition and lives outside every checkout; this file deliberately carries
no second roster.
network path — GitHub is the only queue.
Review panel per PR = the governed repo's `.github/labels.conf` `panel=` line
minus the PR's author, as [REVIEWER.md](REVIEWER.md) specifies (recusal by
| Identity | Box | CLI | Roles |
|---|---|---|---|
| `dan-claude-bot` | triage-box | Claude Code | **triage** — the only issue-minter |
| `claude-bot-andresmgsl` | claude-box | Claude Code | builder (hard machinery) + reviewer |
| `codex-bot-andresmgsl` | codex-box | Codex CLI | builder (mechanical) + reviewer |
| `grok-bot-andresmgsl` | grok-box | Grok CLI | reviewer |
| `kimi-bot-andresmgsl` | kimi-box | Kimi CLI | reviewer |
Review panel per PR = the reviewer bench minus the PR's author (recusal by
construction). Only humans merge — enforced as permissions (the agents team
holds the triage role, not write), not as convention.
## Anatomy of a duty loop
The mechanism is no longer described here. The five hand-rolled duty scripts
converged into crew's shared engine, and a prose mirror of running code in a
second repo is a second thing to keep true — this one drifted (it said cron
ran `duty.sh` directly and gave the hygiene sweep its own cron line; crew's
`duty.sh` records that separate line as the bug it fixed, sharing
`~/duty/work` unlocked). How a tick actually works — cron fires
[`bin/tick.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/tick.sh),
the only cron target, which wraps
[`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)
in a non-blocking `flock` with one evidence line per boundary; the boot gate
and crash recovery; the session runner; backlog hygiene self-scheduling
inside the duty tick under the same lock — lives with the code:
[`shared/README.md`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/README.md)
is the map, provenance table included. Sessions stay disposable: durable work
state lives on the board (issues, PRs, labels) and in git branches, while the
engine keeps only operational evidence and deduplication state under
`~/duty`; detection is the engine's, judgment is the session's.
Every box runs the same skeleton, adapted to its CLI:
What belongs here is what a wake *means*:
- **The registry is the scope.** A box acts only on repos its operator
listed. Work it finds outside that scope is reported and never acted on;
the report is part of the boundary, because a bounded wake that goes quiet
is indistinguishable from a broken one. Adding a repo is an **operator
decision**, never something a sweep makes by writing where nobody listed.
The 2026-07-25 scope ruling (crew#16) closed the org-wide review and
author-side write surface; the crew#66 attention ruling closed the last
exemption. Crew's
[`examples/repos.txt` header](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/examples/repos.txt)
is the pinned source for how that rule is implemented and reported.
- **Tick:** cron `*/5` runs `~/duty/duty.sh` under a non-blocking `flock`; the
triage box adds an hourly hygiene sweep under its own lock. Holding the lock
is load-bearing: a tick that acquires it *knows* nothing else is running on
this identity.
- **Poll:** the script reads `~/duty/repos.txt` and queries GitHub with `gh`
for work matching the box's role. Whose registry that file is depends on
the role: the triage box's `repos.txt` **is** its registry — adding a repo
is adding a line — while a reviewer's registry is the org itself, and its
`repos.txt` is a backstop that cannot scope it (grok's copy says so in its
own first line).
- **Act:** when there is work, the script launches the box's CLI as a one-shot
session with a role prompt; the session does the work via `gh` as the box's
own identity, then exits. Sessions are stateless and disposable — all state
lives on the board (issues, PRs, labels) and in git branches.
### Wake conditions
@ -75,27 +62,9 @@ sibling agent outranks self-directed continuation, and it is frequently the
very thing that unparks the work resume would otherwise pick up. The query is
the authenticated-user endpoint —
`gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no
search index (the review queue below already records that the index lags) —
and it **sees** repos outside the operator's registry, because that endpoint
takes no repo filter.
**Seeing is not acting, and that is a ruling** (crew#66, danmt, 2026-07-27).
The wake used to work every row it saw, which for a builder meant a clone and
the full worktree and round rule set against a repo no operator had listed —
write authority outside the registry, and the one hole left in the
containment story. Rows are now partitioned against the registry: inside it,
a session as before; outside it, reported and never acted on, exactly like an
out-of-scope review request or authored PR.
[`lib/duty-attention.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/lib/duty-attention.sh)
implements the partition and states the ruling in its header.
The cost was argued before the ruling rather than discovered after it: an
assignment plus a label **is** a targeted authorization, so a cross-repo
handoff now waits on an operator adding the repo, and the box most likely to
be handed work outside its beat is the one that goes quiet. That is why an
out-of-scope demand does not only reach `duty.log` — it pings the operator
over the same channel the boot gate uses. A bounded wake that failed silently
would trade an unbounded write surface for a broken channel to the human.
search index (the reviewer trigger below already records that the index
lags), and like the review-request trigger it reaches repos `~/duty/repos.txt`
does not name.
Each demand gets **exactly one session, and the ack bounds it**: the
session's first act, before any of the demanded work, is the pickup comment
@ -103,13 +72,9 @@ plus removing the label — [the `attention`
contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149)
ack (#85), which here becomes the session's ack-then-act ordering.
Then it acts on the thread and exits — short by construction. Until the label
is removed the flag is still up, so a session that **dies** before acking is
simply relaunched at the next tick — the same crash-only shape as resume
below. A session that **completes** without acking is a different fact: that
is a decline, and a seen-ledger stops it re-firing until the issue moves.
Dying and declining used to look identical to the engine, which meant a
demand a session had considered and correctly left alone woke a new one every
tick forever.
is removed the flag is still up, so a session that dies before acking is
simply relaunched at the next tick; that is the whole crash-recovery story,
and it is the same crash-only shape as resume below.
The design this replaces was built and rejected: polling notifications for
`reason: mention` re-arms a thread on every comment, so ordinary round
@ -120,91 +85,47 @@ wake: [#16's 16:49Z
ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
authorized the last open acceptance criterion on a `claimed` issue and sat
unowned for over an hour — the box answered every state signal that day and
never saw the comment, and the eventual pickup ran on a manual bridge. The
wake is no longer on paper: `duty-attention.sh` is deployed engine, and
`duty.sh` runs it first on every box, whatever its roles.
never saw the comment, and the eventual pickup ran on a manual bridge. Like
the notifier's queue below, this wake is the spec for a box-side change only
the operator can make; until `duty.sh` polls it, the wake exists on paper —
though one consumer already polls for the label and no-ops while it is
absent, so the wiring can be verified live the day the row lands.
The engine's duty order is fleet-standard
([`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)):
**attention → triage signals → review queue → resume → ci-red → build →
handoff → rebase → worktree hygiene → backlog hygiene (hourly)** — attention
role-independent and first, then each duty family the box's roles enable.
Every position in that order is deployed engine at the stamped SHA:
[crew#64](https://github.com/heavy-duty/crew/pull/64) merged ci-red between
resume and build, and `duty.sh`'s own header carries the same order.
The earlier form of this file folded handoff and rebase into the other
builder wakes; they are duties of their own.
- **Triage signals**, per registry repo: `needs-triage` issues,
queue-unlabeled strays, discussions without triage's voice, unread
`@`-mentions (their own session), and `blocked` issues whose named blockers
have all landed — a lead the session verifies, never a label the engine
flips. Backlog hygiene (stale claims, label invariants) runs hourly,
self-scheduled inside the duty tick. A `needs-ruling` standing **past
24h** is still triage's to pick up — the ladder's last rung makes the
option triage's to choose — but, like the notifier queue below, that
detection row is on paper only today.
- **Review queue**: one candidate set, enumerated from the pulls pages of
every registry repo — object endpoints, never the search index for the
queue itself, whose lag left cast#143, incubator#25 and box#164 sitting
unreviewed — filtered to PRs listing me in `requested_reviewers`, deduped
by (repo, PR) before acting (the sequential shape double-announced on
ceremony#32), and worked oldest-first. One search-backed **awareness pass**
per tick reports requests outside the registry and never acts on them —
the scope rule above. One verdict per head, deduplicated against my own
latest review's SHA; a re-request at an unchanged head is answered with an
auto-approve through the verdict gate rather than left as a stale blocker
(operator ruling 2026-07-23, ceremony#94).
- **Resume** (builders, checked before build): an open draft PR of mine, or
a `claimed` issue whose `build/*` branch exists on my fork with no open PR
— a session died between first push and PR creation. A branch whose PR
already **merged** is a post-merge wait, never resumed (#172,
incubator#55/#64).
- **ci-red** (builders): a non-draft PR of mine whose check at the current
head is failing. Evaluated before the build wake, so a red PR of mine
outranks a new claim — repairing my own red head comes ahead of new work
(ceremony#163: full-panel approvals at the head, mergeable, stranded on
a transient failure no wake covered). A round owed at a red head is
excluded from the build wake below but reported rather than silent, and
an unchanged red head goes quiet after one attempt, through the
`report_suppressed` path — suppressed, still said. A check that has not
finished is **not** a red head and wakes nothing here: nothing has failed
yet, so there is no investigation to launch. How a red head is detected
and kept quiet is the engine's mechanism, described in crew's
`shared/README.md`, not here.
- **Build**: a `ready` **unclaimed** issue (an assignee means mid-claim, not
pickable), or a completed review round on my PR — a changes-request with
no panel review request still outstanding; whole rounds, never single
verdicts, and never a round the check at its head does not support. The
wake admits a **green** head, and a head with **no checks configured**
terminal, not transient, so holding there would retire the round rather
than delay it. It holds a **red** head (already woken ci-red above) and a
head whose check has **not finished** (opening the round there spends the
panel on a head that may go red — crew#45's measured cost — and it admits
itself a tick later once the check settles). Both holds are reported, not
swallowed, and they are reported *differently*: only one of them is the
author's own work to do.
- **Handoff**: a round of mine that converged — every panelist's latest
opinionated review approves the current head, no panel request
outstanding, mergeable right now, `state:needs-human` not already set.
Convergence is computed from `latestOpinionatedReviews`, never
`reviewDecision`, which stays empty without branch protection and silently
stalled rounds for a day (ceremony#26, #39).
- **Rebase**: my PR `CONFLICTING` — and only `CONFLICTING`; `UNKNOWN` is
GitHub's post-merge recompute flap and waits. A conflicting draft belongs
to resume.
- **Worktree hygiene**: a `build/*` worktree is removed only when its branch
has PR history and no PR on it remains open; a branch with no PR at all is
an in-flight claim and stays.
- **Triage:** new discussions to mint from, builder questions on issues, stray
issues to reconcile, `@`-mentions, hourly hygiene (stale claims, label
invariants), and a `needs-ruling` standing **past 24h** — the ladder's last
rung makes the option triage's to pick, and this wake list is where triage
learns such an item exists (see the notifier section below).
- **Builders**, in priority order: **resume** (an open draft PR of mine, or a
claimed issue with my `build/*` branch but no PR — possible only if a
previous session died mid-work), a `ready` issue to claim, a completed
review round on my PR (act on whole rounds, never single verdicts), my PR
fully approved (write the closing summary, flip to `state:needs-human`,
request the human), my PR `CONFLICTING` (rebase; never act on `UNKNOWN`
post-merge flap).
- **Reviewers**, one candidate set from two merged sources. Source 1,
authoritative: every open PR in the `heavy-duty` org **plus the named bot
forks** that lists me in `requested_reviewers`, enumerated straight from
the pulls API — never `gh search`, whose index lags (cast#143,
incubator#25 and box#164 each sat unreviewed behind it). A review request
is authorization, so no repo filter may gate it. Source 2, backstop: the
`repos.txt` poll for an open PR by someone else whose head I have not yet
reviewed — it only **adds** candidates the sweep may have missed (an
org-enumeration failure, say) and never concludes "nothing to do". The
sources are merged and deduplicated by (repo, PR) **before** acting, not
run as sequential passes — the sequential shape double-announced on
ceremony#32, when the request sweep and the repo-list poll each acted on
the same PR in one tick (operator protocol 2026-07-23) — and worked
oldest-first. Unchanged: one verdict per head, deduplicated against my
own latest review's SHA.
#### The operator notifier — the `needs-ruling` queue
The operator notifier (`notify.sh`, a fleet singleton on the triage box; its
mechanism is crew's too) watches open PRs carrying `state:needs-human`. That
poll never reads `needs-ruling`, which lives mostly on *issues* — so an
escalation waits invisibly on the very human it names. Not hypothetical: on
2026-07-23 alone, three escalations spent their whole lives outside the
operator's view — [#16's fork-PR-workflows
The operator notifier (`notify.sh`, on the triage box) watches open PRs
carrying `state:needs-human`. That poll never reads `needs-ruling`, which
lives mostly on *issues* — so an escalation waits invisibly on the very human
it names. Not hypothetical: on 2026-07-23 alone, three escalations spent
their whole lives outside the operator's view — [#16's fork-PR-workflows
question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689)
(raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884)
— eight hours in which the board showed a `claimed` issue indistinguishable
@ -214,12 +135,11 @@ and [epic #50's own 13:04Z
flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181),
which surfaced only because a human happened to look. This file records how
the fleet actually runs; that is why this wiring changed (#50 D16). The spec
for the engine-side update:
for the box-side update:
- **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh`
polls **open issues and PRs labelled `needs-ruling`** across every repo in
`notify-repos.txt`, which is deliberately wider than the duty registry:
a cross-repo handoff is precisely what the operator cannot discover alone.
`~/duty/repos.txt`.
- **One tracked message per item, edited in place** — the same
one-message-per-item discipline the PR poll already uses, so an aging
ruling reads as a **live queue**, not a feed. The message is removed when
@ -244,46 +164,54 @@ Nothing box-side ever sets, clears, or decides `needs-ruling` (#50 D9, D15):
the notifier and triage's past-24h wake above *report and pick up* what the
board already shows; the label itself moves only by the doctrine's hands.
The duty engine is crew's shared tree, one source deployed to every box.
Specs written in this file have a record of becoming engine: the attention
wake and the reviewers' request sweep both started here as paper (the sweep's
org-wide form was then retired by the 2026-07-25 scope ruling), and the
builders' ci-red wake above is the latest: written here as paper while
crew#64 was open, engine at the stamped SHA. Two rows are still on paper, and
both are `needs-ruling`: the notifier's queue — at that SHA, `notify.sh`'s
only label filter is `state:needs-human` — and triage's **past 24h**
detection row above, which the triage-signals bullet already marks. Earlier
counts here said "two" while silently excluding the second; naming them is
cheaper than a number that has to be recounted every time a wake lands.
`~/duty/repos.txt` and the duty scripts live inside each box and are the
operator's to change; this descriptive edit is the spec for those box-side
updates. The reviewers' request sweep is one such spec made real — deployed
on all four reviewer boxes since 2026-07-23 (the Reviewers wake above). The
notifier's `needs-ruling` queue is still on paper only: `notify.sh`'s one
label filter today is `state:needs-human`.
### Resilience
- **Boot gate:** each tick compares the kernel boot id
(`/proc/sys/kernel/random/boot_id`) to a stored marker. First tick after any
reboot runs credential + disk probes; the marker is written only when auth
actually works, so a box with dead credentials re-checks loudly every tick
instead of silently skipping duty.
- **Crash-only resume:** there is no session state to restore. The recovery
path *is* the normal path: the resume wake condition reads the board, posts
`⟲ resuming from <sha>`, and continues from the worklog. Rebooting a box
never loses work that was pushed.
- **Checkpoint discipline (builders):** open the PR as draft at the first
commit with a `## Worklog` checkbox list; check off and push after every
step. The board and the branch are the only memory.
- **Worktree isolation:** builders build each PR in its own `git worktree`;
reviewers check out PR heads in throwaway detached worktrees and remove them
after the verdict. Main clones stay parked on the default branch, always
clean; stale worktrees are pruned by the boot gate.
### Conventions on the board
- `🔎 reviewing head <sha>` — a reviewer announces work before starting, so
liveness is visible instead of hoped for.
- `⟲ resuming from <sha>` — a builder announces recovery after interruption;
there is no session state to restore, so the recovery path *is* the normal
path: read the board, continue from the worklog. Rebooting a box never
loses work that was pushed.
- Checkpoint discipline (builders): open the PR as draft at the first commit
with a `## Worklog` checkbox list; check off and push after every step.
The board and the branch are the only memory.
- `⟲ resuming from <sha>` — a builder announces recovery after interruption.
- Claim ritual: comment on the issue + self-assign + label flip, before any
branch exists.
- Handoff: the author closes an approved PR's round with a summary comment,
flips `state:needs-human`, and requests the human — merging is never the
fleet's job.
- Worktree isolation: builders build each PR in its own `git worktree`;
reviewers check out PR heads in throwaway detached worktrees and remove
them after the verdict. Main clones stay parked on the default branch,
always clean.
## Where this is going
This wiring proved itself on day one (seven merged PRs, unanimous three-model
review convergence on #39, and a full-fleet crash recovery), and the plan it
carried has become **heavy-duty/crew** — a shared engine, CLI, operator
configuration model, real-host rehearsal and fixture tests — so standing up
a fleet is a bootstrap, not an archaeology dig. What remains is adoption:
crew#85 tracks the road to a `0.1.0` another operator can use without a fork.
Membership stays in the operator definition; this file remains the map of
what a wake means, and crew is the map of how it runs.
review convergence on #39, and a full-fleet crash recovery). The plan:
1. Once the ceremony machinery is complete and adopted, each agent will be
asked to write a **detailed, replicable description of its own setup**
cron lines, duty script, prompts, probes — as durable documentation.
2. Those five descriptions get converged into a **solidified fleet-management
solution** (duty loops as reusable templates, likely living alongside the
rig templates registry), so standing up this roster on a new repo — or a
whole new fleet — is a bootstrap, not an archaeology dig.
Until then, this file is the map.

View file

@ -3,8 +3,7 @@
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's dispatch (which is also the operator's manual
full-board reconcile sweep; issue #10).
the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
@ -212,13 +211,10 @@ on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on subscribed
events plus a consumer-owned scheduled discovery sweep. Hourly is the
recommended default when no other engine drives board state; relax it only as
the transition classes with no other writer shrink. Manual dispatch both
bootstraps this taxonomy idempotently and runs the operator's on-demand
full-board reconcile. The sweep warns when the core taxonomy declares a label
the repository lacks. The same workflow reconciles issue-flow labels on issue
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. The sweep warns when the core taxonomy declares a label the
repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` is a discussion, not an issue.

View file

@ -1 +1 @@
0.4.1-dev
0.3.1-dev

7
changelog.d/167.md Normal file
View file

@ -0,0 +1,7 @@
### Added
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).

3
changelog.d/173.md Normal file
View file

@ -0,0 +1,3 @@
### Changed
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).

3
changelog.d/175.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.

5
changelog.d/178.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).

5
changelog.d/180.md Normal file
View file

@ -0,0 +1,5 @@
### Fixed
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).

10
changelog.d/182.md Normal file
View file

@ -0,0 +1,10 @@
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
### Changed
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).

6
changelog.d/184.md Normal file
View file

@ -0,0 +1,6 @@
### Fixed
- `blocked_reference_records` unions every `Blocked by` clause in the body
instead of binding to the first marker occurrence — a repeated declaration
no longer promotes on its first sentence alone, and earlier prose that
merely mentions being blocked no longer hijacks the parse (#184).

View file

@ -122,8 +122,7 @@ the machinery at all:
[Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
path→scope globs). Run `workflow_dispatch` once — **this bootstraps
the taxonomy, `release` label included** — and use it again whenever an
operator needs a full-board sweep immediately.
the taxonomy, `release` label included**.
7. **The artifact hook** (optional): `.github/actions/release-artifact/`
per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package.
@ -297,39 +296,20 @@ The complete caller is:
```yaml
name: labels
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's only
# wake for four transition classes — a review verdict landing (no
# pull_request_review trigger), blocker:ci-red set/cleared, blocker:conflict
# when another PR merges under this one, and time-based stale / 48h
# claim-reclaim. Events below carry the rest in seconds. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
workflow_dispatch:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
# Fork PRs; these carry the head/draft/review facts state:* derives from.
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
# synchronize re-derives on every push. review_requested/review_request_removed
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
# blocker:unrequested when the panel is asked.
# review_requested/review_request_removed are unreleased — not in 0.2.0;
# add them with the pin bump to the first tag carrying ceremony#137. They
# wake the sweep that clears blocker:unrequested when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block.
issues:
# Narrowed (#199) to the actions carrying a queue-state change the hourly
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
# validation + the 48h claim clock, caught within one cadence, and
# labeled/unlabeled were the issues-churn source. The handoff wake is
# pull_request_target:labeled, not issues, so this leaves it intact.
types: [opened, closed, edited, reopened]
# edited/reopened are unreleased — not in 0.2.0; add them with the pin
# bump to the first tag carrying ceremony#144, the same bump as the
# pull_request_target additions above.
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
@ -350,32 +330,23 @@ needs all three explicit reads above; without them the failure appears as an emp
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
it only by bumping every ceremony reference to `0.2.0` or later; never mix
refs to adopt it early. The type list has grown then narrowed across tags:
`0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned,
unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`;
ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the
cron to hourly, so a consumer picks up the smaller trigger surface at the pin
bump to the first tag carrying ceremony#199. The narrowing drops
`labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim
clock, which the hourly cron catches within one cadence, and `labeled`/
`unlabeled` were the issues-churn source — while **keeping** #144's `edited`/
`reopened`: those carry a queue-state change an event uniquely carries (a body
rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the
must-fail in ceremony#199 keeps them on events. `opened` drives the
mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
the stub and ceremony's own caller stay byte-for-byte identical, the parity
#144 established.
refs to adopt it early. At `0.2.0` the type list stops at `closed`: `edited`
and `reopened` are newer, part of the pending stub edit below.
`pull_request_target` is intentional: fork PRs need the base repository's
token to write labels. The reusable workflow executes no PR code. It checks
out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — but the caller above
is no longer the #18 shape, so adopting current triggers is a stub edit, not
a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
a bare pin bump. The pending edits are `review_requested` and
`review_request_removed` on `pull_request_target:` (#137) — the wake that
clears `blocker:unrequested` the moment the panel is asked, without which a
quiet repo wears that flag until the backstop cron; a consumer picks them up
by pinning `0.3.0` or later, never through mixed refs.
quiet repo wears that flag until the advisory cron — and `edited` and
`reopened` on `issues:` (#144): the wakes for a body edit rewriting the
`Blocked by #N` declaration the reconcile sweep parses, and for a closed
issue re-entering the queue wearing labels derived when it closed. Make both
edits together, with the pin bump to the first tag carrying ceremony#137 and
ceremony#144 — never before it and never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows:
@ -404,20 +375,11 @@ Core state, blocker, work-queue, and release labels come from ceremony. Scope
rows remain consumer-owned because paths and surfaces differ by repository.
After adding the caller and configuration, run `workflow_dispatch` once to
bootstrap labels on a fresh repository. It is also the operator's general
manual full-board sweep — the answer when the board looks wrong now rather
than after the next scheduled cadence:
```sh
gh workflow run labels.yml -R <owner>/<repo>
```
Ceremony dogfoods the caller under the filename `self-labels.yml`, so the
equivalent command in this repository substitutes that filename. Scheduled
and PR-triggered runs only reconcile; they do not repeatedly upsert the
taxonomy. When a ceremony pin bump adds a core label, bump the pin first and
then re-dispatch `workflow_dispatch`; the scheduled sweep warns when the
pinned taxonomy declares a core label the repository lacks.
bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only
reconcile; they do not repeatedly upsert the taxonomy. When a ceremony pin
bump adds a core label, bump the pin first and then re-dispatch
`workflow_dispatch`; the scheduled sweep warns when the pinned taxonomy
declares a core label the repository lacks.
## Doctrine mirror

View file

@ -1,50 +0,0 @@
# 0.4.0 — drill record
Run 2026-07-29 by `codex-bot-andresmgsl` against release PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57`.
Where: disposable private repo
`codex-bot-andresmgsl/ceremony-drill-0.4.0`, carrying the
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
`0.4.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
and a non-blank drill record. The repository is archived, pending the
operator's delete.
## Candidate-ref deviation
The pure consumer path cannot resolve `CEREMONY_SELF_REF: "0.4.0"` before
the candidate creates that tag. No `0.4.0` branch was created in
`heavy-duty/ceremony`. The scratch caller instead used
`codex-bot-andresmgsl/ceremony@drill/0.4.0`, whose parent is PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57` and whose only additional
commit rewrites both `CEREMONY_SELF_REF` carriers to that same canonical
candidate SHA. All runtime machinery was therefore fetched from the 0.4.0
candidate tree.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30445585532 (attempt 1) | ✅ one `0.4.0` release; tag equals merge commit; main re-armed to `0.4.1-dev` with only `changelog.d/README.md` |
| 2 | mislabeled ordinary PR | 30445473977 | ✅ green NOTICE no-op; no tag or release |
| 3 | bare-version PR without `release` | 30445513860 | ✅ refused at decide; no tag or release |
| 4 | re-run completed ceremony | 30445585532 (attempt 2) | ✅ refused because the tag/release already existed; release count stayed one |
| 5 | manual matching tag | 30445658952 | ✅ `0.5.0` published with its changelog section; main untouched |
| 6 | mismatched tag | 30445684268 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterward |
The merge-door `0.4.0` tag and PR #3 merge commit were both
`2b2d592ffcc5c376e55bd5fcf2dd5ffbdd692d64`. Its release body was exactly:
```text
- Fragment mode is exercised by the 0.4.0 drill.
```
## Setup
The armed fixture was committed before the caller so the first workflow run
had a real parent version to inspect. Installing the caller then produced
green baseline run 30445432039.
After the unlabeled bare-version refusal, its merge commit was reverted to
restore the armed fixture. That setup correction produced green no-op run
30445544068 before the merge-door probe began.

View file

@ -1,98 +0,0 @@
#!/usr/bin/env bash
set -u
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
# is what exhausted the fleet's shared Actions allotment. These assertions
# pin the reductions #199 made and the guard it must not trade away — none of
# them touch the reconciler's LOGIC, which its own fixtures cover.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
REUSABLE="$ROOT/.github/workflows/labels.yml"
SELF="$ROOT/.github/workflows/self-labels.yml"
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block
# The `cancel-in-progress:` value of a named top-level job, read from the first
# such line inside that job's block. Job keys sit at two-space indent.
job_cancel_in_progress() { # $1 = file, $2 = job name
awk -v job="^ $2:\$" '
$0 ~ job { f = 1; next }
f && /^ [a-z]/ { exit } # next job — stop before leaking into it
f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit }
' "$1"
}
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
# the first `types:` line after the bare key. The key is bare (nothing after
# the colon) so it never collides with `issues: write` in the permissions block.
trigger_types() { # $1 = file, $2 = trigger key
awk -v key="^ $2:\$" '
$0 ~ key { f = 1; next }
f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit }
f && /^ [a-z]/ { exit }
' "$1"
}
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
# the shared concurrency group exists to prevent. It WOULD cut run count — by
# trading correctness for minutes — so it stays false, forever.
check "reconcile serializes, never cancels mid-board" 0 "false" \
job_cancel_in_progress "$REUSABLE" reconcile
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "reconcile is never cancel-in-progress: true" 1 "" \
bash -c 'job_cancel_in_progress() {
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE"
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
job_cancel_in_progress "$REUSABLE" scope
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
# name to explain the change, and must not re-red their own documentation.
check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "self caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF"
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "stub cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB"
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
# Kept because each carries a queue-state change an event uniquely carries, so
# dropping it would trip #199's must-fail (a transition waiting on the schedule
# when an event could have carried it): opened → mint→needs-triage; closed →
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
for keep in opened closed edited reopened; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface keeps '$keep'" 0 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$keep"
done
# The churn actions must not reappear on the issues surface without a fresh why.
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
# only feed validation and the 48h claim clock, caught within one cadence.
for churn in labeled unlabeled assigned unassigned; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface drops '$churn'" 1 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn"
done
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
# pull_request_target, not issues. A future edit that strips it there re-reds.
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target
summary

View file

@ -69,16 +69,15 @@ check "LABELS.md enumerates no repo's scope labels" 1 "0" \
# same lists. review_requested/review_request_removed are the wake that
# clears blocker:unrequested — the label sat false for as long as a quiet
# repo stayed quiet because the one event that falsifies it was never
# listed (#137). The issues list narrowed to [opened, closed, edited, reopened]
# (#199): each carries a queue-state change the hourly cron cannot wait one
# cadence for — opened drives mint→needs-triage, closed the blocker-closes→ready
# self-heal, edited a body rewrite of the `Blocked by #N` line the sweep parses,
# reopened a closed issue re-entering the queue — while the churn/validation
# actions (labeled/unlabeled/assigned/unassigned) came off. The stub is prose, so nothing but these rows
# keeps the lists from drifting: a type in one file only is a wake that fires
# at home and nowhere in the fleet, or the reverse — the drift #144 caught.
# The NF guard keeps `issues: write` under permissions: from matching the
# issues: trigger key.
# listed (#137). edited/reopened are the wakes for the two events that
# falsify issue labels silently — an edited body rewrites the `Blocked by
# #N` declaration the reconcile sweep parses, and a reopened issue
# re-enters the queue wearing labels derived at close; PR #32 widened the
# caller by both and the stub never followed (#144). The stub is prose, so
# nothing but these rows keeps the lists from drifting: a type in one file
# only is a wake that fires at home and nowhere in the fleet, or the
# reverse. The NF guard keeps `issues: write` under permissions: from
# matching the issues: trigger key.
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
}
@ -89,20 +88,17 @@ types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lis
}
CALLER="$ROOT/.github/workflows/self-labels.yml"
STUB="$ROOT/docs/CONSUMERS.md"
# event_types anchors on the bare trigger key (NF == 1), so it reads the real
# types line even though the #199 comments name pull_request_target: and
# issues: in prose above the keys — an inline /pull_request_target:/ scan would
# latch onto the first mention and read the wrong list.
pr_has_both_review_wakes() {
event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed
}
check "caller and stub pull_request_target lists are identical" 0 "" \
types_in_sync pull_request_target "$CALLER" "$STUB"
check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "the caller lists both review-request wakes" 0 "" bash -c \
'awk "/pull_request_target:/{f=1; next} f && /types: /{print; exit}" "$1" |
grep -F review_requested | grep -qF review_request_removed' _ "$CALLER"
check "caller and stub issues lists are identical" 0 "" \
types_in_sync issues "$CALLER" "$STUB"
check "the caller lists exactly the queue-state-changing issue types" 0 \
"types: [opened, closed, edited, reopened]" event_types "$CALLER" issues
check "the caller still lists all eight issue types" 0 \
"types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \
event_types "$CALLER" issues
# the failing cases: drop a type from either file, or reorder one list only,
# and the identity rows above go red — exercised here on mutated copies
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
@ -116,13 +112,13 @@ sed 's/review_requested, review_request_removed/review_request_removed, review_r
"$STUB" >"$mut_stub"
check "a reorder in one list only goes red" 1 "" \
types_in_sync pull_request_target "$CALLER" "$mut_stub"
sed 's/, closed//' "$CALLER" >"$mut_caller"
sed 's/, edited//' "$CALLER" >"$mut_caller"
check "an issue type dropped from the caller goes red" 1 "" \
types_in_sync issues "$mut_caller" "$STUB"
sed 's/, closed//' "$STUB" >"$mut_stub"
sed 's/, edited//' "$STUB" >"$mut_stub"
check "an issue type dropped from the stub goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"