agent-coord-mcp 0.26.19 → 0.26.21

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 (56) hide show
  1. package/README.md +82 -0
  2. package/dist/capabilities.js +57 -1
  3. package/dist/capabilities.js.map +1 -1
  4. package/dist/gated-head.js +130 -0
  5. package/dist/gated-head.js.map +1 -0
  6. package/dist/server.js +23 -0
  7. package/dist/server.js.map +1 -1
  8. package/dist/tools/queue-write.js +431 -0
  9. package/dist/tools/queue-write.js.map +1 -0
  10. package/dist/tools/records.js +406 -70
  11. package/dist/tools/records.js.map +1 -1
  12. package/dist/tools/registry.js +34 -5
  13. package/dist/tools/registry.js.map +1 -1
  14. package/dist/tools/shared.js.map +1 -1
  15. package/dist/tools/stall.js +2 -1
  16. package/dist/tools/stall.js.map +1 -1
  17. package/dist/tools/transport.js +82 -42
  18. package/dist/tools/transport.js.map +1 -1
  19. package/dist/tools/tree-provenance.js +107 -0
  20. package/dist/tools/tree-provenance.js.map +1 -0
  21. package/dist/tools/work.js +95 -3
  22. package/dist/tools/work.js.map +1 -1
  23. package/dist/transports/config.js +82 -0
  24. package/dist/transports/config.js.map +1 -0
  25. package/dist/transports/index.js +113 -0
  26. package/dist/transports/index.js.map +1 -0
  27. package/dist/transports/tmux.js +140 -0
  28. package/dist/transports/tmux.js.map +1 -0
  29. package/dist/transports/types.js +86 -0
  30. package/dist/transports/types.js.map +1 -0
  31. package/hooks/peek-coord.mjs +0 -0
  32. package/hooks/tmux-pusher.mjs +33 -3
  33. package/package.json +14 -11
  34. package/scripts/coord-attention-clock.mjs +0 -0
  35. package/scripts/coord-node.sh +0 -0
  36. package/scripts/coord-stall-clock.mjs +0 -0
  37. package/scripts/coord-token.mjs +0 -0
  38. package/scripts/probe-tmux-liveness.sh +0 -0
  39. package/scripts/spawn-agent.sh +0 -0
  40. package/scripts/stop-agent.sh +0 -0
  41. package/scripts/typed-record-stats.mjs +0 -0
  42. package/src/capabilities.ts +104 -1
  43. package/src/gated-head.ts +134 -0
  44. package/src/server.ts +29 -0
  45. package/src/tools/queue-write.ts +485 -0
  46. package/src/tools/records.ts +409 -40
  47. package/src/tools/registry.ts +36 -5
  48. package/src/tools/shared.ts +12 -36
  49. package/src/tools/stall.ts +2 -1
  50. package/src/tools/transport.ts +96 -43
  51. package/src/tools/tree-provenance.ts +136 -0
  52. package/src/tools/work.ts +95 -3
  53. package/src/transports/config.ts +110 -0
  54. package/src/transports/index.ts +126 -0
  55. package/src/transports/tmux.ts +177 -0
  56. package/src/transports/types.ts +201 -0
