/* * Record verbs: `next_unblocked` · `claim` · `land`. * * These turn coordinator RECIPES into bus VERBS, so a forgotten step fails closed * instead of looking healthy. The measured cost of the recipe: twelve merges went * 23 hours unlogged, and the queue/DONE loop failed three times in nine hours with * the rule written down each time. * * THE MARKDOWN IS AUTHORITATIVE (ADR-003). These read and write the documents * directly rather than the derived store: a store import is a second source of * truth, and the failure this phase exists to remove is exactly a second source * that drifts. */ import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { treeProvenance } from "./tree-provenance.js"; import { parseWorkDoc, renderWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, type QueueItem, type WorkDoc, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, type WorkState, type WorkstreamsV1Row, type DoneEntry, } from "@davidbalzan/groundwork-seam"; import { ensureWorktreeTool } from "./worktrees.js"; import { ROOT } from "../store.js"; import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js"; import { boardRefFor, classifyBoardRef } from "./board-ref.js"; import { haltState } from "./stall.js"; import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js"; import { prRefsIn, leadingItemIdOf } from "./record-events.js"; const QUEUE_DOC = "docs/QUEUE.md"; const DONE_DOC = "docs/DONE.md"; const BOARD_DOC = "docs/WORKSTREAMS.md"; const PRIORITY_ORDER = { P1: 0, P2: 1, P3: 2 } as const; const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | null => { const p = path.join(repo, rel); if (!existsSync(p)) return null; const text = readFileSync(p, "utf8"); return { text, doc: parseWorkDoc(text) }; }; /** * Write a document back HUNK-FAITHFULLY: everything the seam did not model is * replayed verbatim, and an UNCHANGED file is not written at all. * * The round trip is byte-exact on all three live documents (measured before this * was built, not assumed). Re-rendering an untouched file would still be a write, * and a write is a diff someone has to review — so the no-op case returns false. */ /** * EVERY SUPPORTED WRITE OF A WORK DOC GOES THROUGH HERE, and it stamps. * * `renderWorkDocForWrite` records identity for any queue item that lacks it, * including rows this caller did not author (q-c50e9b83) — the absorbed raw * append is exactly the case, and stamping only the caller's own rows would keep * the defect and move the blame. Every id written is the one the item already * has, so nothing changes value. * * Returns what it stamped so the caller can report it rather than leaving an * absorbing writer to find it in a diff. */ /** * A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal * that can be ignored is how the silent loss happened in the first place. */ export class StaleWriteError extends Error { constructor( readonly rel: string, readonly detail: string, readonly alreadyWritten: string[], ) { super( `refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` + `Nothing was overwritten. Re-read and retry.` + (alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""), ); this.name = "StaleWriteError"; } } /** What moved, in terms a caller can act on without diffing the file itself. */ function describeDrift(original: string, current: string): string { const ol = original.split("\n"); const cl = current.split("\n"); let i = 0; while (i < ol.length && i < cl.length && ol[i] === cl[i]) i++; return ( `${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` + (i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : "") ); } /** * ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT. * * This function used to compare the rendered text against `original` — the * caller's IN-MEMORY snapshot from when it read — and then write. It never looked * at the file again. Anything written in between was clobbered with no error, no * diff, and nothing in the return value: `{ written: true }` came back for a * write that had destroyed someone else's row. * * Measured before fixing: two writers taking the same original and both * committing leave TWO rows where three should be, and the first writer's row is * simply gone. This is not hypothetical — four processes write these documents * (the aide from its own clone, `claim`, `land`, worker seats), and `land --write` * ran six times in one afternoon against a queue the aide was editing. * * So the file is re-read immediately before the write and must still match what * the caller read. On a mismatch this REFUSES — loudly, by throwing — and names * what moved. A refused write is recoverable; a silent overwrite is not, and the * loser of the race never learns it lost. * * What this does NOT claim: it is not a lock. Two processes can still interleave * between this re-read and the `writeFileSync` a few microseconds later. The * window goes from "the whole duration of the caller's work" — parsing, git * calls, composing a line — down to two adjacent statements. That is a large * reduction and not zero, and a lock is the stronger fix if this ever proves * insufficient. * * EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES — * the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its * read to its write, so no in-process test can interleave them. An end-to-end * attempt passed identically with the guard removed, i.e. it proved nothing. A * data-loss guard is worth a narrow export to be testable at the level it lives. */ export function writeDoc( repo: string, rel: string, doc: WorkDoc, original: string, alreadyWritten: string[] = [], own: readonly string[] = [], ): { written: boolean; stamped: string[] } { // `own` = the rows THIS CALL authored. Everything else is stamped only if the // document already speaks the recorded-id grammar — see `stampQueueIds`, which // holds the rule so both writers cannot drift apart on it. const { text, stamped } = renderWorkDocForWrite(doc, { own }); if (text === original) return { written: false, stamped: [] }; const p = path.join(repo, rel); const current = existsSync(p) ? readFileSync(p, "utf8") : ""; if (current !== original) { throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten); } writeFileSync(p, text); return { written: true, stamped }; } const git = (repo: string, args: string[]): string => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); /** `#123` / `owner/repo#123` → "123". */ function prNumber(pr: string): string | null { const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim()); return m ? (m[1] as string) : null; } /** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */ function prNumbersOf(pr: string): string[] { const out: string[] = []; for (const m of String(pr).matchAll(/#(\d+)\b/g)) if (!out.includes(m[1]!)) out.push(m[1]!); if (!out.length) { const one = prNumber(pr); if (one) out.push(one); } return out; } /* * ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed * #245's squash 777 commits back on an 1806-commit main and the verb then * asserted "#245 is not on origin/main" as a fact — a truncated search reported * as an absence, at the verb that writes the delivery record, and worded with a * mechanism (target tip vs merge base) that had nothing to do with it. At the * fleet's speed (~200 record commits a day) 400 is about two days, so it fires * precisely on the oldest rows, the ones closed late. * * Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged * BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use: * the landing is the commit whose subject ENDS with the forge's `(#N)` marker. * A bare `#N` anywhere is a mention, never a landing — qa measured the first * cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322 * squash-merged PRs resolved to their CLOSURE or board commit ("docs(record): * close … by content on #245"), which carry the bare number in the subject and * sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a * hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's * squash and would otherwise be taken for it. The one PR of 323 on this main * that landed as a merge commit (#108, "Merge pull request #108 from …") is the * second identity form — the forge's own, not a mention — and is the last * resort, consulted only when no trailing form exists. A miss reports what was * searched — every subject on the ref — never a mechanism it did not test. */ export function landingCommitOf(repo: string, ref: string, n: string): { sha: string | null; searched: number } { const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0; // git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below. const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]); const trailing = new RegExp(`\\(#${n}\\)\\s*$`); const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`); let landing: string | null = null; let merge: string | null = null; for (const line of out.split("\n")) { const [sha, subject = ""] = line.split("\x00"); if (!sha) continue; if (trailing.test(subject)) landing = sha; // newest first: the last hit is the OLDEST else if (!merge && mergeForm.test(subject)) merge = sha; } return { sha: landing ?? merge, searched }; } /** * The `owner/repo` a BARE `#N` in this repo's docs should be read as naming — * the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties * a closed item's citation to DONE.md. `land` must decide "already cited" with * the SAME rule the auditor will apply, or the two disagree again one level * down: a bare `#77` in an item body is a tie for doctor only when the origin * URL says which repo, and it is a tie here under exactly that condition. * Null (no remote, not a repo) means an unknown context, never a guessed one. */ function originRepoOf(repo: string): string | null { try { const url = git(repo, ["remote", "get-url", "origin"]); const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url); return m ? (m[1] as string) : null; } catch { return null; } } // ---------- blocked / no-downstream ---------- const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i; /** The blocker an item names, if it names one. */ export function blockedBy(item: QueueItem): string | null { const m = BLOCKED_RE.exec(String(item.text)); return m ? (m[1] as string).replace(/[`*]/g, "") : null; } const keyOf = (i: QueueItem) => String(i.text).replace(/\s+/g, " ").slice(0, 60); /** * A DONE summary a human would have written. * * The first `write:true` run produced * `- [x] Kit: **THE ROOT GATE HAND-LISTS ITS FOUR PACKAGES BY NAME WH — …`: * truncated MID-WORD at 60 characters, leaving an unbalanced `**`. It parses, so * the glyph contract accepts it, and it reads as a line someone abandoned * half-way. Cut on a word boundary and drop emphasis markers rather than leaving * half of one. */ export function summarize(text: string, max = 96): string { const flat = String(text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim(); if (flat.length <= max) return flat; const cut = flat.slice(0, max); const at = cut.lastIndexOf(" "); return `${(at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s\-—·,;:]+$/, "")}…`; } /** * Items nothing else waits on. * * "BLOCKS NOTHING" IS NOT "COSTS NOTHING TO DEFER" — and an item that blocks * nothing also ANNOUNCES nothing when it stalls. Everything else surfaces through * the thing waiting on it; these have no such witness, so they go missing silently * and their absence is found by someone re-reading the plan. That is why they are * a SEPARATE AXIS here and not folded into the skipped-because-blocked list: those * are two different states with two different remedies, and one list covering both * leaves the reader to infer which. * * LIMIT, stated because it bounds the claim: dependency is detected from the TEXT. * An unstated dependency is invisible to this, so "no downstream" means "nothing in * the queue SAYS it waits on this", never "nothing waits on this". */ export function noDownstream(items: QueueItem[]): QueueItem[] { // ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation // asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED // list, so this re-filter is a no-op on that path; it is kept because this // function is EXPORTED and its contract is "given items, which have nothing // waiting on them", which must hold for a caller that hands it raw items. // // The two are not the same predicate and must not be merged: this one is // `!done`, while the router's also subtracts the delivery join. Collapsing // them would make an exported helper inherit a routing policy. const open = items.filter((i) => !i.done); const named = new Set(); for (const i of open) { const b = blockedBy(i); if (b) named.add(b.toLowerCase()); } return open.filter((i) => { if (blockedBy(i)) return false; // it waits on something: not this axis const key = keyOf(i).toLowerCase(); for (const n of named) if (n.length > 3 && key.includes(n)) return false; return true; }); } /* ──────────────────────────────────────────────────────────────────────────── * DELIVERED — READ THE RECORD'S STRUCTURE, NOT A SUBSTRING OF THE FILE. * * This was `doneText.includes(id) || boardText.includes(id)` over two whole * documents, so ANY MENTION ANYWHERE counted as "handed out" — including a row * written specifically to warn that an item must NOT be built (q-2ba7f0c5). * The inversion is the finding: the more carefully a coordinator documents why * something must not be routed, the more completely it disappears from the verb * that routes. * * MEASURED ON `origin/main` @17b72ff, and the numbers moved the fix twice: * * q-11257590 hidden by the AIDE'S OWN `🚧 In Progress` ROW, which merely * MENTIONS it in its Last note. A status-only rule leaves it * hidden — the occurrence-vs-position defect one level in — so * identification has to be the row's SUBJECT, not any of its cells. * q-ad0fc8d4 hidden by its own `⏸ Returned to the aide unbuilt` row. * q-507e80c4 hidden with NO PARSED ROW AT ALL: the id sits in board prose * outside the table, which a whole-file substring test cannot * distinguish from a lane. * q-507e80c4 ALSO hidden by a DONE entry that CITES it as the still-open * part (b) of work whose part (a) landed — the canon case * q-912a67e8 requires, LIVE today rather than latent. * * Neither half is fixed by relabelling a row: that is satisfying a guard by * editing what it reads (q-507e80c4 constraint 1) and it would destroy the * warning the row exists to give. */ /** The ids a board row names as ITS OWN SUBJECT — the Stream cell only. */ function rowSubjectIds(row: WorkstreamsV1Row): string[] { return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string); } /** What a board row says about an item it names as its SUBJECT. */ export type BoardHold = { /** The state the seam read off the row's Status cell. */ state: WorkState; /** The routing reason: the state, except `parked` is split into held · queued · parked. */ why: string; /** The declaration the reason was read from — never the commentary after it. */ status: string; /** Whether the row's Branch · Worktree cell names anything at all. */ builtWork: boolean; branch: string; }; /* * ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸ * MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to * `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated * green in an open PR and the other has nothing behind it. Reporting them under * one word re-creates the defect with a wider net: a reader told "parked" for * `#292`'s row would go and build it. * * Two INDEPENDENT facts, deliberately not one: the WORD comes from the * declaration (`held` · `queued` · `parked`), and `builtWork` comes from the * Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell, * and that disagreement is worth seeing rather than resolving. */ const STATUS_DECORATION = /^[\s*⭐]+/u; function holdReasonOf(state: WorkState, status: string): string { if (state !== "parked") return state; const decl = declarationOf(String(status).replace(STATUS_DECORATION, "")); if (/\bheld\b/i.test(decl)) return "held"; if (/\bqueued\b/i.test(decl)) return "queued"; return "parked"; } /** * What does the board say about this item, if it is the SUBJECT of any row? * * ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY * (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍 * only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left * its item at the TOP of the pool: a seat taking it would have rebuilt the PR. * Measured on the live board 2026-09-14: seven `⏸` rows, every one the * coordinator's most careful bookkeeping, every one invisible to this join. * * ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That * fix made a `⏸ Parked` row NOT silence its item, because the alternative * then was a SILENT drop with no axis. The drop is no longer silent: the item * lands on `delivered` with the row's state as its `why`, and an `AWAITS` * item still reaches `awaitingDecision`, which is gathered over every open * item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row * must not make its item VANISH — still holds; what changes is that a warning * row now also stops the item being HANDED OUT, which is what a warning is for. * * Conditions: * SUBJECT the row's Stream cell names the item — by recorded id, or by the * text prefix `claim` has always written there. A mention in a Last * note is one lane REFERRING to another item — the aide's live row * does exactly this — and a status-only rule would still hide it. * STATE whatever the seam reads: in-progress, in-review, parked (split * into held · queued · parked, see `holdReasonOf`), blocked, * orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL * excludes: the row is on the board with this item as its subject, * and offering it anyway is the silent path this exists to close. * A `✅ Done` row excludes too — that item is offerable to nobody. * * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix * would have shipped a transitional hole in its own negative control: every row * `claim` wrote before this change carries `keyOf(item)` — a 60-character text * prefix — and no id at all, so an id-only rule leaves a lane's OWN live item * routable. Measured: q-2ba7f0c5's row, written minutes earlier by the * pre-merge server, was offered back while it was in hand. Rows written from * here carry the id (see `claim`), so the prefix arm is the compatibility half * rather than the mechanism, and it is deliberately still SUBJECT-only. */ function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null { const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim(); for (const r of rows) { const stream = String(r.stream).replace(/\s+/g, " ").trim(); const subject = rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)))); if (!subject) continue; const state = workStateOf(r.status); const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim(); return { state, why: holdReasonOf(state, r.status), status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")), builtWork: branch.length > 0 && branch !== "—" && branch !== "-", branch, }; } return null; } /** * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that * merely MENTIONS it? * * ⛔ THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩` * appeared ANYWHERE in an entry, on a census that had inverted: today * `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the * house spelling everything else teaches — so the COMPLIANT way to cite an item * became the spelling that meant "delivered". Measured before removal, on 143 * open rows: ELEVEN false positives, ZERO independent true positives (every row * it correctly excluded was already excluded by `!i.done`, which runs first), and * a MISS on its own founding case — the row recorded as q-b4e7c209 had its work * merged under a different row's citation, stayed `[ ]`, and was offered as top * P1 twice. * * ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED * BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several * reasons this repo's own canon REQUIRES — a residual gap must cite its queue * item — so no reading of that file separates a closure from a mention. Three * replacements were measured and each was worse or equal: position does not * discriminate (a known-false and a known-true entry are structurally identical, * and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-` * id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23 * false positives, because open rows cite refs as EVIDENCE. * * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer * delivery from prose: it requires the entry to carry the item's own * deterministically composed text, `summarize(item.text)`, which a VERB writes. * A citation cannot accidentally satisfy it. * * ⚠ IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS — 0 of 143 open, 0 of 9 * closed — AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not * code: rows here are closed with `land --write` and the composed line is then * OVERWRITTEN with hand-written prose, so the structural record it would match is * destroyed within the minute. **Do not re-derive "dead code" from another zero * count.** Its input returns the moment a closing entry keeps what `land` wrote. * * ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own * cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S * CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE. * Accepted knowingly — the arm that claimed to cover it did not, on the one live * instance it had. Closing such a row is coordinator discipline, not a predicate. */ function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean { const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim(); const target = norm(summarize(text)).replace(/…$/, ""); return entries.some((e) => { // A leading recorded-id token is stripped before comparing, so an entry // written as `⟨id⟩ ` and one written as `` agree. const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, ""); return target.length >= 12 && body.startsWith(target); }); } // ---------- next_unblocked ---------- export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() }; export async function nextUnblockedTool(args: { project: string; repo?: string }) { // A HALT IS A NAMED STATE AND IT BLOCKS THE LANE, not a suggestion. Handing out // the next item during a board cutover or a cited BLOCKER is how work lands on // a base nobody meant to be building on. const halt = haltState(); if (halt.halted) { return { ok: false as const, error: `HALTED by ${halt.by}: ${halt.reason}. No item is handed out while a halt is set — clear it with set_halt{clear:true} when the named condition is gone.`, halted: true, }; } const repo = args.repo ?? process.cwd(); // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced // a confidently wrong routing decision in both directions inside ten minutes — // a row ruled un-claimable from a stale file, and a worker told to hold on a // row that was already split. The answer was well-formed and said nothing // about its source, which is what made it invisible. const tree = treeProvenance(repo); const q = readDoc(repo, QUEUE_DOC); if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree }; const items = queueItemsOf(q.doc); // The seam's own count of unticked rows — the number every axis must add back // up to. Taken BEFORE any exclusion so it cannot inherit one. const parsedOpen = items.filter((i) => !i.done).length; // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not // establish that. // // MEASURED, 2026-09-01: an item whose work had merged in #196 was still `[ ]` // in the queue — closing it is a separate act from landing it — so it stayed // eligible. A compaction then re-issued its id, it surfaced as the top item, // and it was routed back to the worker that had closed it an hour earlier. // That worker declined because it recognised its own acceptance criteria: // RETAINED CONTEXT, which is not a control and which a `/clear` or a // compaction removes silently. // // So delivery is read from the RECORD instead: an item cited in docs/DONE.md, // or already carrying a 🚧 row on the board, has been handed out. Stable ids // (Task 21.1) are what make this join reliable — with a content-hash id the // board row and the DONE entry stopped matching the moment anyone reworded // the item, which is how the memory was lost in the first place. // ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to // be subtracted from `open` BEFORE any axis ran, so an item it excluded could // appear on NO axis by construction — the one guarantee this verb's contract // makes is that it never skips silently, and this was the one path that did. // // MEASURED on the live queue at `a032069`: the file held 143 open rows, the // seam parsed all 143, and this verb reported `open: 129`. Its declared axes // accounted for ONE. Fourteen rows were outside the router's universe and // nothing in the response said so — invisible from the only seat that would // notice, because a worker asking for the next item still gets a real one. // // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart: // a row is excluded BY a named cause, and the cause is what gets reported. const deliveredBy = new Map(); const doneDoc = readDoc(repo, DONE_DOC); const boardDoc = readDoc(repo, BOARD_DOC); const boardText = boardDoc?.text ?? ""; const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : []; const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : []; for (const i of items) { if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns // Board first, and the order is load-bearing for the REPORT rather than the // routing: both causes exclude, but a board row is a different remedy // (wait for the merge · ask its owner · ask the human it awaits, by its // `why`) from a landed delivery (close the row). const hold = boardHoldOf(boardRows, i.id, String(i.text)); if (hold) deliveredBy.set(i.id, { reason: "board", hold }); else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" }); } const open = items.filter((i) => !i.done && !deliveredBy.has(i.id)); // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion // reported silently is the same defect as an incorrect one. // // ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on // the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word, // and the two want opposite things from the reader — leave the first alone, // MERGE the second. `why` is the seam's state (parked split into held · // queued · parked) and `builtWork` says whether the row names a branch at // all, so "held with nothing behind it" is visible rather than resolved. const delivered = items .filter((i) => !i.done && deliveredBy.has(i.id)) .map((i) => { const d = deliveredBy.get(i.id)!; return d.reason === "board" ? { item: keyOf(i), id: i.id, reason: "board" as const, why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch } : { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" }; }); // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an // identical row` twice yields `q-c9f3ded8` twice. // // Everything downstream is keyed by id, so ONE delivery record then excludes // BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3` // to `offered: 1`. The accounting still reconciles — both are named — so the // self-check above CANNOT catch it, which is exactly why it needs its own axis // rather than a flag on `reconciles`. // // ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a // filter working as designed; a row reported TWICE cannot be anything but the // accounting. It is the one symptom here that does not rest on a count. const idCounts = new Map(); for (const i of items) if (!i.done) idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1); const duplicateIds = [...idCounts.entries()] .filter(([, n]) => n > 1) .map(([id, rows]) => ({ id, rows, why: `${rows} open rows share the id ${id} — stable ids are derived from row TEXT, so identical rows collide. Every axis here is keyed by id, so one delivery record excludes all ${rows}. Reword one row to separate them.`, })); const ranked = open .map((i, idx) => ({ i, idx })) .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx); const skipped: { item: string; blockedBy: string }[] = []; // NOT WORKER-CLAIMABLE (Task 15.4). Measured: 13-14 of the open queue is // `[SWEEP:canon]`/`[SWEEP:canon.N]` — canon prose the aide and coordinator // author directly into the playbook, never assigned as code work. Handing // one out here is the exact defect: `next_unblocked` offered one outside // the caller's lane while the rest of what remained was this same family. // Skipped VISIBLY, same discipline as a blocked top item — never silently // dropped, so a caller can tell "nothing left for me" from "nothing left". const notClaimable: { item: string; sweepTag: string }[] = []; // AWAITING A HUMAN (q-e334937d). "Unblocked" means dependency-free, and an // item awaiting a ruling is dependency-free and unbuildable at once. Measured // 2026-09-02: this verb offered q-11257590 as next; the lane read the // acceptance PROSE to learn it awaited David, declined, and picked by hand — // a read every lane offered it would pay again. The fact is now a FIELD on // the item (`**[AWAITS:]**`, read by the seam), never inferred from // prose. Its own axis, checked FIRST: it is the most specific thing that can // be true of an item, and "three items wait on a human" is a fact David can // act on where "nothing to route" is not. Skipped for routing, never dropped. // // GATHERED OVER EVERY OPEN ITEM, NOT OVER `open` — measured on the real queue // the first time this ran: q-11257590 carried the marker and appeared in NO // bucket, because the board holds a "⏸ Parked — awaits David" row naming it // and the delivered-join above reads any board mention as "handed out". The // row written to WARN about the item was what hid it. Routing still honours // the join; this report does not, because its reader is the human the item // waits on, and a pending decision does not stop being pending when someone // writes it on the board. const awaitingDecision: { item: string; id: string; who: string; onBoard: boolean }[] = []; for (const i of items) { if (i.done) continue; const who = awaitingOf(i); if (who) awaitingDecision.push({ item: keyOf(i), id: i.id, who, onBoard: boardText.includes(i.id) }); } let pick: QueueItem | null = null; for (const { i } of ranked) { if (awaitingOf(i)) continue; // reported above; never routed const tag = sweepTagOf(i); if (tag && /^canon(\.\d+)?$/.test(tag)) { notClaimable.push({ item: keyOf(i), sweepTag: tag }); continue; } const b = blockedBy(i); // NEVER STALL THE LANE waiting on a reorder: a blocked top item is skipped, // visibly, and the next unblocked one is taken. if (b) { skipped.push({ item: keyOf(i), blockedBy: b }); continue; } pick = i; break; } const silent = noDownstream(open); // AN AXIS THAT FIRES ON EVERYTHING IS NOISE. If no item in the queue declares a // dependency, then "nothing waits on this" is true of every item and the axis // cannot discriminate — listing all of them trains the reader to skim, which is // the severity finding one file over. Say the axis is uninformative instead. const undiscriminating = silent.length === open.length && open.length > 1; return { // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A // routing answer whose tree is unnamed is the defect this row exists for. tree, ...(tree.warning ? { staleWarning: tree.warning } : {}), ok: true as const, project: args.project, open: open.length, // ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING // EXPLAINED and the only thing that ever caught it was someone counting the // file by hand. `parsedOpen` is every unticked row the seam sees; `offered` // is what routing considered; `excluded` is what the axes account for. When // `reconciles` is false, rows have left the universe with no named cause — // the exact condition that was previously unobservable from the response. // // ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work // because its own bookkeeping is off strands every lane, which is worse than // the miscount. The caller gets a real item AND the discrepancy. accounting: { parsedOpen, offered: open.length, excluded: delivered.length, reconciles: parsedOpen === open.length + delivered.length, }, // A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`. delivered, // A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above. duplicateIds, next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null, skipped, // A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's // to take" rather than "blocked on something else". Collapsing the two // would read a claimability gap as a dependency, which is a different // remedy (route it, don't wait for it). notClaimable, // A THIRD AXIS, and not a variant of either above: `skipped` waits on // another ITEM, `notClaimable` is not this caller's to take, and this waits // on a HUMAN. The remedy differs each time (wait · route · ask), so one // list covering them would leave the reader to infer which applies. awaitingDecision, boardHunks: [ ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`), ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`), ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`), ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`), ...delivered.map((d) => d.reason === "board" ? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})` : `⏭ not offered — delivery recorded in DONE.md (${d.id})`, ), ], // A SEPARATE AXIS, deliberately. See noDownstream(). noDownstream: undiscriminating ? { count: silent.length, items: [], why: `NO ITEM IN THIS QUEUE DECLARES A DEPENDENCY, so "nothing waits on this" is true of all ${open.length} and this axis ` + "cannot discriminate. Reporting every item would train you to skim it. Every item's absence here is equally silent, " + "which is a fact about the QUEUE rather than about any item — declare dependencies (`blocked by `) and this becomes useful.", } : { count: silent.length, items: silent.slice(0, 10).map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })), why: "nothing in the queue says it waits on these, so their absence is SILENT — they need an explicit check at each stage boundary. " + "Detected from item text: an unstated dependency is invisible here.", }, }; } // ---------- claim ---------- /** * Insert or replace ONE row in the workstreams table, as TEXT. * * 2.4b: "a verb only fails closed if it is the ONLY path — nothing currently * stops a coordinator editing the board directly instead of calling `claim`." * `claim` used to RETURN a `boardHunk` for someone to paste by hand, which is * the same discipline with a nicer API. Every legacy row on the board today is * hand-written, and that is why `stall_check`'s vcs half has nothing to * resolve: a pasted row carries a path, a `claim`-written row carries a real * branch ref. * * Text insertion rather than a re-render: the rest of the file stays * byte-identical, the same reason `land` appends its DONE line as text. A board * this verb rewrote wholesale would be a diff nobody could review. */ export function upsertBoardRow(text: string, agentId: string, row: string): { text: string; action: "inserted" | "replaced" | "unchanged" } { const lines = text.split("\n"); const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l)); if (header === -1) return { text, action: "unchanged" }; // The table ends at the first line that is not a row. let end = header + 1; while (end < lines.length && /^\s*\|/.test(lines[end])) end++; // OWNER MATCHED ON THE CELL, NOT ON THE WHOLE LINE. An agent id appearing in // a "Last note" cell is not that agent's row — the occurrence-vs-position // defect this repo has re-derived at four granularities. const ownerOf = (l: string) => (l.split("|")[2] ?? "").replace(/[`*\s]/g, ""); const existing = lines.findIndex((l, i) => i > header + 1 && i < end && ownerOf(l) === agentId); if (existing !== -1) { if (lines[existing] === row) return { text, action: "unchanged" }; lines[existing] = row; return { text: lines.join("\n"), action: "replaced" }; } lines.splice(end, 0, row); return { text: lines.join("\n"), action: "inserted" }; } export const claimSchema = { project: z.string().min(1), agentId: z.string().min(1), itemId: z.string().optional(), repo: z.string().optional(), base: z.string().optional(), task: z.string().optional(), write: z.boolean().optional(), /** * ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value. * What the slice delivers instead (a ruling, a canon edit, a measurement). * No worktree is cut; the board cell carries the statement in words, which * the grammar and `stall_check` both read as DELIBERATE rather than missing. */ noCodeDeliverable: z.string().min(1).optional(), }; export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) { const halt = haltState(); if (halt.halted) { return { ok: false as const, error: `HALTED by ${halt.by}: ${halt.reason}. Claiming is refused while a halt is set.`, halted: true, }; } const repo = args.repo ?? process.cwd(); const q = readDoc(repo, QUEUE_DOC); if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` }; const items = queueItemsOf(q.doc); let item = args.itemId ? items.find((i) => i.id === args.itemId) : null; if (args.itemId && !item) return { ok: false as const, error: `no queue item with id '${args.itemId}'` }; if (!item) { const next = await nextUnblockedTool({ project: args.project, repo }); if (!next.ok || !next.next) return { ok: false as const, error: "no unblocked item to claim" }; item = items.find((i) => i.id === next.next!.id) ?? null; } if (!item) return { ok: false as const, error: "no unblocked item to claim" }; // TASK 1 LANDED, SO THE PLACEHOLDER IS GONE RATHER THAN LEFT SWITCHED OFF. // // While `ensure_worktree` did not exist this returned a loud warning that the // worktree was NOT ensured — conditional on the verb's absence, because a // warning that never clears stops being read. The verb exists now, so `claim` // CALLS it: the interface is unchanged and the gap is closed rather than // annotated. An interface with a placeholder nobody removes is how a temporary // state becomes canon. // // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an // item to an agent with nowhere isolated to work is the shared-checkout failure // this pair exists to prevent. // ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five // seats wrote this by hand in five spellings because the verb had no way to // say it; `stall_check` reads the prose cell as deliberately branchless and // keeps the row out of its population rather than calling it unmeasurable. if (args.noCodeDeliverable) { const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`; const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`; let board: { action: string; path?: string } = { action: "reported" }; const b = readDoc(repo, BOARD_DOC); if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` }; else if (args.write) { const next = upsertBoardRow(b.text, args.agentId, boardHunk); if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text); board = { action: next.action, path: BOARD_DOC }; } return { ok: true as const, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable }; } const wt = await ensureWorktreeTool({ agentId: args.agentId, repo, base: args.base ?? "main", task: args.task, }); if (!wt.ok) { return { ok: false as const, error: `cannot claim: no isolated worktree — ${wt.error}`, item: { id: item.id, priority: item.priority }, }; } // A NEW TASK DOES NOT START FROM A STALE TREE. // // `ensure_worktree` is idempotent and reuses the agent's existing tree, which // is right mid-slice (1.3) and wrong here: `claim` MEANS "start something // new", and a tree left on last week's base produces the confidently-wrong // inventories the worker card warns about, with nothing about the result // looking stale. // // A freshly CREATED tree is cut from origin/ and needs no check — this // only ever fires on reuse. if (!wt.created && wt.atBase === false) { return { ok: false as const, error: `cannot claim: the worktree at ${wt.path} is NOT at ${wt.base}` + `${wt.behindBy ? ` (${wt.behindBy} commit(s) behind)` : ""} — it is on '${wt.branch}' at ${String(wt.sha).slice(0, 8)}. ` + `A new task started here would be based on stale content, and nothing about the result would look stale. ` + `Run \`refresh_worktrees\` to fast-forward idle trees, or finish and land the work already in it.`, item: { id: item.id, priority: item.priority }, worktree: { path: wt.path, branch: wt.branch, sha: wt.sha, base: wt.base, atBase: false }, }; } // THE CELL NAMES THE REMOTE-TRACKING REF, not the bare local branch. // // A bare name resolves in the checkout that created it and nowhere else, so // the same board read differently from two checkouts and whether a row was // measurable depended on who last ran `git fetch` — a property of the reader // rather than of the work. Writing `origin/` makes the cell mean the // same thing everywhere. // // It does not resolve YET, because a freshly cut branch is unpushed. That is // correct and `stall_check` says so in those words: an unpushed lane has no // shared evidence of activity, which is an absence of evidence rather than a // stall. const intendedRef = boardRefFor(wt.branch ?? ""); /* * AND NOW ACTUALLY ENFORCED AT THE WRITE. * * The comment here used to claim exactly that — "enforced at the WRITE as well * as the read, a rule the writer can defeat is a rule the writer defeats" — * and it was FALSE. `boardRefFor` PREFIXES `origin/`; it refuses nothing. So * `claim` could still write `origin/main` into a board cell, which * `stall_check` then correctly refuses to measure, leaving a row that can * never stall and never be measured. board-ref.ts's own header states the * rule; #197 shipped the read half and I wrote the sentence and did not do it. * * A rule carrying a false mechanism is worse than an absent rule: the next * reader derives from the mechanism, and this one asserted the very coverage * it lacked. * * REFUSED: the four kinds that are structurally wrong however fresh the lane * is — a shared ref (measures the fleet), a path (not a ref at all), another * agent's branch (measures their work), and an empty cell. * * ALLOWED: `unpushed` and `local-only`, deliberately. A freshly cut branch is * ALWAYS unpushed, so refusing those would refuse every legitimate claim — * the over-narrowing I have now made twice in this classifier's history, where * a rule meant to make a check honest disabled it instead. `merged` is allowed * too but noted, since re-claiming onto a landed branch is unusual rather than * structurally broken. */ const refusable = new Set(["shared", "path", "unscoped", "empty"]); const cellVerdict = classifyBoardRef(repo, args.agentId, `\`${intendedRef}\``, `origin/${args.base ?? "main"}`); if (refusable.has(cellVerdict.kind)) { return { ok: false as const, error: `cannot claim: the board cell would name '${intendedRef}', which is not a per-agent activity signal ` + `(${cellVerdict.kind}) — ${"why" in cellVerdict ? cellVerdict.why : ""} ` + `The row would be unmeasurable the moment it was written, so it is refused here rather than reported later.`, item: { id: item.id, priority: item.priority }, worktree: { path: wt.path, branch: wt.branch }, }; } // THE ROW NAMES THE ITEM BY ID, so the delivered-join can recognise a row this // verb itself wrote. Measured on the live board: `keyOf` is the item's text // prefix and the recorded `⟨id⟩` token is not part of `text`, so NO // claim-written row has ever carried an id — the join could only ever match // HAND-WRITTEN rows, which is the opposite of what it was for. Stamping it // makes the identification recorded rather than derived from a 60-character // prose prefix that any rewording changes. const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`; // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with // write:true, matching `land`. A returned hunk that a human pastes is the // same discipline with a nicer API, and the pasted rows are why the board // carries paths where a claim would have carried a resolvable branch ref. let board: { action: string; path?: string } = { action: "reported" }; const b = readDoc(repo, BOARD_DOC); if (!b) { board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` }; } else if (args.write) { const next = upsertBoardRow(b.text, args.agentId, boardHunk); if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text); board = { action: next.action, path: BOARD_DOC }; } return { ok: true as const, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: true, worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base }, }; } // ---------- land ---------- export const landSchema = { project: z.string().min(1), pr: z.string().min(1), queueItemId: z.string().optional(), repo: z.string().optional(), base: z.string().optional(), write: z.boolean().optional(), /** One-line result, written after the citation on the closed item's line. */ result: z.string().optional(), }; /** * What the recording step needs to know about a merge: which head actually * landed, and when. Injected so the report is provable without the network. */ export type PrComment = { body: string; createdAt?: string; author?: string }; /** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */ export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null; export type ReadMergeFacts = (repo: string, n: string) => MergeFacts; const ghMergeFacts: ReadMergeFacts = (repo, n) => { try { const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024, }); const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] }; if (!j.headRefOid || !j.mergedAt) return null; return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt), comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })), }; } catch { return null; } }; /** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */ export function landingCitations(repo: string, n: string): string[] { let subjects = ""; for (const base of ["origin/main", "main"]) { try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ } } const marker = `(#${n})`; const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker)); return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : []; } export async function landTool( args: { project: string; pr: string; queueItemId?: string; repo?: string; base?: string; write?: boolean; result?: string; }, readMergeFacts: ReadMergeFacts = ghMergeFacts, readVerdictLog: ReadVerdictLog = roomLog, ) { const repo = args.repo ?? process.cwd(); const base = args.base ?? "main"; const n = prNumber(args.pr); // A CITED PR OR REFUSE. A `DONE:` without a ref cannot be tied to anything by // anyone, ever — which is its own finding, not a formatting preference. if (!n) { return { ok: false as const, error: `'${args.pr}' names no PR number — a DONE entry with no ref can never be tied to the work. Cite owner/repo#N.` }; } // MERGED ON THE TARGET'S TIP, NOT THE MERGE BASE. // // "What does the thing I am merging INTO have that I do not?" is answered only // by the target's tip: the merge base is the point the branch DIVERGED from, so // anything landed after the cut is missing from it too — comparing against it is // exactly as blind as comparing against the branch (kit#102/#103). Squash merges // leave no ancestor either, so the citation in the merge SUBJECT is the tie. const ref = `origin/${base}`; let landedIn: string | null = null; let reason: string | null = null; // ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first. const numbers = prNumbersOf(args.pr); const landings: Record = {}; let searched = 0; const missing: string[] = []; try { for (const num of numbers) { const found = landingCommitOf(repo, ref, num); searched = found.searched; if (found.sha) landings[num] = found.sha; else missing.push(num); } landedIn = landings[n] ?? null; } catch (e) { reason = `could not read ${ref} (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`; } if (reason) return { ok: false as const, error: reason }; if (!landedIn || missing.length) { return { ok: false as const, error: `${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` + `${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` + `a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`, comparedAgainst: ref, searched, }; } const q = readDoc(repo, QUEUE_DOC); const d = readDoc(repo, DONE_DOC); if (!q || !d) return { ok: false as const, error: `need both ${QUEUE_DOC} and ${DONE_DOC} under '${repo}'` }; const items = queueItemsOf(q.doc); // WHICH ITEM A PR CLOSES IS A JUDGEMENT, AND GUESSING IT CLOSED THE WRONG ONE. // // The first real use of this verb matched `#116` against an item that merely // MENTIONED #116 in its body — an item about seam ids — and reported it closed. // With write:true it would have closed unrelated, still-open work. That is the // occurrence-vs-position defect I fixed for sweep tags, reintroduced here in a // different matcher hours later: a citation IN an item's text is not a claim // that the item is closed BY it. // // There is no positional convention to anchor on, so the honest fix is not a // better guess: `land` CLOSES ONLY WHAT IT IS TOLD TO CLOSE. Without an explicit // `queueItemId` it closes nothing and reports the candidates for the caller to // pick, saying so. const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text))); // ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209 // shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261 // (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one. // The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record // every check reads, local, no network — and listed beside the #N candidates // at the one moment both facts are in hand. Closing nothing: a sibling is a // question for the coordinator, not a claim. const citedIds = landingCitations(repo, n); const siblings = items .filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c))) .map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) })); const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null; if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` }; // CLOSE = STATUS + CITATION. The item's priority and body BYTES are untouched: // the seam re-renders `- [x] (P2) ` from the same text it parsed, so // closing cannot reword an item. That is asserted by a fixture, not by // inspection. // // AND THE LINE CITES WHAT CLOSED IT (q-6d21ff84). `doctor:queue-done-loop` // requires a `[x]` item's FIRST LINE to cite the PR that DONE.md records — // that strictness is what makes a closure machine-checkable, and the repo // paid for it. This verb ticked the box and wrote DONE.md and left line 1 // uncited, so EVERY successful `land` turned main red at the next root gate // and the coordinator hand-repaired a file the verb had just written // (`c29becd`, and again on q-e334937d). The writer and the auditor were built // to different contracts; the fix belongs in the writer, because loosening // the auditor to accept what the writer emits is how a check stops being one. // // A SUFFIX, NOT A REWRITE. The existing text is preserved byte-for-byte as a // prefix; ` · **closed by [ — ]**` is appended — the exact form // q-40449919 carries after its manual repair. Line 1 only, because line 1 is // all `item.text` is and all the check reads; a citation on a continuation // line satisfies nothing (the occurrence-vs-position trap, hit twice). // // IDEMPOTENT: an item whose line already cites this PR (in any spelling // `refsIn` ties to it) is left byte-identical, so a second `land` cannot // double-cite and a hand-cited item is not re-cited. let queueChanged = false; let citationAppended: string | null = null; // The DONE entry and the emitted event describe the WORK, from the text the // item had BEFORE the citation was appended — otherwise the summary would // carry the citation the DONE line already ends with, twice on one line. const originalText = target ? String(target.text) : null; if (target) { target.done = true; queueChanged = true; const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n }; const contextRepo = originRepoOf(repo); /* * ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩). * * This asked whether the PR number appears ANYWHERE in the row, so a row that * DISCUSSES its own closer read as already cited and `land` wrote no token. * The natural experiment that isolates it — three rows, one variable: * * ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN * ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN * ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN * * ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of * somebody else's PRs is cited normally; a row naming its own once is not. So the * better a row documents what closed it, the more certainly the closure goes * unwritten — and the ARTEFACT is what loses, silently. * * ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION * ASKED: the behaviour is harmless to every CONSUMER, because both readers accept * a line-1 prose mention and `doctor` falls back to the row. That was measured and * a patch was reverted rather than shipped. What nobody asked was what it does to * the ARTEFACT — a closure whose citation sits in no fixed place. Position is what * makes a ref a citation, and that rule applies to the verb that WRITES one. * * ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for * rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established * must never happen. */ const closingHere: ReturnType = closingCitation(String(target.text))?.refs ?? []; const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo })); if (!alreadyCited) { citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`; target.text = `${target.text}${citationAppended}`; } } /* * ⛔ ⟨q-552b9912⟩ "ALREADY LOGGED" IS A QUESTION ABOUT THE ITEM, NOT THE PR. * * It asked whether ANY DONE entry cited this PR, so the second item a PR closed found the first item's entry, * reported `alreadyLogged: true`, and wrote nothing: the row left the queue and never reached the delivery record, * so `scan_record_events` emitted no item event for it. Measured twice on 2026-09-16 — ⟨q-3b8e05af⟩ on #360 and * ⟨q-144c97a8⟩ on #366, both hand-repaired (07338a1, 00c8315). * * Keyed per item BY POSITION ONLY: an entry is this item's when it LEADS with `⟨id⟩` (the form this verb writes). A * by-mention fallback was tried and REMOVED (qa's FAIL on #370 @ fce3ae1): an id-less entry for this PR that merely * MENTIONS the id — B never landed — read as logged and wrote nothing, the same defect narrowed. A mention is not a * record. A land with no item keeps the PR key, since there is no item to key on. */ const citesThisPr = (e: { ref?: string }) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")); const already = target ? doneEntriesOf(d.doc).some((e) => leadingItemIdOf(e.text) === target.id) : doneEntriesOf(d.doc).some(citesThisPr); // A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE. // // First real use produced `- [x] PR #117 — #117 · 2026-08-28`: no description of // the work, and a BARE ref where every existing entry carries `owner/repo#N`. // The glyph contract parses it, so a shape check would pass it — and it tells a // reader nothing, which is the whole job of the file. // // So: the summary comes from the item being closed, and a bare `#N` is reported // as UNDER-QUALIFIED rather than silently written. const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr)); const summary = originalText !== null ? summarize(originalText) : null; /* * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT * — `⟨q-d046204d⟩`. * * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted * from these entries: IT WAS CUT, because in the row it sits later than the * truncation point. So the entry came out with a PERFECT `ref` slot and a `text` * that names no row — and `closingRefs` needs BOTH halves, so the row reads * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured. * * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it * out of the truncated region BY CONSTRUCTION, so it survives any headline length * — where raising `max` only moves the cliff. The row is explicit that "write * longer headlines" is not an acceptable fix, and it is right: a length that is * enough today is a truncation tomorrow. * * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩ * ` and one written as `` agree". So the composed-summary arm * keeps matching and this needed no change there. * * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE * in an entry — a reader-side guess that produced eleven false positives, because * this repo's canon requires entries to cite item ids for other reasons. This * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It * infers nothing, and re-reading a sigil as "delivered" is still wrong. * * The id is the ITEM's, taken from the record rather than re-derived: `target` is * non-null whenever `summary` is, since the summary comes from its text. */ const doneLine = already || !summary || !target ? null : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`; // APPEND TO DONE.md — the half this verb exists for. // // The first `write:true` run closed the queue item and wrote NOTHING to // DONE.md: it did half the loop, and the half it skipped is the one that failed // three times in nine hours and left twelve merges unlogged for 23 hours. A verb // built to close that gap that does not write DONE is the gap with a tool in // front of it. // // Appended as TEXT to the last done block so the rest of the file is replayed // byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and // an entry added to the record model renders through the pinned glyph contract. const wrote: string[] = []; let stampedNotSupplied: string[] = []; // A stale-base refusal is a RESULT, not a crash: the caller needs to know // nothing was clobbered, what moved, and what this call had already written // before it stopped. Thrown inside writeDoc so it cannot be ignored; converted // here so the verb still answers. try { if (args.write) { if (queueChanged) { const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote, args.queueItemId ? [args.queueItemId] : []); if (w.written) wrote.push(QUEUE_DOC); // Rows stamped that this call did not ask to touch. `queueItemId` is the // one item the caller supplied, so anything else here is a row that // arrived unstamped from somewhere: the absorption this verb would // otherwise carry to main under its own name. stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId); } if (doneLine) { const blocks = d.doc.blocks; let last = -1; for (let i = 0; i < blocks.length; i++) if (blocks[i]?.kind === "done") last = i; if (last === -1) { return { ok: false as const, error: `${DONE_DOC} has no parsed done block to append to — refusing to guess where the entry goes. A DONE.md that parses to zero entries is a defect in the log, not an empty log.`, }; } const block = blocks[last] as { kind: "done"; entries: unknown[] }; const parsed = parseWorkDoc(`## Done\n${doneLine}\n`); const entry = doneEntriesOf(parsed)[0]; if (!entry) { return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` }; } block.entries.push(entry); if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written) wrote.push(DONE_DOC); } } } catch (e) { if (e instanceof StaleWriteError) { return { ok: false as const, error: e.message, staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten }, }; } throw e; } // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING. // // Read back from disk, AFTER the write, and refuse to emit anything whose ref // is not there. The ordering is the guarantee: an event cannot exist without // the record entry that caused it, because the record is what is consulted to // decide whether to emit. An event stream that can say "task X complete" // while DONE.md does not is a second source of truth, and record-vs-state // divergence is the defect this fleet hit most this week. // // Reported, never thrown: a delivery failure must not undo a merge that has // already happened. `land` is a RECORDER. let events: { emitted: RecordEvent[]; deliveries: unknown[]; refused: string[] } = { emitted: [], deliveries: [], refused: [] }; if (args.write && target) { const after = readDoc(repo, DONE_DOC); const recordText = after?.text ?? ""; // ⟨q-cbace757⟩ — one event, every cited ref; each checked against the record on its own. const refs = prRefsIn(args.pr); const ev: RecordEvent = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) }; const derived = eventIsDerived(recordText, ev); if (!derived.ok) { events.refused.push(derived.error); } else { const now = Date.now(); const { subs, deliveries } = evaluate(readSubs(), ev, now); commitEvaluation(subs); events = { emitted: [ev], deliveries, refused: [] }; } } /* * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩) * * `merge` refuses this BEFORE the fact and is the control that prevents. This * one runs after, and its job is different: it is how the fleet LEARNS a * crossing happened anyway — through a merge done by hand, by `gh` directly, * or by a seat that never called the verb. * * It REPORTS and never refuses. A closure is not the right place to litigate a * merge that already happened: the verdict is QA's artefact, the merge may have * been someone else's, and blocking the record would leave the work landed and * unrecorded — strictly worse than landed and recorded with a flag on it. * * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine". */ const gatedHead = (() => { const mf = readMergeFacts(repo, n); if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` }; const log = readVerdictLog(args.project); if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` }; const at = Date.parse(mf.mergedAt); if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` }; const { verdicts: bus, unparsed } = verdictsFor(log, n); // ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and // the PR page's typed lines. Gatedness is (head sha, typed verdict); the // merge time only DISCLOSES lateness, in words, beside the answer. const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])]; const a = gatedBy(verdicts, mf.headRefOid, at); if (a.gated) { const late = (a.lateByMs ?? 0) > 0; return { checked: true as const, gated: true as const, head: mf.headRefOid.slice(0, 8), // ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the // sender alone; `attribution` says whether that is a seat or the account. by: { from: a.gater, sha: a.by.head.slice(0, 8), channel: a.by.channel ?? "bus", attribution: a.attribution, ...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}), ...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}), }, recordedAfterMerge: late, verified: a.verified, ...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}), }; } return { checked: true as const, gated: false as const, head: mf.headRefOid.slice(0, 8), reason: a.reason, gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)), note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` + `The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` + (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""), }; })(); return { ok: true as const, project: args.project, pr: `#${n}`, comparedAgainst: ref, landedIn: landedIn.slice(0, 8), gatedHead, // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it // fixed, so a foreign row cannot pass silently in either direction. ...(stampedNotSupplied.length ? { stampedNotSupplied, stampNote: `recorded an id for ${stampedNotSupplied.length} queue item(s) this call did not supply (${stampedNotSupplied.join(", ")}) — ` + `they arrived unstamped, most likely a raw text append, and would have reddened doctor:queue-id-recorded on whoever committed next. ` + `Each id written is the one the item already had, so no value changed.`, } : {}), queueItem: target ? { id: target.id, closed: true, // `textUnchanged` keeps its meaning — the priority and the body bytes // the item had are all still there, in place. What is new is a SUFFIX // on line 1, reported separately so a reader can tell "reworded" from // "cited": null when the line already cited this PR. textUnchanged: citationAppended === null, bodyPreserved: true, citationAppended, } : null, events, candidates: target ? undefined : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })), // ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed. siblings, ...(siblings.length ? { note_siblings: `${siblings.length} open row(s) NAME an id #${n} cites (${citedIds.join(", ")}) and are not cited themselves — work shipped under another row's citation is how q-b4e7c209 and q-0c5e73a1 sat open; judge each, close none from here.` } : {}), ...(target || !candidates.length ? {} : { note_candidates: `${candidates.length} open item(s) MENTION #${n}; none was closed. A citation in an item's text is not a claim ` + `that the item is closed by it — pass queueItemId to close one deliberately.`, }), doneEntry: doneLine, ...(bareRef ? { refWarning: `'${args.pr}' is an UNDER-QUALIFIED ref — every entry in DONE.md carries owner/repo#N, and a bare #N does not ` + `identify a repository. Pass the full ref; the glyph contract would parse the bare one and it would tell a reader nothing.`, } : {}), ...(summary || already ? {} : { doneEntryWithheld: "no queue item named, so there is no description to write — a DONE line reading only 'PR #N' is not one a human would write" }), alreadyLogged: already, written: wrote, note: args.write ? undefined : "reporting only — pass write:true to apply. The markdown is authoritative; a report is not a write.", }; } /* ──────────────────────────────────────────────────────────────────────────── * `merge` — THE MERGE STEP CONSUMES THE CHECK VERDICT, STRUCTURALLY. * * MEASURED COST: kit#123 was merged on a red CI. The wait-loop read `test fail` * and the merge command ran anyway — the instrument RAN, its result was READ, * and the control flow did not DEPEND on it. That is the same shape as the six * shell-pattern mutations that reported clean the same day: a check whose * outcome nothing branches on is decoration, and it trains the reader to skip * it precisely because the outcome afterwards was fine. * * A rule saying "wait for green" cannot fix that, because the failure was not * ignorance of the rule — the coordinator wrote the loop, read the failure, and * merged. So the verdict is not returned for a caller to honour: the merge is * DOWNSTREAM OF IT IN ONE CALL. There is no ordering of this verb in which the * check runs and the merge ignores it. * * Naturally this cannot stop someone typing `gh pr merge`. It removes the * unpoliced step from the path that is meant to be used, and it makes the * bypass a visible choice rather than a loop that looked correct. * ──────────────────────────────────────────────────────────────────────────── */ /** One check as we judge it, normalised across gh's two rollup shapes. */ export type CheckRow = { name: string; state: string; verdict: "pass" | "fail" | "pending" }; /** * gh reports CheckRun as status+conclusion and StatusContext as a bare state, * and a rollup routinely contains BOTH. Reading only one shape silently scores * the other as unknown — so normalise explicitly and let anything unrecognised * fall to `pending`, which refuses. An unreadable check is not a passing one. */ export function normalizeChecks(rollup: unknown[]): CheckRow[] { return (rollup ?? []).map((r) => { const c = (r ?? {}) as Record; const name = String(c.name ?? c.context ?? "(unnamed)"); const status = String(c.status ?? "").toUpperCase(); const raw = String(c.conclusion ?? c.state ?? "").toUpperCase(); if (status && status !== "COMPLETED") return { name, state: status, verdict: "pending" as const }; if (raw === "SUCCESS") return { name, state: raw, verdict: "pass" as const }; // NEUTRAL and SKIPPED are deliberately NOT passes. A check that declined to // run has not evidenced anything, and scoring it green is the unread-input // defect: reporting clean on something never read. if (raw === "FAILURE" || raw === "ERROR" || raw === "TIMED_OUT" || raw === "CANCELLED" || raw === "ACTION_REQUIRED") return { name, state: raw, verdict: "fail" as const }; return { name, state: raw || status || "UNKNOWN", verdict: "pending" as const }; }); } export const mergeSchema = { project: z.string().min(1), pr: z.string().min(1), repo: z.string().optional(), method: z.enum(["squash", "merge", "rebase"]).optional(), write: z.boolean().optional(), }; /** Injected so every refusal branch is provable offline; defaults to real gh. */ export type PrFacts = { state: string; mergeable: string; checks: unknown[]; /** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */ headRefOid?: string; /** Unified diff of the PR, for the ticked-box audit. */ diff?: string; /** Everything that will survive the merge as a citation: title + commit subjects + body. */ citationText?: string; /** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */ comments?: PrComment[]; }; const ghFacts = (repo: string, n: string): PrFacts => { const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); const j = JSON.parse(out) as Record; const gh = (args: string[]) => { try { return execFileSync("gh", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); } catch { return ""; } }; // The citation must survive the MERGE, so what counts is what lands in // history: the PR title (which becomes the squash subject) and the commit // subjects. The body is included because a reviewer reads it, but a claim // that lives only in a comment thread is not a record. const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record; const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? []) .map((c) => c.messageHeadline ?? "") .join("\n"); return { state: String(j.state ?? ""), mergeable: String(j.mergeable ?? ""), headRefOid: String(j.headRefOid ?? ""), comments: ((meta.comments as { body?: string; createdAt?: string; author?: { login?: string } }[] | undefined) ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })), checks: (j.statusCheckRollup as unknown[]) ?? [], diff: gh(["pr", "diff", n]), citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"), }; }; /** * The act of merging, injected. * * NOT a testability nicety: with only `facts` injected, a test that supplied * passing checks and `write:true` fell through to the REAL `gh pr merge` and * tried to merge an actual PR. It failed only because that PR was already * merged. A test suite that can merge a live pull request is a worse defect * than anything this verb exists to catch, so the side effect is now something * a caller hands in. */ export type DoMerge = (repo: string, n: string, method: string) => void; const ghMerge: DoMerge = (repo, n, method) => { execFileSync("gh", ["pr", "merge", n, `--${method}`, "--delete-branch"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); }; /** * The room log, injected for the same reason `doMerge` is: a test must be able to * drive every refusal branch without this host's ~/agent-coord. * * Returns null when the log cannot be read — distinct from "" (read, and empty), * because "no verdicts recorded" and "could not look" are different answers and * only one of them is safe to merge on. */ export type ReadVerdictLog = (project: string) => string | null; /** * ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a * verdict recorded in another room still counts for this head. The project * room is read first so a missing bus is still a null (unknown), never an * empty string (nothing). */ const roomLog: ReadVerdictLog = (project) => { try { const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8"); let others = ""; try { const dir = path.join(ROOT, "rooms"); others = readdirSync(dir) .filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`) .map((f) => readFileSync(path.join(dir, f), "utf8")) .join("\n"); } catch { /* other rooms are optional */ } return others ? `${own}\n${others}` : own; } catch { return null; } }; export async function mergeTool( args: { project: string; pr: string; repo?: string; method?: string; write?: boolean }, facts: (repo: string, n: string) => PrFacts = ghFacts, doMerge: DoMerge = ghMerge, readVerdictLog: ReadVerdictLog = roomLog, ) { const repo = args.repo ?? process.cwd(); const n = prNumber(args.pr); if (!n) return { ok: false as const, error: `'${args.pr}' names no PR number. Cite owner/repo#N.` }; let f: PrFacts; try { f = facts(repo, n); } catch (e) { // NOT CHECKED IS NOT CHECKED-AND-GREEN. If we cannot read the verdict we // cannot have consumed it, and this verb's whole claim is that it did. return { ok: false as const, error: `could not read the checks for #${n} (${String((e as Error).message).split("\n")[0]}) — NOT read, which is not the same as read and passing.`, }; } const checks = normalizeChecks(f.checks); const failed = checks.filter((c) => c.verdict === "fail"); const pending = checks.filter((c) => c.verdict === "pending"); // EVERY RETURN CARRIES THE POPULATION IT JUDGED. "All passed" over an empty // set is the sentence this verb exists to make unsayable. const verdict = { population: checks.length, checks, failed: failed.map((c) => c.name), pending: pending.map((c) => c.name) }; if (f.state !== "OPEN") return { ok: false as const, error: `#${n} is ${f.state || "not OPEN"} — nothing to merge.`, verdict }; // NO CHECKS IS NOT PASSING CHECKS. // // Same invariant as the stall clock: no alerts is not no stalls, and a clock // that stopped reads quiet exactly like a system that is fine. An empty // rollup is the strongest-looking green there is — zero failures — and it is // evidence of nothing at all. if (checks.length === 0) return { ok: false as const, error: `#${n} reports ZERO checks. No checks is not passing checks — an empty rollup has zero failures and evidences nothing.`, verdict }; if (failed.length) return { ok: false as const, error: `#${n} has ${failed.length} of ${checks.length} check(s) FAILING: ${failed.map((c) => c.name).join(", ")}. Refusing to merge.`, verdict }; if (pending.length) return { ok: false as const, error: `#${n} has ${pending.length} of ${checks.length} check(s) not yet terminal: ${pending.map((c) => c.name).join(", ")}. A check still running has not returned a verdict to consume.`, verdict }; // EVERY NEWLY-TICKED CHECKBOX MUST BE CITED, CHECKED AT THE MERGE. // // Twice in one day a PR carried two things and left one unrecorded: kit#126 // ticked 4.4 and named it nowhere, and all five of Task 5's boxes shipped // inside kit#127 alongside a CI fix. The pattern is not carelessness — a PR // that fixes an incident AND delivers planned work gets the incident // remembered and the work forgotten, because the incident is what everyone // is talking about. Review caught neither; the coordinator found both after // the fact. // // So it is checked where the record is actually made. `doctor`'s // `phase-checkbox` finds this too, but only AFTER the merge, against DONE.md // and merged subjects — by which time the claim is already in history // unevidenced. The citation grammar is shared through seam rather than // copied, because a copied grammar is two grammars the moment one is fixed. const ticked = newlyTickedInDiff(f.diff ?? ""); if (ticked.size) { /* * A RELATIONAL CITATION DOES NOT EVIDENCE A TICK. * * "unblocks Phase 5.2 Task 15.6" names the task without claiming it is done, * so a PR that ticks 15.6's box and cites it that way is exactly as * unevidenced as one that never mentioned it. Measured on the other side of * this grammar the same day: doctor read that sentence in DONE.md as a * completion claim and red-gated main. * * The two cases are REPORTED SEPARATELY, because they need different fixes * and "UNCITED" would send someone looking for a citation that is already * there. A relational citation needs REWORDING; an absent one needs ADDING. */ const detail = phaseCitationsDetailed(f.citationText ?? ""); const closes = new Set(detail.filter((c) => c.relation === "claim").map((c) => c.key)); const relational = new Map(detail.filter((c) => c.relation === "reference").map((c) => [c.key, c.marker])); const uncited = [...ticked].filter((k) => !closes.has(k)); if (uncited.length) { const nameOf = (k: string) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; }; const names = uncited.map(nameOf); const worded = uncited.filter((k) => relational.has(k)); const missing = uncited.filter((k) => !relational.has(k)); const parts: string[] = []; if (missing.length) { parts.push( `${missing.length} NOT CITED AT ALL: ${missing.map(nameOf).join(", ")} — name them in the PR title or a commit ` + `subject (e.g. "${nameOf(missing[0] as string)}"), the subject being what survives a squash merge`, ); } if (worded.length) { parts.push( `${worded.length} cited only RELATIONALLY: ` + worded.map((k) => `${nameOf(k)} (after '${relational.get(k)}')`).join(", ") + ` — that names the task without claiming it is done, so it evidences nothing. Reword it, or drop the tick`, ); } return { ok: false as const, error: `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNEVIDENCED. ` + `${parts.join(". ")}. Once this merges, the tick claims progress nothing in history supports.`, verdict, uncited: names, }; } } if (f.mergeable === "CONFLICTING") return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict }; /* * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩. * * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later * and took `1d6deab`, because the author force-pushed in between. Checks were * green on BOTH heads, so every refusal above passed honestly. Nothing asked * the only question that mattered: is the head in front of me the head the * verdict named? * * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can * only report afterwards. It sits before the dry-run return on purpose: a dry * run must say it would refuse, or the preview disagrees with the act. */ const head = f.headRefOid ?? ""; if (!head) { return { ok: false as const, error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`, verdict, }; } const log = readVerdictLog(args.project); if (log === null) { return { ok: false as const, error: `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` + `was ever gated is UNKNOWN, and unknown is not gated.`, verdict, }; } const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n); // ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels. const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null); if (!gate.gated) { const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", "); return { ok: false as const, error: `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` + (named ? `Gated instead: ${named}. ` : "") + `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` + (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""), verdict, gatedHead: { head, gated: false as const, reason: gate.reason }, }; } if (!args.write) return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` }; doMerge(repo, n, args.method ?? "squash"); return { ok: true as const, merged: true as const, verdict }; }