agent-coord-mcp 0.26.6 → 0.26.7

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.
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Emit on MERGEABLE-AND-UNATTENDED.
4
+ *
5
+ * Two mergeable, CI-green PRs sat 17 hours; two more sat 40 minutes and 1h48 on
6
+ * the day the coordinator said it was checking for that. Nothing in the room
7
+ * said so — a worker went looking. This is the mechanism that was missing.
8
+ *
9
+ * A TIMER, NOT A TRIGGER, for the reason the stall clock is: a mechanism
10
+ * without a schedule measures inbound traffic rather than elapsed time, and
11
+ * cannot fire during exactly the quiet it exists to cover.
12
+ *
13
+ * Named unit: com.davidbalzan.coord-attention-clock (see --install).
14
+ *
15
+ * UNKNOWN IS NOT UNATTENDED. `mergeable` is UNKNOWN for a while after every
16
+ * push and an empty rollup is ambiguous; both are reported as unknown, never as
17
+ * an alert. Inventing an alert from a question GitHub has not answered is the
18
+ * fabrication class this fleet has spent three days removing.
19
+ *
20
+ * REPORTED ONCE, not every tick: delivery goes through the same subscription
21
+ * machinery as record events, so the idempotency key suppresses a repeat. A DM
22
+ * every 30 minutes about the same PR trains its own dismissal.
23
+ */
24
+ import { execFileSync } from "node:child_process";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { homedir } from "node:os";
28
+
29
+ const argv = Object.fromEntries(
30
+ process.argv.slice(2).flatMap((a) => {
31
+ const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
32
+ return m ? [[m[1], m[2] ?? true]] : [];
33
+ }),
34
+ );
35
+ const here = path.dirname(fileURLToPath(import.meta.url));
36
+ const dist = path.join(here, "..", "dist");
37
+ const LABEL = "com.davidbalzan.coord-attention-clock";
38
+
39
+ if (argv.install) {
40
+ const repo = String(argv.repo ?? process.cwd());
41
+ const to = String(argv.to ?? "<coordinator-agent-id>");
42
+ const every = Number(argv.every ?? 15);
43
+ const self = path.join(here, "coord-attention-clock.mjs");
44
+ console.log(`# macOS launchd — Label: ${LABEL}
45
+ # launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${LABEL}.plist
46
+ <?xml version="1.0" encoding="UTF-8"?>
47
+ <plist version="1.0"><dict>
48
+ <key>Label</key><string>${LABEL}</string>
49
+ <key>ProgramArguments</key><array>
50
+ <string>${process.execPath}</string><string>${self}</string>
51
+ <string>--repo=${repo}</string><string>--to=${to}</string>
52
+ </array>
53
+ <key>StartInterval</key><integer>${every * 60}</integer>
54
+ <key>RunAtLoad</key><true/>
55
+ <key>StandardErrorPath</key><string>${path.join(homedir(), "agent-coord", "logs", "attention-clock.err")}</string>
56
+ </dict></plist>
57
+
58
+ # Linux cron:
59
+ # */${every} * * * * ${process.execPath} ${self} --repo=${repo} --to=${to}
60
+ `);
61
+ process.exit(0);
62
+ }
63
+
64
+ const repo = String(argv.repo ?? process.cwd());
65
+ const to = argv.to ? String(argv.to) : null;
66
+ const from = String(argv.from ?? "coord-attention-clock");
67
+ const quietMinutes = Number(argv["quiet-minutes"] ?? 20);
68
+
69
+ const { partition } = await import(path.join(dist, "tools/attention.js"));
70
+ const { readSubs, evaluate, commitEvaluation } = await import(path.join(dist, "tools/events.js"));
71
+ const { sendMessageTool } = await import(path.join(dist, "tools/messaging.js"));
72
+
73
+ let prs;
74
+ try {
75
+ const out = execFileSync(
76
+ "gh",
77
+ ["pr", "list", "--state", "open", "--json", "number,state,mergeable,updatedAt,statusCheckRollup"],
78
+ { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
79
+ );
80
+ prs = JSON.parse(out).map((p) => ({ ...p, checks: p.statusCheckRollup ?? [] }));
81
+ } catch (e) {
82
+ // NOT FETCHED IS NOT NOTHING-TO-REPORT. A silent failure here is exactly the
83
+ // quiet that let the PRs sit.
84
+ console.error(`[attention-clock] FAILED to list PRs: ${(e && e.message) || e}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ const now = Date.now();
89
+ const { unattended, unknown, attended } = partition(prs, now, quietMinutes * 60_000);
90
+
91
+ let delivered = 0;
92
+ if (to) {
93
+ const subs = readSubs();
94
+ let next = subs;
95
+ const fresh = [];
96
+ for (const u of unattended) {
97
+ const ev = { kind: "pr", target: "mergeable-unattended", ref: `#${u.pr.number}`, summary: u.why };
98
+ const r = evaluate(next, ev, now);
99
+ next = r.subs;
100
+ if (r.deliveries.some((d) => d.status === "delivered")) fresh.push(u);
101
+ }
102
+ commitEvaluation(next);
103
+ if (fresh.length) {
104
+ await sendMessageTool({
105
+ from,
106
+ to,
107
+ text:
108
+ `AGENT_ACTION: ${fresh.length} PR(s) MERGEABLE AND UNATTENDED.\n` +
109
+ fresh.map((u) => `- ${u.why}`).join("\n") +
110
+ `\n\nScheduled check (${LABEL}), not a person. Verify before merging: this reports GATE-READINESS, never that the change is correct.`,
111
+ });
112
+ delivered = fresh.length;
113
+ }
114
+ }
115
+
116
+ // The null result every run: a check that only speaks when it fires cannot be
117
+ // told from a broken one. `unknown` is printed separately from `attended`
118
+ // because collapsing them is the defect this script refuses to commit.
119
+ console.log(
120
+ `[attention-clock] ${prs.length} open · unattended ${unattended.length} (DM'd ${delivered}) · unknown ${unknown.length} · attended ${attended}` +
121
+ (unknown.length ? `\n unknown:\n${unknown.map((u) => ` ${u.why}`).join("\n")}` : ""),
122
+ );
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * THE CLOCK. Runs `stall_check` on a schedule and DMs the duty officer on HIT.
4
+ *
5
+ * WHY A SCHEDULER AND NOT AN AGENT LOOP: a mechanism without a schedule is
6
+ * REACTIVE, and its null reports measure inbound traffic rather than elapsed
7
+ * time. console-worker-1's standing trigger ran zero times in seventeen hours
8
+ * because it only fired when a message woke it — so it could not run during
9
+ * exactly the period it existed to cover, a fleet gone quiet. `wait_for_message`
10
+ * is disqualified for the same reason (Task 3.3).
11
+ *
12
+ * THE UNIT IS NAMED, because "standing" without a named scheduler is a habit:
13
+ * macOS launchd com.davidbalzan.coord-stall-clock (see --install)
14
+ * Linux cron the line --install prints
15
+ *
16
+ * WHAT IT MEASURES WHEN NOBODY IS WATCHING: that is the whole point, and it is
17
+ * why this is a timer rather than a trigger.
18
+ *
19
+ * MISS IS SILENT TO THE DUTY OFFICER AND NEVER SILENT TO THE RECORD. A DM every
20
+ * 30 minutes trains its own dismissal; a missing run mark makes a dead clock
21
+ * look like a healthy fleet. `stall_check` writes the mark itself; this script
22
+ * additionally records FAILURES, without which a clock that throws every time
23
+ * leaves no marks at all and is identical on disk to one never installed.
24
+ *
25
+ * It does NOT speak MCP: `stallCheckTool` is a plain function over
26
+ * ~/agent-coord and the repo's board, so a timer needs no server and no
27
+ * transport. One less thing that can be up while the thing it watches is down.
28
+ */
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { homedir } from "node:os";
32
+
33
+ const argv = Object.fromEntries(
34
+ process.argv.slice(2).flatMap((a) => {
35
+ const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
36
+ return m ? [[m[1], m[2] ?? true]] : [];
37
+ }),
38
+ );
39
+
40
+ const here = path.dirname(fileURLToPath(import.meta.url));
41
+ const dist = path.join(here, "..", "dist");
42
+
43
+ const LABEL = "com.davidbalzan.coord-stall-clock";
44
+
45
+ if (argv.install) {
46
+ const repo = String(argv.repo ?? process.cwd());
47
+ const duty = String(argv.duty ?? "<duty-officer-agent-id>");
48
+ const every = Number(argv.every ?? 30);
49
+ const self = path.join(here, "coord-stall-clock.mjs");
50
+ const node = process.execPath;
51
+ console.log(`# macOS — launchd. Label: ${LABEL}
52
+ # Write to ~/Library/LaunchAgents/${LABEL}.plist, then:
53
+ # launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${LABEL}.plist
54
+ <?xml version="1.0" encoding="UTF-8"?>
55
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
56
+ <plist version="1.0"><dict>
57
+ <key>Label</key><string>${LABEL}</string>
58
+ <key>ProgramArguments</key><array>
59
+ <string>${node}</string><string>${self}</string>
60
+ <string>--repo=${repo}</string><string>--duty=${duty}</string>
61
+ </array>
62
+ <key>StartInterval</key><integer>${every * 60}</integer>
63
+ <key>RunAtLoad</key><true/>
64
+ <key>StandardErrorPath</key><string>${path.join(homedir(), "agent-coord", "logs", "stall-clock.err")}</string>
65
+ </dict></plist>
66
+
67
+ # Linux — cron:
68
+ # */${every} * * * * ${node} ${self} --repo=${repo} --duty=${duty}
69
+ `);
70
+ process.exit(0);
71
+ }
72
+
73
+ const repo = String(argv.repo ?? process.cwd());
74
+ const duty = argv.duty ? String(argv.duty) : null;
75
+ const from = String(argv.from ?? "coord-stall-clock");
76
+ const stallMinutes = argv["stall-minutes"] ? Number(argv["stall-minutes"]) : undefined;
77
+
78
+ const { stallCheckTool, markRunFailure } = await import(path.join(dist, "tools/stall.js"));
79
+ const { sendMessageTool } = await import(path.join(dist, "tools/messaging.js"));
80
+
81
+ try {
82
+ const r = await stallCheckTool({ repo, ...(stallMinutes ? { stallMinutes } : {}) });
83
+
84
+ // `ok:false` is a FAILURE, not a quiet fleet — e.g. no board at that path.
85
+ // Recorded, or the next `stall_clock_status` reads a stopped clock as absent
86
+ // rather than broken.
87
+ if (!r.ok) {
88
+ markRunFailure(r.error ?? "stall_check returned ok:false with no reason");
89
+ console.error(`[stall-clock] FAILED: ${r.error}`);
90
+ process.exit(1);
91
+ }
92
+
93
+ if (r.hits.length && duty) {
94
+ const lines = r.hits.map((h) =>
95
+ h.kind === "no-heartbeat"
96
+ ? `- ${h.agentId}: no heartbeat for ${h.minutes}m (${h.stream})`
97
+ : `- ${h.agentId}: no commits on ${h.branch} for ${h.minutes}m`,
98
+ );
99
+ await sendMessageTool({
100
+ from,
101
+ to: duty,
102
+ text: `AGENT_ACTION: stall_check HIT — ${r.hits.length} of ${r.checked} in-flight row(s) stalled.\n${lines.join("\n")}\n\nThis is a scheduled check (${LABEL}), not a person watching. Verify before acting: a stall is a claim about the BOARD, not about the agent.`,
103
+ });
104
+ }
105
+
106
+ // Null result to stdout every run: the log is the record a human reads, and a
107
+ // trigger that only speaks when it fires cannot be told from a broken one.
108
+ const um = r.unmeasurable?.length ? `, ${r.unmeasurable.length} unmeasurable` : "";
109
+ console.log(
110
+ r.hits.length
111
+ ? `[stall-clock] HIT ${r.hits.length}/${r.checked}${um}${duty ? ` — DM sent to ${duty}` : " — NO DUTY OFFICER SET, no DM"}`
112
+ : `[stall-clock] MISS 0/${r.checked}${um} — no DM, run recorded`,
113
+ );
114
+ } catch (e) {
115
+ // THE CLAUSE MOST EASILY SKIPPED. A clock that throws every time leaves no
116
+ // marks, which on disk is identical to a clock that was never installed.
117
+ const reason = (e && e.message) || String(e);
118
+ try {
119
+ markRunFailure(reason);
120
+ } catch {
121
+ /* if even the mark cannot be written, stderr is all that is left */
122
+ }
123
+ console.error(`[stall-clock] FAILED: ${reason}`);
124
+ process.exit(1);
125
+ }
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import { unlinkSync, writeFileSync } from "node:fs";
8
8
  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
+ import { subscribeSchema, subscribeTool, unsubscribeSchema, unsubscribeTool, listSubscriptionsSchema, listSubscriptionsTool } from "./tools/events.js";
11
12
  import {
12
13
  ensureDirs,
13
14
  getTokenMap,
@@ -626,6 +627,27 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
626
627
  gate("agentId", rotateReconcileTool as (a: Record<string, unknown>) => Promise<unknown>),
627
628
  );
628
629
 
630
+ addTool(
631
+ "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
+ subscribeSchema,
634
+ gate("agentId", subscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
635
+ );
636
+
637
+ addTool(
638
+ "unsubscribe",
639
+ "Remove one of YOUR subscriptions. Refuses another agent's: silently dropping someone else's notification is how a miss is manufactured.",
640
+ unsubscribeSchema,
641
+ gate("agentId", unsubscribeTool as (a: Record<string, unknown>) => Promise<unknown>),
642
+ );
643
+
644
+ addTool(
645
+ "list_subscriptions",
646
+ "List subscriptions with their health. A subscription NEVER EVALUATED is an ERROR, not a quiet zero \u2014 'no events yet' and 'never ran' are the same output and only one is healthy. Carries its population, because 'no subscriptions' and 'none listed for you' are different claims.",
647
+ listSubscriptionsSchema,
648
+ gate(null, listSubscriptionsTool as (a: Record<string, unknown>) => Promise<unknown>),
649
+ );
650
+
629
651
  addTool(
630
652
  "export_work",
631
653
  "Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Refuses write:true when that write would emit a new 5-col lanes-v0 table (parse-only; write grammar is workstreams.v1). Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Emit on the state that has twice cost hours: a PR that is MERGEABLE and
3
+ * UNATTENDED.
4
+ *
5
+ * Two mergeable, CI-green PRs sat 17 hours; two more sat 40 minutes and 1h48
6
+ * on the day the coordinator said it was checking for exactly that. Nothing in
7
+ * the room said so — a worker went looking and found it. There is no mechanism
8
+ * that reports "these PRs are mergeable and nobody is acting".
9
+ *
10
+ * 6.2 APPLIES HERE TOO: the event is DERIVED from observable state, never a
11
+ * parallel claim. Everything below is computed from what GitHub actually
12
+ * reports, and anything it has not answered yet is `unknown`.
13
+ */
14
+ import { normalizeChecks } from "./records.js";
15
+
16
+ export type PrFactsForAttention = {
17
+ number: number;
18
+ state: string;
19
+ mergeable: string;
20
+ updatedAt: string;
21
+ checks: unknown[];
22
+ repo?: string;
23
+ };
24
+
25
+ export type Attention =
26
+ | { state: "unattended"; why: string }
27
+ | { state: "unknown"; why: string }
28
+ | { state: "attended"; why: string };
29
+
30
+ /**
31
+ * UNKNOWN IS NOT UNATTENDED, and this is the clause the coordinator and I have
32
+ * both been bitten by today.
33
+ *
34
+ * `mergeable` is `UNKNOWN` for a while after every push — GitHub computes it
35
+ * asynchronously — and an empty check rollup is ambiguous between "not yet" and
36
+ * "never will be". Reading either as "nobody is acting" invents an alert out of
37
+ * a question GitHub has not answered, which is the fabrication class this fleet
38
+ * has spent three days removing.
39
+ */
40
+ export function classifyPr(pr: PrFactsForAttention, nowMs: number, quietMs: number): Attention {
41
+ if (pr.state !== "OPEN") return { state: "attended", why: `#${pr.number} is ${pr.state}` };
42
+
43
+ if (!pr.mergeable || pr.mergeable === "UNKNOWN")
44
+ return {
45
+ state: "unknown",
46
+ why: `#${pr.number}: GitHub has not computed mergeability yet (frequently the case right after a push). UNKNOWN is not unattended — reporting it would invent an alert from an unanswered question.`,
47
+ };
48
+
49
+ if (pr.mergeable === "CONFLICTING")
50
+ return { state: "attended", why: `#${pr.number} is CONFLICTING — it needs its author, not a gate` };
51
+
52
+ const checks = normalizeChecks(pr.checks ?? []);
53
+ if (checks.length === 0)
54
+ return {
55
+ state: "unknown",
56
+ why: `#${pr.number} reports ZERO checks — ambiguous between "not started" and "never will". No checks is not passing checks, and it is not unattended either.`,
57
+ };
58
+
59
+ const pending = checks.filter((c) => c.verdict === "pending");
60
+ if (pending.length)
61
+ return { state: "unknown", why: `#${pr.number}: ${pending.length} check(s) still running — not yet a gate's turn` };
62
+
63
+ const failed = checks.filter((c) => c.verdict === "fail");
64
+ if (failed.length)
65
+ return { state: "attended", why: `#${pr.number} has failing checks (${failed.map((c) => c.name).join(", ")}) — the author's move, not the gate's` };
66
+
67
+ // Quiet is measured from the PR's own last update. A PR touched a minute ago
68
+ // is not unattended; its author may still be pushing.
69
+ const quietFor = nowMs - Date.parse(pr.updatedAt);
70
+ if (!Number.isFinite(quietFor))
71
+ return { state: "unknown", why: `#${pr.number}: unreadable updatedAt '${pr.updatedAt}' — cannot say how long it has waited` };
72
+ if (quietFor < quietMs)
73
+ return { state: "attended", why: `#${pr.number} was updated ${Math.round(quietFor / 60000)}m ago — still moving` };
74
+
75
+ return {
76
+ state: "unattended",
77
+ why: `#${pr.number} is MERGEABLE, all ${checks.length} check(s) pass, and nothing has touched it for ${Math.round(quietFor / 60000)}m`,
78
+ };
79
+ }
80
+
81
+ /** The three states are distinct on purpose; a caller must not collapse them. */
82
+ export function partition(prs: PrFactsForAttention[], nowMs: number, quietMs: number) {
83
+ const out = { unattended: [] as { pr: PrFactsForAttention; why: string }[], unknown: [] as { pr: PrFactsForAttention; why: string }[], attended: 0 };
84
+ for (const pr of prs) {
85
+ const c = classifyPr(pr, nowMs, quietMs);
86
+ if (c.state === "unattended") out.unattended.push({ pr, why: c.why });
87
+ else if (c.state === "unknown") out.unknown.push({ pr, why: c.why });
88
+ else out.attended++;
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,199 @@
1
+ /*
2
+ * Event subscriptions — stop relying on someone CHOOSING to tell you.
3
+ *
4
+ * The bus is almost entirely direct messaging: an agent learns something
5
+ * happened because another agent decided to say so. Every miss this week was a
6
+ * missing NOTIFICATION rather than a missing capability — two mergeable PRs sat
7
+ * 17 hours because nobody told the coordinator to gate, the console's trigger
8
+ * ran zero times because nothing woke it, and `stall_check` runs only when a
9
+ * human types it.
10
+ */
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { z } from "zod";
15
+ import { ROOT } from "../store.js";
16
+
17
+ const subsFile = () => path.join(ROOT, "subscriptions.json");
18
+
19
+ export type SubKind = "task" | "phase" | "item" | "pr";
20
+ export type Subscription = {
21
+ id: string;
22
+ agentId: string;
23
+ kind: SubKind;
24
+ target: string;
25
+ createdAt: number;
26
+ /** null until this subscription has been EVALUATED at least once. */
27
+ lastEvaluatedAt: number | null;
28
+ lastEventAt: number | null;
29
+ /** Idempotency keys already delivered, for 6.4. */
30
+ delivered: string[];
31
+ };
32
+
33
+ export function readSubs(): Subscription[] {
34
+ const f = subsFile();
35
+ if (!existsSync(f)) return [];
36
+ try {
37
+ return (JSON.parse(readFileSync(f, "utf8")).subscriptions ?? []) as Subscription[];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ function writeSubs(subs: Subscription[]): void {
44
+ mkdirSync(ROOT, { recursive: true });
45
+ writeFileSync(subsFile(), `${JSON.stringify({ subscriptions: subs }, null, 2)}\n`);
46
+ }
47
+
48
+ /**
49
+ * 6.3 — A SUBSCRIPTION THAT NEVER FIRES MUST BE DISTINGUISHABLE FROM ONE NEVER
50
+ * REGISTERED, so never-evaluated is an ERROR rather than a quiet zero.
51
+ *
52
+ * We hit the absence of this rule twice in two days: the console's standing
53
+ * trigger and `stall_check`'s clock both looked healthy while never running. A
54
+ * subscription with no last-evaluated mark has produced no evidence of
55
+ * anything, and "no events" is the same output a broken subscription gives.
56
+ */
57
+ export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
58
+ if (s.lastEvaluatedAt === null)
59
+ return {
60
+ level: "error",
61
+ detail: `never evaluated — this subscription has produced no evidence it is wired to anything. "No events yet" and "never ran" are the same output, and only one of them is healthy.`,
62
+ };
63
+ return {
64
+ level: "ok",
65
+ detail: s.lastEventAt
66
+ ? `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, last event ${new Date(s.lastEventAt).toISOString()}`
67
+ : `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, no events yet — evaluated and quiet, which is different from never run`,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * 6.4 — DELIVERY IS AT-LEAST-ONCE BY DESIGN. Safe for a reader, DOUBLE
73
+ * EXECUTION for an executor: a callback that triggers work must carry a key, or
74
+ * the same merge lands twice. The key is derived from the EVENT, never from the
75
+ * delivery attempt, so a retry produces the same key.
76
+ */
77
+ export const eventKey = (kind: SubKind, target: string, ref: string): string =>
78
+ createHash("sha256").update(`${kind}:${target}:${ref}`).digest("hex").slice(0, 16);
79
+
80
+ export type RecordEvent = { kind: SubKind; target: string; ref: string; summary: string };
81
+
82
+ /**
83
+ * 6.2 — EVENTS ARE DERIVED FROM THE RECORD, NEVER PARALLEL TO IT.
84
+ *
85
+ * Enforced here rather than promised in a comment: the event's `ref` must
86
+ * already be present in the record document before anything is emitted. An
87
+ * event stream that can say "task X complete" while DONE.md does not is a
88
+ * second source of truth, and record-vs-state divergence is the defect this
89
+ * fleet hit most this week. ADR-003 keeps markdown authoritative, and this must
90
+ * not quietly reopen it.
91
+ *
92
+ * So the ordering is not a convention: emission READS the record, and an event
93
+ * whose cause is not in the record cannot be emitted at all.
94
+ */
95
+ export function eventIsDerived(recordText: string, ev: RecordEvent): { ok: true } | { ok: false; error: string } {
96
+ if (!ev.ref) return { ok: false, error: `event for ${ev.kind} ${ev.target} carries no ref — nothing ties it to a record entry` };
97
+ if (!String(recordText).includes(ev.ref))
98
+ return {
99
+ ok: false,
100
+ error:
101
+ `refusing to emit ${ev.kind} ${ev.target}: its ref ${ev.ref} is NOT in the record. ` +
102
+ `An event that exists without the record change that caused it is a second source of truth — ` +
103
+ `the stream would claim something the authoritative document does not.`,
104
+ };
105
+ return { ok: true };
106
+ }
107
+
108
+ /** Subscriptions matching an event. Exact target match; no wildcards yet. */
109
+ export const matching = (subs: Subscription[], ev: RecordEvent): Subscription[] =>
110
+ subs.filter((s) => s.kind === ev.kind && s.target === ev.target);
111
+
112
+ export type Delivery = { subscriptionId: string; agentId: string; key: string; status: "delivered" | "duplicate-suppressed" };
113
+
114
+ /**
115
+ * Evaluate every subscription against one event and return what to deliver.
116
+ *
117
+ * EVALUATION IS RECORDED EVEN WHEN NOTHING MATCHES — that is 6.3's whole point.
118
+ * A subscription only learns it is alive by being evaluated, so the mark is
119
+ * written for every subscription of that kind, not only the ones that fired.
120
+ */
121
+ export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
122
+ const key = eventKey(ev.kind, ev.target, ev.ref);
123
+ const deliveries: Delivery[] = [];
124
+ const next = subs.map((s) => {
125
+ if (s.kind !== ev.kind) return s;
126
+ const evaluated = { ...s, lastEvaluatedAt: now };
127
+ if (s.target !== ev.target) return evaluated;
128
+ if (s.delivered.includes(key)) {
129
+ deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
130
+ return evaluated;
131
+ }
132
+ deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "delivered" });
133
+ return { ...evaluated, lastEventAt: now, delivered: [...s.delivered, key].slice(-200) };
134
+ });
135
+ return { subs: next, deliveries };
136
+ }
137
+
138
+ /* ── verbs ─────────────────────────────────────────────────────────────────── */
139
+
140
+ export const subscribeSchema = {
141
+ agentId: z.string().min(1),
142
+ kind: z.enum(["task", "phase", "item", "pr"]),
143
+ target: z.string().min(1),
144
+ };
145
+
146
+ export async function subscribeTool(args: { agentId: string; kind: SubKind; target: string }) {
147
+ const subs = readSubs();
148
+ const dupe = subs.find((s) => s.agentId === args.agentId && s.kind === args.kind && s.target === args.target);
149
+ if (dupe) return { ok: true as const, subscription: dupe, note: "already subscribed — returning the existing registration rather than a second one" };
150
+ const sub: Subscription = {
151
+ id: randomUUID(),
152
+ agentId: args.agentId,
153
+ kind: args.kind,
154
+ target: args.target,
155
+ createdAt: Date.now(),
156
+ lastEvaluatedAt: null,
157
+ lastEventAt: null,
158
+ delivered: [],
159
+ };
160
+ writeSubs([...subs, sub]);
161
+ return { ok: true as const, subscription: sub, health: subscriptionHealth(sub) };
162
+ }
163
+
164
+ export const unsubscribeSchema = { agentId: z.string().min(1), id: z.string().min(1) };
165
+
166
+ export async function unsubscribeTool(args: { agentId: string; id: string }) {
167
+ const subs = readSubs();
168
+ const sub = subs.find((s) => s.id === args.id);
169
+ if (!sub) return { ok: false as const, error: `no subscription '${args.id}'` };
170
+ // Another agent's subscription is not yours to remove: silently dropping
171
+ // someone else's notification is how a miss is manufactured.
172
+ if (sub.agentId !== args.agentId) return { ok: false as const, error: `subscription '${args.id}' belongs to '${sub.agentId}', not '${args.agentId}'` };
173
+ writeSubs(subs.filter((s) => s.id !== args.id));
174
+ return { ok: true as const, removed: sub };
175
+ }
176
+
177
+ export const listSubscriptionsSchema = { agentId: z.string().optional() };
178
+
179
+ export async function listSubscriptionsTool(args: { agentId?: string }) {
180
+ const all = readSubs();
181
+ const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
182
+ const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
183
+ const neverEvaluated = rows.filter((r) => r.health.level === "error");
184
+ return {
185
+ ok: neverEvaluated.length === 0,
186
+ // Population beside the verdict, always: "no subscriptions" and "none
187
+ // listed for you" are different claims.
188
+ population: { listed: rows.length, total: all.length },
189
+ subscriptions: rows,
190
+ ...(neverEvaluated.length
191
+ ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER been evaluated — they have produced no evidence of being wired to anything.` }
192
+ : {}),
193
+ };
194
+ }
195
+
196
+ /** Persist an evaluation. Callers do this after a record write, never before. */
197
+ export function commitEvaluation(next: Subscription[]): void {
198
+ writeSubs(next);
199
+ }