agent-coord-mcp 0.17.0 → 0.18.0

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 (52) hide show
  1. package/README.md +34 -5
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/roles.js +92 -0
  5. package/dist/roles.js.map +1 -0
  6. package/dist/server.js +30 -14
  7. package/dist/server.js.map +1 -1
  8. package/dist/store.js +54 -1
  9. package/dist/store.js.map +1 -1
  10. package/dist/tools/admin.js +5 -3
  11. package/dist/tools/admin.js.map +1 -1
  12. package/dist/tools/index.js +2 -0
  13. package/dist/tools/index.js.map +1 -1
  14. package/dist/tools/messaging.js +187 -8
  15. package/dist/tools/messaging.js.map +1 -1
  16. package/dist/tools/registry.js +54 -3
  17. package/dist/tools/registry.js.map +1 -1
  18. package/dist/tools/render.js +84 -0
  19. package/dist/tools/render.js.map +1 -0
  20. package/dist/tools/scopes.js +126 -0
  21. package/dist/tools/scopes.js.map +1 -0
  22. package/dist/tools/shared.js +18 -0
  23. package/dist/tools/shared.js.map +1 -1
  24. package/dist/tools/transport.js +367 -33
  25. package/dist/tools/transport.js.map +1 -1
  26. package/dist/tools/work.js +209 -0
  27. package/dist/tools/work.js.map +1 -0
  28. package/dist/work.js +260 -0
  29. package/dist/work.js.map +1 -0
  30. package/hooks/marker.mjs +18 -0
  31. package/hooks/roles.mjs +79 -0
  32. package/hooks/submit.mjs +271 -0
  33. package/hooks/tier.mjs +104 -11
  34. package/hooks/tmux-pusher.mjs +137 -51
  35. package/package.json +5 -4
  36. package/scripts/check-self-dependency.mjs +42 -0
  37. package/scripts/check-test-count.mjs +83 -0
  38. package/scripts/coord-pusher.mjs +150 -33
  39. package/src/build.ts +111 -0
  40. package/src/roles.ts +111 -0
  41. package/src/server.ts +79 -14
  42. package/src/store.ts +60 -1
  43. package/src/tools/admin.ts +10 -4
  44. package/src/tools/index.ts +2 -0
  45. package/src/tools/messaging.ts +206 -7
  46. package/src/tools/registry.ts +63 -4
  47. package/src/tools/render.ts +80 -0
  48. package/src/tools/scopes.ts +177 -0
  49. package/src/tools/shared.ts +120 -0
  50. package/src/tools/transport.ts +386 -34
  51. package/src/tools/work.ts +265 -0
  52. package/src/work.ts +329 -0
@@ -37,6 +37,7 @@
37
37
 
38
38
  import { hostname } from "node:os";
39
39
  import { spawn, spawnSync } from "node:child_process";
40
+ import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
40
41
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
41
42
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
42
43
 
