agent-coord-mcp 0.26.21 → 0.26.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/capabilities.js +250 -2
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/closing-line.js +83 -0
  4. package/dist/closing-line.js.map +1 -0
  5. package/dist/commit-cite.js +55 -0
  6. package/dist/commit-cite.js.map +1 -0
  7. package/dist/gated-head.js +67 -20
  8. package/dist/gated-head.js.map +1 -1
  9. package/dist/server-spread.js +195 -0
  10. package/dist/server-spread.js.map +1 -0
  11. package/dist/server.js +2 -2
  12. package/dist/server.js.map +1 -1
  13. package/dist/store.js +32 -0
  14. package/dist/store.js.map +1 -1
  15. package/dist/tools/away.js +67 -7
  16. package/dist/tools/away.js.map +1 -1
  17. package/dist/tools/board-ref.js +44 -4
  18. package/dist/tools/board-ref.js.map +1 -1
  19. package/dist/tools/event-kinds.js +5 -1
  20. package/dist/tools/event-kinds.js.map +1 -1
  21. package/dist/tools/events.js +31 -2
  22. package/dist/tools/events.js.map +1 -1
  23. package/dist/tools/messaging.js +64 -6
  24. package/dist/tools/messaging.js.map +1 -1
  25. package/dist/tools/record-events.js +85 -5
  26. package/dist/tools/record-events.js.map +1 -1
  27. package/dist/tools/records.js +231 -38
  28. package/dist/tools/records.js.map +1 -1
  29. package/dist/tools/registry.js +52 -2
  30. package/dist/tools/registry.js.map +1 -1
  31. package/dist/tools/seat-build.js +173 -0
  32. package/dist/tools/seat-build.js.map +1 -0
  33. package/dist/tools/shared.js.map +1 -1
  34. package/dist/tools/stall.js +1095 -18
  35. package/dist/tools/stall.js.map +1 -1
  36. package/dist/tools/transport.js +21 -2
  37. package/dist/tools/transport.js.map +1 -1
  38. package/dist/tools/worktrees.js +14 -0
  39. package/dist/tools/worktrees.js.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/coord-attention-clock.mjs +2 -0
  42. package/scripts/coord-stall-clock.mjs +52 -11
  43. package/src/capabilities.ts +264 -2
  44. package/src/closing-line.ts +85 -0
  45. package/src/commit-cite.ts +58 -0
  46. package/src/gated-head.ts +128 -26
  47. package/src/server-spread.ts +233 -0
  48. package/src/server.ts +2 -2
  49. package/src/store.ts +32 -0
  50. package/src/tools/away.ts +82 -9
  51. package/src/tools/board-ref.ts +70 -3
  52. package/src/tools/event-kinds.ts +17 -1
  53. package/src/tools/events.ts +33 -2
  54. package/src/tools/messaging.ts +63 -6
  55. package/src/tools/record-events.ts +78 -5
  56. package/src/tools/records.ts +248 -38
  57. package/src/tools/registry.ts +54 -3
  58. package/src/tools/seat-build.ts +194 -0
  59. package/src/tools/shared.ts +22 -0
  60. package/src/tools/stall.ts +1266 -23
  61. package/src/tools/transport.ts +21 -2
  62. package/src/tools/worktrees.ts +13 -0
@@ -16,24 +16,146 @@
16
16
  * a broken one.
17
17
  */
18
18
  import { isLocallyProbeable } from "../transports/index.js";
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
19
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
20
20
  import { execFileSync } from "node:child_process";
21
21
  import path from "node:path";
22
22
  import { z } from "zod";
