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
@@ -12,16 +12,16 @@
12
12
  * that drifts.
13
13
  */
14
14
  import { execFileSync } from "node:child_process";
15
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import { z } from "zod";
18
18
  import { treeProvenance } from "./tree-provenance.js";
19
- import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
19
+ import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, } from "@davidbalzan/groundwork-seam";
20
20
  import { ensureWorktreeTool } from "./worktrees.js";
21
21
  import { ROOT } from "../store.js";
22
- import { verdictsFor, gatedAt } from "../gated-head.js";
22
+ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
23
23
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
24
- import { haltState, isInFlightStatus } from "./stall.js";
24
+ import { haltState } from "./stall.js";
25
25
  import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
26
26
  const QUEUE_DOC = "docs/QUEUE.md";
27
27
  const DONE_DOC = "docs/DONE.md";
@@ -252,18 +252,60 @@ export function noDownstream(items) {
252
252
  function rowSubjectIds(row) {
253
253
  return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
254
254
  }
255
+ /*
256
+ * ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
257
+ * MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
258
+ * `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
259
+ * green in an open PR and the other has nothing behind it. Reporting them under
260
+ * one word re-creates the defect with a wider net: a reader told "parked" for
261
+ * `#292`'s row would go and build it.
262
+ *
263
+ * Two INDEPENDENT facts, deliberately not one: the WORD comes from the
264
+ * declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
265
+ * Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
266
+ * and that disagreement is worth seeing rather than resolving.
267
+ */
268
+ const STATUS_DECORATION = /^[\s*⭐]+/u;
269
+ function holdReasonOf(state, status) {
270
+ if (state !== "parked")
271
+ return state;
272
+ const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
273
+ if (/\bheld\b/i.test(decl))
274
+ return "held";
275
+ if (/\bqueued\b/i.test(decl))
276
+ return "queued";
277
+ return "parked";
278
+ }
255
279
  /**
256
- * Does the board show this item as IN SOMEBODY'S HANDS?
280
+ * What does the board say about this item, if it is the SUBJECT of any row?
281
+ *
282
+ * ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
283
+ * (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
284
+ * only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
285
+ * its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
286
+ * Measured on the live board 2026-09-14: seven `⏸` rows, every one the
287
+ * coordinator's most careful bookkeeping, every one invisible to this join.
257
288
  *
258
- * Two conditions, and both are needed — measured, not reasoned:
259
- * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
260
- * since #219 rather than a second glyph list). `⏸ Parked`,
261
- * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
262
- * `🔻 Orphaned` describe work nobody holds, so they do not silence.
289
+ * THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
290
+ * fix made a `⏸ Parked` row NOT silence its item, because the alternative
291
+ * then was a SILENT drop with no axis. The drop is no longer silent: the item
292
+ * lands on `delivered` with the row's state as its `why`, and an `AWAITS`
293
+ * item still reaches `awaitingDecision`, which is gathered over every open
294
+ * item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
295
+ * must not make its item VANISH — still holds; what changes is that a warning
296
+ * row now also stops the item being HANDED OUT, which is what a warning is for.
297
+ *
298
+ * Conditions:
263
299
  * SUBJECT the row's Stream cell names the item — by recorded id, or by the
264
300
  * text prefix `claim` has always written there. A mention in a Last
265
301
  * note is one lane REFERRING to another item — the aide's live row
266
302
  * does exactly this — and a status-only rule would still hide it.
303
+ * STATE whatever the seam reads: in-progress, in-review, parked (split
304
+ * into held · queued · parked, see `holdReasonOf`), blocked,
305
+ * orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
306
+ * excludes: the row is on the board with this item as its subject,
307
+ * and offering it anyway is the silent path this exists to close.
308
+ * A `✅ Done` row excludes too — that item is offerable to nobody.
267
309
  *
268
310
  * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
269
311
  * would have shipped a transitional hole in its own negative control: every row
@@ -274,16 +316,24 @@ function rowSubjectIds(row) {
274
316
  * here carry the id (see `claim`), so the prefix arm is the compatibility half
275
317
  * rather than the mechanism, and it is deliberately still SUBJECT-only.
276
318
  */
277
- function boardHoldsItem(rows, id, text) {
319
+ function boardHoldOf(rows, id, text) {
278
320
  const key = keyOf({ text }).replace(/\s+/g, " ").trim();
279
- return rows.some((r) => {
280
- if (!isInFlightStatus(r.status))
281
- return false;
282
- if (rowSubjectIds(r).includes(id))
283
- return true;
321
+ for (const r of rows) {
284
322
  const stream = String(r.stream).replace(/\s+/g, " ").trim();
285
- return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
286
- });
323
+ const subject = rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
324
+ if (!subject)
325
+ continue;
326
+ const state = workStateOf(r.status);
327
+ const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
328
+ return {
329
+ state,
330
+ why: holdReasonOf(state, r.status),
331
+ status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
332
+ builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
333
+ branch,
334
+ };
335
+ }
336
+ return null;
287
337
  }
288
338
  /**
289
339
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
@@ -311,7 +361,7 @@ function boardHoldsItem(rows, id, text) {
311
361
  * false positives, because open rows cite refs as EVIDENCE.
312
362
  *
313
363
  * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
314
- * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
364
+ * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
315
365
  * delivery from prose: it requires the entry to carry the item's own
316
366
  * deterministically composed text, `summarize(item.text)`, which a VERB writes.
317
367
  * A citation cannot accidentally satisfy it.
@@ -407,20 +457,34 @@ export async function nextUnblockedTool(args) {
407
457
  if (i.done)
408
458
  continue; // already off `open` by the checkbox; not an exclusion this axis owns
409
459
  // Board first, and the order is load-bearing for the REPORT rather than the
410
- // routing: both causes exclude, but a live 🚧 row is a different remedy
411
- // (wait, or ask its owner) from a landed delivery (close the row).
412
- if (boardHoldsItem(boardRows, i.id, String(i.text)))
413
- deliveredBy.set(i.id, "board");
460
+ // routing: both causes exclude, but a board row is a different remedy
461
+ // (wait for the merge · ask its owner · ask the human it awaits, by its
462
+ // `why`) from a landed delivery (close the row).
463
+ const hold = boardHoldOf(boardRows, i.id, String(i.text));
464
+ if (hold)
465
+ deliveredBy.set(i.id, { reason: "board", hold });
414
466
  else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
415
- deliveredBy.set(i.id, "done");
467
+ deliveredBy.set(i.id, { reason: "done" });
416
468
  }
417
469
  const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
418
470
  // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
419
471
  // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
420
472
  // reported silently is the same defect as an incorrect one.
473
+ //
474
+ // ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
475
+ // the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
476
+ // and the two want opposite things from the reader — leave the first alone,
477
+ // MERGE the second. `why` is the seam's state (parked split into held ·
478
+ // queued · parked) and `builtWork` says whether the row names a branch at
479
+ // all, so "held with nothing behind it" is visible rather than resolved.
421
480
  const delivered = items
422
481
  .filter((i) => !i.done && deliveredBy.has(i.id))
423
- .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id) }));
482
+ .map((i) => {
483
+ const d = deliveredBy.get(i.id);
484
+ return d.reason === "board"
485
+ ? { item: keyOf(i), id: i.id, reason: "board", why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch }
486
+ : { item: keyOf(i), id: i.id, reason: "done", why: "recorded in DONE.md" };
487
+ });
424
488
  // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
425
489
  // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
426
490
  // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
@@ -555,7 +619,7 @@ export async function nextUnblockedTool(args) {
555
619
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
556
620
  ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
557
621
  ...delivered.map((d) => d.reason === "board"
558
- ? `⏭ not offered — already on the board (${d.id})`
622
+ ? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
559
623
  : `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
560
624
  ],
561
625
  // A SEPARATE AXIS, deliberately. See noDownstream().
@@ -622,6 +686,13 @@ export const claimSchema = {
622
686
  base: z.string().optional(),
623
687
  task: z.string().optional(),
624
688
  write: z.boolean().optional(),
689
+ /**
690
+ * ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
691
+ * What the slice delivers instead (a ruling, a canon edit, a measurement).
692
+ * No worktree is cut; the board cell carries the statement in words, which
693
+ * the grammar and `stall_check` both read as DELIBERATE rather than missing.
694
+ */
695
+ noCodeDeliverable: z.string().min(1).optional(),
625
696
  };
626
697
  export async function claimTool(args) {
627
698
  const halt = haltState();
@@ -660,6 +731,25 @@ export async function claimTool(args) {
660
731
  // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
661
732
  // item to an agent with nowhere isolated to work is the shared-checkout failure
662
733
  // this pair exists to prevent.
734
+ // ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
735
+ // seats wrote this by hand in five spellings because the verb had no way to
736
+ // say it; `stall_check` reads the prose cell as deliberately branchless and
737
+ // keeps the row out of its population rather than calling it unmeasurable.
738
+ if (args.noCodeDeliverable) {
739
+ const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
740
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
741
+ let board = { action: "reported" };
742
+ const b = readDoc(repo, BOARD_DOC);
743
+ if (!b)
744
+ board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
745
+ else if (args.write) {
746
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
747
+ if (next.action !== "unchanged")
748
+ writeFileSync(path.join(repo, BOARD_DOC), next.text);
749
+ board = { action: next.action, path: BOARD_DOC };
750
+ }
751
+ return { ok: true, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable };
752
+ }
663
753
  const wt = await ensureWorktreeTool({
664
754
  agentId: args.agentId,
665
755
  repo,
@@ -792,20 +882,39 @@ export const landSchema = {
792
882
  };
793
883
  const ghMergeFacts = (repo, n) => {
794
884
  try {
795
- const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
885
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
796
886
  cwd: repo,
797
887
  encoding: "utf8",
798
888
  stdio: ["ignore", "pipe", "ignore"],
889
+ maxBuffer: 64 * 1024 * 1024,
799
890
  });
800
891
  const j = JSON.parse(out);
801
892
  if (!j.headRefOid || !j.mergedAt)
802
893
  return null;
803
- return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
894
+ return {
895
+ headRefOid: String(j.headRefOid),
896
+ mergedAt: String(j.mergedAt),
897
+ comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
898
+ };
804
899
  }
805
900
  catch {
806
901
  return null;
807
902
  }
808
903
  };
904
+ /** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
905
+ export function landingCitations(repo, n) {
906
+ let subjects = "";
907
+ for (const base of ["origin/main", "main"]) {
908
+ try {
909
+ subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]);
910
+ break;
911
+ }
912
+ catch { /* next */ }
913
+ }
914
+ const marker = `(#${n})`;
915
+ const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
916
+ return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
917
+ }
809
918
  export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
