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,13 +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
- import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
18
+ import { treeProvenance } from "./tree-provenance.js";
19
+ import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, } from "@davidbalzan/groundwork-seam";
19
20
  import { ensureWorktreeTool } from "./worktrees.js";
21
+ import { ROOT } from "../store.js";
22
+ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
20
23
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
21
- import { haltState, isInFlightStatus } from "./stall.js";
24
+ import { haltState } from "./stall.js";
22
25
  import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
23
26
  const QUEUE_DOC = "docs/QUEUE.md";
24
27
  const DONE_DOC = "docs/DONE.md";
@@ -249,18 +252,60 @@ export function noDownstream(items) {
249
252
  function rowSubjectIds(row) {
250
253
  return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
251
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
+ }
252
279
  /**
253
- * 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.
254
288
  *
255
- * Two conditions, and both are needed — measured, not reasoned:
256
- * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
257
- * since #219 rather than a second glyph list). `⏸ Parked`,
258
- * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
259
- * `🔻 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:
260
299
  * SUBJECT the row's Stream cell names the item — by recorded id, or by the
261
300
  * text prefix `claim` has always written there. A mention in a Last
262
301
  * note is one lane REFERRING to another item — the aide's live row
263
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.
264
309
  *
265
310
  * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
266
311
  * would have shipped a transitional hole in its own negative control: every row
@@ -271,16 +316,24 @@ function rowSubjectIds(row) {
271
316
  * here carry the id (see `claim`), so the prefix arm is the compatibility half
272
317
  * rather than the mechanism, and it is deliberately still SUBJECT-only.
273
318
  */
274
- function boardHoldsItem(rows, id, text) {
319
+ function boardHoldOf(rows, id, text) {
275
320
  const key = keyOf({ text }).replace(/\s+/g, " ").trim();
276
- return rows.some((r) => {
277
- if (!isInFlightStatus(r.status))
278
- return false;
279
- if (rowSubjectIds(r).includes(id))
280
- return true;
321
+ for (const r of rows) {
281
322
  const stream = String(r.stream).replace(/\s+/g, " ").trim();
282
- return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
283
- });
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;
284
337
  }
285
338
  /**
286
339
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
@@ -308,7 +361,7 @@ function boardHoldsItem(rows, id, text) {
308
361
  * false positives, because open rows cite refs as EVIDENCE.
309
362
  *
310
363
  * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
311
- * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
364
+ * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
312
365
  * delivery from prose: it requires the entry to carry the item's own
313
366
  * deterministically composed text, `summarize(item.text)`, which a VERB writes.
314
367
  * A citation cannot accidentally satisfy it.
@@ -351,9 +404,16 @@ export async function nextUnblockedTool(args) {
351
404
  };
352
405
  }
353
406
  const repo = args.repo ?? process.cwd();
407
+ // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
408
+ // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
409
+ // a confidently wrong routing decision in both directions inside ten minutes —
410
+ // a row ruled un-claimable from a stale file, and a worker told to hold on a
411
+ // row that was already split. The answer was well-formed and said nothing
412
+ // about its source, which is what made it invisible.
413
+ const tree = treeProvenance(repo);
354
414
  const q = readDoc(repo, QUEUE_DOC);
355
415
  if (!q)
356
- return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'` };
416
+ return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
357
417
  const items = queueItemsOf(q.doc);
358
418
  // The seam's own count of unticked rows — the number every axis must add back
359
419
  // up to. Taken BEFORE any exclusion so it cannot inherit one.
@@ -397,20 +457,34 @@ export async function nextUnblockedTool(args) {
397
457
  if (i.done)
398
458
  continue; // already off `open` by the checkbox; not an exclusion this axis owns
399
459
  // Board first, and the order is load-bearing for the REPORT rather than the
400
- // routing: both causes exclude, but a live 🚧 row is a different remedy
401
- // (wait, or ask its owner) from a landed delivery (close the row).
402
- if (boardHoldsItem(boardRows, i.id, String(i.text)))
403
- 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 });
404
466
  else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
405
- deliveredBy.set(i.id, "done");
467
+ deliveredBy.set(i.id, { reason: "done" });
406
468
  }
407
469
  const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
408
470
  // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
409
471
  // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
410
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.
411
480
  const delivered = items
412
481
  .filter((i) => !i.done && deliveredBy.has(i.id))
413
- .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
+ });
414
488
  // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
415
489
  // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
416
490
  // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
@@ -500,6 +574,10 @@ export async function nextUnblockedTool(args) {
500
574
  // the severity finding one file over. Say the axis is uninformative instead.
501
575
  const undiscriminating = silent.length === open.length && open.length > 1;
502
576
  return {
577
+ // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
578
+ // routing answer whose tree is unnamed is the defect this row exists for.
579
+ tree,
580
+ ...(tree.warning ? { staleWarning: tree.warning } : {}),
503
581
  ok: true,
504
582
  project: args.project,
505
583
  open: open.length,
@@ -541,7 +619,7 @@ export async function nextUnblockedTool(args) {
541
619
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
542
620
  ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
543
621
  ...delivered.map((d) => d.reason === "board"
544
- ? `⏭ 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})`
545
623
  : `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
546
624
  ],
547
625
  // A SEPARATE AXIS, deliberately. See noDownstream().
@@ -608,6 +686,13 @@ export const claimSchema = {
608
686
  base: z.string().optional(),
609
687
  task: z.string().optional(),
610
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(),
611
696
  };
612
697
  export async function claimTool(args) {
613
698
  const halt = haltState();
@@ -646,6 +731,25 @@ export async function claimTool(args) {
646
731
  // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
647
732
  // item to an agent with nowhere isolated to work is the shared-checkout failure
648
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
+ }
649
753
  const wt = await ensureWorktreeTool({
650
754
  agentId: args.agentId,
651
755
  repo,
@@ -776,7 +880,42 @@ export const landSchema = {
776
880
  /** One-line result, written after the citation on the closed item's line. */
777
881
  result: z.string().optional(),
778
882
  };
779
- export async function landTool(args) {
883
+ const ghMergeFacts = (repo, n) => {
884
+ try {
885
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
886
+ cwd: repo,
887
+ encoding: "utf8",
888
+ stdio: ["ignore", "pipe", "ignore"],
889
+ maxBuffer: 64 * 1024 * 1024,
890
+ });
891
+ const j = JSON.parse(out);
892
+ if (!j.headRefOid || !j.mergedAt)
893
+ return null;
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
+ };
899
+ }
900
+ catch {
901
+ return null;
902
+ }
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
+ }
918
+ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
780
919
  const repo = args.repo ?? process.cwd();