23
- import { parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
23
+ import { parseWorkDoc, workstreamsV1RowsOf, workstreamsExtensionsOf, isMalformedRow, queueItemsOf, workStateOf, coarseOf, type WorkState, type WorkstreamsV1Row } from "@davidbalzan/groundwork-seam";
24
24
  import { ROOT, AGENTS_FILE, readJson } from "../store.js";
25
- import { classifyBoardRef, refInCell } from "./board-ref.js";
25
+ import { classifyBoardRef, refInCell, rowKindOf, cellKindOf } from "./board-ref.js";
26
26
  import { loadLiveTransports } from "./registry.js";
27
+ import { verdictsFor, shaAgrees, verdictShasIn } from "../gated-head.js";
28
+ import { closingsIn, ACCEPTED_FORMS, type Closing } from "../closing-line.js";
27
29
 
28
30
  const BOARD_DOC = "docs/WORKSTREAMS.md";
31
+
32
+ /*
33
+ * ⟨q-3d82f1a9⟩ — A BOARD THAT FAILS TO PARSE RENDERS AS A QUIET FLEET.
34
+ *
35
+ * Measured live 2026-09-14: ad80825 rewrote line 27 — the FIRST row of the
36
+ * table — from 6 columns to 5. The seam ended the workstreams.v1 block at
37
+ * that line and recorded NO malformed row (rows:1, malformed:0), so the other
38
+ * 24 rows fell out of every count, and stall_check answered checked:0 with an
39
+ * EMPTY unmeasurable list and an EMPTY blind list: the healthiest reading the
40
+ * instrument can produce, while two seats were working. Every other
41
+ * degradation here pushes an `unmeasurable` entry; an unreadable board pushes
42
+ * nothing, because there is no row to attach the complaint to.
43
+ *
44
+ * So the parse state is a STATEMENT in this verb's own output, on its own key —
45
+ * never an inference a careful reader must know to draw from the role count.
46
+ * The discriminator is TEXT-LEVEL because the seam's is not: rows PRESENT in
47
+ * the Active Streams table (pipe-lines after the header and separator) versus
48
+ * rows the v1 block PARSED, with the block's own malformed rows counted so a
49
+ * refused-but-recorded row is not double-charged. `unparsed > 0` means rows
50
+ * exist that no record describes. A genuinely EMPTY board — header, separator,
51
+ * nothing — is 0 present / 0 parsed and reads as readable-empty, not suspect.
52
+ */
53
+ export type BoardParse = {
54
+ section: "Active Streams";
55
+ headerFound: boolean;
56
+ rowsPresent: number;
57
+ rowsParsed: number;
58
+ malformed: number;
59
+ unparsed: number;
60
+ readable: boolean;
61
+ why: string;
62
+ };
63
+ export function boardParseOf(text: string): BoardParse {
64
+ const lines = String(text ?? "").split("\n");
65
+ const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
66
+ const doc = parseWorkDoc(String(text ?? ""));
67
+ const block = doc.blocks.find((b) => b.kind === "board" && (b as { schema?: string }).schema === "workstreams.v1") as
68
+ | { rows?: unknown[] }
69
+ | undefined;
70
+ const blockRows = (block?.rows ?? []) as Parameters<typeof isMalformedRow>[0][];
71
+ const rowsParsed = workstreamsV1RowsOf(doc).length;
72
+ const malformed = blockRows.filter((r) => isMalformedRow(r)).length;
73
+ if (header === -1) {
74
+ const pipeLines = lines.filter((l) => /^\s*\|/.test(l)).length;
75
+ const readable = pipeLines === 0 && rowsParsed === 0;
76
+ return {
77
+ section: "Active Streams", headerFound: false, rowsPresent: pipeLines, rowsParsed, malformed, unparsed: Math.max(0, pipeLines - rowsParsed - malformed), readable,
78
+ why: readable
79
+ ? "no Active Streams table in the file and no table-shaped lines — an empty board, readable"
80
+ : `no \`| Stream |\` header found but ${pipeLines} table-shaped line(s) exist — the board is not a workstreams.v1 table this verb can read; UNPARSEABLE, which is not the same as empty`,
81
+ };
82
+ }
83
+ let i = header + 1;
84
+ if (i < lines.length && /^\s*\|\s*:?-+/.test(lines[i]!)) i++; // the alignment row is scaffold, not a row
85
+ let rowsPresent = 0;
86
+ for (; i < lines.length && /^\s*\|/.test(lines[i]!); i++) rowsPresent++;
87
+ const unparsed = Math.max(0, rowsPresent - rowsParsed - malformed);
88
+ // A MALFORMED ROW IS UNREADABLE TOO. Measured while writing the control: a
89
+ // short row that is not the table's first line is recorded by the seam as
90
+ // malformed (refused, kept verbatim) rather than ending the block — and a
91
+ // refused lane is exactly as invisible to this clock as a dropped one. Both
92
+ // conjuncts are required; the ad80825 shape trips the first, the mid-table
93
+ // shape trips the second.
94
+ const readable = unparsed === 0 && malformed === 0;
95
+ return {
96
+ section: "Active Streams", headerFound: true, rowsPresent, rowsParsed, malformed, unparsed, readable,
97
+ why: readable
98
+ ? rowsPresent === 0
99
+ ? "the Active Streams table is present and EMPTY — 0 rows present, 0 parsed; readable"
100
+ : `${rowsPresent} row(s) present, ${rowsParsed} parsed — every row is accounted for`
101
+ : `UNPARSEABLE — ${rowsPresent} row(s) present in the Active Streams table but only ${rowsParsed} parsed` +
102
+ `${malformed ? `; ${malformed} row(s) REFUSED by the parser as malformed (wrong column count) and therefore invisible to this clock` : ""}` +
103
+ `${unparsed ? `; ${unparsed} row(s) exist that no record describes — one bad row ends the table for the parser and nothing below it is measured` : ""}. ` +
104
+ `"Nothing measured" is not "nothing wrong".`,
105
+ };
106
+ }
29
107
  const STALL_MS = 30 * 60 * 1000;
108
+ /**
109
+ * How long a lane may sit in review before it is a HIT. Four times the
110
+ * authoring window, not equal to it: a review is a queue on another seat, and
111
+ * the number that matters is how long that queue has held this row — it is not
112
+ * a tuned threshold on branch activity, which a review-frozen branch fails by
113
+ * design (⟨q-507e80c4⟩).
114
+ */
115
+ const REVIEW_STALL_MINUTES = 120;
116
+ /** ⟨q-7b2f6c04⟩ — the claim window: a lane claimed this long ago with nothing on origin is a hit. Its own window, independent of the VCS one. */
117
+ export const CLAIM_STALL_MINUTES = 120;
30
118
 
31
119
  const runFile = () => path.join(ROOT, "stall-check.json");
32
120
  const haltFile = () => path.join(ROOT, "halt.json");
33
121
 
34
- export type StallHit =
122
+ /*
123
+ * ⟨q-7e3b10c9⟩ — EVERY HIT CARRIES ITS AUDIENCE. Measured 2026-09-11: the
124
+ * clock's first alarm of the day was a `stale-row` — a squash-landed branch
125
+ * under a 🚧 row — and the WORKER was told about a lane it had finished. The
126
+ * predicate had already said "not a stall, a stale row"; the delivery ignored
127
+ * it. A stall alarm that cries on finished lanes is disabled by its third
128
+ * firing. So the hit says who it is FOR, and every relayer — the clock script,
129
+ * a seat forwarding `dm:true` — routes on that rather than on `agentId`:
130
+ * duty the lane's owner may be stuck — no heartbeat, no commits, too
131
+ * long in review, an open PR nobody verdicted, a pushed branch
132
+ * nobody proposed. Somebody must look at the WORK.
133
+ * board-owner bookkeeping: a row pointing at a landed branch, a routed item
134
+ * with no row, a merge with no verdict comment. Somebody must
135
+ * fix the RECORD; the lane's owner did nothing wrong.
136
+ */
137
+ export type HitAudience = "duty" | "board-owner";
138
+ export const audienceOf = (kind: StallHitBody["kind"]): HitAudience =>
139
+ kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population" ? "board-owner" : "duty";
140
+ const withAudience = (hits: StallHitBody[]): StallHit[] => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }) as StallHit);
141
+ export type StallHit = StallHitBody & { audience: HitAudience };
142
+ export type StallHitBody =
35
143
  | { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
36
144
  | { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
145
+ /**
146
+ * ⟨q-7b2f6c04⟩ — THE CLAIM AXIS: a lane claimed (its row entered in-flight,
147
+ * read from the board's git history) whose branch has nothing on origin —
148
+ * not pushed, or pushed empty at claim — for longer than the claim window.
149
+ * The window between `claim` and the first push used to be UNMEASURABLE by
150
+ * construction; a 35-hour fleet-wide stop read as healthy inside it.
151
+ */
152
+ | { kind: "unpushed-claim"; agentId: string; stream: string; branch: string; minutes: number; since: string; why: string }
153
+ /**
154
+ * ⟨q-d0e83b41⟩ (09-14 amend) — a lane LEFT the scored population since the
155
+ * last run with no closing state on the board: its row is gone. Without this
156
+ * a seat leaving the denominator reads as an IMPROVED ratio.
157
+ */
158
+ | { kind: "lane-left-population"; agentId: string; stream: string; why: string }
37
159
  /**
38
160
  * A 🚧 row whose branch HAS ALREADY LANDED. Not a stall — the opposite: the
39
161
  * work finished and nobody closed the row.
@@ -45,7 +167,672 @@ export type StallHit =
45
167
  * stall honest would have silently removed the signal, so the signal gets its
46
168
  * own name instead of inheriting a wrong one.
47
169
  */
48
- | { kind: "stale-row"; agentId: string; branch: string; why: string };
170
+ | { kind: "stale-row"; agentId: string; branch: string; why: string }
171
+ /**
172
+ * A 🔍 row that has DECLARED itself in review for longer than the review
173
+ * window. Measured from the board's own git history — the commit that first
174
+ * (contiguously) put this row in review — never from the branch, which is
175
+ * frozen for the correct reason while a gate reads it.
176
+ */
177
+ | { kind: "in-review-too-long"; agentId: string; stream: string; minutes: number; since: string }
178
+ /**
179
+ * ⟨q-6f0a3d81⟩ — A REPOSITORY FACT, NOT A BUS EMISSION. An open PR whose head
180
+ * sha has no verdict record in any room log, older than the stall limit. It
181
+ * needs no board row, no DONE:, no heartbeat: it is read from `gh` and the
182
+ * log, so it fires when every seat stays silent — which is the one case every
183
+ * other signal here is blind to by construction.
184
+ */
185
+ /**
186
+ * ⟨q-dcbaf544⟩ — `why` splits the one predicate (open PR, no typed verdict at
187
+ * its head) into its two causes: NOBODY gated it, or someone whose role cannot
188
+ * emit `verdict` reported a typed gate line for this head and no gate-runner
189
+ * scribed it. The second fires without waiting: the claim exists, the record
190
+ * does not, and every minute it stands the audit trail is wrong, not late.
191
+ */
192
+ | { kind: "unverdicted-pr"; agentId: string; pr: number; head: string; branch: string; minutes: number; why: "nobody-gated" | "reported-unscribed"; claimedBy?: string; claimedAt?: string; claimedResult?: string }
193
+ /**
194
+ * ⟨q-8f1e604b⟩ — CONVENTION (e) AS A CHECK: a PR merged within the window whose
195
+ * page carries NO verdict comment bound to the head that merged. #299 is the
196
+ * live instance (David's own merge, no verdict). A comment that merely says
197
+ * PASS or FAIL in prose is not a verdict — the aide measured that a substring
198
+ * scan would have counted #299's "not a verdict" review.
199
+ */
200
+ | { kind: "unverdicted-merge"; pr: number; head: string; mergedAt: string; minutes: number; why: string }
201
+ /** ⟨q-fee7239f⟩ — a merge closing on the bus asserting a deletion in neither accepted form, after the floor. The send path refuses new ones; this lists what got through elsewhere. */
202
+ | { kind: "unread-delete-claim"; agentId: string; pr: number | null; closedAt: string; minutes: number; why: string }
203
+ /**
204
+ * ⟨q-8f1e604b⟩ — CONVENTION (f) AS A CHECK: an item ROUTED by a GO on the bus
205
+ * (named on the GO line, not merely mentioned) that is still open in the queue
206
+ * and has no board row naming it as its subject. `next_unblocked` can offer
207
+ * such an item again; the coordinator's cold free-set derivation nearly did.
208
+ */
209
+ | { kind: "routed-without-row"; itemId: string; routedBy: string; routedAt: string; minutes: number; why: string }
210
+ /**
211
+ * ⟨q-4e08b3c1⟩ — CONVENTION (g) AS A CHECK: a commit touching a record
212
+ * document, authored INSIDE an open MERGE WINDOW. The window is read from
213
+ * the bus (qa's typed lines), the write from git — the one convention whose
214
+ * data lives in two places at once, which is why it had no check.
215
+ */
216
+ | { kind: "merge-window-write"; pr: number; sha: string; author: string; authoredAt: string; secondsIntoWindow: number; why: string }
217
+ /**
218
+ * A pushed branch ahead of main with no open PR, older than the limit. Same
219
+ * class, one step earlier. ⚠ STRANDED IS NOT LOST: the first two live hits
220
+ * were superseded drafts of a file that landed under other PRs (measured by
221
+ * the aide, same seven tests on all three). The axis can see that nobody
222
+ * cited the artefact; only its owner can say whether it is superseded,
223
+ * abandoned or genuinely unlanded — so the hit SAYS so, or a true alarm
224
+ * with a false implication teaches the reader to ignore the axis.
225
+ */
226
+ | { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string };
227
+
228
+ /* ────────────────────────────────────────────────────────────────────────────
229
+ * ⟨q-6f0a3d81⟩ — THE HANDOVER IS THE THING BEING MEASURED, AND EVERY INSTRUMENT
230
+ * KEYED ON AN ARTEFACT PRODUCED *BY* A HANDOVER. A cited DONE: proves work
231
+ * ARRIVED; its absence proves nothing. Measured 2026-09-11: #244 sat open,
232
+ * green and never cited, and stall_check (lane rows), QA's queue (cited
233
+ * arrivals) and the board (a row nobody updated) each said "nothing wrong" —
234
+ * each correctly. Three instruments, one blind spot, because all three begin
235
+ * "when someone reports…".
236
+ *
237
+ * So this axis reads the ARTEFACT: what `gh` says is open, what origin says is
238
+ * pushed, and whether the bus holds a verdict bound to that head. No seat has
239
+ * to speak for it to fire. Injected, like `rotate`'s facts and `merge`'s PR
240
+ * facts, so the negative control the item demands — fires with every seat
241
+ * silent — is provable offline.
242
+ *
243
+ * ⛔ AND "COULD NOT READ" IS NEVER "NOTHING STRANDED": an unreachable `gh` or
244
+ * origin lands on `unmeasurable`, named, exactly as an unreadable branch does.
245
+ * ──────────────────────────────────────────────────────────────────────────── */
246
+ export type OpenPrFact = { n: number; headRefOid: string; headRefName: string; updatedAt: string };
247
+ export type RepoArtefacts = {
248
+ /** `gh pr list --state open`, or null when gh could not answer. */
249
+ openPrs: OpenPrFact[] | null;
250
+ /** `git ls-remote --heads origin`, or null when origin could not be read. */
251
+ remoteHeads: { name: string; sha: string }[] | null;
252
+ /**
253
+ * `gh pr list --state merged` — the FORGE's record of what landed, rank 1 in
254
+ * docs/LANDEDNESS.md. A branch whose tip is a merged PR's head is landed,
255
+ * whatever ancestry or patch ids say. Null when gh could not answer.
256
+ */
257
+ mergedPrs?: { headRefName: string; headRefOid: string }[] | null;
258
+ /**
259
+ * ⟨q-8f1e604b⟩ — the RECENT merges with their PR comments, so convention (e)
260
+ * can be checked: `gh pr list --state merged --limit 50 --json number,headRefOid,mergedAt,comments`.
261
+ * Null when gh could not answer.
262
+ */
263
+ recentMerges?: RecentMerge[] | null;
264
+ };
265
+ export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[] };
266
+ const NET_TIMEOUT_MS = 15_000;
267
+ export const realArtefacts = (repo: string): RepoArtefacts => {
268
+ let openPrs: OpenPrFact[] | null = null;
269
+ try {
270
+ const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid,headRefName,updatedAt"], {
271
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
272
+ });
273
+ openPrs = (JSON.parse(out) as { number: number; headRefOid: string; headRefName: string; updatedAt: string }[]).map((p) => ({
274
+ n: p.number, headRefOid: p.headRefOid, headRefName: p.headRefName, updatedAt: p.updatedAt,
275
+ }));
276
+ } catch { /* reported as unmeasurable by the caller */ }
277
+ let mergedPrs: { headRefName: string; headRefOid: string }[] | null = null;
278
+ try {
279
+ const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "1000", "--json", "headRefName,headRefOid"], {
280
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
281
+ });
282
+ mergedPrs = JSON.parse(out) as { headRefName: string; headRefOid: string }[];
283
+ } catch { /* landedness falls back to patch ids; said so by the caller */ }
284
+ let recentMerges: RecentMerge[] | null = null;
285
+ try {
286
+ const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
287
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
288
+ });
289
+ recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[] }[]).map((p) => ({
290
+ n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
291
+ }));
292
+ } catch { /* said by the caller */ }
293
+ let remoteHeads: { name: string; sha: string }[] | null = null;
294
+ try {
295
+ const out = execFileSync("git", ["ls-remote", "--heads", "origin"], {
296
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
297
+ });
298
+ remoteHeads = out.split("\n").filter(Boolean).map((l) => {
299
+ const [sha, ref] = l.split(/\s+/);
300
+ return { sha: sha ?? "", name: (ref ?? "").replace(/^refs\/heads\//, "") };
301
+ }).filter((h) => h.sha && h.name);
302
+ } catch { /* reported as unmeasurable by the caller */ }
303
+ return { openPrs, remoteHeads, mergedPrs, recentMerges };
304
+ };
305
+ /** Every inbox on this bus, concatenated — a GO delivered by DM is a routing record too. */
306
+ export const readAllInboxLogs = (): string => {
307
+ const dir = path.join(ROOT, "inbox");
308
+ try {
309
+ return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
310
+ } catch {
311
+ return "";
312
+ }
313
+ };
314
+
315
+ /* ────────────────────────────────────────────────────────────────────────────
316
+ * ⟨q-8f1e604b⟩ — TWO RECORD-KEEPING CONVENTIONS BECOME CHECKS. Both were
317
+ * adopted on 2026-09-14 by seats that were cycled the same afternoon; a
318
+ * convention does not survive its holder, a check does.
319
+ *
320
+ * (e) VERDICTS ON THE PR. A merged PR must carry a verdict COMMENT bound to the
321
+ * head that merged. "Verdict" is a TYPED line — `QA GATE — **PASS** @ \`sha\``
322
+ * or the merge-time `**GATE: PASS** … @ \`sha\`` — never a substring: #299's
323
+ * page says "not a verdict", "PASS" and "my verdict above" in three review
324
+ * comments and carries no verdict at all. Bound by sha, prefix-tolerant
325
+ * (`shaAgrees`): a verdict on an OLDER head is the #268 breach, not a gate.
326
+ * (f) A GO WRITES THE BOARD ROW. An item is ROUTED when a `go` record names it
327
+ * ON THE GO LINE — `GO ⟨q-…⟩ —`, `GO: … ⟨q-…⟩` — read by POSITION, because a
328
+ * GO's prose mentions neighbouring items as context. A routed item that is
329
+ * still open and has no board row naming it as its SUBJECT is a hit:
330
+ * `next_unblocked` would offer it again. Room logs AND inboxes are read —
331
+ * this fleet routes by DM.
332
+ * Both are bounded by a window: a merge or a GO older than it is listed, not
333
+ * raised, so the day's real instances (#299) fire once and age out.
334
+ * ──────────────────────────────────────────────────────────────────────────── */
335
+ export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
336
+ /**
337
+ * WHEN (e) WAS ADOPTED, as a dated fact: qa posted at 2026-09-14 13:28Z that all
338
+ * five of its verdicts were now on their PRs. Measured live before this floor
339
+ * existed: 39 of the 50 most recent merges had no verdict comment — every one
340
+ * merged BEFORE that instant, when the convention did not exist. Raising them
341
+ * would make the check's first three days pure noise, which is how a check gets
342
+ * switched off. Merges before the floor are LISTED with `beforeAdoption`, never
343
+ * raised; the window subsumes the floor after three days.
344
+ */
345
+ export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
346
+ // The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
347
+ export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
348
+ /** Items named ON A GO LINE of a `go` record — position, not mention. */
349
+ export function routedItemsIn(logText: string): { itemId: string; by: string; ts: number }[] {
350
+ const out: { itemId: string; by: string; ts: number }[] = [];
351
+ for (const l of logText.split("\n")) {
352
+ if (!l.trim()) continue;
353
+ let o: { ts?: number; from?: string; text?: string; record?: { type?: string } };
354
+ try { o = JSON.parse(l); } catch { continue; }
355
+ if (o.record?.type !== "go") continue;
356
+ for (const line of String(o.text ?? "").split("\n")) {
357
+ if (!/\bGO\b/.test(line)) continue;
358
+ for (const m of line.matchAll(/q-[0-9a-f]{8}/g)) out.push({ itemId: m[0], by: String(o.from ?? ""), ts: Number(o.ts ?? 0) });
359
+ }
360
+ }
361
+ return out;
362
+ }
363
+ /* ────────────────────────────────────────────────────────────────────────────
364
+ * ⟨q-4e08b3c1⟩ — THE MERGE WINDOW, JOINED. qa posts `MERGE WINDOW: #N — hold
365
+ * queue/board writes` as the trailing line of its typed PASS and `MERGE WINDOW
366
+ * CLOSED` as the trailing line of its MERGED done; a seat's write is a commit
367
+ * touching docs/QUEUE.md, docs/DONE.md or docs/WORKSTREAMS.md, timestamped by
368
+ * git. The incident (#312): two coordinator board commits at 16:34 and 16:37,
369
+ * before a window opened at ~16:40 — nobody violated anything, the protocol's
370
+ * party list was incomplete. `217a6af` bound every record writer; that is the
371
+ * dated floor. Measured today before this existed: five windows, each under
372
+ * 70 seconds, 67 record commits, none inside a window.
373
+ *
374
+ * ⚠ AN UNCLOSED WINDOW IS CAPPED, NEVER OPEN-ENDED (the ruling): at the next
375
+ * window's open or 30 minutes, whichever comes first, and reported `unclosed`
376
+ * by name — a window nobody closed must not condemn every write after it.
377
+ * ──────────────────────────────────────────────────────────────────────────── */
378
+ export const MERGE_WINDOW_ADOPTED_MS = Date.parse("2026-09-14T16:43:54Z");
379
+ export const MERGE_WINDOW_CAP_MS = 30 * 60 * 1000;
380
+ export const RECORD_DOCS = ["docs/QUEUE.md", "docs/DONE.md", "docs/WORKSTREAMS.md"];
381
+ export type MergeWindow = { pr: number; open: number; close: number | null; end: number; unclosed: boolean; openedBy: string };
382
+ /** Windows as the bus records them: an OPEN line and, for the same PR, the next CLOSED line after it. */
383
+ export function windowsIn(logText: string): MergeWindow[] {
384
+ const opens: { pr: number; ts: number; from: string }[] = [];
385
+ const closes: { pr: number | null; ts: number }[] = [];
386
+ for (const l of logText.split("\n")) {
387
+ if (!l.trim()) continue;
388
+ let o: { ts?: number; from?: string; text?: string };
389
+ try { o = JSON.parse(l); } catch { continue; }
390
+ const t = String(o.text ?? "");
391
+ const om = /MERGE WINDOW: #(\d+)/.exec(t);
392
+ if (om) opens.push({ pr: Number(om[1]), ts: Number(o.ts ?? 0), from: String(o.from ?? "") });
393
+ if (/MERGE WINDOW CLOSED/.test(t)) {
394
+ // The PR is named wherever the message names it (`DONE: MERGED owner/repo#N … MERGE WINDOW CLOSED`); a close naming no PR closes the open window.
395
+ const pm = /#(\d+)/.exec(t);
396
+ closes.push({ pr: pm ? Number(pm[1]) : null, ts: Number(o.ts ?? 0) });
397
+ }
398
+ }
399
+ opens.sort((a, b) => a.ts - b.ts);
400
+ return opens.map((w, i) => {
401
+ const close = closes.filter((c) => c.ts > w.ts && (c.pr === null || c.pr === w.pr)).sort((a, b) => a.ts - b.ts)[0]?.ts ?? null;
402
+ const nextOpen = opens[i + 1]?.ts ?? Infinity;
403
+ const end = close ?? Math.min(nextOpen, w.ts + MERGE_WINDOW_CAP_MS);
404
+ return { pr: w.pr, open: w.ts, close, end, unclosed: close === null, openedBy: w.from };
405
+ });
406
+ }
407
+ /** `mergeOf`: the PR whose landing commit this is, read off the FULL subject's trailing `(#N)` before any display slicing — the live #325 subject is 119 chars and the marker sat past an 80-char cut. */
408
+ export type RecordWrite = { sha: string; at: number; author: string; subject: string; mergeOf: number | null };
409
+ /** The PR a landing commit lands, from the forge's `(#N)` marker at the END of the subject; null for any other commit. */
410
+ export const mergeOf = (fullSubject: string): number | null => { const m = /\(#(\d+)\)\s*$/.exec(fullSubject); return m ? Number(m[1]) : null; };
411
+ /**
412
+ * ⟨q-4e08b3c1⟩ follow-up, MEASURED LIVE 2026-09-14 19:38Z by this file's own
413
+ * live-population test: #325's SQUASH (3b8702e) touched docs/QUEUE.md — the PR
414
+ * migrated a queue receipt — and its author time is the merge time, 13s into
415
+ * MERGE WINDOW #325. The window's own landing commit is the MERGE the window
416
+ * exists to protect, not a seat's write into it; it is listed under the window
417
+ * with `landing: true` and never raised.
418
+ */
419
+ export const isLandingOf = (write: Pick<RecordWrite, "mergeOf">, pr: number): boolean => write.mergeOf !== null && write.mergeOf === pr;
420
+ /** Commits touching a record document on the shared branch, by AUTHOR time — when the seat wrote, not when it landed. */
421
+ export function recordWritesOn(repo: string, sinceMs: number): RecordWrite[] | null {
422
+ const git = (args: string[]) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
423
+ let base: string | null = null;
424
+ for (const cand of ["origin/main", "main", "origin/master", "master"]) {
425
+ try { git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); base = cand; break; } catch { /* next */ }
426
+ }
427
+ if (!base) return null;
428
+ try {
429
+ const out = git(["log", base, `--since=${new Date(sinceMs).toISOString()}`, "--format=%H|%aI|%an|%s", "--", ...RECORD_DOCS]);
430
+ return out.split("\n").filter(Boolean).map((l) => {
431
+ const [sha, aI, author, ...rest] = l.split("|");
432
+ const full = rest.join("|");
433
+ return { sha: sha ?? "", at: Date.parse(aI ?? ""), author: author ?? "", subject: full.slice(0, 120), mergeOf: mergeOf(full) };
434
+ });
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+ export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[] | null; now: number; floorMs?: number; horizonMs?: number }): {
440
+ hits: StallHit[];
441
+ windows: (MergeWindow & { writes: (RecordWrite & { beforeAdoption: boolean })[] })[];
442
+ unmeasurable: { agentId: string; value: string; why: string }[];
443
+ } {
444
+ const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
445
+ const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
446
+ const hits: StallHitBody[] = [];
447
+ const unmeasurable: { agentId: string; value: string; why: string }[] = [];
448
+ const windows = windowsIn(input.logText).filter((w) => input.now - w.open <= horizon);
449
+ if (input.writes === null) {
450
+ unmeasurable.push({ agentId: "-", value: "git log -- docs/{QUEUE,DONE,WORKSTREAMS}.md", why: "record-document writes could not be read from the shared branch — convention (g), the merge window, is UNMEASURED, which is not the same as kept" });
451
+ return { hits: [], windows: windows.map((w) => ({ ...w, writes: [] })), unmeasurable };
452
+ }
453
+ const out = windows.map((w) => {
454
+ const inside = input.writes!.filter((c) => Number.isFinite(c.at) && c.at >= w.open && c.at <= w.end).map((c) => ({ ...c, beforeAdoption: c.at < floor, landing: isLandingOf(c, w.pr) }));
455
+ for (const c of inside) {
456
+ if (c.beforeAdoption || c.landing) continue;
457
+ const secs = Math.round((c.at - w.open) / 1000);
458
+ hits.push({
459
+ kind: "merge-window-write", pr: w.pr, sha: c.sha, author: c.author, authoredAt: new Date(c.at).toISOString(), secondsIntoWindow: secs,
460
+ why: `${c.sha.slice(0, 7)} (${c.author}) wrote a record document ${secs}s into MERGE WINDOW #${w.pr}${w.unclosed ? " (window never closed; capped)" : ""}: "${c.subject}". Convention (g): queue/board/DONE writes pause between MERGE WINDOW and MERGE WINDOW CLOSED.`,
461
+ });
462
+ }
463
+ return { ...w, writes: inside };
464
+ });
465
+ return { hits: withAudience(hits), windows: out, unmeasurable };
466
+ }
467
+
468
+ export function conventionChecks(input: {
469
+ recentMerges: RecentMerge[] | null | undefined;
470
+ routingLogText: string;
471
+ queueText: string | null;
472
+ boardRows: WorkstreamsV1Row[];
473
+ now: number;
474
+ windowMs?: number;
475
+ }): {
476
+ hits: StallHit[];
477
+ unmeasurable: { agentId: string; value: string; why: string }[];
478
+ merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean }[] | null;
479
+ routed: { itemId: string; by: string; minutes: number; open: boolean | null; onBoard: boolean; inWindow: boolean }[];
480
+ closings: (Closing & { inWindow: boolean })[];
481
+ } {
482
+ const windowMs = input.windowMs ?? CONVENTION_WINDOW_MS;
483
+ const hits: StallHitBody[] = [];
484
+ const unmeasurable: { agentId: string; value: string; why: string }[] = [];
485
+ let merges: ReturnType<typeof conventionChecks>["merges"] = null;
486
+ if (input.recentMerges === null || input.recentMerges === undefined) {
487
+ unmeasurable.push({ agentId: "-", value: "gh pr list --state merged (with comments)", why: "recent merges could not be read from gh — convention (e), verdicts on the PR, is UNMEASURED, which is not the same as kept" });
488
+ } else {
489
+ merges = [];
490
+ for (const m of input.recentMerges) {
491
+ const mergedMs = Date.parse(m.mergedAt);
492
+ const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
493
+ const inWindow = input.now - mergedMs <= windowMs;
494
+ const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
495
+ const verdictAtHead = verdictShasIn(m.comments).some((v) => shaAgrees(v.sha, m.headRefOid));
496
+ merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption });
497
+ if (inWindow && !beforeAdoption && !verdictAtHead) {
498
+ hits.push({
499
+ kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
500
+ why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR (${m.comments.length} comment(s), none a typed \`QA GATE — **PASS|FAIL** @ sha\` line for it). Convention (e): the verdict lives on the PR, not only on the bus.`,
501
+ });
502
+ }
503
+ }
504
+ }
505
+ const openIds = new Set<string>();
506
+ const closedIds = new Set<string>();
507
+ if (input.queueText !== null) {
508
+ for (const i of queueItemsOf(parseWorkDoc(input.queueText))) (i.done ? closedIds : openIds).add(i.id);
509
+ }
510
+ const subjectOf = (r: WorkstreamsV1Row) => [...String(r.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((x) => x[1] as string);
511
+ const onBoard = new Set(input.boardRows.flatMap(subjectOf));
512
+ const routed: ReturnType<typeof conventionChecks>["routed"] = [];
513
+ const seen = new Set<string>();
514
+ for (const r of routedItemsIn(input.routingLogText).sort((a, b) => b.ts - a.ts)) {
515
+ if (seen.has(r.itemId)) continue; // the latest GO for an item is the one that binds
516
+ seen.add(r.itemId);
517
+ const minutes = Math.max(0, Math.round((input.now - r.ts) / 60000));
518
+ const inWindow = input.now - r.ts <= windowMs;
519
+ const open = input.queueText === null ? null : openIds.has(r.itemId) ? true : closedIds.has(r.itemId) ? false : null;
520
+ const has = onBoard.has(r.itemId);
521
+ routed.push({ itemId: r.itemId, by: r.by, minutes, open, onBoard: has, inWindow });
522
+ if (inWindow && open === true && !has) {
523
+ hits.push({
524
+ kind: "routed-without-row", itemId: r.itemId, routedBy: r.by, routedAt: new Date(r.ts).toISOString(), minutes,
525
+ why: `⟨${r.itemId}⟩ was routed by a GO from ${r.by} ${minutes}m ago, is still open in the queue, and no board row names it as its subject — next_unblocked can offer it again. Convention (f): a GO writes the board row (claim does it for you).`,
526
+ });
527
+ }
528
+ }
529
+ // ⟨q-fee7239f⟩ — closing lines: every MERGED done on the bus, judged by the
530
+ // grammar; those at or before the floor are LISTED with beforeAdoption.
531
+ const closings = closingsIn(input.routingLogText).map((c) => ({ ...c, inWindow: input.now - c.ts <= windowMs }));
532
+ for (const c of closings) {
533
+ if (!c.inWindow || c.beforeAdoption || !c.claimsDelete || c.form !== null) continue;
534
+ const minutes = Math.max(0, Math.round((input.now - c.ts) / 60000));
535
+ hits.push({
536
+ kind: "unread-delete-claim", agentId: c.from, pr: c.pr, closedAt: new Date(c.ts).toISOString(), minutes,
537
+ why: `${c.from}'s closing line for ${c.pr === null ? "a merge" : `#${c.pr}`} asserts a branch deletion in neither accepted form — ${ACCEPTED_FORMS}. The remote was not read; the record may be premature or false.`,
538
+ });
539
+ }
540
+ return { hits: withAudience(hits), unmeasurable, merges, routed, closings };
541
+ }
542
+ /** Every room log on this bus, concatenated — a verdict for a PR counts from any room. */
543
+ export const readAllRoomLogs = (): string => {
544
+ const dir = path.join(ROOT, "rooms");
545
+ try {
546
+ return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
547
+ } catch {
548
+ return "";
549
+ }
550
+ };
551
+ /** The seat a branch belongs to, by the prefix `claim` writes — identity is in the artefact, not the account. */
552
+ const seatOfBranch = (name: string): string => (name.includes("/") ? name.split("/")[0]! : "unknown");
553
+ const BASE_BRANCHES = new Set(["main", "master", "HEAD"]);
554
+
555
+ /*
556
+ * ⚠ A CLOCK VERB CANNOT TAKE A MINUTE. Measured live: 212 remote heads × ~5 git
557
+ * processes each = 65s per run. Landedness is MONOTONE — once a sha's patches
558
+ * are on main they stay there — so it is memoised per sha across runs, and a
559
+ * not-landed answer is memoised against the base sha it was measured at and
560
+ * re-measured when main moves. A per-run BUDGET bounds the uncached work; what
561
+ * does not fit is reported as deferred, never silently skipped, and the next
562
+ * run picks it up. Steady state is ls-remote + a handful of new branches.
563
+ */
564
+ type LandedBy = "forge" | "patch" | "squash" | null;
565
+ type StrandedMemo = Record<string, { landed: boolean; landedBy: LandedBy; ahead: number; committedAt: string; base: string }>;
566
+ const memoFile = () => path.join(ROOT, "stall-stranded-memo.json");
567
+ const readMemo = (): StrandedMemo => {
568
+ try {
569
+ return JSON.parse(readFileSync(memoFile(), "utf8")) as StrandedMemo;
570
+ } catch {
571
+ return {};
572
+ }
573
+ };
574
+ const writeMemo = (m: StrandedMemo) => {
575
+ try {
576
+ mkdirSync(ROOT, { recursive: true });
577
+ writeFileSync(memoFile(), JSON.stringify(m));
578
+ } catch { /* a lost memo costs time on the next run, never correctness */ }
579
+ };
580
+ export const STRANDED_BUDGET_MS = 15_000;
581
+
582
+ /**
583
+ * Did the WHOLE RANGE land as ONE commit? `git cherry` compares commit by
584
+ * commit, so a squash of several commits — one patch id for the range, none
585
+ * for the parts — reads `+` on every line and the branch looks unproposed
586
+ * forever. Measured live: 47 of 209 ahead-by-ancestry branches survived the
587
+ * cherry test, and every young one was a 2-commit branch merged that day.
588
+ *
589
+ * So index main's recent commits by the patch id of EACH commit — ONE process
590
+ * (`git log -p | git patch-id`), measured at 1.8s for 1000 commits — and ask,
591
+ * per branch, whether the patch id of `mergeBase..sha` as ONE diff is in it.
592
+ * A hit means main carries this branch's cumulative change as a single
593
+ * commit: a squash. Bounded to the last 1000 first-parent commits, so a
594
+ * branch that landed further back than that reads as not landed — the
595
+ * direction that over-reports, never the one that hides stranded work.
596
+ */
597
+ const SQUASH_INDEX_DEPTH = 1000;
598
+ /** `git patch-id --stable` over a diff, without a shell: the inputs are shas and refs from origin, and data is not a command line. */
599
+ const patchIds = (repo: string, diff: string): string =>
600
+ execFileSync("git", ["patch-id", "--stable"], { cwd: repo, encoding: "utf8", input: diff, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
601
+ function squashIndex(repo: string, base: string): Map<string, string> {
602
+ const map = new Map<string, string>();
603
+ try {
604
+ const log = execFileSync("git", ["log", "-p", "--first-parent", `--max-count=${SQUASH_INDEX_DEPTH}`, "--format=%H", base, "--"], {
605
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024,
606
+ });
607
+ const out = patchIds(repo, log);
608
+ for (const l of out.split("\n")) {
609
+ const [id, commit] = l.trim().split(/\s+/);
610
+ if (id && commit) map.set(id, commit);
611
+ }
612
+ } catch { /* an empty index reads every branch as not landed — over-reports */ }
613
+ return map;
614
+ }
615
+ function landedBySquash(repo: string, base: string, sha: string, index: Map<string, string>): boolean {
616
+ if (index.size === 0) return false;
617
+ try {
618
+ const git = (args: string[]) =>
619
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
620
+ const mb = git(["merge-base", base, sha]).trim();
621
+ if (!mb) return false;
622
+ const id = patchIds(repo, git(["diff", mb, sha, "--"])).trim().split(/\s+/)[0] ?? "";
623
+ return id.length > 0 && index.has(id);
624
+ } catch {
625
+ return false;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * The stranded-work axis. Pure over its inputs so it is testable without a
631
+ * network; the caller supplies the artefacts and the log.
632
+ */
633
+ /*
634
+ * ⟨q-dcbaf544⟩ — A GATE RESULT REPORTED BY A SEAT THAT CANNOT RECORD IT. The
635
+ * typed gate line (#313's grammar, `GATE: PASS @ sha`) inside a NON-verdict
636
+ * record that cites the PR — a repo-owner's or console's `done`/`fyi` — is a
637
+ * claim that the head was gated. If no typed `verdict` names that head, the
638
+ * claim was never scribed, and `unverdicted-pr` says so instead of "nobody".
639
+ * Measured live 2026-09-14 before this existed: 0 such claims in 14 rooms +
640
+ * inboxes — the convention was applied by prose, which this cannot read, and
641
+ * that is the point: the typed line is what makes a report scribable.
642
+ */
643
+ export type GateClaim = { from: string; ts: number; result: string; sha: string; type: string };
644
+ export function gateClaimsIn(logText: string, pr: string, head: string): GateClaim[] {
645
+ const out: GateClaim[] = [];
646
+ for (const l of logText.split("\n")) {
647
+ if (!l.trim()) continue;
648
+ let o: { ts?: number; from?: string; text?: string; record?: { type?: string; cites?: { ref?: string }[] } };
649
+ try { o = JSON.parse(l); } catch { continue; }
650
+ const r = o.record;
651
+ if (!r || r.type === "verdict") continue;
652
+ if (!(r.cites ?? []).some((c) => (String(c?.ref ?? "").match(/#(\d+)/) ?? [])[1] === pr)) continue;
653
+ for (const v of verdictShasIn([{ body: String(o.text ?? "") }])) {
654
+ if (shaAgrees(v.sha, head)) out.push({ from: String(o.from ?? ""), ts: Number(o.ts ?? 0), result: v.result, sha: v.sha, type: String(r.type ?? "") });
655
+ }
656
+ }
657
+ return out.sort((a, b) => a.ts - b.ts);
658
+ }
659
+
660
+ export function strandedWork(
661
+ repo: string,
662
+ artefacts: RepoArtefacts,
663
+ logText: string,
664
+ now: number,
665
+ limitMs: number,
666
+ /** Seats the bus knows. A branch hit needs a recipient; a branch nobody owns is reported, not DMed. */
667
+ registeredSeats: ReadonlySet<string> = new Set(),
668
+ /** Uncached branch classification stops here; the rest is DEFERRED and named. */
669
+ budgetMs: number = STRANDED_BUDGET_MS,
670
+ ): {
671
+ hits: StallHit[];
672
+ unmeasurable: { agentId: string; value: string; why: string }[];
673
+ openPrs: { pr: number; head: string; branch: string; minutes: number; verdictAtHead: boolean; claimAtHead: GateClaim | null }[] | null;
674
+ pushedBranches: { branch: string; head: string; ahead: number; landed: boolean; landedBy: LandedBy; minutes: number; hasPr: boolean }[] | null;
675
+ } {
676
+ const hits: StallHitBody[] = [];
677
+ const unmeasurable: { agentId: string; value: string; why: string }[] = [];
678
+ const git = (args: string[]) =>
679
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
680
+
681
+ let openPrs: ReturnType<typeof strandedWork>["openPrs"] = null;
682
+ if (artefacts.openPrs === null) {
683
+ unmeasurable.push({ agentId: "-", value: "gh pr list", why: "open PRs could not be read from gh — stranded PRs are UNMEASURED, which is not the same as none" });
684
+ } else {
685
+ openPrs = [];
686
+ for (const p of artefacts.openPrs) {
687
+ const { verdicts } = verdictsFor(logText, String(p.n));
688
+ const verdictAtHead = verdicts.some((v) => shaAgrees(v.head, p.headRefOid));
689
+ const minutes = Math.max(0, Math.round((now - Date.parse(p.updatedAt)) / 60000));
690
+ const claimAtHead = verdictAtHead ? null : gateClaimsIn(logText, String(p.n), p.headRefOid)[0] ?? null;
691
+ openPrs.push({ pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes, verdictAtHead, claimAtHead });
692
+ // A verdict bound to an OLDER head does not count: the head moved, and a
693
+ // PASS on what used to be there is the #268 breach one step earlier.
694
+ // ⟨q-dcbaf544⟩ — an UNSCRIBED gate report fires at once; "nobody gated" waits the limit.
695
+ if (!verdictAtHead && (claimAtHead || now - Date.parse(p.updatedAt) > limitMs)) {
696
+ hits.push({
697
+ kind: "unverdicted-pr", agentId: seatOfBranch(p.headRefName), pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes,
698
+ ...(claimAtHead
699
+ ? { why: "reported-unscribed" as const, claimedBy: claimAtHead.from, claimedAt: new Date(claimAtHead.ts).toISOString(), claimedResult: claimAtHead.result }
700
+ : { why: "nobody-gated" as const }),
701
+ });
702
+ }
703
+ }
704
+ }
705
+
706
+ let pushedBranches: ReturnType<typeof strandedWork>["pushedBranches"] = null;
707
+ if (artefacts.remoteHeads === null) {
708
+ unmeasurable.push({ agentId: "-", value: "git ls-remote --heads origin", why: "origin's branches could not be read — pushed-but-unproposed work is UNMEASURED, which is not the same as none" });
709
+ } else {
710
+ pushedBranches = [];
711
+ const prHeads = new Set((artefacts.openPrs ?? []).map((p) => p.headRefOid));
712
+ // RANK 1, docs/LANDEDNESS.md: the forge's own record. Measured live before it
713
+ // was asked: a 15-day-old branch whose PR #170 had merged with this very tip
714
+ // as its head read "not landed" — its squash sat beyond the patch index's
715
+ // window. A patch-id negative is inconclusive by construction; the forge's
716
+ // positive is not.
717
+ const forgeLanded = new Set((artefacts.mergedPrs ?? []).map((p) => p.headRefOid));
718
+ if (artefacts.mergedPrs === null || artefacts.mergedPrs === undefined) {
719
+ unmeasurable.push({ agentId: "-", value: "gh pr list --state merged", why: "the forge's merged-PR record could not be read — landedness falls back to patch ids, whose NEGATIVES are inconclusive in a squash-merging repo (docs/LANDEDNESS.md); a branch reported unproposed here may have landed" });
720
+ }
721
+ const prBranches = new Set((artefacts.openPrs ?? []).map((p) => p.headRefName));
722
+ let base: string | null = null;
723
+ for (const cand of ["origin/main", "main", "origin/master", "master"]) {
724
+ try { git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); base = cand; break; } catch { /* next */ }
725
+ }
726
+ let squashes: Map<string, string> | null = null; // built once per run, only if a branch needs it
727
+ const memo = readMemo();
728
+ let baseSha = "";
729
+ try { baseSha = base ? git(["rev-parse", base]) : ""; } catch { /* handled below as no base */ }
730
+ const started = Date.now();
731
+ let deferred = 0;
732
+ // The budget is spent on the branches that can RAISE something first: a
733
+ // registered seat's branch is the only kind that becomes a hit, and on a
734
+ // 212-head origin the alphabet puts `docs/…` and `fix/…` ahead of every
735
+ // `groundwork-kit-worker-*/…`. Stable within each group.
736
+ const ordered = [...artefacts.remoteHeads].sort(
737
+ (x, y) => Number(registeredSeats.has(seatOfBranch(y.name))) - Number(registeredSeats.has(seatOfBranch(x.name))),
738
+ );
739
+ for (const h of ordered) {
740
+ if (BASE_BRANCHES.has(h.name)) continue;
741
+ const hasPr = prHeads.has(h.sha) || prBranches.has(h.name);
742
+ if (!base) {
743
+ unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: "no main/master to measure distance from" });
744
+ continue;
745
+ }
746
+ // ⛔ THE FORGE BEFORE THE MEMO — QA's row B (#307 FAIL @ 0f5f334): a branch
747
+ // memoised as not-landed against a base that has not moved stayed a hit
748
+ // AFTER the forge said its PR had merged, because the memo answered first.
749
+ // Live shape: PR merged, local origin/main not yet fetched, owner DMed that
750
+ // merged work is stranded — the instrument mirroring the aide's clearance
751
+ // error. The forge is rank 1 and a set lookup; it is asked first, always,
752
+ // and its answer overwrites the memo.
753
+ const cached = memo[h.sha];
754
+ let ahead: number;
755
+ let landed: boolean;
756
+ let landedBy: LandedBy;
757
+ let committedAt: string;
758
+ if (forgeLanded.has(h.sha) && cached && !cached.landed) {
759
+ ({ ahead, committedAt } = cached);
760
+ landed = true;
761
+ landedBy = "forge";
762
+ memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
763
+ } else if (cached && (cached.landed || cached.base === baseSha)) {
764
+ ({ ahead, landed, landedBy, committedAt } = cached);
765
+ } else {
766
+ if (Date.now() - started > budgetMs) {
767
+ deferred++;
768
+ continue;
769
+ }
770
+ try {
771
+ git(["cat-file", "-e", `${h.sha}^{commit}`]);
772
+ } catch {
773
+ unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: `origin/${h.name} @ ${h.sha.slice(0, 7)} is not fetched here — its age and distance from main cannot be read; not counted as clean` });
774
+ continue;
775
+ }
776
+ ahead = Number(git(["rev-list", "--count", `${base}..${h.sha}`]) || 0);
777
+ committedAt = git(["log", "-1", "--format=%cI", h.sha, "--"]);
778
+ // ⛔⛆ ANCESTRY CANNOT SAY "LANDED" ON A SQUASH-MERGING FLEET. Measured live
779
+ // before this line existed: 205 hits, and every young one was a branch
780
+ // whose PR had merged that afternoon — `rev-list --count main..sha` reads
781
+ // a squash-landed branch as ahead forever. `git cherry` compares PATCH
782
+ // IDS, the same instrument `classifyBoardRef` and `refresh_worktrees` use:
783
+ // every line `-` means every patch is upstream. Same caveat as theirs: a
784
+ // squash of SEVERAL commits into one changes the combined patch id, so a
785
+ // multi-commit landed branch can still read as unproposed — the direction
786
+ // that over-reports, never the one that hides stranded work.
787
+ landed = false;
788
+ landedBy = null;
789
+ if (ahead > 0) {
790
+ if (forgeLanded.has(h.sha)) {
791
+ landed = true;
792
+ landedBy = "forge";
793
+ }
794
+ if (!landed) {
795
+ try {
796
+ const cherry = git(["cherry", base, h.sha]);
797
+ landed = cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
798
+ if (landed) landedBy = "patch";
799
+ } catch { /* unreadable: inconclusive, which over-reports */ }
800
+ }
801
+ if (!landed) {
802
+ if (!squashes) squashes = squashIndex(repo, base);
803
+ landed = landedBySquash(repo, base, h.sha, squashes);
804
+ if (landed) landedBy = "squash";
805
+ }
806
+ }
807
+ memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
808
+ }
809
+ const minutes = Math.max(0, Math.round((now - Date.parse(committedAt)) / 60000));
810
+ pushedBranches.push({ branch: h.name, head: h.sha, ahead, landed, landedBy, minutes, hasPr });
811
+ // ⚠ A BRANCH HIT IS RAISED ONLY FOR A SEAT THE BUS KNOWS. Measured live
812
+ // after the two landedness tests: the survivors were `docs/…`, `fix/…`
813
+ // branches from before the seat-prefix convention — abandoned work with no
814
+ // owner to DM. 40+ hits every run is how a signal gets switched off. They
815
+ // stay VISIBLE in `pushedBranches` (ahead, not landed, no PR); the DM goes
816
+ // only where there is a lane to answer it. The PR half is NOT filtered:
817
+ // an open PR is somebody's by construction, and it is the acceptance.
818
+ const seat = seatOfBranch(h.name);
819
+ if (ahead > 0 && !landed && !hasPr && registeredSeats.has(seat) && now - Date.parse(committedAt) > limitMs) {
820
+ hits.push({
821
+ kind: "unproposed-branch", agentId: seat, branch: h.name, head: h.sha, ahead, minutes,
822
+ note: "STRANDED, not necessarily LOST: nobody has cited this artefact. Its owner classifies it — superseded draft (delete) · abandoned (say so) · unlanded work (open the PR).",
823
+ });
824
+ }
825
+ }
826
+ // Forget shas origin no longer has, so the memo cannot grow without bound.
827
+ const live = new Set(artefacts.remoteHeads.map((h) => h.sha));
828
+ for (const k of Object.keys(memo)) if (!live.has(k)) delete memo[k];
829
+ writeMemo(memo);
830
+ if (deferred > 0) {
831
+ unmeasurable.push({ agentId: "-", value: `${deferred} branch(es)`, why: `DEFERRED — the ${budgetMs}ms budget for uncached branch classification ran out; these are UNMEASURED this run, not clean, and the next run continues from the memo` });
832
+ }
833
+ }
834
+ return { hits: withAudience(hits), unmeasurable, openPrs, pushedBranches };
835
+ }
49
836
 
50
837
  // ---------- halt ----------
51
838
 
@@ -107,9 +894,34 @@ export function markRunFailure(reason: string): void {
107
894
  writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
108
895
  }
109
896
 
110
- type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string };
897
+ /**
898
+ * ⟨q-d0e83b41⟩ (09-14 amend) — THE POPULATION TRAVELS WITH THE RUN. A ratio is
899
+ * a claim about a population; a run that records only the ratio cannot tell
900
+ * "a lane got measurable" from "a lane left the denominator". So each run
901
+ * records WHO was scored, from which board (path@sha), and the next run
902
+ * reports the delta as its own signal beside the ratio.
903
+ */
904
+ export type RunPopulation = { repo: string; source: string; scored: string[]; roles: string[]; deliberate: string[]; held: string[] };
905
+ type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string; population?: RunPopulation };
906
+ export function lastPopulationFor(repo: string): { at: number; population: RunPopulation } | null {
907
+ let history: RunMark[] = [];
908
+ try { history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? []; } catch { return null; }
909
+ for (let i = history.length - 1; i >= 0; i--) {
910
+ const h = history[i]!;
911
+ if (!h.failed && h.population && h.population.repo === repo) return { at: h.at, population: h.population };
912
+ }
913
+ return null;
914
+ }
915
+ /** `docs/WORKSTREAMS.md@<sha>`, with `+uncommitted` when the working copy differs from that commit. */
916
+ export function boardSourceOf(repo: string): string {
917
+ let sha = "unknown";
918
+ try { sha = gitOut(repo, ["log", "-1", "--format=%h", "--", BOARD_DOC]) || "uncommitted"; } catch { /* no history */ }
919
+ let dirty = false;
920
+ try { dirty = gitOut(repo, ["status", "--porcelain", "--", BOARD_DOC]).length > 0; } catch { /* unknown */ }
921
+ return `${BOARD_DOC}@${sha}${dirty ? "+uncommitted" : ""}`;
922
+ }
111
923
 
112
- function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
924
+ function markRun(result: { hits: StallHit[]; checked: number; measurable?: number; population?: RunPopulation }) {
113
925
  mkdirSync(ROOT, { recursive: true });
114
926
  let history: RunMark[] = [];
115
927
  try {
@@ -122,7 +934,7 @@ function markRun(result: { hits: StallHit[]; checked: number; measurable?: numbe
122
934
  // reported `runs 8, failures 0` — all green — while every one of those runs
123
935
  // had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
124
936
  // instrument meant to cover an absence.
125
- history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
937
+ history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0, ...(result.population ? { population: result.population } : {}) });
126
938
  // A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
127
939
  // list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
128
940
  // are different states and only the first is a healthy fleet.
@@ -217,7 +1029,115 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
217
1029
 
218
1030
  // ---------- the predicate ----------
219
1031
 
220
- export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
1032
+ export const stallCheckSchema = {
1033
+ repo: z.string().optional(),
1034
+ stallMinutes: z.number().optional(),
1035
+ /** The 🔍 window, in minutes. Default REVIEW_STALL_MINUTES. Independent of stallMinutes on purpose. */
1036
+ reviewMinutes: z.number().optional(),
1037
+ /** ⟨q-7b2f6c04⟩ — the claim-axis window, in minutes. Default CLAIM_STALL_MINUTES. */
1038
+ claimMinutes: z.number().optional(),
1039
+ };
1040
+
1041
+ const gitOut = (repo: string, args: string[]) =>
1042
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1043
+
1044
+ /** The key a row keeps while its cells are edited: its stream text. */
1045
+ export const rowKey = (r: { stream: string }) => r.stream.replace(/\s+/g, " ").trim();
1046
+
1047
+ export type ReviewAge = { since: string; minutes: number; commits: number };
1048
+
1049
+ /**
1050
+ * ⛔⛆ AN `🔍 In Review` ROW IS NOT SCORED ON BRANCH ACTIVITY AT ALL (⟨q-507e80c4⟩).
1051
+ *
1052
+ * Measured 2026-09-14: `no-vcs-activity` fired on a review lane at 41m, again at
1053
+ * 45m, and would have fired on every correctly-behaving review lane given an
1054
+ * hour — a branch under review is SUPPOSED to stop moving, so that predicate
1055
+ * reaches its guaranteed end state on every healthy lane. The stall question in
1056
+ * review is a DIFFERENT one: how long has the row been in review.
1057
+ *
1058
+ * The board carries no timestamp column, but it is a git-tracked document, so
1059
+ * the answer is in its history: walk the commits that touched the board from
1060
+ * newest to oldest while this row (by stream key) still declares in-review; the
1061
+ * oldest such commit is when the row ENTERED review. A row that declares review
1062
+ * only in the working tree entered it just now (`since: "uncommitted"`).
1063
+ *
1064
+ * Returns `null` only when the board's history cannot be read — which is
1065
+ * reported as unmeasurable, never as healthy.
1066
+ */
1067
+ export function reviewEnteredAt(repo: string, key: string, now = Date.now()): ReviewAge | null {
1068
+ return stateEnteredAt(repo, key, now, (row) => workStateOf(row.status) === "in-review");
1069
+ }
1070
+ /**
1071
+ * ⟨q-7b2f6c04⟩ — when the row was CLAIMED: the oldest board commit in which it
1072
+ * declares an in-flight state. The claim axis reads the board's history the
1073
+ * way the review axis does, so a lane is observable from its claim, not from
1074
+ * its first push.
1075
+ */
1076
+ export function claimEnteredAt(repo: string, key: string, now = Date.now()): ReviewAge | null {
1077
+ return stateEnteredAt(repo, key, now, (row) => coarseOf(workStateOf(row.status)) === "in-flight");
1078
+ }
1079
+ function stateEnteredAt(repo: string, key: string, now: number, matches: (row: WorkstreamsV1Row) => boolean): ReviewAge | null {
1080
+ let log: string;
1081
+ try {
1082
+ log = gitOut(repo, ["log", "-n", "300", "--format=%H%x09%cI", "--", BOARD_DOC]);
1083
+ } catch {
1084
+ return null;
1085
+ }
1086
+ let since: string | null = null;
1087
+ let commits = 0;
1088
+ for (const line of log.split("\n").filter(Boolean)) {
1089
+ const [sha, iso] = line.split("\t");
1090
+ let text: string;
1091
+ try {
1092
+ text = gitOut(repo, ["show", `${sha}:${BOARD_DOC}`]);
1093
+ } catch {
1094
+ break;
1095
+ }
1096
+ const row = workstreamsV1RowsOf(parseWorkDoc(text)).find((r) => rowKey(r) === key);
1097
+ if (!row || !matches(row)) break;
1098
+ since = iso;
1099
+ commits++;
1100
+ }
1101
+ if (!since) return { since: "uncommitted", minutes: 0, commits: 0 };
1102
+ return { since, minutes: Math.round((now - Date.parse(since)) / 60000), commits };
1103
+ }
1104
+
1105
+ export type RowFreshness = {
1106
+ stream: string;
1107
+ owner: string;
1108
+ state: WorkState;
1109
+ /** ISO of the commit that last touched this row's line, `uncommitted` for a working-tree edit, `unknown` if blame failed. */
1110
+ lastUpdated: string;
1111
+ minutesAgo: number | null;
1112
+ commit: string | null;
1113
+ };
1114
+
1115
+ /**
1116
+ * ⛆ THE BOARD'S OWN FRESHNESS, PER ROW — the acceptance clause the original row
1117
+ * lacked. A predicate reading stale cells is not measuring lanes, it is
1118
+ * measuring the board's maintenance; on 2026-09-14 one silent seat froze the
1119
+ * board and the clock then fired on three HEALTHY lanes, and nothing in its
1120
+ * output could tell the two apart. So every scored row now says when its line
1121
+ * was last written, read from `git blame` on the row's own bytes.
1122
+ */
1123
+ export function rowFreshness(repo: string, boardText: string, rows: { stream: string; owner: string; status: string; raw?: string }[], now = Date.now()): RowFreshness[] {
1124
+ const lines = boardText.split("\n");
1125
+ return rows.map((r) => {
1126
+ const base = { stream: r.stream.slice(0, 60), owner: r.owner.replace(/[`*]/g, "").trim(), state: workStateOf(r.status) };
1127
+ const idx = r.raw ? lines.indexOf(r.raw) : -1;
1128
+ if (idx < 0) return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
1129
+ try {
1130
+ const out = gitOut(repo, ["blame", "-L", `${idx + 1},${idx + 1}`, "--porcelain", "--", BOARD_DOC]);
1131
+ const sha = out.split(/\s/)[0] ?? "";
1132
+ if (/^0+$/.test(sha)) return { ...base, lastUpdated: "uncommitted", minutesAgo: 0, commit: null };
1133
+ const t = Number(/^committer-time (\d+)/m.exec(out)?.[1]);
1134
+ if (!Number.isFinite(t)) return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: sha.slice(0, 8) };
1135
+ return { ...base, lastUpdated: new Date(t * 1000).toISOString(), minutesAgo: Math.round((now - t * 1000) / 60000), commit: sha.slice(0, 8) };
1136
+ } catch {
1137
+ return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
1138
+ }
1139
+ });
1140
+ }
221
1141
 
222
1142
  /**
223
1143
  * A row somebody is WORKING — the population the stall clock watches.
@@ -237,28 +1157,151 @@ export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.n
237
1157
  *
238
1158
  * NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
239
1159
  * `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
240
- * would be the 0/0 defect inverted.
1160
+ * would be the 0/0 defect inverted. They are REPORTED, though — see
1161
+ * `notWatched` below — because a row the clock declines to watch and a row
1162
+ * the clock cannot see produce the same silence.
241
1163
  *
242
1164
  * One predicate, exported: `coord_away` measures coverage by calling
243
1165
  * `stall_check` and reading `checked`/`measurable`, so it inherits this
244
1166
  * population without a change of its own. Anything else that asks "which rows
245
1167
  * are in flight" should ask here rather than re-derive it from a glyph.
1168
+ *
1169
+ * ⛔⛆ DERIVED FROM THE SEAM, NOT MATCHED ON A GLYPH (⟨q-a42503cb⟩). This was
1170
+ * `IN_FLIGHT_STATUS = /🚧|🔍/`, and the sentence above it — "ask here rather
1171
+ * than re-derive it from a glyph" — described a single authority that WAS a
1172
+ * glyph regex. The board's vocabulary grew to eleven documented states
1173
+ * (`workStateOf`, seam ⟨q-5b3e9a04⟩) and this predicate stayed at two, so
1174
+ * every `⏸` row was invisible here AND in `next_unblocked`'s routing
1175
+ * exclusion, which shares it: a seat that wrote `⏸ MERGE-HELD` instead of a
1176
+ * stale `🚧` was doing the right thing and punished for it by a silent drop
1177
+ * from both populations.
1178
+ *
1179
+ * The seam reads the LEADING glyph after stripping decoration, so
1180
+ * `⛔ Blocked — was 🚧 yesterday` is blocked here and was in-flight under the
1181
+ * regex. Measured on the live board at origin/main 687a74f (25 rows): the two
1182
+ * predicates agree on every row, 4 in flight under each — the swap changes no
1183
+ * verdict today; it changes what the file can SAY about the other 21.
246
1184
  */
247
- export const IN_FLIGHT_STATUS = /🚧|🔍/;
248
- export const isInFlightStatus = (status: string): boolean => IN_FLIGHT_STATUS.test(status);
1185
+ export const isInFlightStatus = (status: string): boolean => coarseOf(workStateOf(status)) === "in-flight";
1186
+
1187
+ /** A hold the board declares for this lane, or null. Blocker cell first (the lane's own statement), then the Cutover Gates table. */
1188
+ export function holdOf(row: WorkstreamsV1Row, cutoverGates: Record<string, string>[]): { text: string; by: "blocker" | "cutover-gate" } | null {
1189
+ const blocker = String(row.blocker ?? "").replace(/[`*]/g, "").trim();
1190
+ if (blocker && !/^(—|-|–|none|n\/a)$/i.test(blocker)) return { text: blocker.slice(0, 160), by: "blocker" };
1191
+ const ids = [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
1192
+ const prs = [...String(row.stream).matchAll(/#(\d{2,})\b/g)].map((m) => `#${m[1]}`);
1193
+ const ref = refInCell(row.branchWorktree);
1194
+ for (const g of cutoverGates) {
1195
+ const gate = String(g["Gate"] ?? Object.values(g)[0] ?? "");
1196
+ if (ids.some((id) => gate.includes(id)) || prs.some((p) => gate.includes(p)) || (ref && gate.includes(ref))) {
1197
+ return { text: gate.replace(/[`*]/g, "").trim().slice(0, 160), by: "cutover-gate" };
1198
+ }
1199
+ }
1200
+ return null;
1201
+ }
1202
+ /**
1203
+ * ⟨q-7e3b10c9⟩ — THE BOARD NAMES ITS OWNER. The Rooms table's owner cell says
1204
+ * who holds the topic — this fleet's `groundwork-kit-coordinator (topic owner)`
1205
+ * — so the clock can route bookkeeping hits without a bus-meta dependency.
1206
+ * Null when the board does not say; the clock then says so and falls back.
1207
+ */
1208
+ export function boardOwnerOf(boardText: string): string | null {
1209
+ const ext = workstreamsExtensionsOf(parseWorkDoc(boardText));
1210
+ for (const r of ext.rooms) {
1211
+ for (const v of Object.values(r)) {
1212
+ const m = /([\w.-]+-coordinator)\b/.exec(String(v));
1213
+ if (m) return m[1]!;
1214
+ }
1215
+ }
1216
+ return null;
1217
+ }
249
1218
 
250
- export async function stallCheckTool(args: { repo?: string; stallMinutes?: number }) {
1219
+ export async function stallCheckTool(
1220
+ args: { repo?: string; stallMinutes?: number; reviewMinutes?: number; claimMinutes?: number },
1221
+ artefacts: (repo: string) => RepoArtefacts = realArtefacts,
1222
+ readRooms: () => string = readAllRoomLogs,
1223
+ readInboxes: () => string = readAllInboxLogs,
1224
+ ) {
251
1225
  const repo = args.repo ?? process.cwd();
252
1226
  const limit = (args.stallMinutes ?? 30) * 60 * 1000;
1227
+ const reviewLimit = args.reviewMinutes ?? REVIEW_STALL_MINUTES;
1228
+ const claimLimit = args.claimMinutes ?? CLAIM_STALL_MINUTES;
253
1229
  const board = path.join(repo, BOARD_DOC);
254
1230
  if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
255
1231
 
256
1232
  const liveTransports = await loadLiveTransports();
257
- const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
258
- const inFlight = rows.filter((r) => isInFlightStatus(r.status));
1233
+ const boardText = readFileSync(board, "utf8");
1234
+ const boardDoc = parseWorkDoc(boardText);
1235
+ const rows = workstreamsV1RowsOf(boardDoc);
1236
+ // ⟨q-7e3b10c9⟩ — holds DECLARED on the board: the Cutover Gates table's Gate
1237
+ // cell names what is held; a lane row's Blocker cell names what it waits on.
1238
+ const cutoverGates = workstreamsExtensionsOf(boardDoc).cutoverGates;
1239
+ // ⟨q-3d82f1a9⟩ — SAY THE PARSE STATE FIRST. An unreadable board is refused
1240
+ // as a measurement, not answered as a quiet fleet: ok:false, dm:true, and
1241
+ // the counts named, so the duty officer is told the board broke rather than
1242
+ // shown an empty list that reads as healthy.
1243
+ const boardParse = boardParseOf(boardText);
1244
+ if (!boardParse.readable) {
1245
+ markRunFailure(`unparseable board — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed`);
1246
+ return {
1247
+ ok: false as const,
1248
+ error: `${BOARD_DOC} is ${boardParse.why}`,
1249
+ boardParse,
1250
+ checked: 0,
1251
+ measurable: 0,
1252
+ blind: [] as string[],
1253
+ hits: [] as StallHit[],
1254
+ unmeasurable: [] as { agentId: string; value: string; why: string }[],
1255
+ halted: haltState().halted,
1256
+ dm: true,
1257
+ note: `UNPARSEABLE BOARD — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed. This is NOT a quiet fleet: nothing was measured. Fix the row the parser stopped at and re-run.`,
1258
+ };
1259
+ }
1260
+ // ⟨q-5d1c8e04⟩ — LANES ARE SCORED; ROLES ARE REPORTED. A role row (🪑, branch
1261
+ // `—`) is a standing seat with nothing to stall; it is listed as present or
1262
+ // absent from the registry and live or not on its transport, and never
1263
+ // enters `checked`. Two roles and zero lanes read 0 of 0, not 0 of 2. A 🪑
1264
+ // row that carries a ref is a lane in disguise and is scored below.
259
1265
  const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
1266
+ const kinds = rows.map((r) => ({ row: r, kind: rowKindOf(r) }));
1267
+ const roles = kinds
1268
+ .filter((k) => k.kind === "role")
1269
+ .map(({ row }) => {
1270
+ const owner = row.owner.replace(/[`*]/g, "").trim();
1271
+ return { stream: row.stream.slice(0, 60), owner, present: !!reg[owner], active: liveTransports.has(owner) };
1272
+ });
1273
+ const disguised = kinds.filter((k) => k.kind === "role-with-ref").map((k) => k.row);
1274
+ const inFlight = [...rows.filter((r) => isInFlightStatus(r.status)), ...disguised];
1275
+ // THE ROWS THIS CLOCK DOES NOT WATCH, NAMED AS THEIR OWN STATE (⟨q-a42503cb⟩).
1276
+ // A parked row is not a stalled one, so it is not in `checked` — but a
1277
+ // clock that cannot name what it declined to watch reads the same as one that
1278
+ // never saw it. Finished rows are left out: there is nothing to watch or
1279
+ // route behind a `✅`. Everything else that is not in flight is listed with
1280
+ // the state the seam read, so a reader can tell "held pending a merge" from
1281
+ // "blocked" from "parked awaiting a human" without opening the board.
1282
+ const byState: Partial<Record<WorkState, number>> = {};
1283
+ for (const r of rows) {
1284
+ const s = workStateOf(r.status);
1285
+ byState[s] = (byState[s] ?? 0) + 1;
1286
+ }
1287
+ const notWatched = rows
1288
+ .filter((r) => {
1289
+ const c = coarseOf(workStateOf(r.status));
1290
+ return c !== "in-flight" && c !== "finished";
1291
+ })
1292
+ .map((r) => ({
1293
+ stream: r.stream.slice(0, 60),
1294
+ owner: r.owner.replace(/[`*]/g, "").trim(),
1295
+ state: workStateOf(r.status),
1296
+ status: r.status.slice(0, 80),
1297
+ }));
260
1298
  const now = Date.now();
261
- const hits: StallHit[] = [];
1299
+ const hits: StallHitBody[] = [];
1300
+ /** The base's tip, for telling an empty claim-time push from a lane with commits. */
1301
+ let baseTip: string | null = null;
1302
+ for (const cand of ["origin/main", "origin/master", "main", "master"]) {
1303
+ try { baseTip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); break; } catch { /* next */ }
1304
+ }
262
1305
  /** Rows whose VCS activity could not be measured. NOT stall claims. */
263
1306
  const unmeasurable: { agentId: string; value: string; why: string }[] = [];
264
1307
  /**
@@ -271,9 +1314,67 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
271
1314
  * skip and a clean pass produce the same number.
272
1315
  */
273
1316
  const measured = new Set<string>();
1317
+ /**
1318
+ * ⟨q-5d1c8e04⟩ — DELIBERATELY BRANCHLESS LANES, out of the population. The
1319
+ * cell holds a statement (`docs-direct · no per-agent branch`) rather than a
1320
+ * ref; the seat said so in words. Reported by name, subtracted from
1321
+ * `checked`, never "unmeasurable" — the coverage ratio stops carrying rows
1322
+ * that were never measurable by design. A blank or `—` on a lane is NOT
1323
+ * this: that lane should carry a branch and names none, and stays unmeasurable.
1324
+ */
1325
+ const deliberate: { agentId: string; stream: string; cell: string }[] = [];
1326
+ /**
1327
+ * ⟨q-7e3b10c9⟩ — HELD, NOT STALLED AND NOT UNATTENDED. A lane ageing behind a
1328
+ * hold the board DECLARES — its Blocker cell names a hold, or a Cutover Gates
1329
+ * row names the lane — is reported as held, with the hold, and is not scored:
1330
+ * a frozen branch behind a PASS-HOLD is the gate working, not a seat stuck.
1331
+ * Measured as a verdict (coverage counts it). An UNDECLARED wait is not a
1332
+ * hold: the same frozen branch with an empty Blocker cell still fires.
1333
+ */
1334
+ const held: { agentId: string; stream: string; hold: string; declaredBy: "blocker" | "cutover-gate" }[] = [];
1335
+ const scored: typeof inFlight = [];
1336
+ /**
1337
+ * ⟨q-7b2f6c04⟩ — WHICH AXIS EACH ROW WAS MEASURED ON. One number over three
1338
+ * axes is how all three stayed hidden; every scored row now says which
1339
+ * predicate reached a verdict about it, or that none could.
1340
+ */
1341
+ type Axis = "vcs" | "review" | "claim" | "landed";
1342
+ const axes: { agentId: string; stream: string; axis: Axis | null; measurable: boolean; why?: string }[] = [];
1343
+ const onAxis = (agentId: string, row: { stream: string }, axis: Axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
1344
+ const offAxis = (agentId: string, row: { stream: string }, why: string) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
1345
+ /** The claim axis: scored from the board's history; fires past the claim window. */
1346
+ const scoreClaim = (agentId: string, row: WorkstreamsV1Row, branch: string, because: string) => {
1347
+ const age = claimEnteredAt(repo, rowKey(row), now);
1348
+ if (!age) {
1349
+ unmeasurable.push({ agentId, value: BOARD_DOC, why: "the board's git history could not be read, so the claim time is unknown — unknown is not healthy" });
1350
+ offAxis(agentId, row, "board history unreadable");
1351
+ return;
1352
+ }
1353
+ onAxis(agentId, row, "claim");
1354
+ if (age.minutes > claimLimit) {
1355
+ hits.push({
1356
+ kind: "unpushed-claim", agentId, stream: row.stream.slice(0, 60), branch, minutes: age.minutes, since: age.since,
1357
+ why: `claimed ${age.minutes}m ago (row in flight since ${age.since}) and ${because} — nothing from this lane has reached origin inside the ${claimLimit}m claim window. A worker thinking hard and a worker whose model has wedged look the same from here; say which.`,
1358
+ });
1359
+ }
1360
+ };
274
1361
 
275
1362
  for (const row of inFlight) {
276
1363
  const agentId = row.owner.replace(/[`*]/g, "").trim();
1364
+ // Only a row the VCS half would score can declare itself branchless. A 🔍
1365
+ // row is scored on time-in-review from the board (#306), whatever its cell
1366
+ // says — so its prose is a note, not an exemption.
1367
+ if (cellKindOf(row.branchWorktree) === "prose" && workStateOf(row.status) !== "in-review") {
1368
+ deliberate.push({ agentId, stream: row.stream.slice(0, 60), cell: String(row.branchWorktree).trim().slice(0, 80) });
1369
+ continue;
1370
+ }
1371
+ scored.push(row);
1372
+ const hold = holdOf(row, cutoverGates);
1373
+ if (hold) {
1374
+ held.push({ agentId, stream: row.stream.slice(0, 60), hold: hold.text, declaredBy: hold.by });
1375
+ measured.add(agentId);
1376
+ continue;
1377
+ }
277
1378
  const entry = reg[agentId];
278
1379
  if (!entry) {
279
1380
  // NOT SKIPPED IN SILENCE. An owner with no registry entry has no
@@ -391,17 +1492,56 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
391
1492
  // ref. Resolution was never the property; the property is whether the ref's
392
1493
  // movement is THIS AGENT'S WORK.
393
1494
  const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
1495
+ // ⟨q-7b2f6c04⟩ — a ref sitting EXACTLY on the base's tip is the empty push
1496
+ // `ensure_worktree` makes at claim: zero commits of its own, so the
1497
+ // classifier reads it as landed and the VCS axis would read the base's
1498
+ // age. It is a CLAIM, and the claim axis scores it — checked before either.
1499
+ if ((verdict.kind === "merged" || verdict.kind === "measurable") && baseTip) {
1500
+ const cellRef = refInCell(row.branchWorktree).replace(/^origin\//, "");
1501
+ let tip: string | null = null;
1502
+ try { tip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `origin/${cellRef}^{commit}`]); } catch { /* not on origin */ }
1503
+ if (tip && tip === baseTip) {
1504
+ scoreClaim(agentId, row, `origin/${cellRef}`, "its branch sits on the base with no commits of its own (the empty claim-time push)");
1505
+ continue;
1506
+ }
1507
+ }
394
1508
  if (verdict.kind === "merged") {
395
1509
  // A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
396
1510
  // did learn something about this lane: its work landed and its row is
397
1511
  // stale. Reported as its own kind rather than as a stall, which is what
398
1512
  // the frozen ref was reporting it as.
399
1513
  hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
400
- measured.add(agentId);
1514
+ onAxis(agentId, row, "landed");
1515
+ continue;
1516
+ }
1517
+ if (workStateOf(row.status) === "in-review") {
1518
+ // ⛔ NOT THE VCS PREDICATE. A review-frozen branch is frozen for the
1519
+ // correct reason; scoring it on activity fires on every healthy review
1520
+ // lane eventually. The question here is how long the row has been in
1521
+ // review, read from the board's own history — which also means a 🔍 row
1522
+ // whose cell holds a path is MEASURED here rather than unmeasurable: the
1523
+ // predicate needs the board, not the ref.
1524
+ const age = reviewEnteredAt(repo, rowKey(row), now);
1525
+ if (!age) {
1526
+ unmeasurable.push({ agentId, value: BOARD_DOC, why: "the board's git history could not be read, so time in review is unknown — unknown is not healthy" });
1527
+ offAxis(agentId, row, "board history unreadable");
1528
+ continue;
1529
+ }
1530
+ onAxis(agentId, row, "review");
1531
+ if (age.minutes > reviewLimit) {
1532
+ hits.push({ kind: "in-review-too-long", agentId, stream: row.stream.slice(0, 60), minutes: age.minutes, since: age.since });
1533
+ }
1534
+ continue;
1535
+ }
1536
+ // ⟨q-7b2f6c04⟩ — a branch with nothing on origin is not unmeasurable: it is
1537
+ // a lane between claim and first push, and the CLAIM axis measures it.
1538
+ if (verdict.kind === "unpushed" || verdict.kind === "local-only") {
1539
+ scoreClaim(agentId, row, refInCell(row.branchWorktree), `its branch is not on origin (${verdict.kind})`);
401
1540
  continue;
402
1541
  }
403
1542
  if (verdict.kind !== "measurable") {
404
1543
  unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
1544
+ offAxis(agentId, row, verdict.why);
405
1545
  continue;
406
1546
  }
407
1547
  try {
@@ -414,12 +1554,13 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
414
1554
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
415
1555
  }).trim();
416
1556
  const age = now - Date.parse(iso);
417
- measured.add(agentId);
1557
+ onAxis(agentId, row, "vcs");
418
1558
  if (age > limit) {
419
1559
  hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
420
1560
  }
421
1561
  } catch {
422
1562
  unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
1563
+ offAxis(agentId, row, "resolved as a ref but its log could not be read");
423
1564
  }
424
1565
  }
425
1566
 
@@ -427,11 +1568,113 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
427
1568
  // verdict about it; a row where every predicate came back unmeasurable was
428
1569
  // looked at and not measured, and counting it as checked is how "the clock
429
1570
  // ran" gets mistaken for "the fleet is observed".
430
- const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
1571
+ const owners = scored.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
431
1572
  const blind = [...new Set(owners)].filter((id) => !measured.has(id));
432
- const measurable = Math.max(0, inFlight.length - blind.length);
433
- const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
434
- markRun({ hits, checked: inFlight.length, measurable });
1573
+ const measurable = Math.max(0, scored.length - blind.length);
1574
+ // ⟨q-d0e83b41⟩ (09-14 amend) the population's source and its delta since
1575
+ // the last run of THIS repo. A lane that left with no closing state on the
1576
+ // board (its row is gone) is a board-owner hit, never an improved ratio.
1577
+ const runPopulation: RunPopulation = {
1578
+ repo, source: boardSourceOf(repo),
1579
+ scored: scored.map(rowKey), roles: roles.map((r) => r.stream), deliberate: deliberate.map((d) => d.stream), held: held.map((h) => h.stream),
1580
+ };
1581
+ const prev = lastPopulationFor(repo);
1582
+ const ownerOf = (key: string) => (rows.find((r) => rowKey(r) === key)?.owner ?? "").replace(/[`*]/g, "").trim();
1583
+ const whereNow = (key: string): string => {
1584
+ const row = rows.find((r) => rowKey(r) === key);
1585
+ if (!row) return "gone";
1586
+ if (runPopulation.held.includes(row.stream.slice(0, 60))) return "held";
1587
+ if (runPopulation.deliberate.includes(row.stream.slice(0, 60))) return "deliberate";
1588
+ return workStateOf(row.status);
1589
+ };
1590
+ const delta = prev
1591
+ ? {
1592
+ since: new Date(prev.at).toISOString(),
1593
+ left: prev.population.scored.filter((k) => !runPopulation.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), to: whereNow(k) })),
1594
+ joined: runPopulation.scored.filter((k) => !prev.population.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), owner: ownerOf(k) })),
1595
+ }
1596
+ : { since: null, left: [], joined: [] };
1597
+ for (const l of delta.left) {
1598
+ if (l.to !== "gone") continue;
1599
+ hits.push({
1600
+ kind: "lane-left-population", agentId: boardOwnerOf(boardText) ?? "-", stream: l.stream,
1601
+ why: `"${l.stream}" was in the scored population at the last run (${delta.since}) and its row is GONE from ${runPopulation.source} with no closing state — the coverage ratio would otherwise read as improved. A lane leaves the board through a state (✅, ⏸, ⛔), never by deletion.`,
1602
+ });
1603
+ }
1604
+ // THE BOARD'S OWN FRESHNESS BESIDE EVERY VERDICT. A stall report over stale
1605
+ // cells is a report about the board's maintenance, and the reader must be
1606
+ // able to see which it is without opening git.
1607
+ const freshness = rowFreshness(repo, boardText, scored, now);
1608
+ const aged = freshness.map((f) => f.minutesAgo).filter((m): m is number => typeof m === "number");
1609
+
1610
+ // ⟨q-6f0a3d81⟩ — THE AXIS THAT NEEDS NOBODY TO SPEAK. Computed over the
1611
+ // repository, not the board: a stranded PR has no row, which is the point.
1612
+ // `checked`/`measurable` stay the lane population `coord_away` reads; these
1613
+ // hits join `hits` so the duty officer is DMed. Its unreadable SOURCES are
1614
+ // reported under its own key, not in the per-row `unmeasurable` list: that
1615
+ // list is a population of lane rows (a test pins "two halves of one row"),
1616
+ // and an unreachable gh is not a row. Separate populations, separate keys.
1617
+ const arte = artefacts(repo);
1618
+ const roomText = readRooms();
1619
+ const stranded = strandedWork(repo, arte, roomText, now, limit, new Set(Object.keys(reg)));
1620
+ hits.push(...stranded.hits);
1621
+ // ⟨q-8f1e604b⟩ — the two conventions, checked from the artefacts: PR pages
1622
+ // for (e), the bus's own routing records against the board and queue for (f).
1623
+ const queuePath = path.join(repo, "docs/QUEUE.md");
1624
+ const conventions = conventionChecks({
1625
+ recentMerges: arte.recentMerges,
1626
+ routingLogText: `${roomText}\n${readInboxes()}`,
1627
+ queueText: existsSync(queuePath) ? readFileSync(queuePath, "utf8") : null,
1628
+ boardRows: rows,
1629
+ now,
1630
+ });
1631
+ hits.push(...conventions.hits);
1632
+ // ⟨q-4e08b3c1⟩ — convention (g): the bus's windows joined to git's write times.
1633
+ const mergeWindows = mergeWindowChecks({ logText: roomText, writes: recordWritesOn(repo, now - CONVENTION_WINDOW_MS), now });
1634
+ hits.push(...mergeWindows.hits);
1635
+
1636
+ const stamped = withAudience(hits);
1637
+ const dmBy = { duty: stamped.filter((h) => h.audience === "duty").length, "board-owner": stamped.filter((h) => h.audience === "board-owner").length };
1638
+ const result = {
1639
+ hits: stamped,
1640
+ // ⟨q-7e3b10c9⟩ — who each hit is for, counted, so a relayer can split.
1641
+ dmBy,
1642
+ held,
1643
+ // ⟨q-3d82f1a9⟩ — the parse state travels with every answer, readable or not.
1644
+ boardParse,
1645
+ // The population the clock SCORES: lanes with a ref position to read. Roles
1646
+ // and deliberately branchless lanes are reported beside it, not inside it.
1647
+ checked: scored.length,
1648
+ unmeasurable,
1649
+ measurable,
1650
+ blind,
1651
+ roles,
1652
+ deliberate,
1653
+ disguisedRoles: disguised.map((r) => r.stream.slice(0, 60)),
1654
+ predicates: {
1655
+ "in-progress": `heartbeat (where a source exists) + branch activity, window ${Math.round(limit / 60000)}m`,
1656
+ "in-review": `time in review from the board's git history, window ${reviewLimit}m — branch activity is NOT scored`,
1657
+ claimed: `time since the row entered in-flight, from the board's git history, for a lane whose branch has nothing on origin (unpushed, local-only, or pushed empty at claim); window ${claimLimit}m`,
1658
+ },
1659
+ boardFreshness: {
1660
+ rows: freshness,
1661
+ stalestMinutes: aged.length ? Math.max(...aged) : null,
1662
+ uncommitted: freshness.filter((f) => f.lastUpdated === "uncommitted").length,
1663
+ note:
1664
+ "when each scored row's line was last written (git blame). A stall over a row nobody has updated for hours may be the board's maintenance, not the lane.",
1665
+ },
1666
+ // Population beside every count: `checked` is the in-flight rows; this is
1667
+ // the rest of the board by the state the seam read, and the not-in-flight,
1668
+ // not-finished rows by name.
1669
+ population: { rows: rows.length, inFlight: inFlight.length, byState, source: runPopulation.source, delta },
1670
+ notWatched,
1671
+ // ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
1672
+ axes,
1673
+ stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
1674
+ conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
1675
+ mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
1676
+ };
1677
+ markRun({ hits: stamped, checked: scored.length, measurable, population: runPopulation });
435
1678
  return {
436
1679
  ok: true as const,
437
1680
  ...result,
@@ -441,7 +1684,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
441
1684
  dm: hits.length > 0,
442
1685
  note:
443
1686
  hits.length === 0
444
- ? `MISS — ${inFlight.length} in-flight row(s), none stalled${unmeasurable.length ? `; ${unmeasurable.length} row(s) UNMEASURABLE for VCS activity (${unmeasurable.map((u) => u.agentId).join(", ")}) — reported, not counted as healthy` : ""}. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
1687
+ ? `MISS — ${scored.length} lane(s) scored${held.length ? `, ${held.length} held behind a declared hold` : ""}${roles.length ? `, ${roles.length} role(s) present and not scored` : ""}${deliberate.length ? `, ${deliberate.length} deliberately branchless lane(s) out of population` : ""}, none stalled${unmeasurable.length ? `; ${unmeasurable.length} row(s) UNMEASURABLE for VCS activity (${unmeasurable.map((u) => u.agentId).join(", ")}) — reported, not counted as healthy` : ""}. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
445
1688
  : undefined,
446
1689
  };
447
1690
  }