agent-coord-mcp 0.26.17 → 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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/server.ts +2 -2
  3. package/src/tools/admin.ts +8 -1
  4. package/src/tools/away.ts +48 -4
  5. package/src/tools/records.ts +274 -18
  6. package/src/tools/rooms.ts +2 -1
  7. package/src/tools/shared.ts +18 -0
  8. package/src/tools/stall.ts +29 -1
  9. package/src/tools/transport.ts +12 -4
  10. package/src/tools/work.ts +70 -4
  11. package/src/tools/worktrees.ts +64 -2
  12. package/src/work.ts +4 -0
  13. package/dist/build.js +0 -113
  14. package/dist/build.js.map +0 -1
  15. package/dist/capabilities.js +0 -158
  16. package/dist/capabilities.js.map +0 -1
  17. package/dist/prefix.js +0 -64
  18. package/dist/prefix.js.map +0 -1
  19. package/dist/roles.js +0 -132
  20. package/dist/roles.js.map +0 -1
  21. package/dist/server-identity.js +0 -82
  22. package/dist/server-identity.js.map +0 -1
  23. package/dist/server.js +0 -646
  24. package/dist/server.js.map +0 -1
  25. package/dist/store.js +0 -553
  26. package/dist/store.js.map +0 -1
  27. package/dist/tools/admin.js +0 -317
  28. package/dist/tools/admin.js.map +0 -1
  29. package/dist/tools/attention.js +0 -73
  30. package/dist/tools/attention.js.map +0 -1
  31. package/dist/tools/away.js +0 -243
  32. package/dist/tools/away.js.map +0 -1
  33. package/dist/tools/board-ref.js +0 -208
  34. package/dist/tools/board-ref.js.map +0 -1
  35. package/dist/tools/event-kinds.js +0 -39
  36. package/dist/tools/event-kinds.js.map +0 -1
  37. package/dist/tools/events.js +0 -234
  38. package/dist/tools/events.js.map +0 -1
  39. package/dist/tools/index.js +0 -14
  40. package/dist/tools/index.js.map +0 -1
  41. package/dist/tools/logwatch.js +0 -85
  42. package/dist/tools/logwatch.js.map +0 -1
  43. package/dist/tools/messaging.js +0 -706
  44. package/dist/tools/messaging.js.map +0 -1
  45. package/dist/tools/record-events.js +0 -380
  46. package/dist/tools/record-events.js.map +0 -1
  47. package/dist/tools/records.js +0 -778
  48. package/dist/tools/records.js.map +0 -1
  49. package/dist/tools/registry.js +0 -500
  50. package/dist/tools/registry.js.map +0 -1
  51. package/dist/tools/render.js +0 -2
  52. package/dist/tools/render.js.map +0 -1
  53. package/dist/tools/rooms.js +0 -210
  54. package/dist/tools/rooms.js.map +0 -1
  55. package/dist/tools/rotate.js +0 -143
  56. package/dist/tools/rotate.js.map +0 -1
  57. package/dist/tools/scopes.js +0 -126
  58. package/dist/tools/scopes.js.map +0 -1
  59. package/dist/tools/shared.js +0 -86
  60. package/dist/tools/shared.js.map +0 -1
  61. package/dist/tools/stall.js +0 -387
  62. package/dist/tools/stall.js.map +0 -1
  63. package/dist/tools/transport.js +0 -1871
  64. package/dist/tools/transport.js.map +0 -1
  65. package/dist/tools/work.js +0 -319
  66. package/dist/tools/work.js.map +0 -1
  67. package/dist/tools/worktrees.js +0 -339
  68. package/dist/tools/worktrees.js.map +0 -1
  69. package/dist/typed-records.js +0 -174
  70. package/dist/typed-records.js.map +0 -1
  71. package/dist/work.js +0 -2
  72. package/dist/work.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.17",
3
+ "version": "0.26.18",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/server.ts CHANGED
@@ -627,7 +627,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
627
627
 
628
628
  addTool(
629
629
  "stall_check",
630
- "The stall predicate over the board and the bus: a 🚧 row whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
630
+ "The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
631
631
  stallCheckSchema,
632
632
  gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
633
633
  );
@@ -662,7 +662,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
662
662
 
663
663
  addTool(
664
664
  "next_unblocked",
665
- "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
665
+ "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also skips — as its own axis, `awaitingDecision`, never silently — an item that DECLARES it awaits a named human (`**[AWAITS:<who>]**` leading the item line; never inferred from prose): dependency-free and unbuildable at once, so it is reported for the human and not routed to a lane. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
666
666
  nextUnblockedSchema,
667
667
  gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
668
668
  );
