Adopt ceremony labels and review machinery #31
17 changed files with 1388 additions and 1 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# AGENTS.md — start here
|
||||
|
||||
You are an agent working in a repo governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||
the router: find your role below, read its file, then act. The role files
|
||||
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||
|
||||
## Your role
|
||||
|
||||
You were told your role when you were pointed at this repo ("you are a
|
||||
reviewer here"). That one word is your whole onboarding:
|
||||
|
||||
| you are the… | read | your job in one line |
|
||||
|---|---|---|
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||
|
||||
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||
are the shared state machine, and misusing one lies to every other agent on
|
||||
the board.
|
||||
|
||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||
asked to implement an issue → builder; asked to process discussions or the
|
||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||
pushing fixes, breaks the separation the pipeline depends on.
|
||||
|
||||
## The pipeline you are part of
|
||||
|
||||
```
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
## Repo specifics
|
||||
|
||||
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||
label set, what a drill means, code conventions — lives in the repo's own
|
||||
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||
292
.ceremony/BUILDER.md
Normal file
292
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# BUILDER.md — the builder role
|
||||
|
||||
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||
so you can succeed without asking anyone anything — if you can't, that is a
|
||||
triage bug, and the move is to say so on the issue, not to guess.
|
||||
|
||||
## Picking
|
||||
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
|
||||
`epic` (epics organize; their children are the work). Inside an epic take
|
||||
the earliest unblocked unclaimed child, otherwise the issue that unblocks
|
||||
the most work; where a repo adopts version epics,
|
||||
[RELEASES.md](RELEASES.md) governs among window members.
|
||||
- **Your own red head outranks a new claim**: repair a failing check at your
|
||||
PR's head before claiming another issue (#163). Red and green here are the
|
||||
review round's ruled terms: cancelled, stale, or unreported — every entry
|
||||
at the head cancelled — is not green; skipped or neutral is. Record the
|
||||
check and its failure class; rerun a clearly retryable infrastructure
|
||||
failure unchanged; treat a branch failure as an ordinary fix round,
|
||||
worklog and all; leave evidence where a rerun cannot start or the cause is
|
||||
unclear; never rerun a deterministic failure without a corrective commit;
|
||||
hand off once green with current-head approvals. Such a PR is **never
|
||||
parked**, whatever the verdict state says; how the engine detects a red
|
||||
head is crew's to describe.
|
||||
- **One build at a time**: one issue on which you are writing or revising a
|
||||
deliverable, finished or released before you start more. The rule counts
|
||||
work in flight, not claims — a **parked** claim, whose next move is
|
||||
someone else's, does not hold the slot. Five shapes park:
|
||||
1. `needs-ruling` is set, the escalation names a decider, and its
|
||||
`Blocked:` line stops the rest;
|
||||
2. a **live** review round holds it, every outstanding verdict someone
|
||||
else's — awaiting first verdicts, or answered whole with the owed
|
||||
re-requests posted, by head and not by verdict (steps 1–2). A red check
|
||||
at the head takes it out of this shape: the next move is yours;
|
||||
3. every remaining acceptance criterion is operator-owned, stated so by
|
||||
triage on the issue;
|
||||
4. it is **handed off** — round passed, no `blocker:*` standing,
|
||||
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
|
||||
and 4 are sequential and never overlap;
|
||||
5. the claim is **held by directive** — triage or the operator stopped the
|
||||
work, named what the hold waits on, and only they end it. A hold ends
|
||||
as it started, **on the labels**: where labels and prose disagree, the
|
||||
most recent queue-label event by the hold's owner governs, and an
|
||||
operator may lift by label alone (#149, #151). So read the label events
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
|
||||
comments, before standing down *or* up, and say in the claim which you
|
||||
read, their timestamps and their actor. Where they do not resolve the
|
||||
contradiction, say so and take the next `ready` issue; refusing is no
|
||||
resting place.
|
||||
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
|
||||
resolves without you), or for a good moment. An issue you stopped working
|
||||
on is abandoned — unassign and restore `ready`. Parked claims are held
|
||||
beside the one active build (#15, #16, #73).
|
||||
|
||||
## Claiming
|
||||
|
||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||
starting. The claim promises a draft PR soon: a claim with no PR and no
|
||||
activity is what the staleness sweep reclaims, unless `offsite` records
|
||||
that its PR lives in another repo.
|
||||
- **A park is declared, never inferred.** Comment naming what the claim
|
||||
waits on and who owns the next move — no new label; the comment is the
|
||||
activity the reclaim clock reads, as for `needs-ruling` (#52) and
|
||||
`offsite` (#68). Shape 4 is exempt: the handoff comment and
|
||||
`state:needs-human` already say both.
|
||||
- **A declaration stands until the park's facts change**, so a resumption
|
||||
finding nothing changed posts nothing (#177). Each change owes one comment
|
||||
— the wait resolves or changes hands, the shape changes, the claim
|
||||
unparks. A parked claim with **no open PR** still feeds the 48-hour
|
||||
reclaim clock, so refresh the declaration before it closes; that is a
|
||||
park's only repeat.
|
||||
- **Pick up `attention` before anything else**: post a short pickup comment
|
||||
and remove the label, which is the ack. A demand on a parked claim is
|
||||
usually its unpark, so take the slot back — unless the demand *is* the
|
||||
park, the pickup comment then doubling as the declaration.
|
||||
- **A directed hold keeps its bookkeeping visible.** The PR carries
|
||||
`blocked` with a comment naming what it waits on; the issue stays
|
||||
`claimed` and carries `attention` until the builder acks. Nobody unassigns
|
||||
it, and the 48-hour reclaim does not fire while the claim has an open PR.
|
||||
- **Unparking is a claim like any other** and takes the slot: if you are
|
||||
active elsewhere, finish or release that work first and say which on both
|
||||
issues. No machinery counts claims per builder, and none should be built
|
||||
expecting this section to have specified one.
|
||||
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
|
||||
branch if it holds anything useful, unassign, restore `ready`.
|
||||
|
||||
## Building
|
||||
|
||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||
body. Drafts are invisible to the panel on purpose: that phase is yours.
|
||||
- **`Closes #N` does not cross repos.** A PR in a different repo from its
|
||||
issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
|
||||
draft link on that issue in the same step; triage closes that issue by
|
||||
hand once its criteria are met, the builder reporting there whether the PR
|
||||
merged or closed and clearing `offsite` in the same comment. The
|
||||
cross-repo merge never closes the authorizing issue (#13, #16).
|
||||
- **`Closes #N` does not survive a post-merge criterion.** Where the issue
|
||||
body says a criterion can only be checked after the merge — a workflow
|
||||
trigger proved live, a released artifact, anything whose subject does not
|
||||
exist until the change is on the base branch — the same-repo PR says
|
||||
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
|
||||
away, and triage owns verification and closure on the evidence, returning
|
||||
the issue to `ready` or minting a fresh one where corrective work is
|
||||
needed — claimable by any builder from current `main`, the original having
|
||||
no special standing. The issue body says so — you never judge which
|
||||
qualify — and absent it `Closes #N` is the default (#151).
|
||||
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
|
||||
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
|
||||
immediately before `#N` anywhere in the body, including the sentence
|
||||
explaining why the PR does not close it: GitHub reads the body by
|
||||
adjacency, not intent, and a code span does not protect the phrase (#200,
|
||||
#218). Put the number first (`#N is closed by hand`) or omit it.
|
||||
- **The issue's acceptance criteria are your definition of done**: reproduce
|
||||
them as a checklist in the PR body and check them honestly. One that turns
|
||||
out wrong or unreachable goes back to triage to be amended, never silently
|
||||
shipped short.
|
||||
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
|
||||
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
|
||||
prose to be published and nothing else — `- ` bullets, plus in a grouped
|
||||
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
|
||||
where a change genuinely is one. An entry is at most 300 characters, so a
|
||||
long change ships several short ones (wrapping over continuation lines is
|
||||
free), and it **ends with its issue citation**: a parenthesised group of
|
||||
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
|
||||
nothing after — `(#262).`, `(#236, #250).` — which need not name the
|
||||
fragment's own issue, the filename carrying it. The guard reds a long
|
||||
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
|
||||
release PR assembles the section from fragments (#112), and the monotonic
|
||||
guard refuses anything deleting a shipped heading.
|
||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||
not optional: the issue's test plan is the floor, not the ceiling.
|
||||
- **A write-capable job gets a repo-owned script, not a third-party
|
||||
action.** Where the token can write (`packages: write`, `contents: write`,
|
||||
`id-token: write`, deploy secrets), default to a script a test can drive;
|
||||
a third-party action there needs an established publisher and a
|
||||
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
|
||||
its red-flag profile are in REVIEWER.md §What you review against, item 2
|
||||
(#216).
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems go to a discussion, or a comment on the relevant issue;
|
||||
you do not mint issues — nobody but triage does — and you do not fix
|
||||
drive-by findings in the same PR.
|
||||
|
||||
## The review round
|
||||
|
||||
(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
|
||||
such as the panel roster live in that repo's own CONTRIBUTING.)
|
||||
|
||||
1. Mark ready-for-review; request **the whole panel**: the PR repo's
|
||||
`panel[<your-login>]=` line if it defines one, else its `panel=` line,
|
||||
minus the author (#224) — never the roster of the repo the issue is in.
|
||||
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
|
||||
being what the state machine reads; where it names no roster, ask triage
|
||||
on the authorizing issue rather than guess. An off-panel reviewer may be
|
||||
requested, said to be advisory and not required.
|
||||
|
||||
**A review request requires a green check at the head**, whether or not
|
||||
an engine enforces it: a red check is the author's own signal, so fix it
|
||||
and push, then request. The one exception is a failure genuinely outside
|
||||
the PR — a runner outage, a flaky dependency, a failure already on the
|
||||
default branch — and only where the request says so and names the
|
||||
evidence ("the same job fails identically on `origin/main` at `<sha>`");
|
||||
silence about a red check is what is prohibited, and an argued exception
|
||||
shifts the burden to the author.
|
||||
|
||||
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
|
||||
**First take the check's word at this head**: its newest entry by start
|
||||
time — not completion, a cancelled run outliving its replacement's start
|
||||
— and never a `CANCELLED` entry while the same check has a non-cancelled
|
||||
one there. A check whose entries at the head are all cancelled has not
|
||||
reported at all and is not green — a collapse, not a new class, and the
|
||||
gate partitions alike, dropping a cancelled entry only where a
|
||||
non-cancelled survivor remains and leaving an all-cancelled context
|
||||
blocking (#139, #276). **Then classify that entry by `conclusion`, never
|
||||
`status`**, which can disagree with it (#259). No conclusion is not
|
||||
green: a configured run in progress is waited on, and waiting is
|
||||
compliance, not a stall. Cancelled or stale is not green, *stale* being a
|
||||
superseded head's check, which a head-scoped rollup never shows. Skipped
|
||||
or neutral is green, those being deliberate "passed / not applicable"
|
||||
conclusions. No checks configured is green — the third ruled case, not an
|
||||
argued exception, so the request goes out at once with no evidence owed;
|
||||
that never covers nothing-answered-yet, and the machine partitions alike,
|
||||
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
|
||||
line are asymmetric: a false green spends a three-reviewer round, a false
|
||||
red one author session. What the machine drops from the rollup before
|
||||
grading is crew's to describe.
|
||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||
covering every point, stating what changed and what was verified. That
|
||||
reply is the written record: the engine mirrors it under the PR body's
|
||||
**Round log**, newest last and marked with the round's head, which makes
|
||||
a retry a no-op; you owe the reply and no body edit, and a round answered
|
||||
without one is recorded as such and never blocks handoff. Then push the
|
||||
fixes and re-request **by head, not by verdict**. A push makes every
|
||||
approval 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, approvers included**; one left un-re-requested can never
|
||||
approve the tree you shipped (#26, #39). Only where the head did not move
|
||||
— answered with argument or evidence, nothing pushed — do you re-request
|
||||
just the non-approvers; the engine absorbs a re-request at an unchanged
|
||||
head, and its mechanism is crew's to describe (#94). **The re-request
|
||||
carries the same green-check-at-head precondition**, argued exception
|
||||
included: a fix push whose check comes up red is your next fix, not the
|
||||
panel's. Prefer verification over argument — add the test that settles
|
||||
the doubt.
|
||||
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
|
||||
in the PR; silence and force-forward are not options, and a panel
|
||||
deadlock is one kind of human-owned decision (#50 D11).
|
||||
|
||||
**A fix round may ride a draft**, and the draft changes nothing about who
|
||||
owes what: a mid-round draft reads as a draft always read — the phase is
|
||||
yours, the panel cannot see it — while the round outranks it, so you owe the
|
||||
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
|
||||
`state:building` row, #205). **Ready-for-review is the act that ends the
|
||||
round, and it is the builder's alone**: the flip asserts the round was
|
||||
answered whole, the one judgement its author cannot delegate, so an engine
|
||||
may draft a PR but only the builder undrafts it. **Where a draft suppressed
|
||||
the checks, green is proven at the flip and the request still follows it** —
|
||||
marking ready runs the checks the draft held back, so the order is flip, let
|
||||
the head answer, then request, step 1's precondition and not a second one.
|
||||
Waiting there is compliance, and `blocker:unrequested` does not fire while a
|
||||
head's checks are pending or red (#236).
|
||||
|
||||
## The ruling ask
|
||||
|
||||
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
||||
published artifacts, secrets, prod, or any choice whose cost lands outside
|
||||
the PR — a panel deadlock is one instance, not the definition. The builder
|
||||
is the PR's accountable flag-setter and consolidates the decision into one
|
||||
comment rather than forwarding several reviewers' phrasings (#50 D11).
|
||||
|
||||
Keep at most these five lines above the fold, all other analysis inside it.
|
||||
The field labels are fixed because the ruling machinery checks for them (#50
|
||||
D12):
|
||||
|
||||
```text
|
||||
🧭 needs-ruling — <the decision, one line>
|
||||
Options: A — <one clause> B — <one clause>
|
||||
Recommend: A, because <one clause>.
|
||||
Blocked: <what stops; what continues meanwhile>
|
||||
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
|
||||
<details><summary>Analysis</summary>…everything else…</details>
|
||||
```
|
||||
|
||||
The options must be exhaustive and mutually exclusive; more than three means
|
||||
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
||||
whole problem to the human. `Blocked:` names both what stops and what
|
||||
continues. Write a timed `Default:` only when affirmatively confident the
|
||||
decision is reversible inside the PR before merge; unsure is not a tie but a
|
||||
hard block, as published artifacts, secrets, prod and org policy are by
|
||||
construction (#50 D12–D13).
|
||||
|
||||
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
||||
event**, not its `Default:` deadline or the last activity (#50 D13–D14):
|
||||
|
||||
- **0–12h:** proceed when a still-clear, reversible default expires, saying
|
||||
out loud that you did; a hard block waits.
|
||||
- **at 12h:** do not fire a stale default — re-read it against what has
|
||||
landed, and where doubt has appeared, make it a hard block.
|
||||
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
|
||||
body which way you went and what doubt remains. Nothing merges by this;
|
||||
the human still gates the merge.
|
||||
- **past 24h:** hand the choice to triage, which picks the option, records
|
||||
it as a decision, and stays accountable; the operator can overturn it at
|
||||
merge.
|
||||
|
||||
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
|
||||
hard block included, and an active back-and-forth still climbs it — unlike
|
||||
the 7-day nudge, which resets on real activity. The machine observes both
|
||||
clocks but never sets, clears, or decides `needs-ruling`. The label stays
|
||||
until agreement is *reached*, not until the maintainer replies: the setter
|
||||
records the ruling, removes the label, and returns the item to its flow in
|
||||
the same comment ([LABELS.md](LABELS.md)).
|
||||
|
||||
## Handoff
|
||||
|
||||
When the round passes — every panel verdict approving the **current head**,
|
||||
no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
|
||||
is a release PR) — the engine does these steps for the builder, 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**.
|
||||
|
||||
The builder composes no new summary: the authored record already lives in
|
||||
the Round log, mirrored from each whole-round reply. The label write is
|
||||
optimistic — the reconciler validates it and takes it back if the PR is not
|
||||
mergeable-right-now. Then stop: the PR is the human's, and the claim parks
|
||||
as shape 4 (Picking, above), that comment its declaration and your slot
|
||||
free. Address what comes back (`state:addressing`) and re-hand-off the same
|
||||
way.
|
||||
243
.ceremony/LABELS.md
Normal file
243
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# Labels
|
||||
|
||||
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).
|
||||
|
||||
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||
triage → build queue, reconciled by the work-queue sweep). One rule joins
|
||||
everything: **states are machine-owned, intent
|
||||
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||
and the reconciler recomputes it from GitHub's own facts.
|
||||
|
||||
## PR state — who is the ball with? (exactly one per open PR)
|
||||
|
||||
| Label | Color | Waiting on |
|
||||
|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
|
||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||
|
||||
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||
so it requires zero blockers and head-current approvals; anything less and
|
||||
the reconciler takes it back. The author sets it at handoff (the one
|
||||
hand-set state); the `labeled` event fires the sweep that validates the
|
||||
write within seconds.
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
| Label | Color | Means |
|
||||
|---|---|---|
|
||||
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||
|
||||
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||
separate axes because the single-label version kept lying — independent facts
|
||||
projected onto one totally-ordered label meant one always won and the losers
|
||||
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||
strips it on sight).
|
||||
|
||||
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
|
||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||
|
||||
The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||
`blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
|
||||
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
||||
comments, unassigns the stale owner, and restores `ready`.
|
||||
|
||||
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
|
||||
criteria remain, the sweep moves the issue to `post-merge`, clears the
|
||||
assignee, and comments with the remaining criteria verbatim. The comment says
|
||||
that the claim is released and that triage owes a follow-up naming the owner
|
||||
and wake condition for completion. Triage writes that full transition comment
|
||||
in the same tick when it or the operator makes the move by hand. The sweep
|
||||
never reclaims `post-merge`: weeks of quiet can be the state working. It does
|
||||
make the quiet visible — after 7 days with no comment on the issue, the sweep
|
||||
posts one nudge naming the triage actor, saying the wake evidence is owed and
|
||||
linking the item. Only a comment resets that clock: label churn does not, and
|
||||
neither does an assignment, which is the claim clock's fact and on this queue
|
||||
state is the invalid composition flagged below. Which criterion starved is
|
||||
prose the machine never judges; the link is the payload. Like the ruling nudge
|
||||
it carries no idempotency marker on purpose — the comment is itself activity,
|
||||
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
|
||||
label.
|
||||
|
||||
`post-merge` never composes with `blocked`; the transition comment carries the
|
||||
wait. It never composes with `attention`, because releasing the claim clears
|
||||
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
|
||||
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
|
||||
still composes. When the remainder becomes buildable, triage moves
|
||||
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
|
||||
that work from current `main`; the original builder has no special standing,
|
||||
and re-entry does not set `attention`.
|
||||
|
||||
## Cross-cutting (PRs and issues)
|
||||
|
||||
| Label | Color | Meaning |
|
||||
|---|---|---|
|
||||
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) |
|
||||
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
|
||||
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
|
||||
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
|
||||
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||
|
||||
`needs-ruling` marks where the human's turn is when the pending thing is a
|
||||
*decision*, not a merge ([#50 D1–D14](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
It applies to any human-owned decision — org policy, published artifacts,
|
||||
secrets, prod, or any choice whose cost lands outside the work. A panel
|
||||
deadlock is one instance, not the definition (D11). It is not
|
||||
`state:needs-human`: that label means exactly "this PR could be merged right
|
||||
now", and the retired `state:needs-rebase` is the family's proof that a
|
||||
label meaning two things lies about both. It is not a `blocker:*` either:
|
||||
every blocker names work the *builder* owes, a ruling is owed by the human —
|
||||
and the flag must live on issues too, where blockers do not exist. On issues
|
||||
it coexists with the queue labels (the one-of-three invariant above ignores
|
||||
it); its color is the light shade of `state:needs-human`'s, so the human
|
||||
axis reads as one family. It is a state, not a signal: set only with the
|
||||
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
|
||||
flag is noise. The comment carries exhaustive, mutually exclusive options
|
||||
(at most three), a mandatory recommendation, what stops and what continues,
|
||||
and either a default affirmatively known to be reversible inside the PR or
|
||||
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
|
||||
and org policy are hard blocks by construction (D13).
|
||||
|
||||
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
|
||||
event** (D13–D14):
|
||||
|
||||
- **0–12h:** a clear, reversible decision may proceed when its stated default
|
||||
expires, saying out loud that it did; anything with reasonable doubt waits
|
||||
as a hard block.
|
||||
- **at 12h:** the setter re-reads the default against what has landed and asks
|
||||
whether it still holds and whether doubt remains. A stale default does not
|
||||
fire; new doubt makes it a hard block.
|
||||
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
|
||||
chosen and the doubt that remains. Nothing merges by this; the human still
|
||||
gates the merge.
|
||||
- **past 24h:** triage picks the option, records it as a decision, and remains
|
||||
accountable. The operator may overturn it at merge.
|
||||
|
||||
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
|
||||
including a hard block. Active discussion still climbs the ladder; by
|
||||
contrast, the separate 7-day nudge resets on real activity. The machine
|
||||
observes the rungs but never sets, clears, or decides `needs-ruling`.
|
||||
|
||||
The flag stays up until agreement is *reached* — a human reply alone does not
|
||||
clear it — and its setter closes it out: records the ruling as a decision in
|
||||
one comment, removes the label, and returns the item to its flow in that same
|
||||
comment, never as a side effect. If the human disagrees that agreement was
|
||||
reached, the label goes back on. The reconciler refuses `state:needs-human`
|
||||
while it stands (the PR falls to `state:addressing` — the ball on the PR is
|
||||
the builder's, who carries the ruling in), and the staleness sweep skips it,
|
||||
because waiting on a human is legitimately quiet. Quiet, but not unwatched
|
||||
(#52, both surfaces): a flag set with no escalation comment from its setter
|
||||
is called out by the sweep — comment-only, scoped to the labeled event, the
|
||||
label never removed — and a ruling with no real activity for 7 days draws a
|
||||
comment-only nudge addressed to the decider, linking the escalation. The
|
||||
nudge carries no marker on purpose: the comment is itself activity, so it
|
||||
resets its own window and never repeats within a quiet week. Label churn is
|
||||
never activity, or the sweep would reset itself — and each surface's clock
|
||||
reads what exists on it: on a pull request, comments, reviews and commits;
|
||||
on an issue, comments alone. An assignment is the claim clock's fact, not
|
||||
the ruling's — claiming a flagged issue does not answer it, and buys the
|
||||
escalation no quiet (#284).
|
||||
|
||||
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
||||
in another repository, where a closing reference cannot make a local open PR
|
||||
visible to the sweep (#68). The builder sets it in the same step that posts
|
||||
the cross-repo draft link, then clears it at handoff in the same comment that
|
||||
reports whether that PR merged or closed. The machine reads the flag and
|
||||
never writes it. It stops only the claim-reclaim clock: missing assignees are
|
||||
still flagged, queue-label conflicts and missing queue state are still
|
||||
repaired, and epic-completion and PR-side stale behavior are unchanged. The
|
||||
sweep tells the assignee once when every visible cross-referenced PR has
|
||||
closed; it only tells, and never clears the flag or changes the claim.
|
||||
|
||||
`attention` is issue-only and says a demand is parked on an issue for its
|
||||
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
|
||||
sibling agent — sets it. The assignee alone clears it, as the first act of
|
||||
pickup together with a short comment; that removal is the acknowledgement
|
||||
and re-arms the flag for the next demand. If the session dies before the ack,
|
||||
the still-visible flag launches the next pickup instead. An unanswered flag
|
||||
is auditable evidence on the board.
|
||||
|
||||
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
|
||||
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
||||
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
||||
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||
take. It is hand-set: the machine never sets `attention`, never assigns
|
||||
anyone to receive one, and never decides that one has been answered — the
|
||||
assignee's removal is the only ack. It writes the label in exactly one
|
||||
place, the derived `claimed` → `post-merge` transition below, and nowhere
|
||||
else; where it reads the flag it reads it to diagnose. The PR sweep comments
|
||||
when `attention` is put on a pull request, and the issue sweep comments when
|
||||
it is put on an issue with no assignee. Both diagnoses leave the label and
|
||||
assignees alone; the machine never infers the claim issue, decides that the
|
||||
demand was answered, or repairs either malformed shape.
|
||||
An `attention` issue without an assignee is therefore a board bug, not a
|
||||
demand; anyone may assign it or remove the flag. It never composes with
|
||||
`post-merge`, whose released claim has no assignee to answer the demand. The
|
||||
one machine-clear exception is the derived `claimed` → `post-merge`
|
||||
transition: releasing the assignee clears a carried `attention` in the same
|
||||
edit. A hand-created `post-merge` + `attention` composition is flagged, not
|
||||
rewritten.
|
||||
|
||||
The three signals are mutually distinct: `attention` means an assignee owes
|
||||
a move; `needs-ruling` means a human owes a decision under
|
||||
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
|
||||
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
|
||||
demand that is itself a human decision carries `needs-ruling`, never both.
|
||||
This distinction records the
|
||||
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
|
||||
and why the rejected mention poll is not returning: ordinary thread traffic
|
||||
re-arms mentions, but only the writer can declare that a move is owed (#83).
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||
set is per-repo: PRs get theirs from changed paths via the labels workflow's
|
||||
scope job — an additive write only, so a label applied by hand or by an agent
|
||||
while the machine runs always survives it (#130) — and issues get theirs from
|
||||
triage. This file never enumerates a set — it is mirrored
|
||||
byte-identically into every governed repo, and any list it carried would be
|
||||
true in one repo and false in the rest (#104). The set for the repo you are
|
||||
standing in lives in the two places that are true wherever you read them: its
|
||||
`.github/labels.conf` (the definitions, one `name|color|description` row per
|
||||
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
|
||||
|
||||
## Issue types
|
||||
|
||||
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||
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
|
||||
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.
|
||||
13
.ceremony/README.md
Normal file
13
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# .ceremony/ — the vendored doctrine mirror
|
||||
|
||||
Never edit these files ad hoc. The six manifest-listed doctrine files are
|
||||
byte-identical copies of
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at 0.6.1, but
|
||||
stoke does not run `docs-sync` or re-diff the mirror in CI. Change doctrine
|
||||
upstream through its own flow, then re-vendor it here when the pin moves.
|
||||
|
||||
The labels doctrine is vendored manually from heavy-duty/ceremony at 0.6.1
|
||||
because stoke keeps its own tag-driven `.forgejo/workflows/release.yml`.
|
||||
`docs-sync --fix` cannot run until or unless a future issue adopts the
|
||||
ceremony release-workflow pin; until then, doctrine updates must re-vendor
|
||||
the pinned manifest manually.
|
||||
125
.ceremony/RELEASES.md
Normal file
125
.ceremony/RELEASES.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Release management
|
||||
|
||||
This file describes the release-management pattern available to governed
|
||||
repositories. Adoption is per repository and operator-ruled: a repository
|
||||
without version epics is not out of compliance. A repo-local roadmap is the
|
||||
map; each epic remains the source of truth for its own release. Where an older
|
||||
repo-local description differs from this file, this file governs.
|
||||
|
||||
## The ladder
|
||||
|
||||
Represent each planned release with one version epic. The epic is the working
|
||||
surface for that release: it states the goal, names the members, and records
|
||||
the ordered waves as checklists. Keep the machine-readable progress checklist
|
||||
under a heading matching `## Task list`, case-insensitively; the issue-flow
|
||||
sweep reads task rows there until the next heading when it decides whether to
|
||||
nudge triage about a completed epic. Other member or wave headings are not
|
||||
completion inputs.
|
||||
|
||||
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
|
||||
ladder and points to each working surface; it does not duplicate the live
|
||||
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
|
||||
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
|
||||
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
|
||||
|
||||
## Gates
|
||||
|
||||
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
|
||||
double gate or an out-of-chain gate — is written explicitly on that epic;
|
||||
there is no hidden global schedule. The epic carries `epic` and the
|
||||
repository's release label, with no queue label. Its `Blocked by` line is a
|
||||
declaration a human reads: shipping closes the predecessor, then triage opens
|
||||
the next window by hand as the first step of release-init. The issue-flow
|
||||
sweep does not promote version epics; automating that gate would require a
|
||||
separately specified change to its queue-category model.
|
||||
|
||||
The gate orders windows, not their contents. Members enter a release only by
|
||||
decision during release-init. The double gate on
|
||||
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
|
||||
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
are worked examples of exceptions declared where they apply.
|
||||
|
||||
## Release-init
|
||||
|
||||
The predecessor closing and clearing the next epic's declared gate is the
|
||||
trigger, and today triage must notice it and open that window by hand.
|
||||
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
|
||||
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
|
||||
announcement as present until the consumer's pin carries it. Triage runs five
|
||||
steps:
|
||||
|
||||
1. Mint the epic's “to mint when this arc opens” list together with findings,
|
||||
deferred work, and discussion outcomes accumulated since the epic was
|
||||
written. Each member initially declares `Blocked by <the epic>`.
|
||||
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
|
||||
3. Write the waves into the epic body as checklists in claim order, with a
|
||||
separate verification lane and the progress view under `## Task list`.
|
||||
4. Ask the operator to bless the order, then have triage open the first wave
|
||||
by applying the flip mechanics below. The operator's blessing is the one
|
||||
step this chain never automates.
|
||||
5. Ship through the repository's cut process, close the epic, and treat that
|
||||
close as the trigger for the next window.
|
||||
|
||||
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
|
||||
and shared-file contention visible before builders entered the queue. If init
|
||||
finds no work worth minting, the operator either folds the empty window into a
|
||||
later release or skips the version, recording that ruling on the epic before
|
||||
closing it unshipped.
|
||||
|
||||
## One primary window, declared parallel tracks
|
||||
|
||||
Run one primary release window by default. A cut takes whatever has landed, so
|
||||
interleaving unrelated windows blurs both the release story and the evidence
|
||||
behind it. Gates open windows; they do not silently admit members, so builders
|
||||
still see one deliberately ordered queue.
|
||||
|
||||
While a window stands — an open release-labeled issue with a non-empty
|
||||
enumerated gate — its members form a DAG whose sink is the release issue.
|
||||
Every member reaches that sink. Members declare only their immediate
|
||||
predecessors; ordering edges live on members, while the sink records membership
|
||||
only; and the `ready` set is exactly the graph's current sources. Every close
|
||||
releases exactly its declared successors, and that whole set is concurrently
|
||||
claimable: a member may have multiple successors, while the collision rule
|
||||
already orders any that share a deliverable. Insertion re-points downstream
|
||||
edges rather than merely appending membership at the sink. It follows that
|
||||
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt
|
||||
because neither is claimable (#292).
|
||||
|
||||
The operator may declare a parallel track at init when its footprint is
|
||||
disjoint from the primary window: another repository, another artifact, or
|
||||
provably non-overlapping clusters. The declaration names the boundary and any
|
||||
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
|
||||
is the worked example: its app and artifact form a parallel track while its
|
||||
small crew-side bridge remains in the primary window.
|
||||
|
||||
## Flip mechanics
|
||||
|
||||
To admit a member, delete or rewrite its literal, parseable
|
||||
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
|
||||
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
|
||||
the raw marker text and still returns the reference. Never preserve history by
|
||||
negating the marker phrase — the parser unions declarations even when prose
|
||||
says they no longer apply. Preserve the history only after rewriting the
|
||||
marker into non-parseable prose, then verify that the parser returns an empty
|
||||
set for the release gate.
|
||||
|
||||
Release membership is a decision, never a sweep default. Triage performs each
|
||||
flip only after the operator blesses the wave; the issue-flow sweep may resolve
|
||||
ordinary issue dependencies, but it does not choose a release's contents.
|
||||
heavy-duty/crew#346 records the member-by-member flip that opened its first
|
||||
wave.
|
||||
|
||||
## The ledger pattern
|
||||
|
||||
When a release epic has become too crufty to remain a clear working surface,
|
||||
create a replacement and treat the old epic as a ledger. Do not close the old
|
||||
epic until every live member declaration points at the replacement and the
|
||||
blocker parser verifies the new set. Closing early can release every member
|
||||
that still names the old issue.
|
||||
|
||||
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
|
||||
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
|
||||
transition is the worked example: all member declarations were re-pointed and
|
||||
parse-verified before #162 closed; #162 remains the historical record while
|
||||
#346 is the release's working surface.
|
||||
164
.ceremony/REVIEWER.md
Normal file
164
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# REVIEWER.md — the reviewer role
|
||||
|
||||
You are one voice on a panel. The panel's job is to converge — on an
|
||||
approval the human can trust, or on a precise statement of what is wrong.
|
||||
The machine reads only your **verdict**; humans read your reasons.
|
||||
|
||||
## The verdict doctrine
|
||||
|
||||
- **Every review ends in a verdict**: approve, or request changes. A
|
||||
comment-only review is a non-verdict — it does not say whether the round
|
||||
passed, the state machine treats it as not-approved, and the PR simply
|
||||
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||
only wedges the flow.
|
||||
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||
their discretion. Anything blocking — including a question whose answer
|
||||
gates your approval — is **request changes**, saying exactly what
|
||||
unblocks it.
|
||||
- **Name what you could not verify, in the verdict body.** Say which checks
|
||||
you could not run and why, and what you relied on instead: CI, reading, or
|
||||
a narrower probe. An unstated environment gap reads as coverage — exactly
|
||||
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
|
||||
identified for boxes without `node` or `shellcheck`.
|
||||
- An approval you would not defend to the human is a defect. You are not
|
||||
being asked to be agreeable; you are being asked to be right.
|
||||
|
||||
## What you review against
|
||||
|
||||
In order of authority:
|
||||
|
||||
1. **The issue's acceptance criteria** — the PR's `Closes #N`, its
|
||||
cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
|
||||
body marks a criterion post-merge, names your spec. That last shape is
|
||||
not a defect: the issue directs it, triage owns that close, and a
|
||||
request-changes on the "missing" keyword enforces the bug the shape
|
||||
exists to fix — `Closes #137` closed its issue with a post-merge
|
||||
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
|
||||
keyword immediately precedes `#N` anywhere in the body, even in prose
|
||||
explaining the hand close or inside a code span: GitHub used those exact
|
||||
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
|
||||
number first (`#N is closed by hand`) or omit it (`triage closes the issue
|
||||
by hand`). Check every
|
||||
criterion; a PR that ships less than the issue says is a request-changes
|
||||
even if the code is beautiful.
|
||||
2. **The repo's load-bearing constraints** — the rules bought with
|
||||
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||
"simplifies away" a constraint gets request-changes with a link to the
|
||||
incident that made the rule.
|
||||
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
|
||||
option, trigger, config key, and unmarked documentation claim must exist
|
||||
at that ref; run the pinned tool against the proposed config or read the
|
||||
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
|
||||
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
||||
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
||||
nothing about the new config: the base branch's workflow is what ran.
|
||||
- **Third-party actions never hold a write-capable token by default.** In
|
||||
any job whose token is write-capable (`packages: write`,
|
||||
`contents: write`, `id-token: write`, or one carrying deploy secrets),
|
||||
the default is a repo-owned script a test can drive. A third-party
|
||||
action may hold that token only if it comes from an **established
|
||||
publisher** — a real organization with maintenance history and more
|
||||
than one maintainer, not a memberless shell or a lone account shipping
|
||||
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
|
||||
action matching the incubator red-flag profile never holds a write
|
||||
token, however well it works. Read-only jobs: ordinary dependency
|
||||
judgement, SHA-pinning still required. This is bot-run infrastructure —
|
||||
no human watches runtime logs, so a compromised action's window is
|
||||
unbounded (incubator#53/#54; #216).
|
||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||
floor exist? do the failure cases actually fail?), then conventions.
|
||||
Changelog line present for behavior changes; comments carry why, not
|
||||
what.
|
||||
|
||||
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||
test settles what a comment thread can't. A review that says "I ran X and
|
||||
saw Y" outranks one that says "this looks like it might".
|
||||
|
||||
## Where you review
|
||||
|
||||
- **A review request on you is your authorization** in any `heavy-duty` repo
|
||||
and on any fleet member's fork. You need no separate permission and do not
|
||||
wait for the repo to appear on a list: review is reversible
|
||||
read-plus-comment work, and the requester already decided it should happen.
|
||||
- **A request is authorization, not panel membership.** Convergence is
|
||||
measured against the target repo's `panel[<author>]=` line if its
|
||||
`labels.conf` defines one for the PR author, else its `panel=` line; minus
|
||||
the author in either case (#224). If you
|
||||
are requested off-panel, post the verdict anyway and say in its body that
|
||||
it is advisory; neither your silence nor your request-changes is a gate the
|
||||
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
||||
rig#112 showed why authorization and membership must not be conflated.
|
||||
- **Being requested is a wake condition of its own.** It is how work in a
|
||||
repo you have never heard of reaches you; a repo list finds only work in
|
||||
repos somebody thought to list.
|
||||
|
||||
## How you work the queue
|
||||
|
||||
- **Your queue is the API, not the search index.** Enumerate
|
||||
`requested_reviewers` from the pulls API, your reviews from
|
||||
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
|
||||
cast#143, incubator#25, and box#164 waiting, as Claude's
|
||||
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
|
||||
records: search is only a backstop that adds candidates, never evidence of
|
||||
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
|
||||
shows what you owe now.
|
||||
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
|
||||
read and verify immediately around the mutation; a session-start check is
|
||||
insufficient. If verification says it landed, stop even when the CLI
|
||||
looked unhappy. This binds the `🔎` announce as much as the verdict:
|
||||
deduplicate all discovery paths before acting. Duplicate verdicts on
|
||||
[#26](https://github.com/heavy-duty/ceremony/pull/26),
|
||||
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
|
||||
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
|
||||
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
|
||||
the rule; do not answer a double-post with a third comment.
|
||||
- **Review each head in a throwaway checkout; keep the main clone clean.**
|
||||
Use a detached worktree per PR head and remove it after the verdict. A
|
||||
crashed build corrupted Claude's build clone in 2026-07-22
|
||||
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
|
||||
running another tree in the clone you keep risks the whole box.
|
||||
|
||||
## What you do not do
|
||||
|
||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||
above it, in a discussion where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
discussion, while still reviewing the implementation against the spec as
|
||||
written. Spec changes go through triage, not through a review round.
|
||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||
human; only humans merge.
|
||||
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||
builder pushes after your approval, GitHub stales it — that is correct,
|
||||
and the builder owes a re-request, not an assumption.
|
||||
|
||||
## The round rhythm
|
||||
|
||||
- Review the **whole PR at the current head** each round, not just the diff
|
||||
since your last comments — the fix for someone else's point can break
|
||||
yours.
|
||||
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||
reviews mid-address just churn the target).
|
||||
- A **draft carrying `state:addressing` is a fix round in progress**, not
|
||||
abandonment: an engine may convert a PR back to draft at round close so the
|
||||
builder's mid-round saves stop firing CI, and the flip back to ready is the
|
||||
builder's own act announcing the round is answered
|
||||
([BUILDER.md](BUILDER.md#the-review-round)).
|
||||
- Convergence = every panel verdict approves the current head, no
|
||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||
the panel's job is done.
|
||||
- Flag an unowned decision when it belongs to a human: org policy, published
|
||||
artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
|
||||
disagreement within the panel is one instance, not the definition
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
|
||||
panel disagreement in the PR with evidence until one side concedes or the
|
||||
builder escalates; two reviewers pulling a builder in opposite directions
|
||||
without resolution is a panel failure, not a builder failure.
|
||||
`needs-ruling` is set by the **builder**, never by you: one accountable
|
||||
flag-setter per PR hands the human one consolidated question. State the
|
||||
unowned decision precisely enough for the builder to write
|
||||
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
|
||||
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
|
||||
[LABELS.md](LABELS.md)).
|
||||
160
.ceremony/TRIAGE.md
Normal file
160
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door issues come through. Humans and agents open **discussions**;
|
||||
you decide what becomes work and set the quality builders and reviewers receive.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Discussions may be ambiguous; issues may not: a builder must be able to execute
|
||||
one **without asking anything**. One accountable role keeps builders from guessing.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance back into a discussion and close it, saying why. Route the work
|
||||
without shaming the filer.
|
||||
|
||||
## For each discussion, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or tracked. Link the code, doc, or issue; mark answered.
|
||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||
answers would let you write the issue — then stop and wait. Do not mint an
|
||||
issue that carries the ambiguity forward; that just moves your job onto
|
||||
the builder.
|
||||
3. **Escalate.** The pending thing is a decision only a human owns — org
|
||||
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
||||
outside the work. A panel deadlock is one instance, not the definition
|
||||
(#50 D11). Say precisely what the decision is, name the decider, and use
|
||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||
including its options, recommendation, blocked/continues statement, and
|
||||
reversible-only default rules (#50 D12–D13).
|
||||
The discussion is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||
shows where the human's turn is; the issue keeps its queue label.
|
||||
When you direct a builder to hold a claim, say the claim is **parked**,
|
||||
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||
on the board — the directive and the builder's doctrine
|
||||
([BUILDER.md](BUILDER.md#claiming)) must use one word.
|
||||
Immediately before asserting label-borne state in prose — a hold, a
|
||||
claim, a queue state, whether in a comment, a body header, or a
|
||||
`needs-ruling` ask — re-read that issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
||||
comments: the answer often arrives as a label with no comment, and a
|
||||
write that re-read only the thread races it (#149, #151).
|
||||
Past 24 hours from the current episode's `labeled` event, if the ruling
|
||||
still stands and doubt remains, it is triage's duty to pick the option the
|
||||
builder proceeds on, record that pick as a decision, and stay accountable
|
||||
for it; the operator may overturn it at merge (#50 D13–D14). You set the
|
||||
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||
agreement is reached, record the ruling as a decision in one comment,
|
||||
remove the label, and return the issue to its flow in that same comment;
|
||||
when that ruling or any directive or answered builder question delivers
|
||||
the assignee's next move in prose, set `attention` in the same comment on
|
||||
the assigned issue that owns the claim — never on the pull request, even
|
||||
when the comment lives there. Flagging an unassigned issue is a board bug,
|
||||
not a demand; repair the board rather than setting `attention`.
|
||||
This is not a substitute for minting work or for `needs-ruling`.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
outcome; a zombie discussion is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
## The issue contract
|
||||
|
||||
Every issue you mint carries, in this order:
|
||||
|
||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling".
|
||||
- **Context**: why this exists, with links — the discussion it came from,
|
||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||
open question, the issue is not ready to exist — go back to outcome 2 or 3.
|
||||
- **Tasks**: the steps, checkboxed, in order.
|
||||
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
|
||||
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||
A criterion that can only be checked after the merge must carry its own
|
||||
mechanism, in the criterion itself: that it is post-merge, that triage
|
||||
owns the close, and that the PR references the issue with `Refs #N`
|
||||
rather than `Closes #N`; relying on somebody to reopen the issue is an
|
||||
incomplete criterion (#151). The merge moves the issue to `post-merge` and
|
||||
releases the claim. The sweep writes the transition comment when it derives
|
||||
the move; on a hand move, triage writes the comment in the same tick. In
|
||||
either case triage follows up with the remaining criteria, their owner, and
|
||||
the wake condition for completion.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it. Name a cross-repo dependency the same way with its
|
||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||
When a deliverable is already carried by an open `ready`, `claimed`, or
|
||||
`blocked` issue, the newer issue must declare an unconditional collision
|
||||
edge with `Blocked by #N`, naming the newest open carrier; there is no
|
||||
alternative for disjoint regions. This keeps every `ready` issue
|
||||
concurrently claimable and makes each close release one successor (#288).
|
||||
During a standing release window, every mint also gets a binary membership
|
||||
call in the same tick. A non-member names the release issue as its blocker
|
||||
in its own Dependencies. A member is placed with three writes: the new issue
|
||||
names its immediate member predecessors; every member whose immediate
|
||||
predecessor the new issue becomes adds or re-points its dependency to the
|
||||
new issue, dropping any predecessor the new issue now reaches (inserting X
|
||||
into A → B makes A → X → B, so B drops A); a member that must land after the
|
||||
new issue but already reaches it through another member declares nothing
|
||||
new; and the release issue adds the new issue to its gate, recording
|
||||
membership only. Collision and window edges are independent, so write both
|
||||
when both apply (#292).
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
The bar, stated once: **a competent builder who has read only this issue and
|
||||
the repo can succeed.** The release-ceremony epic and its children
|
||||
(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density
|
||||
expected.
|
||||
|
||||
## Multi-issue work
|
||||
|
||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||
label) with the approach, decisions, constraints, and a dependency-ordered
|
||||
child checklist. Children reference the epic; that checklist is the progress
|
||||
view. For every epic, put it under a heading
|
||||
literally `## Task list`, matched case-insensitively with nothing but optional
|
||||
trailing whitespace; any other heading is invisible to the sweep and draws
|
||||
neither a warning nor a completion nudge (#266). Builders never pick the epic
|
||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
|
||||
|
||||
## Backlog hygiene
|
||||
|
||||
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||
reopen before duplicating.
|
||||
- The issue-flow sweep flips `blocked` → `ready` when every named dependency
|
||||
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
||||
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
||||
+ no activity → comment, unassign, restore `ready`.
|
||||
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
|
||||
criteria and close under the criterion's existing contract. If corrective
|
||||
build work becomes necessary, move it to `ready` or mint a fresh `ready`
|
||||
issue: any builder claims from current `main`, the original builder has no
|
||||
special standing, and re-entry does not set `attention`.
|
||||
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
||||
malformed queue states, and close or extend completed epics when nudged.
|
||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||
them. Every label on every open issue stays true; the board is only worth
|
||||
scanning if it does not lie.
|
||||
- **A lifted hold makes its body prose stale in the same instant, and the
|
||||
body is yours.** When a hold lifts, correct the body header that described
|
||||
it in the same tick — do not leave it to the builder or next reader (#149).
|
||||
|
||||
## What you never do
|
||||
|
||||
- Write code, review code, or build the thing yourself.
|
||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||
refusal into a vague issue to avoid saying no.
|
||||
- Mint an issue to "discuss" something — that is a discussion.
|
||||
|
|
@ -22,4 +22,4 @@ jobs:
|
|||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run tests
|
||||
run: npm ci && npm test
|
||||
run: npm ci && npm test && npm run check:governance
|
||||
|
|
|
|||
49
.forgejo/workflows/labels-sweep.yml
Normal file
49
.forgejo/workflows/labels-sweep.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: labels-sweep
|
||||
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 on the labels caller), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# rest in seconds, one trigger-job dispatch away. 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. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||
# wakes this workflow with bootstrap=no on every board event, so the
|
||||
# declared input is part of the contract: a dispatch naming an undeclared
|
||||
# input is refused, and the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bootstrap:
|
||||
description: Bootstrap the label taxonomy before sweeping
|
||||
type: choice
|
||||
options: ["yes", "no"]
|
||||
default: "yes"
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
sweep:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@0.6.1
|
||||
with:
|
||||
# Pass the dispatch input through the workflow_call boundary
|
||||
# explicitly — a called workflow must not rely on reading the caller's
|
||||
# event inputs (ceremony#215 measured that failing). Empty (schedule)
|
||||
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
|
||||
# taxonomy.
|
||||
bootstrap: ${{ inputs.bootstrap || 'no' }}
|
||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
||||
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
|
||||
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
|
||||
# trigger) never count toward blocker:ci-red — a red trigger means "fix
|
||||
# the caller", which no PR edit can do (#208 reads it).
|
||||
34
.forgejo/workflows/labels.yml
Normal file
34
.forgejo/workflows/labels.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: labels
|
||||
on:
|
||||
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.
|
||||
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]
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read # mergeability/check-rollup read for PR state
|
||||
statuses: read # commit-status rollup read for PR state
|
||||
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
|
||||
issues: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.6.1
|
||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
||||
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
|
||||
# dogfood does (self-labels-sweep.yml).
|
||||
16
.github/labeler.yml
vendored
Normal file
16
.github/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Path → scope:* map consumed by ceremony's additive labels-scope job.
|
||||
"scope:cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/**"]
|
||||
"scope:packaging":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["scripts/**", ".forgejo/workflows/release.yml"]
|
||||
"scope:manifests":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["manifests/**"]
|
||||
"scope:ci":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: [".forgejo/workflows/**"]
|
||||
"scope:docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["README.md", "docs/**"]
|
||||
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
|
||||
triage-actors=claude-bot-andresmgsl
|
||||
scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)
|
||||
scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path
|
||||
scope:manifests|C5DEF5|manifests/ — the fleet repo registry data
|
||||
scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates
|
||||
scope:docs|C5DEF5|README and docs/ — the prose contract
|
||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# AGENTS.md — start at .ceremony/
|
||||
|
||||
This repository is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||
1
changelog.d/30.md
Normal file
1
changelog.d/30.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adopted ceremony label and review automation with a machine-readable panel and scope taxonomy. (#30).
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"description": "CLI for the heavy-duty forge (Forgejo)",
|
||||
"main": "src/cli.js",
|
||||
"scripts": {
|
||||
"check:governance": "node scripts/check-governance.js",
|
||||
"test": "node --test",
|
||||
"start": "node src/cli.js"
|
||||
},
|
||||
|
|
|
|||
82
scripts/check-governance.js
Normal file
82
scripts/check-governance.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
config: path.join(process.cwd(), '.github', 'labels.conf'),
|
||||
apiUrl: process.env.FORGE_API_URL
|
||||
|| `${(process.env.FORGE_SERVER_URL || 'https://forgejo.heavyduty.builders').replace(/\/$/, '')}/api/v1`,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag !== '--config' && flag !== '--api-url') {
|
||||
throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error(`${flag} requires a value`);
|
||||
options[flag === '--config' ? 'config' : 'apiUrl'] = value;
|
||||
index += 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseConfig(contents, filename) {
|
||||
let panel;
|
||||
let triageActors = [];
|
||||
const scopes = [];
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
if (line.startsWith('panel=')) {
|
||||
if (panel) throw new Error(`duplicate panel line in ${filename}`);
|
||||
panel = line.slice('panel='.length).trim().split(/\s+/).filter(Boolean);
|
||||
if (panel.length === 0) throw new Error(`panel must name at least one reviewer in ${filename}`);
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('triage-actors=')) {
|
||||
triageActors = line.slice('triage-actors='.length).trim().split(/\s+/).filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
const fields = line.split('|');
|
||||
if (fields.length !== 3 || !fields[0] || !/^[0-9A-Fa-f]{6}$/.test(fields[1]) || !fields[2]) {
|
||||
throw new Error(`malformed label row: ${line} in ${filename}`);
|
||||
}
|
||||
scopes.push(line);
|
||||
}
|
||||
if (!panel) throw new Error(`missing panel= line in ${filename}`);
|
||||
|
||||
const identities = [...new Set([...panel, ...triageActors])];
|
||||
for (const login of identities) {
|
||||
if (!/^[A-Za-z0-9-]+$/.test(login)) {
|
||||
throw new Error(`malformed login in ${filename}: ${login}`);
|
||||
}
|
||||
}
|
||||
return { identities, scopes };
|
||||
}
|
||||
|
||||
async function validateIdentities(apiUrl, identities) {
|
||||
for (const login of identities) {
|
||||
const endpoint = `${apiUrl.replace(/\/$/, '')}/users/${encodeURIComponent(login)}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, { redirect: 'manual' });
|
||||
} catch (error) {
|
||||
throw new Error(`${login}: fetch failed: ${error.message}`);
|
||||
}
|
||||
if (response.status !== 200) throw new Error(`${login}: HTTP ${response.status} from ${endpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const contents = fs.readFileSync(options.config, 'utf8');
|
||||
const { identities, scopes } = parseConfig(contents, options.config);
|
||||
await validateIdentities(options.apiUrl, identities);
|
||||
console.log(`governance: ${identities.length} identities resolved; ${scopes.length} scope rows valid`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`governance: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
144
test/governance.test.js
Normal file
144
test/governance.test.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.join(__dirname, '..', 'scripts', 'check-governance.js');
|
||||
const REPOSITORY_CONFIG = path.join(__dirname, '..', '.github', 'labels.conf');
|
||||
const REPOSITORY_LABELER = path.join(__dirname, '..', '.github', 'labeler.yml');
|
||||
const REPOSITORY_MIRROR = path.join(__dirname, '..', '.ceremony');
|
||||
const ROOT_AGENTS = path.join(__dirname, '..', 'AGENTS.md');
|
||||
const cleanups = [];
|
||||
process.on('exit', () => {
|
||||
for (const dir of cleanups) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-governance-test-'));
|
||||
cleanups.push(dir);
|
||||
const config = path.join(dir, 'labels.conf');
|
||||
fs.writeFileSync(config, contents);
|
||||
return config;
|
||||
}
|
||||
|
||||
function runValidator(config, apiUrl) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, '--config', config, '--api-url', apiUrl], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function withIdentityServer(logins, callback, redirects = new Map()) {
|
||||
const server = http.createServer((request, response) => {
|
||||
const login = decodeURIComponent(request.url.replace('/api/v1/users/', ''));
|
||||
if (redirects.has(login)) {
|
||||
response.writeHead(307, { location: `/api/v1/users/${redirects.get(login)}` });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(logins.has(login) ? 200 : 404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(logins.has(login) ? { login } : { message: 'not found' }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
try {
|
||||
await callback(`http://127.0.0.1:${port}/api/v1`);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
const validConfig = [
|
||||
'panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl',
|
||||
'triage-actors=claude-bot-andresmgsl',
|
||||
'scope:cli|C5DEF5|src/ — the command surface (cli.js, api.js, config.js)',
|
||||
'scope:packaging|C5DEF5|scripts/ and the release workflow — deb build, registry publish, apt install path',
|
||||
'scope:manifests|C5DEF5|manifests/ — the fleet repo registry data',
|
||||
'scope:ci|C5DEF5|.forgejo/workflows/ — the test and label gates',
|
||||
'scope:docs|C5DEF5|README and docs/ — the prose contract',
|
||||
].join('\n');
|
||||
|
||||
test('governance validator accepts the configured roster when every identity resolves', async () => {
|
||||
const config = writeConfig(`${validConfig}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator fails when a roster identity does not resolve', async () => {
|
||||
const config = writeConfig(`${validConfig.replace('kimi-bot-andresmgsl', 'kimi-bto-andresmgsl')}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /kimi-bto-andresmgsl.*HTTP 404/);
|
||||
});
|
||||
});
|
||||
|
||||
test('governance validator rejects a renamed identity that redirects to a live login', async () => {
|
||||
const config = writeConfig(`${validConfig.replaceAll('claude-bot-andresmgsl', 'cluade-bot-andresmgsl')}\n`);
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
const redirects = new Map([['cluade-bot-andresmgsl', 'claude-bot-andresmgsl']]);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(config, apiUrl);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /cluade-bot-andresmgsl.*HTTP 307/);
|
||||
}, redirects);
|
||||
});
|
||||
|
||||
test('governance validator rejects malformed scope rows before identity requests', async () => {
|
||||
const config = writeConfig(`${validConfig.replace('|C5DEF5|', '|not-a-color|')}\n`);
|
||||
const result = await runValidator(config, 'http://127.0.0.1:1/api/v1');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /malformed label row/);
|
||||
assert.doesNotMatch(result.stderr, /fetch failed/);
|
||||
});
|
||||
|
||||
test('repository governance config resolves the current four-member panel and five scopes', async () => {
|
||||
const logins = new Set(['codex-bot-andresmgsl', 'glm-bot-andresmgsl', 'claude-bot-andresmgsl', 'kimi-bot-andresmgsl']);
|
||||
await withIdentityServer(logins, async (apiUrl) => {
|
||||
const result = await runValidator(REPOSITORY_CONFIG, apiUrl);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /4 identities resolved; 5 scope rows valid/);
|
||||
});
|
||||
});
|
||||
|
||||
test('repository scope mapping covers every configured scope with the ruled paths', () => {
|
||||
const labeler = fs.readFileSync(REPOSITORY_LABELER, 'utf8');
|
||||
const expected = {
|
||||
'scope:cli': ['src/**'],
|
||||
'scope:packaging': ['scripts/**', '.forgejo/workflows/release.yml'],
|
||||
'scope:manifests': ['manifests/**'],
|
||||
'scope:ci': ['.forgejo/workflows/**'],
|
||||
'scope:docs': ['README.md', 'docs/**'],
|
||||
};
|
||||
|
||||
for (const [label, globs] of Object.entries(expected)) {
|
||||
assert.match(labeler, new RegExp(`^"${label}":`, 'm'), `${label} has no mapping`);
|
||||
for (const glob of globs) assert.ok(labeler.includes(JSON.stringify(glob)), `${label} does not map ${glob}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('repository carries the complete 0.6.1 doctrine mirror and root router', () => {
|
||||
const vendored = ['AGENTS.md', 'TRIAGE.md', 'BUILDER.md', 'REVIEWER.md', 'LABELS.md', 'RELEASES.md'];
|
||||
for (const filename of vendored) {
|
||||
assert.ok(fs.statSync(path.join(REPOSITORY_MIRROR, filename)).isFile(), `${filename} is missing`);
|
||||
}
|
||||
const mirrorReadme = fs.readFileSync(path.join(REPOSITORY_MIRROR, 'README.md'), 'utf8');
|
||||
assert.match(mirrorReadme, /labels doctrine is vendored manually/);
|
||||
assert.doesNotMatch(mirrorReadme, /The pin lives in `.github\/workflows\/release\.yml`/);
|
||||
assert.doesNotMatch(mirrorReadme, /Machine-managed by|CI re-diffs them/);
|
||||
assert.match(fs.readFileSync(ROOT_AGENTS, 'utf8'), /read\s+`.ceremony\/AGENTS\.md` first/i);
|
||||
});
|
||||
Loading…
Reference in a new issue