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,17 +12,18 @@
12
12
  * that drifts.
13
13
  */
14
14
  import { execFileSync } from "node:child_process";
15
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import { z } from "zod";
18
18
  import { treeProvenance } from "./tree-provenance.js";
19
- import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
19
+ import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, } from "@davidbalzan/groundwork-seam";
20
20
  import { ensureWorktreeTool } from "./worktrees.js";
21
21
  import { ROOT } from "../store.js";
22
- import { verdictsFor, gatedAt } from "../gated-head.js";
22
+ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
23
23
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
24
- import { haltState, isInFlightStatus } from "./stall.js";
24
+ import { haltState } from "./stall.js";
25
25
  import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
26
+ import { prRefsIn } from "./record-events.js";
26
27
  const QUEUE_DOC = "docs/QUEUE.md";
27
28
  const DONE_DOC = "docs/DONE.md";
28
29
  const BOARD_DOC = "docs/WORKSTREAMS.md";
@@ -133,6 +134,62 @@ function prNumber(pr) {
133
134
  const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
134
135
  return m ? m[1] : null;
135
136
  }
137
+ /** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */
138
+ function prNumbersOf(pr) {
139
+ const out = [];
140
+ for (const m of String(pr).matchAll(/#(\d+)\b/g))
141
+ if (!out.includes(m[1]))
142
+ out.push(m[1]);
143
+ if (!out.length) {
144
+ const one = prNumber(pr);
145
+ if (one)
146
+ out.push(one);
147
+ }
148
+ return out;
149
+ }
150
+ /*
151
+ * ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed
152
+ * #245's squash 777 commits back on an 1806-commit main and the verb then
153
+ * asserted "#245 is not on origin/main" as a fact — a truncated search reported
154
+ * as an absence, at the verb that writes the delivery record, and worded with a
155
+ * mechanism (target tip vs merge base) that had nothing to do with it. At the
156
+ * fleet's speed (~200 record commits a day) 400 is about two days, so it fires
157
+ * precisely on the oldest rows, the ones closed late.
158
+ *
159
+ * Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged
160
+ * BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use:
161
+ * the landing is the commit whose subject ENDS with the forge's `(#N)` marker.
162
+ * A bare `#N` anywhere is a mention, never a landing — qa measured the first
163
+ * cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322
164
+ * squash-merged PRs resolved to their CLOSURE or board commit ("docs(record):
165
+ * close … by content on #245"), which carry the bare number in the subject and
166
+ * sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a
167
+ * hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's
168
+ * squash and would otherwise be taken for it. The one PR of 323 on this main
169
+ * that landed as a merge commit (#108, "Merge pull request #108 from …") is the
170
+ * second identity form — the forge's own, not a mention — and is the last
171
+ * resort, consulted only when no trailing form exists. A miss reports what was
172
+ * searched — every subject on the ref — never a mechanism it did not test.
173
+ */
174
+ export function landingCommitOf(repo, ref, n) {
175
+ const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0;
176
+ // git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below.
177
+ const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]);
178
+ const trailing = new RegExp(`\\(#${n}\\)\\s*$`);
179
+ const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`);
180
+ let landing = null;
181
+ let merge = null;
182
+ for (const line of out.split("\n")) {
183
+ const [sha, subject = ""] = line.split("\x00");
184
+ if (!sha)
185
+ continue;
186
+ if (trailing.test(subject))
187
+ landing = sha; // newest first: the last hit is the OLDEST
188
+ else if (!merge && mergeForm.test(subject))
189
+ merge = sha;
190
+ }
191
+ return { sha: landing ?? merge, searched };
192
+ }
136
193
  /**
137
194
  * The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
138
195
  * the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
@@ -252,18 +309,60 @@ export function noDownstream(items) {
252
309
  function rowSubjectIds(row) {
253
310
  return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
254
311
  }
312
+ /*
313
+ * ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
314
+ * MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
315
+ * `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
316
+ * green in an open PR and the other has nothing behind it. Reporting them under
317
+ * one word re-creates the defect with a wider net: a reader told "parked" for
318
+ * `#292`'s row would go and build it.
319
+ *
320
+ * Two INDEPENDENT facts, deliberately not one: the WORD comes from the
321
+ * declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
322
+ * Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
323
+ * and that disagreement is worth seeing rather than resolving.
324
+ */
325
+ const STATUS_DECORATION = /^[\s*⭐]+/u;
326
+ function holdReasonOf(state, status) {
327
+ if (state !== "parked")
328
+ return state;
329
+ const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
330
+ if (/\bheld\b/i.test(decl))
331
+ return "held";
332
+ if (/\bqueued\b/i.test(decl))
333
+ return "queued";
334
+ return "parked";
335
+ }
255
336
  /**
256
- * Does the board show this item as IN SOMEBODY'S HANDS?
337
+ * What does the board say about this item, if it is the SUBJECT of any row?
257
338
  *
258
- * Two conditions, and both are needed measured, not reasoned:
259
- * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
260
- * since #219 rather than a second glyph list). `⏸ Parked`,
261
- * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
262
- * `🔻 Orphaned` describe work nobody holds, so they do not silence.
339
+ * ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
340
+ * (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` 🚧 and 🔍
341
+ * only so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
342
+ * its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
343
+ * Measured on the live board 2026-09-14: seven `⏸` rows, every one the
344
+ * coordinator's most careful bookkeeping, every one invisible to this join.
345
+ *
346
+ * ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
347
+ * fix made a `⏸ Parked` row NOT silence its item, because the alternative
348
+ * then was a SILENT drop with no axis. The drop is no longer silent: the item
349
+ * lands on `delivered` with the row's state as its `why`, and an `AWAITS`
350
+ * item still reaches `awaitingDecision`, which is gathered over every open
351
+ * item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
352
+ * must not make its item VANISH — still holds; what changes is that a warning
353
+ * row now also stops the item being HANDED OUT, which is what a warning is for.
354
+ *
355
+ * Conditions:
263
356
  * SUBJECT the row's Stream cell names the item — by recorded id, or by the
264
357
  * text prefix `claim` has always written there. A mention in a Last
265
358
  * note is one lane REFERRING to another item — the aide's live row
266
359
  * does exactly this — and a status-only rule would still hide it.
360
+ * STATE whatever the seam reads: in-progress, in-review, parked (split
361
+ * into held · queued · parked, see `holdReasonOf`), blocked,
362
+ * orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
363
+ * excludes: the row is on the board with this item as its subject,
364
+ * and offering it anyway is the silent path this exists to close.
365
+ * A `✅ Done` row excludes too — that item is offerable to nobody.
267
366
  *
268
367
  * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
269
368
  * would have shipped a transitional hole in its own negative control: every row
@@ -274,16 +373,24 @@ function rowSubjectIds(row) {
274
373
  * here carry the id (see `claim`), so the prefix arm is the compatibility half
275
374
  * rather than the mechanism, and it is deliberately still SUBJECT-only.
276
375
  */
277
- function boardHoldsItem(rows, id, text) {
376
+ function boardHoldOf(rows, id, text) {
278
377
  const key = keyOf({ text }).replace(/\s+/g, " ").trim();
279
- return rows.some((r) => {
280
- if (!isInFlightStatus(r.status))
281
- return false;
282
- if (rowSubjectIds(r).includes(id))
283
- return true;
378
+ for (const r of rows) {
284
379
  const stream = String(r.stream).replace(/\s+/g, " ").trim();
285
- return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
286
- });
380
+ const subject = rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
381
+ if (!subject)
382
+ continue;
383
+ const state = workStateOf(r.status);
384
+ const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
385
+ return {
386
+ state,
387
+ why: holdReasonOf(state, r.status),
388
+ status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
389
+ builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
390
+ branch,
391
+ };
392
+ }
393
+ return null;
287
394
  }
288
395
  /**
289
396
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
@@ -311,7 +418,7 @@ function boardHoldsItem(rows, id, text) {
311
418
  * false positives, because open rows cite refs as EVIDENCE.
312
419
  *
313
420
  * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
314
- * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
421
+ * CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
315
422
  * delivery from prose: it requires the entry to carry the item's own
316
423
  * deterministically composed text, `summarize(item.text)`, which a VERB writes.
317
424
  * A citation cannot accidentally satisfy it.
@@ -407,20 +514,34 @@ export async function nextUnblockedTool(args) {
407
514
  if (i.done)
408
515
  continue; // already off `open` by the checkbox; not an exclusion this axis owns
409
516
  // Board first, and the order is load-bearing for the REPORT rather than the
410
- // routing: both causes exclude, but a live 🚧 row is a different remedy
411
- // (wait, or ask its owner) from a landed delivery (close the row).
412
- if (boardHoldsItem(boardRows, i.id, String(i.text)))
413
- deliveredBy.set(i.id, "board");
517
+ // routing: both causes exclude, but a board row is a different remedy
518
+ // (wait for the merge · ask its owner · ask the human it awaits, by its
519
+ // `why`) from a landed delivery (close the row).
520
+ const hold = boardHoldOf(boardRows, i.id, String(i.text));
521
+ if (hold)
522
+ deliveredBy.set(i.id, { reason: "board", hold });
414
523
  else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
415
- deliveredBy.set(i.id, "done");
524
+ deliveredBy.set(i.id, { reason: "done" });
416
525
  }
417
526
  const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
418
527
  // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
419
528
  // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
420
529
  // reported silently is the same defect as an incorrect one.
530
+ //
531
+ // ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
532
+ // the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
533
+ // and the two want opposite things from the reader — leave the first alone,
534
+ // MERGE the second. `why` is the seam's state (parked split into held ·
535
+ // queued · parked) and `builtWork` says whether the row names a branch at
536
+ // all, so "held with nothing behind it" is visible rather than resolved.
421
537
  const delivered = items
422
538
  .filter((i) => !i.done && deliveredBy.has(i.id))
423
- .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id) }));
539
+ .map((i) => {
540
+ const d = deliveredBy.get(i.id);
541
+ return d.reason === "board"
542
+ ? { 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 }
543
+ : { item: keyOf(i), id: i.id, reason: "done", why: "recorded in DONE.md" };
544
+ });
424
545
  // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
425
546
  // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
426
547
  // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
@@ -555,7 +676,7 @@ export async function nextUnblockedTool(args) {
555
676
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
556
677
  ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
557
678
  ...delivered.map((d) => d.reason === "board"
558
- ? `⏭ not offered — already on the board (${d.id})`
679
+ ? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
559
680
  : `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
560
681
  ],
561
682
  // A SEPARATE AXIS, deliberately. See noDownstream().
@@ -622,6 +743,13 @@ export const claimSchema = {
622
743
  base: z.string().optional(),
623
744
  task: z.string().optional(),
624
745
  write: z.boolean().optional(),
746
+ /**
747
+ * ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
748
+ * What the slice delivers instead (a ruling, a canon edit, a measurement).
749
+ * No worktree is cut; the board cell carries the statement in words, which
750
+ * the grammar and `stall_check` both read as DELIBERATE rather than missing.
751
+ */
752
+ noCodeDeliverable: z.string().min(1).optional(),
625
753
  };
626
754
  export async function claimTool(args) {
627
755
  const halt = haltState();
@@ -660,6 +788,25 @@ export async function claimTool(args) {
660
788
  // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
661
789
  // item to an agent with nowhere isolated to work is the shared-checkout failure
662
790
  // this pair exists to prevent.
791
+ // ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
792
+ // seats wrote this by hand in five spellings because the verb had no way to
793
+ // say it; `stall_check` reads the prose cell as deliberately branchless and
794
+ // keeps the row out of its population rather than calling it unmeasurable.
795
+ if (args.noCodeDeliverable) {
796
+ const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
797
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
798
+ let board = { action: "reported" };
799
+ const b = readDoc(repo, BOARD_DOC);
800
+ if (!b)
801
+ board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
802
+ else if (args.write) {
803
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
804
+ if (next.action !== "unchanged")
805
+ writeFileSync(path.join(repo, BOARD_DOC), next.text);
806
+ board = { action: next.action, path: BOARD_DOC };
807
+ }
808
+ 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 };
809
+ }
663
810
  const wt = await ensureWorktreeTool({
664
811
  agentId: args.agentId,
665
812
  repo,
@@ -792,20 +939,39 @@ export const landSchema = {
792
939
  };
793
940
  const ghMergeFacts = (repo, n) => {
794
941
  try {
795
- const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
942
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
796
943
  cwd: repo,
797
944
  encoding: "utf8",
798
945
  stdio: ["ignore", "pipe", "ignore"],
946
+ maxBuffer: 64 * 1024 * 1024,
799
947
  });
800
948
  const j = JSON.parse(out);
801
949
  if (!j.headRefOid || !j.mergedAt)
802
950
  return null;
803
- return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
951
+ return {
952
+ headRefOid: String(j.headRefOid),
953
+ mergedAt: String(j.mergedAt),
954
+ comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
955
+ };
804
956
  }
805
957
  catch {
806
958
  return null;
807
959
  }
808
960
  };
961
+ /** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
962
+ export function landingCitations(repo, n) {
963
+ let subjects = "";
964
+ for (const base of ["origin/main", "main"]) {
965
+ try {
966
+ subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]);
967
+ break;
968
+ }
969
+ catch { /* next */ }
970
+ }
971
+ const marker = `(#${n})`;
972
+ const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
973
+ return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
974
+ }
809
975
  export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
