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,18 +16,646 @@
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 } 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 } from "../closing-line.js";
27
29
  const BOARD_DOC = "docs/WORKSTREAMS.md";
30
+ export function boardParseOf(text) {
31
+ const lines = String(text ?? "").split("\n");
32
+ const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
33
+ const doc = parseWorkDoc(String(text ?? ""));
34
+ const block = doc.blocks.find((b) => b.kind === "board" && b.schema === "workstreams.v1");
35
+ const blockRows = (block?.rows ?? []);
36
+ const rowsParsed = workstreamsV1RowsOf(doc).length;
37
+ const malformed = blockRows.filter((r) => isMalformedRow(r)).length;
38
+ if (header === -1) {
39
+ const pipeLines = lines.filter((l) => /^\s*\|/.test(l)).length;
40
+ const readable = pipeLines === 0 && rowsParsed === 0;
41
+ return {
42
+ section: "Active Streams", headerFound: false, rowsPresent: pipeLines, rowsParsed, malformed, unparsed: Math.max(0, pipeLines - rowsParsed - malformed), readable,
43
+ why: readable
44
+ ? "no Active Streams table in the file and no table-shaped lines — an empty board, readable"
45
+ : `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`,
46
+ };
47
+ }
48
+ let i = header + 1;
49
+ if (i < lines.length && /^\s*\|\s*:?-+/.test(lines[i]))
50
+ i++; // the alignment row is scaffold, not a row
51
+ let rowsPresent = 0;
52
+ for (; i < lines.length && /^\s*\|/.test(lines[i]); i++)
53
+ rowsPresent++;
54
+ const unparsed = Math.max(0, rowsPresent - rowsParsed - malformed);
55
+ // A MALFORMED ROW IS UNREADABLE TOO. Measured while writing the control: a
56
+ // short row that is not the table's first line is recorded by the seam as
57
+ // malformed (refused, kept verbatim) rather than ending the block — and a
58
+ // refused lane is exactly as invisible to this clock as a dropped one. Both
59
+ // conjuncts are required; the ad80825 shape trips the first, the mid-table
60
+ // shape trips the second.
61
+ const readable = unparsed === 0 && malformed === 0;
62
+ return {
63
+ section: "Active Streams", headerFound: true, rowsPresent, rowsParsed, malformed, unparsed, readable,
64
+ why: readable
65
+ ? rowsPresent === 0
66
+ ? "the Active Streams table is present and EMPTY — 0 rows present, 0 parsed; readable"
67
+ : `${rowsPresent} row(s) present, ${rowsParsed} parsed — every row is accounted for`
68
+ : `UNPARSEABLE — ${rowsPresent} row(s) present in the Active Streams table but only ${rowsParsed} parsed` +
69
+ `${malformed ? `; ${malformed} row(s) REFUSED by the parser as malformed (wrong column count) and therefore invisible to this clock` : ""}` +
70
+ `${unparsed ? `; ${unparsed} row(s) exist that no record describes — one bad row ends the table for the parser and nothing below it is measured` : ""}. ` +
71
+ `"Nothing measured" is not "nothing wrong".`,
72
+ };
73
+ }
28
74
  const STALL_MS = 30 * 60 * 1000;
75
+ /**
76
+ * How long a lane may sit in review before it is a HIT. Four times the
77
+ * authoring window, not equal to it: a review is a queue on another seat, and
78
+ * the number that matters is how long that queue has held this row — it is not
79
+ * a tuned threshold on branch activity, which a review-frozen branch fails by
80
+ * design (⟨q-507e80c4⟩).
81
+ */
82
+ const REVIEW_STALL_MINUTES = 120;
83
+ /** ⟨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. */
84
+ export const CLAIM_STALL_MINUTES = 120;
29
85
  const runFile = () => path.join(ROOT, "stall-check.json");
30
86
  const haltFile = () => path.join(ROOT, "halt.json");
