agent-coord-mcp 0.26.21 → 0.26.22

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 (62) hide show
  1. package/dist/capabilities.js +250 -2
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/closing-line.js +83 -0
  4. package/dist/closing-line.js.map +1 -0
  5. package/dist/commit-cite.js +55 -0
  6. package/dist/commit-cite.js.map +1 -0
  7. package/dist/gated-head.js +67 -20
  8. package/dist/gated-head.js.map +1 -1
  9. package/dist/server-spread.js +195 -0
  10. package/dist/server-spread.js.map +1 -0
  11. package/dist/server.js +2 -2
  12. package/dist/server.js.map +1 -1
  13. package/dist/store.js +32 -0
  14. package/dist/store.js.map +1 -1
  15. package/dist/tools/away.js +67 -7
  16. package/dist/tools/away.js.map +1 -1
  17. package/dist/tools/board-ref.js +44 -4
  18. package/dist/tools/board-ref.js.map +1 -1
  19. package/dist/tools/event-kinds.js +5 -1
  20. package/dist/tools/event-kinds.js.map +1 -1
  21. package/dist/tools/events.js +31 -2
  22. package/dist/tools/events.js.map +1 -1
  23. package/dist/tools/messaging.js +64 -6
  24. package/dist/tools/messaging.js.map +1 -1
  25. package/dist/tools/record-events.js +85 -5
  26. package/dist/tools/record-events.js.map +1 -1
  27. package/dist/tools/records.js +231 -38
  28. package/dist/tools/records.js.map +1 -1
  29. package/dist/tools/registry.js +52 -2
  30. package/dist/tools/registry.js.map +1 -1
  31. package/dist/tools/seat-build.js +173 -0
  32. package/dist/tools/seat-build.js.map +1 -0
  33. package/dist/tools/shared.js.map +1 -1
  34. package/dist/tools/stall.js +1095 -18
  35. package/dist/tools/stall.js.map +1 -1
  36. package/dist/tools/transport.js +21 -2
  37. package/dist/tools/transport.js.map +1 -1
  38. package/dist/tools/worktrees.js +14 -0
  39. package/dist/tools/worktrees.js.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/coord-attention-clock.mjs +2 -0
  42. package/scripts/coord-stall-clock.mjs +52 -11
  43. package/src/capabilities.ts +264 -2
  44. package/src/closing-line.ts +85 -0
  45. package/src/commit-cite.ts +58 -0
  46. package/src/gated-head.ts +128 -26
  47. package/src/server-spread.ts +233 -0
  48. package/src/server.ts +2 -2
  49. package/src/store.ts +32 -0
  50. package/src/tools/away.ts +82 -9
  51. package/src/tools/board-ref.ts +70 -3
  52. package/src/tools/event-kinds.ts +17 -1
  53. package/src/tools/events.ts +33 -2
  54. package/src/tools/messaging.ts +63 -6
  55. package/src/tools/record-events.ts +78 -5
  56. package/src/tools/records.ts +248 -38
  57. package/src/tools/registry.ts +54 -3
  58. package/src/tools/seat-build.ts +194 -0
  59. package/src/tools/shared.ts +22 -0
  60. package/src/tools/stall.ts +1266 -23
  61. package/src/tools/transport.ts +21 -2
  62. package/src/tools/worktrees.ts +13 -0
package/src/tools/away.ts CHANGED
@@ -75,7 +75,46 @@ export const PARKED_CATEGORIES = [
75
75
  "destructive machine actions",
76
76
  ] as const;
77
77
 
