agent-coord-mcp 0.26.5 → 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.
Files changed (43) hide show
  1. package/dist/prefix.js +64 -0
  2. package/dist/prefix.js.map +1 -0
  3. package/dist/server.js +32 -2
  4. package/dist/server.js.map +1 -1
  5. package/dist/tools/attention.js +73 -0
  6. package/dist/tools/attention.js.map +1 -0
  7. package/dist/tools/away.js +122 -0
  8. package/dist/tools/away.js.map +1 -0
  9. package/dist/tools/events.js +171 -0
  10. package/dist/tools/events.js.map +1 -0
  11. package/dist/tools/index.js +3 -0
  12. package/dist/tools/index.js.map +1 -1
  13. package/dist/tools/records.js +651 -0
  14. package/dist/tools/records.js.map +1 -0
  15. package/dist/tools/registry.js +45 -5
  16. package/dist/tools/registry.js.map +1 -1
  17. package/dist/tools/rotate.js +143 -0
  18. package/dist/tools/rotate.js.map +1 -0
  19. package/dist/tools/shared.js +9 -0
  20. package/dist/tools/shared.js.map +1 -1
  21. package/dist/tools/stall.js +294 -0
  22. package/dist/tools/stall.js.map +1 -0
  23. package/dist/tools/transport.js +92 -16
  24. package/dist/tools/transport.js.map +1 -1
  25. package/dist/tools/worktrees.js +294 -0
  26. package/dist/tools/worktrees.js.map +1 -0
  27. package/package.json +2 -2
  28. package/scripts/check-test-count.mjs +1 -1
  29. package/scripts/coord-attention-clock.mjs +122 -0
  30. package/scripts/coord-stall-clock.mjs +125 -0
  31. package/src/prefix.ts +72 -0
  32. package/src/server.ts +138 -3
  33. package/src/tools/attention.ts +91 -0
  34. package/src/tools/away.ts +121 -0
  35. package/src/tools/events.ts +199 -0
  36. package/src/tools/index.ts +3 -0
  37. package/src/tools/records.ts +747 -0
  38. package/src/tools/registry.ts +45 -5
  39. package/src/tools/rotate.ts +180 -0
  40. package/src/tools/shared.ts +24 -0
  41. package/src/tools/stall.ts +311 -0
  42. package/src/tools/transport.ts +99 -3
  43. package/src/tools/worktrees.ts +311 -0