@@ -15,6 +15,8 @@ import { execFileSync } from "node:child_process";
15
15
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import { z } from "zod";
18
+
19
+ import { treeProvenance } from "./tree-provenance.js";
18
20
  import {
19
21
  parseWorkDoc,
20
22
  renderWorkDoc,
@@ -34,6 +36,8 @@ import {
34
36
  type DoneEntry,
35
37
  } from "@davidbalzan/groundwork-seam";
36
38
  import { ensureWorktreeTool } from "./worktrees.js";
39
+ import { ROOT } from "../store.js";
40
+ import { verdictsFor, gatedAt } from "../gated-head.js";
37
41
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
38
42
  import { haltState, isInFlightStatus } from "./stall.js";
39
43
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
@@ -70,10 +74,85 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
70
74
  * Returns what it stamped so the caller can report it rather than leaving an
71
75
  * absorbing writer to find it in a diff.
72
76
  */
73
- function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): { written: boolean; stamped: string[] } {
77
+ /**
78
+ * A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
79
+ * that can be ignored is how the silent loss happened in the first place.
80
+ */
81
+ export class StaleWriteError extends Error {
82
+ constructor(
83
+ readonly rel: string,
84
+ readonly detail: string,
85
+ readonly alreadyWritten: string[],
86
+ ) {
87
+ super(
88
+ `refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
89
+ `Nothing was overwritten. Re-read and retry.` +
90
+ (alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""),
91
+ );
92
+ this.name = "StaleWriteError";
93
+ }
94
+ }
95
+
96
+ /** What moved, in terms a caller can act on without diffing the file itself. */
97
+ function describeDrift(original: string, current: string): string {
98
+ const ol = original.split("\n");
99
+ const cl = current.split("\n");
100
+ let i = 0;
101
+ while (i < ol.length && i < cl.length && ol[i] === cl[i]) i++;
102
+ return (
103
+ `${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
104
+ (i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : "")
105
+ );
106
+ }
107
+
108
+ /**
109
+ * ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
110
+ *
111
+ * This function used to compare the rendered text against `original` — the
112
+ * caller's IN-MEMORY snapshot from when it read — and then write. It never looked
113
+ * at the file again. Anything written in between was clobbered with no error, no
114
+ * diff, and nothing in the return value: `{ written: true }` came back for a
115
+ * write that had destroyed someone else's row.
116
+ *
117
+ * Measured before fixing: two writers taking the same original and both
118
+ * committing leave TWO rows where three should be, and the first writer's row is
119
+ * simply gone. This is not hypothetical — four processes write these documents
120
+ * (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
121
+ * ran six times in one afternoon against a queue the aide was editing.
122
+ *
123
+ * So the file is re-read immediately before the write and must still match what
124
+ * the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
125
+ * what moved. A refused write is recoverable; a silent overwrite is not, and the
126
+ * loser of the race never learns it lost.
127
+ *
128
+ * What this does NOT claim: it is not a lock. Two processes can still interleave
129
+ * between this re-read and the `writeFileSync` a few microseconds later. The
130
+ * window goes from "the whole duration of the caller's work" — parsing, git
131
+ * calls, composing a line — down to two adjacent statements. That is a large
132
+ * reduction and not zero, and a lock is the stronger fix if this ever proves
133
+ * insufficient.
134
+ *
135
+ * EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
136
+ * the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
137
+ * read to its write, so no in-process test can interleave them. An end-to-end
138
+ * attempt passed identically with the guard removed, i.e. it proved nothing. A
139
+ * data-loss guard is worth a narrow export to be testable at the level it lives.
140
+ */
141
+ export function writeDoc(
142
+ repo: string,
143
+ rel: string,
144
+ doc: WorkDoc,
145
+ original: string,
146
+ alreadyWritten: string[] = [],
147
+ ): { written: boolean; stamped: string[] } {
74
148
  const { text, stamped } = renderWorkDocForWrite(doc);
75
149
  if (text === original) return { written: false, stamped: [] };
76
- writeFileSync(path.join(repo, rel), text);
150
+ const p = path.join(repo, rel);
151
+ const current = existsSync(p) ? readFileSync(p, "utf8") : "";
152
+ if (current !== original) {
153
+ throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
154
+ }
155
+ writeFileSync(p, text);
77
156
  return { written: true, stamped };
78
157
  }
79
158
 
@@ -151,6 +230,15 @@ export function summarize(text: string, max = 96): string {
151
230
  * the queue SAYS it waits on this", never "nothing waits on this".
152
231
  */
153
232
  export function noDownstream(items: QueueItem[]): QueueItem[] {
233
+ // ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
234
+ // asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
235
+ // list, so this re-filter is a no-op on that path; it is kept because this
236
+ // function is EXPORTED and its contract is "given items, which have nothing
237
+ // waiting on them", which must hold for a caller that hands it raw items.
238
+ //
239
+ // The two are not the same predicate and must not be merged: this one is
240
+ // `!done`, while the router's also subtracts the delivery join. Collapsing
241
+ // them would make an exported helper inherit a routing policy.
154
242
  const open = items.filter((i) => !i.done);
155
243
  const named = new Set<string>();
156
244
  for (const i of open) {
@@ -235,43 +323,53 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
235
323
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
236
324
  * merely MENTIONS it?
237
325
  *
238
- * `DONE.md` has no status cells, so the board's answer does not transfer and
239
- * this needs its own and the item's own words for what it needs are that "the
240
- * canon rule must name its citations in a form the join can tell apart".
326
+ * THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
327
+ * appeared ANYWHERE in an entry, on a census that had inverted: today
328
+ * `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
329
+ * house spelling everything else teaches — so the COMPLIANT way to cite an item
330
+ * became the spelling that meant "delivered". Measured before removal, on 143
331
+ * open rows: ELEVEN false positives, ZERO independent true positives (every row
332
+ * it correctly excluded was already excluded by `!i.done`, which runs first), and
333
+ * a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
334
+ * merged under a different row's citation, stayed `[ ]`, and was offered as top
335
+ * P1 twice.
336
+ *
337
+ * ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
338
+ * BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
339
+ * reasons this repo's own canon REQUIRES — a residual gap must cite its queue
340
+ * item — so no reading of that file separates a closure from a mention. Three
341
+ * replacements were measured and each was worse or equal: position does not
342
+ * discriminate (a known-false and a known-true entry are structurally identical,
343
+ * and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
344
+ * id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
345
+ * false positives, because open rows cite refs as EVIDENCE.
241
346
  *
242
- * MEASURED ON THE REAL `DONE.md` @17b72ff, which settles which form is which:
243
- * BRACKETED ids appear ZERO times; all ELEVEN id mentions are BARE, in prose,
244
- * inside backticks every one of them a citation. So:
347
+ * 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
349
+ * delivery from prose: it requires the entry to carry the item's own
350
+ * deterministically composed text, `summarize(item.text)`, which a VERB writes.
351
+ * A citation cannot accidentally satisfy it.
245
352
  *
246
- * DELIVERY the entry carries the item's own COMPOSED SUMMARYthe
247
- * deterministic form `land` writes, `summarize(item.text)` or it
248
- * names the item in the recorded-id sigil `⟨id⟩`, which is this
249
- * corpus's "this IS that item" marker (it is how QUEUE.md records
250
- * identity, and a human writing it in DONE.md means exactly that).
251
- * CITATION a BARE `q-xxxxxxxx` anywhere. This is the form our own shipped
252
- * canon (q-912a67e8) produces: a `DONE` entry may name a residual
253
- * gap but MUST CITE A QUEUE ITEM for it. Obeying that rule wrote an
254
- * open item's id into `DONE.md`, and the substring join read the
255
- * citation as delivery — a canon rule and a code path in direct
256
- * contradiction, where COMPLIANCE with the canon triggered the
257
- * defect. LIVE on origin/main, not latent: the part (a) entry for
258
- * q-507e80c4 cites it exactly this way while part (b) is open work.
353
+ * IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS 0 of 143 open, 0 of 9
354
+ * closed AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
355
+ * code: rows here are closed with `land --write` and the composed line is then
356
+ * OVERWRITTEN with hand-written prose, so the structural record it would match is
357
+ * destroyed within the minute. **Do not re-derive "dead code" from another zero
358
+ * count.** Its input returns the moment a closing entry keeps what `land` wrote.
259
359
  *
260
- * Note what is deliberately NOT the rule: a shared PR ref. `land` never writes
261
- * the item id into `DONE.md` at all (the summary excludes the `⟨id⟩` token), and
262
- * the live entry for q-507e80c4 cites `#219` while the item's own line cites no
263
- * PRso a ref-tie would have marked a genuinely-open part (b) as delivered.
360
+ * AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
361
+ * cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
362
+ * CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
363
+ * Accepted knowingly the arm that claimed to cover it did not, on the one live
364
+ * instance it had. Closing such a row is coordinator discipline, not a predicate.
264
365
  */
265
366
  function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
266
367
  const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
267
368
  const target = norm(summarize(text)).replace(/…$/, "");
268
- const sigil = `⟨${id}⟩`;
269
369
  return entries.some((e) => {
270
- const t = String(e.text);
271
- if (t.includes(sigil)) return true;
272
370
  // A leading recorded-id token is stripped before comparing, so an entry
273
371
  // written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
274
- const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
372
+ const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
275
373
  return target.length >= 12 && body.startsWith(target);
276
374
  });
277
375
  }
@@ -293,9 +391,19 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
293
391
  };
294
392
  }
295
393
  const repo = args.repo ?? process.cwd();
394
+ // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
395
+ // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
396
+ // a confidently wrong routing decision in both directions inside ten minutes —
397
+ // a row ruled un-claimable from a stale file, and a worker told to hold on a
398
+ // row that was already split. The answer was well-formed and said nothing
399
+ // about its source, which is what made it invisible.
400
+ const tree = treeProvenance(repo);
296
401
  const q = readDoc(repo, QUEUE_DOC);
297
- if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
402
+ if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
298
403
  const items = queueItemsOf(q.doc);
404
+ // The seam's own count of unticked rows — the number every axis must add back
405
+ // up to. Taken BEFORE any exclusion so it cannot inherit one.
406
+ const parsedOpen = items.filter((i) => !i.done).length;
299
407
 
300
408
  // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
301
409
  // establish that.
@@ -313,17 +421,66 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
313
421
  // (Task 21.1) are what make this join reliable — with a content-hash id the
314
422
  // board row and the DONE entry stopped matching the moment anyone reworded
315
423
  // the item, which is how the memory was lost in the first place.
316
- const delivered = new Set<string>();
424
+ // ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
425
+ // be subtracted from `open` BEFORE any axis ran, so an item it excluded could
426
+ // appear on NO axis by construction — the one guarantee this verb's contract
427
+ // makes is that it never skips silently, and this was the one path that did.
428
+ //
429
+ // MEASURED on the live queue at `a032069`: the file held 143 open rows, the
430
+ // seam parsed all 143, and this verb reported `open: 129`. Its declared axes
431
+ // accounted for ONE. Fourteen rows were outside the router's universe and
432
+ // nothing in the response said so — invisible from the only seat that would
433
+ // notice, because a worker asking for the next item still gets a real one.
434
+ //
435
+ // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
436
+ // a row is excluded BY a named cause, and the cause is what gets reported.
437
+ const deliveredBy = new Map<string, "board" | "done">();
317
438
  const doneDoc = readDoc(repo, DONE_DOC);
318
439
  const boardDoc = readDoc(repo, BOARD_DOC);
319
440
  const boardText = boardDoc?.text ?? "";
320
441
  const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
321
442
  const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
322
443
  for (const i of items) {
323
- if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text))) delivered.add(i.id);
444
+ if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
445
+ // 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");
324
450
  }