78
- export type AwayCoverage = { checked: number; measurable: number; blind: string[]; at: string };
78
+ /*
79
+ * ⟨q-5a71fc3b⟩ — THE THRESHOLD, NAMED. `coord_away` armed a fleet that was 3/5
80
+ * blind, silently: the refusal only fired at ZERO measurable lanes, so "2 of
81
+ * 5" — three seats the clock could not see — read as covered enough. A
82
+ * partially blind fleet IS the incident, not a lesser case of it: the seats
83
+ * nobody can see are exactly the ones an absence leaves unattended. So the
84
+ * threshold is every in-flight lane the clock scores, the refusal names the
85
+ * seats short of it, and `acknowledgeBlindFleet` is the only way past.
86
+ */
87
+ export const COVERAGE_THRESHOLD = "EVERY in-flight lane the clock scores must be measurable";
88
+ /*
89
+ * ⟨q-ce585029⟩ — A QUERY REPORTS THE COVERAGE MEASURED AT ARM TIME, AND SAYS
90
+ * SO. "Coverage 2/5" read on 09-11 was three days old by the time it was
91
+ * quoted as current. The query stays free (no re-measure); the summary
92
+ * carries the reading's age and labels it stale past this many minutes.
93
+ */
94
+ export const COVERAGE_STALE_MINUTES = 60;
95
+ export const fmtAge = (ms: number): string => (ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.floor(ms / 60_000)}m` : ms < 86_400_000 ? `${Math.floor(ms / 3_600_000)}h` : `${Math.floor(ms / 86_400_000)}d`);
96
+ export function coverageAgeOf(coverage: { at: string }, now = Date.now()): { ageMs: number | null; stale: boolean; label: string } {
97
+ const at = Date.parse(coverage.at);
98
+ if (!Number.isFinite(at)) return { ageMs: null, stale: true, label: "measured at an unreadable time — treat as stale" };
99
+ const ageMs = Math.max(0, now - at);
100
+ const stale = ageMs > COVERAGE_STALE_MINUTES * 60_000;
101
+ return { ageMs, stale, label: `measured ${fmtAge(ageMs)} ago${stale ? ` — STALE (older than ${COVERAGE_STALE_MINUTES}m; re-arm or run stall_check for a current reading)` : ""}` };
102
+ }
103
+ /** Met when nothing scored is unseen — an empty board (0 of 0) is idle, not blind, and needs no special case. */
104
+ export const meetsCoverageThreshold = (c: { checked: number; measurable: number }): boolean => c.measurable >= c.checked;
105
+
106
+ export type AwayCoverage = {
107
+ checked: number;
108
+ measurable: number;
109
+ blind: string[];
110
+ /** ⟨q-5d1c8e04⟩ — standing seats on the board: present, not scored, and therefore not blind. */
111
+ roles?: number;
112
+ /** Lanes that declare no per-agent branch: out of the population by their own statement. */
113
+ deliberate?: number;
114
+ /** ⟨q-3d82f1a9⟩ — the board could not be read: rows present vs parsed, as stall_check stated it. */
115
+ unreadable?: { rowsPresent: number; rowsParsed: number; why: string };
116
+ at: string;
117
+ };
79
118
 
80
119
  export type AwayState = {
81
120
  on: boolean;
@@ -180,10 +219,28 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
180
219
  if (!repo) return null;
181
220
  try {
182
221
  const r = (await stallCheckTool({ repo })) as unknown as {
183
- ok?: boolean; checked?: number; measurable?: number; blind?: string[];
222
+ ok?: boolean; checked?: number; measurable?: number; blind?: string[]; roles?: unknown[]; deliberate?: unknown[];
223
+ boardParse?: { readable: boolean; rowsPresent: number; rowsParsed: number; why: string };
184
224
  };
225
+ // ⟨q-3d82f1a9⟩ — AN UNREADABLE BOARD IS A NAMED STATE, not "could not measure".
226
+ // The clock refused to answer because the board broke; arming on it would
227
+ // promise a watched fleet over a table nobody can read.
228
+ if (r?.boardParse && !r.boardParse.readable) {
229
+ return {
230
+ checked: 0, measurable: 0, blind: [], roles: 0, deliberate: 0,
231
+ unreadable: { rowsPresent: r.boardParse.rowsPresent, rowsParsed: r.boardParse.rowsParsed, why: r.boardParse.why },
232
+ at: new Date().toISOString(),
233
+ };
234
+ }
185
235
  if (!r?.ok) return null;
186
- return { checked: r.checked ?? 0, measurable: r.measurable ?? 0, blind: r.blind ?? [], at: new Date().toISOString() };
236
+ return {
237
+ checked: r.checked ?? 0,
238
+ measurable: r.measurable ?? 0,
239
+ blind: r.blind ?? [],
240
+ roles: r.roles?.length ?? 0,
241
+ deliberate: r.deliberate?.length ?? 0,
242
+ at: new Date().toISOString(),
243
+ };
187
244
  } catch {
188
245
  return null;
189
246
  }
@@ -225,10 +282,12 @@ export async function coordAwayTool(args: {
225
282
  project: args.project,
226
283
  armed: true,
227
284
  state: prior,
285
+ coverageAge: coverageAgeOf(prior.coverage),
228
286
  summary:
229
287
  `'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
230
288
  `${prior.until ? ` until ${prior.until}` : ""}. ` +
231
- `Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
289
+ `Coverage ${prior.coverage.measurable}/${prior.coverage.checked} lane(s) ${coverageAgeOf(prior.coverage).label}` +
290
+ `${prior.coverage.roles ? ` · ${prior.coverage.roles} role(s) present, not scored` : ""}` +
232
291
  `${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
233
292
  `Decisions logged to ${prior.decisionLog}.`,
234
293
  };
@@ -265,17 +324,31 @@ export async function coordAwayTool(args: {
265
324
  `record that the promise is being made anyway.`,
266
325
  };
267
326
  }
327
+ // ⟨q-3d82f1a9⟩ — REFUSED OUTRIGHT, and not acknowledgeable: a blind fleet is
328
+ // one the clock can see and cannot measure; an unreadable board is one the
329
+ // clock cannot even see. "0 of 0" here is not idle, and no flag makes it so.
330
+ if (coverage.unreadable) {
331
+ return {
332
+ ok: false as const,
333
+ error:
334
+ `REFUSING: docs/WORKSTREAMS.md is UNREADABLE — ${coverage.unreadable.rowsPresent} row(s) present in the Active Streams table, ` +
335
+ `${coverage.unreadable.rowsParsed} parsed. ${coverage.unreadable.why} ` +
336
+ `Away mode promises a watched fleet; a board the clock cannot read is not a quiet fleet, it is an unwatched one. Fix the row and re-arm.`,
337
+ };
338
+ }
268
339
  // `checked > 0` MATTERS: an empty board is an IDLE fleet, not a blind one.
269
340
  // Without it, arming while nothing is in flight refuses with "the clock
270
341
  // covers 0 of 0" — a refusal that reads as a fleet nobody can see when in
271
342
  // fact there is nothing to see, and the only way past it would be to
272
343
  // acknowledge a blindness that does not exist.