@@ -0,0 +1,121 @@
1
+ /*
2
+ * `coord_away` — Phase 5 Task 4.1/4.2.
3
+ *
4
+ * WHAT THIS IS NOT: a way to hand the coordinator's judgement to someone else.
5
+ * A duty officer keeps the lane MOVING while the coordinator is away; it does
6
+ * not inherit the authority to decide what the lane is for. The distinction is
7
+ * the whole point, so it is enforced as an ALLOWLIST rather than a denylist:
8
+ * a new tool added tomorrow is refused by default, and someone must decide, in
9
+ * writing, that a duty officer may call it.
10
+ *
11
+ * A denylist would have the opposite failure — every tool anyone forgets to
12
+ * list is silently granted — which is the same shape as an empty check rollup
13
+ * scoring green (kit#125): absence read as permission.
14
+ *
15
+ * ENFORCEMENT LIVES IN `addTool`, THE SINGLE PATH EVERY TOOL IS REGISTERED
16
+ * THROUGH, for the reason kit#125 folded the check into the merge call: a
17
+ * guard repeated at call sites is a guard someone forgets at one call site,
18
+ * and the forgotten one looks identical to the guarded ones from outside.
19
+ */
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { z } from "zod";
23
+ import { ROOT } from "../store.js";
24
+
25
+ const awayFile = () => path.join(ROOT, "coord-away.json");
26
+
27
+ /**
28
+ * WHAT A DUTY OFFICER MAY DO: move claimed work along and report.
29
+ *
30
+ * Deliberately absent, each for its own reason rather than by omission:
31
+ * `merge` — merging is a judgement about whether work is DONE.
32
+ * `register`/`join` w/ coordinator role — see `secondCoordinatorRefusal`.
33
+ * grow-fleet verbs — a stand-in does not get to change the fleet's shape.
34
+ * CANON writes — canon outlives the absence that created the stand-in.
35
+ */
36
+ export const DUTY_OFFICER_ALLOWLIST = ["next_unblocked", "claim", "land", "stall_check", "stall_clock_status", "post_status", "send_message", "read_messages", "status", "heartbeat", "list_work"] as const;
37
+
38
+ export type AwayState = { on: boolean; project: string; coordinatorId: string; dutyOfficerId: string; until?: string; at: string };
39
+
40
+ export function readAway(): Record<string, AwayState> {
41
+ const f = awayFile();
42
+ if (!existsSync(f)) return {};
43
+ try {
44
+ return JSON.parse(readFileSync(f, "utf8")) as Record<string, AwayState>;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ function writeAway(state: Record<string, AwayState>): void {
51
+ mkdirSync(ROOT, { recursive: true });
52
+ writeFileSync(awayFile(), `${JSON.stringify(state, null, 2)}\n`);
53
+ }
54
+
55
+ /**
56
+ * The refusal a duty officer gets, or null if the call is allowed.
57
+ *
58
+ * Only the DUTY OFFICER is constrained. Everyone else's lane is unchanged:
59
+ * `coord-away` is not a freeze on the project, and a worker whose tools stop
60
+ * working because the coordinator stepped out would simply stop calling them.
61
+ */
62
+ export function awayRefusal(state: Record<string, AwayState>, agentId: string | undefined, tool: string): string | null {
63
+ if (!agentId) return null;
64
+ const held = Object.values(state).find((s) => s.on && s.dutyOfficerId === agentId);
65
+ if (!held) return null;
66
+ if ((DUTY_OFFICER_ALLOWLIST as readonly string[]).includes(tool)) return null;
67
+ return `'${tool}' is not on the duty-officer allowlist. You are standing in for '${held.coordinatorId}' on '${held.project}' while coord-away is ON: a duty officer keeps the lane MOVING and does not inherit the authority to decide what it is for. Allowed: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. If this genuinely needs doing, it needs ${held.coordinatorId} back or David — not a wider allowlist added in the moment.`;
68
+ }
69
+
70
+ /**
71
+ * 4.2 — a SECOND coordinator may not join while the first is away.
72
+ *
73
+ * Not a lock for its own sake: two coordinators is the condition under which
74
+ * two GOs can be issued for one lane, and the absent one cannot see the other
75
+ * arrive. The duty officer exists precisely so the seat is not empty, so a
76
+ * second claimant is a fleet-shape change, which is the category `coord_away`
77
+ * refuses by construction.
78
+ */
79
+ export function secondCoordinatorRefusal(state: Record<string, AwayState>, agentId: string, roleId: string | undefined): string | null {
80
+ if (roleId !== "coordinator") return null;
81
+ const held = Object.values(state).find((s) => s.on && s.coordinatorId !== agentId);
82
+ if (!held) return null;
83
+ return `coord-away is HELD on '${held.project}' by '${held.coordinatorId}' (duty officer '${held.dutyOfficerId}'). A second coordinator cannot join until it is RELEASED — two coordinators is the condition under which one lane gets two GOs, and the absent one cannot see the second arrive.`;
84
+ }
85
+
86
+ export const coordAwaySchema = {
87
+ project: z.string().min(1),
88
+ on: z.boolean(),
89
+ coordinatorId: z.string().min(1),
90
+ dutyOfficerId: z.string().optional(),
91
+ until: z.string().optional(),
92
+ };
93
+
94
+ export async function coordAwayTool(args: { project: string; on: boolean; coordinatorId: string; dutyOfficerId?: string; until?: string }) {
95
+ const state = readAway();
96
+ const prior = state[args.project];
97
+
98
+ if (args.on) {
99
+ // A STAND-IN WITH NO NAME IS AN EMPTY SEAT DESCRIBED AS COVERED, which is
100
+ // strictly worse than a seat everyone can see is empty.
101
+ if (!args.dutyOfficerId) return { ok: false as const, error: `coord-away ON requires a dutyOfficerId. Turning it on without naming a stand-in reports the lane as covered while leaving it uncovered.` };
102
+ if (args.dutyOfficerId === args.coordinatorId) return { ok: false as const, error: `'${args.coordinatorId}' cannot be its own duty officer.` };
103
+ if (prior?.on && prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away is already HELD on '${args.project}' by '${prior.coordinatorId}'. Release it first.` };
104
+ state[args.project] = { on: true, project: args.project, coordinatorId: args.coordinatorId, dutyOfficerId: args.dutyOfficerId, until: args.until, at: new Date().toISOString() };
105
+ writeAway(state);
106
+ return {
107
+ ok: true as const,
108
+ state: state[args.project],
109
+ allowlist: DUTY_OFFICER_ALLOWLIST,
110
+ announce: `AGENT_ACTION: coord-away ON — '${args.coordinatorId}' away${args.until ? ` until ${args.until}` : ""}; '${args.dutyOfficerId}' is duty officer, limited to: ${DUTY_OFFICER_ALLOWLIST.join(", ")}. A second coordinator cannot join until released.`,
111
+ };
112
+ }
113
+
114
+ // RELEASE IS THE COORDINATOR'S. A duty officer releasing its own limits is
115
+ // the limit not existing — the one call it must not be able to make.
116
+ if (!prior?.on) return { ok: false as const, error: `coord-away is not on for '${args.project}' — nothing to release.` };
117
+ if (prior.coordinatorId !== args.coordinatorId) return { ok: false as const, error: `coord-away on '${args.project}' is held by '${prior.coordinatorId}' and only they can release it. A stand-in that can lift its own limits does not have any.` };
118
+ state[args.project] = { ...prior, on: false, at: new Date().toISOString() };
119
+ writeAway(state);
120
+ return { ok: true as const, state: state[args.project], announce: `AGENT_ACTION: coord-away RELEASED on '${args.project}' — '${prior.coordinatorId}' is back; '${prior.dutyOfficerId}' stands down.` };
121
+ }
@@ -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
+ }
@@ -6,3 +6,6 @@ export * from "./transport.js";
6
6
  export * from "./admin.js";
7
7
  export * from "./scopes.js";
8
8
  export * from "./work.js";
9
+ export * from "./records.js";
10
+ export * from "./worktrees.js";
11
+ export * from "./stall.js";