Compare commits

...

33 commits

Author SHA1 Message Date
github-actions[bot]
84bb1a424d chore: bump main to 0.4.1-dev — a dev install must not impersonate 0.4.0
Some checks failed
CI / test (push) Has been cancelled
CI / release-exercise (push) Has been cancelled
CI / self-guards (push) Has been cancelled
CI / action-exercise (push) Has been cancelled
CI / docs-sync-exercise (push) Has been cancelled
release / release (push) Has been cancelled
2026-07-29 12:27:50 +00:00
Daniel Marin
0a54e31972
Merge pull request #207 from codex-bot-andresmgsl/build/206-release-0-4-0
Some checks failed
release / release (push) Has been cancelled
release: cut 0.4.0
2026-07-29 13:27:32 +01:00
codex-bot-andresmgsl
98c8dc2eb4 docs: record 0.4.0 live drill 2026-07-29 10:58:28 +00:00
codex-bot-andresmgsl
7c755bcd40 release: stamp 0.4.0 candidate 2026-07-29 10:51:51 +00:00
Daniel Marin
fa04d67033
Merge pull request #204 from codex-bot-andresmgsl/build/203-sweep-cadence-manual-sweep
docs: explain sweep cadence and manual dispatch
2026-07-29 11:40:36 +01:00
codex-bot-andresmgsl
4198597834 docs: correct labels maintenance cadence 2026-07-29 10:37:03 +00:00
codex-bot-andresmgsl
3eff28e624 docs: explain sweep cadence and manual dispatch 2026-07-29 09:27:29 +00:00
Daniel Marin
1de6b742f8
Merge pull request #202 from codex-bot-andresmgsl/build/198-contributing-flow-boundary
docs: make BUILDER the single PR flow contract
2026-07-29 10:23:37 +01:00
codex-bot-andresmgsl
6fe549cdb7 docs: make BUILDER the single PR flow contract 2026-07-28 22:31:55 +00:00
Daniel Marin
c4b49cd8d3
Merge pull request #200 from claude-bot-andresmgsl/build/199-labels-sweep-cadence
labels: reduce full-board sweep trigger frequency (#199)
2026-07-28 23:15:09 +01:00
claude-bot-andresmgsl
8cf3c335df labels: name what the hourly cron uniquely covers (#199 round)
The trigger comments, CONSUMERS stub, changelog, and reusable labels.yml
comment said "events carry every real state change in seconds; the cron
only backstops a forgotten handoff." That is backwards: no subscribed
event wakes this sweep for a review verdict landing (no pull_request_review
trigger), blocker:ci-red set/cleared, blocker:conflict when another PR
merges, or the time-based stale/48h-reclaim. The hourly cron is the sole
discovery path for those four classes, not a forgotten-handoff net —
so the comments now name them and warn against deleting the cron (AC5).

codex-2 / kimi-2 (both blocking, round 2 @0a812c4b).
2026-07-28 19:26:40 +00:00
claude-bot-andresmgsl
0a812c4b19 labels: keep edited/reopened on issues; correct 0.3.0 adoption prose (#199 round)
Round fixes on #200.

codex-1 (blocking): the issues narrowing dropped `edited`/`reopened`, but both
carry a queue-state change an event uniquely carries — `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.
Dropping them tripped #199's must-fail. Narrow to
`[opened, closed, edited, reopened]`, dropping only the churn/validation
actions labeled/unlabeled/assigned/unassigned. Trigger tests now pin
edited/reopened present and the four dropped; labels.test.sh exact-list updated.

kimi (blocking): the "supersedes unreleased #144" prose was false — #144's
edited/reopened shipped in 0.3.0. Dissolved: we now keep them. CONSUMERS prose
rewritten to the real version history (0.2.0 #32 / 0.3.0 #144 / #199 narrows),
and the #137 review-request line corrected from "unreleased" to shipped-in-0.3.0.

kimi (non-blocking): reusable labels.yml comment no longer cites */15.

codex-2 (AC1 after-measurement / closing) escalated to triage on #199 — held,
not guessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 19:04:27 +00:00
claude-bot-andresmgsl
155828a069 changelog: group the #199 fragment and split it under the 300-char bound
The tree is grouped (fragments carry ### headings) and changelog-armed
caps each '- ' entry at 300 chars; the single 574-char bullet failed
both. Rewrite as three '### Changed' bullets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:24:23 +00:00
claude-bot-andresmgsl
f8ad0b34c7 test: guard the #199 trigger surface; update #144 parity to [opened, closed]
Add test/labels-triggers.test.sh: reconcile stays cancel-in-progress:
false (the must-fail — true kills a sweep mid-board), the cron is hourly
not */15, each churn action is gone from the issues surface, and the PR
labeled handoff wake survives the issues narrowing. Update the #137/#144
parity block in labels.test.sh to the narrowed [opened, closed] contract
and replace its fragile inline pull_request_target scan (the #199 prose
comments name the trigger keys) with the anchored event_types reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:21:48 +00:00
claude-bot-andresmgsl
12887cc9de docs: narrow CONSUMERS stub issues triggers, relax cron to hourly (#199)
Bring the published caller stub to the #199 shape in lockstep with
self-labels.yml (preserving the stub<->caller parity #144 established):
hourly cron, issues: [opened, closed], per-trigger why-comments. Rewrite
the adoption prose — 0.2.0 shipped the broad set; #199 narrows it at its
pin bump and supersedes the unreleased edited/reopened additions (#144).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:13:15 +00:00
Daniel Marin
0e3d8de2a6
Merge pull request #197 from codex-bot-andresmgsl/build/196-round-log-handoff
docs: make round replies the durable handoff record
2026-07-28 19:11:10 +01:00
claude-bot-andresmgsl
dfafeedeec labels: relax self caller cron to hourly, narrow issues triggers (#199)
The advisory */15 cron fired a full-board sweep four times an hour at
GitHub's 1-minute billing floor; events carry real state changes in
seconds, so the cron only backstops a forgotten handoff. Relax it to
hourly and narrow the issues: trigger to [opened, closed] — the two
actions with promptness-critical reconcile behavior — dropping the
labeled/unlabeled churn source. Per-trigger why-comments added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:10:23 +00:00
codex-bot-andresmgsl
0405d206ae docs: align mechanical handoff wording 2026-07-28 17:57:31 +00:00
codex-bot-andresmgsl
ab77fa93ac docs: relocate handoff summary to round log 2026-07-28 17:04:33 +00:00
Daniel Marin
0a9316f7e1
Merge pull request #195 from codex-bot-andresmgsl/build/193-fleet-roster-audit
docs(fleet): move roster ownership to operator config
2026-07-28 12:08:48 +01:00
codex-bot-andresmgsl
5757e24cd4 docs(fleet): move roster ownership to operator config 2026-07-28 10:48:03 +00:00
Daniel Marin
c96d2d8f4e
Merge pull request #194 from codex-bot-andresmgsl/build/192-reduce-fleet-registry
docs(fleet): reduce registry mechanism to a pinned pointer
2026-07-28 11:18:56 +01:00
codex-bot-andresmgsl
7fc0425184 docs(fleet): reduce registry mechanism to a pinned pointer 2026-07-28 09:58:49 +00:00
Daniel Marin
432dff0613
Merge pull request #191 from dan-claude-bot/build/189-ci-red-deployed
docs(fleet): ci-red is deployed engine — advance the stamp to crew@4da17c4
2026-07-27 23:04:12 +01:00
dan-claude-bot
4f3fc2a20b docs(fleet): reconcile the attention wake too — the stamp covers the whole file
Refs #189. Round 1 on #191 (codex blocking, kimi nit).

codex caught that advancing the stamp to crew@4da17c4 made the attention
section false. It did, and the error is mine in kind, not only in detail: a
reconciliation stamp is a claim about the WHOLE file against that tree, and I
audited only the ci-red surface across a 17-commit advance. That is the #187
failure mode, on the PR that closes #189.

What was false at the pinned SHA, all from the crew#66 ruling (danmt,
2026-07-27) landing in d578150e:

- "One wake is registry-independent, by design: attention" — no wake is
  exempt now. _attention_partition splits rows against the registry; OUT rows
  are reported and never acted on.
- "the assignment is what carries the authorization — there is nothing here
  for a repo list to scope" — this is the position the ruling REJECTED. The
  cost was argued first: a cross-repo handoff now waits on an operator adding
  the repo, which is why an out-of-scope demand also pings the operator over
  the boot-gate channel rather than only reaching duty.log.
- "a fix that bounds this wake to the registry would re-create the #16
  incident" — that fix landed, with the ping as its mitigation.
- The parenthetical calling crew's repos-default.txt header a contradiction
  "raised there as a discussion" — the discussion is crew#66, it was ruled,
  and crew's header now names the attention wake explicitly. This file was
  preserving the losing side of a settled question.

Also in range and owned by this section: d849f166 ledgered the wake, so
"a session that dies before acking is relaunched; that is the whole
crash-recovery story" no longer is. Dying relaunches; COMPLETING without
acking is a decline, and the ledger stops it re-firing until the issue
moves. Meaning, not mechanism, so it belongs here.

Re-audited the rest of the advance rather than spot-fixing: fleet.roster
still declares itself the TARGET environment (the roster paragraph holds),
and the roster/install commits touch role resolution inside crew, which this
file does not describe.

kimi's nit, which the earlier rewrite made mine: the paper inventory counted
one row while the triage-signals bullet marks a second. Both are needs-ruling
rows and both are now named — a number that has to be recounted every time a
wake lands is the thing that went wrong.

18/18 test files; changelog_fragment_problem OK; shellcheck, self-ref and
git diff --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:36:10 +00:00
dan-claude-bot
45934b54a2 docs(fleet): ci-red is deployed engine — advance the stamp to crew@4da17c4
Refs #189 — the post-merge, triage-owned task, now reachable: heavy-duty/crew#64
merged at 4da17c49594c2d86bd3793fa3567846cbca38e90.

#190 wrote the ci-red wake into FLEET.md deliberately marked on paper, per this
file's convention for a spec that is not yet running, and left the reconciliation
stamp at crew@01fb49c because advancing it to a SHA that did not carry the wake
would be the drift #187 exists to remove. crew#64 has merged, so both halves flip:

- The stamp advances to crew@4da17c4, and every crew permalink in the file with
  it. A stamp and its evidence links naming different trees is worse than no
  stamp: the reader diffs the wrong engine and finds no drift because the tree
  they were pointed at is the one the prose was written against.
- The four on-paper markers go: the duty-order caveat, the ci-red bullet's
  parenthetical, the Build bullet's "once ci-red deploys", and the closing
  paper inventory — which now names one remaining paper wake, the notifier's
  needs-ruling queue, not two.

The Build bullet is not a pure marker removal. crew#64's last review round
changed what it has to say: the operator ruled the round gate a whitelist, so
the wake admits a green head OR one with no checks configured, and holds a red
head AND one whose check has not finished. Copying the old "never a round at a
red head" through the flip would have shipped a fresh inaccuracy on the same
commit that claims the file is reconciled. The ci-red bullet gains the matching
sentence from the other side: an unfinished check is not a red head and wakes
nothing, because nothing has failed yet.

Verified at the stamped SHA rather than assumed: duty.sh's header carries
attention → … → resume → ci-red → build (and says it is what this file is
reconciled against), shared/README.md's duty order matches, and notify.sh's
only label filter is still state:needs-human — which is what keeps the
remaining paper claim true.

18/18 test files pass; changelog_fragment_problem OK (it caught a 387-char
entry against the 300 bound, now split); shellcheck, actionlint self-ref and
git diff --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:22:07 +00:00
Daniel Marin
e3820dbf9a
Merge pull request #190 from claude-bot-andresmgsl/build/189-ci-red-doctrine
docs: the check at the head — round precondition, CI-red recovery, and the ci-red wake on paper
2026-07-27 21:25:20 +01:00
claude-bot-andresmgsl
44ff7524af docs(builder): re-request by head, not by verdict — a push stales every approval
The round protocol told the author to re-request exactly the reviewers
who did not approve, but the handoff predicate counts an approval only
at the current head: any push while answering a round staled the
earlier approver's verdict, doctrine said not to re-request them, and
the PR could never converge — the silent-stall shape of #26/#39.
Step 2 now re-requests by head, not by verdict: every panelist after a
push, the non-approvers alone at an unchanged head. Shape 2's wording
is aligned so the two paragraphs agree.

Defect raised by dan-claude-bot on #190; folded in at the operator's
direction while the paragraph is open. Refs #189.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:53:59 +00:00
claude-bot-andresmgsl
e9cf461ed3 docs(builder): avoid 'ledger' for review-verdict state in the red-head rule
Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:47:00 +00:00
claude-bot-andresmgsl
e85a7d42f3 docs(fleet): ci-red in the duty order and wake list, on paper; changelog fragment
FLEET.md gains the ci-red wake between resume and build in crew#64's
engine position, marked on paper per this file's existing convention (the
notifier queue, triage's past-24h wake); the Build bullet records the
red-head exclusion as reported-not-swallowed; the reconciliation stamp
stays at crew@01fb49c because crew#64 has not merged.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:46:10 +00:00
claude-bot-andresmgsl
203907b0ea docs(builder): green check at the head gates both request points; red-head recovery in picking
The round precondition (crew#45) at step 1 and the re-request in step 2,
with the argued exception and its evidence requirement; the ruled
classification (cancelled/stale not green, skipped/neutral green,
operator 2026-07-27); ci-red pickup precedence and crew#17's recovery
path in Picking, with the explicit shape-2 carve-out so a red head never
reads as parked.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:45:03 +00:00
Daniel Marin
a414f3105b
Merge pull request #188 from claude-bot-andresmgsl/build/187-fleet-split
docs: FLEET.md reconciled against crew@01fb49c — registry is the scope
2026-07-27 19:29:15 +01:00
claude-bot-andresmgsl
7a49de7efb docs: FLEET.md reconciled against crew@01fb49c — registry is the scope
The two scope passages said the opposite of merged behaviour: a reviewer's
registry was 'the org itself' and 'no repo filter may gate' a request.
duty-review.sh implements repos.txt as the queue's scope since the
2026-07-25 operator ruling (crew#16) — out-of-scope requests WARN, never
act. The attention wake survives as the one stated registry-independent
exception: the assignment is the authorization (duty-attention.sh).

Mechanism moved to crew: the duty-loop anatomy and resilience prose is now
a pointer to crew's shared/README.md, folding the two drifted facts (cron
runs tick.sh; hygiene self-schedules inside the duty tick). Wake lists
follow the engine's duty order; the roster keeps the as-built bench beside
fleet.roster's target with the delta stated; stamp updated to crew@01fb49c.

Closes #187

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:16:17 +00:00
21 changed files with 682 additions and 280 deletions

View file

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

View file

@ -10,11 +10,14 @@ 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 — 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.
# 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.
#
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
@ -25,7 +28,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.3.0"
CEREMONY_SELF_REF: "0.4.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.3.0"
CEREMONY_SELF_REF: "0.4.0"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:

View file

@ -5,11 +5,40 @@ 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:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
# 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.
issues:
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
types: [opened, closed, edited, 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,6 +11,27 @@ 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
@ -20,9 +41,16 @@ 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 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;
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;
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:*`
@ -53,7 +81,8 @@ 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, or waiting for a good moment. An issue you have
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
simply stopped working on is not parked either — that is abandonment,
and its move is unchanged: unassign and restore `ready` (Claiming,
below).
@ -78,12 +107,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 handoff round
summary plus the `state:needs-human` write *is* its declaration — both
Shape 4 alone is exempt from the separate comment: the factual handoff
comment 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 summary beside it. No second comment is owed
on the issue. Every other shape still declares as above.
`labeled` event with the comment 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
@ -179,9 +208,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: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
(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.)
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
@ -194,9 +223,56 @@ CONTRIBUTING; everything below is the shared flow.)
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, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
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
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
@ -263,14 +339,19 @@ 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) — hand it to the human, in order:
this is a release PR) — the engine performs these mechanical steps on the
builder's behalf, in order:
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
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**.
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 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.

