agent-coord-mcp 0.26.20 → 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 (68) 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 +177 -0
  8. package/dist/gated-head.js.map +1 -0
  9. package/dist/server-spread.js +195 -0
  10. package/dist/server-spread.js.map +1 -0
  11. package/dist/server.js +4 -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/queue-write.js +431 -0
  26. package/dist/tools/queue-write.js.map +1 -0
  27. package/dist/tools/record-events.js +85 -5
  28. package/dist/tools/record-events.js.map +1 -1
  29. package/dist/tools/records.js +384 -33
  30. package/dist/tools/records.js.map +1 -1
  31. package/dist/tools/registry.js +52 -2
  32. package/dist/tools/registry.js.map +1 -1
  33. package/dist/tools/seat-build.js +173 -0
  34. package/dist/tools/seat-build.js.map +1 -0
  35. package/dist/tools/shared.js.map +1 -1
  36. package/dist/tools/stall.js +1095 -18
  37. package/dist/tools/stall.js.map +1 -1
  38. package/dist/tools/transport.js +21 -2
  39. package/dist/tools/transport.js.map +1 -1
  40. package/dist/tools/tree-provenance.js +107 -0
  41. package/dist/tools/tree-provenance.js.map +1 -0
  42. package/dist/tools/worktrees.js +14 -0
  43. package/dist/tools/worktrees.js.map +1 -1
  44. package/package.json +1 -1
  45. package/scripts/coord-attention-clock.mjs +2 -0
  46. package/scripts/coord-stall-clock.mjs +52 -11
  47. package/src/capabilities.ts +264 -2
  48. package/src/closing-line.ts +85 -0
  49. package/src/commit-cite.ts +58 -0
  50. package/src/gated-head.ts +236 -0
  51. package/src/server-spread.ts +233 -0
  52. package/src/server.ts +10 -2
  53. package/src/store.ts +32 -0
  54. package/src/tools/away.ts +82 -9
  55. package/src/tools/board-ref.ts +70 -3
  56. package/src/tools/event-kinds.ts +17 -1
  57. package/src/tools/events.ts +33 -2
  58. package/src/tools/messaging.ts +63 -6
  59. package/src/tools/queue-write.ts +485 -0
  60. package/src/tools/record-events.ts +78 -5
  61. package/src/tools/records.ts +423 -31
  62. package/src/tools/registry.ts +54 -3
  63. package/src/tools/seat-build.ts +194 -0
  64. package/src/tools/shared.ts +22 -0
  65. package/src/tools/stall.ts +1266 -23
  66. package/src/tools/transport.ts +21 -2
  67. package/src/tools/tree-provenance.ts +136 -0
  68. package/src/tools/worktrees.ts +13 -0