810
976
  const repo = args.repo ?? process.cwd();
811
977
  const base = args.base ?? "main";
@@ -825,21 +991,35 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
825
991
  const ref = `origin/${base}`;
826
992
  let landedIn = null;
827
993
  let reason = null;
994
+ // ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first.
995
+ const numbers = prNumbersOf(args.pr);
996
+ const landings = {};
997
+ let searched = 0;
998
+ const missing = [];
828
999
  try {
829
- const subjects = git(repo, ["log", "-n", "400", "--format=%H %s", ref]);
830
- const hit = subjects.split("\n").find((l) => new RegExp(`\\(#${n}\\)|#${n}\\b`).test(l));
831
- landedIn = hit ? hit.split(" ")[0] : null;
1000
+ for (const num of numbers) {
1001
+ const found = landingCommitOf(repo, ref, num);
1002
+ searched = found.searched;
1003
+ if (found.sha)
1004
+ landings[num] = found.sha;
1005
+ else
1006
+ missing.push(num);
1007
+ }
1008
+ landedIn = landings[n] ?? null;
832
1009
  }
833
1010
  catch (e) {
834
1011
  reason = `could not read ${ref} (${String(e.message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
835
1012
  }
836
1013
  if (reason)
837
1014
  return { ok: false, error: reason };
838
- if (!landedIn) {
1015
+ if (!landedIn || missing.length) {
839
1016
  return {
840
1017
  ok: false,
841
- 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.`,
1018
+ error: `${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` +
1019
+ `${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` +
1020
+ `a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`,
842
1021
  comparedAgainst: ref,
1022
+ searched,
843
1023
  };