810
919
  const repo = args.repo ?? process.cwd();
811
920
  const base = args.base ?? "main";
@@ -861,6 +970,17 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
861
970
  // `queueItemId` it closes nothing and reports the candidates for the caller to
862
971
  // pick, saying so.
863
972
  const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
973
+ // ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
974
+ // shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
975
+ // (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
976
+ // The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
977
+ // every check reads, local, no network — and listed beside the #N candidates
978
+ // at the one moment both facts are in hand. Closing nothing: a sibling is a
979
+ // question for the coordinator, not a claim.
980
+ const citedIds = landingCitations(repo, n);
981
+ const siblings = items
982
+ .filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
983
+ .map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
864
984
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
865
985
  if (args.queueItemId && !target)
866
986
  return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
@@ -899,7 +1019,35 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
899
1019
  queueChanged = true;
900
1020
  const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
901
1021
  const contextRepo = originRepoOf(repo);
902
- const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
1022
+ /*
1023
+ * ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
1024
+ *
1025
+ * This asked whether the PR number appears ANYWHERE in the row, so a row that
1026
+ * DISCUSSES its own closer read as already cited and `land` wrote no token.
1027
+ * The natural experiment that isolates it — three rows, one variable:
1028
+ *
1029
+ * ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
1030
+ * ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
1031
+ * ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
1032
+ *
1033
+ * ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
1034
+ * somebody else's PRs is cited normally; a row naming its own once is not. So the
1035
+ * better a row documents what closed it, the more certainly the closure goes
1036
+ * unwritten — and the ARTEFACT is what loses, silently.
1037
+ *
1038
+ * ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
1039
+ * ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
1040
+ * a line-1 prose mention and `doctor` falls back to the row. That was measured and
1041
+ * a patch was reverted rather than shipped. What nobody asked was what it does to
1042
+ * the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
1043
+ * makes a ref a citation, and that rule applies to the verb that WRITES one.
1044
+ *
1045
+ * ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
1046
+ * rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
1047
+ * must never happen.
1048
+ */
1049
+ const closingHere = closingCitation(String(target.text))?.refs ?? [];
1050
+ const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
903
1051
  if (!alreadyCited) {
904
1052
  citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
905
1053
  target.text = `${target.text}${citationAppended}`;
@@ -1066,10 +1214,32 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1066
1214
  const at = Date.parse(mf.mergedAt);
1067
1215
  if (!Number.isFinite(at))
1068
1216
  return { checked: false, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
1069
- const { verdicts, unparsed } = verdictsFor(log, n);
1070
- const a = gatedAt(verdicts, mf.headRefOid, at);
1217
+ const { verdicts: bus, unparsed } = verdictsFor(log, n);
1218
+ // ⟨q-5a93c2d7⟩ both channels, one predicate: the bus's typed records and
1219
+ // the PR page's typed lines. Gatedness is (head sha, typed verdict); the
1220
+ // merge time only DISCLOSES lateness, in words, beside the answer.
1221
+ const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(mf.comments ?? [])];
1222
+ const a = gatedBy(verdicts, mf.headRefOid, at);
1071
1223
  if (a.gated) {
1072
- return { checked: true, gated: true, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
1224
+ const late = (a.lateByMs ?? 0) > 0;
1225
+ return {
1226
+ checked: true,
1227
+ gated: true,
1228
+ head: mf.headRefOid.slice(0, 8),
1229
+ // ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
1230
+ // sender alone; `attribution` says whether that is a seat or the account.
1231
+ by: {
1232
+ from: a.gater,
1233
+ sha: a.by.head.slice(0, 8),
1234
+ channel: a.by.channel ?? "bus",
1235
+ attribution: a.attribution,
1236
+ ...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
1237
+ ...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
1238
+ },
1239
+ recordedAfterMerge: late,
1240
+ verified: a.verified,
1241
+ ...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
1242
+ };
1073
1243
  }
1074
1244
  return {
1075
1245
  checked: true,
@@ -1078,7 +1248,7 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1078
1248
  reason: a.reason,
1079
1249
  gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1080
1250
  note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
1081
- `The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
1251
+ `The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
1082
1252
  (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1083
1253
  };
1084
1254
  })();
@@ -1117,6 +1287,11 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1117
1287
  candidates: target
1118
1288
  ? undefined
1119
1289
  : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
1290
+ // ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
1291
+ siblings,
1292
+ ...(siblings.length
1293
+ ? { note_siblings: `${siblings.length} open row(s) NAME an id #${n} cites (${citedIds.join(", ")}) and are not cited themselves — work shipped under another row's citation is how q-b4e7c209 and q-0c5e73a1 sat open; judge each, close none from here.` }
1294
+ : {}),
1120
1295
  ...(target || !candidates.length
1121
1296
  ? {}
1122
1297
  : {
@@ -1186,7 +1361,7 @@ const ghFacts = (repo, n) => {
1186
1361
  // history: the PR title (which becomes the squash subject) and the commit
1187
1362
  // subjects. The body is included because a reviewer reads it, but a claim
1188
1363
  // that lives only in a comment thread is not a record.
1189
- const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}");
1364
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}");
1190
1365
  const subjects = (meta.commits ?? [])
1191
1366
  .map((c) => c.messageHeadline ?? "")
1192
1367
  .join("\n");
@@ -1194,6 +1369,7 @@ const ghFacts = (repo, n) => {
1194
1369
  state: String(j.state ?? ""),
1195
1370
  mergeable: String(j.mergeable ?? ""),
1196
1371
  headRefOid: String(j.headRefOid ?? ""),
1372
+ comments: (meta.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
1197
1373
  checks: j.statusCheckRollup ?? [],
1198
1374
  diff: gh(["pr", "diff", n]),
1199
1375
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1206,9 +1382,25 @@ const ghMerge = (repo, n, method) => {
1206
1382
  stdio: ["ignore", "pipe", "ignore"],
1207
1383
  });
1208
1384
  };
1385
+ /**
1386
+ * ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
1387
+ * verdict recorded in another room still counts for this head. The project
1388
+ * room is read first so a missing bus is still a null (unknown), never an
1389
+ * empty string (nothing).
1390
+ */
1209
1391
  const roomLog = (project) => {
1210
1392
  try {
1211
- return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1393
+ const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1394
+ let others = "";
1395
+ try {
1396
+ const dir = path.join(ROOT, "rooms");
1397
+ others = readdirSync(dir)
1398
+ .filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
1399
+ .map((f) => readFileSync(path.join(dir, f), "utf8"))
1400
+ .join("\n");
1401
+ }
1402
+ catch { /* other rooms are optional */ }
1403
+ return others ? `${own}\n${others}` : own;
1212
1404
  }
1213
1405
  catch {
1214
1406
  return null;
@@ -1341,8 +1533,9 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
1341
1533
  verdict,
1342
1534
  };
1343
1535
  }
1344
- const { verdicts, unparsed } = verdictsFor(log, n);
1345
- const gate = gatedAt(verdicts, head, Date.now());
1536
+ const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
1537
+ // ⟨q-5a93c2d7⟩ the pre-merge question, same predicate, both channels.
1538
+ const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(f.comments ?? [])], head, null);
1346
1539
  if (!gate.gated) {
1347
1540
  const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1348
1541
  return {
@@ -1356,7 +1549,7 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
1356
1549
  };
1357
1550
  }
1358
1551
  if (!args.write)
1359
- return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.by.from}. Pass write:true to apply.` };
1552
+ return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` };
1360
1553
  doMerge(repo, n, args.method ?? "squash");
1361
1554
  return { ok: true, merged: true, verdict };
1362
1555
  }