781
920
  const base = args.base ?? "main";
782
921
  const n = prNumber(args.pr);
@@ -831,6 +970,17 @@ export async function landTool(args) {
831
970
  // `queueItemId` it closes nothing and reports the candidates for the caller to
832
971
  // pick, saying so.
833
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)) }));
834
984
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
835
985
  if (args.queueItemId && !target)
836
986
  return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
@@ -869,7 +1019,35 @@ export async function landTool(args) {
869
1019
  queueChanged = true;
870
1020
  const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
871
1021
  const contextRepo = originRepoOf(repo);
872
- 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 }));
873
1051
  if (!alreadyCited) {
874
1052
  citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
875
1053
  target.text = `${target.text}${citationAppended}`;
@@ -887,7 +1065,40 @@ export async function landTool(args) {
887
1065
  // as UNDER-QUALIFIED rather than silently written.
888
1066
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
889
1067
  const summary = originalText !== null ? summarize(originalText) : null;
890
- const doneLine = already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
1068
+ /*
1069
+ * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
1070
+ * — `⟨q-d046204d⟩`.
1071
+ *
1072
+ * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
1073
+ * from these entries: IT WAS CUT, because in the row it sits later than the
1074
+ * truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
1075
+ * that names no row — and `closingRefs` needs BOTH halves, so the row reads
1076
+ * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
1077
+ *
1078
+ * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
1079
+ * out of the truncated region BY CONSTRUCTION, so it survives any headline length
1080
+ * — where raising `max` only moves the cliff. The row is explicit that "write
1081
+ * longer headlines" is not an acceptable fix, and it is right: a length that is
1082
+ * enough today is a truncation tomorrow.
1083
+ *
1084
+ * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
1085
+ * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
1086
+ * <summary>` and one written as `<summary>` agree". So the composed-summary arm
1087
+ * keeps matching and this needed no change there.
1088
+ *
1089
+ * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
1090
+ * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
1091
+ * in an entry — a reader-side guess that produced eleven false positives, because
1092
+ * this repo's canon requires entries to cite item ids for other reasons. This
1093
+ * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
1094
+ * infers nothing, and re-reading a sigil as "delivered" is still wrong.
1095
+ *
1096
+ * The id is the ITEM's, taken from the record rather than re-derived: `target` is
1097
+ * non-null whenever `summary` is, since the summary comes from its text.
1098
+ */
1099
+ const doneLine = already || !summary || !target
1100
+ ? null
1101
+ : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
891
1102
  // APPEND TO DONE.md — the half this verb exists for.
892
1103
  //
893
1104
  // The first `write:true` run closed the queue item and wrote NOTHING to
@@ -978,12 +1189,76 @@ export async function landTool(args) {
978
1189
  events = { emitted: [ev], deliveries, refused: [] };
979
1190
  }
980
1191
  }
1192
+ /*
1193
+ * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
1194
+ *
1195
+ * `merge` refuses this BEFORE the fact and is the control that prevents. This
1196
+ * one runs after, and its job is different: it is how the fleet LEARNS a
1197
+ * crossing happened anyway — through a merge done by hand, by `gh` directly,
1198
+ * or by a seat that never called the verb.
1199
+ *
1200
+ * It REPORTS and never refuses. A closure is not the right place to litigate a
1201
+ * merge that already happened: the verdict is QA's artefact, the merge may have
1202
+ * been someone else's, and blocking the record would leave the work landed and
1203
+ * unrecorded — strictly worse than landed and recorded with a flag on it.
1204
+ *
1205
+ * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
1206
+ */
1207
+ const gatedHead = (() => {
1208
+ const mf = readMergeFacts(repo, n);
1209
+ if (!mf)
1210
+ return { checked: false, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
1211
+ const log = readVerdictLog(args.project);
1212
+ if (log === null)
1213
+ return { checked: false, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
1214
+ const at = Date.parse(mf.mergedAt);
1215
+ if (!Number.isFinite(at))
1216
+ return { checked: false, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
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);
1223
+ if (a.gated) {
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
+ };
1243
+ }
1244
+ return {
1245
+ checked: true,
1246
+ gated: false,
1247
+ head: mf.headRefOid.slice(0, 8),
1248
+ reason: a.reason,
1249
+ gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1250
+ note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
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.` +
1252
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1253
+ };
1254
+ })();
981
1255
  return {
982
1256
  ok: true,
983
1257
  project: args.project,
984
1258
  pr: `#${n}`,
985
1259
  comparedAgainst: ref,
986
1260
  landedIn: landedIn.slice(0, 8),
1261
+ gatedHead,
987
1262
  // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
988
1263
  // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
989
1264
  // fixed, so a foreign row cannot pass silently in either direction.
@@ -1012,6 +1287,11 @@ export async function landTool(args) {
1012
1287
  candidates: target
1013
1288
  ? undefined
1014
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
+ : {}),
1015
1295
  ...(target || !candidates.length
1016
1296
  ? {}
1017
1297
  : {
@@ -1063,7 +1343,7 @@ export const mergeSchema = {
1063
1343
  write: z.boolean().optional(),
1064
1344
  };
1065
1345
  const ghFacts = (repo, n) => {
1066
- const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1346
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
1067
1347
  cwd: repo,
1068
1348
  encoding: "utf8",
1069
1349
  stdio: ["ignore", "pipe", "ignore"],
@@ -1081,13 +1361,15 @@ const ghFacts = (repo, n) => {
1081
1361
  // history: the PR title (which becomes the squash subject) and the commit
1082
1362
  // subjects. The body is included because a reviewer reads it, but a claim
1083
1363
  // that lives only in a comment thread is not a record.
1084
- 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"]) || "{}");
1085
1365
  const subjects = (meta.commits ?? [])
1086
1366
  .map((c) => c.messageHeadline ?? "")
1087
1367
  .join("\n");
1088
1368
  return {
1089
1369
  state: String(j.state ?? ""),
1090
1370
  mergeable: String(j.mergeable ?? ""),
1371
+ headRefOid: String(j.headRefOid ?? ""),
1372
+ comments: (meta.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
1091
1373
  checks: j.statusCheckRollup ?? [],
1092
1374
  diff: gh(["pr", "diff", n]),
1093
1375
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1100,7 +1382,31 @@ const ghMerge = (repo, n, method) => {
1100
1382
  stdio: ["ignore", "pipe", "ignore"],
1101
1383
  });
1102
1384
  };
1103
- export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
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
+ */
1391
+ const roomLog = (project) => {
1392
+ try {
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;
1404
+ }
1405
+ catch {
1406
+ return null;
1407
+ }
1408
+ };
1409
+ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVerdictLog = roomLog) {
1104
1410
  const repo = args.repo ?? process.cwd();
1105
1411
  const n = prNumber(args.pr);
1106
1412
  if (!n)
@@ -1197,8 +1503,53 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
1197
1503
  }
1198
1504
  if (f.mergeable === "CONFLICTING")
1199
1505
  return { ok: false, error: `#${n} is CONFLICTING with its base.`, verdict };
1506
+ /*
1507
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
1508
+ *
1509
+ * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
1510
+ * and took `1d6deab`, because the author force-pushed in between. Checks were
1511
+ * green on BOTH heads, so every refusal above passed honestly. Nothing asked
1512
+ * the only question that mattered: is the head in front of me the head the
1513
+ * verdict named?
1514
+ *
1515
+ * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
1516
+ * only report afterwards. It sits before the dry-run return on purpose: a dry
1517
+ * run must say it would refuse, or the preview disagrees with the act.
1518
+ */
1519
+ const head = f.headRefOid ?? "";
1520
+ if (!head) {
1521
+ return {
1522
+ ok: false,
1523
+ error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
1524
+ verdict,
1525
+ };
1526
+ }
1527
+ const log = readVerdictLog(args.project);
1528
+ if (log === null) {
1529
+ return {
1530
+ ok: false,
1531
+ error: `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
1532
+ `was ever gated is UNKNOWN, and unknown is not gated.`,
1533
+ verdict,
1534
+ };
1535
+ }
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);
1539
+ if (!gate.gated) {
1540
+ const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1541
+ return {
1542
+ ok: false,
1543
+ error: `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
1544
+ (named ? `Gated instead: ${named}. ` : "") +
1545
+ `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
1546
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1547
+ verdict,
1548
+ gatedHead: { head, gated: false, reason: gate.reason },
1549
+ };
1550
+ }
1200
1551
  if (!args.write)
1201
- return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. 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.` };
1202
1553
  doMerge(repo, n, args.method ?? "squash");
1203
1554
  return { ok: true, merged: true, verdict };
1204
1555
  }