325
451
 
326
- const open = items.filter((i) => !i.done && !delivered.has(i.id));
452
+ const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
453
+
454
+ // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
455
+ // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
456
+ // reported silently is the same defect as an incorrect one.
457
+ const delivered = items
458
+ .filter((i) => !i.done && deliveredBy.has(i.id))
459
+ .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
460
+
461
+ // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
462
+ // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
463
+ // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
464
+ // identical row` twice yields `q-c9f3ded8` twice.
465
+ //
466
+ // Everything downstream is keyed by id, so ONE delivery record then excludes
467
+ // BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
468
+ // to `offered: 1`. The accounting still reconciles — both are named — so the
469
+ // self-check above CANNOT catch it, which is exactly why it needs its own axis
470
+ // rather than a flag on `reconciles`.
471
+ //
472
+ // ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
473
+ // filter working as designed; a row reported TWICE cannot be anything but the
474
+ // accounting. It is the one symptom here that does not rest on a count.
475
+ const idCounts = new Map<string, number>();
476
+ for (const i of items) if (!i.done) idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
477
+ const duplicateIds = [...idCounts.entries()]
478
+ .filter(([, n]) => n > 1)
479
+ .map(([id, rows]) => ({
480
+ id,
481
+ rows,
482
+ why: `${rows} open rows share the id ${id} — stable ids are derived from row TEXT, so identical rows collide. Every axis here is keyed by id, so one delivery record excludes all ${rows}. Reword one row to separate them.`,
483
+ }));
327
484
  const ranked = open