844
1024
  }
845
1025
  const q = readDoc(repo, QUEUE_DOC);
@@ -861,6 +1041,17 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
861
1041
  // `queueItemId` it closes nothing and reports the candidates for the caller to
862
1042
  // pick, saying so.
863
1043
  const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
1044
+ // ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
1045
+ // shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
1046
+ // (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
1047
+ // The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
1048
+ // every check reads, local, no network — and listed beside the #N candidates
1049
+ // at the one moment both facts are in hand. Closing nothing: a sibling is a
1050
+ // question for the coordinator, not a claim.
1051
+ const citedIds = landingCitations(repo, n);
1052
+ const siblings = items
1053
+ .filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
1054
+ .map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
864
1055
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
865
1056
  if (args.queueItemId && !target)
866
1057
  return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
@@ -899,7 +1090,35 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
899
1090
  queueChanged = true;
900
1091
  const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
901
1092
  const contextRepo = originRepoOf(repo);
902
- const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
1093
+ /*
1094
+ * ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
1095
+ *
1096
+ * This asked whether the PR number appears ANYWHERE in the row, so a row that
1097
+ * DISCUSSES its own closer read as already cited and `land` wrote no token.
1098
+ * The natural experiment that isolates it — three rows, one variable:
1099
+ *
1100
+ * ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
1101
+ * ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
1102
+ * ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
1103
+ *
1104
+ * ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
1105
+ * somebody else's PRs is cited normally; a row naming its own once is not. So the
1106
+ * better a row documents what closed it, the more certainly the closure goes
1107
+ * unwritten — and the ARTEFACT is what loses, silently.
1108
+ *
1109
+ * ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
1110
+ * ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
1111
+ * a line-1 prose mention and `doctor` falls back to the row. That was measured and
1112
+ * a patch was reverted rather than shipped. What nobody asked was what it does to
1113
+ * the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
1114
+ * makes a ref a citation, and that rule applies to the verb that WRITES one.
1115
+ *
1116
+ * ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
1117
+ * rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
1118
+ * must never happen.
1119
+ */
1120
+ const closingHere = closingCitation(String(target.text))?.refs ?? [];
1121
+ const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
903
1122
  if (!alreadyCited) {
904
1123
  citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
905
1124
  target.text = `${target.text}${citationAppended}`;
@@ -1029,7 +1248,9 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1029
1248
  if (args.write && target) {
1030
1249
  const after = readDoc(repo, DONE_DOC);
1031
1250
  const recordText = after?.text ?? "";
1032
- const ev = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
1251
+ // ⟨q-cbace757⟩ one event, every cited ref; each checked against the record on its own.
1252
+ const refs = prRefsIn(args.pr);
1253
+ const ev = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) };
1033
1254
  const derived = eventIsDerived(recordText, ev);
