agent-coord-mcp 0.26.21 → 0.26.23

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 (71) hide show
  1. package/dist/capabilities.js +270 -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 +40 -4
  22. package/dist/tools/events.js.map +1 -1
  23. package/dist/tools/herdr-delivery.js +99 -0
  24. package/dist/tools/herdr-delivery.js.map +1 -0
  25. package/dist/tools/messaging.js +72 -6
  26. package/dist/tools/messaging.js.map +1 -1
  27. package/dist/tools/record-events.js +85 -5
  28. package/dist/tools/record-events.js.map +1 -1
  29. package/dist/tools/records.js +310 -44
  30. package/dist/tools/records.js.map +1 -1
  31. package/dist/tools/registry.js +67 -3
  32. package/dist/tools/registry.js.map +1 -1
  33. package/dist/tools/seat-build.js +182 -0
  34. package/dist/tools/seat-build.js.map +1 -0
  35. package/dist/tools/shared.js.map +1 -1
  36. package/dist/tools/stall.js +1095 -18
  37. package/dist/tools/stall.js.map +1 -1
  38. package/dist/tools/transport.js +71 -3
  39. package/dist/tools/transport.js.map +1 -1
  40. package/dist/tools/worktrees.js +14 -0
  41. package/dist/tools/worktrees.js.map +1 -1
  42. package/dist/transports/herdr.js +297 -0
  43. package/dist/transports/herdr.js.map +1 -0
  44. package/dist/transports/index.js +10 -4
  45. package/dist/transports/index.js.map +1 -1
  46. package/package.json +1 -1
  47. package/scripts/coord-attention-clock.mjs +2 -0
  48. package/scripts/coord-stall-clock.mjs +52 -11
  49. package/src/capabilities.ts +284 -2
  50. package/src/closing-line.ts +85 -0
  51. package/src/commit-cite.ts +58 -0
  52. package/src/gated-head.ts +128 -26
  53. package/src/server-spread.ts +233 -0
  54. package/src/server.ts +2 -2
  55. package/src/store.ts +32 -0
  56. package/src/tools/away.ts +82 -9
  57. package/src/tools/board-ref.ts +70 -3
  58. package/src/tools/event-kinds.ts +19 -2
  59. package/src/tools/events.ts +42 -4
  60. package/src/tools/herdr-delivery.ts +87 -0
  61. package/src/tools/messaging.ts +71 -6
  62. package/src/tools/record-events.ts +78 -5
  63. package/src/tools/records.ts +316 -44
  64. package/src/tools/registry.ts +68 -4
  65. package/src/tools/seat-build.ts +201 -0
  66. package/src/tools/shared.ts +22 -0
  67. package/src/tools/stall.ts +1266 -23
  68. package/src/tools/transport.ts +69 -2
  69. package/src/tools/worktrees.ts +13 -0
  70. package/src/transports/herdr.ts +311 -0
  71. package/src/transports/index.ts +10 -4
@@ -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,7 +17,20 @@
17
17
  * widen without adding an emitter in the same file.
18
18
  */
19
19
  export type SubKind = keyof typeof EVENT_KINDS;
20
- export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
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> } };
32
+ /** `refs`: ⟨q-cbace757⟩ — a closure cited by N PRs carries all N, each checked against the record on its own; `ref` stays the joined form for the event key. */
33
+ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string; refs?: string[] };
21
34
 
22
35
  /*
23
36
  * THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
@@ -37,23 +50,27 @@ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary:
37
50
  export const EVENT_KINDS = {
38
51
  item: {
39
52
  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",
53
+ 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
54
  targetIs: "the queue item id",
55
+ 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
56
  },
43
57
  pr: {
44
58
  record: "docs/DONE.md",
45
59
  what: "a PR recorded in the completion log",
46
60
  targetIs: "the PR ref, e.g. owner/repo#163",
61
+ 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
62
  },
48
63
  task: {
49
64
  record: "docs/phases/**/PHASE*_TASKS.md",
50
65
  what: "a phase task checkbox newly ticked",
51
66
  targetIs: "the task key, e.g. 5:12.1",
67
+ 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
68
  },
53
69
  phase: {
54
70
  record: "docs/phases/**/PHASE*_TASKS.md",
55
71
  what: "the last open checkbox in a phase document ticked",
56
72
  targetIs: "the phase number, e.g. 5",
73
+ 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
74
  },
58
75
  } as const;
59
76
 
