agent-coord-mcp 0.26.16 → 0.26.18

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 (79) hide show
  1. package/hooks/replay.mjs +107 -0
  2. package/hooks/tier.mjs +11 -0
  3. package/hooks/tmux-pusher.mjs +27 -0
  4. package/package.json +1 -1
  5. package/scripts/coord-pusher.mjs +16 -32
  6. package/src/server.ts +40 -20
  7. package/src/tools/admin.ts +8 -1
  8. package/src/tools/away.ts +48 -4
  9. package/src/tools/board-ref.ts +47 -2
  10. package/src/tools/messaging.ts +43 -2
  11. package/src/tools/records.ts +381 -25
  12. package/src/tools/registry.ts +5 -2
  13. package/src/tools/rooms.ts +2 -1
  14. package/src/tools/shared.ts +28 -0
  15. package/src/tools/stall.ts +29 -1
  16. package/src/tools/transport.ts +79 -10
  17. package/src/tools/work.ts +70 -4
  18. package/src/tools/worktrees.ts +64 -2
  19. package/src/work.ts +4 -0
  20. package/dist/build.js +0 -113
  21. package/dist/build.js.map +0 -1
  22. package/dist/capabilities.js +0 -158
  23. package/dist/capabilities.js.map +0 -1
  24. package/dist/prefix.js +0 -64
  25. package/dist/prefix.js.map +0 -1
  26. package/dist/roles.js +0 -132
  27. package/dist/roles.js.map +0 -1
  28. package/dist/server-identity.js +0 -82
  29. package/dist/server-identity.js.map +0 -1
  30. package/dist/server.js +0 -625
  31. package/dist/server.js.map +0 -1
  32. package/dist/store.js +0 -553
  33. package/dist/store.js.map +0 -1
  34. package/dist/tools/admin.js +0 -317
  35. package/dist/tools/admin.js.map +0 -1
  36. package/dist/tools/attention.js +0 -73
  37. package/dist/tools/attention.js.map +0 -1
  38. package/dist/tools/away.js +0 -243
  39. package/dist/tools/away.js.map +0 -1
  40. package/dist/tools/board-ref.js +0 -164
  41. package/dist/tools/board-ref.js.map +0 -1
  42. package/dist/tools/event-kinds.js +0 -39
  43. package/dist/tools/event-kinds.js.map +0 -1
  44. package/dist/tools/events.js +0 -234
  45. package/dist/tools/events.js.map +0 -1
  46. package/dist/tools/index.js +0 -14
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tools/logwatch.js +0 -85
  49. package/dist/tools/logwatch.js.map +0 -1
  50. package/dist/tools/messaging.js +0 -667
  51. package/dist/tools/messaging.js.map +0 -1
  52. package/dist/tools/record-events.js +0 -380
  53. package/dist/tools/record-events.js.map +0 -1
  54. package/dist/tools/records.js +0 -686
  55. package/dist/tools/records.js.map +0 -1
  56. package/dist/tools/registry.js +0 -497
  57. package/dist/tools/registry.js.map +0 -1
  58. package/dist/tools/render.js +0 -2
  59. package/dist/tools/render.js.map +0 -1
  60. package/dist/tools/rooms.js +0 -210
  61. package/dist/tools/rooms.js.map +0 -1
  62. package/dist/tools/rotate.js +0 -143
  63. package/dist/tools/rotate.js.map +0 -1
  64. package/dist/tools/scopes.js +0 -126
  65. package/dist/tools/scopes.js.map +0 -1
  66. package/dist/tools/shared.js +0 -86
  67. package/dist/tools/shared.js.map +0 -1
  68. package/dist/tools/stall.js +0 -387
  69. package/dist/tools/stall.js.map +0 -1
  70. package/dist/tools/transport.js +0 -1810
  71. package/dist/tools/transport.js.map +0 -1
  72. package/dist/tools/work.js +0 -319
  73. package/dist/tools/work.js.map +0 -1
  74. package/dist/tools/worktrees.js +0 -339
  75. package/dist/tools/worktrees.js.map +0 -1
  76. package/dist/typed-records.js +0 -174
  77. package/dist/typed-records.js.map +0 -1
  78. package/dist/work.js +0 -2
  79. package/dist/work.js.map +0 -1