328
485
  .map((i, idx) => ({ i, idx }))
329
486
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -387,9 +544,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
387
544
  // the severity finding one file over. Say the axis is uninformative instead.
388
545
  const undiscriminating = silent.length === open.length && open.length > 1;
389
546
  return {
547
+ // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
548
+ // routing answer whose tree is unnamed is the defect this row exists for.
549
+ tree,
550
+ ...(tree.warning ? { staleWarning: tree.warning } : {}),
390
551
  ok: true as const,
391
552
  project: args.project,
392
553
  open: open.length,
554
+ // ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
555
+ // EXPLAINED and the only thing that ever caught it was someone counting the
556
+ // file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
557
+ // is what routing considered; `excluded` is what the axes account for. When
558
+ // `reconciles` is false, rows have left the universe with no named cause —
559
+ // the exact condition that was previously unobservable from the response.
560
+ //
561
+ // ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
562
+ // because its own bookkeeping is off strands every lane, which is worse than
563
+ // the miscount. The caller gets a real item AND the discrepancy.
564
+ accounting: {
565
+ parsedOpen,
566
+ offered: open.length,
567
+ excluded: delivered.length,
568
+ reconciles: parsedOpen === open.length + delivered.length,
569
+ },
570
+ // A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
571
+ delivered,
572
+ // A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
573
+ duplicateIds,
393
574
  next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
394
575
  skipped,
395
576
  // A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
@@ -406,6 +587,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
406
587
  ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
407
588
  ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
408
589
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
590
+ ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
591
+ ...delivered.map((d) =>
592
+ d.reason === "board"
593
+ ? `⏭ not offered — already on the board (${d.id})`
594
+ : `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
595
+ ),
409
596
  ],
410
597
  // A SEPARATE AXIS, deliberately. See noDownstream().
411
598
  noDownstream: undiscriminating
@@ -650,7 +837,29 @@ export const landSchema = {
650
837
  result: z.string().optional(),
651
838
  };
652
839
 
653
- export async function landTool(args: {
840
+ /**
841
+ * What the recording step needs to know about a merge: which head actually
842
+ * landed, and when. Injected so the report is provable without the network.
843
+ */
844
+ export type MergeFacts = { headRefOid: string; mergedAt: string } | null;
845
+ export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
846
+ const ghMergeFacts: ReadMergeFacts = (repo, n) => {
847
+ try {
848
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
849
+ cwd: repo,
850
+ encoding: "utf8",
851
+ stdio: ["ignore", "pipe", "ignore"],
852
+ });
853
+ const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
854
+ if (!j.headRefOid || !j.mergedAt) return null;
855
+ return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
856
+ } catch {
857
+ return null;
858
+ }
859
+ };
860
+
861
+ export async function landTool(
862
+ args: {
654
863
  project: string;
655
864
  pr: string;
656
865
  queueItemId?: string;
@@ -658,7 +867,10 @@ export async function landTool(args: {
658
867
  base?: string;
659
868
  write?: boolean;
660
869
  result?: string;
661
- }) {
870
+ },
871
+ readMergeFacts: ReadMergeFacts = ghMergeFacts,
872
+ readVerdictLog: ReadVerdictLog = roomLog,
873
+ ) {
662
874
  const repo = args.repo ?? process.cwd();
663
875
  const base = args.base ?? "main";
664
876
  const n = prNumber(args.pr);
@@ -773,8 +985,41 @@ export async function landTool(args: {
773
985
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
774
986
  const summary = originalText !== null ? summarize(originalText) : null;
775
987
 
988
+ /*
989
+ * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
990
+ * — `⟨q-d046204d⟩`.
991
+ *
992
+ * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
993
+ * from these entries: IT WAS CUT, because in the row it sits later than the
994
+ * truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
995
+ * that names no row — and `closingRefs` needs BOTH halves, so the row reads
996
+ * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
997
+ *
998
+ * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
999
+ * out of the truncated region BY CONSTRUCTION, so it survives any headline length
1000
+ * — where raising `max` only moves the cliff. The row is explicit that "write
1001
+ * longer headlines" is not an acceptable fix, and it is right: a length that is
1002
+ * enough today is a truncation tomorrow.
1003
+ *
1004
+ * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
1005
+ * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
1006
+ * <summary>` and one written as `<summary>` agree". So the composed-summary arm
1007
+ * keeps matching and this needed no change there.
1008
+ *
1009
+ * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
1010
+ * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
1011
+ * in an entry — a reader-side guess that produced eleven false positives, because
1012
+ * this repo's canon requires entries to cite item ids for other reasons. This
1013
+ * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
1014
+ * infers nothing, and re-reading a sigil as "delivered" is still wrong.
1015
+ *
1016
+ * The id is the ITEM's, taken from the record rather than re-derived: `target` is
1017
+ * non-null whenever `summary` is, since the summary comes from its text.
1018
+ */
776
1019
  const doneLine =
777
- already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
1020
+ already || !summary || !target
1021
+ ? null
1022
+ : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
778
1023
 
779
1024
  // APPEND TO DONE.md — the half this verb exists for.
780
1025
  //
@@ -789,9 +1034,14 @@ export async function landTool(args: {
789
1034
  // an entry added to the record model renders through the pinned glyph contract.
790
1035
  const wrote: string[] = [];
791
1036
  let stampedNotSupplied: string[] = [];
1037
+ // A stale-base refusal is a RESULT, not a crash: the caller needs to know
1038
+ // nothing was clobbered, what moved, and what this call had already written
1039
+ // before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
1040
+ // here so the verb still answers.
1041
+ try {
792
1042
  if (args.write) {
793
1043
  if (queueChanged) {
794
- const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
1044
+ const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
795
1045
  if (w.written) wrote.push(QUEUE_DOC);
796
1046
  // Rows stamped that this call did not ask to touch. `queueItemId` is the
797
1047
  // one item the caller supplied, so anything else here is a row that
@@ -816,9 +1066,19 @@ export async function landTool(args: {
816
1066
  return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
817
1067
  }
818
1068
  block.entries.push(entry);
819
- if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
1069
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written) wrote.push(DONE_DOC);
820
1070
  }
821
1071
  }
1072
+ } catch (e) {
1073
+ if (e instanceof StaleWriteError) {
1074
+ return {
1075
+ ok: false as const,
1076
+ error: e.message,
1077
+ staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
1078
+ };
1079
+ }
1080
+ throw e;
1081
+ }
822
1082
 
823
1083
  // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
824
1084
  //
@@ -847,12 +1107,53 @@ export async function landTool(args: {
847
1107
  }
848
1108
  }
849
1109
 
1110
+ /*
1111
+ * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
1112
+ *
1113
+ * `merge` refuses this BEFORE the fact and is the control that prevents. This
1114
+ * one runs after, and its job is different: it is how the fleet LEARNS a
1115
+ * crossing happened anyway — through a merge done by hand, by `gh` directly,
1116
+ * or by a seat that never called the verb.
1117
+ *
1118
+ * It REPORTS and never refuses. A closure is not the right place to litigate a
1119
+ * merge that already happened: the verdict is QA's artefact, the merge may have
1120
+ * been someone else's, and blocking the record would leave the work landed and
1121
+ * unrecorded — strictly worse than landed and recorded with a flag on it.
1122
+ *
1123
+ * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
1124
+ */
1125
+ const gatedHead = (() => {
1126
+ const mf = readMergeFacts(repo, n);
1127
+ if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
1128
+ const log = readVerdictLog(args.project);
1129
+ 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
+ const at = Date.parse(mf.mergedAt);
1131
+ 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);
1134
+ 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) } };
1136
+ }
1137
+ return {
1138
+ checked: true as const,
1139
+ gated: false as const,
1140
+ head: mf.headRefOid.slice(0, 8),
1141
+ reason: a.reason,
1142
+ gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1143
+ note:
1144
+ `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.` +
1146
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1147
+ };
1148
+ })();
1149
+
850
1150
  return {
851
1151
  ok: true as const,
852
1152
  project: args.project,
853
1153
  pr: `#${n}`,
854
1154
  comparedAgainst: ref,
855
1155
  landedIn: landedIn.slice(0, 8),
1156
+ gatedHead,
856
1157
  // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
857
1158
  // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
858
1159
  // fixed, so a foreign row cannot pass silently in either direction.
@@ -964,13 +1265,15 @@ export type PrFacts = {
964
1265
  state: string;
965
1266
  mergeable: string;
966
1267
  checks: unknown[];
1268
+ /** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
1269
+ headRefOid?: string;
967
1270
  /** Unified diff of the PR, for the ticked-box audit. */
968
1271
  diff?: string;
969
1272
  /** Everything that will survive the merge as a citation: title + commit subjects + body. */
970
1273
  citationText?: string;
971
1274
  };
972
1275
  const ghFacts = (repo: string, n: string): PrFacts => {
973
- const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1276
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
974
1277
  cwd: repo,
975
1278
  encoding: "utf8",
976
1279
  stdio: ["ignore", "pipe", "ignore"],
@@ -994,6 +1297,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
994
1297
  return {
995
1298
  state: String(j.state ?? ""),
996
1299
  mergeable: String(j.mergeable ?? ""),
1300
+ headRefOid: String(j.headRefOid ?? ""),
997
1301
  checks: (j.statusCheckRollup as unknown[]) ?? [],
998
1302
  diff: gh(["pr", "diff", n]),
999
1303
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -1019,10 +1323,28 @@ const ghMerge: DoMerge = (repo, n, method) => {
1019
1323
  });
1020
1324
  };
1021
1325
 
1326
+ /**
1327
+ * The room log, injected for the same reason `doMerge` is: a test must be able to
1328
+ * drive every refusal branch without this host's ~/agent-coord.
1329
+ *
1330
+ * Returns null when the log cannot be read — distinct from "" (read, and empty),
1331
+ * because "no verdicts recorded" and "could not look" are different answers and
1332
+ * only one of them is safe to merge on.
1333
+ */
1334
+ export type ReadVerdictLog = (project: string) => string | null;
1335
+ const roomLog: ReadVerdictLog = (project) => {
1336
+ try {
1337
+ return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1338
+ } catch {
1339
+ return null;
1340
+ }
1341
+ };
1342
+
1022
1343
  export async function mergeTool(
1023
1344
  args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
1024
1345
  facts: (repo: string, n: string) => PrFacts = ghFacts,
1025
1346
  doMerge: DoMerge = ghMerge,
1347
+ readVerdictLog: ReadVerdictLog = roomLog,
1026
1348
  ) {
1027
1349
  const repo = args.repo ?? process.cwd();
1028
1350
  const n = prNumber(args.pr);
@@ -1131,8 +1453,55 @@ export async function mergeTool(
1131
1453
  if (f.mergeable === "CONFLICTING")
1132
1454
  return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
1133
1455
 
1456
+ /*
1457
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
1458
+ *
1459
+ * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
1460
+ * and took `1d6deab`, because the author force-pushed in between. Checks were
1461
+ * green on BOTH heads, so every refusal above passed honestly. Nothing asked
1462
+ * the only question that mattered: is the head in front of me the head the
1463
+ * verdict named?
1464
+ *
1465
+ * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
1466
+ * only report afterwards. It sits before the dry-run return on purpose: a dry
1467
+ * run must say it would refuse, or the preview disagrees with the act.
1468
+ */
1469
+ const head = f.headRefOid ?? "";
1470
+ if (!head) {
1471
+ return {
1472
+ ok: false as const,
1473
+ error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
1474
+ verdict,
1475
+ };
1476
+ }
1477
+ const log = readVerdictLog(args.project);
1478
+ if (log === null) {
1479
+ return {
1480
+ ok: false as const,
1481
+ error:
1482
+ `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
1483
+ `was ever gated is UNKNOWN, and unknown is not gated.`,
1484
+ verdict,
1485
+ };
1486
+ }
1487
+ const { verdicts, unparsed } = verdictsFor(log, n);
1488
+ const gate = gatedAt(verdicts, head, Date.now());
1489
+ if (!gate.gated) {
1490
+ const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1491
+ return {
1492
+ ok: false as const,
1493
+ error:
1494
+ `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
1495
+ (named ? `Gated instead: ${named}. ` : "") +
1496
+ `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
1497
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1498
+ verdict,
1499
+ gatedHead: { head, gated: false as const, reason: gate.reason },
1500
+ };
1501
+ }
1502
+
1134
1503
  if (!args.write)
1135
- return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
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.` };
1136
1505
 
1137
1506
  doMerge(repo, n, args.method ?? "squash");
1138
1507
  return { ok: true as const, merged: true as const, verdict };