1034
1255
  if (!derived.ok) {
1035
1256
  events.refused.push(derived.error);
@@ -1066,10 +1287,32 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1066
1287
  const at = Date.parse(mf.mergedAt);
1067
1288
  if (!Number.isFinite(at))
1068
1289
  return { checked: false, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
1069
- const { verdicts, unparsed } = verdictsFor(log, n);
1070
- const a = gatedAt(verdicts, mf.headRefOid, at);
1290
+ const { verdicts: bus, unparsed } = verdictsFor(log, n);
1291
+ // ⟨q-5a93c2d7⟩ both channels, one predicate: the bus's typed records and
1292
+ // the PR page's typed lines. Gatedness is (head sha, typed verdict); the
1293
+ // merge time only DISCLOSES lateness, in words, beside the answer.
1294
+ const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(mf.comments ?? [])];
1295
+ const a = gatedBy(verdicts, mf.headRefOid, at);
1071
1296
  if (a.gated) {
1072
- return { checked: true, gated: true, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
1297
+ const late = (a.lateByMs ?? 0) > 0;
1298
+ return {
1299
+ checked: true,
1300
+ gated: true,
1301
+ head: mf.headRefOid.slice(0, 8),
1302
+ // ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
1303
+ // sender alone; `attribution` says whether that is a seat or the account.
1304
+ by: {
1305
+ from: a.gater,
1306
+ sha: a.by.head.slice(0, 8),
1307
+ channel: a.by.channel ?? "bus",
1308
+ attribution: a.attribution,
1309
+ ...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
1310
+ ...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
1311
+ },
1312
+ recordedAfterMerge: late,
1313
+ verified: a.verified,
1314
+ ...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
1315
+ };
1073
1316
  }
1074
1317
  return {
1075
1318
  checked: true,
@@ -1078,7 +1321,7 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1078
1321
  reason: a.reason,
1079
1322
  gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1080
1323
  note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
1081
- `The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
1324
+ `The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
1082
1325
  (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1083
1326
  };
1084
1327
  })();
@@ -1117,6 +1360,11 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
1117
1360
  candidates: target
1118
1361
  ? undefined
1119
1362
  : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
1363
+ // ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
1364
+ siblings,
1365
+ ...(siblings.length
1366
+ ? { 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.` }
1367
+ : {}),
1120
1368
  ...(target || !candidates.length
1121
1369
  ? {}
1122
1370
  : {
@@ -1186,7 +1434,7 @@ const ghFacts = (repo, n) => {
1186
1434
  // history: the PR title (which becomes the squash subject) and the commit
1187
1435
  // subjects. The body is included because a reviewer reads it, but a claim
1188
1436
  // that lives only in a comment thread is not a record.
1189
- const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}");
1437
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}");
1190
1438
  const subjects = (meta.commits ?? [])
1191
1439
  .map((c) => c.messageHeadline ?? "")
1192
1440
  .join("\n");
@@ -1194,6 +1442,7 @@ const ghFacts = (repo, n) => {
1194
1442
  state: String(j.state ?? ""),
1195
1443
  mergeable: String(j.mergeable ?? ""),
1196
1444
  headRefOid: String(j.headRefOid ?? ""),
1445
+ comments: (meta.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
1197
1446
  checks: j.statusCheckRollup ?? [],
1198
1447
  diff: gh(["pr", "diff", n]),
1199
1448
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1206,9 +1455,25 @@ const ghMerge = (repo, n, method) => {
1206
1455
  stdio: ["ignore", "pipe", "ignore"],
1207
1456
  });
1208
1457
  };
1458
+ /**
1459
+ * ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
1460
+ * verdict recorded in another room still counts for this head. The project
1461
+ * room is read first so a missing bus is still a null (unknown), never an
1462
+ * empty string (nothing).
1463
+ */
1209
1464
  const roomLog = (project) => {
1210
1465
  try {
1211
- return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1466
+ const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1467
+ let others = "";
1468
+ try {
1469
+ const dir = path.join(ROOT, "rooms");
1470
+ others = readdirSync(dir)
1471
+ .filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
1472
+ .map((f) => readFileSync(path.join(dir, f), "utf8"))
1473
+ .join("\n");
1474
+ }
1475
+ catch { /* other rooms are optional */ }
1476
+ return others ? `${own}\n${others}` : own;
1212
1477
  }
1213
1478
  catch {
1214
1479
  return null;
@@ -1341,8 +1606,9 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
1341
1606
  verdict,
1342
1607
  };
1343
1608
  }
1344
- const { verdicts, unparsed } = verdictsFor(log, n);
1345
- const gate = gatedAt(verdicts, head, Date.now());
1609
+ const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
1610
+ // ⟨q-5a93c2d7⟩ the pre-merge question, same predicate, both channels.
1611
+ const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(f.comments ?? [])], head, null);
1346
1612
  if (!gate.gated) {
1347
1613
  const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1348
1614
  return {
@@ -1356,7 +1622,7 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
1356
1622
  };
1357
1623
  }
1358
1624
  if (!args.write)
1359
- return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.by.from}. Pass write:true to apply.` };
1625
+ return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` };
1360
1626
  doMerge(repo, n, args.method ?? "squash");
1361
1627
  return { ok: true, merged: true, verdict };
1362
1628
  }