@@ -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`
@@ -152,11 +177,18 @@ export const eventKey = (kind: SubKind, target: string, ref: string): string =>
152
177
  */
153
178
  export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
154
179
  if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
155
- if (!String(recordText).includes(ev.ref))
180
+ // ⟨q-cbace757⟩ — a multi-PR closer is N citations, checked ONE BY ONE. The
181
+ // joined string ("a#1, a#2, a#3") is how the event is keyed, not how the record
182
+ // is read: a DONE line citing the same three PRs in another arrangement carries
183
+ // every ref and none of the joined form. The guard stays exactly as strict per
184
+ // ref — a citation absent from the record still refuses, and is named.
185
+ const refs = ev.refs?.length ? ev.refs : [ev.ref];
186
+ const missing = refs.filter((r) => !String(recordText).includes(r));
187
+ if (missing.length)
156
188
  return {
157
189
  ok: false,
158
190
  error:
159
- `refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
191
+ `refusing to emit ${ev.kind} ${ev.target}: ${missing.length === refs.length && refs.length === 1 ? `its ref ${ev.ref}` : `${missing.length} of its ${refs.length} cited ref(s) — ${missing.join(", ")} —`} is NOT in the record. ` +
160
192
  `An event that exists without the record change that caused it is a second source of truth — ` +
161
193
  `the stream would claim something the authoritative document does not.`,
162
194
  };
@@ -258,10 +290,16 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
258
290
  const all = readSubs();
259
291
  const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
260
292
  const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
261
- const neverEvaluated = rows.filter((r) => r.health.level === "error");
293
+ const cannotFire = rows.filter((r) => r.health.level === "error" && /^CANNOT FIRE/.test(r.health.detail));
294
+ const neverEvaluated = rows.filter((r) => r.health.level === "error" && !/^CANNOT FIRE/.test(r.health.detail));
262
295
  const undetermined = rows.filter((r) => r.health.level === "unknown");
