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.
- package/README.md +34 -5
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/roles.js +92 -0
- package/dist/roles.js.map +1 -0
- package/dist/server.js +30 -14
- package/dist/server.js.map +1 -1
- package/dist/store.js +54 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +5 -3
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +187 -8
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +54 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +84 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +367 -33
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +209 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/work.js +260 -0
- package/dist/work.js.map +1 -0
- package/hooks/marker.mjs +18 -0
- package/hooks/roles.mjs +79 -0
- package/hooks/submit.mjs +271 -0
- package/hooks/tier.mjs +104 -11
- package/hooks/tmux-pusher.mjs +137 -51
- package/package.json +5 -4
- package/scripts/check-self-dependency.mjs +42 -0
- package/scripts/check-test-count.mjs +83 -0
- package/scripts/coord-pusher.mjs +150 -33
- package/src/build.ts +111 -0
- package/src/roles.ts +111 -0
- package/src/server.ts +79 -14
- package/src/store.ts +60 -1
- package/src/tools/admin.ts +10 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +206 -7
- package/src/tools/registry.ts +63 -4
- package/src/tools/render.ts +80 -0
- package/src/tools/scopes.ts +177 -0
- package/src/tools/shared.ts +120 -0
- package/src/tools/transport.ts +386 -34
- package/src/tools/work.ts +265 -0
- package/src/work.ts +329 -0
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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): ` [<
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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(${
|
|
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
|
-
|
|
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>;
|