273
- if (coverage.checked > 0 && coverage.measurable === 0 && !args.acknowledgeBlindFleet) {
344
+ // ⟨q-5a71fc3b⟩ below the threshold, not only at zero: "2 of 5" refuses.
345
+ if (!meetsCoverageThreshold(coverage) && !args.acknowledgeBlindFleet) {
274
346
  return {
275
347
  ok: false as const,
276
348
  error:
277
- `REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s). ` +
278
- `It would RUN and see nothing${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
349
+ `REFUSING: the stall clock covers ${coverage.measurable} of ${coverage.checked} in-flight row(s); the threshold is that ${COVERAGE_THRESHOLD} ` +
350
+ `(${coverage.checked - coverage.measurable} short)${coverage.blind.length ? ` — blind on: ${coverage.blind.join(", ")}` : ""}. ` +
351
+ `${coverage.measurable === 0 ? "It would RUN and see nothing. " : "The seats it cannot see are exactly the ones an absence leaves unattended. "}` +
279
352
  `"The clock ran" and "the fleet is observed" are different facts, and this verb must not treat the first as the second. ` +
280
353
  `The usual cause is a board 'Branch · Worktree' cell holding a PATH rather than a branch ref: a path resolves for ` +
281
354
  `git and measures the wrong thing, so the check reports it unmeasurable rather than guessing. Fix those cells and ` +
@@ -292,7 +365,7 @@ export async function coordAwayTool(args: {
292
365
  at: new Date().toISOString(),
293
366
  decisionLog: args.decisionLog,
294
367
  coverage,
295
- ...(coverage.checked > 0 && coverage.measurable === 0 ? { blindAcknowledged: true } : {}),
368
+ ...(!meetsCoverageThreshold(coverage) ? { blindAcknowledged: true } : {}),
296
369
  };
297
370
  writeAway(state);
298
371
  return {
@@ -309,7 +382,7 @@ export async function coordAwayTool(args: {
309
382
  `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
310
383
  `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
311
384
  `Decisions logged to ${args.decisionLog}. ` +
312
- `Stall coverage ${coverage.measurable}/${coverage.checked}${coverage.checked > 0 && coverage.measurable === 0 ? " — ACKNOWLEDGED BLIND: the clock runs and sees nothing" : ""}.`,
385
+ `Stall coverage ${coverage.measurable}/${coverage.checked}${!meetsCoverageThreshold(coverage) ? ` ACKNOWLEDGED BLIND${coverage.measurable === 0 ? ": the clock runs and sees nothing" : ""}${coverage.blind.length ? ` on: ${coverage.blind.join(", ")}` : ""}` : ""}.`,
313
386
  };
314
387
  }
315
388
 
@@ -49,6 +49,16 @@ import { execFileSync } from "node:child_process";
49
49
  export type BoardRefVerdict =
50
50
  | { kind: "measurable"; ref: string }
51
51
  | { kind: "empty"; why: string }
52
+ /**
53
+ * ⟨q-5d1c8e04⟩ — A DELIBERATE NO-BRANCH. The cell holds WORDS in the ref
54
+ * position (`docs-direct · no per-agent branch`, `own clone …`): a human
55
+ * statement that this lane carries no per-agent branch. Out of the stall
56
+ * clock's population, never "unmeasurable" — five seats wrote this shape
57
+ * because the grammar gave them no other way to say it.
58
+ */
59
+ | { kind: "none"; why: string }
60
+ /** A ref a human ABBREVIATED for display (`…/kit-worker-lane`) — unreadable by construction, and said so. */
61
+ | { kind: "elided"; why: string }
52
62
  | { kind: "path"; why: string }
53
63
  | { kind: "shared"; why: string }
54
64
  | { kind: "unscoped"; why: string }
@@ -84,9 +94,51 @@ const isSyntacticallyValidRef = (name: string): boolean => {
84
94
  }
85
95
  };
86
96
 
87
- /** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
97
+ /*
98
+ * ⟨q-5d1c8e04⟩ — THE CELL GRAMMAR, read by POSITION rather than by scanning.
99
+ *
100
+ * \`ref\` · <path> the ref is the LEADING backticked token; what follows is a note
101
+ * — (or blank) nothing declared — on a lane row this is "lacks a branch"
102
+ * <prose> a deliberate statement: this row carries no per-agent branch
103
+ *
104
+ * `refInCell` used to take the FIRST backticked token ANYWHERE, so the aide's
105
+ * `**own clone \`groundwork-kit-aide-write\`** (ADR-016, \`a7bd341\`)` yielded a
106
+ * clone name that was then looked up as `origin/groundwork-kit-aide-write` and
107
+ * reported "unpushed" — a ref manufactured from prose. Measured live: five
108
+ * prose cells on one board, three of them reaching `unmeasurable` under three
109
+ * different mis-parses. A position cannot be mis-parsed that way.
110
+ */
111
+ export type CellKind = "ref" | "empty" | "prose";
112
+ export function cellKindOf(cell: string): CellKind {
113
+ const s = String(cell ?? "").trim();
114
+ if (!s || s === "—" || s === "-" || s === "–") return "empty";
115
+ return /^`[^`]+`/.test(s) ? "ref" : "prose";
116
+ }
117
+ /** The `\`ref\`` in a `Branch · Worktree` cell's REF POSITION (leading token), or "". */
88
118
  export function refInCell(cell: string): string {
89
- return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
119
+ return (String(cell ?? "").trim().match(/^`([^`]+)`/)?.[1] ?? "").trim();
120
+ }
121
+ /** A display elision a human typed into a machine-read field. */
122
+ const ELIDED = /…|\.\.\./;
123
+
124
+ /*
125
+ * ⟨q-5d1c8e04⟩ — LANES vs ROLES. A ROLE row is a standing seat (coordinator,
126
+ * aide) with nothing to score: no branch, no slice, no stall. It is written with
127
+ * its own status glyph and a `—` branch cell, in the same table. The glyph is
128
+ * a bus-level ROW KIND, not a work state: the seam's `workStateOf` reads it as
129
+ * `unknown`, which is correct — a role is not work, and the seam is not asked
130
+ * to invent a state for it.
131
+ *
132
+ * ⚠ A 🪑 ROW THAT CARRIES A REF IS A LANE IN DISGUISE and is scored as one —
133
+ * relabelling work as a role must not exempt it from the clock.
134
+ */
135
+ export const ROLE_GLYPH = "🪑";
136
+ const STATUS_DECORATION = /^[\s*⭐]+/u;
137
+ export const isRoleStatus = (status: string): boolean => String(status ?? "").replace(STATUS_DECORATION, "").startsWith(ROLE_GLYPH);
138
+ export type RowKind = "lane" | "role" | "role-with-ref";
139
+ export function rowKindOf(row: { status: string; branchWorktree: string }): RowKind {
140
+ if (!isRoleStatus(row.status)) return "lane";
141
+ return cellKindOf(row.branchWorktree) === "ref" ? "role-with-ref" : "role";
90
142
  }
91
143
 
92
144
  /** The remote-tracking form a board cell should name for `branch`. */
@@ -106,7 +158,22 @@ export function classifyBoardRef(
106
158
  base = "origin/main",
107
159
  ): BoardRefVerdict {
108
160
  const raw = refInCell(cell);
109
- if (!raw) return { kind: "empty", why: "no ref in the Branch · Worktree cell" };
161
+ if (!raw) {
162
+ // Words in the ref position are a DECLARATION, not an absence: the row
163
+ // says it has no per-agent branch. Only a blank or `—` is "nothing said".
164
+ if (cellKindOf(cell) === "prose") {
165
+ return { kind: "none", why: `the Branch · Worktree cell declares in words that this row carries no per-agent branch: "${String(cell).trim().slice(0, 60)}"` };
166
+ }
167
+ return { kind: "empty", why: "no ref in the Branch · Worktree cell, and nothing declared in its place" };
168
+ }
169
+ if (ELIDED.test(raw)) {
170
+ return {
171
+ kind: "elided",
172
+ why:
173
+ `'${raw}' is a ref a human abbreviated for display — it contains an elision and cannot be looked up as written. ` +
174
+ `Unreadable by construction, not "unpushed": the real ref may well exist. Write the full ref in the cell; the note can carry the shape.`,
175
+ };
176
+ }
110
177
 
111
178
  const bare = raw.replace(/^origin\//, "");
112
179
 
@@ -17,6 +17,18 @@
17
17
  * widen without adding an emitter in the same file.
18
18
  */
19
19
  export type SubKind = keyof typeof EVENT_KINDS;
20
+ /**
21
+ * ⟨q-2b7d9f04⟩ — EVERY KIND SHIPS WITH THE CHANGE THAT MUST PRODUCE IT. The
22
+ * `item` subscription sat at `health: ok` for three days while structurally
23
+ * unable to fire: an emitter existed, so the registry was satisfied, and the
24
+ * scanner RAN, so liveness was satisfied — but the grammar never read the
25
+ * leading item id `land` writes, so no live commit could ever produce the
26
+ * kind. A probe is the LIVE shape, not the smallest one: two items out, two
27
+ * leading-id entries in, one PR — the #328 commit. Health asks the scanner to
28
+ * produce each kind from its probe; a kind whose probe yields nothing CANNOT
29
+ * FIRE, whatever the emitter list and the scan clock say.
30
+ */
31
+ export type KindProbe = { diff: string; after?: { done?: string; phases?: Record<string, string> } };
20
32
  export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
21
33
 
22
34
  /*
@@ -37,23 +49,27 @@ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary:
37
49
  export const EVENT_KINDS = {
38
50
  item: {
39
51
  record: "docs/QUEUE.md + docs/DONE.md",
40
- what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
52
+ what: "a queue item closed — a DONE.md entry beginning with its ⟨q-…⟩ id appeared (land writes it), or it left QUEUE.md against one unambiguous entry",
41
53
  targetIs: "the queue item id",
54
+ probe: { diff: "diff --git a/docs/QUEUE.md b/docs/QUEUE.md\n--- a/docs/QUEUE.md\n+++ b/docs/QUEUE.md\n@@ -1,1 +1,1 @@\n-- [ ] (P1) ⟨q-0a0a0a01⟩ Kit: the first thing to do\n-- [ ] (P1) ⟨q-0a0a0a02⟩ Kit: the second thing to do\n\ndiff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] ⟨q-0a0a0a01⟩ Kit: what was done for the first… — owner/repo#77 · 2026-09-14\n+- [x] ⟨q-0a0a0a02⟩ Kit: what was done for the second… — owner/repo#77 · 2026-09-14\n" } as KindProbe,
42
55
  },
43
56
  pr: {
44
57
  record: "docs/DONE.md",
45
58
  what: "a PR recorded in the completion log",
46
59
  targetIs: "the PR ref, e.g. owner/repo#163",
60
+ probe: { diff: "diff --git a/docs/DONE.md b/docs/DONE.md\n--- a/docs/DONE.md\n+++ b/docs/DONE.md\n@@ -1,1 +1,1 @@\n+- [x] a PR recorded in the completion log — owner/repo#78 · 2026-09-14\n" } as KindProbe,
47
61
  },
48
62
  task: {
49
63
  record: "docs/phases/**/PHASE*_TASKS.md",
50
64
  what: "a phase task checkbox newly ticked",
51
65
  targetIs: "the task key, e.g. 5:12.1",
66
+ probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.1 first\n+- [x] 1.1 first\n" } as KindProbe,
52
67
  },
53
68
  phase: {
54
69
  record: "docs/phases/**/PHASE*_TASKS.md",
55
70
  what: "the last open checkbox in a phase document ticked",
56
71
  targetIs: "the phase number, e.g. 5",
72
+ probe: { diff: "diff --git a/docs/phases/phase9/PHASE9_TASKS.md b/docs/phases/phase9/PHASE9_TASKS.md\n--- a/docs/phases/phase9/PHASE9_TASKS.md\n+++ b/docs/phases/phase9/PHASE9_TASKS.md\n@@ -1,1 +1,1 @@\n-- [ ] 1.2 second\n+- [x] 1.2 second\n", after: { phases: { "docs/phases/phase9/PHASE9_TASKS.md": "# Phase 9\n\n- [x] 1.1 first\n- [x] 1.2 second\n" } } } as KindProbe,
57
73
  },
58
74
  } as const;
59
75
 
@@ -78,8 +78,33 @@ function writeSubs(subs: Subscription[]): void {
78
78
  * subscription with no last-evaluated mark has produced no evidence of
79
79
  * anything, and "no events" is the same output a broken subscription gives.
80
80
  */
81
+ /**
82
+ * ⟨q-2b7d9f04⟩ — CAPABILITY, NOT LIVENESS. The `item` subscription read
83
+ * `health: ok — no events yet` for three days while the scanner's grammar
84
+ * could not produce its kind from any commit: the scan clock was fresh, the
85
+ * emitter list had an entry, and the field answered the easier question. The
86
+ * probe is registered by record-events.ts (which owns the grammar) and asks
87
+ * "can this kind be produced at all?"; a kind that cannot is an ERROR, however
88
+ * recently the scanner ran. Absent probe → no claim either way.
89
+ */
90
+ let capabilityProbe: ((kind: SubKind) => boolean) | null = null;
91
+ export function setCapabilityProbe(fn: ((kind: SubKind) => boolean) | null): void {
92
+ capabilityProbe = fn;
93
+ }
94
+ export const kindCanFire = (kind: SubKind): boolean | null => (capabilityProbe ? capabilityProbe(kind) : null);
95
+
81
96
  export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
82
97
  const iso = (n: number) => new Date(n).toISOString();
98
+ // ⟨q-2b7d9f04⟩ — a kind the grammar cannot produce is not ok, scanned or not.
99
+ if (kindCanFire(s.kind) === false) {
100
+ return {
101
+ level: "error",
102
+ detail:
103
+ `CANNOT FIRE — the scanner's grammar produces no '${s.kind}' event from the kind's own probe, so this subscription is structurally unable to deliver ` +
104
+ `${s.lastScannedAt ? `(scanned ${iso(s.lastScannedAt)}: the clock is live, the capability is not)` : "(and has never been scanned)"}. ` +
105
+ "Never fired and cannot fire are different facts; this is the second.",
106
+ };
107
+ }
83
108
 
84
109
  // THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
85
110
  // collapse cost a week: an `item` subscriber read `error — never evaluated`
@@ -258,10 +283,16 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
258
283
  const all = readSubs();
259
284
  const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
260
285
  const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
261
- const neverEvaluated = rows.filter((r) => r.health.level === "error");
286
+ const cannotFire = rows.filter((r) => r.health.level === "error" && /^CANNOT FIRE/.test(r.health.detail));
287
+ const neverEvaluated = rows.filter((r) => r.health.level === "error" && !/^CANNOT FIRE/.test(r.health.detail));
262
288
  const undetermined = rows.filter((r) => r.health.level === "unknown");
263
289
  return {
264
- ok: neverEvaluated.length === 0,
290
+ ok: neverEvaluated.length === 0 && cannotFire.length === 0,
291
+ // ⟨q-2b7d9f04⟩ — capability per kind, beside each row's liveness.
292
+ capability: Object.fromEntries(EVENT_KIND_IDS.map((k) => [k, kindCanFire(k)])),
293
+ ...(cannotFire.length
294
+ ? { cannotFire: `${cannotFire.length} of ${rows.length} subscription(s) are to a kind the scanner CANNOT PRODUCE — they will never fire, however live the scan clock reads: ${[...new Set(cannotFire.map((r) => r.kind))].join(", ")}.` }
295
+ : {}),
265
296
  // Population beside the verdict, always: "no subscriptions" and "none
266
297
  // listed for you" are different claims.
267
298
  population: { listed: rows.length, total: all.length },
@@ -13,6 +13,7 @@ import { promises as fsp } from "node:fs";
13
13
  import { spawn, spawnSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { z } from "zod";
16
+ import { checkClosingLine } from "../closing-line.js";
16
17
  import { readLog } from "./logwatch.js";
17
18
  // The replay grammar is single-sourced in hooks/replay.mjs and shared with both
18
19
  // pushers — see the note at the annotation site below. `hooks/` ships beside
@@ -82,6 +83,8 @@ import {
82
83
  MAX_WAIT_MS,
83
84
  isDecision,
84
85
  } from "./shared.js";
86
+ import { isKnownHuman } from "../store.js";
87
+ import { verifyCommitCite } from "../commit-cite.js";
85
88
 
86
89
  // ---------- send_message ----------
87
90
 
@@ -110,10 +113,18 @@ const decisionPayload = z.looseObject({
110
113
  ifNoAction: z.string().min(1),
111
114
  });
112
115
 
116
+ // ⟨q-dcbaf544⟩ — `gatedBy` is the seat that JUDGED when it is not the sender;
117
+ // `scribe` is the sender that TRANSCRIBED it. Fields, not prose: a name in
118
+ // prose is a mention, a name in a field is a position, and only the position
119
+ // survives a scanner. Authority does not move (David's ruling 2026-09-14):
120
+ // the roles that may emit `verdict` are unchanged; this is how a gate routed
121
+ // to any other seat reaches the record without lying about who gated.
113
122
  const verdictPayload = z.looseObject({
114
123
  result: z.enum(["pass", "fail"]),
115
124
  headRefOid: z.string().min(1),
116
125
  notes: z.string().optional(),
126
+ gatedBy: z.string().min(1).optional(),
127
+ scribe: z.string().min(1).optional(),
117
128
  });
118
129
 
119
130
  // Discriminated on `type`, so an unknown type is rejected outright while a
@@ -170,6 +181,21 @@ export async function checkRecordAuthority(
170
181
  };
171
182
  }
172
183
 
184
+ /** ⟨q-dcbaf544⟩ — `scribe`, when present, must be the sender; `gatedBy` may name anyone (that is its point). */
185
+ export function checkVerdictScribe(from: string, record: MessageRecord | undefined): { ok: true } | { ok: false; error: string } {
186
+ if (!record || record.type !== "verdict") return { ok: true };
187
+ const p = record.payload as { scribe?: string; gatedBy?: string } | undefined;
188
+ if (p?.scribe && p.scribe !== from) {
189
+ return {
190
+ ok: false,
191
+ error:
192
+ `verdict payload.scribe is '${p.scribe}' but the sender is '${from}' — the scribe is the seat that SENDS the record. ` +
193
+ `Put the seat that judged in payload.gatedBy and either omit scribe or set it to '${from}'.`,
194
+ };
195
+ }
196
+ return { ok: true };
197
+ }
198
+
173
199
  // ---------- typed records obligatory (Phase 5.1 Task 12) ----------
174
200
 
175
201
  // An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
@@ -202,7 +228,10 @@ async function typedRecordCheck(args: {
202
228
  // David-facing prose stays prose. An UNREGISTERED recipient is treated as an
203
229
  // agent, not as a human: the safe reading of "I cannot tell" is the rule, and
204
230
  // a human on this bus has a registry entry (that is how the pane is found).
205
- if (args.to && isHuman(reg[args.to])) return { ok: true };
231
+ // ⟨q-178878aa⟩ and the human need not be a REGISTERED agent: a registry entry for a
232
+ // human is evicted after EVICT_MS (no heartbeat), so the exemption keys on the durable
233
+ // human set the server knows (humans.json + AGENT_COORD_HUMANS), read here, at send time.
234
+ if (args.to && (isHuman(reg[args.to]) || (await isKnownHuman(args.to)))) return { ok: true };
206
235
 
207
236
  const sender = reg[args.from];
208
237
  if (sender?.proseOnly) return { ok: true };
@@ -227,7 +256,8 @@ async function typedRecordCheck(args: {
227
256
  `${guidance} ` +
228
257
  `Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
229
258
  `catch-all — do not force a false 'decision'/'risk' to get past this. ` +
230
- `Messages TO a human are exempt, and an agent that cannot pick a type declares proseOnly:true at join.`,
259
+ `Messages TO a human are exempt (a recipient registered with a human role, or an id the server knows as human: ` +
260
+ `humans.json / AGENT_COORD_HUMANS — see list_agents.humans), and an agent that cannot pick a type declares proseOnly:true at join.`,
231
261
  };
232
262
  }
233
263
 
@@ -245,6 +275,9 @@ export const sendMessageSchema = {
245
275
  // unknown → store + warn) so the rejection shape matches identity-binding
246
276
  // / unknown-recipient, not a schema throw. Existing records unchanged.
247
277
  inReplyTo: z.string().optional(),
278
+ // ⟨q-cc0819dc⟩ — needed only for a `done` cited by COMMIT: the repository whose
279
+ // origin/main must carry the sha (one local git call, no network).
280
+ repo: z.string().optional(),
248
281
  };
249
282
 
250
283
  const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -298,25 +331,47 @@ export async function sendMessageTool(args: {
298
331
  kind?: "decision" | "status" | "chatter";
299
332
  record?: MessageRecord;
300
333
  inReplyTo?: string;
334
+ repo?: string;
301
335
  }) {
302
336
  // Record authority first — a sender who may not emit this type is refused
303
337
  // before any other check runs, so a rejected record writes nothing anywhere.
304
338
  const authority = await checkRecordAuthority(args.from, args.record);
305
339
  if (!authority.ok) return { ok: false as const, error: authority.error };
340
+ // ⟨q-dcbaf544⟩ — a scribe is the sender by definition; a verdict whose
341
+ // `scribe` names someone else has its two positions crossed and would be
342
+ // attributed wrongly by every reader. Refused as a value, nothing written.
343
+ const scribeCheck = checkVerdictScribe(args.from, args.record);
344
+ if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
306
345
 
307
346
  // A `done` must cite the work it claims. Presence and shape only — resolving
308
347
  // the ref against gh/git is a consumer's job, and the send path makes no
309
348
  // network calls. Rejected as a value, not a throw, mirroring the
310
349
  // identity-binding rejection in src/server.ts.
311
350
  if (args.record?.type === "done") {
312
- const hasPr = (args.record.cites ?? []).some((c) => c.kind === "pr" && c.ref.trim().length > 0);
313
- if (!hasPr) {
351
+ // ⟨q-fee7239f⟩ the same check that reads the closing citation reads the
352
+ // closing GRAMMAR: a merge closing may not assert a deletion it has not
353
+ // read. Refused as a value, naming the two accepted forms; nothing written.
354
+ const closing = checkClosingLine(String(args.text ?? ""));
355
+ if (!closing.ok) return { ok: false as const, error: closing.error };
356
+ const cites = args.record.cites ?? [];
357
+ const hasPr = cites.some((c) => c.kind === "pr" && c.ref.trim().length > 0);
358
+ const commitCites = cites.filter((c) => c.kind === "commit");
359
+ if (!hasPr && commitCites.length === 0) {
314
360
  return {
315
361
  ok: false as const,
316
362
  error:
317
- "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim",
363
+ "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim. " +
364
+ "Work that has no PR by rule (docs pushed straight to the shared branch) cites {kind:'commit', ref:<full 40-hex sha>} " +
365
+ "and passes `repo`, the repository whose origin/main carries it.",
318
366
  };
319
367
  }
368
+ // ⟨q-cc0819dc⟩ — a COMMIT cite satisfies a `done` only when the commit is real and on the
369
+ // shared branch: every commit cite is verified, so a fabricated or short sha is refused
370
+ // by name and nothing is written. A PR cite beside it does not excuse a bad commit cite.
371
+ for (const c of commitCites) {
372
+ const v = verifyCommitCite(c.ref, args.repo);
373
+ if (!v.ok) return { ok: false as const, error: `a 'done' record cited by commit was refused — ${v.why}` };
374
+ }
320
375
  }
321
376
 
322
377
  // `text` is what every consumer reads, so it must exist. The author's
@@ -405,7 +460,9 @@ export async function sendMessageTool(args: {
405
460
  const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
406
461
  const recipientWarning = reg[args.to]
407
462
  ? undefined
408
- : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
463
+ : (await isKnownHuman(args.to))
464
+ ? undefined // a human is not an agent and is not expected to be registered; the inbox is theirs to read
465
+ : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
409
466
  const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
410
467
  return { ok: true, id: msg.id, target, room: undefined, warning };
411
468
  }
@@ -159,8 +159,28 @@ export function eventsFromCommittedChange(
159
159
  // first PR it saw, which is a wrong claim about fourteen of them, and a wrong
160
160
  // claim on this bus is worse than a missing one.
161
161
  const removedQueue = linesWhere(byFile, isQueue, "removed");
162
+ // ⟨q-d527f435⟩ — A REMOVED LINE IS NOT A DEPARTED ITEM. The scan diffed text,
163
+ // so the aide's in-place rewrite of q-a1c9d4e7 (70c730a) read as a departure
164
+ // and was reported unattributed. The question is whether the ⟨q-…⟩ id is
165
+ // still present at the end of the range; three cases, never conflated:
166
+ // id still present in the ADDED lines → an EDIT — no event, not unattributed
167
+ // id absent → a DEPARTURE — attributed or reported
168
+ // id CHANGED (same text, new id) → a RE-ID — reported as such (⟨q-1c4f8ae3⟩)
169
+ const addedQueue = linesWhere(byFile, isQueue, "added");
170
+ // Only rows still OPEN count as present: the house style closes an item by
171
+ // flipping `[ ]` → `[x]` in place, and that row is closed, not edited.
172
+ const addedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${addedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string; done?: boolean }>).filter((i) => i.id && !i.done);
173
+ const addedIds = new Set(addedItems.map((i) => i.id!));
174
+ const reIdentified: { from: string; to: string }[] = [];
162
175
  const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
163
- .filter((i) => i.id);
176
+ .filter((i) => i.id)
177
+ .filter((i) => {
178
+ if (addedIds.has(i.id!)) return false; // edited in place: still on the queue
179
+ const twin = addedItems.find((a) => a.id !== i.id && norm(a.text ?? "") === norm(i.text ?? "") && norm(i.text ?? ""));
180
+ if (twin) { reIdentified.push({ from: i.id!, to: twin.id! }); return false; }
181
+ return true;
182
+ });
183
+ lastReIdentifiedItems = reIdentified;
164
184
  // An entry qualifies to CLOSE an item if it cites anything resolvable — a PR,
165
185
  // or an `@sha` commit. `land` requires a PR by rule; the scan reads what the
166
186
  // record actually says, and a commit-cited entry is still the record stating
@@ -168,7 +188,24 @@ export function eventsFromCommittedChange(
168
188
  const citedEntries = newEntries.filter((e) => prRefsIn(e.ref).length > 0 || hasCommitRef(e.ref ?? ""));
169
189
  const unattributed: string[] = [];
170
190
 
191
+ // ⟨q-2b7d9f04⟩ — THE LINK THE MARKDOWN DOES CARRY. `land` writes the item's
192
+ // id in LEADING position on the DONE line (#286), and a hand-written line in
193
+ // land's format carries it the same way. An entry that begins with ⟨q-…⟩ IS
194
+ // the record of that item closing — read by position, never guessed. The
195
+ // `item` subscription never fired in three days because this read was
196
+ // missing while the paragraph above said no link existed.
197
+ const attributedByLeadingId = new Set<string>();
198
+ for (const entry of citedEntries) {
199
+ const id = leadingItemIdOf(entry.text);
200
+ if (!id || attributedByLeadingId.has(id)) continue;
201
+ const ref = prRefsIn(entry.ref)[0] ?? entry.ref?.trim();
202
+ if (!ref) continue;
203
+ attributedByLeadingId.add(id);
204
+ events.push({ kind: "item", target: id, ref, summary: entry.text?.slice(0, 120) ?? id });
205
+ }
206
+
171
207
  for (const item of removedItems) {
208
+ if (attributedByLeadingId.has(item.id!)) continue;
172
209
  const itemText = norm(item.text ?? "");
173
210
  let entry = itemText
174
211
  ? citedEntries.find((e) => {
@@ -241,6 +278,36 @@ export function eventsFromCommittedChange(
241
278
  * different facts, and only one of them needs a human.
242
279
  */
243
280
  export let lastUnattributedItems: string[] = [];
281
+ /** ⟨q-d527f435⟩ — removed rows whose TEXT reappeared under a NEW id in the same change: re-identified, not departed. */
282
+ export let lastReIdentifiedItems: { from: string; to: string }[] = [];
283
+ /** ⟨q-2b7d9f04⟩ — the ⟨q-…⟩ id in LEADING position on a DONE entry's text (bold allowed), or null. Position, not occurrence: an id quoted mid-sentence is a mention. */
284
+ export function leadingItemIdOf(text: string | undefined): string | null {
285
+ const m = /^\s*(?:\*\*)?⟨(q-[0-9a-f]{8})⟩/.exec(String(text ?? ""));
286
+ return m ? m[1]! : null;
287
+ }
288
+ /**
289
+ * ⟨q-2b7d9f04⟩ — CAN THIS KIND FIRE AT ALL? Runs the scanner over the kind's
290
+ * own probe (event-kinds.ts) and asks whether an event of that kind comes out.
291
+ * Health reads this as CAPABILITY, beside the scan clock's LIVENESS: a kind
292
+ * whose probe yields nothing cannot be produced by the grammar, and a
293
+ * subscription to it is not `ok` however recently the scanner ran.
294
+ */
295
+ export function kindCapability(kind: SubKind): { capable: boolean; produced: number; why: string } {
296
+ const probe = (EVENT_KINDS[kind] as { probe?: { diff: string; after?: { done?: string; phases?: Record<string, string> } } }).probe;
297
+ if (!probe) return { capable: false, produced: 0, why: `'${kind}' ships no probe — its capability cannot be shown` };
298
+ const saved = lastUnattributedItems;
299
+ let produced = 0;
300
+ try {
301
+ produced = eventsFromCommittedChange(probe.diff, probe.after ?? {}).filter((e) => e.kind === kind).length;
302
+ } finally {
303
+ lastUnattributedItems = saved;
304
+ }
305
+ return produced > 0
306
+ ? { capable: true, produced, why: `the scanner produced ${produced} '${kind}' event(s) from the kind's own probe` }
307
+ : { capable: false, produced: 0, why: `the scanner produced NO '${kind}' event from the kind's own probe — this kind CANNOT FIRE on any commit, whatever the scan clock says` };
308
+ }
309
+ export const kindCapabilities = (): Record<SubKind, ReturnType<typeof kindCapability>> =>
310
+ Object.fromEntries((Object.keys(EVENT_KINDS) as SubKind[]).map((k) => [k, kindCapability(k)])) as Record<SubKind, ReturnType<typeof kindCapability>>;
244
311
 
245
312
  /** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
246
313
  export function git(repo: string, args: string[]): string {
@@ -257,7 +324,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
257
324
  import path from "node:path";
258
325
  import { z } from "zod";
259
326
  import { ROOT } from "../store.js";
260
- import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
327
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned, setCapabilityProbe } from "./events.js";
328
+ // ⟨q-2b7d9f04⟩ — health consults the scanner's grammar through this hook (events.ts cannot import this module: the kinds are its leaf and record-events imports events).
329
+ setCapabilityProbe((kind) => kindCapability(kind).capable);
261
330
 
262
331
  /**
263
332
  * The watermark: the last commit whose record change has been turned into
@@ -333,6 +402,7 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
333
402
 
334
403
  const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
335
404
  const unattributed = [...lastUnattributedItems];
405
+ const reIdentified = [...lastReIdentifiedItems];
336
406
 
337
407
  // Every event is checked against the record AS IT NOW STANDS (6.2). A change
338
408
  // that has since been reverted produces a candidate the record no longer
@@ -378,11 +448,14 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
378
448
  ? {
379
449
  unattributedItems: unattributed,
380
450
  unattributedNote:
381
- `${unattributed.length} queue item(s) left docs/QUEUE.md in this range without a done entry they could be tied to. ` +
382
- `They emitted NOTHING: the markdown carries no link between an item and the entry that closes it, so attributing them ` +
383
- `would be a guess. "Closed by an unknown PR" and "not closed" are different facts and this is the first.`,
451
+ `${unattributed.length} queue item(s) left docs/QUEUE.md in this range with NO done entry carrying their id in leading position ` +
452
+ `(\`- [x] ⟨q-…⟩ …\`, the form land writes) and no unambiguous pairing. They emitted NOTHING rather than a guess: a DONE line ` +
453
+ `without a leading id names no item. "Closed by an unknown entry" and "not closed" are different facts and this is the first.`,
384
454
  }
385
455
  : {}),
456
+ ...(reIdentified.length
457
+ ? { reIdentifiedItems: reIdentified, reIdentifiedNote: `${reIdentified.length} queue row(s) reappeared under a NEW id with the same text — re-identified, neither departed nor closed (⟨q-1c4f8ae3⟩'s subject).` }
458
+ : {}),
386
459
  ...(args.write
387
460
  ? { watermark: head }
388
461
  : {