agent-coord-mcp 0.26.10 → 0.26.12

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.
package/src/server.ts CHANGED
@@ -9,6 +9,7 @@ import { z, type ZodRawShape } from "zod";
9
9
  import { coordAwaySchema, coordAwayTool, readAway, awayRefusal, secondCoordinatorRefusal } from "./tools/away.js";
10
10
  import { rotateSchema, rotateTool, rotateReconcileSchema, rotateReconcileTool } from "./tools/rotate.js";
11
11
  import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
12
+ import { scanRecordEventsSchema, scanRecordEventsTool } from "./tools/record-events.js";
12
13
  import {
13
14
  ensureDirs,
14
15
  getTokenMap,
@@ -287,7 +288,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
287
288
 
288
289
  addTool(
289
290
  "join",
290
- "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
291
+ "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
291
292
  joinSchema,
292
293
  // join explicitly sets the session binding when unset, so each agent can
293
294
  // declare its identity via join rather than relying on env vars.
@@ -310,7 +311,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
310
311
 
311
312
  addTool(
312
313
  "register",
313
- "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
314
+ "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control. `proseOnly:true` claims the per-agent exemption from the typed-record rule, for a model that cannot reliably pick a record.type; it is visible and counted in list_agents, and it is granted to the SENDER but paid by every READER (an untyped message cannot be slimmed). Omit it to leave any existing exemption untouched; pass false to revoke.",
314
315
  registerSchema,
315
316
  gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
316
317
  );
@@ -352,14 +353,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
352
353
 
353
354
  addTool(
354
355
  "list_agents",
355
- "List all known agents and whether they appear online (heartbeat <5min).",
356
+ "List all known agents and whether they appear online (heartbeat <5min). Also reports the prose-only exemptions from the typed-record rule — who holds one, since when, and the count over the total, so a rising exempt share is visible rather than inferred.",
356
357
  listAgentsSchema,
357
358
  gate(null, listAgentsTool as () => Promise<unknown>),
358
359
  );
359
360
 
360
361
  addTool(
361
362
  "send_message",
362
- "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured.",
363
+ "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. Optional 'inReplyTo' is a parent message uuid (a reply that resolves a DAVID_DECISION); malformed id is refused, unknown id is stored with a warning. The 'from' field is enforced against the session's bound identity when binding is configured. EVERY AGENT→AGENT MESSAGE MUST CARRY 'record' with a typed 'type' (decision · verdict · done · blocker · risk · fyi · action · go · scope): a typed multi-line message is delivered as ONE attributed line plus a retrieve_message handle, while an untyped one arrives in full in every reader's context. 'fyi' is the honest catch-all — use it rather than forcing a false 'decision'/'risk'. Untyped sends WARN today and are REFUSED from 2026-09-15. Messages TO a human are exempt (David-facing traffic stays prose), as is a sender that declared proseOnly:true at join.",
363
364
  sendMessageSchema,
364
365
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
365
366
  );
@@ -629,11 +630,18 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
629
630
 
630
631
  addTool(
631
632
  "subscribe",
632
- "Register for a record event: a task completing, a phase completing, or a queue item closing. Events are DERIVED from the record \u2014 emitted by `land` after the DONE entry is written, and refused if the ref is not in the record \u2014 so the stream can never claim something the authoritative markdown does not. Re-subscribing returns the existing registration rather than a duplicate.",
633
+ "Register for a record event: `task` (a phase checkbox newly ticked), `phase` (the last open box in a phase ticked), `item` (a queue item closed), or `pr` (a PR recorded in DONE.md). Events are DERIVED from the record and refused if the ref is not in it, so the stream can never claim something the authoritative markdown does not \u2014 but the TRIGGER is the record's COMMITTED CHANGE (`scan_record_events`), not any one verb, so a hand-edited DONE.md fires exactly like `land` does. Every offered kind has an emitter: the wire enum is generated from the emitter registry, so an unsatisfiable subscription cannot be registered. Re-subscribing returns the existing registration rather than a duplicate.",
633
634
  subscribeSchema,
634
635
  gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
635
636
  );
636
637
 
638
+ addTool(
639
+ "scan_record_events",
640
+ "Turn a repo's COMMITTED record changes into events: new `docs/DONE.md` entries, queue items that left `docs/QUEUE.md`, and newly-ticked phase checkboxes, between the stored watermark and HEAD. The commit is the boundary \u2014 an uncommitted edit is not yet a record. A HAND-EDIT fires exactly like `land` does, which is the point: fleets merge with `gh` and edit DONE.md directly. Reports by default; `write:true` delivers and advances the watermark. Re-scanning a delivered range is safe (the idempotency key comes from the event, so it reports duplicate-suppressed), and a watermark that no longer resolves REFUSES rather than silently narrowing its window.",
641
+ scanRecordEventsSchema,
642
+ gate(null, scanRecordEventsTool as (a: Record<string, unknown>) => Promise<unknown>),
643
+ );
644
+
637
645
  addTool(
638
646
  "unsubscribe",
639
647
  "Remove one of YOUR subscriptions. Refuses another agent's: silently dropping someone else's notification is how a miss is manufactured.",
@@ -13,10 +13,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { z } from "zod";
15
15
  import { ROOT } from "../store.js";
16
+ import { EVENT_KINDS, EVENT_KIND_IDS, type RecordEvent, type SubKind } from "./record-events.js";
16
17
 
17
18
  const subsFile = () => path.join(ROOT, "subscriptions.json");
18
19
 
19
- export type SubKind = "task" | "phase" | "item" | "pr";
20
+ // Re-exported so every existing importer keeps its import path. The vocabulary
21
+ // itself is defined beside the emitters (record-events.ts) — Task 9.2: a kind
22
+ // that can be subscribed to but never emitted is unsatisfiable BY CONSTRUCTION
23
+ // only if the enum cannot be widened without an emitter.
24
+ export { EVENT_KINDS, EVENT_KIND_IDS };
25
+ export type { RecordEvent, SubKind };
20
26
  export type Subscription = {
21
27
  id: string;
22
28
  agentId: string;
@@ -77,8 +83,6 @@ export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; de
77
83
  export const eventKey = (kind: SubKind, target: string, ref: string): string =>
78
84
  createHash("sha256").update(`${kind}:${target}:${ref}`).digest("hex").slice(0, 16);
79
85
 
80
- export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
81
-
82
86
  /**
83
87
  * 6.2 — EVENTS ARE DERIVED FROM THE RECORD, NEVER PARALLEL TO IT.
84
88
  *
@@ -139,7 +143,7 @@ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): {
139
143
 
140
144
  export const subscribeSchema = {
141
145
  agentId: z.string().min(1),
142
- kind: z.enum(["task", "phase", "item", "pr"]),
146
+ kind: z.enum(EVENT_KIND_IDS),
143
147
  target: z.string().min(1),
144
148
  };
145
149
 
@@ -9,3 +9,5 @@ export * from "./work.js";
9
9
  export * from "./records.js";
10
10
  export * from "./worktrees.js";
11
11
  export * from "./stall.js";
12
+ export * from "./events.js";
13
+ export * from "./record-events.js";
@@ -1,5 +1,11 @@
1
1
  import { adjustCursors } from "./admin.js";
2
- import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
2
+ import { RECORD_AUTHORITY, isHuman, recordAuthorityFor, resolveRole, roleMatches } from "../roles.js";
3
+ import {
4
+ TYPED_RECORD_CUTOVER_ISO,
5
+ suggestRecordType,
6
+ typedRecordGuidance,
7
+ typedRecordMode,
8
+ } from "../typed-records.js";
3
9
  import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
4
10
  import { randomUUID } from "node:crypto";
5
11
  import { existsSync, openSync, watch } from "node:fs";
@@ -159,6 +165,67 @@ export async function checkRecordAuthority(
159
165
  };
160
166
  }
161
167
 
168
+ // ---------- typed records obligatory (Phase 5.1 Task 12) ----------
169
+
170
+ // An untyped agent→agent message must not be able to EXIST. Enforced HERE, at
171
+ // the send, and not at the render: a rule applied where the message is read
172
+ // leaves the untyped message on disk, and the next reader re-derives the type
173
+ // from prose. See src/typed-records.ts for the staging, the suggestion rules,
174
+ // and why `fyi` stays an honest catch-all.
175
+ //
176
+ // Returns `undefined` when the send is fine, a WARNING string while the rule is
177
+ // staged, or a REFUSAL after the cutover.
178
+ //
179
+ // TWO EXEMPTIONS, AND THEY ARE DIFFERENT IN KIND:
180
+ // - the RECIPIENT is a human. Canon: "David-facing messages may use normal
181
+ // prose". Scoped to agent→agent traffic, so the one channel whose reader is
182
+ // a person is untouched. Nothing is declared for this — it is a property of
183
+ // who is being written to.
184
+ // - the SENDER holds a prose-only exemption, declared per agent at join and
185
+ // visible in list_agents. That one is a statement about a model's ability
186
+ // to pick a type, and it is paid for by every reader.
187
+ async function typedRecordCheck(args: {
188
+ from: string;
189
+ to?: string;
190
+ text: string;
191
+ record?: MessageRecord;
192
+ }): Promise<{ ok: true; warning?: string } | { ok: false; error: string }> {
193
+ if (args.record?.type) return { ok: true };
194
+
195
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
196
+
197
+ // David-facing prose stays prose. An UNREGISTERED recipient is treated as an
198
+ // agent, not as a human: the safe reading of "I cannot tell" is the rule, and
199
+ // a human on this bus has a registry entry (that is how the pane is found).
200
+ if (args.to && isHuman(reg[args.to])) return { ok: true };
201
+
202
+ const sender = reg[args.from];
203
+ if (sender?.proseOnly) return { ok: true };
204
+
205
+ const suggestion = suggestRecordType(args.text, recordAuthorityFor(sender).mayNotEmit);
206
+ const guidance = typedRecordGuidance(suggestion);
207
+
208
+ if (typedRecordMode() === "warn") {
209
+ return {
210
+ ok: true,
211
+ warning:
212
+ `UNTYPED — stored, but this send is REFUSED from ${TYPED_RECORD_CUTOVER_ISO}. ` +
213
+ `${guidance} Until then an untyped multi-line message arrives in full in every reader's context ` +
214
+ `instead of one line plus a retrieve_message handle. ` +
215
+ `A model that cannot pick a type declares proseOnly:true at join (per-agent, visible in list_agents).`,
216
+ };
217
+ }
218
+ return {
219
+ ok: false,
220
+ error:
221
+ `agent→agent messages must carry a typed record (since ${TYPED_RECORD_CUTOVER_ISO}) — nothing was written. ` +
222
+ `${guidance} ` +
223
+ `Types: decision · verdict · done · blocker · risk · fyi · action · go · scope; 'fyi' is the honest ` +
224
+ `catch-all — do not force a false 'decision'/'risk' to get past this. ` +
225
+ `Messages TO a human are exempt, and an agent that cannot pick a type declares proseOnly:true at join.`,
226
+ };
227
+ }
228
+
162
229
  export const sendMessageSchema = {
163
230
  from: z.string().min(1),
164
231
  to: z.string().optional(),
@@ -245,6 +312,12 @@ export async function sendMessageTool(args: {
245
312
  };
246
313
  }
247
314
 
315
+ // After `text` is resolved (a record can fill it) and before anything is
316
+ // written, so a refusal leaves nothing on disk.
317
+ const typed = await typedRecordCheck({ from: args.from, to: args.to, text, record: args.record });
318
+ if (!typed.ok) return { ok: false as const, error: typed.error };
319
+ const typedWarning = typed.warning;
320
+
248
321
  let replyWarning: string | undefined;
249
322
  if (args.inReplyTo !== undefined) {
250
323
  if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
@@ -312,7 +385,7 @@ export async function sendMessageTool(args: {
312
385
  const recipientWarning = reg[args.to]
313
386
  ? undefined
314
387
  : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
315
- const warning = [recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
388
+ const warning = [typedWarning, recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
316
389
  return { ok: true, id: msg.id, target, room: undefined, warning };
317
390
  }
318
391
 
@@ -331,7 +404,7 @@ export async function sendMessageTool(args: {
331
404
  const target = roomFile(chan);
332
405
  await appendJsonl(target, msg);
333
406
  await maybeCompactRoom(chan);
334
- const roomWarning = [replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
407
+ const roomWarning = [typedWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
335
408
  return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
336
409
  }
337
410
 
@@ -0,0 +1,386 @@
1
+ /*
2
+ * Phase 5.1 Task 9 — the four subscribable kinds all emit, and the TRIGGER is
3
+ * the record's COMMITTED CHANGE rather than the `land` verb (David's ruling,
4
+ * option (b), 2026-08-30).
5
+ *
6
+ * WHY NOT (a), "emit from the verbs": it bets on every fleet adopting the
7
+ * workflow layer, and the only evidence available says they do not — a consumer fleet called
8
+ * `claim`/`land`/`merge`/`next_unblocked`/`rotate`/`set_halt` ZERO times in a day
9
+ * of heavy use. A `pr` subscription tested against a real PR came back
10
+ * `health: error — never evaluated`, and the fleet unsubscribed. Kinds that can
11
+ * be subscribed to and can never fire are a permanent, honestly-reported error.
12
+ *
13
+ * WHAT (b) CHANGES, AND WHAT IT DOES NOT: the record stays the source — only the
14
+ * trigger moves from the verb to the change. That still satisfies 6.2 ("events
15
+ * derived from the record, never parallel to it"), because the derivation still
16
+ * reads the record; it just no longer requires that a particular verb performed
17
+ * the write. THE POINT IS THAT A HAND-EDIT BECOMES A FIRST-CLASS CAUSE rather
18
+ * than an invisible one, which is what fleets actually do: they merge with `gh`
19
+ * and edit `docs/DONE.md` by hand. `land` keeps emitting, as ONE WRITER AMONG
20
+ * SEVERAL rather than as the gate — the idempotency key makes the overlap safe,
21
+ * since both paths derive the same key from the same event.
22
+ *
23
+ * THE COMMIT IS THE BOUNDARY, not the working tree. An uncommitted edit is not
24
+ * yet a record: it can be reverted, rebased away, or never pushed, and a
25
+ * notification for work that then vanishes is worse than a late one.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import { newlyTickedInDiff, parseWorkDoc, queueItemsOf, doneEntriesOf } from "@davidbalzan/groundwork-seam";
29
+ /**
30
+ * The kind vocabulary lives HERE, beside the emitters, and `events.ts` imports
31
+ * it. That direction is deliberate: it is what makes the enum impossible to
32
+ * widen without adding an emitter in the same file.
33
+ */
34
+ export type SubKind = keyof typeof EVENT_KINDS;
35
+ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
36
+
37
+ /*
38
+ * THE KIND REGISTRY IS THE SINGLE SOURCE, AND THAT IS TASK 9.2.
39
+ *
40
+ * `SubKind` used to be a hand-written union next to a hand-written zod enum next
41
+ * to an emitter that covered one of the four. Nothing connected them, so a kind
42
+ * could be offered for subscription while nothing could ever emit it — and
43
+ * `list_subscriptions` would report its permanent `never evaluated` forever,
44
+ * honestly and uselessly.
45
+ *
46
+ * Now the union, the wire enum, and the emitter set are all derived from THIS
47
+ * object. A kind cannot be offered without an emitter because the enum is
48
+ * generated from the emitters; `test/record-events.test.mjs` closes the other
49
+ * half by asserting each kind actually fires. Unsatisfiable BY CONSTRUCTION,
50
+ * rather than by a reviewer noticing.
51
+ */
52
+ export const EVENT_KINDS = {
53
+ item: {
54
+ record: "docs/QUEUE.md + docs/DONE.md",
55
+ what: "a queue item closed — it left QUEUE.md and a DONE.md entry appeared in the same commit",
56
+ targetIs: "the queue item id",
57
+ },
58
+ pr: {
59
+ record: "docs/DONE.md",
60
+ what: "a PR recorded in the completion log",
61
+ targetIs: "the PR ref, e.g. owner/repo#163",
62
+ },
63
+ task: {
64
+ record: "docs/phases/**/PHASE*_TASKS.md",
65
+ what: "a phase task checkbox newly ticked",
66
+ targetIs: "the task key, e.g. 5:12.1",
67
+ },
68
+ phase: {
69
+ record: "docs/phases/**/PHASE*_TASKS.md",
70
+ what: "the last open checkbox in a phase document ticked",
71
+ targetIs: "the phase number, e.g. 5",
72
+ },
73
+ } as const;
74
+
75
+ export const EVENT_KIND_IDS = Object.keys(EVENT_KINDS) as [SubKind, ...SubKind[]];
76
+
77
+ /**
78
+ * One pass over the diff → per-file added and removed lines.
79
+ *
80
+ * Per FILE, not per predicate: the phase rule needs to know WHICH document a
81
+ * tick came from, and a flat "all added lines" view cannot answer that. It
82
+ * emitted `phase 5 complete` off a tick in the phase 5.1 document on the first
83
+ * real-data run.
84
+ */
85
+ export function diffByFile(diff: string): Map<string, { added: string[]; removed: string[] }> {
86
+ const out = new Map<string, { added: string[]; removed: string[] }>();
87
+ let cur: { added: string[]; removed: string[] } | null = null;
88
+ for (const line of String(diff ?? "").split("\n")) {
89
+ if (line.startsWith("diff --git ")) { cur = null; continue; }
90
+ if (line.startsWith("+++ b/")) {
91
+ const p = line.slice(6).trim();
92
+ if (p === "/dev/null") { cur = null; continue; }
93
+ cur = out.get(p) ?? { added: [], removed: [] };
94
+ out.set(p, cur);
95
+ continue;
96
+ }
97
+ if (line.startsWith("--- a/") || line.startsWith("@@")) continue;
98
+ if (!cur) continue;
99
+ if (line.startsWith("+")) cur.added.push(line.slice(1));
100
+ else if (line.startsWith("-")) cur.removed.push(line.slice(1));
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const isDone = (p: string) => /(^|\/)DONE\.md$/.test(p);
106
+ const isQueue = (p: string) => /(^|\/)QUEUE\.md$/.test(p);
107
+ const isPhaseDoc = (p: string) => /PHASE[^/]*TASKS\.md$/i.test(p);
108
+
109
+ const linesWhere = (byFile: ReturnType<typeof diffByFile>, match: (p: string) => boolean, side: "added" | "removed") =>
110
+ [...byFile.entries()].filter(([p]) => match(p)).flatMap(([, v]) => v[side]);
111
+
112
+ /**
113
+ * A ref that actually identifies a pull request.
114
+ *
115
+ * MEASURED ON REAL DATA, and this is why the check exists: the done-entry
116
+ * parser splits on the LAST ` — `, so an entry whose own text contains an em
117
+ * dash yields a "ref" of `aide-verified, coordinator-closed`. Emitting that as
118
+ * a `pr` event would put a target on the bus that names no PR, and a
119
+ * subscription could never match it — a silent, permanent miss dressed as a
120
+ * delivery. Parsing correctly is not the same as the parse MEANING what you
121
+ * assumed.
122
+ */
123
+ const PR_REF = /^(?:[\w.-]+\/[\w.-]+#\d+|#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+)$/;
124
+
125
+ /** Queue and done text, compared for the pairing below. */
126
+ const norm = (s: string) => String(s).toLowerCase().replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
127
+
128
+ /**
129
+ * Events implied by one committed change.
130
+ *
131
+ * `after` carries the documents AS THEY NOW STAND — every event is checked
132
+ * against them by the caller (`eventIsDerived`), so a diff that has since been
133
+ * reverted cannot produce an event that outlives the record.
134
+ */
135
+ export function eventsFromCommittedChange(
136
+ diff: string,
137
+ after: { done?: string; phases?: Record<string, string> } = {},
138
+ ): RecordEvent[] {
139
+ const events: RecordEvent[] = [];
140
+ const unattributedItems: string[] = [];
141
+ const byFile = diffByFile(diff);
142
+
143
+ // ── pr: a new DONE.md entry naming a PR ───────────────────────────────────
144
+ // Parsed, never regexed off the raw line: the glyph contract (` — ` U+2014,
145
+ // ` · ` U+00B7) is exact, and a hand-written entry using a plain hyphen must
146
+ // NOT quietly become an event with a mangled ref. It fails to parse, and a
147
+ // line that does not parse is not a record entry.
148
+ const addedDone = linesWhere(byFile, isDone, "added");
149
+ const newEntries = doneEntriesOf(parseWorkDoc(`## Done\n${addedDone.join("\n")}\n`)) as Array<{ ref?: string; text?: string }>;
150
+
151
+ for (const entry of newEntries) {
152
+ const ref = entry.ref?.trim();
153
+ if (!ref || !PR_REF.test(ref)) continue;
154
+ events.push({ kind: "pr", target: ref, ref, summary: entry.text?.slice(0, 120) || ref });
155
+ }
156
+
157
+ // ── item: a queue item that closed ────────────────────────────────────────
158
+ //
159
+ // ATTRIBUTION IS THE WHOLE PROBLEM, and the markdown does not carry it. A
160
+ // queue item and the DONE entry that closes it share no id and, on real data,
161
+ // no wording either: the item states what to do and the entry states what was
162
+ // done. `land` only knows because its caller passed `queueItemId`.
163
+ //
164
+ // So: pair on text when the texts DO correspond, otherwise pair only when the
165
+ // commit is unambiguous — exactly one item removed and exactly one entry
166
+ // added. Anything else is reported as unattributed rather than guessed. The
167
+ // naive version attributed all 15 items removed in one real commit to the
168
+ // first PR it saw, which is a wrong claim about fourteen of them, and a wrong
169
+ // claim on this bus is worse than a missing one.
170
+ const removedQueue = linesWhere(byFile, isQueue, "removed");
171
+ const removedItems = (queueItemsOf(parseWorkDoc(`## Queue\n${removedQueue.join("\n")}\n`)) as Array<{ id?: string; text?: string }>)
172
+ .filter((i) => i.id);
173
+ const prEntries = newEntries.filter((e) => e.ref && PR_REF.test(e.ref.trim()));
174
+ const unattributed: string[] = [];
175
+
176
+ for (const item of removedItems) {
177
+ const itemText = norm(item.text ?? "");
178
+ let entry = itemText
179
+ ? prEntries.find((e) => {
180
+ const t = norm(e.text ?? "");
181
+ return t && (t === itemText || t.startsWith(itemText) || itemText.startsWith(t));
182
+ })
183
+ : undefined;
184
+ // The unambiguous-commit case: one out, one in. This is the shape the fleet
185
+ // actually commits ("close its queue item"), and it is the case the aide's
186
+ // dead `item` subscriptions need.
187
+ if (!entry && removedItems.length === 1 && prEntries.length === 1) entry = prEntries[0];
188
+ if (!entry) {
189
+ unattributed.push(item.id!);
190
+ continue;
191
+ }
192
+ events.push({ kind: "item", target: item.id!, ref: entry.ref!.trim(), summary: entry.text?.slice(0, 120) ?? item.id! });
193
+ }
194
+ if (unattributed.length) unattributedItems.push(...unattributed);
195
+
196
+ // ── task: checkbox transitions ────────────────────────────────────────────
197
+ // `newlyTickedInDiff` requires BOTH a removed open box and an added ticked one
198
+ // for the same id, so a moved or reformatted line cannot manufacture a
199
+ // completion — that rule is the seam's and is reused rather than re-derived.
200
+ const ticked = [...newlyTickedInDiff(diff)];
201
+ const tickedLines = linesWhere(byFile, isPhaseDoc, "added");
202
+ for (const key of ticked) {
203
+ const id = key.split(":")[1] ?? "";
204
+ // Ref is the ticked LINE, not the bare id: `12.1` appears in prose all over
205
+ // a phase doc, so a bare id would pass the derivation check against a
206
+ // document that never ticked anything.
207
+ const line = tickedLines.find((l) => new RegExp(`^\\s*-\\s*\\[x\\]\\s*\\*{0,2}${id.replace(".", "\\.")}\\b`, "i").test(l));
208
+ if (!line) continue;
209
+ events.push({ kind: "task", target: key, ref: line.trim(), summary: line.trim().slice(0, 120) });
210
+ }
211
+
212
+ // ── phase: the last open box in ONE document ──────────────────────────────
213
+ // Keyed on the FILE, both for "is it complete" and for "did this change close
214
+ // it". `newlyTickedInDiff` keys ticks by the integer in the path, so
215
+ // `phase5.1` and `phase5` collapse to the same `5:` — the first real-data run
216
+ // announced PHASE 5 COMPLETE on the strength of a tick in the 5.1 document.
217
+ // A false completion is worse than a missing one: it closes a phase nobody
218
+ // finished.
219
+ for (const [rel, text] of Object.entries(after.phases ?? {})) {
220
+ if (!isPhaseDoc(rel)) continue;
221
+ const boxes = [...String(text).matchAll(/^\s*-\s*\[( |x)\]\s*\*{0,2}(\d+\.\d+[a-z]?)\b/gim)];
222
+ if (!boxes.length || boxes.some((m) => m[1] !== "x")) continue;
223
+ // This commit must have ticked a box IN THIS FILE.
224
+ const closedHere = (byFile.get(rel)?.added ?? []).some((l) => /^\s*-\s*\[x\]\s*\*{0,2}\d+\.\d+/.test(l));
225
+ if (!closedHere) continue;
226
+ const phase = /phase(\d+(?:\.\d+)?)/i.exec(rel)?.[1];
227
+ if (!phase) continue;
228
+ events.push({ kind: "phase", target: phase, ref: boxes[boxes.length - 1]![0].trim(), summary: `phase ${phase} — every task box ticked` });
229
+ }
230
+
231
+ lastUnattributedItems = unattributedItems;
232
+ return events;
233
+ }
234
+
235
+ /**
236
+ * Queue item ids removed in the last scanned change that could NOT be tied to a
237
+ * done entry. Reported by `scan_record_events` rather than dropped: "we saw
238
+ * items close and could not say which PR closed them" and "nothing closed" are
239
+ * different facts, and only one of them needs a human.
240
+ */
241
+ export let lastUnattributedItems: string[] = [];
242
+
243
+ /** `git` in a repo, returning "" rather than throwing — a scan is read-only. */
244
+ export function git(repo: string, args: string[]): string {
245
+ try {
246
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
247
+ } catch {
248
+ return "";
249
+ }
250
+ }
251
+
252
+ /* ── the verb ──────────────────────────────────────────────────────────────── */
253
+
254
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
255
+ import path from "node:path";
256
+ import { z } from "zod";
257
+ import { ROOT } from "../store.js";
258
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
259
+
260
+ /**
261
+ * The watermark: the last commit whose record change has been turned into
262
+ * events, per repo.
263
+ *
264
+ * It is stored rather than inferred, because "what have I already emitted" is
265
+ * not derivable from the repo — and the alternative, re-deriving from some
266
+ * fixed point every time, would re-announce a year of closures on first run.
267
+ * Losing the file is safe in the direction that matters: the idempotency key is
268
+ * derived from the EVENT, so a re-scan of already-delivered events reports
269
+ * `duplicate-suppressed` rather than waking anyone twice.
270
+ */
271
+ const watermarkFile = () => path.join(ROOT, "record-events.json");
272
+
273
+ type Watermarks = Record<string, { sha: string; at: number }>;
274
+
275
+ function readWatermarks(): Watermarks {
276
+ const f = watermarkFile();
277
+ if (!existsSync(f)) return {};
278
+ try {
279
+ return (JSON.parse(readFileSync(f, "utf8")).repos ?? {}) as Watermarks;
280
+ } catch {
281
+ return {};
282
+ }
283
+ }
284
+
285
+ function writeWatermark(repo: string, sha: string): void {
286
+ mkdirSync(ROOT, { recursive: true });
287
+ const all = readWatermarks();
288
+ all[repo] = { sha, at: Date.now() };
289
+ writeFileSync(watermarkFile(), `${JSON.stringify({ repos: all }, null, 2)}\n`);
290
+ }
291
+
292
+ export const scanRecordEventsSchema = {
293
+ repo: z.string().min(1),
294
+ /** Defaults to the stored watermark; first run with none scans HEAD~1..HEAD. */
295
+ since: z.string().optional(),
296
+ /** Report only. Default true — same posture as `land` and `next_unblocked`. */
297
+ write: z.boolean().optional(),
298
+ };
299
+
300
+ export async function scanRecordEventsTool(args: { repo: string; since?: string; write?: boolean }) {
301
+ const repo = path.resolve(args.repo);
302
+ if (!existsSync(path.join(repo, ".git"))) {
303
+ return { ok: false as const, error: `'${repo}' is not a git repository — the commit is the boundary for a record change, so there is nothing to scan` };
304
+ }
305
+
306
+ const head = git(repo, ["rev-parse", "HEAD"]).trim();
307
+ if (!head) return { ok: false as const, error: `could not resolve HEAD in ${repo}` };
308
+
309
+ const stored = readWatermarks()[repo]?.sha;
310
+ const since = args.since ?? stored ?? `${head}~1`;
311
+ // A watermark from a rebased-away commit resolves to nothing. Say so rather
312
+ // than silently falling back to HEAD~1 and reporting a one-commit scan as if
313
+ // it covered the gap — that is the shape where a miss looks like a clean run.
314
+ if (!git(repo, ["cat-file", "-e", `${since}^{commit}`]) && !git(repo, ["rev-parse", "--verify", `${since}^{commit}`]).trim()) {
315
+ return {
316
+ ok: false as const,
317
+ error:
318
+ `base '${since}' does not resolve in ${repo} — it was probably rebased away. ` +
319
+ `Nothing was scanned and the watermark was NOT advanced: a scan that silently narrows its window ` +
320
+ `reports a clean run over the commits it never looked at. Pass an explicit 'since'.`,
321
+ };
322
+ }
323
+
324
+ const diff = git(repo, ["diff", "--unified=0", `${since}..${head}`, "--", "docs/"]);
325
+ const doneText = existsSync(path.join(repo, "docs/DONE.md")) ? readFileSync(path.join(repo, "docs/DONE.md"), "utf8") : "";
326
+ const phases: Record<string, string> = {};
327
+ for (const rel of git(repo, ["ls-files", "docs/phases/"]).split("\n").filter((p) => /PHASE[^/]*TASKS\.md$/i.test(p))) {
328
+ const p = path.join(repo, rel);
329
+ if (existsSync(p)) phases[rel] = readFileSync(p, "utf8");
330
+ }
331
+
332
+ const candidates = eventsFromCommittedChange(diff, { done: doneText, phases });
333
+ const unattributed = [...lastUnattributedItems];
334
+
335
+ // Every event is checked against the record AS IT NOW STANDS (6.2). A change
336
+ // that has since been reverted produces a candidate the record no longer
337
+ // supports, and it is refused — the stream can never claim what the
338
+ // authoritative markdown does not.
339
+ const emitted: RecordEvent[] = [];
340
+ const refused: string[] = [];
341
+ for (const ev of candidates) {
342
+ const recordText = ev.kind === "task" || ev.kind === "phase" ? Object.values(phases).join("\n") : doneText;
343
+ const derived = eventIsDerived(recordText, ev);
344
+ if (derived.ok) emitted.push(ev);
345
+ else refused.push(derived.error);
346
+ }
347
+
348
+ const deliveries: unknown[] = [];
349
+ if (args.write) {
350
+ let subs = readSubs();
351
+ const now = Date.now();
352
+ for (const ev of emitted) {
353
+ const r = evaluate(subs, ev, now);
354
+ subs = r.subs;
355
+ deliveries.push(...r.deliveries);
356
+ }
357
+ commitEvaluation(subs);
358
+ writeWatermark(repo, head);
359
+ }
360
+
361
+ return {
362
+ ok: true as const,
363
+ repo,
364
+ scanned: { from: since, to: head },
365
+ emitted,
366
+ refused,
367
+ deliveries,
368
+ ...(unattributed.length
369
+ ? {
370
+ unattributedItems: unattributed,
371
+ unattributedNote:
372
+ `${unattributed.length} queue item(s) left docs/QUEUE.md in this range without a done entry they could be tied to. ` +
373
+ `They emitted NOTHING: the markdown carries no link between an item and the entry that closes it, so attributing them ` +
374
+ `would be a guess. "Closed by an unknown PR" and "not closed" are different facts and this is the first.`,
375
+ }
376
+ : {}),
377
+ ...(args.write
378
+ ? { watermark: head }
379
+ : {
380
+ note:
381
+ "REPORT ONLY — nothing was delivered and the watermark was not advanced. Pass write:true to deliver. " +
382
+ "Re-scanning an already-delivered range is safe: the idempotency key is derived from the event, so it reports duplicate-suppressed.",
383
+ }),
384
+ kinds: EVENT_KINDS,
385
+ };
386
+ }