87
+ export const audienceOf = (kind) => kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population" ? "board-owner" : "duty";
88
+ const withAudience = (hits) => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }));
89
+ const NET_TIMEOUT_MS = 15_000;
90
+ export const realArtefacts = (repo) => {
91
+ let openPrs = null;
92
+ try {
93
+ const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid,headRefName,updatedAt"], {
94
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
95
+ });
96
+ openPrs = JSON.parse(out).map((p) => ({
97
+ n: p.number, headRefOid: p.headRefOid, headRefName: p.headRefName, updatedAt: p.updatedAt,
98
+ }));
99
+ }
100
+ catch { /* reported as unmeasurable by the caller */ }
101
+ let mergedPrs = null;
102
+ try {
103
+ const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "1000", "--json", "headRefName,headRefOid"], {
104
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
105
+ });
106
+ mergedPrs = JSON.parse(out);
107
+ }
108
+ catch { /* landedness falls back to patch ids; said so by the caller */ }
109
+ let recentMerges = null;
110
+ try {
111
+ const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
112
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
113
+ });
114
+ recentMerges = JSON.parse(out).map((p) => ({
115
+ n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
116
+ }));
117
+ }
118
+ catch { /* said by the caller */ }
119
+ let remoteHeads = null;
120
+ try {
121
+ const out = execFileSync("git", ["ls-remote", "--heads", "origin"], {
122
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
123
+ });
124
+ remoteHeads = out.split("\n").filter(Boolean).map((l) => {
125
+ const [sha, ref] = l.split(/\s+/);
126
+ return { sha: sha ?? "", name: (ref ?? "").replace(/^refs\/heads\//, "") };
127
+ }).filter((h) => h.sha && h.name);
128
+ }
129
+ catch { /* reported as unmeasurable by the caller */ }
130
+ return { openPrs, remoteHeads, mergedPrs, recentMerges };
131
+ };
132
+ /** Every inbox on this bus, concatenated — a GO delivered by DM is a routing record too. */
133
+ export const readAllInboxLogs = () => {
134
+ const dir = path.join(ROOT, "inbox");
135
+ try {
136
+ return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
137
+ }
138
+ catch {
139
+ return "";
140
+ }
141
+ };
142
+ /* ────────────────────────────────────────────────────────────────────────────
143
+ * ⟨q-8f1e604b⟩ — TWO RECORD-KEEPING CONVENTIONS BECOME CHECKS. Both were
144
+ * adopted on 2026-09-14 by seats that were cycled the same afternoon; a
145
+ * convention does not survive its holder, a check does.
146
+ *
147
+ * (e) VERDICTS ON THE PR. A merged PR must carry a verdict COMMENT bound to the
148
+ * head that merged. "Verdict" is a TYPED line — `QA GATE — **PASS** @ \`sha\``
149
+ * or the merge-time `**GATE: PASS** … @ \`sha\`` — never a substring: #299's
150
+ * page says "not a verdict", "PASS" and "my verdict above" in three review
151
+ * comments and carries no verdict at all. Bound by sha, prefix-tolerant
152
+ * (`shaAgrees`): a verdict on an OLDER head is the #268 breach, not a gate.
153
+ * (f) A GO WRITES THE BOARD ROW. An item is ROUTED when a `go` record names it
154
+ * ON THE GO LINE — `GO ⟨q-…⟩ —`, `GO: … ⟨q-…⟩` — read by POSITION, because a
155
+ * GO's prose mentions neighbouring items as context. A routed item that is
156
+ * still open and has no board row naming it as its SUBJECT is a hit:
157
+ * `next_unblocked` would offer it again. Room logs AND inboxes are read —
158
+ * this fleet routes by DM.
159
+ * Both are bounded by a window: a merge or a GO older than it is listed, not
160
+ * raised, so the day's real instances (#299) fire once and age out.
161
+ * ──────────────────────────────────────────────────────────────────────────── */
162
+ export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
163
+ /**
164
+ * WHEN (e) WAS ADOPTED, as a dated fact: qa posted at 2026-09-14 13:28Z that all
165
+ * five of its verdicts were now on their PRs. Measured live before this floor
166
+ * existed: 39 of the 50 most recent merges had no verdict comment — every one
167
+ * merged BEFORE that instant, when the convention did not exist. Raising them
168
+ * would make the check's first three days pure noise, which is how a check gets
169
+ * switched off. Merges before the floor are LISTED with `beforeAdoption`, never
170
+ * raised; the window subsumes the floor after three days.
171
+ */
172
+ export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
173
+ // The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
174
+ export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
175
+ /** Items named ON A GO LINE of a `go` record — position, not mention. */
176
+ export function routedItemsIn(logText) {
177
+ const out = [];
178
+ for (const l of logText.split("\n")) {
179
+ if (!l.trim())
180
+ continue;
181
+ let o;
182
+ try {
183
+ o = JSON.parse(l);
184
+ }
185
+ catch {
186
+ continue;
187
+ }
188
+ if (o.record?.type !== "go")
189
+ continue;
190
+ for (const line of String(o.text ?? "").split("\n")) {
191
+ if (!/\bGO\b/.test(line))
192
+ continue;
193
+ for (const m of line.matchAll(/q-[0-9a-f]{8}/g))
194
+ out.push({ itemId: m[0], by: String(o.from ?? ""), ts: Number(o.ts ?? 0) });
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+ /* ────────────────────────────────────────────────────────────────────────────
200
+ * ⟨q-4e08b3c1⟩ — THE MERGE WINDOW, JOINED. qa posts `MERGE WINDOW: #N — hold
201
+ * queue/board writes` as the trailing line of its typed PASS and `MERGE WINDOW
202
+ * CLOSED` as the trailing line of its MERGED done; a seat's write is a commit
203
+ * touching docs/QUEUE.md, docs/DONE.md or docs/WORKSTREAMS.md, timestamped by
204
+ * git. The incident (#312): two coordinator board commits at 16:34 and 16:37,
205
+ * before a window opened at ~16:40 — nobody violated anything, the protocol's
206
+ * party list was incomplete. `217a6af` bound every record writer; that is the
207
+ * dated floor. Measured today before this existed: five windows, each under
208
+ * 70 seconds, 67 record commits, none inside a window.
209
+ *
210
+ * ⚠ AN UNCLOSED WINDOW IS CAPPED, NEVER OPEN-ENDED (the ruling): at the next
211
+ * window's open or 30 minutes, whichever comes first, and reported `unclosed`
212
+ * by name — a window nobody closed must not condemn every write after it.
213
+ * ──────────────────────────────────────────────────────────────────────────── */
214
+ export const MERGE_WINDOW_ADOPTED_MS = Date.parse("2026-09-14T16:43:54Z");
215
+ export const MERGE_WINDOW_CAP_MS = 30 * 60 * 1000;
216
+ export const RECORD_DOCS = ["docs/QUEUE.md", "docs/DONE.md", "docs/WORKSTREAMS.md"];
217
+ /** Windows as the bus records them: an OPEN line and, for the same PR, the next CLOSED line after it. */
218
+ export function windowsIn(logText) {
219
+ const opens = [];
220
+ const closes = [];
221
+ for (const l of logText.split("\n")) {
222
+ if (!l.trim())
223
+ continue;
224
+ let o;
225
+ try {
226
+ o = JSON.parse(l);
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ const t = String(o.text ?? "");
232
+ const om = /MERGE WINDOW: #(\d+)/.exec(t);
233
+ if (om)
234
+ opens.push({ pr: Number(om[1]), ts: Number(o.ts ?? 0), from: String(o.from ?? "") });
235
+ if (/MERGE WINDOW CLOSED/.test(t)) {
236
+ // 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.
237
+ const pm = /#(\d+)/.exec(t);
238
+ closes.push({ pr: pm ? Number(pm[1]) : null, ts: Number(o.ts ?? 0) });
239
+ }
240
+ }
241
+ opens.sort((a, b) => a.ts - b.ts);
242
+ return opens.map((w, i) => {
243
+ 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;
244
+ const nextOpen = opens[i + 1]?.ts ?? Infinity;
245
+ const end = close ?? Math.min(nextOpen, w.ts + MERGE_WINDOW_CAP_MS);
246
+ return { pr: w.pr, open: w.ts, close, end, unclosed: close === null, openedBy: w.from };
247
+ });
248
+ }
249
+ /** The PR a landing commit lands, from the forge's `(#N)` marker at the END of the subject; null for any other commit. */
250
+ export const mergeOf = (fullSubject) => { const m = /\(#(\d+)\)\s*$/.exec(fullSubject); return m ? Number(m[1]) : null; };
251
+ /**
252
+ * ⟨q-4e08b3c1⟩ follow-up, MEASURED LIVE 2026-09-14 19:38Z by this file's own
253
+ * live-population test: #325's SQUASH (3b8702e) touched docs/QUEUE.md — the PR
254
+ * migrated a queue receipt — and its author time is the merge time, 13s into
255
+ * MERGE WINDOW #325. The window's own landing commit is the MERGE the window
256
+ * exists to protect, not a seat's write into it; it is listed under the window
257
+ * with `landing: true` and never raised.
258
+ */
259
+ export const isLandingOf = (write, pr) => write.mergeOf !== null && write.mergeOf === pr;
260
+ /** Commits touching a record document on the shared branch, by AUTHOR time — when the seat wrote, not when it landed. */
261
+ export function recordWritesOn(repo, sinceMs) {
262
+ const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
263
+ let base = null;
264
+ for (const cand of ["origin/main", "main", "origin/master", "master"]) {
265
+ try {
266
+ git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
267
+ base = cand;
268
+ break;
269
+ }
270
+ catch { /* next */ }
271
+ }
272
+ if (!base)
273
+ return null;
274
+ try {
275
+ const out = git(["log", base, `--since=${new Date(sinceMs).toISOString()}`, "--format=%H|%aI|%an|%s", "--", ...RECORD_DOCS]);
276
+ return out.split("\n").filter(Boolean).map((l) => {
277
+ const [sha, aI, author, ...rest] = l.split("|");
278
+ const full = rest.join("|");
279
+ return { sha: sha ?? "", at: Date.parse(aI ?? ""), author: author ?? "", subject: full.slice(0, 120), mergeOf: mergeOf(full) };
280
+ });
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ }
286
+ export function mergeWindowChecks(input) {
287
+ const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
288
+ const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
289
+ const hits = [];
290
+ const unmeasurable = [];
291
+ const windows = windowsIn(input.logText).filter((w) => input.now - w.open <= horizon);
292
+ if (input.writes === null) {
293
+ 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" });
294
+ return { hits: [], windows: windows.map((w) => ({ ...w, writes: [] })), unmeasurable };
295
+ }
296
+ const out = windows.map((w) => {
297
+ 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) }));
298
+ for (const c of inside) {
299
+ if (c.beforeAdoption || c.landing)
300
+ continue;
301
+ const secs = Math.round((c.at - w.open) / 1000);
302
+ hits.push({
303
+ kind: "merge-window-write", pr: w.pr, sha: c.sha, author: c.author, authoredAt: new Date(c.at).toISOString(), secondsIntoWindow: secs,
304
+ 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.`,
305
+ });
306
+ }
307
+ return { ...w, writes: inside };
308
+ });
309
+ return { hits: withAudience(hits), windows: out, unmeasurable };
310
+ }
311
+ export function conventionChecks(input) {
312
+ const windowMs = input.windowMs ?? CONVENTION_WINDOW_MS;
313
+ const hits = [];
314
+ const unmeasurable = [];
315
+ let merges = null;
316
+ if (input.recentMerges === null || input.recentMerges === undefined) {
317
+ 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" });
318
+ }
319
+ else {
320
+ merges = [];
321
+ for (const m of input.recentMerges) {
322
+ const mergedMs = Date.parse(m.mergedAt);
323
+ const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
324
+ const inWindow = input.now - mergedMs <= windowMs;
325
+ const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
326
+ const verdictAtHead = verdictShasIn(m.comments).some((v) => shaAgrees(v.sha, m.headRefOid));
327
+ merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption });
328
+ if (inWindow && !beforeAdoption && !verdictAtHead) {
329
+ hits.push({
330
+ kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
331
+ 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.`,
332
+ });
333
+ }
334
+ }
335
+ }
336
+ const openIds = new Set();
337
+ const closedIds = new Set();
338
+ if (input.queueText !== null) {
339
+ for (const i of queueItemsOf(parseWorkDoc(input.queueText)))
340
+ (i.done ? closedIds : openIds).add(i.id);
341
+ }
342
+ const subjectOf = (r) => [...String(r.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((x) => x[1]);
343
+ const onBoard = new Set(input.boardRows.flatMap(subjectOf));
344
+ const routed = [];
345
+ const seen = new Set();
346
+ for (const r of routedItemsIn(input.routingLogText).sort((a, b) => b.ts - a.ts)) {
347
+ if (seen.has(r.itemId))
348
+ continue; // the latest GO for an item is the one that binds
349
+ seen.add(r.itemId);
350
+ const minutes = Math.max(0, Math.round((input.now - r.ts) / 60000));
351
+ const inWindow = input.now - r.ts <= windowMs;
352
+ const open = input.queueText === null ? null : openIds.has(r.itemId) ? true : closedIds.has(r.itemId) ? false : null;
353
+ const has = onBoard.has(r.itemId);
354
+ routed.push({ itemId: r.itemId, by: r.by, minutes, open, onBoard: has, inWindow });
355
+ if (inWindow && open === true && !has) {
356
+ hits.push({
357
+ kind: "routed-without-row", itemId: r.itemId, routedBy: r.by, routedAt: new Date(r.ts).toISOString(), minutes,
358
+ 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).`,
359
+ });
360
+ }
361
+ }
362
+ // ⟨q-fee7239f⟩ — closing lines: every MERGED done on the bus, judged by the
363
+ // grammar; those at or before the floor are LISTED with beforeAdoption.
364
+ const closings = closingsIn(input.routingLogText).map((c) => ({ ...c, inWindow: input.now - c.ts <= windowMs }));
365
+ for (const c of closings) {
366
+ if (!c.inWindow || c.beforeAdoption || !c.claimsDelete || c.form !== null)
367
+ continue;
368
+ const minutes = Math.max(0, Math.round((input.now - c.ts) / 60000));
369
+ hits.push({
370
+ kind: "unread-delete-claim", agentId: c.from, pr: c.pr, closedAt: new Date(c.ts).toISOString(), minutes,
371
+ 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.`,
372
+ });
373
+ }
374
+ return { hits: withAudience(hits), unmeasurable, merges, routed, closings };
375
+ }
376
+ /** Every room log on this bus, concatenated — a verdict for a PR counts from any room. */
377
+ export const readAllRoomLogs = () => {
378
+ const dir = path.join(ROOT, "rooms");
379
+ try {
380
+ return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
381
+ }
382
+ catch {
383
+ return "";
384
+ }
385
+ };
386
+ /** The seat a branch belongs to, by the prefix `claim` writes — identity is in the artefact, not the account. */
387
+ const seatOfBranch = (name) => (name.includes("/") ? name.split("/")[0] : "unknown");
388
+ const BASE_BRANCHES = new Set(["main", "master", "HEAD"]);
389
+ const memoFile = () => path.join(ROOT, "stall-stranded-memo.json");
390
+ const readMemo = () => {
391
+ try {
392
+ return JSON.parse(readFileSync(memoFile(), "utf8"));
393
+ }
394
+ catch {
395
+ return {};
396
+ }
397
+ };
398
+ const writeMemo = (m) => {
399
+ try {
400
+ mkdirSync(ROOT, { recursive: true });
401
+ writeFileSync(memoFile(), JSON.stringify(m));
402
+ }
403
+ catch { /* a lost memo costs time on the next run, never correctness */ }
404
+ };
405
+ export const STRANDED_BUDGET_MS = 15_000;
406
+ /**
407
+ * Did the WHOLE RANGE land as ONE commit? `git cherry` compares commit by
408
+ * commit, so a squash of several commits — one patch id for the range, none
409
+ * for the parts — reads `+` on every line and the branch looks unproposed
410
+ * forever. Measured live: 47 of 209 ahead-by-ancestry branches survived the
411
+ * cherry test, and every young one was a 2-commit branch merged that day.
412
+ *
413
+ * So index main's recent commits by the patch id of EACH commit — ONE process
414
+ * (`git log -p | git patch-id`), measured at 1.8s for 1000 commits — and ask,
415
+ * per branch, whether the patch id of `mergeBase..sha` as ONE diff is in it.
416
+ * A hit means main carries this branch's cumulative change as a single
417
+ * commit: a squash. Bounded to the last 1000 first-parent commits, so a
418
+ * branch that landed further back than that reads as not landed — the
419
+ * direction that over-reports, never the one that hides stranded work.
420
+ */
421
+ const SQUASH_INDEX_DEPTH = 1000;
422
+ /** `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. */
423
+ const patchIds = (repo, diff) => execFileSync("git", ["patch-id", "--stable"], { cwd: repo, encoding: "utf8", input: diff, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
424
+ function squashIndex(repo, base) {
425
+ const map = new Map();
426
+ try {
427
+ const log = execFileSync("git", ["log", "-p", "--first-parent", `--max-count=${SQUASH_INDEX_DEPTH}`, "--format=%H", base, "--"], {
428
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024,
429
+ });
430
+ const out = patchIds(repo, log);
431
+ for (const l of out.split("\n")) {
432
+ const [id, commit] = l.trim().split(/\s+/);
433
+ if (id && commit)
434
+ map.set(id, commit);
435
+ }
436
+ }
437
+ catch { /* an empty index reads every branch as not landed — over-reports */ }
438
+ return map;
439
+ }
440
+ function landedBySquash(repo, base, sha, index) {
441
+ if (index.size === 0)
442
+ return false;
443
+ try {
444
+ const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
445
+ const mb = git(["merge-base", base, sha]).trim();
446
+ if (!mb)
447
+ return false;
448
+ const id = patchIds(repo, git(["diff", mb, sha, "--"])).trim().split(/\s+/)[0] ?? "";
449
+ return id.length > 0 && index.has(id);
450
+ }
451
+ catch {
452
+ return false;
453
+ }
454
+ }
455
+ export function gateClaimsIn(logText, pr, head) {
456
+ const out = [];
457
+ for (const l of logText.split("\n")) {
458
+ if (!l.trim())
459
+ continue;
460
+ let o;
461
+ try {
462
+ o = JSON.parse(l);
463
+ }
464
+ catch {
465
+ continue;
466
+ }
467
+ const r = o.record;
468
+ if (!r || r.type === "verdict")
469
+ continue;
470
+ if (!(r.cites ?? []).some((c) => (String(c?.ref ?? "").match(/#(\d+)/) ?? [])[1] === pr))
471
+ continue;
472
+ for (const v of verdictShasIn([{ body: String(o.text ?? "") }])) {
473
+ if (shaAgrees(v.sha, head))
474
+ out.push({ from: String(o.from ?? ""), ts: Number(o.ts ?? 0), result: v.result, sha: v.sha, type: String(r.type ?? "") });
475
+ }
476
+ }
477
+ return out.sort((a, b) => a.ts - b.ts);
478
+ }
479
+ export function strandedWork(repo, artefacts, logText, now, limitMs,
480
+ /** Seats the bus knows. A branch hit needs a recipient; a branch nobody owns is reported, not DMed. */
481
+ registeredSeats = new Set(),
482
+ /** Uncached branch classification stops here; the rest is DEFERRED and named. */
483
+ budgetMs = STRANDED_BUDGET_MS) {
484
+ const hits = [];
485
+ const unmeasurable = [];
486
+ const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
487
+ let openPrs = null;
488
+ if (artefacts.openPrs === null) {
489
+ 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" });
490
+ }
491
+ else {
492
+ openPrs = [];
493
+ for (const p of artefacts.openPrs) {
494
+ const { verdicts } = verdictsFor(logText, String(p.n));
495
+ const verdictAtHead = verdicts.some((v) => shaAgrees(v.head, p.headRefOid));
496
+ const minutes = Math.max(0, Math.round((now - Date.parse(p.updatedAt)) / 60000));
497
+ const claimAtHead = verdictAtHead ? null : gateClaimsIn(logText, String(p.n), p.headRefOid)[0] ?? null;
498
+ openPrs.push({ pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes, verdictAtHead, claimAtHead });
499
+ // A verdict bound to an OLDER head does not count: the head moved, and a
500
+ // PASS on what used to be there is the #268 breach one step earlier.
501
+ // ⟨q-dcbaf544⟩ — an UNSCRIBED gate report fires at once; "nobody gated" waits the limit.
502
+ if (!verdictAtHead && (claimAtHead || now - Date.parse(p.updatedAt) > limitMs)) {
503
+ hits.push({
504
+ kind: "unverdicted-pr", agentId: seatOfBranch(p.headRefName), pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes,
505
+ ...(claimAtHead
506
+ ? { why: "reported-unscribed", claimedBy: claimAtHead.from, claimedAt: new Date(claimAtHead.ts).toISOString(), claimedResult: claimAtHead.result }
507
+ : { why: "nobody-gated" }),
508
+ });
509
+ }
510
+ }
511
+ }
512
+ let pushedBranches = null;
513
+ if (artefacts.remoteHeads === null) {
514
+ 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" });
515
+ }
516
+ else {
517
+ pushedBranches = [];
518
+ const prHeads = new Set((artefacts.openPrs ?? []).map((p) => p.headRefOid));
519
+ // RANK 1, docs/LANDEDNESS.md: the forge's own record. Measured live before it
520
+ // was asked: a 15-day-old branch whose PR #170 had merged with this very tip
521
+ // as its head read "not landed" — its squash sat beyond the patch index's
522
+ // window. A patch-id negative is inconclusive by construction; the forge's
523
+ // positive is not.
524
+ const forgeLanded = new Set((artefacts.mergedPrs ?? []).map((p) => p.headRefOid));
525
+ if (artefacts.mergedPrs === null || artefacts.mergedPrs === undefined) {
526
+ 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" });
527
+ }
528
+ const prBranches = new Set((artefacts.openPrs ?? []).map((p) => p.headRefName));
529
+ let base = null;
530
+ for (const cand of ["origin/main", "main", "origin/master", "master"]) {
531
+ try {
532
+ git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
533
+ base = cand;
534
+ break;
535
+ }
536
+ catch { /* next */ }
537
+ }
538
+ let squashes = null; // built once per run, only if a branch needs it
539
+ const memo = readMemo();
540
+ let baseSha = "";
541
+ try {
542
+ baseSha = base ? git(["rev-parse", base]) : "";
543
+ }
544
+ catch { /* handled below as no base */ }
545
+ const started = Date.now();
546
+ let deferred = 0;
547
+ // The budget is spent on the branches that can RAISE something first: a
548
+ // registered seat's branch is the only kind that becomes a hit, and on a
549
+ // 212-head origin the alphabet puts `docs/…` and `fix/…` ahead of every
550
+ // `groundwork-kit-worker-*/…`. Stable within each group.
551
+ const ordered = [...artefacts.remoteHeads].sort((x, y) => Number(registeredSeats.has(seatOfBranch(y.name))) - Number(registeredSeats.has(seatOfBranch(x.name))));
552
+ for (const h of ordered) {
553
+ if (BASE_BRANCHES.has(h.name))
554
+ continue;
555
+ const hasPr = prHeads.has(h.sha) || prBranches.has(h.name);
556
+ if (!base) {
557
+ unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: "no main/master to measure distance from" });
558
+ continue;
559
+ }
560
+ // ⛔ THE FORGE BEFORE THE MEMO — QA's row B (#307 FAIL @ 0f5f334): a branch
561
+ // memoised as not-landed against a base that has not moved stayed a hit
562
+ // AFTER the forge said its PR had merged, because the memo answered first.
563
+ // Live shape: PR merged, local origin/main not yet fetched, owner DMed that
564
+ // merged work is stranded — the instrument mirroring the aide's clearance
565
+ // error. The forge is rank 1 and a set lookup; it is asked first, always,
566
+ // and its answer overwrites the memo.
567
+ const cached = memo[h.sha];
568
+ let ahead;
569
+ let landed;
570
+ let landedBy;
571
+ let committedAt;
572
+ if (forgeLanded.has(h.sha) && cached && !cached.landed) {
573
+ ({ ahead, committedAt } = cached);
574
+ landed = true;
575
+ landedBy = "forge";
576
+ memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
577
+ }
578
+ else if (cached && (cached.landed || cached.base === baseSha)) {
579
+ ({ ahead, landed, landedBy, committedAt } = cached);
580
+ }
581
+ else {
582
+ if (Date.now() - started > budgetMs) {
583
+ deferred++;
584
+ continue;
585
+ }
586
+ try {
587
+ git(["cat-file", "-e", `${h.sha}^{commit}`]);
588
+ }
589
+ catch {
590
+ 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` });
591
+ continue;
592
+ }
593
+ ahead = Number(git(["rev-list", "--count", `${base}..${h.sha}`]) || 0);
594
+ committedAt = git(["log", "-1", "--format=%cI", h.sha, "--"]);
595
+ // ⛔⛆ ANCESTRY CANNOT SAY "LANDED" ON A SQUASH-MERGING FLEET. Measured live
596
+ // before this line existed: 205 hits, and every young one was a branch
597
+ // whose PR had merged that afternoon — `rev-list --count main..sha` reads
598
+ // a squash-landed branch as ahead forever. `git cherry` compares PATCH
599
+ // IDS, the same instrument `classifyBoardRef` and `refresh_worktrees` use:
600
+ // every line `-` means every patch is upstream. Same caveat as theirs: a
601
+ // squash of SEVERAL commits into one changes the combined patch id, so a
602
+ // multi-commit landed branch can still read as unproposed — the direction
603
+ // that over-reports, never the one that hides stranded work.
604
+ landed = false;
605
+ landedBy = null;
606
+ if (ahead > 0) {
607
+ if (forgeLanded.has(h.sha)) {
608
+ landed = true;
609
+ landedBy = "forge";
610
+ }
611
+ if (!landed) {
612
+ try {
613
+ const cherry = git(["cherry", base, h.sha]);
614
+ landed = cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
615
+ if (landed)
616
+ landedBy = "patch";
617
+ }
618
+ catch { /* unreadable: inconclusive, which over-reports */ }
619
+ }
620
+ if (!landed) {
621
+ if (!squashes)
622
+ squashes = squashIndex(repo, base);
623
+ landed = landedBySquash(repo, base, h.sha, squashes);
624
+ if (landed)
625
+ landedBy = "squash";
626
+ }
627
+ }
628
+ memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
629
+ }
630
+ const minutes = Math.max(0, Math.round((now - Date.parse(committedAt)) / 60000));
631
+ pushedBranches.push({ branch: h.name, head: h.sha, ahead, landed, landedBy, minutes, hasPr });
632
+ // ⚠ A BRANCH HIT IS RAISED ONLY FOR A SEAT THE BUS KNOWS. Measured live
633
+ // after the two landedness tests: the survivors were `docs/…`, `fix/…`
634
+ // branches from before the seat-prefix convention — abandoned work with no
635
+ // owner to DM. 40+ hits every run is how a signal gets switched off. They
636
+ // stay VISIBLE in `pushedBranches` (ahead, not landed, no PR); the DM goes
637
+ // only where there is a lane to answer it. The PR half is NOT filtered:
638
+ // an open PR is somebody's by construction, and it is the acceptance.
639
+ const seat = seatOfBranch(h.name);
640
+ if (ahead > 0 && !landed && !hasPr && registeredSeats.has(seat) && now - Date.parse(committedAt) > limitMs) {
641
+ hits.push({
642
+ kind: "unproposed-branch", agentId: seat, branch: h.name, head: h.sha, ahead, minutes,
643
+ 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).",
644
+ });
645
+ }
646
+ }
647
+ // Forget shas origin no longer has, so the memo cannot grow without bound.
648
+ const live = new Set(artefacts.remoteHeads.map((h) => h.sha));
649
+ for (const k of Object.keys(memo))
650
+ if (!live.has(k))
651
+ delete memo[k];
652
+ writeMemo(memo);
653
+ if (deferred > 0) {
654
+ 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` });
655
+ }
656
+ }
657
+ return { hits: withAudience(hits), unmeasurable, openPrs, pushedBranches };
658
+ }
31
659
  // ---------- halt ----------
32
660
  export const setHaltSchema = {
33
661
  reason: z.string().min(1),
@@ -84,6 +712,35 @@ export function markRunFailure(reason) {
84
712
  history.push({ at: Date.now(), hits: 0, checked: 0, failed: reason });
85
713
  writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
86
714
  }
715
+ export function lastPopulationFor(repo) {
716
+ let history = [];
717
+ try {
718
+ history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
719
+ }
720
+ catch {
721
+ return null;
722
+ }
723
+ for (let i = history.length - 1; i >= 0; i--) {
724
+ const h = history[i];
725
+ if (!h.failed && h.population && h.population.repo === repo)
726
+ return { at: h.at, population: h.population };
727
+ }
728
+ return null;
729
+ }
730
+ /** `docs/WORKSTREAMS.md@<sha>`, with `+uncommitted` when the working copy differs from that commit. */
731
+ export function boardSourceOf(repo) {
732
+ let sha = "unknown";
733
+ try {
734
+ sha = gitOut(repo, ["log", "-1", "--format=%h", "--", BOARD_DOC]) || "uncommitted";
735
+ }
736
+ catch { /* no history */ }
737
+ let dirty = false;
738
+ try {
739
+ dirty = gitOut(repo, ["status", "--porcelain", "--", BOARD_DOC]).length > 0;
740
+ }
741
+ catch { /* unknown */ }
742
+ return `${BOARD_DOC}@${sha}${dirty ? "+uncommitted" : ""}`;
743
+ }
87
744
  function markRun(result) {
88
745
  mkdirSync(ROOT, { recursive: true });
89
746
  let history = [];
@@ -98,7 +755,7 @@ function markRun(result) {
98
755
  // reported `runs 8, failures 0` — all green — while every one of those runs
99
756
  // had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
100
757
  // instrument meant to cover an absence.
101
- history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
758
+ history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0, ...(result.population ? { population: result.population } : {}) });
102
759
  // A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
103
760
  // list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
104
761
  // are different states and only the first is a healthy fleet.
@@ -187,7 +844,106 @@ export async function stallClockStatusTool(args) {
187
844
  };
188
845
  }
189
846
  // ---------- the predicate ----------
190
- export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
847
+ export const stallCheckSchema = {
848
+ repo: z.string().optional(),
849
+ stallMinutes: z.number().optional(),
850
+ /** The 🔍 window, in minutes. Default REVIEW_STALL_MINUTES. Independent of stallMinutes on purpose. */
851
+ reviewMinutes: z.number().optional(),
852
+ /** ⟨q-7b2f6c04⟩ — the claim-axis window, in minutes. Default CLAIM_STALL_MINUTES. */
853
+ claimMinutes: z.number().optional(),
854
+ };
855
+ const gitOut = (repo, args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
856
+ /** The key a row keeps while its cells are edited: its stream text. */
857
+ export const rowKey = (r) => r.stream.replace(/\s+/g, " ").trim();
858
+ /**
859
+ * ⛔⛆ AN `🔍 In Review` ROW IS NOT SCORED ON BRANCH ACTIVITY AT ALL (⟨q-507e80c4⟩).
860
+ *
861
+ * Measured 2026-09-14: `no-vcs-activity` fired on a review lane at 41m, again at
862
+ * 45m, and would have fired on every correctly-behaving review lane given an
863
+ * hour — a branch under review is SUPPOSED to stop moving, so that predicate
864
+ * reaches its guaranteed end state on every healthy lane. The stall question in
865
+ * review is a DIFFERENT one: how long has the row been in review.
866
+ *
867
+ * The board carries no timestamp column, but it is a git-tracked document, so
868
+ * the answer is in its history: walk the commits that touched the board from
869
+ * newest to oldest while this row (by stream key) still declares in-review; the
870
+ * oldest such commit is when the row ENTERED review. A row that declares review
871
+ * only in the working tree entered it just now (`since: "uncommitted"`).
872
+ *
873
+ * Returns `null` only when the board's history cannot be read — which is
874
+ * reported as unmeasurable, never as healthy.
875
+ */
876
+ export function reviewEnteredAt(repo, key, now = Date.now()) {
877
+ return stateEnteredAt(repo, key, now, (row) => workStateOf(row.status) === "in-review");
878
+ }
879
+ /**
880
+ * ⟨q-7b2f6c04⟩ — when the row was CLAIMED: the oldest board commit in which it
881
+ * declares an in-flight state. The claim axis reads the board's history the
882
+ * way the review axis does, so a lane is observable from its claim, not from
883
+ * its first push.
884
+ */
885
+ export function claimEnteredAt(repo, key, now = Date.now()) {
886
+ return stateEnteredAt(repo, key, now, (row) => coarseOf(workStateOf(row.status)) === "in-flight");
887
+ }
888
+ function stateEnteredAt(repo, key, now, matches) {
889
+ let log;
890
+ try {
891
+ log = gitOut(repo, ["log", "-n", "300", "--format=%H%x09%cI", "--", BOARD_DOC]);
892
+ }
893
+ catch {
894
+ return null;
895
+ }
896
+ let since = null;
897
+ let commits = 0;
898
+ for (const line of log.split("\n").filter(Boolean)) {
899
+ const [sha, iso] = line.split("\t");
900
+ let text;
901
+ try {
902
+ text = gitOut(repo, ["show", `${sha}:${BOARD_DOC}`]);
903
+ }
904
+ catch {
905
+ break;
906
+ }
907
+ const row = workstreamsV1RowsOf(parseWorkDoc(text)).find((r) => rowKey(r) === key);
908
+ if (!row || !matches(row))
909
+ break;
910
+ since = iso;
911
+ commits++;
912
+ }
913
+ if (!since)
914
+ return { since: "uncommitted", minutes: 0, commits: 0 };
915
+ return { since, minutes: Math.round((now - Date.parse(since)) / 60000), commits };
916
+ }
917
+ /**
918
+ * ⛆ THE BOARD'S OWN FRESHNESS, PER ROW — the acceptance clause the original row
919
+ * lacked. A predicate reading stale cells is not measuring lanes, it is
920
+ * measuring the board's maintenance; on 2026-09-14 one silent seat froze the
921
+ * board and the clock then fired on three HEALTHY lanes, and nothing in its
922
+ * output could tell the two apart. So every scored row now says when its line
923
+ * was last written, read from `git blame` on the row's own bytes.
924
+ */
925
+ export function rowFreshness(repo, boardText, rows, now = Date.now()) {
926
+ const lines = boardText.split("\n");
927
+ return rows.map((r) => {
928
+ const base = { stream: r.stream.slice(0, 60), owner: r.owner.replace(/[`*]/g, "").trim(), state: workStateOf(r.status) };
929
+ const idx = r.raw ? lines.indexOf(r.raw) : -1;
930
+ if (idx < 0)
931
+ return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
932
+ try {
933
+ const out = gitOut(repo, ["blame", "-L", `${idx + 1},${idx + 1}`, "--porcelain", "--", BOARD_DOC]);
934
+ const sha = out.split(/\s/)[0] ?? "";
935
+ if (/^0+$/.test(sha))
936
+ return { ...base, lastUpdated: "uncommitted", minutesAgo: 0, commit: null };
937
+ const t = Number(/^committer-time (\d+)/m.exec(out)?.[1]);
938
+ if (!Number.isFinite(t))
939
+ return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: sha.slice(0, 8) };
940
+ return { ...base, lastUpdated: new Date(t * 1000).toISOString(), minutesAgo: Math.round((now - t * 1000) / 60000), commit: sha.slice(0, 8) };
941
+ }
942
+ catch {
943
+ return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
944
+ }
945
+ });
946
+ }
191
947
  /**
192
948
  * A row somebody is WORKING — the population the stall clock watches.
193
949
  *
@@ -206,27 +962,150 @@ export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.n
206
962
  *
207
963
  * NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
208
964
  * `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
209
- * would be the 0/0 defect inverted.
965
+ * would be the 0/0 defect inverted. They are REPORTED, though — see
966
+ * `notWatched` below — because a row the clock declines to watch and a row
967
+ * the clock cannot see produce the same silence.
210
968
  *
211
969
  * One predicate, exported: `coord_away` measures coverage by calling
212
970
  * `stall_check` and reading `checked`/`measurable`, so it inherits this
213
971
  * population without a change of its own. Anything else that asks "which rows
214
972
  * are in flight" should ask here rather than re-derive it from a glyph.
973
+ *
974
+ * ⛔⛆ DERIVED FROM THE SEAM, NOT MATCHED ON A GLYPH (⟨q-a42503cb⟩). This was
975
+ * `IN_FLIGHT_STATUS = /🚧|🔍/`, and the sentence above it — "ask here rather
976
+ * than re-derive it from a glyph" — described a single authority that WAS a
977
+ * glyph regex. The board's vocabulary grew to eleven documented states
978
+ * (`workStateOf`, seam ⟨q-5b3e9a04⟩) and this predicate stayed at two, so
979
+ * every `⏸` row was invisible here AND in `next_unblocked`'s routing
980
+ * exclusion, which shares it: a seat that wrote `⏸ MERGE-HELD` instead of a
981
+ * stale `🚧` was doing the right thing and punished for it by a silent drop
982
+ * from both populations.
983
+ *
984
+ * The seam reads the LEADING glyph after stripping decoration, so
985
+ * `⛔ Blocked — was 🚧 yesterday` is blocked here and was in-flight under the
986
+ * regex. Measured on the live board at origin/main 687a74f (25 rows): the two
987
+ * predicates agree on every row, 4 in flight under each — the swap changes no
988
+ * verdict today; it changes what the file can SAY about the other 21.
215
989
  */
216
- export const IN_FLIGHT_STATUS = /🚧|🔍/;
217
- export const isInFlightStatus = (status) => IN_FLIGHT_STATUS.test(status);
218
- export async function stallCheckTool(args) {
990
+ export const isInFlightStatus = (status) => coarseOf(workStateOf(status)) === "in-flight";
991
+ /** A hold the board declares for this lane, or null. Blocker cell first (the lane's own statement), then the Cutover Gates table. */
992
+ export function holdOf(row, cutoverGates) {
993
+ const blocker = String(row.blocker ?? "").replace(/[`*]/g, "").trim();
994
+ if (blocker && !/^(—|-|–|none|n\/a)$/i.test(blocker))
995
+ return { text: blocker.slice(0, 160), by: "blocker" };
996
+ const ids = [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
997
+ const prs = [...String(row.stream).matchAll(/#(\d{2,})\b/g)].map((m) => `#${m[1]}`);
998
+ const ref = refInCell(row.branchWorktree);
999
+ for (const g of cutoverGates) {
1000
+ const gate = String(g["Gate"] ?? Object.values(g)[0] ?? "");
1001
+ if (ids.some((id) => gate.includes(id)) || prs.some((p) => gate.includes(p)) || (ref && gate.includes(ref))) {
1002
+ return { text: gate.replace(/[`*]/g, "").trim().slice(0, 160), by: "cutover-gate" };
1003
+ }
1004
+ }
1005
+ return null;
1006
+ }
1007
+ /**
1008
+ * ⟨q-7e3b10c9⟩ — THE BOARD NAMES ITS OWNER. The Rooms table's owner cell says
1009
+ * who holds the topic — this fleet's `groundwork-kit-coordinator (topic owner)`
1010
+ * — so the clock can route bookkeeping hits without a bus-meta dependency.
1011
+ * Null when the board does not say; the clock then says so and falls back.
1012
+ */
1013
+ export function boardOwnerOf(boardText) {
1014
+ const ext = workstreamsExtensionsOf(parseWorkDoc(boardText));
1015
+ for (const r of ext.rooms) {
1016
+ for (const v of Object.values(r)) {
1017
+ const m = /([\w.-]+-coordinator)\b/.exec(String(v));
1018
+ if (m)
1019
+ return m[1];
1020
+ }
1021
+ }
1022
+ return null;
1023
+ }
1024
+ export async function stallCheckTool(args, artefacts = realArtefacts, readRooms = readAllRoomLogs, readInboxes = readAllInboxLogs) {
219
1025
  const repo = args.repo ?? process.cwd();
220
1026
  const limit = (args.stallMinutes ?? 30) * 60 * 1000;
1027
+ const reviewLimit = args.reviewMinutes ?? REVIEW_STALL_MINUTES;
1028
+ const claimLimit = args.claimMinutes ?? CLAIM_STALL_MINUTES;
221
1029
  const board = path.join(repo, BOARD_DOC);
222
1030
  if (!existsSync(board))
223
1031
  return { ok: false, error: `no ${BOARD_DOC} under '${repo}'` };
224
1032
  const liveTransports = await loadLiveTransports();
225
- const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
226
- const inFlight = rows.filter((r) => isInFlightStatus(r.status));
1033
+ const boardText = readFileSync(board, "utf8");
1034
+ const boardDoc = parseWorkDoc(boardText);
1035
+ const rows = workstreamsV1RowsOf(boardDoc);
1036
+ // ⟨q-7e3b10c9⟩ — holds DECLARED on the board: the Cutover Gates table's Gate
1037
+ // cell names what is held; a lane row's Blocker cell names what it waits on.
1038
+ const cutoverGates = workstreamsExtensionsOf(boardDoc).cutoverGates;
1039
+ // ⟨q-3d82f1a9⟩ — SAY THE PARSE STATE FIRST. An unreadable board is refused
1040
+ // as a measurement, not answered as a quiet fleet: ok:false, dm:true, and
1041
+ // the counts named, so the duty officer is told the board broke rather than
1042
+ // shown an empty list that reads as healthy.
1043
+ const boardParse = boardParseOf(boardText);
1044
+ if (!boardParse.readable) {
1045
+ markRunFailure(`unparseable board — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed`);
1046
+ return {
1047
+ ok: false,
1048
+ error: `${BOARD_DOC} is ${boardParse.why}`,
1049
+ boardParse,
1050
+ checked: 0,
1051
+ measurable: 0,
1052
+ blind: [],
1053
+ hits: [],
1054
+ unmeasurable: [],
1055
+ halted: haltState().halted,
1056
+ dm: true,
1057
+ 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.`,
1058
+ };
1059
+ }
1060
+ // ⟨q-5d1c8e04⟩ — LANES ARE SCORED; ROLES ARE REPORTED. A role row (🪑, branch
1061
+ // `—`) is a standing seat with nothing to stall; it is listed as present or
1062
+ // absent from the registry and live or not on its transport, and never
1063
+ // enters `checked`. Two roles and zero lanes read 0 of 0, not 0 of 2. A 🪑
1064
+ // row that carries a ref is a lane in disguise and is scored below.
227
1065
  const reg = await readJson(AGENTS_FILE, {});
1066
+ const kinds = rows.map((r) => ({ row: r, kind: rowKindOf(r) }));
1067
+ const roles = kinds
1068
+ .filter((k) => k.kind === "role")
1069
+ .map(({ row }) => {
1070
+ const owner = row.owner.replace(/[`*]/g, "").trim();
1071
+ return { stream: row.stream.slice(0, 60), owner, present: !!reg[owner], active: liveTransports.has(owner) };
1072
+ });
1073
+ const disguised = kinds.filter((k) => k.kind === "role-with-ref").map((k) => k.row);
1074
+ const inFlight = [...rows.filter((r) => isInFlightStatus(r.status)), ...disguised];
1075
+ // THE ROWS THIS CLOCK DOES NOT WATCH, NAMED AS THEIR OWN STATE (⟨q-a42503cb⟩).
1076
+ // A parked row is not a stalled one, so it is not in `checked` — but a
1077
+ // clock that cannot name what it declined to watch reads the same as one that
1078
+ // never saw it. Finished rows are left out: there is nothing to watch or
1079
+ // route behind a `✅`. Everything else that is not in flight is listed with
1080
+ // the state the seam read, so a reader can tell "held pending a merge" from
1081
+ // "blocked" from "parked awaiting a human" without opening the board.
1082
+ const byState = {};
1083
+ for (const r of rows) {
1084
+ const s = workStateOf(r.status);
1085
+ byState[s] = (byState[s] ?? 0) + 1;
1086
+ }
1087
+ const notWatched = rows
1088
+ .filter((r) => {
1089
+ const c = coarseOf(workStateOf(r.status));
1090
+ return c !== "in-flight" && c !== "finished";
1091
+ })
1092
+ .map((r) => ({
1093
+ stream: r.stream.slice(0, 60),
1094
+ owner: r.owner.replace(/[`*]/g, "").trim(),
1095
+ state: workStateOf(r.status),
1096
+ status: r.status.slice(0, 80),
1097
+ }));
228
1098
  const now = Date.now();
229
1099
  const hits = [];
1100
+ /** The base's tip, for telling an empty claim-time push from a lane with commits. */
1101
+ let baseTip = null;
1102
+ for (const cand of ["origin/main", "origin/master", "main", "master"]) {
1103
+ try {
1104
+ baseTip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
1105
+ break;
1106
+ }
1107
+ catch { /* next */ }
1108
+ }
230
1109
  /** Rows whose VCS activity could not be measured. NOT stall claims. */
231
1110
  const unmeasurable = [];
232
1111
  /**
@@ -239,8 +1118,60 @@ export async function stallCheckTool(args) {
239
1118
  * skip and a clean pass produce the same number.
240
1119
  */
241
1120
  const measured = new Set();
1121
+ /**
1122
+ * ⟨q-5d1c8e04⟩ — DELIBERATELY BRANCHLESS LANES, out of the population. The
1123
+ * cell holds a statement (`docs-direct · no per-agent branch`) rather than a
1124
+ * ref; the seat said so in words. Reported by name, subtracted from
1125
+ * `checked`, never "unmeasurable" — the coverage ratio stops carrying rows
1126
+ * that were never measurable by design. A blank or `—` on a lane is NOT
1127
+ * this: that lane should carry a branch and names none, and stays unmeasurable.
1128
+ */
1129
+ const deliberate = [];
1130
+ /**
1131
+ * ⟨q-7e3b10c9⟩ — HELD, NOT STALLED AND NOT UNATTENDED. A lane ageing behind a
1132
+ * hold the board DECLARES — its Blocker cell names a hold, or a Cutover Gates
1133
+ * row names the lane — is reported as held, with the hold, and is not scored:
1134
+ * a frozen branch behind a PASS-HOLD is the gate working, not a seat stuck.
1135
+ * Measured as a verdict (coverage counts it). An UNDECLARED wait is not a
1136
+ * hold: the same frozen branch with an empty Blocker cell still fires.
1137
+ */
1138
+ const held = [];
1139
+ const scored = [];
1140
+ const axes = [];
1141
+ const onAxis = (agentId, row, axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
1142
+ const offAxis = (agentId, row, why) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
1143
+ /** The claim axis: scored from the board's history; fires past the claim window. */
1144
+ const scoreClaim = (agentId, row, branch, because) => {
1145
+ const age = claimEnteredAt(repo, rowKey(row), now);
1146
+ if (!age) {
1147
+ 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" });
1148
+ offAxis(agentId, row, "board history unreadable");
1149
+ return;
1150
+ }
1151
+ onAxis(agentId, row, "claim");
1152
+ if (age.minutes > claimLimit) {
1153
+ hits.push({
1154
+ kind: "unpushed-claim", agentId, stream: row.stream.slice(0, 60), branch, minutes: age.minutes, since: age.since,
1155
+ 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.`,
1156
+ });
1157
+ }
1158
+ };
242
1159
  for (const row of inFlight) {
243
1160
  const agentId = row.owner.replace(/[`*]/g, "").trim();
1161
+ // Only a row the VCS half would score can declare itself branchless. A 🔍
1162
+ // row is scored on time-in-review from the board (#306), whatever its cell
1163
+ // says — so its prose is a note, not an exemption.
1164
+ if (cellKindOf(row.branchWorktree) === "prose" && workStateOf(row.status) !== "in-review") {
1165
+ deliberate.push({ agentId, stream: row.stream.slice(0, 60), cell: String(row.branchWorktree).trim().slice(0, 80) });
1166
+ continue;
1167
+ }
1168
+ scored.push(row);
1169
+ const hold = holdOf(row, cutoverGates);
1170
+ if (hold) {
1171
+ held.push({ agentId, stream: row.stream.slice(0, 60), hold: hold.text, declaredBy: hold.by });
1172
+ measured.add(agentId);
1173
+ continue;
1174
+ }
244
1175
  const entry = reg[agentId];
245
1176
  if (!entry) {
246
1177
  // NOT SKIPPED IN SILENCE. An owner with no registry entry has no
@@ -359,17 +1290,59 @@ export async function stallCheckTool(args) {
359
1290
  // ref. Resolution was never the property; the property is whether the ref's
360
1291
  // movement is THIS AGENT'S WORK.
361
1292
  const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
1293
+ // ⟨q-7b2f6c04⟩ — a ref sitting EXACTLY on the base's tip is the empty push
1294
+ // `ensure_worktree` makes at claim: zero commits of its own, so the
1295
+ // classifier reads it as landed and the VCS axis would read the base's
1296
+ // age. It is a CLAIM, and the claim axis scores it — checked before either.
1297
+ if ((verdict.kind === "merged" || verdict.kind === "measurable") && baseTip) {
1298
+ const cellRef = refInCell(row.branchWorktree).replace(/^origin\//, "");
1299
+ let tip = null;
1300
+ try {
1301
+ tip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `origin/${cellRef}^{commit}`]);
1302
+ }
1303
+ catch { /* not on origin */ }
1304
+ if (tip && tip === baseTip) {
1305
+ scoreClaim(agentId, row, `origin/${cellRef}`, "its branch sits on the base with no commits of its own (the empty claim-time push)");
1306
+ continue;
1307
+ }
1308
+ }
362
1309
  if (verdict.kind === "merged") {
363
1310
  // A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
364
1311
  // did learn something about this lane: its work landed and its row is
365
1312
  // stale. Reported as its own kind rather than as a stall, which is what
366
1313
  // the frozen ref was reporting it as.
367
1314
  hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
368
- measured.add(agentId);
1315
+ onAxis(agentId, row, "landed");
1316
+ continue;
1317
+ }
1318
+ if (workStateOf(row.status) === "in-review") {
1319
+ // ⛔ NOT THE VCS PREDICATE. A review-frozen branch is frozen for the
1320
+ // correct reason; scoring it on activity fires on every healthy review
1321
+ // lane eventually. The question here is how long the row has been in
1322
+ // review, read from the board's own history — which also means a 🔍 row
1323
+ // whose cell holds a path is MEASURED here rather than unmeasurable: the
1324
+ // predicate needs the board, not the ref.
1325
+ const age = reviewEnteredAt(repo, rowKey(row), now);
1326
+ if (!age) {
1327
+ 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" });
1328
+ offAxis(agentId, row, "board history unreadable");
1329
+ continue;
1330
+ }
1331
+ onAxis(agentId, row, "review");
1332
+ if (age.minutes > reviewLimit) {
1333
+ hits.push({ kind: "in-review-too-long", agentId, stream: row.stream.slice(0, 60), minutes: age.minutes, since: age.since });
1334
+ }
1335
+ continue;
1336
+ }
1337
+ // ⟨q-7b2f6c04⟩ — a branch with nothing on origin is not unmeasurable: it is
1338
+ // a lane between claim and first push, and the CLAIM axis measures it.
1339
+ if (verdict.kind === "unpushed" || verdict.kind === "local-only") {
1340
+ scoreClaim(agentId, row, refInCell(row.branchWorktree), `its branch is not on origin (${verdict.kind})`);
369
1341
  continue;
370
1342
  }
371
1343
  if (verdict.kind !== "measurable") {
372
1344
  unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
1345
+ offAxis(agentId, row, verdict.why);
373
1346
  continue;
374
1347
  }
375
1348
  try {
@@ -382,24 +1355,128 @@ export async function stallCheckTool(args) {
382
1355
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
383
1356
  }).trim();
384
1357
  const age = now - Date.parse(iso);
385
- measured.add(agentId);
1358
+ onAxis(agentId, row, "vcs");
386
1359
  if (age > limit) {
387
1360
  hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
388
1361
  }
389
1362
  }
390
1363
  catch {
391
1364
  unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
1365
+ offAxis(agentId, row, "resolved as a ref but its log could not be read");
392
1366
  }
393
1367
  }
394
1368
  // COVERAGE, not "did it run". A row is COVERED when a predicate produced a
395
1369
  // verdict about it; a row where every predicate came back unmeasurable was
396
1370
  // looked at and not measured, and counting it as checked is how "the clock
397
1371
  // ran" gets mistaken for "the fleet is observed".
398
- const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
1372
+ const owners = scored.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
399
1373
  const blind = [...new Set(owners)].filter((id) => !measured.has(id));
400
- const measurable = Math.max(0, inFlight.length - blind.length);
401
- const result = { hits, checked: inFlight.length, unmeasurable, measurable, blind };
402
- markRun({ hits, checked: inFlight.length, measurable });
1374
+ const measurable = Math.max(0, scored.length - blind.length);
1375
+ // ⟨q-d0e83b41⟩ (09-14 amend) the population's source and its delta since
1376
+ // the last run of THIS repo. A lane that left with no closing state on the
1377
+ // board (its row is gone) is a board-owner hit, never an improved ratio.
1378
+ const runPopulation = {
1379
+ repo, source: boardSourceOf(repo),
1380
+ scored: scored.map(rowKey), roles: roles.map((r) => r.stream), deliberate: deliberate.map((d) => d.stream), held: held.map((h) => h.stream),
1381
+ };
1382
+ const prev = lastPopulationFor(repo);
1383
+ const ownerOf = (key) => (rows.find((r) => rowKey(r) === key)?.owner ?? "").replace(/[`*]/g, "").trim();
1384
+ const whereNow = (key) => {
1385
+ const row = rows.find((r) => rowKey(r) === key);
1386
+ if (!row)
1387
+ return "gone";
1388
+ if (runPopulation.held.includes(row.stream.slice(0, 60)))
1389
+ return "held";
1390
+ if (runPopulation.deliberate.includes(row.stream.slice(0, 60)))
1391
+ return "deliberate";
1392
+ return workStateOf(row.status);
1393
+ };
1394
+ const delta = prev
1395
+ ? {
1396
+ since: new Date(prev.at).toISOString(),
1397
+ left: prev.population.scored.filter((k) => !runPopulation.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), to: whereNow(k) })),
1398
+ joined: runPopulation.scored.filter((k) => !prev.population.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), owner: ownerOf(k) })),
1399
+ }
1400
+ : { since: null, left: [], joined: [] };
1401
+ for (const l of delta.left) {
1402
+ if (l.to !== "gone")
1403
+ continue;
1404
+ hits.push({
1405
+ kind: "lane-left-population", agentId: boardOwnerOf(boardText) ?? "-", stream: l.stream,
1406
+ 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.`,
1407
+ });
1408
+ }
1409
+ // THE BOARD'S OWN FRESHNESS BESIDE EVERY VERDICT. A stall report over stale
1410
+ // cells is a report about the board's maintenance, and the reader must be
1411
+ // able to see which it is without opening git.
1412
+ const freshness = rowFreshness(repo, boardText, scored, now);
1413
+ const aged = freshness.map((f) => f.minutesAgo).filter((m) => typeof m === "number");
1414
+ // ⟨q-6f0a3d81⟩ — THE AXIS THAT NEEDS NOBODY TO SPEAK. Computed over the
1415
+ // repository, not the board: a stranded PR has no row, which is the point.
1416
+ // `checked`/`measurable` stay the lane population `coord_away` reads; these
1417
+ // hits join `hits` so the duty officer is DMed. Its unreadable SOURCES are
1418
+ // reported under its own key, not in the per-row `unmeasurable` list: that
1419
+ // list is a population of lane rows (a test pins "two halves of one row"),
1420
+ // and an unreachable gh is not a row. Separate populations, separate keys.
1421
+ const arte = artefacts(repo);
1422
+ const roomText = readRooms();
1423
+ const stranded = strandedWork(repo, arte, roomText, now, limit, new Set(Object.keys(reg)));
1424
+ hits.push(...stranded.hits);
1425
+ // ⟨q-8f1e604b⟩ — the two conventions, checked from the artefacts: PR pages
1426
+ // for (e), the bus's own routing records against the board and queue for (f).
1427
+ const queuePath = path.join(repo, "docs/QUEUE.md");
1428
+ const conventions = conventionChecks({
1429
+ recentMerges: arte.recentMerges,
1430
+ routingLogText: `${roomText}\n${readInboxes()}`,
1431
+ queueText: existsSync(queuePath) ? readFileSync(queuePath, "utf8") : null,
1432
+ boardRows: rows,
1433
+ now,
1434
+ });
1435
+ hits.push(...conventions.hits);
1436
+ // ⟨q-4e08b3c1⟩ — convention (g): the bus's windows joined to git's write times.
1437
+ const mergeWindows = mergeWindowChecks({ logText: roomText, writes: recordWritesOn(repo, now - CONVENTION_WINDOW_MS), now });
1438
+ hits.push(...mergeWindows.hits);
1439
+ const stamped = withAudience(hits);
1440
+ const dmBy = { duty: stamped.filter((h) => h.audience === "duty").length, "board-owner": stamped.filter((h) => h.audience === "board-owner").length };
1441
+ const result = {
1442
+ hits: stamped,
1443
+ // ⟨q-7e3b10c9⟩ — who each hit is for, counted, so a relayer can split.
1444
+ dmBy,
1445
+ held,
1446
+ // ⟨q-3d82f1a9⟩ — the parse state travels with every answer, readable or not.
1447
+ boardParse,
1448
+ // The population the clock SCORES: lanes with a ref position to read. Roles
1449
+ // and deliberately branchless lanes are reported beside it, not inside it.
1450
+ checked: scored.length,
1451
+ unmeasurable,
1452
+ measurable,
1453
+ blind,
1454
+ roles,
1455
+ deliberate,
1456
+ disguisedRoles: disguised.map((r) => r.stream.slice(0, 60)),
1457
+ predicates: {
1458
+ "in-progress": `heartbeat (where a source exists) + branch activity, window ${Math.round(limit / 60000)}m`,
1459
+ "in-review": `time in review from the board's git history, window ${reviewLimit}m — branch activity is NOT scored`,
1460
+ 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`,
1461
+ },
1462
+ boardFreshness: {
1463
+ rows: freshness,
1464
+ stalestMinutes: aged.length ? Math.max(...aged) : null,
1465
+ uncommitted: freshness.filter((f) => f.lastUpdated === "uncommitted").length,
1466
+ note: "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.",
1467
+ },
1468
+ // Population beside every count: `checked` is the in-flight rows; this is
1469
+ // the rest of the board by the state the seam read, and the not-in-flight,
1470
+ // not-finished rows by name.
1471
+ population: { rows: rows.length, inFlight: inFlight.length, byState, source: runPopulation.source, delta },
1472
+ notWatched,
1473
+ // ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
1474
+ axes,
1475
+ stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
1476
+ conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
1477
+ mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
1478
+ };
1479
+ markRun({ hits: stamped, checked: scored.length, measurable, population: runPopulation });
403
1480
  return {
404
1481
  ok: true,
405
1482
  ...result,
@@ -408,7 +1485,7 @@ export async function stallCheckTool(args) {
408
1485
  // never silent to the record, which markRun just wrote.
409
1486
  dm: hits.length > 0,
410
1487
  note: hits.length === 0
411
- ? `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.`
1488
+ ? `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.`
412
1489
  : undefined,
413
1490
  };
414
1491
  }