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,8 +15,11 @@ 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
+ import { treeProvenance } from "./tree-provenance.js";
18
19
  import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
19
20
  import { ensureWorktreeTool } from "./worktrees.js";
21
+ import { ROOT } from "../store.js";
22
+ import { verdictsFor, gatedAt } from "../gated-head.js";
20
23
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
21
24
  import { haltState, isInFlightStatus } from "./stall.js";
22
25
  import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
@@ -51,11 +54,77 @@ const readDoc = (repo, rel) => {
51
54
  * Returns what it stamped so the caller can report it rather than leaving an
52
55
  * absorbing writer to find it in a diff.
53
56
  */
54
- function writeDoc(repo, rel, doc, original) {
57
+ /**
58
+ * A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
59
+ * that can be ignored is how the silent loss happened in the first place.
60
+ */
61
+ export class StaleWriteError extends Error {
62
+ rel;
63
+ detail;
64
+ alreadyWritten;
65
+ constructor(rel, detail, alreadyWritten) {
66
+ super(`refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
67
+ `Nothing was overwritten. Re-read and retry.` +
68
+ (alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""));
69
+ this.rel = rel;
70
+ this.detail = detail;
71
+ this.alreadyWritten = alreadyWritten;
72
+ this.name = "StaleWriteError";
73
+ }
74
+ }
75
+ /** What moved, in terms a caller can act on without diffing the file itself. */
76
+ function describeDrift(original, current) {
77
+ const ol = original.split("\n");
78
+ const cl = current.split("\n");
79
+ let i = 0;
80
+ while (i < ol.length && i < cl.length && ol[i] === cl[i])
81
+ i++;
82
+ return (`${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
83
+ (i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : ""));
84
+ }
85
+ /**
86
+ * ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
87
+ *
88
+ * This function used to compare the rendered text against `original` — the
89
+ * caller's IN-MEMORY snapshot from when it read — and then write. It never looked
90
+ * at the file again. Anything written in between was clobbered with no error, no
91
+ * diff, and nothing in the return value: `{ written: true }` came back for a
92
+ * write that had destroyed someone else's row.
93
+ *
94
+ * Measured before fixing: two writers taking the same original and both
95
+ * committing leave TWO rows where three should be, and the first writer's row is
96
+ * simply gone. This is not hypothetical — four processes write these documents
97
+ * (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
98
+ * ran six times in one afternoon against a queue the aide was editing.
99
+ *
100
+ * So the file is re-read immediately before the write and must still match what
101
+ * the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
102
+ * what moved. A refused write is recoverable; a silent overwrite is not, and the
103
+ * loser of the race never learns it lost.
104
+ *
105
+ * What this does NOT claim: it is not a lock. Two processes can still interleave
106
+ * between this re-read and the `writeFileSync` a few microseconds later. The
107
+ * window goes from "the whole duration of the caller's work" — parsing, git
108
+ * calls, composing a line — down to two adjacent statements. That is a large
109
+ * reduction and not zero, and a lock is the stronger fix if this ever proves
110
+ * insufficient.
111
+ *
112
+ * EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
113
+ * the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
114
+ * read to its write, so no in-process test can interleave them. An end-to-end
115
+ * attempt passed identically with the guard removed, i.e. it proved nothing. A
116
+ * data-loss guard is worth a narrow export to be testable at the level it lives.
117
+ */
118
+ export function writeDoc(repo, rel, doc, original, alreadyWritten = []) {
55
119
  const { text, stamped } = renderWorkDocForWrite(doc);
56
120
  if (text === original)
57
121
  return { written: false, stamped: [] };
58
- writeFileSync(path.join(repo, rel), text);
122
+ const p = path.join(repo, rel);
123
+ const current = existsSync(p) ? readFileSync(p, "utf8") : "";
124
+ if (current !== original) {
125
+ throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
126
+ }
127
+ writeFileSync(p, text);
59
128
  return { written: true, stamped };
60
129
  }
61
130
  const git = (repo, args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -125,6 +194,15 @@ export function summarize(text, max = 96) {
125
194
  * the queue SAYS it waits on this", never "nothing waits on this".
126
195
  */
127
196
  export function noDownstream(items) {
197
+ // ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
198
+ // asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
199
+ // list, so this re-filter is a no-op on that path; it is kept because this
200
+ // function is EXPORTED and its contract is "given items, which have nothing
201
+ // waiting on them", which must hold for a caller that hands it raw items.
202
+ //
203
+ // The two are not the same predicate and must not be merged: this one is
204
+ // `!done`, while the router's also subtracts the delivery join. Collapsing
205
+ // them would make an exported helper inherit a routing policy.
128
206
  const open = items.filter((i) => !i.done);
129
207
  const named = new Set();
130
208
  for (const i of open) {
@@ -211,44 +289,53 @@ function boardHoldsItem(rows, id, text) {
211
289
  * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
212
290
  * merely MENTIONS it?
213
291
  *
214
- * `DONE.md` has no status cells, so the board's answer does not transfer and
215
- * this needs its own and the item's own words for what it needs are that "the
216
- * canon rule must name its citations in a form the join can tell apart".
292
+ * THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
293
+ * appeared ANYWHERE in an entry, on a census that had inverted: today
294
+ * `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
295
+ * house spelling everything else teaches — so the COMPLIANT way to cite an item
296
+ * became the spelling that meant "delivered". Measured before removal, on 143
297
+ * open rows: ELEVEN false positives, ZERO independent true positives (every row
298
+ * it correctly excluded was already excluded by `!i.done`, which runs first), and
299
+ * a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
300
+ * merged under a different row's citation, stayed `[ ]`, and was offered as top
301
+ * P1 twice.
217
302
  *
218
- * MEASURED ON THE REAL `DONE.md` @17b72ff, which settles which form is which:
219
- * BRACKETED ids appear ZERO times; all ELEVEN id mentions are BARE, in prose,
220
- * inside backticks every one of them a citation. So:
303
+ * THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
304
+ * BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
305
+ * reasons this repo's own canon REQUIRES a residual gap must cite its queue
306
+ * item — so no reading of that file separates a closure from a mention. Three
307
+ * replacements were measured and each was worse or equal: position does not
308
+ * discriminate (a known-false and a known-true entry are structurally identical,
309
+ * and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
310
+ * id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
311
+ * false positives, because open rows cite refs as EVIDENCE.
221
312
  *
222
- * DELIVERY the entry carries the item's own COMPOSED SUMMARY the
223
- * deterministic form `land` writes, `summarize(item.text)` or it
224
- * names the item in the recorded-id sigil `⟨id⟩`, which is this
225
- * corpus's "this IS that item" marker (it is how QUEUE.md records
226
- * identity, and a human writing it in DONE.md means exactly that).
227
- * CITATION a BARE `q-xxxxxxxx` anywhere. This is the form our own shipped
228
- * canon (q-912a67e8) produces: a `DONE` entry may name a residual
229
- * gap but MUST CITE A QUEUE ITEM for it. Obeying that rule wrote an
230
- * open item's id into `DONE.md`, and the substring join read the
231
- * citation as delivery — a canon rule and a code path in direct
232
- * contradiction, where COMPLIANCE with the canon triggered the
233
- * defect. LIVE on origin/main, not latent: the part (a) entry for
234
- * q-507e80c4 cites it exactly this way while part (b) is open work.
313
+ * 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
315
+ * delivery from prose: it requires the entry to carry the item's own
316
+ * deterministically composed text, `summarize(item.text)`, which a VERB writes.
317
+ * A citation cannot accidentally satisfy it.
235
318
  *
236
- * Note what is deliberately NOT the rule: a shared PR ref. `land` never writes
237
- * the item id into `DONE.md` at all (the summary excludes the `⟨id⟩` token), and
238
- * the live entry for q-507e80c4 cites `#219` while the item's own line cites no
239
- * PR so a ref-tie would have marked a genuinely-open part (b) as delivered.
319
+ * IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS 0 of 143 open, 0 of 9
320
+ * closed AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
321
+ * code: rows here are closed with `land --write` and the composed line is then
322
+ * OVERWRITTEN with hand-written prose, so the structural record it would match is
323
+ * destroyed within the minute. **Do not re-derive "dead code" from another zero
324
+ * count.** Its input returns the moment a closing entry keeps what `land` wrote.
325
+ *
326
+ * ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
327
+ * cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
328
+ * CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
329
+ * Accepted knowingly — the arm that claimed to cover it did not, on the one live
330
+ * instance it had. Closing such a row is coordinator discipline, not a predicate.
240
331
  */
241
332
  function doneRecordsDelivery(entries, id, text) {
242
333
  const norm = (v) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
243
334
  const target = norm(summarize(text)).replace(/…$/, "");
244
- const sigil = `⟨${id}⟩`;
245
335
  return entries.some((e) => {
246
- const t = String(e.text);
247
- if (t.includes(sigil))
248
- return true;
249
336
  // A leading recorded-id token is stripped before comparing, so an entry
250
337
  // written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
251
- const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
338
+ const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
252
339
  return target.length >= 12 && body.startsWith(target);
253
340
  });
254
341
  }
@@ -267,10 +354,20 @@ export async function nextUnblockedTool(args) {
267
354
  };
268
355
  }
269
356
  const repo = args.repo ?? process.cwd();
357
+ // ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
358
+ // `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
359
+ // a confidently wrong routing decision in both directions inside ten minutes —
360
+ // a row ruled un-claimable from a stale file, and a worker told to hold on a
361
+ // row that was already split. The answer was well-formed and said nothing
362
+ // about its source, which is what made it invisible.
363
+ const tree = treeProvenance(repo);
270
364
  const q = readDoc(repo, QUEUE_DOC);
271
365
  if (!q)
272
- return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'` };
366
+ return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
273
367
  const items = queueItemsOf(q.doc);
368
+ // The seam's own count of unticked rows — the number every axis must add back
369
+ // up to. Taken BEFORE any exclusion so it cannot inherit one.
370
+ const parsedOpen = items.filter((i) => !i.done).length;
274
371
  // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
275
372
  // establish that.
276
373
  //
@@ -287,17 +384,68 @@ export async function nextUnblockedTool(args) {
287
384
  // (Task 21.1) are what make this join reliable — with a content-hash id the
288
385
  // board row and the DONE entry stopped matching the moment anyone reworded
289
386
  // the item, which is how the memory was lost in the first place.
290
- const delivered = new Set();
387
+ // ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
388
+ // be subtracted from `open` BEFORE any axis ran, so an item it excluded could
389
+ // appear on NO axis by construction — the one guarantee this verb's contract
390
+ // makes is that it never skips silently, and this was the one path that did.
391
+ //
392
+ // MEASURED on the live queue at `a032069`: the file held 143 open rows, the
393
+ // seam parsed all 143, and this verb reported `open: 129`. Its declared axes
394
+ // accounted for ONE. Fourteen rows were outside the router's universe and
395
+ // nothing in the response said so — invisible from the only seat that would
396
+ // notice, because a worker asking for the next item still gets a real one.
397
+ //
398
+ // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
399
+ // a row is excluded BY a named cause, and the cause is what gets reported.
400
+ const deliveredBy = new Map();
291
401
  const doneDoc = readDoc(repo, DONE_DOC);
292
402
  const boardDoc = readDoc(repo, BOARD_DOC);
293
403
  const boardText = boardDoc?.text ?? "";
294
404
  const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
295
405
  const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
296
406
  for (const i of items) {
297
- if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text)))
298
- delivered.add(i.id);
407
+ if (i.done)
408
+ continue; // already off `open` by the checkbox; not an exclusion this axis owns
409
+ // 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");
414
+ else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
415
+ deliveredBy.set(i.id, "done");
299
416
  }
300
- const open = items.filter((i) => !i.done && !delivered.has(i.id));
417
+ const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
418
+ // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
419
+ // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
420
+ // reported silently is the same defect as an incorrect one.
421
+ const delivered = items
422
+ .filter((i) => !i.done && deliveredBy.has(i.id))
423
+ .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id) }));
424
+ // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
425
+ // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
426
+ // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
427
+ // identical row` twice yields `q-c9f3ded8` twice.
428
+ //
429
+ // Everything downstream is keyed by id, so ONE delivery record then excludes
430
+ // BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
431
+ // to `offered: 1`. The accounting still reconciles — both are named — so the
432
+ // self-check above CANNOT catch it, which is exactly why it needs its own axis
433
+ // rather than a flag on `reconciles`.
434
+ //
435
+ // ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
436
+ // filter working as designed; a row reported TWICE cannot be anything but the
437
+ // accounting. It is the one symptom here that does not rest on a count.
438
+ const idCounts = new Map();
439
+ for (const i of items)
440
+ if (!i.done)
441
+ idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
442
+ const duplicateIds = [...idCounts.entries()]
443
+ .filter(([, n]) => n > 1)
444
+ .map(([id, rows]) => ({
445
+ id,
446
+ rows,
447
+ 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.`,
448
+ }));
301
449
  const ranked = open
302
450
  .map((i, idx) => ({ i, idx }))
303
451
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -362,9 +510,33 @@ export async function nextUnblockedTool(args) {
362
510
  // the severity finding one file over. Say the axis is uninformative instead.
363
511
  const undiscriminating = silent.length === open.length && open.length > 1;
364
512
  return {
513
+ // ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
514
+ // routing answer whose tree is unnamed is the defect this row exists for.
515
+ tree,
516
+ ...(tree.warning ? { staleWarning: tree.warning } : {}),
365
517
  ok: true,
366
518
  project: args.project,
367
519
  open: open.length,
520
+ // ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
521
+ // EXPLAINED and the only thing that ever caught it was someone counting the
522
+ // file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
523
+ // is what routing considered; `excluded` is what the axes account for. When
524
+ // `reconciles` is false, rows have left the universe with no named cause —
525
+ // the exact condition that was previously unobservable from the response.
526
+ //
527
+ // ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
528
+ // because its own bookkeeping is off strands every lane, which is worse than
529
+ // the miscount. The caller gets a real item AND the discrepancy.
530
+ accounting: {
531
+ parsedOpen,
532
+ offered: open.length,
533
+ excluded: delivered.length,
534
+ reconciles: parsedOpen === open.length + delivered.length,
535
+ },
536
+ // A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
537
+ delivered,
538
+ // A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
539
+ duplicateIds,
368
540
  next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
369
541
  skipped,
370
542
  // A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
@@ -381,6 +553,10 @@ export async function nextUnblockedTool(args) {
381
553
  ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
382
554
  ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
383
555
  ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
556
+ ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
557
+ ...delivered.map((d) => d.reason === "board"
558
+ ? `⏭ not offered — already on the board (${d.id})`
559
+ : `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
384
560
  ],
385
561
  // A SEPARATE AXIS, deliberately. See noDownstream().
386
562
  noDownstream: undiscriminating
@@ -614,7 +790,23 @@ export const landSchema = {
614
790
  /** One-line result, written after the citation on the closed item's line. */
615
791
  result: z.string().optional(),
616
792
  };
617
- export async function landTool(args) {
793
+ const ghMergeFacts = (repo, n) => {
794
+ try {
795
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
796
+ cwd: repo,
797
+ encoding: "utf8",
798
+ stdio: ["ignore", "pipe", "ignore"],
799
+ });
800
+ const j = JSON.parse(out);
801
+ if (!j.headRefOid || !j.mergedAt)
802
+ return null;
803
+ return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
804
+ }
805
+ catch {
806
+ return null;
807
+ }
808
+ };
809
+ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
618
810
  const repo = args.repo ?? process.cwd();
619
811
  const base = args.base ?? "main";
620
812
  const n = prNumber(args.pr);
@@ -725,7 +917,40 @@ export async function landTool(args) {
725
917
  // as UNDER-QUALIFIED rather than silently written.
726
918
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
727
919
  const summary = originalText !== null ? summarize(originalText) : null;
728
- const doneLine = already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
920
+ /*
921
+ * ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
922
+ * — `⟨q-d046204d⟩`.
923
+ *
924
+ * `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
925
+ * from these entries: IT WAS CUT, because in the row it sits later than the
926
+ * truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
927
+ * that names no row — and `closingRefs` needs BOTH halves, so the row reads
928
+ * untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
929
+ *
930
+ * ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
931
+ * out of the truncated region BY CONSTRUCTION, so it survives any headline length
932
+ * — where raising `max` only moves the cliff. The row is explicit that "write
933
+ * longer headlines" is not an acceptable fix, and it is right: a length that is
934
+ * enough today is a truncation tomorrow.
935
+ *
936
+ * ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
937
+ * a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
938
+ * <summary>` and one written as `<summary>` agree". So the composed-summary arm
939
+ * keeps matching and this needed no change there.
940
+ *
941
+ * ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
942
+ * reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
943
+ * in an entry — a reader-side guess that produced eleven false positives, because
944
+ * this repo's canon requires entries to cite item ids for other reasons. This
945
+ * writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
946
+ * infers nothing, and re-reading a sigil as "delivered" is still wrong.
947
+ *
948
+ * The id is the ITEM's, taken from the record rather than re-derived: `target` is
949
+ * non-null whenever `summary` is, since the summary comes from its text.
950
+ */
951
+ const doneLine = already || !summary || !target
952
+ ? null
953
+ : `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
729
954
  // APPEND TO DONE.md — the half this verb exists for.
730
955
  //
731
956
  // The first `write:true` run closed the queue item and wrote NOTHING to
@@ -739,40 +964,56 @@ export async function landTool(args) {
739
964
  // an entry added to the record model renders through the pinned glyph contract.
740
965
  const wrote = [];
741
966
  let stampedNotSupplied = [];
742
- if (args.write) {
743
- if (queueChanged) {
744
- const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
745
- if (w.written)
746
- wrote.push(QUEUE_DOC);
747
- // Rows stamped that this call did not ask to touch. `queueItemId` is the
748
- // one item the caller supplied, so anything else here is a row that
749
- // arrived unstamped from somewhere: the absorption this verb would
750
- // otherwise carry to main under its own name.
751
- stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
752
- }
753
- if (doneLine) {
754
- const blocks = d.doc.blocks;
755
- let last = -1;
756
- for (let i = 0; i < blocks.length; i++)
757
- if (blocks[i]?.kind === "done")
758
- last = i;
759
- if (last === -1) {
760
- return {
761
- ok: false,
762
- error: `${DONE_DOC} has no parsed done block to append to — refusing to guess where the entry goes. A DONE.md that parses to zero entries is a defect in the log, not an empty log.`,
763
- };
967
+ // A stale-base refusal is a RESULT, not a crash: the caller needs to know
968
+ // nothing was clobbered, what moved, and what this call had already written
969
+ // before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
970
+ // here so the verb still answers.
971
+ try {
972
+ if (args.write) {
973
+ if (queueChanged) {
974
+ const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
975
+ if (w.written)
976
+ wrote.push(QUEUE_DOC);
977
+ // Rows stamped that this call did not ask to touch. `queueItemId` is the
978
+ // one item the caller supplied, so anything else here is a row that
979
+ // arrived unstamped from somewhere: the absorption this verb would
980
+ // otherwise carry to main under its own name.
981
+ stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
764
982
  }
765
- const block = blocks[last];
766
- const parsed = parseWorkDoc(`## Done\n${doneLine}\n`);
767
- const entry = doneEntriesOf(parsed)[0];
768
- if (!entry) {
769
- return { ok: false, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
983
+ if (doneLine) {
984
+ const blocks = d.doc.blocks;
985
+ let last = -1;
986
+ for (let i = 0; i < blocks.length; i++)
987
+ if (blocks[i]?.kind === "done")
988
+ last = i;
989
+ if (last === -1) {
990
+ return {
991
+ ok: false,
992
+ error: `${DONE_DOC} has no parsed done block to append to — refusing to guess where the entry goes. A DONE.md that parses to zero entries is a defect in the log, not an empty log.`,
993
+ };
994
+ }
995
+ const block = blocks[last];
996
+ const parsed = parseWorkDoc(`## Done\n${doneLine}\n`);
997
+ const entry = doneEntriesOf(parsed)[0];
998
+ if (!entry) {
999
+ return { ok: false, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
1000
+ }
1001
+ block.entries.push(entry);
1002
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written)
1003
+ wrote.push(DONE_DOC);
770
1004
  }
771
- block.entries.push(entry);
772
- if (writeDoc(repo, DONE_DOC, d.doc, d.text).written)
773
- wrote.push(DONE_DOC);
774
1005
  }
775
1006
  }
1007
+ catch (e) {
1008
+ if (e instanceof StaleWriteError) {
1009
+ return {
1010
+ ok: false,
1011
+ error: e.message,
1012
+ staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
1013
+ };
1014
+ }
1015
+ throw e;
1016
+ }
776
1017
  // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
777
1018
  //
778
1019
  // Read back from disk, AFTER the write, and refuse to emit anything whose ref
@@ -800,12 +1041,54 @@ export async function landTool(args) {
800
1041
  events = { emitted: [ev], deliveries, refused: [] };
801
1042
  }
802
1043
  }
1044
+ /*
1045
+ * ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
1046
+ *
1047
+ * `merge` refuses this BEFORE the fact and is the control that prevents. This
1048
+ * one runs after, and its job is different: it is how the fleet LEARNS a
1049
+ * crossing happened anyway — through a merge done by hand, by `gh` directly,
1050
+ * or by a seat that never called the verb.
1051
+ *
1052
+ * It REPORTS and never refuses. A closure is not the right place to litigate a
1053
+ * merge that already happened: the verdict is QA's artefact, the merge may have
1054
+ * been someone else's, and blocking the record would leave the work landed and
1055
+ * unrecorded — strictly worse than landed and recorded with a flag on it.
1056
+ *
1057
+ * ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
1058
+ */
1059
+ const gatedHead = (() => {
1060
+ const mf = readMergeFacts(repo, n);
1061
+ if (!mf)
1062
+ return { checked: false, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
1063
+ const log = readVerdictLog(args.project);
1064
+ if (log === null)
1065
+ return { checked: false, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
1066
+ const at = Date.parse(mf.mergedAt);
1067
+ if (!Number.isFinite(at))
1068
+ 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);
1071
+ 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) } };
1073
+ }
1074
+ return {
1075
+ checked: true,
1076
+ gated: false,
1077
+ head: mf.headRefOid.slice(0, 8),
1078
+ reason: a.reason,
1079
+ gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
1080
+ 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.` +
1082
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1083
+ };
1084
+ })();
803
1085
  return {
804
1086
  ok: true,
805
1087
  project: args.project,
806
1088
  pr: `#${n}`,
807
1089
  comparedAgainst: ref,
808
1090
  landedIn: landedIn.slice(0, 8),
1091
+ gatedHead,
809
1092
  // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
810
1093
  // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
811
1094
  // fixed, so a foreign row cannot pass silently in either direction.
@@ -885,7 +1168,7 @@ export const mergeSchema = {
885
1168
  write: z.boolean().optional(),
886
1169
  };
887
1170
  const ghFacts = (repo, n) => {
888
- const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1171
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
889
1172
  cwd: repo,
890
1173
  encoding: "utf8",
891
1174
  stdio: ["ignore", "pipe", "ignore"],
@@ -910,6 +1193,7 @@ const ghFacts = (repo, n) => {
910
1193
  return {
911
1194
  state: String(j.state ?? ""),
912
1195
  mergeable: String(j.mergeable ?? ""),
1196
+ headRefOid: String(j.headRefOid ?? ""),
913
1197
  checks: j.statusCheckRollup ?? [],
914
1198
  diff: gh(["pr", "diff", n]),
915
1199
  citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
@@ -922,7 +1206,15 @@ const ghMerge = (repo, n, method) => {
922
1206
  stdio: ["ignore", "pipe", "ignore"],
923
1207
  });
924
1208
  };
925
- export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
1209
+ const roomLog = (project) => {
1210
+ try {
1211
+ return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
1212
+ }
1213
+ catch {
1214
+ return null;
1215
+ }
1216
+ };
1217
+ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVerdictLog = roomLog) {
926
1218
  const repo = args.repo ?? process.cwd();
927
1219
  const n = prNumber(args.pr);
928
1220
  if (!n)
@@ -1019,8 +1311,52 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
1019
1311
  }
1020
1312
  if (f.mergeable === "CONFLICTING")
1021
1313
  return { ok: false, error: `#${n} is CONFLICTING with its base.`, verdict };
1314
+ /*
1315
+ * ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
1316
+ *
1317
+ * A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
1318
+ * and took `1d6deab`, because the author force-pushed in between. Checks were
1319
+ * green on BOTH heads, so every refusal above passed honestly. Nothing asked
1320
+ * the only question that mattered: is the head in front of me the head the
1321
+ * verdict named?
1322
+ *
1323
+ * THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
1324
+ * only report afterwards. It sits before the dry-run return on purpose: a dry
1325
+ * run must say it would refuse, or the preview disagrees with the act.
1326
+ */
1327
+ const head = f.headRefOid ?? "";
1328
+ if (!head) {
1329
+ return {
1330
+ ok: false,
1331
+ error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
1332
+ verdict,
1333
+ };
1334
+ }
1335
+ const log = readVerdictLog(args.project);
1336
+ if (log === null) {
1337
+ return {
1338
+ ok: false,
1339
+ error: `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
1340
+ `was ever gated is UNKNOWN, and unknown is not gated.`,
1341
+ verdict,
1342
+ };
1343
+ }
1344
+ const { verdicts, unparsed } = verdictsFor(log, n);
1345
+ const gate = gatedAt(verdicts, head, Date.now());
1346
+ if (!gate.gated) {
1347
+ const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
1348
+ return {
1349
+ ok: false,
1350
+ error: `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
1351
+ (named ? `Gated instead: ${named}. ` : "") +
1352
+ `Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
1353
+ (unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
1354
+ verdict,
1355
+ gatedHead: { head, gated: false, reason: gate.reason },
1356
+ };
1357
+ }
1022
1358
  if (!args.write)
1023
- return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
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.` };
1024
1360
  doMerge(repo, n, args.method ?? "squash");
1025
1361
  return { ok: true, merged: true, verdict };
1026
1362
  }