@@ -83,17 +84,36 @@ try {
83
84
  // Call a tool and JSON-parse the wrapped text content. The server's tool
84
85
  // handlers return { content: [{ type:"text", text: JSON.stringify(payload) }] }
85
86
  // (see jsonResult in src/server.ts), so we unwrap exactly once.
86
- async function call(name, args) {
87
+ //
88
+ // { strict: true } makes an MCP tool error (isError:true — e.g. an
89
+ // identity-mismatch on register/report_transport) throw instead of being
90
+ // silently returned as parsed data. Startup uses this so a failed
91
+ // register/report_transport can't be mistaken for success.
92
+ async function call(name, args, { strict = false } = {}) {
87
93
  // SDK zod schemas reject arguments:undefined; send {} for parameter-less tools.
88
94
  const r = await client.callTool({ name, arguments: args ?? {} });
89
95
  const text = r?.content?.[0]?.text;
90
- if (typeof text !== "string") return r;
91
- try { return JSON.parse(text); } catch { return text; }
96
+ const data = typeof text !== "string" ? r : parseOr(text, text);
97
+ if (strict && r?.isError) {
98
+ throw new Error(typeof data === "string" ? data : JSON.stringify(data));
99
+ }
100
+ return data;
101
+ }
102
+
103
+ function parseOr(text, fallback) {
104
+ try { return JSON.parse(text); } catch { return fallback; }
92
105
  }
93
106
 
94
107
  // Register (idempotent), then publish the transport marker so list_agents
95
- // shows us attached. Marker liveness is heartbeat-based server-side.
96
- await call("register", { agentId: AGENT_ID });
108
+ // shows us attached. Marker liveness is heartbeat-based server-side. Both
109
+ // calls are strict: a server-side isError (e.g. identity-mismatch on a
110
+ // stale/reused token) must hard-fail the process rather than let us log
111
+ // "attached" and heartbeat into the void while delivering nothing.
112
+ try {
113
+ await call("register", { agentId: AGENT_ID }, { strict: true });
114
+ } catch (e) {
115
+ die(`register failed: ${e?.message ?? e}`);
116
+ }
97
117
  // Stamp the mtime of THIS process's loaded script so doctor() can spot a
98
118
  // stale remote pusher after a remote-side upgrade (see v0.8.2 stale-pusher
99
119
  // check — same hazard as the local tmux-pusher).
@@ -103,14 +123,22 @@ try {
103
123
  const { fileURLToPath } = await import("node:url");
104
124
  scriptMtime = statSync(fileURLToPath(import.meta.url)).mtimeMs;
105
125
  } catch { /* non-fatal */ }
106
- await call("report_transport", {
107
- agentId: AGENT_ID,
108
- transport: "tmux-push-remote",
109
- host: hostname(),
110
- tmuxTarget: TMUX_TARGET,
111
- since: Date.now(),
112
- ...(scriptMtime !== undefined ? { scriptMtime } : {}),
113
- });
126
+ try {
127
+ await call(
128
+ "report_transport",
129
+ {
130
+ agentId: AGENT_ID,
131
+ transport: "tmux-push-remote",
132
+ host: hostname(),
133
+ tmuxTarget: TMUX_TARGET,
134
+ since: Date.now(),
135
+ ...(scriptMtime !== undefined ? { scriptMtime } : {}),
136
+ },
137
+ { strict: true },
138
+ );
139
+ } catch (e) {
140
+ die(`report_transport failed: ${e?.message ?? e}`);
141
+ }
114
142
  process.stderr.write(
115
143
  `[coord-pusher] attached agent='${AGENT_ID}' tmux=${TMUX_TARGET} server=${SERVER} (room=${INCLUDE_ROOM ? "on" : "off"})\n`,
116
144
  );
@@ -188,15 +216,35 @@ async function flush() {
188
216
 
189
217
  // The per-message PARSE CONTRACT line — MUST stay byte-identical to
190
218
  // hooks/tier.mjs's injectLine (agent harnesses parse from/room/text out of
191
- // it). Compact form (v0.14.0): ` [<kind> <HH:MM> <from>] <text>`, kind drops
219
+ // it). Compact form (v0.14.0): ` [<tag> <HH:MM> <from>] <text>`, tag drops
192
220
  // the leading "room ", timestamp is HH:MM UTC, no "from=" label. This pusher
193
221
  // is standalone (may deploy without hooks/), so the helper is duplicated
194
222
  // rather than imported; test/tier.test.mjs locks both to the same shape.
195
223
  function injectLine(m) {
196
- const tag = String(m.kind ?? "").replace(/^room /, "");
224
+ const tag = String(m.tag ?? "").replace(/^room /, "");
197
225
  const d = new Date(m.ts ?? 0);
198
226
  const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
199
- return ` [${tag} ${hhmm} ${m.from}] ${m.text ?? ""}`;
227
+ let text = m.text ?? "";
228
+ // Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
229
+ // ONE attributed line — first line, a count of what was withheld, and the
230
+ // message id as the retrieval handle. Continuation lines used to arrive bare,
231
+ // with no `[tag HH:MM from]` header, so a parser could not attribute them.
232
+ //
233
+ // The handle is the message id, NOT a stashed copy: the full record is
234
+ // already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
235
+ // retrieve_message reads it back by id (falling through to the append-only
236
+ // archive if compaction moved it). A cache would have had a TTL and lost the
237
+ // record permanently on expiry.
238
+ //
239
+ // Gated on `m.record`: a record-LESS multi-line message is untouched and
240
+ // still arrives unattributed past line 1, exactly as today. Task 6 does not
241
+ // fix hand-typed multi-line messages, and must not change their bytes.
242
+ const nl = text.indexOf("\n");
243
+ if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
244
+ const held = text.split("\n").length - 1;
245
+ text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
246
+ }
247
+ return ` [${tag} ${hhmm} ${m.from}] ${text}`;
200
248
  }
201
249
 
202
250
  function formatBatch(batch) {
@@ -226,12 +274,23 @@ async function injectViaTmux(batch) {
226
274
  const flushRun = async () => {
227
275
  if (run.length === 0) return;
228
276
  await pasteAndSubmit(formatBatch(run), true); // peer content: inert bracketed paste
277
+ await reportReceipts(run); // stamp only AFTER the paste+submit resolves
229
278
  run = [];
230
279
  };
231
280
  for (const m of batch) {
232
281
  if (isControl(m)) {
233
282
  await flushRun();
234
- await pasteAndSubmit(m.text.trim(), false); // validated control: raw slash command
283
+ // Verified like the local path: the extra Enters and the capture-pane
284
+ // check are what make a control command actually run. The outcome is
285
+ // reported back over MCP (report_receipt) so send_command can return
286
+ // delivery:"confirmed" for a remote agent — before that wire tool
287
+ // existed this pusher could only log, and a remote control command was
288
+ // never confirmable no matter how well it went.
289
+ const outcome = await submitControlCommand(m.text.trim());
290
+ if (!outcome.submitted) {
291
+ process.stderr.write(`[coord-pusher] control command NOT submitted: ${outcome.reason}\n`);
292
+ }
293
+ await reportReceipts([m], outcome);
235
294
  } else {
236
295
  run.push(m);
237
296
  }
@@ -239,24 +298,76 @@ async function injectViaTmux(batch) {
239
298
  await flushRun();
240
299
  }
241
300
 
301
+ // Wire counterpart to tmux-pusher's writeReceipts: this pusher cannot append
302
+ // to receipts/<id>.jsonl on the server's filesystem, so it reports each
303
+ // delivery over MCP and the server writes the same receipt line the local
304
+ // path does. `outcome` (control commands only) carries what submit
305
+ // verification actually observed — submitted/verified/reason are forwarded
306
+ // verbatim and OMITTED entirely for ordinary peer batches, because an absent
307
+ // `submitted` means "typed but unverified" and must never be upgraded to a
308
+ // claim of execution this pusher did not make.
309
+ //
310
+ // Best-effort by design: a failed report must not break delivery or crash the
311
+ // inject loop (the message IS in the pane by the time we get here). The
312
+ // sender just times out to delivery:"pending" — the same honest answer an
313
+ // unreported submission always produced. This also covers servers predating
314
+ // the report_receipt tool: the unknown-tool error lands here and is logged.
315
+ async function reportReceipts(msgs, outcome) {
316
+ for (const m of msgs) {
317
+ if (!m || !m.id) continue;
318
+ try {
319
+ await call(
320
+ "report_receipt",
321
+ {
322
+ agentId: AGENT_ID,
323
+ id: m.id,
324
+ ...(m.from !== undefined ? { from: m.from } : {}),
325
+ control: m.control === true,
326
+ ...(outcome
327
+ ? {
328
+ submitted: outcome.submitted === true,
329
+ verified: outcome.verified === true,
330
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
331
+ }
332
+ : {}),
333
+ },
334
+ { strict: true },
335
+ );
336
+ } catch (e) {
337
+ process.stderr.write(`[coord-pusher] report_receipt for ${m.id} failed: ${e?.message ?? e}\n`);
338
+ }
339
+ }
340
+ }
341
+
242
342
  // bracketed=true wraps the paste in bracketed-paste markers (paste-buffer -p) so
243
343
  // a compliant TUI treats the payload as inert data — embedded newlines can't
244
344
  // submit lines or smuggle a "/command". Control commands (/clear, /compact) must
245
345
  // paste RAW (bracketed=false) so the TUI still runs them as slash commands.
346
+ //
347
+ // Paste + submit. The pipeline is ./hooks/submit.mjs, shared with the local
348
+ // pusher — this file used to carry its own copy, which had already drifted to a
349
+ // SINGLE Enter with no settle delay at all, so a remote agent's control command
350
+ // was even less likely to run than a local one. Only tmux is supplied here.
351
+ const tmuxDeps = {
352
+ target: TMUX_TARGET,
353
+ buffer: BUFFER_NAME,
354
+ run: (args) => spawnSync("tmux", args, { encoding: "utf8" }),
355
+ runStdin: (args, payload) =>
356
+ new Promise((resolve, reject) => {
357
+ const load = spawn("tmux", args);
358
+ load.on("error", reject);
359
+ load.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tmux ${args[0]} exit ${code}`))));
360
+ load.stdin.end(payload);
361
+ }),
362
+ };
363
+
246
364
  function pasteAndSubmit(payload, bracketed = false) {
247
- return new Promise((resolve, reject) => {
248
- const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
249
- load.on("error", reject);
250
- load.on("exit", (code) => {
251
- if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
252
- const paste = spawnSync("tmux", ["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", BUFFER_NAME, "-t", TMUX_TARGET, "-d"]);
253
- if (paste.status !== 0) return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
254
- const enter = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
255
- if (enter.status !== 0) return reject(new Error(`tmux send-keys: ${(enter.stderr ?? "").toString().trim()}`));
256
- resolve();
257
- });
258
- load.stdin.end(payload);
259
- });
365
+ return sharedPasteAndSubmit(tmuxDeps, payload, { bracketed });
366
+ }
367
+
368
+ // Control commands go through the preflight + verify path, never the plain one.
369
+ function submitControlCommand(payload) {
370
+ return sharedSubmitControl(tmuxDeps, payload);
260
371
  }
261
372
 
262
373
  // ---------- per-source wait loops + subscription refresh ----------
@@ -275,7 +386,9 @@ function startLoop(source, room) {
275
386
  if (loops.has(key)) return;
276
387
  const state = { cancelled: false };
277
388
  loops.set(key, state);
278
- const tag = source === "inbox" ? "DM" : `room #${normalizeRoom(room)}`;
389
+ // Named `label` to match hooks/tmux-pusher.mjs, and to leave `tag` free as
390
+ // the field name it is assigned to below.
391
+ const label = source === "inbox" ? "DM" : `room #${normalizeRoom(room)}`;
279
392
  (async () => {
280
393
  while (!state.cancelled) {
281
394
  let r;
@@ -283,14 +396,18 @@ function startLoop(source, room) {
283
396
  r = await call("wait_for_message", { agentId: AGENT_ID, source, room, timeoutMs: 60_000 });
284
397
  } catch (e) {
285
398
  // Transport hiccup — back off briefly so we don't spin against a dead server.
286
- process.stderr.write(`[coord-pusher] wait_for_message(${tag}) error: ${e?.message ?? e}\n`);
399
+ process.stderr.write(`[coord-pusher] wait_for_message(${label}) error: ${e?.message ?? e}\n`);
287
400
  await sleep(2_000);
288
401
  continue;
289
402
  }
290
403
  if (state.cancelled) break;
291
404
  const msgs = Array.isArray(r?.messages) ? r.messages : [];
292
405
  for (const m of msgs) {
293
- if (shouldInject(m)) pending.push({ kind: tag, ...m });
406
+ // The channel tag lives in `tag` — a stored Message's own `kind`
407
+ // (retention weight) shared the name and overwrote what injectLine
408
+ // renders. Mirrors hooks/tmux-pusher.mjs; the two must stay
409
+ // byte-identical.
410
+ if (shouldInject(m)) pending.push({ ...m, tag: label });
294
411
  }
295
412
  if (pending.length > 0) scheduleFlush();
296
413
  }
package/src/build.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Build identity of the RUNNING server process.
2
+ //
3
+ // The principle is the #28 pusher-freshness fix carried one layer up: stamp
4
+ // what you LOADED at init, compare against what's on disk NOW, and resolve the
5
+ // measured artifact from the code that is actually executing
6
+ // (import.meta.url), never from configuration — the thing that measures must
7
+ // be the thing that ran. A server process outlives `npm run build`; without a
8
+ // load-time sample there is nothing truthful to compare the on-disk build to,
9
+ // and a server running pre-rebuild code stamps transport markers with logic
10
+ // the rebuild replaced (observed live 2026-07-29: post-#28 attach spawned a
11
+ // pusher with no `--agent` argv and a single-file freshness stamp, agreeing
12
+ // with the new on-disk check only by coincidence).
13
+
14
+ import { readdirSync, statSync, readFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ // Newest mtime (epoch ms) across every file under `dir` (recursive) whose
19
+ // name ends with one of `exts`. Returns undefined when the dir is missing or
20
+ // unreadable — callers skip their check rather than guess. Pure over its
21
+ // arguments so tests exercise it on temp trees instead of touching real
22
+ // sources (a utimes on a shared checkout flips every live pusher's freshness
23
+ // while the suite runs files in parallel).
24
+ export function newestMtimeUnder(dir: string, exts: string[]): number | undefined {
25
+ try {
26
+ let newest: number | undefined;
27
+ for (const rel of readdirSync(dir, { recursive: true }) as string[]) {
28
+ const name = String(rel);
29
+ if (!exts.some((e) => name.endsWith(e))) continue;
30
+ let m: number;
31
+ try {
32
+ m = statSync(path.join(dir, name)).mtimeMs;
33
+ } catch {
34
+ continue; // deleted mid-scan
35
+ }
36
+ if (newest === undefined || m > newest) newest = m;
37
+ }
38
+ return newest;
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ // The dir this module was loaded FROM: dist/ in production, src/ under tsx.
45
+ // Either way it is the code actually running, which is the point.
46
+ export const BUILD_DIR = path.dirname(fileURLToPath(import.meta.url));
47
+
48
+ // .js for the compiled build, .ts for dev-mode (tsx src/server.ts) — both
49
+ // sides of every comparison use the same list, so the two modes are each
50
+ // self-consistent and can never be compared across.
51
+ const BUILD_EXTS = [".js", ".ts"];
52
+
53
+ // Sampled ONCE at module load: the newest mtime across the build this server
54
+ // process actually imported. A later `npm run build` rewrites dist/ under a
55
+ // still-running server; this value stays behind, which is exactly what
56
+ // doctor's server-build-drift check compares against.
57
+ export const SERVER_BUILD_MTIME: number | undefined = newestMtimeUnder(BUILD_DIR, BUILD_EXTS);
58
+
59
+ // The on-disk side of the comparison, statted fresh per call.
60
+ // AGENT_COORD_DIST_DIR is a test seam only: it redirects what doctor
61
+ // MEASURES so tests can stage a newer/older build in a temp dir — it never
62
+ // changes what the server loads.
63
+ export function onDiskBuildMtime(): number | undefined {
64
+ const dir = process.env.AGENT_COORD_DIST_DIR ?? BUILD_DIR;
65
+ return newestMtimeUnder(dir, BUILD_EXTS);
66
+ }
67
+
68
+ // The uncompiled side of the dist-behind-source comparison: newest mtime
69
+ // across src/**/*.ts, resolved as BUILD_DIR's sibling. undefined on a
70
+ // packaged install with no src/ (callers report "nothing to compare", never
71
+ // warn). Under tsx dev-mode BUILD_DIR *is* src/, so the comparison degrades
72
+ // to src-vs-src and reads ok — dev-mode has no build to fall behind.
73
+ // AGENT_COORD_SRC_DIR is the same test seam as AGENT_COORD_DIST_DIR:
74
+ // it redirects measurement only.
75
+ export function onDiskSourceMtime(): number | undefined {
76
+ const dir = process.env.AGENT_COORD_SRC_DIR ?? path.resolve(BUILD_DIR, "..", "src");
77
+ return newestMtimeUnder(dir, [".ts"]);
78
+ }
79
+
80
+ // Best-effort checkout identity, report-only: lets doctor NAME the build
81
+ // (`branch@sha` would be nicer, but HEAD's sha alone already makes
82
+ // mutable-checkout drift visible, which is all this claims). undefined when
83
+ // not a git checkout (npm install) — never an error.
84
+ export const SERVER_BUILD_SHA: string | undefined = (() => {
85
+ try {
86
+ let gitDir = path.resolve(BUILD_DIR, "..", ".git");
87
+ const st = statSync(gitDir);
88
+ if (st.isFile()) {
89
+ // A worktree's .git is a pointer file: "gitdir: <real dir>".
90
+ const ptr = readFileSync(gitDir, "utf8").trim();
91
+ if (!ptr.startsWith("gitdir:")) return undefined;
92
+ gitDir = ptr.slice("gitdir:".length).trim();
93
+ }
94
+ const head = readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
95
+ if (!head.startsWith("ref:")) return head.slice(0, 12); // detached
96
+ const ref = head.slice(4).trim();
97
+ try {
98
+ return readFileSync(path.join(gitDir, ref), "utf8").trim().slice(0, 12);
99
+ } catch {
100
+ // Ref may be packed. commondir handling is deliberately out of scope —
101
+ // best-effort means undefined beats wrong.
102
+ const packed = readFileSync(path.join(gitDir, "packed-refs"), "utf8");
103
+ for (const line of packed.split("\n")) {
104
+ if (line.endsWith(` ${ref}`)) return line.slice(0, 12);
105
+ }
106
+ return undefined;
107
+ }
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ })();
package/src/roles.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Canonical role identity, server side (Phase 8 Task 4).
2
+ //
3
+ // MIRROR of hooks/roles.mjs — tsconfig's rootDir is `src`, so this cannot
4
+ // import the hook copy, and the hook copy must stay build-free (the pusher
5
+ // loads it from a bare checkout). test/roles.test.mjs asserts the two agree;
6
+ // change one, change the other.
7
+ //
8
+ // See hooks/roles.mjs for the rationale behind `explicit` and the word-match
9
+ // fallback.
10
+
11
+ import { z } from "zod";
12
+
13
+ export type ResolvedRole = {
14
+ roleId: string;
15
+ displayName: string;
16
+ // true when the id was DECLARED (frozen), false when DERIVED from display text.
17
+ explicit: boolean;
18
+ };
19
+
20
+ export type RoleInput =
21
+ | string
22
+ | { roleId?: string; displayName?: string; role?: string | null }
23
+ | null
24
+ | undefined;
25
+
26
+ export function slugifyRole(role: unknown): string {
27
+ return String(role ?? "")
28
+ .trim()
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "");
32
+ }
33
+
34
+ export const GATE_RUNNER_ROLE_IDS = new Set(["qa", "quality", "coordinator", "gate"]);
35
+ export const COORDINATOR_ROLE_IDS = new Set(["coordinator"]);
36
+
37
+ export function resolveRole(role: RoleInput): ResolvedRole | undefined {
38
+ if (role === null || role === undefined) return undefined;
39
+ if (typeof role === "string") {
40
+ const roleId = slugifyRole(role);
41
+ return roleId ? { roleId, displayName: role, explicit: false } : undefined;
42
+ }
43
+ if (typeof role === "object") {
44
+ const declared = typeof role.roleId === "string" ? slugifyRole(role.roleId) : "";
45
+ const name =
46
+ typeof role.displayName === "string" && role.displayName
47
+ ? role.displayName
48
+ : typeof role.role === "string" && role.role
49
+ ? role.role
50
+ : undefined;
51
+ if (declared) return { roleId: declared, displayName: name ?? declared, explicit: true };
52
+ if (name) return resolveRole(name);
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ export function roleMatches(role: RoleInput, allowedIds: Set<string>): boolean {
58
+ const resolved = resolveRole(role);
59
+ if (!resolved) return false;
60
+ if (allowedIds.has(resolved.roleId)) return true;
61
+ if (resolved.explicit) return false;
62
+ return resolved.roleId.split("-").some((word) => allowedIds.has(word));
63
+ }
64
+
65
+ export function isGateRunner(role: RoleInput): boolean {
66
+ return roleMatches(role, GATE_RUNNER_ROLE_IDS);
67
+ }
68
+
69
+ export function isCoordinator(role: RoleInput): boolean {
70
+ return roleMatches(role, COORDINATOR_ROLE_IDS);
71
+ }
72
+
73
+ // Which roles may emit which record.type at the send path. Enforcement lives
74
+ // in messaging.ts (checkRecordAuthority); the table lives here so register/join
75
+ // can ECHO the consequence back at onboarding — a role that cannot emit `go`
76
+ // should learn it when it registers, not when it sends its first work order.
77
+ //
78
+ // NOT A TRUST BOUNDARY — roles are self-declared. See checkRecordAuthority.
79
+ export const RECORD_AUTHORITY: Record<string, { roles: Set<string>; label: string }> = {
80
+ verdict: { roles: GATE_RUNNER_ROLE_IDS, label: "gate-runner" },
81
+ go: { roles: COORDINATOR_ROLE_IDS, label: "coordinator" },
82
+ scope: { roles: COORDINATOR_ROLE_IDS, label: "coordinator" },
83
+ };
84
+
85
+ // Split the restricted record types into what this role may and may not emit.
86
+ // Unrestricted types are omitted from both lists — they are nobody's business.
87
+ export function recordAuthorityFor(role: RoleInput): { mayEmit: string[]; mayNotEmit: string[] } {
88
+ const mayEmit: string[] = [];
89
+ const mayNotEmit: string[] = [];
90
+ for (const [type, rule] of Object.entries(RECORD_AUTHORITY)) {
91
+ (roleMatches(role, rule.roles) ? mayEmit : mayNotEmit).push(type);
92
+ }
93
+ return { mayEmit, mayNotEmit };
94
+ }
95
+
96
+ // Wire shape for `role` on register/join. Either free text (v1: `role: "qa
97
+ // lead"`) or a declared identity (`{roleId: "qa", displayName: "QA gate"}`).
98
+ // Both are supported forever — the string form is not deprecated, it just
99
+ // leaves the id derived rather than frozen.
100
+ //
101
+ // Lives here rather than in tools/ because registry.ts and transport.ts import
102
+ // each other; a schema in either would be a temporal-dead-zone hazard.
103
+ export const roleInputSchema = z.union([
104
+ z.string(),
105
+ z.object({
106
+ roleId: z.string().min(1).optional(),
107
+ displayName: z.string().min(1).optional(),
108
+ }),
109
+ ]);
110
+
111
+ export type RoleArg = z.infer<typeof roleInputSchema>;