263
296
  return {
264
- ok: neverEvaluated.length === 0,
297
+ ok: neverEvaluated.length === 0 && cannotFire.length === 0,
298
+ // ⟨q-2b7d9f04⟩ — capability per kind, beside each row's liveness.
299
+ capability: Object.fromEntries(EVENT_KIND_IDS.map((k) => [k, kindCanFire(k)])),
300
+ ...(cannotFire.length
301
+ ? { 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(", ")}.` }
302
+ : {}),
265
303
  // Population beside the verdict, always: "no subscriptions" and "none
266
304
  // listed for you" are different claims.
267
305
  population: { listed: rows.length, total: all.length },
@@ -0,0 +1,87 @@
1
+ /**
2
+ * IN-PROCESS DELIVERY FOR HERDR SEATS (Phase 5.4 Task 4, box 4.4).
3
+ *
4
+ * A tmux seat has a pusher that tails its inbox and the rooms it joined, formats a batch,
5
+ * types it into the pane and advances the PUSH cursor once the paste is verified submitted
6
+ * (hooks/tmux-pusher.mjs + hooks/push-cursor.mjs). A herdr seat has no pusher, so the
7
+ * server does the same at SEND time: the message it just appended is formatted by the very
8
+ * same formatter the pusher uses (hooks/tier.mjs — one renderer, so the pane sees one
9
+ * shape on either transport), pushed through the transport, and ONLY on a verified
10
+ * delivery is the seat's push cursor advanced past it.
11
+ *
12
+ * THE CURSOR RULE IS THE PUSHER'S, UNCHANGED (⟨q-7be94b5e⟩): a paste that did not reach the
13
+ * pane must not mark the message consumed. The push cursor is advanced to the offset the
14
+ * append produced, and never touched when the transport reports not-delivered — the
15
+ * message stays where `read_messages` still serves it. The READ cursor is the agent's and
16
+ * is never written here.
17
+ *
18
+ * Simplification stated: the pusher batches routine room traffic into digests on a
19
+ * debounce; a herdr seat receives each message as it is sent, in the digest FORMAT (the
20
+ * `[agent-coord] …` banner and the attributed line) but one at a time. Tiers still decide
21
+ * the banner text. Batching for herdr is Task 5 territory if it is wanted.
22
+ */
23
+ import { statSync } from "node:fs";
24
+ import path from "node:path";
25
+ import { pathToFileURL } from "node:url";
26
+ import { activeTransport, HERDR, type TransportMarker } from "../transports/index.js";
27
+ import { ROOT, inboxFile, roomFile } from "../store.js";
28
+ import { loadLiveTransports } from "./registry.js";
29
+ import type { Message } from "./shared.js";
30
+
31
+ type Tier = { formatBatch: (batch: unknown[], agentId: string, rooms: string[]) => string; classifyTier: (m: unknown, opts?: unknown) => string };
32
+ type PushCursor = { readPushCursor: (root: string, safeId: string) => Record<string, unknown>; writePushCursor: (root: string, safeId: string, c: Record<string, unknown>) => void };
33
+ let hooks: Promise<{ tier: Tier; cursor: PushCursor }> | undefined;
34
+ function loadHooks(): Promise<{ tier: Tier; cursor: PushCursor }> {
35
+ hooks ??= (async () => {
36
+ const base = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "hooks");
37
+ const tier = (await import(pathToFileURL(path.join(base, "tier.mjs")).href)) as Tier;
38
+ const cursor = (await import(pathToFileURL(path.join(base, "push-cursor.mjs")).href)) as PushCursor;
39
+ return { tier, cursor };
40
+ })();
41
+ return hooks;
42
+ }
43
+ const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, "_");
44
+
45
+ export type HerdrDeliveryOutcome = { agentId: string; delivered: boolean; enters?: number; error?: string; cursorAdvanced: boolean };
46
+
47
+ /** Deliver one just-appended message to every herdr-attached recipient among `recipients`. */
48
+ export async function deliverToHerdrSeats(msg: Message, recipients: string[], where: { kind: "dm" } | { kind: "room"; chan: string }): Promise<HerdrDeliveryOutcome[]> {
49
+ const t = activeTransport();
50
+ if (!t || t.kind !== HERDR) return [];
51
+ let markers: Map<string, TransportMarker>;
52
+ try { markers = await loadLiveTransports(); } catch { return []; }
53
+ const out: HerdrDeliveryOutcome[] = [];
54
+ for (const agentId of recipients) {
55
+ const marker = markers.get(agentId);
56
+ if (!marker || marker.transport !== HERDR) continue;
57
+ if (where.kind === "room" && marker.rooms === false) continue;
58
+ if (msg.from === agentId) continue;
59
+ const { tier, cursor } = await loadHooks();
60
+ const tagged = { ...msg, tag: where.kind === "dm" ? "DM" : `room #${where.chan}` };
61
+ const rendered = tier.formatBatch([{ ...tagged, tier: tier.classifyTier(tagged) }], agentId, where.kind === "room" ? [where.chan] : []);
62
+ // A control never comes through here: send_command hands a herdr seat's control to the
63
+ // transport's sendControl directly (a raw-vs-rendered branch here was dead and its
64
+ // mutation survived, so it is gone). Everything delivered here is a rendered message.
65
+ let r: { delivered: boolean; error?: string; enters?: number };
66
+ try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, error: (e as Error).message }; }
67
+ let cursorAdvanced = false;
68
+ if (r.delivered) {
69
+ try {
70
+ const file = where.kind === "dm" ? inboxFile(agentId) : roomFile(where.chan);
71
+ const size = statSync(file).size;
72
+ const id = safeId(agentId);
73
+ const c = cursor.readPushCursor(ROOT, id) ?? {};
74
+ if (where.kind === "dm") c.inboxOffset = Math.max(Number(c.inboxOffset ?? 0), size);
75
+ else {
76
+ const offsets = ((c.roomOffsets as Record<string, number> | undefined) ?? {});
77
+ offsets[where.chan] = Math.max(Number(offsets[where.chan] ?? 0), size);
78
+ c.roomOffsets = offsets;
79
+ }
80
+ cursor.writePushCursor(ROOT, id, c);
81
+ cursorAdvanced = true;
82
+ } catch { cursorAdvanced = false; }
83
+ }
84
+ out.push({ agentId, delivered: r.delivered, enters: r.enters, error: r.error, cursorAdvanced });
85
+ }
86
+ return out;
87
+ }
@@ -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,9 @@ import {
82
83
  MAX_WAIT_MS,
83
84
  isDecision,
84
85
  } from "./shared.js";
86
+ import { isKnownHuman } from "../store.js";
87
+ import { deliverToHerdrSeats } from "./herdr-delivery.js";
88
+ import { verifyCommitCite } from "../commit-cite.js";
85
89
 
86
90
  // ---------- send_message ----------
87
91
 