View file

@ -9,6 +9,102 @@ 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,43 +35,9 @@ Who may set which label is [LABELS.md](LABELS.md)'s contract.
## The PR flow
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.
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.
### Roster

312
FLEET.md
View file

@ -1,54 +1,67 @@
# FLEET.md — the roster, and how it actually runs
# FLEET.md — the fleet shape, and how it actually runs
> **Status:** descriptive snapshot, not doctrine. This file records how the
> 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.
> 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.
## The roster
## Fleet shape
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.
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.
| 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
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
construction). Only humans merge — enforced as permissions (the agents team
holds the triage role, not write), not as convention.
## Anatomy of a duty loop
Every box runs the same skeleton, adapted to its CLI:
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.
- **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.
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.
### Wake conditions
@ -62,9 +75,27 @@ 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 reviewer trigger below already records that the index
lags), and like the review-request trigger it reaches repos `~/duty/repos.txt`
does not name.
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.
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
@ -72,9 +103,13 @@ 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; that is the whole crash-recovery story,
and it is the same crash-only shape as resume below.
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.
The design this replaces was built and rejected: polling notifications for
`reason: mention` re-arms a thread on every comment, so ordinary round
@ -85,47 +120,91 @@ 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. 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.
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.
- **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 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.
#### The operator notifier — the `needs-ruling` queue
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
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
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
@ -135,11 +214,12 @@ 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 box-side update:
for the engine-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
`~/duty/repos.txt`.
`notify-repos.txt`, which is deliberately wider than the duty registry:
a cross-repo handoff is precisely what the operator cannot discover alone.
- **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
@ -164,54 +244,46 @@ 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.
`~/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.
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.
### 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.
- `⟲ 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.
- 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). 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.
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.