@@ -12,9 +12,11 @@
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
+
19
+ import { treeProvenance } from "./tree-provenance.js";
18
20
  import {
19
21
  parseWorkDoc,
20
22
  renderWorkDoc,
@@ -28,14 +30,20 @@ import {
28
30
  sweepTagOf,
29
31
  awaitingOf,
30
32
  refsIn,
33
+ closingCitation,
31
34
  refsMatch,
32
35
  workstreamsV1RowsOf,
36
+ workStateOf,
37
+ declarationOf,
38
+ type WorkState,
33
39
  type WorkstreamsV1Row,
34
40
  type DoneEntry,
35
41
  } from "@davidbalzan/groundwork-seam";
36
42
  import { ensureWorktreeTool } from "./worktrees.js";
43
+ import { ROOT } from "../store.js";
44
+ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
37
45
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
38
- import { haltState, isInFlightStatus } from "./stall.js";
46
+ import { haltState } from "./stall.js";
39
47
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
40
48
 
41
49
  const QUEUE_DOC = "docs/QUEUE.md";
@@ -156,6 +164,7 @@ const git = (repo: string, args: string[]): string =>
156
164
  execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
157
165
 
158
166
  /** `#123` / `owner/repo#123` → "123". */
167
+
159
168
  function prNumber(pr: string): string | null {
160
169
  const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
161
170
  return m ? (m[1] as string) : null;
@@ -283,18 +292,71 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
283
292
  return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
284
293
  }
285
294
 
295
+ /** What a board row says about an item it names as its SUBJECT. */
296
+ export type BoardHold = {
297
+ /** The state the seam read off the row's Status cell. */
298
+ state: WorkState;
299
+ /** The routing reason: the state, except `parked` is split into held · queued · parked. */
300
+ why: string;
301
+ /** The declaration the reason was read from — never the commentary after it. */
302
+ status: string;
303
+ /** Whether the row's Branch · Worktree cell names anything at all. */
304
+ builtWork: boolean;
305
+ branch: string;
306
+ };
307
+
308
+ /*
309
+ * ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
310
+ * MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
311
+ * `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
312
+ * green in an open PR and the other has nothing behind it. Reporting them under
313
+ * one word re-creates the defect with a wider net: a reader told "parked" for
314
+ * `#292`'s row would go and build it.
315
+ *
316
+ * Two INDEPENDENT facts, deliberately not one: the WORD comes from the
317
+ * declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
318
+ * Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
319
+ * and that disagreement is worth seeing rather than resolving.
320
+ */
321
+ const STATUS_DECORATION = /^[\s*⭐]+/u;
322
+ function holdReasonOf(state: WorkState, status: string): string {
323
+ if (state !== "parked") return state;
324
+ const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
325
+ if (/\bheld\b/i.test(decl)) return "held";
326
+ if (/\bqueued\b/i.test(decl)) return "queued";
327
+ return "parked";
328
+ }
329
+
286
330
  /**
287
- * Does the board show this item as IN SOMEBODY'S HANDS?
331
+ * What does the board say about this item, if it is the SUBJECT of any row?
288
332
  *
289
- * Two conditions, and both are needed — measured, not reasoned:
290
- * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
291
- * since #219 rather than a second glyph list). `⏸ Parked`,
292
- * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
293
- * `🔻 Orphaned` describe work nobody holds, so they do not silence.
333
+ * ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
334
+ * (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
335
+ * only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
336
+ * its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
337
+ * Measured on the live board 2026-09-14: seven `⏸` rows, every one the
338
+ * coordinator's most careful bookkeeping, every one invisible to this join.
339
+ *
340
+ * ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
341
+ * fix made a `⏸ Parked` row NOT silence its item, because the alternative
342
+ * then was a SILENT drop with no axis. The drop is no longer silent: the item
343
+ * lands on `delivered` with the row's state as its `why`, and an `AWAITS`
344
+ * item still reaches `awaitingDecision`, which is gathered over every open
345
+ * item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
346
+ * must not make its item VANISH — still holds; what changes is that a warning
347
+ * row now also stops the item being HANDED OUT, which is what a warning is for.
348
+ *
349
+ * Conditions:
294
350
  * SUBJECT the row's Stream cell names the item — by recorded id, or by the
295
351
  * text prefix `claim` has always written there. A mention in a Last
296
352
  * note is one lane REFERRING to another item — the aide's live row
297
353
  * does exactly this — and a status-only rule would still hide it.
354
+ * STATE whatever the seam reads: in-progress, in-review, parked (split
355
+ * into held · queued · parked, see `holdReasonOf`), blocked,
356
+ * orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
357
+ * excludes: the row is on the board with this item as its subject,
358
+ * and offering it anyway is the silent path this exists to close.
359
+ * A `✅ Done` row excludes too — that item is offerable to nobody.
298
360
  *
299
361
  * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
300
362
  * would have shipped a transitional hole in its own negative control: every row
@@ -305,14 +367,24 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
305
367
  * here carry the id (see `claim`), so the prefix arm is the compatibility half
306
368
  * rather than the mechanism, and it is deliberately still SUBJECT-only.
307
369
  */
308
- function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boolean {
370
+ function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null {
309
371
  const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
310
- return rows.some((r) => {
311
- if (!isInFlightStatus(r.status)) return false;
312
- if (rowSubjectIds(r).includes(id)) return true;
372
+ for (const r of rows) {
313
373
  const stream = String(r.stream).replace(/\s+/g, " ").trim();
314
- return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
315
- });
374
+ const subject =
375
+ rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
376
+ if (!subject) continue;
377
+ const state = workStateOf(r.status);
378
+ const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
379
+ return {
380
+ state,
381
+ why: holdReasonOf(state, r.status),
382
+ status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
383
+ builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
384
+ branch,
385
+ };
386
+ }
387
+ return null;
316
388
  }
317
389
 
318
390
  /**
@@ -341,7 +413,7 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
341
413
  * false positives, because open rows cite refs as EVIDENCE.
342
414
  *
343
415
  * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
344
- * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
416
+ * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
345
417
  * delivery from prose: it requires the entry to carry the item's own
346
418
  * deterministically composed text, `summarize(item.text)`, which a VERB writes.
347
419
  * A citation cannot accidentally satisfy it.
@@ -387,8 +459,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
387
459
  };
388
460
  }
389
461
  const repo = args.repo ?? process.cwd();
462
+ // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
463
+ // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
464
+ // a confidently wrong routing decision in both directions inside ten minutes —
465
+ // a row ruled un-claimable from a stale file, and a worker told to hold on a
466
+ // row that was already split. The answer was well-formed and said nothing
467
+ // about its source, which is what made it invisible.
468
+ const tree = treeProvenance(repo);
390
469
  const q = readDoc(repo, QUEUE_DOC);
391
- if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
470
+ if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
392
471
  const items = queueItemsOf(q.doc);
393
472
  // The seam's own count of unticked rows — the number every axis must add back
394
473
  // up to. Taken BEFORE any exclusion so it cannot inherit one.
@@ -423,7 +502,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
423
502
  //
424
503
  // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
425
504
  // a row is excluded BY a named cause, and the cause is what gets reported.
426
- const deliveredBy = new Map<string, "board" | "done">();
505
+ const deliveredBy = new Map<string, { reason: "board"; hold: BoardHold } | { reason: "done" }>();
427
506
  const doneDoc = readDoc(repo, DONE_DOC);
428
507
  const boardDoc = readDoc(repo, BOARD_DOC);
429
508
  const boardText = boardDoc?.text ?? "";
@@ -432,10 +511,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
432
511
  for (const i of items) {
433
512
  if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
434
513
  // Board first, and the order is load-bearing for the REPORT rather than the
435
- // routing: both causes exclude, but a live 🚧 row is a different remedy
436
- // (wait, or ask its owner) from a landed delivery (close the row).
437
- if (boardHoldsItem(boardRows, i.id, String(i.text))) deliveredBy.set(i.id, "board");
438
- else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, "done");
514
+ // routing: both causes exclude, but a board row is a different remedy
515
+ // (wait for the merge · ask its owner · ask the human it awaits, by its
516
+ // `why`) from a landed delivery (close the row).
517
+ const hold = boardHoldOf(boardRows, i.id, String(i.text));
518
+ if (hold) deliveredBy.set(i.id, { reason: "board", hold });
519
+ else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" });
439
520
  }
440
521
 
441
522
  const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
@@ -443,9 +524,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
443
524
  // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
444
525
  // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
445
526
  // reported silently is the same defect as an incorrect one.
527
+ //
528
+ // ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
529
+ // the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
530
+ // and the two want opposite things from the reader — leave the first alone,
531
+ // MERGE the second. `why` is the seam's state (parked split into held ·
532
+ // queued · parked) and `builtWork` says whether the row names a branch at
533
+ // all, so "held with nothing behind it" is visible rather than resolved.
446
534
  const delivered = items
447
535
  .filter((i) => !i.done && deliveredBy.has(i.id))
448
- .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
536
+ .map((i) => {
537
+ const d = deliveredBy.get(i.id)!;
538
+ return d.reason === "board"
539
+ ? { item: keyOf(i), id: i.id, reason: "board" as const, why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch }
540
+ : { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" };
541
+ });
449
542
 
450
543
  // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
451
544
  // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
@@ -533,6 +626,10 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
533
626
  // the severity finding one file over. Say the axis is uninformative instead.
534
627
  const undiscriminating = silent.length === open.length && open.length > 1;
535
628
  return {
629
+ // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
630
+ // routing answer whose tree is unnamed is the defect this row exists for.
631
+ tree,
632
+ ...(tree.warning ? { staleWarning: tree.warning } : {}),
536
633
  ok: true as const,
537
634
  project: args.project,
538
635
  open: open.length,
@@ -575,7 +672,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
575
672
  ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
576
673
  ...delivered.map((d) =>
577
674
  d.reason === "board"
578
- ? `⏭ not offered — already on the board (${d.id})`
675
+ ? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
579
676
  : `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
580
677
  ),
581
678
  ],
@@ -647,9 +744,16 @@ export const claimSchema = {
647
744
  base: z.string().optional(),
648
745
  task: z.string().optional(),
649
746
  write: z.boolean().optional(),
747
+ /**
748
+ * ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
749
+ * What the slice delivers instead (a ruling, a canon edit, a measurement).
750
+ * No worktree is cut; the board cell carries the statement in words, which
751
+ * the grammar and `stall_check` both read as DELIBERATE rather than missing.
752
+ */
753
+ noCodeDeliverable: z.string().min(1).optional(),
650
754
  };
651
755
 
652
- export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
756
+ export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) {
653
757
  const halt = haltState();
654
758
  if (halt.halted) {
655
759
  return {
@@ -683,6 +787,24 @@ export async function claimTool(args: { project: string; agentId: string; itemId
683
787
  // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
684
788
  // item to an agent with nowhere isolated to work is the shared-checkout failure
685
789
  // this pair exists to prevent.
790
+ // ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
791
+ // seats wrote this by hand in five spellings because the verb had no way to
792
+ // say it; `stall_check` reads the prose cell as deliberately branchless and
793
+ // keeps the row out of its population rather than calling it unmeasurable.
794
+ if (args.noCodeDeliverable) {
795
+ const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
796
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
797
+ let board: { action: string; path?: string } = { action: "reported" };
798
+ const b = readDoc(repo, BOARD_DOC);
799
+ if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
800
+ else if (args.write) {
801
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
802
+ if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
803
+ board = { action: next.action, path: BOARD_DOC };
804
+ }
805
+ return { ok: true as const, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable };
806
+ }
807
+
686
808
  const wt = await ensureWorktreeTool({
687
809
  agentId: args.agentId,
688
810
  repo,
@@ -822,7 +944,47 @@ export const landSchema = {
822
944
  result: z.string().optional(),
823
945
  };
824
946
 
825
- export async function landTool(args: {
947
+ /**
948
+ * What the recording step needs to know about a merge: which head actually
949
+ * landed, and when. Injected so the report is provable without the network.
950
+ */
951
+ export type PrComment = { body: string; createdAt?: string; author?: string };
952
+ /** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */
953
+ export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null;
954
+ export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
955
+ const ghMergeFacts: ReadMergeFacts = (repo, n) => {
956
+ try {
957
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
958
+ cwd: repo,
959
+ encoding: "utf8",
960
+ stdio: ["ignore", "pipe", "ignore"],
961
+ maxBuffer: 64 * 1024 * 1024,
962
+ });
963
+ const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] };
964
+ if (!j.headRefOid || !j.mergedAt) return null;
965
+ return {
966
+ headRefOid: String(j.headRefOid),
967
+ mergedAt: String(j.mergedAt),
968
+ comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
969
+ };
970
+ } catch {
971
+ return null;
972
+ }
973
+ };
974
+
975
+ /** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
976
+ export function landingCitations(repo: string, n: string): string[] {
977
+ let subjects = "";
978
+ for (const base of ["origin/main", "main"]) {
979
+ try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ }
980
+ }
981
+ const marker = `(#${n})`;
982
+ const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
983
+ return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
984
+ }
985
+
986
+ export async function landTool(
987
+ args: {
826
988
  project: string;
827
989
  pr: string;
828
990
  queueItemId?: string;
@@ -830,7 +992,10 @@ export async function landTool(args: {
830
992
  base?: string;
831
993
  write?: boolean;
832
994
  result?: string;
833
- }) {
995
+ },
996
+ readMergeFacts: ReadMergeFacts = ghMergeFacts,
997
+ readVerdictLog: ReadVerdictLog = roomLog,
998
+ ) {
834
999
  const repo = args.repo ?? process.cwd();
835
1000
  const base = args.base ?? "main";
836
1001
  const n = prNumber(args.pr);
@@ -886,6 +1051,17 @@ export async function landTool(args: {
886
1051
  // `queueItemId` it closes nothing and reports the candidates for the caller to
887
1052
  // pick, saying so.
888
1053
  const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
1054
+ // ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
1055
+ // shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
1056
+ // (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
1057
+ // The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
1058
+ // every check reads, local, no network — and listed beside the #N candidates
1059
+ // at the one moment both facts are in hand. Closing nothing: a sibling is a
1060
+ // question for the coordinator, not a claim.
1061
+ const citedIds = landingCitations(repo, n);
1062
+ const siblings = items
1063
+ .filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
1064
+ .map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
889
1065
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
890
1066
  if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
891
1067
 
@@ -924,7 +1100,35 @@ export async function landTool(args: {
924
1100
  queueChanged = true;
925
1101
  const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
926
1102
  const contextRepo = originRepoOf(repo);
927
- const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
1103
+ /*
1104
+ * ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
1105
+ *
1106
+ * This asked whether the PR number appears ANYWHERE in the row, so a row that
1107
+ * DISCUSSES its own closer read as already cited and `land` wrote no token.
1108
+ * The natural experiment that isolates it — three rows, one variable:
1109
+ *
1110
+ * ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
1111
+ * ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
1112
+ * ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
1113
+ *
1114
+ * ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
1115
+ * somebody else's PRs is cited normally; a row naming its own once is not. So the
1116
+ * better a row documents what closed it, the more certainly the closure goes
1117
+ * unwritten — and the ARTEFACT is what loses, silently.
1118
+ *
1119
+ * ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
1120
+ * ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
1121
+ * a line-1 prose mention and `doctor` falls back to the row. That was measured and
1122
+ * a patch was reverted rather than shipped. What nobody asked was what it does to
1123
+ * the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
1124
+ * makes a ref a citation, and that rule applies to the verb that WRITES one.
1125
+ *
1126
+ * ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
1127
+ * rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
1128
+ * must never happen.
1129
+ */
1130
+ const closingHere: ReturnType<typeof refsIn> = closingCitation(String(target.text))?.refs ?? [];
1131
+ const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
928
1132
  if (!alreadyCited) {
929
1133
  citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
930
1134
  target.text = `${target.text}${citationAppended}`;
@@ -945,8 +1149,41 @@ export async function landTool(args: {
945
1149
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
946
1150
  const summary = originalText !== null ? summarize(originalText) : null;
947
1151
 
1152
+ /*
1153
+ * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
1154
+ * — `⟨q-d046204d⟩`.
1155
+ *
1156
+ * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
1157
+ * from these entries: IT WAS CUT, because in the row it sits later than the
1158
+ * truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
1159
+ * that names no row — and `closingRefs` needs BOTH halves, so the row reads
1160
+ * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
1161
+ *
1162
+ * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
1163
+ * out of the truncated region BY CONSTRUCTION, so it survives any headline length
1164
+ * — where raising `max` only moves the cliff. The row is explicit that "write
1165
+ * longer headlines" is not an acceptable fix, and it is right: a length that is
1166
+ * enough today is a truncation tomorrow.
1167
+ *
1168
+ * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
1169
+ * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
1170
+ * <summary>` and one written as `<summary>` agree". So the composed-summary arm
1171
+ * keeps matching and this needed no change there.
1172
+ *
1173
+ * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
1174
+ * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
1175
+ * in an entry — a reader-side guess that produced eleven false positives, because
1176
+ * this repo's canon requires entries to cite item ids for other reasons. This
1177
+ * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
1178
+ * infers nothing, and re-reading a sigil as "delivered" is still wrong.
1179
+ *
1180
+ * The id is the ITEM's, taken from the record rather than re-derived: `target` is
1181
+ * non-null whenever `summary` is, since the summary comes from its text.
1182
+ */
948
1183
  const doneLine =
949
- already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
1184
+ already || !summary || !target
1185
+ ? null
1186
+ : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
950
1187
 
951
1188
  // APPEND TO DONE.md — the half this verb exists for.
952
1189
  //
@@ -1034,12 +1271,75 @@ export async function landTool(args: {
1034
1271
  }
1035
1272
  }
1036
1273
 
1274
+ /*
1275
+ * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
1276
+ *
1277
+ * `merge` refuses this BEFORE the fact and is the control that prevents. This
1278
+ * one runs after, and its job is different: it is how the fleet LEARNS a
1279
+ * crossing happened anyway — through a merge done by hand, by `gh` directly,
1280
+ * or by a seat that never called the verb.
1281
+ *
1282
+ * It REPORTS and never refuses. A closure is not the right place to litigate a
1283
+ * merge that already happened: the verdict is QA's artefact, the merge may have
1284
+ * been someone else's, and blocking the record would leave the work landed and
1285
+ * unrecorded — strictly worse than landed and recorded with a flag on it.
1286
+ *
1287
+ * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
1288
+ */
1289
+ const gatedHead = (() => {
1290
+ const mf = readMergeFacts(repo, n);
1291
+ if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
1292
+ const log = readVerdictLog(args.project);
1293
+ if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
1294
+ const at = Date.parse(mf.mergedAt);
1295
+ if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
1296
+ const { verdicts: bus, unparsed } = verdictsFor(log, n);
1297
+ // ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
1298
+ // the PR page's typed lines. Gatedness is (head sha, typed verdict); the
1299
+ // merge time only DISCLOSES lateness, in words, beside the answer.
1300
+ const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])];
1301
+ const a = gatedBy(verdicts, mf.headRefOid, at);
1302
+ if (a.gated) {
1303
+ const late = (a.lateByMs ?? 0) > 0;
1304
+ return {
1305
+ checked: true as const,
1306
+ gated: true as const,
1307
+ head: mf.headRefOid.slice(0, 8),
1308
+ // ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
1309
+ // sender alone; `attribution` says whether that is a seat or the account.
1310
+ by: {
1311
+ from: a.gater,
1312
+ sha: a.by.head.slice(0, 8),
1313
+ channel: a.by.channel ?? "bus",
1314
+ attribution: a.attribution,
1315
+ ...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
1316
+ ...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
1317
+ },
1318
+ recordedAfterMerge: late,
1319
+ verified: a.verified,
1320
+ ...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
1321
+ };
1322
+ }
1323
+ return {
1324
+ checked: true as const,
1325
+ gated: false as const,
1326
+ head: mf.headRefOid.slice(0, 8),
1327
+ reason: a.reason,
1328
+ gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1329
+ note:
1330
+ `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
1331
+ `The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
1332
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1333
+ };
1334
+ })();
1335
+
1037
1336
  return {
1038
1337
  ok: true as const,
1039
1338
  project: args.project,
1040
1339
  pr: `#${n}`,
1041
1340
  comparedAgainst: ref,
1042
1341
  landedIn: landedIn.slice(0, 8),
1342
+ gatedHead,
1043
1343
  // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
1044
1344
  // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
1045
1345
  // fixed, so a foreign row cannot pass silently in either direction.
@@ -1069,6 +1369,11 @@ export async function landTool(args: {
1069
1369
  candidates: target
1070
1370
  ? undefined
1071
1371
  : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
1372
+ // ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
1373
+ siblings,
1374
+ ...(siblings.length
1375
+ ? { 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.` }
1376
+ : {}),
1072
1377
  ...(target || !candidates.length
1073
1378
  ? {}
1074
1379
  : {
@@ -1151,13 +1456,17 @@ export type PrFacts = {
1151
1456
  state: string;
1152
1457
  mergeable: string;
1153
1458
  checks: unknown[];
1459
+ /** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
1460
+ headRefOid?: string;
1154
1461
  /** Unified diff of the PR, for the ticked-box audit. */
1155
1462
  diff?: string;
1156
1463
  /** Everything that will survive the merge as a citation: title + commit subjects + body. */
1157
1464
  citationText?: string;
1465
+ /** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */
1466
+ comments?: PrComment[];
1158
1467
  };
1159
1468
  const ghFacts = (repo: string, n: string): PrFacts => {
1160
- const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1469
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
1161
1470
  cwd: repo,
1162
1471
  encoding: "utf8",
1163
1472
  stdio: ["ignore", "pipe", "ignore"],
@@ -1174,13 +1483,15 @@ const ghFacts = (repo: string, n: string): PrFacts => {
1174
1483
  // history: the PR title (which becomes the squash subject) and the commit
1175
1484
  // subjects. The body is included because a reviewer reads it, but a claim
1176
1485
  // that lives only in a comment thread is not a record.
1177
- const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}") as Record<string, unknown>;
1486
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record<string, unknown>;
1178
1487
  const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
1179
1488
  .map((c) => c.messageHeadline ?? "")
1180
1489
  .join("\n");
1181
1490
  return {
1182
1491
  state: String(j.state ?? ""),
1183
1492
  mergeable: String(j.mergeable ?? ""),
1493
+ headRefOid: String(j.headRefOid ?? ""),
1494
+ comments: ((meta.comments as { body?: string; createdAt?: string; author?: { login?: string } }[] | undefined) ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
1184
1495
  checks: (j.statusCheckRollup as unknown[]) ?? [],
1185
1496
  diff: gh(["pr", "diff", n]),
1186
1497
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1206,10 +1517,43 @@ const ghMerge: DoMerge = (repo, n, method) => {
1206
1517
  });
1207
1518
  };
1208
1519
 
1520
+ /**
1521
+ * The room log, injected for the same reason `doMerge` is: a test must be able to
1522
+ * drive every refusal branch without this host's ~/agent-coord.
1523
+ *
1524
+ * Returns null when the log cannot be read — distinct from "" (read, and empty),
1525
+ * because "no verdicts recorded" and "could not look" are different answers and
1526
+ * only one of them is safe to merge on.
1527
+ */
1528
+ export type ReadVerdictLog = (project: string) => string | null;
1529
+ /**
1530
+ * ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
1531
+ * verdict recorded in another room still counts for this head. The project
1532
+ * room is read first so a missing bus is still a null (unknown), never an
1533
+ * empty string (nothing).
1534
+ */
1535
+ const roomLog: ReadVerdictLog = (project) => {
1536
+ try {
1537
+ const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1538
+ let others = "";
1539
+ try {
1540
+ const dir = path.join(ROOT, "rooms");
1541
+ others = readdirSync(dir)
1542
+ .filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
1543
+ .map((f) => readFileSync(path.join(dir, f), "utf8"))
1544
+ .join("\n");
1545
+ } catch { /* other rooms are optional */ }
1546
+ return others ? `${own}\n${others}` : own;
1547
+ } catch {
1548
+ return null;
1549
+ }
1550
+ };
1551
+
1209
1552
  export async function mergeTool(
1210
1553
  args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
1211
1554
  facts: (repo: string, n: string) => PrFacts = ghFacts,
1212
1555
  doMerge: DoMerge = ghMerge,
1556
+ readVerdictLog: ReadVerdictLog = roomLog,
1213
1557
  ) {
1214
1558
  const repo = args.repo ?? process.cwd();
1215
1559
  const n = prNumber(args.pr);
@@ -1318,8 +1662,56 @@ export async function mergeTool(
1318
1662
  if (f.mergeable === "CONFLICTING")
1319
1663
  return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
1320
1664
 
1665
+ /*
1666
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
1667
+ *
1668
+ * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
1669
+ * and took `1d6deab`, because the author force-pushed in between. Checks were
1670
+ * green on BOTH heads, so every refusal above passed honestly. Nothing asked
1671
+ * the only question that mattered: is the head in front of me the head the
1672
+ * verdict named?
1673
+ *
1674
+ * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
1675
+ * only report afterwards. It sits before the dry-run return on purpose: a dry
1676
+ * run must say it would refuse, or the preview disagrees with the act.
1677
+ */
1678
+ const head = f.headRefOid ?? "";
1679
+ if (!head) {
1680
+ return {
1681
+ ok: false as const,
1682
+ error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
1683
+ verdict,
1684
+ };
1685
+ }
1686
+ const log = readVerdictLog(args.project);
1687
+ if (log === null) {
1688
+ return {
1689
+ ok: false as const,
1690
+ error:
1691
+ `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
1692
+ `was ever gated is UNKNOWN, and unknown is not gated.`,
1693
+ verdict,
1694
+ };
1695
+ }
1696
+ const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
1697
+ // ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
1698
+ const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null);
1699
+ if (!gate.gated) {
1700
+ const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1701
+ return {
1702
+ ok: false as const,
1703
+ error:
1704
+ `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
1705
+ (named ? `Gated instead: ${named}. ` : "") +
1706
+ `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
1707
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1708
+ verdict,
1709
+ gatedHead: { head, gated: false as const, reason: gate.reason },
1710
+ };
1711
+ }
1712
+
1321
1713
  if (!args.write)
1322
- return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
1714
+ return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` };
1323
1715
 
1324
1716
  doMerge(repo, n, args.method ?? "squash");
1325
1717
  return { ok: true as const, merged: true as const, verdict };