diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index e98a452a..8035b17f 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -1243,10 +1243,24 @@ binding do it. missed doorbell costs a night, not a deadline. - **`deps-prs` adopts the bot's version-update pull requests.** A `dependabot[bot]` pull request in a watched repo gets a `pr` binding under - the `deps-prs` playbook whether or not it is labelled. It reviews the bump - and escalates for a human merge; it never merges. A patch or minor bump - with green checks is escalated for a merge, and a major bump or anything - touching an owner-reviewed path is escalated with the reason. + the `deps-prs` playbook whether or not it is labelled. It gets the same + independent reviewer round a ticket worker's pull request gets, and the + ordinary pull-request gate then files the `merge` need for a person to + press; the steward never merges. A major bump or anything touching an + owner-reviewed path is escalated with the reason. +- **A replacement pull request moves the binding.** A bump that cannot + merge as is (the build breaks, the reviewer blocks on part of it) is + redone by a worker as a new pull request on its own branch, and the bot's + is closed. The steward reports `steward_report { replacedBy: "owner/repo#N" }` + and the plugin moves the binding to the replacement: same id, thread, + playbook, guidance, escalations and workers; the entity, snapshot and + phase become the replacement's, items filed for the old pull request are + withdrawn, an ending begun by the old one's close is undone, the thread is + retitled and the leases refreshed. The replacement must be an open pull + request in the same repository with no steward of its own. Without this + the binding stays on a closed pull request, the gate never looks at the + replacement, and nothing files its Merge item, because the plugin never + adopts a pull request the signed-in login wrote. `bb stewards init` writes `deps-alerts.md` and `deps-prs.md`; a project that has not written them runs no dependency watch, which is how the feature diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index 60e1873f..d52d35af 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -52,7 +52,11 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run. new alert joins or tightens it. A fixed or dismissed alert trims or closes its ticket, and the nightly pass re-lists open alerts as the backstop. A `dependabot[bot]` version-update pull request is adopted under `deps-prs`, - reviewed, and escalated for a human merge; it is never merged. Reading + gets the reviewer round, and once its marker is approved on green checks + the plugin files a Merge item for a person to press; the steward never + merges. A bump redone as a replacement pull request is reported with + `steward_report { replacedBy }`, which moves the binding, its gate and its + Merge item to the replacement instead of ending it with the bot's. Reading alerts needs the token's alert access; a server whose login cannot read them skips the targets, and `bb stewards check` says which. DESIGN.md ยง19. - **The reconcile loop.** linear and github-watcher hold bb-gate-hooks leases for the diff --git a/plugins/stewards/config.test.ts b/plugins/stewards/config.test.ts index f9ebc4ce..2d971d0e 100644 --- a/plugins/stewards/config.test.ts +++ b/plugins/stewards/config.test.ts @@ -254,7 +254,8 @@ describe("the dependency playbooks", () => { if (!prs.ok) throw new Error(prs.error); expect(prs.value).toMatchObject({ kind: "pr", source: "github", terminal: ["merged", "closed"] }); expect(prs.value.general).toMatch(/never merge/u); - expect(prs.value.general).toMatch(/patch or minor bump whose checks are green is escalated for a\s+human merge/u); + expect(prs.value.general).toMatch(/files a \*\*Merge\*\* item for it there, and the owner presses it/u); + expect(prs.value.general).toMatch(/steward_report \{ replacedBy: "owner\/repo#N" \}/u); expect(prs.value.general).toMatch(/major bump/u); expect(prs.value.general).toMatch(/owner-reviewed/u); expect(prs.value.skipAuthors).toEqual(expect.arrayContaining(["dependabot[bot]"])); diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index a2df570c..5bfbdb4f 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -5007,7 +5007,112 @@ describe("dependency stewards", () => { const prompt = String(pr!.prompt); expect(prompt).toMatch(/playbook deps-prs@/u); expect(prompt).toMatch(/You steward one Dependabot version-update pull request/u); - expect(prompt).toMatch(/never merge it: you adopt it, review it, and escalate it for a\s+human merge/u); + expect(prompt).toMatch(/never merge it: you adopt it, get it reviewed, and leave the\s+merge to a person/u); + }); + + // A Dependabot bump that cannot merge as is comes back as a worker's own + // pull request. The binding follows it: same thread, playbook and history, + // and the ordinary gate files the replacement's Merge item. + async function depsBound() { + const t = await setup(); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] } }); + t.files[playbookPath("deps-prs")] = DEPS_PRS; + t.entities.set("acme/web#9", pull({ id: "acme/web#9", key: "acme/web#9", url: "https://github.com/acme/web/pull/9", title: "deps: bump the npm group", author: "dependabot[bot]", labels: [] }, { head: "dependabot/npm/group", headAuthor: "dependabot[bot]" })); + await t.hooks.sweep(); + await t.flush(); + const steward = t.spawns.find((x) => x.title === "acme/web#9: deps: bump the npm group")!.id; + const replacement = (over: Partial = {}, pullOver: Parameters[1] = {}) => + pull({ id: "acme/web#10", key: "acme/web#10", url: "https://github.com/acme/web/pull/10", title: "deps: patch/minor-only bumps replacing #9", author: "jane", labels: [], ...over }, { head: "deps-replace-9", headAuthor: "jane", ...pullOver }); + const report = (over: Record) => t.tool("steward_report", { phase: "open", summary: "Replaced.", nextAction: "Wait for the review.", actor: "steward", ...over }, steward); + return { t, steward, replacement, report }; + } + + it("moves a deps-prs binding to the replacement pull request its steward reports, and files the replacement's merge item", async () => { + const { t, steward, replacement, report } = await depsBound(); + const before = t.hooks.store.find("acme/web#9")!; + t.entities.set("acme/web#10", replacement()); + const reported = await report({ replacedBy: "acme/web#10" }); + expect(reported.error).toBe(false); + expect(reported.text).toMatch(/acme\/web#10 replaces acme\/web#9: the gate, the Merge item and your wakes now follow it/u); + await t.flush(); + const after = t.hooks.store.find("acme/web#10")!; + expect(after.id).toBe(before.id); + expect(after.threadId).toBe(steward); + expect(after).toMatchObject({ entityId: "acme/web#10", entityKey: "acme/web#10", url: "https://github.com/acme/web/pull/10", title: "deps: patch/minor-only bumps replacing #9", playbook: "deps-prs", ending: null, endedAt: null }); + expect(t.hooks.store.find("acme/web#9")).toBeNull(); + // The leases follow: the plugin now wants doorbells for #10, not #9. + const github = t.rpcCalls.filter((c) => c.pluginId === "github-watcher" && c.method === "sourceSetInterest").at(-1)!.input as { entities: string[] }; + expect(github.entities).toContain("acme/web#10"); + expect(github.entities).not.toContain("acme/web#9"); + + // The bot's pull request closing is no longer this binding's end. + t.entities.set("acme/web#9", pull({ id: "acme/web#9", key: "acme/web#9", url: "https://github.com/acme/web/pull/9", author: "dependabot[bot]", labels: [], state: "closed" })); + await t.ring("github", "acme/web#9"); + expect(t.hooks.store.binding(before.id)).toMatchObject({ ending: null, endedAt: null, entityKey: "acme/web#10" }); + + // The reviewer approves the replacement on green checks: the ordinary gate files its Merge item. + const marker = comment({ id: "r1", author: "jane", body: "", signedBy: "thr_reviewer", createdAt: "2026-10-14T16:00:00Z" }); + t.entities.set("acme/web#10", replacement({ comments: [marker] }, { checks: { state: "success", failing: [] } })); + await t.ring("github", "acme/web#10"); + const needs = t.hooks.store.openNeeds(before.id); + expect(needs.map((n) => [n.kind, n.url])).toEqual([["merge", "https://github.com/acme/web/pull/10"]]); + }); + + it("undoes an ending begun by the replaced pull request's close when the steward reports the replacement in that wrap-up turn", async () => { + const { t, steward, replacement, report } = await depsBound(); + const before = t.hooks.store.find("acme/web#9")!; + + // The bot's pull request closes: the close doorbell begins the ending, and + // the steward wakes for its one last (wrap-up) turn with the binding still + // open enough to report. + t.entities.set("acme/web#9", pull({ id: "acme/web#9", key: "acme/web#9", url: "https://github.com/acme/web/pull/9", title: "deps: bump the npm group", author: "dependabot[bot]", labels: [], state: "closed" })); + await t.ring("github", "acme/web#9"); + expect(t.hooks.store.binding(before.id)).toMatchObject({ entityKey: "acme/web#9", ending: "terminal", endedAt: null }); + expect(t.hooks.store.binding(before.id)!.endingAt).not.toBeNull(); + + // In that turn the steward reports the replacement, with a follow-through: + // the report lands in one round trip and undoes the ending. + t.entities.set("acme/web#10", replacement()); + const reported = await report({ replacedBy: "acme/web#10", followThrough: "thr_w1 is running the review loop on #10." }); + expect(reported.error).toBe(false); + await t.flush(); + const after = t.hooks.store.binding(before.id)!; + expect(after).toMatchObject({ entityId: "acme/web#10", entityKey: "acme/web#10", ending: null, endingAt: null, followThrough: null, followThroughUntil: null, endedAt: null }); + expect(after.threadId).toBe(steward); + + // The replacement's gate still files its Merge item. + const marker = comment({ id: "r1", author: "jane", body: "", signedBy: "thr_reviewer", createdAt: "2026-10-14T16:00:00Z" }); + t.entities.set("acme/web#10", replacement({ comments: [marker] }, { checks: { state: "success", failing: [] } })); + await t.ring("github", "acme/web#10"); + expect(t.hooks.store.openNeeds(before.id).map((n) => [n.kind, n.url])).toEqual([["merge", "https://github.com/acme/web/pull/10"]]); + }); + + it("refuses a replacement that is not an open pull request in the same repository, or is already stewarded", async () => { + const { t, replacement, report } = await depsBound(); + expect((await report({ replacedBy: "nonsense" })).text).toMatch(/must name a pull request as owner\/repo#N/u); + expect((await report({ replacedBy: "acme/other#10" })).text).toMatch(/same repository: acme\/web#9 is in acme\/web, not acme\/other/u); + expect((await report({ replacedBy: "acme/web#9" })).text).toMatch(/is this pull request, not a replacement/u); + expect((await report({ replacedBy: "acme/web#10" })).text).toMatch(/acme\/web#10 could not be read as a pull request/u); + t.entities.set("acme/web#10", replacement({ state: "closed" })); + expect((await report({ replacedBy: "acme/web#10" })).text).toMatch(/acme\/web#10 is closed; a replacement must be open/u); + t.entities.set("acme/web#10", replacement({ author: "dependabot[bot]" }, { headAuthor: "dependabot[bot]" })); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.find("acme/web#10")).not.toBeNull(); + expect((await report({ replacedBy: "acme/web#10" })).text).toMatch(/acme\/web#10 already has a steward \(open\)/u); + expect(t.hooks.store.find("acme/web#9")).toMatchObject({ entityKey: "acme/web#9", endedAt: null }); + }); + + it("refuses a replacement when its own pull request key cannot be read", async () => { + const { t, replacement, report } = await depsBound(); + const before = t.hooks.store.find("acme/web#9")!; + // Defensive: a github pr binding's key is always owner/repo#N, but if one + // ever is not, the same-repo and self checks cannot run, so refuse rather + // than let store.byEntity find the binding itself. + t.hooks.store.patch(before.id, { entityKey: "not-a-pull-request", url: "not-a-pull-request" }); + t.entities.set("acme/web#10", replacement()); + expect((await report({ replacedBy: "acme/web#10" })).text).toMatch(/replacedBy is for a pull request whose key reads as owner\/repo#N/u); + expect(t.hooks.store.binding(before.id)).toMatchObject({ entityKey: "not-a-pull-request", endedAt: null }); }); it("ends a deps-prs binding when the playbook disappears rather than falling back to a merge-capable one", async () => { diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 257908fb..0632eae8 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -4713,6 +4713,58 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const toolError = (error: unknown) => ({ content: [{ type: "text" as const, text: errorMessage(error) }], isError: true }); + /** + * Move a pull-request binding to the pull request that replaces it (a + * Dependabot bump redone on a branch of its own, say). The binding keeps + * its id, thread, playbook, guidance, escalations and workers; its entity, + * snapshot and phase become the replacement's, and an ending begun by the + * replaced pull request's close is undone, so the steward is not archived + * out from under the open replacement. Items filed for the old pull + * request are withdrawn; the next reconcile files the replacement's. + */ + async function replaceBinding(b: Binding, ref: string): Promise<{ binding: Binding } | { error: string }> { + if (b.kind !== "pr" || b.source !== "github") return { error: "replacedBy is for a pull-request steward: this binding is not one." }; + const next = parsePullRef(ref); + if (next === null) return { error: `replacedBy must name a pull request as owner/repo#N or its URL; ${JSON.stringify(ref)} does not.` }; + const own = parsePullRef(b.url ?? b.entityKey); + if (own === null) return { error: `replacedBy is for a pull request whose key reads as owner/repo#N; ${JSON.stringify(b.entityKey)} does not.` }; + if (lc(own.repo) !== lc(next.repo)) return { error: `A replacement is in the same repository: ${b.entityKey} is in ${own.repo}, not ${next.repo}.` }; + const key = `${next.repo}#${next.number}`; + if (own.number === next.number) return { error: `${key} is this pull request, not a replacement.` }; + const entity = await snapshot("github", key).catch(() => null); + if (entity === null || entity.pull === null) return { error: `${key} could not be read as a pull request.` }; + if (entity.pull.merged || entity.state === "merged" || entity.state === "closed") return { error: `${entity.key} is ${entity.state}; a replacement must be open.` }; + const taken = store.byEntity("github", entity.id); + if (taken !== null && taken.id !== b.id) return { error: `${entity.key} already has a steward (${taken.endedAt === null ? "open" : "ended"}); it cannot replace ${b.entityKey}.` }; + const config = await loadConfig(b.projectId); + const playbook = config === null ? null : bindingPlaybook(config, b); + const { phase, stage } = playbook === null ? { phase: entity.state, stage: null } : phaseAndStage(playbook, entity.state); + const oldKey = b.entityKey; + const oldUrl = b.url ?? b.entityKey; + const why = `Replaced by ${entity.key}.`; + for (const kind of ["approve-pr", "merge"] as const) store.closeNeedByKey(b.id, kind, oldUrl, why); + for (const x of store.openEscalations(b.id)) if (x.kind === "approval" && x.url === oldUrl) store.resolve(x.id, why); + const moved = store.patch(b.id, { + entityId: entity.id, + entityKey: entity.key, + url: entity.url, + title: entity.title, + phase, + stage, + lastSnapshot: { entity, pulls: [] }, + ending: null, + endingAt: null, + followThrough: null, + followThroughUntil: null, + }); + store.note(moved.id, `${entity.key} replaces ${oldKey}; this steward follows ${entity.key} now.`, true); + bb.log.info(`stewards: ${oldKey} is replaced by ${entity.key}; the binding follows it`); + if (moved.threadId !== null) await quietly(`retitle ${entity.key}'s steward`, () => bb.sdk.threads.update({ threadId: moved.threadId!, title: `${entity.key}: ${entity.title}` })); + await sendInterest().catch((error) => bb.log.warn(`stewards: could not refresh leases after ${oldKey} moved to ${entity.key}: ${errorMessage(error)}`)); + scheduleReconcile(moved.id); + return { binding: moved }; + } + function ownBinding(threadId: string): Binding { const b = store.byThread(threadId); if (b === null || b.endedAt !== null) throw new Error("This thread is not the steward of an open binding."); @@ -4811,6 +4863,13 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P .boolean() .optional() .describe('Ticket stewards: true once no more pull requests are coming for this ticket, so its work is finished when its linked pull requests have merged and its hold can lift silently. Set it again as more pull requests open: reporting false (more are coming), or a new linked pull request, clears it.'), + replacedBy: z + .string() + .trim() + .min(1) + .max(500) + .optional() + .describe('Pull-request stewards: the open pull request that replaces this one (owner/repo#N or its URL), opened on its own branch when this one could not merge as is. The binding moves to it: the gate, the Merge item and every later wake are about the replacement, and this pull request is closed by whoever opened the replacement. Report it once, as soon as the replacement is open.'), needs: z .array( z.object({ @@ -4864,10 +4923,11 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P async execute(input, { threadId }) { try { let b = ownBinding(threadId); - // The gate as it stood before this report, so the reply can tell a - // report that just passed it from one that finds it already passed. - const priorReady = b.ready; - const priorSnapshot = b.lastSnapshot; + // Validate the input that does not depend on which pull request the + // binding is on before a replacement moves it: a wrap-up report that + // carries `replacedBy` and `followThrough` (the natural combination + // in the turn the old pull request closed) must not move the binding + // and then refuse the rest of itself. const opsTickets = input.opsTickets ?? []; if (opsTickets.length > 0) { if (b.kind !== "ops") return toolError(new Error("opsTickets is for an ops steward's report. Report without it.")); @@ -4877,7 +4937,11 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P return toolError(new Error(`Nothing was recorded: ${unknown.join(", ")} ${unknown.length === 1 ? "is not a fingerprint" : "are not fingerprints"} this project has seen. Use them as the wake gives them${known === "" ? "." : `:\n${known}`}`)); } } - if (input.followThrough !== undefined && b.ending !== "terminal") { + // `followThrough` is judged against the ending the report found: a + // replacement undoes a terminal ending, so the wrap-up report that + // moves the binding may still carry it. + const endingBefore = b.ending; + if (input.followThrough !== undefined && endingBefore !== "terminal") { return toolError(new Error(`followThrough is only for after a terminal state, and ${b.entityKey} is not in one now. Report without it.`)); } // Fixed choices are for a decision, as a `decide` need is: they are @@ -4885,6 +4949,20 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P if (input.escalate !== undefined && input.escalate.choices !== undefined && input.escalate.kind !== "decision") { return toolError(new Error("choices are for a decision escalation: escalate a decision with choices.")); } + // A replacement pull request moves the binding before the gate is + // judged: the terminal state of the one it replaces no longer ends + // it, and the gate below reads the replacement. + let replacedNote = ""; + if (input.replacedBy !== undefined) { + const moved = await replaceBinding(b, input.replacedBy); + if ("error" in moved) return toolError(new Error(moved.error)); + replacedNote = ` ${moved.binding.entityKey} replaces ${b.entityKey}: the gate, the Merge item and your wakes now follow it.`; + b = moved.binding; + } + // The gate as it stood before this report, so the reply can tell a + // report that just passed it from one that finds it already passed. + const priorReady = b.ready; + const priorSnapshot = b.lastSnapshot; const config = await loadConfig(b.projectId); const playbook = config === null ? null : bindingPlaybook(config, b); const priorGate = playbook !== null && playbook.ready !== null ? gateState(playbook, priorReady, priorSnapshot === null ? (priorReady.approval?.specHash ?? "") : specHashFor(b, priorSnapshot.entity)) : null; @@ -5108,15 +5186,15 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P scheduleReconcile(b.id); } if (b.ending === "terminal") { - if (b.followThrough === null) return `Reported.${refusalNote}${escalated}${deploymentNote} The binding ends when this turn does: running workers are stopped and the thread is archived. End the turn now.`; - return `Reported.${refusalNote}${escalated}${deploymentNote} The binding stays open to follow through until ${iso(followThroughUntil!)}; running workers keep going. You will be woken when a worker finishes or something actionable changes, or in ${formatDuration(requeueMs)}. End the turn now.`; + if (b.followThrough === null) return `Reported.${replacedNote}${refusalNote}${escalated}${deploymentNote} The binding ends when this turn does: running workers are stopped and the thread is archived. End the turn now.`; + return `Reported.${replacedNote}${refusalNote}${escalated}${deploymentNote} The binding stays open to follow through until ${iso(followThroughUntil!)}; running workers keep going. You will be woken when a worker finishes or something actionable changes, or in ${formatDuration(requeueMs)}. End the turn now.`; } const hidden = archiveWhenIdle && !needsUser(b) ? " Your thread is hidden when the turn ends, and comes back on that wake." : ""; const recorded = opsTickets.length === 0 ? "" : ` Recorded ${opsTickets.map((t) => `${t.ticket} (${t.did})`).join(", ")} against ${opsTickets.length === 1 ? "its fingerprint" : "their fingerprints"}.`; if (gateJustPassed && nothingPending) { - return `Reported.${recorded}${refusalNote}${escalated} Ready gate: passed and nothing schedules you. Move the ticket to In Progress and start the worker now, in this turn, with the task in the In Progress instructions; do not end the turn first.`; + return `Reported.${replacedNote}${recorded}${refusalNote}${escalated} Ready gate: passed and nothing schedules you. Move the ticket to In Progress and start the worker now, in this turn, with the task in the In Progress instructions; do not end the turn first.`; } - return `Reported.${recorded}${refusalNote}${gateNote}${escalated} You will be woken when something actionable changes, or in ${formatDuration(requeueMs)}.${hidden} End the turn now.`; + return `Reported.${replacedNote}${recorded}${refusalNote}${gateNote}${escalated} You will be woken when something actionable changes, or in ${formatDuration(requeueMs)}.${hidden} End the turn now.`; } catch (error) { return toolError(error); } diff --git a/plugins/stewards/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md index 1242a551..c595c8a3 100644 --- a/plugins/stewards/skills/stewards/SKILL.md +++ b/plugins/stewards/skills/stewards/SKILL.md @@ -162,6 +162,13 @@ Two tools are yours; everything else you do with the tools every thread has. steward to schedule you: start no workers and do not move the ticket into a build state until it holds you behind that work or tells you to start. Do not escalate for it. +- **A replacement pull request (pull-request stewards).** When the pull + request you steward is redone as a new one on its own branch, report + `steward_report { replacedBy: "owner/repo#N" }` as soon as the + replacement is open. Your binding moves to it: the gate, the Merge item + and your wakes follow the replacement, and the old one's close no longer + ends you. The replacement must be open, in the same repository, and not + already stewarded. - **@bb mentions.** A wake headed "Linear mention" is someone's `@bb` on your issue. A wake from the owner is their instruction: do what it says, end with exactly one `linear_session_reply` for its session, and then diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts index 7e8df6c4..1e909b2c 100644 --- a/plugins/stewards/store.ts +++ b/plugins/stewards/store.ts @@ -573,6 +573,7 @@ function rowToBinding(r: BindingRow): Binding { /** Field names a caller may patch, and their column. */ const COLUMNS = { + entityId: "entity_id", entityKey: "entity_key", url: "url", title: "title", diff --git a/plugins/stewards/templates.ts b/plugins/stewards/templates.ts index c8af3367..0ec9b74e 100644 --- a/plugins/stewards/templates.ts +++ b/plugins/stewards/templates.ts @@ -392,29 +392,54 @@ skip-authors: [dependabot, "dependabot[bot]", "renovate[bot]"] --- You steward one Dependabot version-update pull request until it is merged or -closed. You never merge it: you adopt it, review it, and escalate it for a -human merge. A patch or minor bump whose checks are green is escalated for a -human merge. A major bump, or anything touching a path the project's -guidance (AGENTS.md) says is owner-reviewed, is escalated with the reason. +closed. You never merge it: you adopt it, get it reviewed, and leave the +merge to a person. A worker you start is told not to merge. The person +merges from the Stewards page: once a pull request carries the reviewer's +\`verdict=approved\` marker on its head and its checks are green, the plugin +files a **Merge** item for it there, and the owner presses it. Do not +escalate a decision for the merge; the Merge item is the ask. A major bump, +or anything touching a path the project's guidance (AGENTS.md) says is +owner-reviewed, is escalated with the reason instead, and gets no reviewer +until that is decided. + +Every pull request gets the independent review the project's reviewer skill +describes, on a different model from whoever wrote it, exactly as a ticket +worker's does. For the bot's own pull request, start one worker whose task +is to run that review loop as the author would: spawn the reviewer thread +(the pull request's description stands in for the ticket and its acceptance +criteria), fix what the reviewer blocks only if a fix is needed, and end +when the marker says approved and checks are green. It never merges. + +A grouped bump can break the build, and a reviewer can block on part of it. +Then the worker redoes it on a branch of its own as a new pull request that +says which Dependabot pull request it replaces and which bumps it drops, +closes the Dependabot one with a signed comment (pushing to Dependabot's +branch stops it rebasing), and runs the review loop on the replacement. As +soon as the replacement is open, report it with +\`steward_report { replacedBy: "owner/repo#N" }\`: the binding, the gate and +the Merge item move to the replacement, and you are woken for it from then +on. Closing the Dependabot pull request does not end your binding once +that is reported. Use gh for everything you do on GitHub, and sign what you write. A merge to -bb-plugins main rebuilds the bb-server image and stages a restart for every -developer, so the escalation and the eventual merge comment say so. +main may rebuild an image and restart what runs it, so the replacement's +description and the eventual merge comment say so when it does. ## draft Get it ready: a worker fixes failing checks and finishes what the description -says is left. When checks pass, mark it ready (\`gh pr ready\`) and escalate -for a human merge as above. +says is left. When checks pass, mark it ready (\`gh pr ready\`) and run the +review loop as above. ## open Review the bump against the project's guidance. When it is a patch or minor -bump with green checks, escalate a decision for a human merge, saying the -version and that a merge stages a restart. When it is a major bump, or it -touches an owner-reviewed path, escalate a decision with the reason and do -not ask for a merge. Address review comments with a worker and re-request -review after each round. Do not merge, and do not approve on GitHub. +bump, start the review-loop worker; the Merge item appears by itself when +the reviewer approves and checks are green, so say so in your report and +wait. When it is a major bump, or it touches an owner-reviewed path, +escalate a decision with the reason and do not ask for a merge. Address a +human's review comments with a worker and re-request review after each +round. Do not merge, and do not approve on GitHub. `; export const PLAYBOOK_TEMPLATES: Record = {