@@ -110,10 +114,18 @@ const decisionPayload = z.looseObject({
110
114
  ifNoAction: z.string().min(1),
111
115
  });
112
116
 
117
+ // ⟨q-dcbaf544⟩ — `gatedBy` is the seat that JUDGED when it is not the sender;
118
+ // `scribe` is the sender that TRANSCRIBED it. Fields, not prose: a name in
119
+ // prose is a mention, a name in a field is a position, and only the position
120
+ // survives a scanner. Authority does not move (David's ruling 2026-09-14):
121
+ // the roles that may emit `verdict` are unchanged; this is how a gate routed
122
+ // to any other seat reaches the record without lying about who gated.
113
123
  const verdictPayload = z.looseObject({
114
124
  result: z.enum(["pass", "fail"]),
115
125
  headRefOid: z.string().min(1),
116
126
  notes: z.string().optional(),
127
+ gatedBy: z.string().min(1).optional(),
128
+ scribe: z.string().min(1).optional(),
117
129
  });
118
130
 
119
131
  // Discriminated on `type`, so an unknown type is rejected outright while a
@@ -170,6 +182,21 @@ export async function checkRecordAuthority(
170
182
  };
171
183
  }
172
184
 
185
+ /** ⟨q-dcbaf544⟩ — `scribe`, when present, must be the sender; `gatedBy` may name anyone (that is its point). */
186
+ export function checkVerdictScribe(from: string, record: MessageRecord | undefined): { ok: true } | { ok: false; error: string } {
187
+ if (!record || record.type !== "verdict") return { ok: true };
188
+ const p = record.payload as { scribe?: string; gatedBy?: string } | undefined;
189
+ if (p?.scribe && p.scribe !== from) {
190
+ return {
191
+ ok: false,
192
+ error:
193
+ `verdict payload.scribe is '${p.scribe}' but the sender is '${from}' — the scribe is the seat that SENDS the record. ` +
194
+ `Put the seat that judged in payload.gatedBy and either omit scribe or set it to '${from}'.`,
195
+ };
196
+ }
197
+ return { ok: true };
198
+ }
199
+
173
200
  // ---------- typed records obligatory (Phase 5.1 Task 12) ----------
174
201
 
175
202
  // An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
