diff --git a/.ceremony/AGENTS.md b/.ceremony/AGENTS.md new file mode 100644 index 0000000..b8af0fa --- /dev/null +++ b/.ceremony/AGENTS.md @@ -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. diff --git a/.ceremony/BUILDER.md b/.ceremony/BUILDER.md new file mode 100644 index 0000000..4429bbc --- /dev/null +++ b/.ceremony/BUILDER.md @@ -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 /#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/.md` + named for the authorizing 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[]=` 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 ``"); + 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 — +Options: A — B — +Recommend: A, because . +Blocked: +Default: | none — hard block +
Analysis…everything else…
+``` + +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. diff --git a/.ceremony/LABELS.md b/.ceremony/LABELS.md new file mode 100644 index 0000000..61ffcfc --- /dev/null +++ b/.ceremony/LABELS.md @@ -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. diff --git a/.ceremony/README.md b/.ceremony/README.md new file mode 100644 index 0000000..75d701a --- /dev/null +++ b/.ceremony/README.md @@ -0,0 +1,14 @@ +# .ceremony/ — the vendored doctrine mirror + +Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit +these files here: they are byte-identical copies of +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this +repository's pinned ref, and CI re-diffs them on every PR — a hand edit +goes red. They are changed in heavy-duty/ceremony, through its own flow, +and arrive 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. diff --git a/.ceremony/RELEASES.md b/.ceremony/RELEASES.md new file mode 100644 index 0000000..930463c --- /dev/null +++ b/.ceremony/RELEASES.md @@ -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 `. 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 `. +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 ` 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. diff --git a/.ceremony/REVIEWER.md b/.ceremony/REVIEWER.md new file mode 100644 index 0000000..17e8b77 --- /dev/null +++ b/.ceremony/REVIEWER.md @@ -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 /#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[]=` 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)). diff --git a/.ceremony/TRIAGE.md b/.ceremony/TRIAGE.md new file mode 100644 index 0000000..e05ccb4 --- /dev/null +++ b/.ceremony/TRIAGE.md @@ -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. diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..7ece498 --- /dev/null +++ b/.github/labeler.yml @@ -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/**"] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e047dee --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/test/governance.test.js b/test/governance.test.js index 1b97705..1cf1341 100644 --- a/test/governance.test.js +++ b/test/governance.test.js @@ -8,6 +8,9 @@ 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 }); @@ -95,3 +98,30 @@ test('repository governance config resolves the four-member panel and five scope 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.match(fs.readFileSync(ROOT_AGENTS, 'utf8'), /read\s+`.ceremony\/AGENTS\.md` first/i); +});