agent-coord-mcp 0.26.21 → 0.26.23

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 (71) hide show
  1. package/dist/capabilities.js +270 -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 +40 -4
  22. package/dist/tools/events.js.map +1 -1
  23. package/dist/tools/herdr-delivery.js +99 -0
  24. package/dist/tools/herdr-delivery.js.map +1 -0
  25. package/dist/tools/messaging.js +72 -6
  26. package/dist/tools/messaging.js.map +1 -1
  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 +310 -44
  30. package/dist/tools/records.js.map +1 -1
  31. package/dist/tools/registry.js +67 -3
  32. package/dist/tools/registry.js.map +1 -1
  33. package/dist/tools/seat-build.js +182 -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 +71 -3
  39. package/dist/tools/transport.js.map +1 -1
  40. package/dist/tools/worktrees.js +14 -0
  41. package/dist/tools/worktrees.js.map +1 -1
  42. package/dist/transports/herdr.js +297 -0
  43. package/dist/transports/herdr.js.map +1 -0
  44. package/dist/transports/index.js +10 -4
  45. package/dist/transports/index.js.map +1 -1
  46. package/package.json +1 -1
  47. package/scripts/coord-attention-clock.mjs +2 -0
  48. package/scripts/coord-stall-clock.mjs +52 -11
  49. package/src/capabilities.ts +284 -2
  50. package/src/closing-line.ts +85 -0
  51. package/src/commit-cite.ts +58 -0
  52. package/src/gated-head.ts +128 -26
  53. package/src/server-spread.ts +233 -0
  54. package/src/server.ts +2 -2
  55. package/src/store.ts +32 -0
  56. package/src/tools/away.ts +82 -9
  57. package/src/tools/board-ref.ts +70 -3
  58. package/src/tools/event-kinds.ts +19 -2
  59. package/src/tools/events.ts +42 -4
  60. package/src/tools/herdr-delivery.ts +87 -0
  61. package/src/tools/messaging.ts +71 -6
  62. package/src/tools/record-events.ts +78 -5
  63. package/src/tools/records.ts +316 -44
  64. package/src/tools/registry.ts +68 -4
  65. package/src/tools/seat-build.ts +201 -0
  66. package/src/tools/shared.ts +22 -0
  67. package/src/tools/stall.ts +1266 -23
  68. package/src/tools/transport.ts +69 -2
  69. package/src/tools/worktrees.ts +13 -0
  70. package/src/transports/herdr.ts +311 -0
  71. package/src/transports/index.ts +10 -4
@@ -12,7 +12,7 @@
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
 
@@ -30,17 +30,22 @@ import {
30
30
  sweepTagOf,
31
31
  awaitingOf,
32
32
  refsIn,
33
+ closingCitation,
33
34
  refsMatch,
34
35
  workstreamsV1RowsOf,
36
+ workStateOf,
37
+ declarationOf,
38
+ type WorkState,
35
39
  type WorkstreamsV1Row,
36
40
  type DoneEntry,
37
41
  } from "@davidbalzan/groundwork-seam";
38
42
  import { ensureWorktreeTool } from "./worktrees.js";
39
43
  import { ROOT } from "../store.js";
40
- import { verdictsFor, gatedAt } from "../gated-head.js";
44
+ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
41
45
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
42
- import { haltState, isInFlightStatus } from "./stall.js";
46
+ import { haltState } from "./stall.js";
43
47
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
48
+ import { prRefsIn } from "./record-events.js";
44
49
 
45
50
  const QUEUE_DOC = "docs/QUEUE.md";
46
51
  const DONE_DOC = "docs/DONE.md";
@@ -160,10 +165,58 @@ const git = (repo: string, args: string[]): string =>
160
165
  execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
161
166
 
162
167
  /** `#123` / `owner/repo#123` → "123". */
168
+
163
169
  function prNumber(pr: string): string | null {
164
170
  const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
165
171
  return m ? (m[1] as string) : null;
166
172
  }