View file

@ -3,7 +3,8 @@
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 bootstrap dispatch (issue #10).
the labels workflow's dispatch (which is also the operator's manual
full-board reconcile sweep; issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
@ -211,10 +212,13 @@ on a PR would say the same thing twice and drift.
## Maintenance
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
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
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.3.1-dev
0.4.1-dev

View file

@ -1,7 +0,0 @@
### 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).

View file

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

View file

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

View file

@ -1,5 +0,0 @@
### 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).

View file

@ -1,5 +0,0 @@
### 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).

View file

@ -1,10 +0,0 @@
### 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).

View file

@ -1,6 +0,0 @@
### 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,7 +122,8 @@ 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**.
the taxonomy, `release` label included** — and use it again whenever an
operator needs a full-board sweep immediately.
7. **The artifact hook** (optional): `.github/actions/release-artifact/`
per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package.
@ -296,20 +297,39 @@ The complete caller is:
```yaml
name: labels
on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
# 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:
pull_request_target:
# 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.
# 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.
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:
# 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]
# 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]
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
@ -330,23 +350,32 @@ 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. At `0.2.0` the type list stops at `closed`: `edited`
and `reopened` are newer, part of the pending stub edit below.
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.
`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. The pending edits are `review_requested` and
`review_request_removed` on `pull_request_target:` (#137) — the wake that
a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
clears `blocker:unrequested` the moment the panel is asked, without which a
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.
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.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows:
@ -375,11 +404,20 @@ 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. 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. 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.
## Doctrine mirror

50
drills/0.4.0.md Normal file
View file

@ -0,0 +1,50 @@
# 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

@ -0,0 +1,98 @@
#!/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,15 +69,16 @@ 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). 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.
# 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.
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"
}
@ -88,17 +89,20 @@ 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"
# 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 "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes
check "caller and stub issues lists are identical" 0 "" \
types_in_sync issues "$CALLER" "$STUB"
check "the caller still lists all eight issue types" 0 \
"types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \
event_types "$CALLER" issues
check "the caller lists exactly the queue-state-changing issue types" 0 \
"types: [opened, closed, edited, 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"
@ -112,13 +116,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/, edited//' "$CALLER" >"$mut_caller"
sed 's/, closed//' "$CALLER" >"$mut_caller"
check "an issue type dropped from the caller goes red" 1 "" \
types_in_sync issues "$mut_caller" "$STUB"
sed 's/, edited//' "$STUB" >"$mut_stub"
sed 's/, closed//' "$STUB" >"$mut_stub"
check "an issue type dropped from the stub goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub"
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"