@@ -31,6 +31,7 @@ import {
31
31
  type Message,
32
32
  type StatusEntry,
33
33
  isDecision,
34
+ agentIdFromCursorFilename,
34
35
  } from "./shared.js";
35
36
 
36
37
  // ---------- prune ----------
@@ -73,7 +74,13 @@ export async function adjustCursors(removals: {
73
74
  const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
74
75
  const cursorsAdjusted: string[] = [];
75
76
  for (const cname of await listCursorFiles()) {
76
- const id = cname.replace(/\.json$/, "");
77
+ // q-40449919: this was `cname.replace(/\.json$/, "")`, which strips only
78
+ // the outer extension. A push cursor's `<agent>.push.json` then keyed
79
+ // `perAgentInboxRemoved` lookups below on `<agent>.push` — a key that
80
+ // never matches — so a push cursor's inboxOffset was silently never
81
+ // decremented after a prune shrank the inbox, drifting it toward a
82
+ // false past-EOF the next time doctor ran.
83
+ const { id } = agentIdFromCursorFilename(cname);
77
84
  const cursorPath = path.join(CURSOR_DIR, cname);
78
85
  let touched = false;
79
86
  await updateJson<Cursor>(cursorPath, {}, (current) => {
package/src/tools/away.ts CHANGED
@@ -155,7 +155,14 @@ export function secondCoordinatorRefusal(
155
155
 
156
156
  export const coordAwaySchema = {
157
157
  project: z.string().min(1),
158
- on: z.boolean(),
158
+ // Omitted → a QUERY: report current state and mutate nothing (q-1abbbdbf).
159
+ // `/coord-away` used to be a bare toggle read by a human deciding which
160
+ // direction to call it — the incident this fixes is David typing it
161
+ // meaning ARM while it was already armed, and the aide independently
162
+ // reading the same typed command as RELEASE, with no state visible to
163
+ // either of them before acting. State-it-aloud-first only works if there
164
+ // is a state to read that does not itself flip the switch.
165
+ on: z.boolean().optional(),
159
166
  /** The agent leading in David's absence — the aide. */
160
167
  leadId: z.string().optional(),
161
168
  /** Repo-relative path where decisions taken during the absence are written. */
@@ -184,7 +191,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
184
191
 
185
192
  export async function coordAwayTool(args: {
186
193
  project: string;
187
- on: boolean;
194
+ on?: boolean;
188
195
  leadId?: string;
189
196
  decisionLog?: string;
190
197
  until?: string;
@@ -195,6 +202,38 @@ export async function coordAwayTool(args: {
195
202
  const state = readAway();
196
203
  const prior = state[args.project];
197
204
 
205
+ // QUERY — `on` omitted. Reads, never writes: no coverage measurement, no
206
+ // refusal, no side effect. This is the call a card makes to state current
207
+ // status aloud BEFORE deciding which direction to arm/release, so the
208
+ // decision is never made blind the way it was on 2026-08-30/31.
209
+ if (args.on === undefined) {
210
+ if (!prior?.on) {
211
+ return {
212
+ ok: true as const,
213
+ query: true as const,
214
+ project: args.project,
215
+ armed: false,
216
+ state: prior ?? null,
217
+ summary: prior
218
+ ? `'${args.project}' is NOT armed — last released ${prior.at}.`
219
+ : `'${args.project}' is NOT armed — never armed.`,
220
+ };
221
+ }
222
+ return {
223
+ ok: true as const,
224
+ query: true as const,
225
+ project: args.project,
226
+ armed: true,
227
+ state: prior,
228
+ summary:
229
+ `'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
230
+ `${prior.until ? ` until ${prior.until}` : ""}. ` +
231
+ `Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
232
+ `${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
233
+ `Decisions logged to ${prior.decisionLog}.`,
234
+ };
235
+ }
236
+
198
237
  if (args.on) {
199
238
  // A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
200
239
  // worse than an absence everyone can see is uncovered.
@@ -261,8 +300,12 @@ export async function coordAwayTool(args: {
261
300
  state: state[args.project],
262
301
  refused: LEAD_REFUSED,
263
302
  parked: PARKED_CATEGORIES,
303
+ // A confirmation naming the transition, not just the imperative
304
+ // direction (q-1abbbdbf) — "ON" and "already ON" read the same at a
305
+ // glance; "OFF → ARMED" cannot be misread as a no-op or a repeat.
306
+ transition: "OFF → ARMED" as const,
264
307
  announce:
265
- `AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
308
+ `OFF → ARMED. AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
266
309
  `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
267
310
  `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
268
311
  `Decisions logged to ${args.decisionLog}. ` +
@@ -280,8 +323,9 @@ export async function coordAwayTool(args: {
280
323
  return {
281
324
  ok: true as const,
282
325
  state: state[args.project],
326
+ transition: "ARMED → RELEASED" as const,
283
327
  announce:
284
- `AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
328
+ `ARMED → RELEASED. AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
285
329
  `Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
286
330
  };
287
331
  }
@@ -18,6 +18,7 @@ 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,
@@ -25,10 +26,16 @@ import {
25
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
37
  import { boardRefFor, classifyBoardRef } from "./board-ref.js";
31
- import { haltState } from "./stall.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() };
@@ -166,12 +315,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
166
315
  // the item, which is how the memory was lost in the first place.
167
316
  const delivered = new Set<string>();
168
317
  const doneDoc = readDoc(repo, DONE_DOC);
169
- const doneText = doneDoc?.text ?? "";
170
318
  const boardDoc = readDoc(repo, BOARD_DOC);
171
319
  const boardText = boardDoc?.text ?? "";
320
+ const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
321
+ const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
172
322
  for (const i of items) {
173
- // The id as written, so a recorded id matches the same token in either doc.
174
- if (doneText.includes(i.id) || boardText.includes(i.id)) delivered.add(i.id);
323
+ if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text))) delivered.add(i.id);
175
324
  }
176
325
 
177
326
  const open = items.filter((i) => !i.done && !delivered.has(i.id));
@@ -188,8 +337,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
188
337
  // Skipped VISIBLY, same discipline as a blocked top item — never silently
189
338
  // dropped, so a caller can tell "nothing left for me" from "nothing left".
190
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
+ }
191
364
  let pick: QueueItem | null = null;
192
365
  for (const { i } of ranked) {
366
+ if (awaitingOf(i)) continue; // reported above; never routed
193
367
  const tag = sweepTagOf(i);
194
368
  if (tag && /^canon(\.\d+)?$/.test(tag)) {
195
369
  notClaimable.push({ item: keyOf(i), sweepTag: tag });
@@ -223,9 +397,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
223
397
  // would read a claimability gap as a dependency, which is a different
224
398
  // remedy (route it, don't wait for it).
225
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,
226
405
  boardHunks: [
227
406
  ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
228
407
  ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
408
+ ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
229
409
  ],
230
410
  // A SEPARATE AXIS, deliberately. See noDownstream().
231
411
  noDownstream: undiscriminating
@@ -422,7 +602,14 @@ export async function claimTool(args: { project: string; agentId: string; itemId
422
602
  };
423
603
  }
424
604
 
425
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
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 |`;
426
613
 
427
614
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
428
615
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -459,6 +646,8 @@ export const landSchema = {
459
646
  repo: z.string().optional(),
460
647
  base: z.string().optional(),
461
648
  write: z.boolean().optional(),
649
+ /** One-line result, written after the citation on the closed item's line. */
650
+ result: z.string().optional(),
462
651
  };
463
652
 
464
653
  export async function landTool(args: {
@@ -468,6 +657,7 @@ export async function landTool(args: {
468
657
  repo?: string;
469
658
  base?: string;
470
659
  write?: boolean;
660
+ result?: string;
471
661
  }) {
472
662
  const repo = args.repo ?? process.cwd();
473
663
  const base = args.base ?? "main";
@@ -527,13 +717,46 @@ export async function landTool(args: {
527
717
  const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
528
718
  if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
529
719
 
530
- // CLOSE = STATUS ONLY. The item's priority and body BYTES are untouched: the
531
- // seam re-renders `- [x] (P2) <text>` from the same text it parsed, so closing
532
- // 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.
533
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;
534
750
  if (target) {
535
751
  target.done = true;
536
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
+ }
537
760
  }
538
761
 
539
762
  const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
@@ -548,7 +771,7 @@ export async function landTool(args: {
548
771
  // So: the summary comes from the item being closed, and a bare `#N` is reported
549
772
  // as UNDER-QUALIFIED rather than silently written.
550
773
  const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
551
- const summary = target ? summarize(target.text) : null;
774
+ const summary = originalText !== null ? summarize(originalText) : null;
552
775
 
553
776
  const doneLine =
554
777
  already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
@@ -565,8 +788,17 @@ export async function landTool(args: {
565
788
  // byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
566
789
  // an entry added to the record model renders through the pinned glyph contract.
567
790
  const wrote: string[] = [];
791
+ let stampedNotSupplied: string[] = [];
568
792
  if (args.write) {
569
- 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
+ }
570
802
  if (doneLine) {
571
803
  const blocks = d.doc.blocks;
572
804
  let last = -1;
@@ -584,7 +816,7 @@ export async function landTool(args: {
584
816
  return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
585
817
  }
586
818
  block.entries.push(entry);
587
- 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);
588
820
  }
589
821
  }
590
822
 
@@ -603,7 +835,7 @@ export async function landTool(args: {
603
835
  if (args.write && target) {
604
836
  const after = readDoc(repo, DONE_DOC);
605
837
  const recordText = after?.text ?? "";
606
- 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 ?? "") };
607
839
  const derived = eventIsDerived(recordText, ev);
608
840
  if (!derived.ok) {
609
841
  events.refused.push(derived.error);
@@ -621,7 +853,31 @@ export async function landTool(args: {
621
853
  pr: `#${n}`,
622
854
  comparedAgainst: ref,
623
855
  landedIn: landedIn.slice(0, 8),
624
- 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,
625
881
  events,
626
882
  candidates: target
627
883
  ? undefined
@@ -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) => {
@@ -229,6 +229,24 @@ export type Cursor = {
229
229
 
230
230
  export type Source = "inbox" | "room" | "status";
231
231
 
232
+ // q-40449919 (regression from 0.26.8): a `cursors/*.json` filename has TWO
233
+ // shapes since the push/read cursor split (hooks/push-cursor.mjs) —
234
+ // `<agent>.json` (read) and `<agent>.push.json` (push) — and every reader of
235
+ // `listCursorFiles()` was stripping only the outer `.json`, so a push
236
+ // cursor for `worker-3` parsed as an agent literally named `worker-3.push`.
237
+ // That id is unregistered by construction (false `orphan-inboxes-cursors`),
238
+ // and looking up ITS inbox resolves to a file that never existed, so its
239
+ // real, correct offset compares as past-EOF against an empty file — the
240
+ // bus's only error-level finding, driving `healthy:false` for a healthy
241
+ // fleet. ONE parser, used everywhere a cursor filename becomes an agent id,
242
+ // so the assumption cannot re-drift into some other reader the way the
243
+ // filename shape itself drifted out from under the callers that predate it.
244
+ export function agentIdFromCursorFilename(fname: string): { id: string; kind: "read" | "push" } {
245
+ const push = /^(.*)\.push\.json$/.exec(fname);
246
+ if (push) return { id: push[1], kind: "push" };
247
+ return { id: fname.replace(/\.json$/, ""), kind: "read" };
248
+ }
249
+
232
250
  // Resolve the physical file for a (source, agent, channel) tuple.
233
251
  export function sourceFile(source: Source, agentId: string, room?: string): string {
234
252
  if (source === "inbox") return inboxFile(agentId);
@@ -218,6 +218,34 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
218
218
 
219
219
  export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.number().optional() };
220
220
 
221
+ /**
222
+ * A row somebody is WORKING — the population the stall clock watches.
223
+ *
224
+ * It was `/🚧/` alone, and that read a fleet living in review as an EMPTY
225
+ * fleet (q-507e80c4). Measured on a consumer fleet 2026-09-02: a board of 3× `🔍 In
226
+ * Review` and 0× `🚧` returned `checked: 0, measurable: 0`, and `coord_away`
227
+ * armed at 0/0 — correct and vacuous at once. A clock watching nothing and a
228
+ * clock watching a healthy fleet return identical green.
229
+ *
230
+ * `🔍 In Review` is a lane with an owner and a branch that can stop moving
231
+ * exactly as an authoring lane can; the review that never gets re-gated is
232
+ * THE stall shape of a QA-gated fleet. It is read here, on the row as written
233
+ * — relabelling `🔍`→`🚧` to satisfy the old predicate was the item's
234
+ * forbidden move, because it satisfies a guard by editing what it reads and
235
+ * reports authoring lanes that do not exist.
236
+ *
237
+ * NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
238
+ * `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
239
+ * would be the 0/0 defect inverted.
240
+ *
241
+ * One predicate, exported: `coord_away` measures coverage by calling
242
+ * `stall_check` and reading `checked`/`measurable`, so it inherits this
243
+ * population without a change of its own. Anything else that asks "which rows
244
+ * are in flight" should ask here rather than re-derive it from a glyph.
245
+ */
246
+ export const IN_FLIGHT_STATUS = /🚧|🔍/;
247
+ export const isInFlightStatus = (status: string): boolean => IN_FLIGHT_STATUS.test(status);
248
+
221
249
  export async function stallCheckTool(args: { repo?: string; stallMinutes?: number }) {
222
250
  const repo = args.repo ?? process.cwd();
223
251
  const limit = (args.stallMinutes ?? 30) * 60 * 1000;
@@ -226,7 +254,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
226
254
 
227
255
  const liveTransports = await loadLiveTransports();
228
256
  const rows = workstreamsV1RowsOf(parseWorkDoc(readFileSync(board, "utf8")));
229
- const inFlight = rows.filter((r) => /🚧/.test(r.status));
257
+ const inFlight = rows.filter((r) => isInFlightStatus(r.status));
230
258
  const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
231
259
  const now = Date.now();
232
260
  const hits: StallHit[] = [];