173
+ /** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */
174
+ function prNumbersOf(pr: string): string[] {
175
+ const out: string[] = [];
176
+ for (const m of String(pr).matchAll(/#(\d+)\b/g)) if (!out.includes(m[1]!)) out.push(m[1]!);
177
+ if (!out.length) { const one = prNumber(pr); if (one) out.push(one); }
178
+ return out;
179
+ }
180
+ /*
181
+ * ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed
182
+ * #245's squash 777 commits back on an 1806-commit main and the verb then
183
+ * asserted "#245 is not on origin/main" as a fact — a truncated search reported
184
+ * as an absence, at the verb that writes the delivery record, and worded with a
185
+ * mechanism (target tip vs merge base) that had nothing to do with it. At the
186
+ * fleet's speed (~200 record commits a day) 400 is about two days, so it fires
187
+ * precisely on the oldest rows, the ones closed late.
188
+ *
189
+ * Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged
190
+ * BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use:
191
+ * the landing is the commit whose subject ENDS with the forge's `(#N)` marker.
192
+ * A bare `#N` anywhere is a mention, never a landing — qa measured the first
193
+ * cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322
194
+ * squash-merged PRs resolved to their CLOSURE or board commit ("docs(record):
195
+ * close … by content on #245"), which carry the bare number in the subject and
196
+ * sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a
197
+ * hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's
198
+ * squash and would otherwise be taken for it. The one PR of 323 on this main
199
+ * that landed as a merge commit (#108, "Merge pull request #108 from …") is the
200
+ * second identity form — the forge's own, not a mention — and is the last
201
+ * resort, consulted only when no trailing form exists. A miss reports what was
202
+ * searched — every subject on the ref — never a mechanism it did not test.
203
+ */
204
+ export function landingCommitOf(repo: string, ref: string, n: string): { sha: string | null; searched: number } {
205
+ const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0;
206
+ // git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below.
207
+ const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]);
208
+ const trailing = new RegExp(`\\(#${n}\\)\\s*$`);
209
+ const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`);
210
+ let landing: string | null = null;
211
+ let merge: string | null = null;
212
+ for (const line of out.split("\n")) {
213
+ const [sha, subject = ""] = line.split("\x00");
214
+ if (!sha) continue;
215
+ if (trailing.test(subject)) landing = sha; // newest first: the last hit is the OLDEST
216
+ else if (!merge && mergeForm.test(subject)) merge = sha;
217
+ }
218
+ return { sha: landing ?? merge, searched };
219
+ }
167
220
 
168
221
  /**
169
222
  * The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
@@ -287,18 +340,71 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
287
340
  return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
288
341
  }
289
342
 
343
+ /** What a board row says about an item it names as its SUBJECT. */
344
+ export type BoardHold = {
345
+ /** The state the seam read off the row's Status cell. */
346
+ state: WorkState;
347
+ /** The routing reason: the state, except `parked` is split into held · queued · parked. */
348
+ why: string;
349
+ /** The declaration the reason was read from — never the commentary after it. */
350
+ status: string;
351
+ /** Whether the row's Branch · Worktree cell names anything at all. */
352
+ builtWork: boolean;
353
+ branch: string;
354
+ };
355
+
356
+ /*
357
+ * ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
358
+ * MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
359
+ * `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
360
+ * green in an open PR and the other has nothing behind it. Reporting them under
361
+ * one word re-creates the defect with a wider net: a reader told "parked" for
362
+ * `#292`'s row would go and build it.
363
+ *
364
+ * Two INDEPENDENT facts, deliberately not one: the WORD comes from the
365
+ * declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
366
+ * Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
367
+ * and that disagreement is worth seeing rather than resolving.
368
+ */
369
+ const STATUS_DECORATION = /^[\s*⭐]+/u;
370
+ function holdReasonOf(state: WorkState, status: string): string {
371
+ if (state !== "parked") return state;
372
+ const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
373
+ if (/\bheld\b/i.test(decl)) return "held";
374
+ if (/\bqueued\b/i.test(decl)) return "queued";
375
+ return "parked";
376
+ }
377
+
290
378
  /**
291
- * Does the board show this item as IN SOMEBODY'S HANDS?
379
+ * What does the board say about this item, if it is the SUBJECT of any row?
380
+ *
381
+ * ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
382
+ * (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
383
+ * only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
384
+ * its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
385
+ * Measured on the live board 2026-09-14: seven `⏸` rows, every one the
386
+ * coordinator's most careful bookkeeping, every one invisible to this join.
292
387
  *
293
- * Two conditions, and both are needed — measured, not reasoned:
294
- * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
295
- * since #219 rather than a second glyph list). `⏸ Parked`,
296
- * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
297
- * `🔻 Orphaned` describe work nobody holds, so they do not silence.
388
+ * THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
389
+ * fix made a `⏸ Parked` row NOT silence its item, because the alternative
390
+ * then was a SILENT drop with no axis. The drop is no longer silent: the item
391
+ * lands on `delivered` with the row's state as its `why`, and an `AWAITS`
392
+ * item still reaches `awaitingDecision`, which is gathered over every open
393
+ * item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
394
+ * must not make its item VANISH — still holds; what changes is that a warning
395
+ * row now also stops the item being HANDED OUT, which is what a warning is for.
396
+ *
397
+ * Conditions:
298
398
  * SUBJECT the row's Stream cell names the item — by recorded id, or by the
299
399
  * text prefix `claim` has always written there. A mention in a Last
300
400
  * note is one lane REFERRING to another item — the aide's live row
301
401
  * does exactly this — and a status-only rule would still hide it.
402
+ * STATE whatever the seam reads: in-progress, in-review, parked (split
403
+ * into held · queued · parked, see `holdReasonOf`), blocked,
404
+ * orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
405
+ * excludes: the row is on the board with this item as its subject,
406
+ * and offering it anyway is the silent path this exists to close.
407
+ * A `✅ Done` row excludes too — that item is offerable to nobody.
302
408
  *
303
409
  * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
304
410
  * would have shipped a transitional hole in its own negative control: every row
@@ -309,14 +415,24 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
309
415
  * here carry the id (see `claim`), so the prefix arm is the compatibility half
310
416
  * rather than the mechanism, and it is deliberately still SUBJECT-only.
311
417
  */
312
- function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boolean {
418
+ function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null {
313
419
  const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
314
- return rows.some((r) => {
315
- if (!isInFlightStatus(r.status)) return false;
316
- if (rowSubjectIds(r).includes(id)) return true;
420
+ for (const r of rows) {
317
421
  const stream = String(r.stream).replace(/\s+/g, " ").trim();
318
- return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
319
- });
422
+ const subject =
423
+ rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
424
+ if (!subject) continue;
425
+ const state = workStateOf(r.status);
426
+ const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
427
+ return {
428
+ state,
429
+ why: holdReasonOf(state, r.status),
430
+ status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
431
+ builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
432
+ branch,
433
+ };
434
+ }
435
+ return null;
320
436
  }
321
437
 
322
438
  /**
@@ -345,7 +461,7 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
345
461
  * false positives, because open rows cite refs as EVIDENCE.
346
462
  *
347
463
  * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
348
- * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
464
+ * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
349
465
  * delivery from prose: it requires the entry to carry the item's own
350
466
  * deterministically composed text, `summarize(item.text)`, which a VERB writes.
351
467
  * A citation cannot accidentally satisfy it.
@@ -434,7 +550,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
434
550
  //
435
551
  // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
436
552
  // a row is excluded BY a named cause, and the cause is what gets reported.
437
- const deliveredBy = new Map<string, "board" | "done">();
553
+ const deliveredBy = new Map<string, { reason: "board"; hold: BoardHold } | { reason: "done" }>();
438
554
  const doneDoc = readDoc(repo, DONE_DOC);
439
555
  const boardDoc = readDoc(repo, BOARD_DOC);
440
556
  const boardText = boardDoc?.text ?? "";
@@ -443,10 +559,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
443
559
  for (const i of items) {
444
560
  if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
445
561
  // Board first, and the order is load-bearing for the REPORT rather than the
446
- // routing: both causes exclude, but a live 🚧 row is a different remedy
447
- // (wait, or ask its owner) from a landed delivery (close the row).
448
- if (boardHoldsItem(boardRows, i.id, String(i.text))) deliveredBy.set(i.id, "board");
449
- else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, "done");
562
+ // routing: both causes exclude, but a board row is a different remedy
563
+ // (wait for the merge · ask its owner · ask the human it awaits, by its
564
+ // `why`) from a landed delivery (close the row).
565
+ const hold = boardHoldOf(boardRows, i.id, String(i.text));
566
+ if (hold) deliveredBy.set(i.id, { reason: "board", hold });
567
+ else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" });
450
568
  }
451
569
 
452
570
  const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
@@ -454,9 +572,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
454
572
  // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
455
573
  // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
456
574
  // reported silently is the same defect as an incorrect one.
575
+ //
576
+ // ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
577
+ // the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
578
+ // and the two want opposite things from the reader — leave the first alone,
579
+ // MERGE the second. `why` is the seam's state (parked split into held ·
580
+ // queued · parked) and `builtWork` says whether the row names a branch at
581
+ // all, so "held with nothing behind it" is visible rather than resolved.
457
582
  const delivered = items
458
583
  .filter((i) => !i.done && deliveredBy.has(i.id))
459
- .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
584
+ .map((i) => {
585
+ const d = deliveredBy.get(i.id)!;
586
+ return d.reason === "board"
587
+ ? { 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 }
588
+ : { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" };
589
+ });
460
590
 
461
591
  // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
462
592
  // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
@@ -590,7 +720,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
590
720
  ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
591
721
  ...delivered.map((d) =>
592
722
  d.reason === "board"
593
- ? `⏭ not offered — already on the board (${d.id})`
723
+ ? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
594
724
  : `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
595
725
  ),
596
726
  ],
@@ -662,9 +792,16 @@ export const claimSchema = {
662
792
  base: z.string().optional(),
663
793
  task: z.string().optional(),
664
794
  write: z.boolean().optional(),
795
+ /**
796
+ * ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
797
+ * What the slice delivers instead (a ruling, a canon edit, a measurement).
798
+ * No worktree is cut; the board cell carries the statement in words, which
799
+ * the grammar and `stall_check` both read as DELIBERATE rather than missing.
800
+ */
801
+ noCodeDeliverable: z.string().min(1).optional(),
665
802
  };
666
803
 
667
- export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
804
+ export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) {
668
805
  const halt = haltState();
669
806
  if (halt.halted) {
670
807
  return {
@@ -698,6 +835,24 @@ export async function claimTool(args: { project: string; agentId: string; itemId
698
835
  // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
699
836
  // item to an agent with nowhere isolated to work is the shared-checkout failure
700
837
  // this pair exists to prevent.
838
+ // ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
839
+ // seats wrote this by hand in five spellings because the verb had no way to
840
+ // say it; `stall_check` reads the prose cell as deliberately branchless and
841
+ // keeps the row out of its population rather than calling it unmeasurable.
842
+ if (args.noCodeDeliverable) {
843
+ const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
844
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
845
+ let board: { action: string; path?: string } = { action: "reported" };
846
+ const b = readDoc(repo, BOARD_DOC);
847
+ if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
848
+ else if (args.write) {
849
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
850
+ if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
851
+ board = { action: next.action, path: BOARD_DOC };
852
+ }
853
+ 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 };
854
+ }
855
+
701
856
  const wt = await ensureWorktreeTool({
702
857
  agentId: args.agentId,
703
858
  repo,
@@ -841,23 +996,41 @@ export const landSchema = {
841
996
  * What the recording step needs to know about a merge: which head actually
842
997
  * landed, and when. Injected so the report is provable without the network.
843
998
  */
844
- export type MergeFacts = { headRefOid: string; mergedAt: string } | null;
999
+ export type PrComment = { body: string; createdAt?: string; author?: string };
1000
+ /** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */
1001
+ export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null;
845
1002
  export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
846
1003
  const ghMergeFacts: ReadMergeFacts = (repo, n) => {
847
1004
  try {
848
- const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
1005
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
849
1006
  cwd: repo,
850
1007
  encoding: "utf8",
851
1008
  stdio: ["ignore", "pipe", "ignore"],
1009
+ maxBuffer: 64 * 1024 * 1024,
852
1010
  });
853
- const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
1011
+ const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] };
854
1012
  if (!j.headRefOid || !j.mergedAt) return null;
855
- return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
1013
+ return {
1014
+ headRefOid: String(j.headRefOid),
1015
+ mergedAt: String(j.mergedAt),
1016
+ comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
1017
+ };
856
1018
  } catch {
857
1019
  return null;
858
1020
  }
859
1021
  };
860
1022
 
1023
+ /** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
1024
+ export function landingCitations(repo: string, n: string): string[] {
1025
+ let subjects = "";
1026
+ for (const base of ["origin/main", "main"]) {
1027
+ try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ }
1028
+ }
1029
+ const marker = `(#${n})`;
1030
+ const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
1031
+ return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
1032
+ }
1033
+
861
1034
  export async function landTool(
862
1035
  args: {
863
1036
  project: string;
@@ -890,19 +1063,31 @@ export async function landTool(
890
1063
  const ref = `origin/${base}`;
891
1064
  let landedIn: string | null = null;
892
1065
  let reason: string | null = null;
1066
+ // ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first.
1067
+ const numbers = prNumbersOf(args.pr);
1068
+ const landings: Record<string, string> = {};
1069
+ let searched = 0;
1070
+ const missing: string[] = [];
893
1071
  try {
894
- const subjects = git(repo, ["log", "-n", "400", "--format=%H %s", ref]);
895
- const hit = subjects.split("\n").find((l) => new RegExp(`\\(#${n}\\)|#${n}\\b`).test(l));
896
- landedIn = hit ? (hit.split(" ")[0] as string) : null;
1072
+ for (const num of numbers) {
1073
+ const found = landingCommitOf(repo, ref, num);
1074
+ searched = found.searched;
1075
+ if (found.sha) landings[num] = found.sha; else missing.push(num);
1076
+ }
1077
+ landedIn = landings[n] ?? null;
897
1078
  } catch (e) {
898
1079
  reason = `could not read ${ref} (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
899
1080
  }
900
1081
  if (reason) return { ok: false as const, error: reason };
901
- if (!landedIn) {
1082
+ if (!landedIn || missing.length) {
902
1083
  return {
903
1084
  ok: false as const,
904
- error: `#${n} is not on ${ref} — refusing to record it as landed. Compared against the TARGET TIP (${ref}), not a merge base: an item merged after this branch was cut is missing from the base too.`,
1085
+ error:
1086
+ `${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` +
1087
+ `${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` +
1088
+ `a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`,
905
1089
  comparedAgainst: ref,
1090
+ searched,
906
1091
  };
907
1092
  }
908
1093
 
@@ -926,6 +1111,17 @@ export async function landTool(
926
1111
  // `queueItemId` it closes nothing and reports the candidates for the caller to
927
1112
  // pick, saying so.
928
1113
  const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
1114
+ // ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
1115
+ // shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
1116
+ // (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
1117
+ // The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
1118
+ // every check reads, local, no network — and listed beside the #N candidates
1119
+ // at the one moment both facts are in hand. Closing nothing: a sibling is a
1120
+ // question for the coordinator, not a claim.
1121
+ const citedIds = landingCitations(repo, n);
1122
+ const siblings = items
1123
+ .filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
1124
+ .map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
929
1125
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
930
1126
  if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
931
1127
 
@@ -964,7 +1160,35 @@ export async function landTool(
964
1160
  queueChanged = true;
965
1161
  const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
966
1162
  const contextRepo = originRepoOf(repo);
967
- const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
1163
+ /*
1164
+ * ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
1165
+ *
1166
+ * This asked whether the PR number appears ANYWHERE in the row, so a row that
1167
+ * DISCUSSES its own closer read as already cited and `land` wrote no token.
1168
+ * The natural experiment that isolates it — three rows, one variable:
1169
+ *
1170
+ * ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
1171
+ * ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
1172
+ * ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
1173
+ *
1174
+ * ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
1175
+ * somebody else's PRs is cited normally; a row naming its own once is not. So the
1176
+ * better a row documents what closed it, the more certainly the closure goes
1177
+ * unwritten — and the ARTEFACT is what loses, silently.
1178
+ *
1179
+ * ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
1180
+ * ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
1181
+ * a line-1 prose mention and `doctor` falls back to the row. That was measured and
1182
+ * a patch was reverted rather than shipped. What nobody asked was what it does to
1183
+ * the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
1184
+ * makes a ref a citation, and that rule applies to the verb that WRITES one.
1185
+ *
1186
+ * ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
1187
+ * rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
1188
+ * must never happen.
1189
+ */
1190
+ const closingHere: ReturnType<typeof refsIn> = closingCitation(String(target.text))?.refs ?? [];
1191
+ const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
968
1192
  if (!alreadyCited) {
969
1193
  citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
970
1194
  target.text = `${target.text}${citationAppended}`;
@@ -1095,7 +1319,9 @@ export async function landTool(
1095
1319
  if (args.write && target) {
1096
1320
  const after = readDoc(repo, DONE_DOC);
1097
1321
  const recordText = after?.text ?? "";
1098
- const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
1322
+ // ⟨q-cbace757⟩ one event, every cited ref; each checked against the record on its own.
1323
+ const refs = prRefsIn(args.pr);
1324
+ const ev: RecordEvent = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) };
1099
1325
  const derived = eventIsDerived(recordText, ev);
1100
1326
  if (!derived.ok) {
1101
1327
  events.refused.push(derived.error);
@@ -1129,10 +1355,32 @@ export async function landTool(
1129
1355
  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.` };
1130
1356
  const at = Date.parse(mf.mergedAt);
1131
1357
  if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
1132
- const { verdicts, unparsed } = verdictsFor(log, n);
1133
- const a = gatedAt(verdicts, mf.headRefOid, at);
1358
+ const { verdicts: bus, unparsed } = verdictsFor(log, n);
1359
+ // ⟨q-5a93c2d7⟩ both channels, one predicate: the bus's typed records and
1360
+ // the PR page's typed lines. Gatedness is (head sha, typed verdict); the
1361
+ // merge time only DISCLOSES lateness, in words, beside the answer.
1362
+ const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])];
1363
+ const a = gatedBy(verdicts, mf.headRefOid, at);
1134
1364
  if (a.gated) {
1135
- return { checked: true as const, gated: true as const, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
1365
+ const late = (a.lateByMs ?? 0) > 0;
1366
+ return {
1367
+ checked: true as const,
1368
+ gated: true as const,
1369
+ head: mf.headRefOid.slice(0, 8),
1370
+ // ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
1371
+ // sender alone; `attribution` says whether that is a seat or the account.
1372
+ by: {
1373
+ from: a.gater,
1374
+ sha: a.by.head.slice(0, 8),
1375
+ channel: a.by.channel ?? "bus",
1376
+ attribution: a.attribution,
1377
+ ...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
1378
+ ...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
1379
+ },
1380
+ recordedAfterMerge: late,
1381
+ verified: a.verified,
1382
+ ...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
1383
+ };
1136
1384
  }
1137
1385
  return {
1138
1386
  checked: true as const,
@@ -1142,7 +1390,7 @@ export async function landTool(
1142
1390
  gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1143
1391
  note:
1144
1392
  `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
1145
- `The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
1393
+ `The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
1146
1394
  (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1147
1395
  };
1148
1396
  })();
@@ -1183,6 +1431,11 @@ export async function landTool(
1183
1431
  candidates: target
1184
1432
  ? undefined
1185
1433
  : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
1434
+ // ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
1435
+ siblings,
1436
+ ...(siblings.length
1437
+ ? { 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.` }
1438
+ : {}),
1186
1439
  ...(target || !candidates.length
1187
1440
  ? {}
1188
1441
  : {
@@ -1271,6 +1524,8 @@ export type PrFacts = {
1271
1524
  diff?: string;
1272
1525
  /** Everything that will survive the merge as a citation: title + commit subjects + body. */
1273
1526
  citationText?: string;
1527
+ /** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */
1528
+ comments?: PrComment[];
1274
1529
  };
1275
1530
  const ghFacts = (repo: string, n: string): PrFacts => {
1276
1531
  const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
@@ -1290,7 +1545,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
1290
1545
  // history: the PR title (which becomes the squash subject) and the commit
1291
1546
  // subjects. The body is included because a reviewer reads it, but a claim
1292
1547
  // that lives only in a comment thread is not a record.
1293
- const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}") as Record<string, unknown>;
1548
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record<string, unknown>;
1294
1549
  const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
1295
1550
  .map((c) => c.messageHeadline ?? "")
1296
1551
  .join("\n");
@@ -1298,6 +1553,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
1298
1553
  state: String(j.state ?? ""),
1299
1554
  mergeable: String(j.mergeable ?? ""),
1300
1555
  headRefOid: String(j.headRefOid ?? ""),
1556
+ 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 })),
1301
1557
  checks: (j.statusCheckRollup as unknown[]) ?? [],
1302
1558
  diff: gh(["pr", "diff", n]),
1303
1559
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1332,9 +1588,24 @@ const ghMerge: DoMerge = (repo, n, method) => {
1332
1588
  * only one of them is safe to merge on.
1333
1589
  */
1334
1590
  export type ReadVerdictLog = (project: string) => string | null;
1591
+ /**
1592
+ * ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
1593
+ * verdict recorded in another room still counts for this head. The project
1594
+ * room is read first so a missing bus is still a null (unknown), never an
1595
+ * empty string (nothing).
1596
+ */
1335
1597
  const roomLog: ReadVerdictLog = (project) => {
1336
1598
  try {
1337
- return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1599
+ const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1600
+ let others = "";
1601
+ try {
1602
+ const dir = path.join(ROOT, "rooms");
1603
+ others = readdirSync(dir)
1604
+ .filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
1605
+ .map((f) => readFileSync(path.join(dir, f), "utf8"))
1606
+ .join("\n");
1607
+ } catch { /* other rooms are optional */ }
1608
+ return others ? `${own}\n${others}` : own;
1338
1609
  } catch {
1339
1610
  return null;
1340
1611
  }
@@ -1484,8 +1755,9 @@ export async function mergeTool(
1484
1755
  verdict,
1485
1756
  };
1486
1757
  }
1487
- const { verdicts, unparsed } = verdictsFor(log, n);
1488
- const gate = gatedAt(verdicts, head, Date.now());
1758
+ const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
1759
+ // ⟨q-5a93c2d7⟩ the pre-merge question, same predicate, both channels.
1760
+ const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null);
1489
1761
  if (!gate.gated) {
1490
1762
  const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1491
1763
  return {
@@ -1501,7 +1773,7 @@ export async function mergeTool(
1501
1773
  }
1502
1774
 
1503
1775
  if (!args.write)
1504
- 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.by.from}. Pass write:true to apply.` };
1776
+ 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.` };
1505
1777
 
1506
1778
  doMerge(repo, n, args.method ?? "squash");
1507
1779
  return { ok: true as const, merged: true as const, verdict };