@@ -14,6 +14,11 @@ import { spawn, spawnSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { z } from "zod";
16
16
  import { readLog } from "./logwatch.js";
17
+ // The replay grammar is single-sourced in hooks/replay.mjs and shared with both
18
+ // pushers — see the note at the annotation site below. `hooks/` ships beside
19
+ // `dist/`, so this resolves the same in the built package as in source.
20
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
21
+ import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
17
22
  import { renderRecord } from "./render.js";
18
23
  import path from "node:path";
19
24
  import {
@@ -270,6 +275,21 @@ async function messageIdExists(id: string): Promise<boolean> {
270
275
  return false;
271
276
  }
272
277
 
278
+ // q-314e0187: `process.pid` is this MCP process's own pid — the process
279
+ // writing the entry right now, always known, no lookup needed. It is what a
280
+ // disputed message can be cross-referenced against (sessions/*.json,
281
+ // `ps -p <pid>`) when two live sessions hold one identity. In stdio mode this
282
+ // pid uniquely identifies the session (one process per session); in HTTP mode
283
+ // many sessions can share a process, so it identifies the SERVER handling the
284
+ // call rather than the caller — still strictly more than nothing, and the
285
+ // same scoping `trackSession` already applies to session markers.
286
+ function messageProvenance(): { pid: number; tmuxPane?: string } {
287
+ return {
288
+ pid: process.pid,
289
+ ...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
290
+ };
291
+ }
292
+
273
293
  export async function sendMessageTool(args: {
274
294
  from: string;
275
295
  to?: string;
@@ -375,6 +395,7 @@ export async function sendMessageTool(args: {
375
395
  text,
376
396
  ...(args.record ? { record: args.record } : {}),
377
397
  ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
398
+ provenance: messageProvenance(),
378
399
  };
379
400
  const target = inboxFile(args.to);
380
401
  await appendJsonl(target, msg);
@@ -400,6 +421,7 @@ export async function sendMessageTool(args: {
400
421
  ...(args.kind ? { kind: args.kind } : {}),
401
422
  ...(args.record ? { record: args.record } : {}),
402
423
  ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
424
+ provenance: messageProvenance(),
403
425
  };
404
426
  const target = roomFile(chan);
405
427
  await appendJsonl(target, msg);
@@ -540,11 +562,30 @@ export async function readMessagesTool(args: {
540
562
  ? recent.filter((e) => entryAuthor(e) !== args.agentId)
541
563
  : recent;
542
564
 
565
+ // Phase 5.3 Task 21.2 — ANNOTATE A MESSAGE THAT HAS ALREADY BEEN DELIVERED.
566
+ //
567
+ // The REMOTE pusher (scripts/coord-pusher.mjs) consumes the bus over the wire
568
+ // and cannot read this host's receipts/, so it cannot see for itself that a
569
+ // message it is about to paste was pasted before. Without this, the replay
570
+ // marker would appear in local panes only — HALF THE FLEET, and the half a
571
+ // reader could not identify from inside the pane, which is worse than no
572
+ // marker because its absence would read as "live".
573
+ //
574
+ // The two routes compute the same DATA and share ONE renderer
575
+ // (hooks/replay.mjs `replayMarker`), so neither pane can be handed a
576
+ // different vocabulary for the same fact.
577
+ const priorText = await fsp.readFile(receiptFile(args.agentId), "utf8").catch(() => "");
578
+ const prior = priorDeliveries(priorText);
579
+ const annotated = visible.map((e) => {
580
+ const replay = "id" in e ? replayInfo(prior, (e as Message).id) : undefined;
581
+ return replay ? { ...e, replay } : e;
582
+ });
583
+
543
584
  return {
544
585
  ok: true,
545
- messages: visible,
586
+ messages: annotated,
546
587
  totalNew,
547
- returned: visible.length,
588
+ returned: annotated.length,
548
589
  room: args.source === "room" ? normalizeRoom(args.room) : undefined,
549
590
  ...(history ? { history } : {}),
550
591
  };
@@ -18,17 +18,24 @@ import { z } from "zod";
18
18
  import {
19
19
  parseWorkDoc,
20
20
  renderWorkDoc,
21
+ renderWorkDocForWrite,
21
22
  queueItemsOf,
22
23
  doneEntriesOf,
23
24
  type QueueItem,
24
25
  type WorkDoc,
25
- phaseCitationsIn,
26
+ phaseCitationsDetailed,
26
27
  newlyTickedInDiff,
27
28
  sweepTagOf,
29
+ awaitingOf,
30
+ refsIn,
31
+ refsMatch,
32
+ workstreamsV1RowsOf,
33
+ type WorkstreamsV1Row,
34
+ type DoneEntry,
28
35
  } from "@davidbalzan/groundwork-seam";
29
36
  import { ensureWorktreeTool } from "./worktrees.js";
30
- import { boardRefFor } from "./board-ref.js";
31
- import { haltState } from "./stall.js";
37
+ import { boardRefFor, classifyBoardRef } from "./board-ref.js";
38
+ import { haltState, isInFlightStatus } from "./stall.js";
32
39
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
33
40
 
34
41
  const QUEUE_DOC = "docs/QUEUE.md";
@@ -51,11 +58,23 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
51
58
  * was built, not assumed). Re-rendering an untouched file would still be a write,
52
59
  * and a write is a diff someone has to review — so the no-op case returns false.
53
60
  */
54
- function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): boolean {
55
- const out = renderWorkDoc(doc);
56
- if (out === original) return false;
57
- writeFileSync(path.join(repo, rel), out);
58
- return true;
61
+ /**
62
+ * EVERY SUPPORTED WRITE OF A WORK DOC GOES THROUGH HERE, and it stamps.
63
+ *
64
+ * `renderWorkDocForWrite` records identity for any queue item that lacks it,
65
+ * including rows this caller did not author (q-c50e9b83) — the absorbed raw
66
+ * append is exactly the case, and stamping only the caller's own rows would keep
67
+ * the defect and move the blame. Every id written is the one the item already
68
+ * has, so nothing changes value.
69
+ *
70
+ * Returns what it stamped so the caller can report it rather than leaving an
71
+ * absorbing writer to find it in a diff.
72
+ */
73
+ function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): { written: boolean; stamped: string[] } {
74
+ const { text, stamped } = renderWorkDocForWrite(doc);
75
+ if (text === original) return { written: false, stamped: [] };
76
+ writeFileSync(path.join(repo, rel), text);
77
+ return { written: true, stamped };
59
78
  }
60
79
 
61
80
  const git = (repo: string, args: string[]): string =>
@@ -67,6 +86,25 @@ function prNumber(pr: string): string | null {
67
86
  return m ? (m[1] as string) : null;
68
87
  }
69
88
 
89
+ /**
90
+ * The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
91
+ * the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
92
+ * a closed item's citation to DONE.md. `land` must decide "already cited" with
93
+ * the SAME rule the auditor will apply, or the two disagree again one level
94
+ * down: a bare `#77` in an item body is a tie for doctor only when the origin
95
+ * URL says which repo, and it is a tie here under exactly that condition.
96
+ * Null (no remote, not a repo) means an unknown context, never a guessed one.
97
+ */
98
+ function originRepoOf(repo: string): string | null {
99
+ try {
100
+ const url = git(repo, ["remote", "get-url", "origin"]);
101
+ const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
102
+ return m ? (m[1] as string) : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
70
108
  // ---------- blocked / no-downstream ----------
71
109
 
72
110
  const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i;
@@ -127,6 +165,117 @@ export function noDownstream(items: QueueItem[]): QueueItem[] {
127
165
  });
128
166
  }
129
167
 
168
+ /* ────────────────────────────────────────────────────────────────────────────
169
+ * DELIVERED — READ THE RECORD'S STRUCTURE, NOT A SUBSTRING OF THE FILE.
170
+ *
171
+ * This was `doneText.includes(id) || boardText.includes(id)` over two whole
172
+ * documents, so ANY MENTION ANYWHERE counted as "handed out" — including a row
173
+ * written specifically to warn that an item must NOT be built (q-2ba7f0c5).
174
+ * The inversion is the finding: the more carefully a coordinator documents why
175
+ * something must not be routed, the more completely it disappears from the verb
176
+ * that routes.
177
+ *
178
+ * MEASURED ON `origin/main` @17b72ff, and the numbers moved the fix twice:
179
+ *
180
+ * q-11257590 hidden by the AIDE'S OWN `🚧 In Progress` ROW, which merely
181
+ * MENTIONS it in its Last note. A status-only rule leaves it
182
+ * hidden — the occurrence-vs-position defect one level in — so
183
+ * identification has to be the row's SUBJECT, not any of its cells.
184
+ * q-ad0fc8d4 hidden by its own `⏸ Returned to the aide unbuilt` row.
185
+ * q-507e80c4 hidden with NO PARSED ROW AT ALL: the id sits in board prose
186
+ * outside the table, which a whole-file substring test cannot
187
+ * distinguish from a lane.
188
+ * q-507e80c4 ALSO hidden by a DONE entry that CITES it as the still-open
189
+ * part (b) of work whose part (a) landed — the canon case
190
+ * q-912a67e8 requires, LIVE today rather than latent.
191
+ *
192
+ * Neither half is fixed by relabelling a row: that is satisfying a guard by
193
+ * editing what it reads (q-507e80c4 constraint 1) and it would destroy the
194
+ * warning the row exists to give.
195
+ */
196
+
197
+ /** The ids a board row names as ITS OWN SUBJECT — the Stream cell only. */
198
+ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
199
+ return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
200
+ }
201
+
202
+ /**
203
+ * Does the board show this item as IN SOMEBODY'S HANDS?
204
+ *
205
+ * Two conditions, and both are needed — measured, not reasoned:
206
+ * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
207
+ * since #219 rather than a second glyph list). `⏸ Parked`,
208
+ * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
209
+ * `🔻 Orphaned` describe work nobody holds, so they do not silence.
210
+ * SUBJECT the row's Stream cell names the item — by recorded id, or by the
211
+ * text prefix `claim` has always written there. A mention in a Last
212
+ * note is one lane REFERRING to another item — the aide's live row
213
+ * does exactly this — and a status-only rule would still hide it.
214
+ *
215
+ * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
216
+ * would have shipped a transitional hole in its own negative control: every row
217
+ * `claim` wrote before this change carries `keyOf(item)` — a 60-character text
218
+ * prefix — and no id at all, so an id-only rule leaves a lane's OWN live item
219
+ * routable. Measured: q-2ba7f0c5's row, written minutes earlier by the
220
+ * pre-merge server, was offered back while it was in hand. Rows written from
221
+ * here carry the id (see `claim`), so the prefix arm is the compatibility half
222
+ * rather than the mechanism, and it is deliberately still SUBJECT-only.
223
+ */
224
+ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boolean {
225
+ const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
226
+ return rows.some((r) => {
227
+ if (!isInFlightStatus(r.status)) return false;
228
+ if (rowSubjectIds(r).includes(id)) return true;
229
+ const stream = String(r.stream).replace(/\s+/g, " ").trim();
230
+ return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
236
+ * merely MENTIONS it?
237
+ *
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".
241
+ *
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:
245
+ *
246
+ * DELIVERY the entry carries the item's own COMPOSED SUMMARY — the
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.
259
+ *
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
+ * PR — so a ref-tie would have marked a genuinely-open part (b) as delivered.
264
+ */
265
+ function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
266
+ const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
267
+ const target = norm(summarize(text)).replace(/…$/, "");
268
+ const sigil = `⟨${id}⟩`;
269
+ return entries.some((e) => {
270
+ const t = String(e.text);
271
+ if (t.includes(sigil)) return true;
272
+ // A leading recorded-id token is stripped before comparing, so an entry
273
+ // written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
274
+ const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
275
+ return target.length >= 12 && body.startsWith(target);
276
+ });
277
+ }
278
+
130
279
  // ---------- next_unblocked ----------
131
280
 
132
281
  export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() };
@@ -147,7 +296,34 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
147
296
  const q = readDoc(repo, QUEUE_DOC);
148
297
  if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
149
298
  const items = queueItemsOf(q.doc);
150
- const open = items.filter((i) => !i.done);
299
+
300
+ // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
301
+ // establish that.
302
+ //
303
+ // MEASURED, 2026-09-01: an item whose work had merged in #196 was still `[ ]`
304
+ // in the queue — closing it is a separate act from landing it — so it stayed
305
+ // eligible. A compaction then re-issued its id, it surfaced as the top item,
306
+ // and it was routed back to the worker that had closed it an hour earlier.
307
+ // That worker declined because it recognised its own acceptance criteria:
308
+ // RETAINED CONTEXT, which is not a control and which a `/clear` or a
309
+ // compaction removes silently.
310
+ //
311
+ // So delivery is read from the RECORD instead: an item cited in docs/DONE.md,
312
+ // or already carrying a 🚧 row on the board, has been handed out. Stable ids
313
+ // (Task 21.1) are what make this join reliable — with a content-hash id the
314
+ // board row and the DONE entry stopped matching the moment anyone reworded
315
+ // the item, which is how the memory was lost in the first place.
316
+ const delivered = new Set<string>();
317
+ const doneDoc = readDoc(repo, DONE_DOC);
318
+ const boardDoc = readDoc(repo, BOARD_DOC);
319
+ const boardText = boardDoc?.text ?? "";
320
+ const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
321
+ const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
322
+ 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);
324
+ }
325
+
326
+ const open = items.filter((i) => !i.done && !delivered.has(i.id));
151
327
  const ranked = open
152
328
  .map((i, idx) => ({ i, idx }))
153
329
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -161,8 +337,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
161
337
  // Skipped VISIBLY, same discipline as a blocked top item — never silently
162
338
  // dropped, so a caller can tell "nothing left for me" from "nothing left".
163
339
  const notClaimable: { item: string; sweepTag: string }[] = [];
340
+ // AWAITING A HUMAN (q-e334937d). "Unblocked" means dependency-free, and an
341
+ // item awaiting a ruling is dependency-free and unbuildable at once. Measured
342
+ // 2026-09-02: this verb offered q-11257590 as next; the lane read the
343
+ // acceptance PROSE to learn it awaited David, declined, and picked by hand —
344
+ // a read every lane offered it would pay again. The fact is now a FIELD on
345
+ // the item (`**[AWAITS:<who>]**`, read by the seam), never inferred from
346
+ // prose. Its own axis, checked FIRST: it is the most specific thing that can
347
+ // be true of an item, and "three items wait on a human" is a fact David can
348
+ // act on where "nothing to route" is not. Skipped for routing, never dropped.
349
+ //
350
+ // GATHERED OVER EVERY OPEN ITEM, NOT OVER `open` — measured on the real queue
351
+ // the first time this ran: q-11257590 carried the marker and appeared in NO
352
+ // bucket, because the board holds a "⏸ Parked — awaits David" row naming it
353
+ // and the delivered-join above reads any board mention as "handed out". The
354
+ // row written to WARN about the item was what hid it. Routing still honours
355
+ // the join; this report does not, because its reader is the human the item
356
+ // waits on, and a pending decision does not stop being pending when someone
357
+ // writes it on the board.
358
+ const awaitingDecision: { item: string; id: string; who: string; onBoard: boolean }[] = [];
359
+ for (const i of items) {
360
+ if (i.done) continue;
361
+ const who = awaitingOf(i);
362
+ if (who) awaitingDecision.push({ item: keyOf(i), id: i.id, who, onBoard: boardText.includes(i.id) });
363
+ }
164
364
  let pick: QueueItem | null = null;
165
365
  for (const { i } of ranked) {
366
+ if (awaitingOf(i)) continue; // reported above; never routed
166
367
  const tag = sweepTagOf(i);
167
368
  if (tag && /^canon(\.\d+)?$/.test(tag)) {
168
369
  notClaimable.push({ item: keyOf(i), sweepTag: tag });
@@ -196,9 +397,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
196
397
  // would read a claimability gap as a dependency, which is a different
197
398
  // remedy (route it, don't wait for it).
198
399
  notClaimable,
400
+ // A THIRD AXIS, and not a variant of either above: `skipped` waits on
401
+ // another ITEM, `notClaimable` is not this caller's to take, and this waits
402
+ // on a HUMAN. The remedy differs each time (wait · route · ask), so one
403
+ // list covering them would leave the reader to infer which applies.
404
+ awaitingDecision,
199
405
  boardHunks: [
200
406
  ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
201
407
  ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
408
+ ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
202
409
  ],
203
410
  // A SEPARATE AXIS, deliberately. See noDownstream().
204
411
  noDownstream: undiscriminating
@@ -352,9 +559,57 @@ export async function claimTool(args: { project: string; agentId: string; itemId
352
559
  // It does not resolve YET, because a freshly cut branch is unpushed. That is
353
560
  // correct and `stall_check` says so in those words: an unpushed lane has no
354
561
  // shared evidence of activity, which is an absence of evidence rather than a
355
- // stall. Enforced at the WRITE as well as the read (board-ref.ts) — a rule the
356
- // writer can defeat is a rule the writer defeats.
357
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${boardRefFor(wt.branch ?? "")}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
562
+ // stall.
563
+ const intendedRef = boardRefFor(wt.branch ?? "");
564
+
565
+ /*
566
+ * AND NOW ACTUALLY ENFORCED AT THE WRITE.
567
+ *
568
+ * The comment here used to claim exactly that — "enforced at the WRITE as well
569
+ * as the read, a rule the writer can defeat is a rule the writer defeats" —
570
+ * and it was FALSE. `boardRefFor` PREFIXES `origin/`; it refuses nothing. So
571
+ * `claim` could still write `origin/main` into a board cell, which
572
+ * `stall_check` then correctly refuses to measure, leaving a row that can
573
+ * never stall and never be measured. board-ref.ts's own header states the
574
+ * rule; #197 shipped the read half and I wrote the sentence and did not do it.
575
+ *
576
+ * A rule carrying a false mechanism is worse than an absent rule: the next
577
+ * reader derives from the mechanism, and this one asserted the very coverage
578
+ * it lacked.
579
+ *
580
+ * REFUSED: the four kinds that are structurally wrong however fresh the lane
581
+ * is — a shared ref (measures the fleet), a path (not a ref at all), another
582
+ * agent's branch (measures their work), and an empty cell.
583
+ *
584
+ * ALLOWED: `unpushed` and `local-only`, deliberately. A freshly cut branch is
585
+ * ALWAYS unpushed, so refusing those would refuse every legitimate claim —
586
+ * the over-narrowing I have now made twice in this classifier's history, where
587
+ * a rule meant to make a check honest disabled it instead. `merged` is allowed
588
+ * too but noted, since re-claiming onto a landed branch is unusual rather than
589
+ * structurally broken.
590
+ */
591
+ const refusable = new Set(["shared", "path", "unscoped", "empty"]);
592
+ const cellVerdict = classifyBoardRef(repo, args.agentId, `\`${intendedRef}\``, `origin/${args.base ?? "main"}`);
593
+ if (refusable.has(cellVerdict.kind)) {
594
+ return {
595
+ ok: false as const,
596
+ error:
597
+ `cannot claim: the board cell would name '${intendedRef}', which is not a per-agent activity signal ` +
598
+ `(${cellVerdict.kind}) — ${"why" in cellVerdict ? cellVerdict.why : ""} ` +
599
+ `The row would be unmeasurable the moment it was written, so it is refused here rather than reported later.`,
600
+ item: { id: item.id, priority: item.priority },
601
+ worktree: { path: wt.path, branch: wt.branch },
602
+ };
603
+ }
604
+
605
+ // THE ROW NAMES THE ITEM BY ID, so the delivered-join can recognise a row this
606
+ // verb itself wrote. Measured on the live board: `keyOf` is the item's text
607
+ // prefix and the recorded `⟨id⟩` token is not part of `text`, so NO
608
+ // claim-written row has ever carried an id — the join could only ever match
609
+ // HAND-WRITTEN rows, which is the opposite of what it was for. Stamping it
610
+ // makes the identification recorded rather than derived from a 60-character
611
+ // prose prefix that any rewording changes.
612
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
358
613
 
359
614
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
360
615
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -391,6 +646,8 @@ export const landSchema = {
391
646
  repo: z.string().optional(),
392
647
  base: z.string().optional(),
393
648
  write: z.boolean().optional(),
649
+ /** One-line result, written after the citation on the closed item's line. */
650
+ result: z.string().optional(),
394
651
  };
395
652
 
396
653
  export async function landTool(args: {
@@ -400,6 +657,7 @@ export async function landTool(args: {
400
657
  repo?: string;
401
658
  base?: string;
402
659
  write?: boolean;
660
+ result?: string;
403
661
  }) {
404
662
  const repo = args.repo ?? process.cwd();
405
663
  const base = args.base ?? "main";
@@ -459,13 +717,46 @@ export async function landTool(args: {
459
717
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
460
718
  if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
461
719
 
462
- // CLOSE = STATUS ONLY. The item's priority and body BYTES are untouched: the
463
- // seam re-renders `- [x] (P2) <text>` from the same text it parsed, so closing
464
- // cannot reword an item. That is asserted by a fixture, not by inspection.
720
+ // CLOSE = STATUS + CITATION. The item's priority and body BYTES are untouched:
721
+ // the seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
722
+ // closing cannot reword an item. That is asserted by a fixture, not by
723
+ // inspection.
724
+ //
725
+ // AND THE LINE CITES WHAT CLOSED IT (q-6d21ff84). `doctor:queue-done-loop`
726
+ // requires a `[x]` item's FIRST LINE to cite the PR that DONE.md records —
727
+ // that strictness is what makes a closure machine-checkable, and the repo
728
+ // paid for it. This verb ticked the box and wrote DONE.md and left line 1
729
+ // uncited, so EVERY successful `land` turned main red at the next root gate
730
+ // and the coordinator hand-repaired a file the verb had just written
731
+ // (`c29becd`, and again on q-e334937d). The writer and the auditor were built
732
+ // to different contracts; the fix belongs in the writer, because loosening
733
+ // the auditor to accept what the writer emits is how a check stops being one.
734
+ //
735
+ // A SUFFIX, NOT A REWRITE. The existing text is preserved byte-for-byte as a
736
+ // prefix; ` · **closed by <ref>[ — <result>]**` is appended — the exact form
737
+ // q-40449919 carries after its manual repair. Line 1 only, because line 1 is
738
+ // all `item.text` is and all the check reads; a citation on a continuation
739
+ // line satisfies nothing (the occurrence-vs-position trap, hit twice).
740
+ //
741
+ // IDEMPOTENT: an item whose line already cites this PR (in any spelling
742
+ // `refsIn` ties to it) is left byte-identical, so a second `land` cannot
743
+ // double-cite and a hand-cited item is not re-cited.
465
744
  let queueChanged = false;
745
+ let citationAppended: string | null = null;
746
+ // The DONE entry and the emitted event describe the WORK, from the text the
747
+ // item had BEFORE the citation was appended — otherwise the summary would
748
+ // carry the citation the DONE line already ends with, twice on one line.
749
+ const originalText = target ? String(target.text) : null;
466
750
  if (target) {
467
751
  target.done = true;
468
752
  queueChanged = true;
753
+ const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
754
+ const contextRepo = originRepoOf(repo);
755
+ const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
756
+ if (!alreadyCited) {
757
+ citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
758
+ target.text = `${target.text}${citationAppended}`;
759
+ }
469
760
  }
470
761
 
471
762
  const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
@@ -480,7 +771,7 @@ export async function landTool(args: {
480
771
  // So: the summary comes from the item being closed, and a bare `#N` is reported
481
772
  // as UNDER-QUALIFIED rather than silently written.
482
773
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
483
- const summary = target ? summarize(target.text) : null;
774
+ const summary = originalText !== null ? summarize(originalText) : null;
484
775
 
485
776
  const doneLine =
486
777
  already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
@@ -497,8 +788,17 @@ export async function landTool(args: {
497
788
  // byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
498
789
  // an entry added to the record model renders through the pinned glyph contract.
499
790
  const wrote: string[] = [];
791
+ let stampedNotSupplied: string[] = [];
500
792
  if (args.write) {
501
- if (queueChanged && writeDoc(repo, QUEUE_DOC, q.doc, q.text)) wrote.push(QUEUE_DOC);
793
+ if (queueChanged) {
794
+ const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
795
+ if (w.written) wrote.push(QUEUE_DOC);
796
+ // Rows stamped that this call did not ask to touch. `queueItemId` is the
797
+ // one item the caller supplied, so anything else here is a row that
798
+ // arrived unstamped from somewhere: the absorption this verb would
799
+ // otherwise carry to main under its own name.
800
+ stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
801
+ }
502
802
  if (doneLine) {
503
803
  const blocks = d.doc.blocks;
504
804
  let last = -1;
@@ -516,7 +816,7 @@ export async function landTool(args: {
516
816
  return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
517
817
  }
518
818
  block.entries.push(entry);
519
- if (writeDoc(repo, DONE_DOC, d.doc, d.text)) wrote.push(DONE_DOC);
819
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
520
820
  }
521
821
  }
522
822
 
@@ -535,7 +835,7 @@ export async function landTool(args: {
535
835
  if (args.write && target) {
536
836
  const after = readDoc(repo, DONE_DOC);
537
837
  const recordText = after?.text ?? "";
538
- const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(target.text) };
838
+ const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
539
839
  const derived = eventIsDerived(recordText, ev);
540
840
  if (!derived.ok) {
541
841
  events.refused.push(derived.error);
@@ -553,7 +853,31 @@ export async function landTool(args: {
553
853
  pr: `#${n}`,
554
854
  comparedAgainst: ref,
555
855
  landedIn: landedIn.slice(0, 8),
556
- queueItem: target ? { id: target.id, closed: true, textUnchanged: true } : null,
856
+ // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
857
+ // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
858
+ // fixed, so a foreign row cannot pass silently in either direction.
859
+ ...(stampedNotSupplied.length
860
+ ? {
861
+ stampedNotSupplied,
862
+ stampNote:
863
+ `recorded an id for ${stampedNotSupplied.length} queue item(s) this call did not supply (${stampedNotSupplied.join(", ")}) — ` +
864
+ `they arrived unstamped, most likely a raw text append, and would have reddened doctor:queue-id-recorded on whoever committed next. ` +
865
+ `Each id written is the one the item already had, so no value changed.`,
866
+ }
867
+ : {}),
868
+ queueItem: target
869
+ ? {
870
+ id: target.id,
871
+ closed: true,
872
+ // `textUnchanged` keeps its meaning — the priority and the body bytes
873
+ // the item had are all still there, in place. What is new is a SUFFIX
874
+ // on line 1, reported separately so a reader can tell "reworded" from
875
+ // "cited": null when the line already cited this PR.
876
+ textUnchanged: citationAppended === null,
877
+ bodyPreserved: true,
878
+ citationAppended,
879
+ }
880
+ : null,
557
881
  events,
558
882
  candidates: target
559
883
  ? undefined
@@ -757,15 +1081,47 @@ export async function mergeTool(
757
1081
  // copied, because a copied grammar is two grammars the moment one is fixed.
758
1082
  const ticked = newlyTickedInDiff(f.diff ?? "");
759
1083
  if (ticked.size) {
760
- const cited = phaseCitationsIn(f.citationText ?? "");
761
- const uncited = [...ticked].filter((k) => !cited.has(k));
1084
+ /*
1085
+ * A RELATIONAL CITATION DOES NOT EVIDENCE A TICK.
1086
+ *
1087
+ * "unblocks Phase 5.2 Task 15.6" names the task without claiming it is done,
1088
+ * so a PR that ticks 15.6's box and cites it that way is exactly as
1089
+ * unevidenced as one that never mentioned it. Measured on the other side of
1090
+ * this grammar the same day: doctor read that sentence in DONE.md as a
1091
+ * completion claim and red-gated main.
1092
+ *
1093
+ * The two cases are REPORTED SEPARATELY, because they need different fixes
1094
+ * and "UNCITED" would send someone looking for a citation that is already
1095
+ * there. A relational citation needs REWORDING; an absent one needs ADDING.
1096
+ */
1097
+ const detail = phaseCitationsDetailed(f.citationText ?? "");
1098
+ const closes = new Set(detail.filter((c) => c.relation === "claim").map((c) => c.key));
1099
+ const relational = new Map(detail.filter((c) => c.relation === "reference").map((c) => [c.key, c.marker]));
1100
+ const uncited = [...ticked].filter((k) => !closes.has(k));
762
1101
  if (uncited.length) {
763
- const names = uncited.map((k) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; });
1102
+ const nameOf = (k: string) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; };
1103
+ const names = uncited.map(nameOf);
1104
+ const worded = uncited.filter((k) => relational.has(k));
1105
+ const missing = uncited.filter((k) => !relational.has(k));
1106
+ const parts: string[] = [];
1107
+ if (missing.length) {
1108
+ parts.push(
1109
+ `${missing.length} NOT CITED AT ALL: ${missing.map(nameOf).join(", ")} — name them in the PR title or a commit ` +
1110
+ `subject (e.g. "${nameOf(missing[0] as string)}"), the subject being what survives a squash merge`,
1111
+ );
1112
+ }
1113
+ if (worded.length) {
1114
+ parts.push(
1115
+ `${worded.length} cited only RELATIONALLY: ` +
1116
+ worded.map((k) => `${nameOf(k)} (after '${relational.get(k)}')`).join(", ") +
1117
+ ` — that names the task without claiming it is done, so it evidences nothing. Reword it, or drop the tick`,
1118
+ );
1119
+ }
764
1120
  return {
765
1121
  ok: false as const,
766
1122
  error:
767
- `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNCITED: ${names.join(", ")}. ` +
768
- `Once this merges, the tick claims progress that nothing in history evidences. Name them in the PR title or a commit subject (e.g. "${names[0]}") — the subject is what survives a squash merge.`,
1123
+ `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNEVIDENCED. ` +
1124
+ `${parts.join(". ")}. Once this merges, the tick claims progress nothing in history supports.`,
769
1125
  verdict,
770
1126
  uncited: names,
771
1127
  };
@@ -79,8 +79,11 @@ export const registerSchema = {
79
79
  project: z.string().optional(),
80
80
  role: roleInputSchema.optional(),
81
81
  // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
82
- // that is LIVE on the bus refuses unless the call presents that agent's
83
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
82
+ // that is PROVABLY LIVE on the bus refuses unless the call presents that
83
+ // agent's token (tokens.json / coord-token) `force` alone no longer
84
+ // overrides a provably live incumbent (q-314e0187). `force` still bypasses
85
+ // the guard when liveness cannot be verified, or the id is verified absent.
86
+ // Both ignored once bound.
84
87
  token: z.string().optional(),
85
88
  force: z.boolean().optional(),
86
89
  // PROSE-ONLY EXEMPTION (Phase 5.1 Task 12.8). `true` grants it, `false`
@@ -60,6 +60,7 @@ import {
60
60
  setOffset,
61
61
  sysMsg,
62
62
  moveFile,
63
+ agentIdFromCursorFilename,
63
64
  STALE_MS,
64
65
  EVICT_MS,
65
66
  MAX_WAIT_MS,
@@ -284,7 +285,7 @@ export async function deleteRoomTool(args: { agentId: string; room: string; forc
284
285
  // a now-deleted file.
285
286
  const cursorsAdjusted: string[] = [];
286
287
  for (const fname of await listCursorFiles()) {
287
- const id = fname.replace(/\.json$/, "");
288
+ const { id } = agentIdFromCursorFilename(fname);
288
289
  const cursorPath = path.join(CURSOR_DIR, fname);
289
290
  let touched = false;
290
291
  await updateJson<Cursor>(cursorPath, {}, (current) => {