@@ -202,7 +229,10 @@ async function typedRecordCheck(args: {
202
229
  // David-facing prose stays prose. An UNREGISTERED recipient is treated as an
203
230
  // agent, not as a human: the safe reading of "I cannot tell" is the rule, and
204
231
  // 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 };
232
+ // ⟨q-178878aa⟩ and the human need not be a REGISTERED agent: a registry entry for a
233
+ // human is evicted after EVICT_MS (no heartbeat), so the exemption keys on the durable
234
+ // human set the server knows (humans.json + AGENT_COORD_HUMANS), read here, at send time.
235
+ if (args.to && (isHuman(reg[args.to]) || (await isKnownHuman(args.to)))) return { ok: true };
206
236
 
207
237
  const sender = reg[args.from];
208
238
  if (sender?.proseOnly) return { ok: true };
@@ -227,7 +257,8 @@ async function typedRecordCheck(args: {
227
257
  `${guidance} ` +
228
258
  `Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
229
259
  `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.`,
260
+ `Messages TO a human are exempt (a recipient registered with a human role, or an id the server knows as human: ` +
261
+ `humans.json / AGENT_COORD_HUMANS — see list_agents.humans), and an agent that cannot pick a type declares proseOnly:true at join.`,
231
262
  };
232
263
  }
233
264
 
@@ -245,6 +276,9 @@ export const sendMessageSchema = {
245
276
  // unknown → store + warn) so the rejection shape matches identity-binding
246
277
  // / unknown-recipient, not a schema throw. Existing records unchanged.
247
278
  inReplyTo: z.string().optional(),
279
+ // ⟨q-cc0819dc⟩ — needed only for a `done` cited by COMMIT: the repository whose
280
+ // origin/main must carry the sha (one local git call, no network).
281
+ repo: z.string().optional(),
248
282
  };
249
283
 
250
284
  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 +332,47 @@ export async function sendMessageTool(args: {
298
332
  kind?: "decision" | "status" | "chatter";
299
333
  record?: MessageRecord;
300
334
  inReplyTo?: string;
335
+ repo?: string;
301
336
  }) {
302
337
  // Record authority first — a sender who may not emit this type is refused
303
338
  // before any other check runs, so a rejected record writes nothing anywhere.
304
339
  const authority = await checkRecordAuthority(args.from, args.record);
305
340
  if (!authority.ok) return { ok: false as const, error: authority.error };
341
+ // ⟨q-dcbaf544⟩ — a scribe is the sender by definition; a verdict whose
342
+ // `scribe` names someone else has its two positions crossed and would be
343
+ // attributed wrongly by every reader. Refused as a value, nothing written.
344
+ const scribeCheck = checkVerdictScribe(args.from, args.record);
345
+ if (!scribeCheck.ok) return { ok: false as const, error: scribeCheck.error };
306
346
 
307
347
  // A `done` must cite the work it claims. Presence and shape only — resolving
308
348
  // the ref against gh/git is a consumer's job, and the send path makes no
309
349
  // network calls. Rejected as a value, not a throw, mirroring the
310
350
  // identity-binding rejection in src/server.ts.
311
351
  if (args.record?.type === "done") {
312
- const hasPr = (args.record.cites ?? []).some((c) => c.kind === "pr" && c.ref.trim().length > 0);
313
- if (!hasPr) {
352
+ // ⟨q-fee7239f⟩ the same check that reads the closing citation reads the
353
+ // closing GRAMMAR: a merge closing may not assert a deletion it has not
354
+ // read. Refused as a value, naming the two accepted forms; nothing written.
355
+ const closing = checkClosingLine(String(args.text ?? ""));
356
+ if (!closing.ok) return { ok: false as const, error: closing.error };
357
+ const cites = args.record.cites ?? [];
358
+ const hasPr = cites.some((c) => c.kind === "pr" && c.ref.trim().length > 0);
359
+ const commitCites = cites.filter((c) => c.kind === "commit");
360
+ if (!hasPr && commitCites.length === 0) {
314
361
  return {
315
362
  ok: false as const,
316
363
  error:
317
- "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim",
364
+ "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim. " +
365
+ "Work that has no PR by rule (docs pushed straight to the shared branch) cites {kind:'commit', ref:<full 40-hex sha>} " +
366
+ "and passes `repo`, the repository whose origin/main carries it.",
318
367
  };
319
368
  }
369
+ // ⟨q-cc0819dc⟩ — a COMMIT cite satisfies a `done` only when the commit is real and on the
370
+ // shared branch: every commit cite is verified, so a fabricated or short sha is refused
371
+ // by name and nothing is written. A PR cite beside it does not excuse a bad commit cite.
372
+ for (const c of commitCites) {
373
+ const v = verifyCommitCite(c.ref, args.repo);
374
+ if (!v.ok) return { ok: false as const, error: `a 'done' record cited by commit was refused — ${v.why}` };
375
+ }
320
376
  }
321
377
 
322
378
  // `text` is what every consumer reads, so it must exist. The author's
@@ -399,13 +455,18 @@ export async function sendMessageTool(args: {
399
455
  };
400
456
  const target = inboxFile(args.to);
401
457
  await appendJsonl(target, msg);
458
+ // Phase 5.4 Task 4 — a herdr-attached recipient has no pusher: deliver now, in-process.
459
+ const herdr = await deliverToHerdrSeats(msg, [args.to], { kind: "dm" });
460
+ void herdr;
402
461
  // Offline delivery is intentional (the inbox is created on demand), but a
403
462
  // typo'd recipient shouldn't vanish silently — surface a warning when the
404
463
  // target isn't a known agent so the caller can catch the mistake.
405
464
  const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
406
465
  const recipientWarning = reg[args.to]
407
466
  ? undefined
408
- : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
467
+ : (await isKnownHuman(args.to))
468
+ ? undefined // a human is not an agent and is not expected to be registered; the inbox is theirs to read
469
+ : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
409
470
  const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
410
471
  return { ok: true, id: msg.id, target, room: undefined, warning };
411
472
  }
@@ -425,6 +486,10 @@ export async function sendMessageTool(args: {
425
486
  };
426
487
  const target = roomFile(chan);
427
488
  await appendJsonl(target, msg);
489
+ // Phase 5.4 Task 4 — herdr-attached members with rooms on receive the post now, in-process.
490
+ const roomMembers = (await getRooms())[chan]?.members ?? [];
491
+ const herdrRoom = await deliverToHerdrSeats(msg, roomMembers, { kind: "room", chan });
492
+ void herdrRoom;
428
493
  await maybeCompactRoom(chan);
429
494
  const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
430
495
  return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
@@ -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
  : {