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
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
- import { ensureDirs, readTokenMapSync } from "./store.js";
7
+ import { ensureDirs, getTokenMap, reloadTokenMapSync } from "./store.js";
8
8
  import {
9
9
  attachAgentSchema,
10
10
  attachAgentTool,
@@ -38,12 +38,16 @@ import {
38
38
  pruneTool,
39
39
  readMessagesSchema,
40
40
  readMessagesTool,
41
+ retrieveMessageSchema,
42
+ retrieveMessageTool,
41
43
  retrieveRoomHistorySchema,
42
44
  retrieveRoomHistoryTool,
43
45
  registerSchema,
44
46
  registerTool,
45
47
  renameAgentSchema,
46
48
  renameAgentTool,
49
+ reportReceiptSchema,
50
+ reportReceiptTool,
47
51
  reportTransportSchema,
48
52
  reportTransportTool,
49
53
  sendCommandSchema,
@@ -62,6 +66,14 @@ import {
62
66
  quitTool,
63
67
  waitForMessageSchema,
64
68
  waitForMessageTool,
69
+ listScopesSchema,
70
+ listScopesTool,
71
+ importWorkSchema,
72
+ importWorkTool,
73
+ listWorkSchema,
74
+ listWorkTool,
75
+ exportWorkSchema,
76
+ exportWorkTool,
65
77
  } from "./tools/index.js";
66
78
 
67
79
  function jsonResult(data: unknown) {
@@ -88,16 +100,25 @@ function buildServer(initialBound?: string): McpServer {
88
100
 
89
101
  // Gate every tool that takes a caller identity. `field: null` (list_agents,
90
102
  // list_rooms, prune) bypasses the check entirely.
103
+ //
104
+ // `bindOnClaim: false` (status, ping) means the tool still enforces a
105
+ // mismatch against an *existing* binding, but a fresh (unbound) session
106
+ // never claims one just by naming an agentId — a diagnostic status/ping
107
+ // call must not be able to silently TOFU-bind a session to some other,
108
+ // already-live agent's identity.
91
109
  function gate(
92
110
  field: "agentId" | "from" | null,
93
111
  handler: (args: Record<string, unknown>) => Promise<unknown>,
112
+ { bindOnClaim = true }: { bindOnClaim?: boolean } = {},
94
113
  ) {
95
114
  return async (args: Record<string, unknown>) => {
96
115
  if (field) {
97
116
  const claimed = args[field];
98
117
  if (typeof claimed === "string") {
99
118
  if (bound === undefined) {
100
- bound = claimed; // TOFU: first claim wins, then sticky.
119
+ if (bindOnClaim) {
120
+ bound = claimed; // TOFU: first claim wins, then sticky.
121
+ }
101
122
  } else if (bound !== claimed) {
102
123
  throw new Error(
103
124
  `identity bound to '${bound}'; rejected attempt to act as '${claimed}'`,
@@ -158,9 +179,9 @@ function buildServer(initialBound?: string): McpServer {
158
179
 
159
180
  server.tool(
160
181
  "status",
161
- "Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'.",
182
+ "Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'. Read-only — naming an agentId here never binds this session's identity.",
162
183
  statusSchema,
163
- gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>),
184
+ gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
164
185
  );
165
186
 
166
187
  server.tool(
@@ -172,9 +193,9 @@ function buildServer(initialBound?: string): McpServer {
172
193
 
173
194
  server.tool(
174
195
  "ping",
175
- "Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity.",
196
+ "Liveness probe for another agent, answered entirely from server-side state (registry entry, transport marker, pusher pid, tmux pane) — it never touches the target's session, so a fleet-wide sweep costs zero model tokens on the targets. Returns alive (fresh heartbeat or live transport), reachable (a DM pushed now would land), granular checks, and latencyMs. Distinct from heartbeat, which is an agent refreshing its OWN activity timestamp. Pass echo:true (default off) to additionally drop a PING DM into the target's inbox — that wakes the target's model, so use it sparingly and only when you need an agent-level acknowledgement. 'from' is enforced against the session's bound identity, but read-only — naming 'from' here never binds this session's identity.",
176
197
  pingSchema,
177
- gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>),
198
+ gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
178
199
  );
179
200
 
180
201
  server.tool(
@@ -212,6 +233,13 @@ function buildServer(initialBound?: string): McpServer {
212
233
  gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
213
234
  );
214
235
 
236
+ server.tool(
237
+ "retrieve_message",
238
+ "Expand a `retrieve_message id=<uuid>` handle from a pane digest into the full message and its typed `record`. A record whose text rendering spans multiple lines (a DAVID_DECISION packet) is delivered to a pane as ONE attributed line plus this handle; call it to get the structured record back. Reads the message by id from the channels you can read (your inbox and rooms you belong to), falling through to the append-only archive if compaction moved it — so unlike retrieve_room_history there is no TTL and nothing to expire. A handle for a message never delivered to you is simply not found.",
239
+ retrieveMessageSchema,
240
+ gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
241
+ );
242
+
215
243
  server.tool(
216
244
  "post_status",
217
245
  "Append a status broadcast to the shared status stream.",
@@ -320,6 +348,13 @@ function buildServer(initialBound?: string): McpServer {
320
348
  gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
321
349
  );
322
350
 
351
+ server.tool(
352
+ "report_receipt",
353
+ "Append a delivery receipt for a message this agent's pusher just typed into its pane — the wire-callable counterpart to the local pusher's receipts/<id>.jsonl stamp, for remote pushers (scripts/coord-pusher.mjs) that cannot write this host's filesystem. This is what lets send_command to a tmux-push-remote agent return delivery:'confirmed'. For control commands pass exactly what submit verification observed (submitted/verified/reason); omitting 'submitted' means 'typed but unverified' and is reported as delivery:'pending', never 'confirmed'. 'agentId' (the receiving agent) is enforced against the session's bound identity, so a pusher can only stamp its own agent's receipt file.",
354
+ reportReceiptSchema,
355
+ gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
356
+ );
357
+
323
358
  server.tool(
324
359
  "doctor",
325
360
  "Bus-wide health check: inspects the whole state dir and reports drift, leaks, and corruption (orphan transport markers / memberships / inboxes, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, environment). Read-only by default; pass fix=true to apply the safe, reversible repairs (malformed-line rewrites are backed up to .bak first). A clean report (healthy=true) means the bus is internally consistent.",
@@ -327,6 +362,34 @@ function buildServer(initialBound?: string): McpServer {
327
362
  gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
328
363
  );
329
364
 
365
+ server.tool(
366
+ "list_scopes",
367
+ "Read the declared write scopes for managed documents (~/agent-coord/scopes.json). Call it with 'path' (and your 'agentId') to ask \"may I write this?\" BEFORE editing a shared doc like docs/QUEUE.md; call it bare to list every declared document and its owning role. ADVISORY ONLY: the bus does not mediate file writes, so this answers who owns a document, it does not stop anyone — enforcement arrives when work state moves into the store. Absent scopes.json means nothing is owned and nothing warns (opt-in).",
368
+ listScopesSchema,
369
+ gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
370
+ );
371
+
372
+ server.tool(
373
+ "import_work",
374
+ "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date} and board rows. The markdown stays authoritative — this store is a derived index, and export_work renders it back byte-identically.",
375
+ importWorkSchema,
376
+ gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
377
+ );
378
+
379
+ server.tool(
380
+ "list_work",
381
+ "Query a project's work state as records instead of parsing markdown: open queue items (filter by priority), done entries with their ref and date as separate fields, and the board's lane rows. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
382
+ listWorkSchema,
383
+ gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
384
+ );
385
+
386
+ server.tool(
387
+ "export_work",
388
+ "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. Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
389
+ exportWorkSchema,
390
+ gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
391
+ );
392
+
330
393
  server.tool(
331
394
  "delete_room",
332
395
  "Permanently delete a channel: removes it from the registry, deletes its JSONL file, and clears all agent cursor offsets for that channel. Refuses if agents are still joined unless force=true. Cannot delete the default 'general' channel. Posts a system notice to #general on success.",
@@ -344,12 +407,13 @@ function buildServer(initialBound?: string): McpServer {
344
407
  return server;
345
408
  }
346
409
 
347
- // Lazy-loaded token map for HTTP identity binding. Hot-reloaded on SIGHUP so
348
- // operators can rotate / add agents without a server restart.
349
- let tokenMap: Map<string, string> | null = null;
410
+ // Token map for HTTP identity binding, held in-process via store.ts's
411
+ // shared cache. Hot-reloaded on SIGHUP so operators can rotate / add agents
412
+ // without a server restart; also refreshed automatically by rename_agent
413
+ // (see rotateAgentToken) so a live rename doesn't need one.
350
414
  function loadTokenMap(initial: boolean): void {
351
415
  try {
352
- tokenMap = readTokenMapSync();
416
+ reloadTokenMapSync();
353
417
  } catch (e) {
354
418
  // On initial load a bad file is fatal — refuse to start in a known-bad
355
419
  // auth state. On SIGHUP, log and keep the previous (valid) map.
@@ -361,7 +425,7 @@ function loadTokenMap(initial: boolean): void {
361
425
  return;
362
426
  }
363
427
  if (!initial) {
364
- console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${tokenMap?.size ?? 0} agents)`);
428
+ console.error(`[agent-coord-mcp] SIGHUP: token map reloaded (${getTokenMap()?.size ?? 0} agents)`);
365
429
  }
366
430
  }
367
431
 
@@ -396,7 +460,7 @@ async function main() {
396
460
 
397
461
  async function startHttp(port: number): Promise<void> {
398
462
  const sharedToken = process.env.AGENT_COORD_TOKEN;
399
- const bound = tokenMap !== null;
463
+ const bound = getTokenMap() !== null;
400
464
  if (!bound && !sharedToken) {
401
465
  console.error(
402
466
  "[agent-coord-mcp] HTTP mode needs auth: either set AGENT_COORD_TOKEN (legacy " +
@@ -486,6 +550,7 @@ async function startHttp(port: number): Promise<void> {
486
550
  function resolveBoundAgent(authHeader: string | undefined): { ok: boolean; agent?: string } {
487
551
  if (!authHeader || !authHeader.startsWith("Bearer ")) return { ok: false };
488
552
  const bearer = authHeader.slice("Bearer ".length);
553
+ const tokenMap = getTokenMap();
489
554
  if (tokenMap) {
490
555
  const agent = tokenMap.get(bearer);
491
556
  return agent ? { ok: true, agent } : { ok: false };
@@ -528,7 +593,7 @@ async function startHttp(port: number): Promise<void> {
528
593
  // a leaked session id could drive that session's identity (session hijack).
529
594
  if (
530
595
  transport &&
531
- tokenMap &&
596
+ getTokenMap() &&
532
597
  typeof sid === "string" &&
533
598
  sessionAgents.get(sid) !== resolved.agent
534
599
  ) {
@@ -555,7 +620,7 @@ async function startHttp(port: number): Promise<void> {
555
620
  });
556
621
 
557
622
  http.listen(port, bindAddr, () => {
558
- const mode = bound ? `pre-bound (${tokenMap?.size ?? 0} agents)` : "TOFU";
623
+ const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
559
624
  console.error(`[agent-coord-mcp] http listening on ${bindAddr}:${port} — identity ${mode}`);
560
625
  if (bindAddr !== "127.0.0.1" && bindAddr !== "localhost") {
561
626
  console.error(
package/src/store.ts CHANGED
@@ -63,8 +63,26 @@ export const ARCHIVE_STATUS_FILE = path.join(ARCHIVE_DIR, "status.jsonl");
63
63
  // startup warning). Should be mode 600; operator-managed.
64
64
  export const TOKENS_FILE = path.join(ROOT, "tokens.json");
65
65
 
66
+ // Declared write scopes for managed documents (v0.18.0, Phase 8 Task 4).
67
+ // Opt-in and operator-managed: absent → nothing is owned and nothing warns.
68
+ // ADVISORY. The bus does not mediate writes to docs/QUEUE.md & friends —
69
+ // agents edit them with ordinary file tools, so there is no interception
70
+ // point. This file lets an agent ASK who owns a document (list_scopes) and
71
+ // lets `doctor` DETECT drift after the fact. Pre-emptive enforcement waits
72
+ // for Phase 8 Task 5, when work state moves into the store.
73
+ export const SCOPES_FILE = path.join(ROOT, "scopes.json");
74
+
75
+ // Work state as data (v0.18.0, Phase 8 Task 5). One file per project holding
76
+ // the parsed QUEUE/DONE/board documents. DERIVED, not authoritative: the
77
+ // markdown in the repo remains the source of truth, and deleting this
78
+ // directory costs an `import_work`, never data (see src/work.ts).
79
+ export const WORK_DIR = path.join(ROOT, "work");
80
+ export function workFile(project: string): string {
81
+ return path.join(WORK_DIR, `${sanitize(project)}.json`);
82
+ }
83
+
66
84
  export function ensureDirs(): void {
67
- for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR]) {
85
+ for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR, ROOMS_DIR, RECEIPTS_DIR, HISTORY_DIR, WORK_DIR]) {
68
86
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
69
87
  }
70
88
  for (const f of [ROOM_FILE, STATUS_FILE]) {
@@ -105,18 +123,59 @@ export function readTokenMapSync(): Map<string, string> | null {
105
123
  return out;
106
124
  }
107
125
 
126
+ // In-process cache of the token map, shared by the HTTP identity-binding
127
+ // layer (server.ts) and rotateAgentToken below. A module-level cache (rather
128
+ // than each caller re-reading the file) is what lets a rename refresh the
129
+ // live server's view without an operator SIGHUP.
130
+ let tokenMapCache: Map<string, string> | null = null;
131
+ let tokenMapInitialized = false;
132
+
133
+ // Current in-process token map (bearer -> agentId), or null if unbound (no
134
+ // tokens.json). Lazily loads from disk on first call.
135
+ export function getTokenMap(): Map<string, string> | null {
136
+ if (!tokenMapInitialized) {
137
+ tokenMapCache = readTokenMapSync();
138
+ tokenMapInitialized = true;
139
+ }
140
+ return tokenMapCache;
141
+ }
142
+
143
+ // Re-read tokens.json from disk and replace the in-process cache. Throws on
144
+ // a malformed file — callers decide whether that's fatal (startup) or
145
+ // recoverable (SIGHUP, post-rotate refresh).
146
+ export function reloadTokenMapSync(): Map<string, string> | null {
147
+ tokenMapCache = readTokenMapSync();
148
+ tokenMapInitialized = true;
149
+ return tokenMapCache;
150
+ }
151
+
108
152
  // Atomically rotate the token entry for an agent rename (used by
109
153
  // rename_agent so the same bearer continues to authenticate the renamed
110
154
  // identity). No-op if the file is absent or the old id isn't in the map.
111
155
  export async function rotateAgentToken(oldAgentId: string, newAgentId: string): Promise<void> {
112
156
  if (!existsSync(TOKENS_FILE)) return;
157
+ let rotated = false;
113
158
  await updateJson<Record<string, string>>(TOKENS_FILE, {}, (current) => {
114
159
  if (current[oldAgentId] !== undefined) {
115
160
  current[newAgentId] = current[oldAgentId];
116
161
  delete current[oldAgentId];
162
+ rotated = true;
117
163
  }
118
164
  return current;
119
165
  });
166
+ if (rotated) {
167
+ // Refresh the in-process cache immediately: without this, the renamed
168
+ // agent's bearer keeps resolving to the OLD id in this process until an
169
+ // operator sends SIGHUP, silently misattributing its calls.
170
+ try {
171
+ reloadTokenMapSync();
172
+ } catch (e) {
173
+ console.error(
174
+ `[agent-coord-mcp] rename_agent: token map reload after rotate failed: ${(e as Error).message} ` +
175
+ `(keeping previous in-process map; send SIGHUP to retry)`,
176
+ );
177
+ }
178
+ }
120
179
  }
121
180
 
122
181
  export type RoomEntry = {
@@ -30,6 +30,7 @@ import {
30
30
  type Cursor,
31
31
  type Message,
32
32
  type StatusEntry,
33
+ isDecision,
33
34
  } from "./shared.js";
34
35
 
35
36
  // ---------- prune ----------
@@ -47,10 +48,15 @@ export const pruneSchema = {
47
48
  dryRun: z.boolean().optional(),
48
49
  };
49
50
 
50
- // Retention predicate: kind="decision" messages live by the (longer)
51
- // decision cutoff; everything else by the standard cutoff.
52
- function keepEntry(e: { ts: number; kind?: string }, cutoff: number, decisionCutoff: number): boolean {
53
- return e.kind === "decision" ? e.ts > decisionCutoff : e.ts > cutoff;
51
+ // Retention: decisions live by the (longer) decision cutoff; everything else
52
+ // by the standard cutoff. What counts as a decision is `isDecision` — the
53
+ // shared predicate, not a fourth copy of the comparison.
54
+ function keepEntry(
55
+ e: { ts: number; kind?: string; record?: { type?: string } },
56
+ cutoff: number,
57
+ decisionCutoff: number,
58
+ ): boolean {
59
+ return isDecision(e) ? e.ts > decisionCutoff : e.ts > cutoff;
54
60
  }
55
61
 
56
62
  // Shift every agent's cursor offsets down by the number of entries removed
@@ -4,3 +4,5 @@ export * from "./rooms.js";
4
4
  export * from "./messaging.js";
5
5
  export * from "./transport.js";
6
6
  export * from "./admin.js";
7
+ export * from "./scopes.js";
8
+ export * from "./work.js";
@@ -1,11 +1,13 @@
1
1
  import { adjustCursors } from "./admin.js";
2
- import { ARCHIVE_STATUS_FILE, archiveJsonl, archiveRoomFile } from "../store.js";
2
+ import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
3
+ import { ARCHIVE_STATUS_FILE, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { existsSync, openSync, watch } from "node:fs";
5
6
  import { promises as fsp } from "node:fs";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { z } from "zod";
10
+ import { renderRecord } from "./render.js";
9
11
  import path from "node:path";
10
12
  import {
11
13
  AGENTS_FILE,
@@ -53,6 +55,7 @@ import {
53
55
  type AgentEntry,
54
56
  type AgentRegistry,
55
57
  type Message,
58
+ type MessageRecord,
56
59
  type StatusEntry,
57
60
  type Cursor,
58
61
  type Source,
@@ -65,25 +68,149 @@ import {
65
68
  STALE_MS,
66
69
  EVICT_MS,
67
70
  MAX_WAIT_MS,
71
+ isDecision,
68
72
  } from "./shared.js";
69
73
 
70
74
  // ---------- send_message ----------
71
75
 
76
+ // Typed protocol record (Phase 8). Additive: omitting it reproduces v1
77
+ // behavior exactly.
78
+ const citationSchema = z.object({
79
+ kind: z.enum(["pr", "file", "commit", "url"]),
80
+ ref: z.string().min(1),
81
+ });
82
+
83
+ // Payloads are LOOSE objects: a v3 sender's extra keys ride through to disk
84
+ // untouched rather than being silently stripped. That is safe precisely
85
+ // because payload is nested — it is caller data, and nothing in it is ever
86
+ // read as a top-level Message field (which is what keeps a forged `tag` or
87
+ // `urgent` out; see the source-level lock in test/tier.test.mjs).
88
+ const summaryPayload = z.looseObject({ summary: z.string().min(1) });
89
+
90
+ // All five fields, or none. A `decision` carrying three of them is
91
+ // structurally wrong for the type it claims — and would render as a truncated
92
+ // packet, which is worse than no packet.
93
+ const decisionPayload = z.looseObject({
94
+ title: z.string().min(1),
95
+ context: z.string().min(1),
96
+ options: z.array(z.string().min(1)).min(1),
97
+ recommendation: z.string().min(1),
98
+ ifNoAction: z.string().min(1),
99
+ });
100
+
101
+ const verdictPayload = z.looseObject({
102
+ result: z.enum(["pass", "fail"]),
103
+ headRefOid: z.string().min(1),
104
+ notes: z.string().optional(),
105
+ });
106
+
107
+ // Discriminated on `type`, so an unknown type is rejected outright while a
108
+ // known type is checked only against its own shape. `payload` is optional on
109
+ // every arm: Phase 8 is additive and may not put a new required field on the
110
+ // wire. `cites` is optional here too — `done` needs a PR citation, but that is
111
+ // enforced in sendMessageTool as a plain {ok:false,error}, mirroring the
112
+ // identity-binding rejection, rather than as a schema throw.
113
+ export const messageRecordSchema = z.discriminatedUnion("type", [
114
+ z.object({ type: z.literal("decision"), payload: decisionPayload.optional(), cites: z.array(citationSchema).optional() }),
115
+ z.object({ type: z.literal("verdict"), payload: verdictPayload.optional(), cites: z.array(citationSchema).optional() }),
116
+ z.object({ type: z.literal("done"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
117
+ z.object({ type: z.literal("blocker"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
118
+ z.object({ type: z.literal("risk"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
119
+ z.object({ type: z.literal("fyi"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
120
+ z.object({ type: z.literal("action"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
121
+ z.object({ type: z.literal("go"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
122
+ z.object({ type: z.literal("scope"), payload: summaryPayload.optional(), cites: z.array(citationSchema).optional() }),
123
+ ]);
124
+
125
+ // ---------- record authority (Phase 8 Task 4) ----------
126
+
127
+ // The table (RECORD_AUTHORITY, ../roles.ts) is a floor on the three types
128
+ // other agents ACT on; everything else is unrestricted.
129
+ //
130
+ // NOT A TRUST BOUNDARY. A role is self-declared at register/join (there is no
131
+ // authority issuing them), so this is a CONSISTENCY check: it stops a worker
132
+ // from accidentally emitting a `verdict` or countersigning its own `scope`,
133
+ // the same way a linter stops a typo. Anything that must actually be
134
+ // authenticated has to resolve identity-bound tokens (see tokens.json), never
135
+ // this table.
136
+ // Rejection shape mirrors the identity-binding rejection in server.ts: the
137
+ // caller gets a plain `{ok: false, error}`, and nothing is written.
138
+ export async function checkRecordAuthority(
139
+ from: string,
140
+ record: MessageRecord | undefined,
141
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
142
+ if (!record) return { ok: true };
143
+ const rule = RECORD_AUTHORITY[record.type];
144
+ if (!rule) return { ok: true };
145
+
146
+ const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
147
+ const entry = reg[from];
148
+ if (roleMatches(entry, rule.roles)) return { ok: true };
149
+
150
+ const held = resolveRole(entry);
151
+ return {
152
+ ok: false,
153
+ error:
154
+ `record.type '${record.type}' is restricted to ${rule.label} roles ` +
155
+ `(${[...rule.roles].join(", ")}); sender '${from}' holds ` +
156
+ `${held ? `role '${held.roleId}'` : entry ? "no role" : "no registry entry"}. ` +
157
+ `Send it as text, or register with the role that owns this record type.`,
158
+ };
159
+ }
160
+
72
161
  export const sendMessageSchema = {
73
162
  from: z.string().min(1),
74
163
  to: z.string().optional(),
75
164
  room: z.string().optional(),
76
- text: z.string().min(1),
165
+ // Optional ONLY so a record can fill it (Task 3.3). This relaxes a
166
+ // constraint rather than adding one, so v1 senders are unaffected; a call
167
+ // with neither `text` nor a renderable `record` is rejected in the tool.
168
+ text: z.string().min(1).optional(),
77
169
  kind: z.enum(["decision", "status", "chatter"]).optional(),
170
+ record: messageRecordSchema.optional(),
78
171
  };
79
172
 
80
173
  export async function sendMessageTool(args: {
81
174
  from: string;
82
175
  to?: string;
83
176
  room?: string;
84
- text: string;
177
+ text?: string;
85
178
  kind?: "decision" | "status" | "chatter";
179
+ record?: MessageRecord;
86
180
  }) {
181
+ // Record authority first — a sender who may not emit this type is refused
182
+ // before any other check runs, so a rejected record writes nothing anywhere.
183
+ const authority = await checkRecordAuthority(args.from, args.record);
184
+ if (!authority.ok) return { ok: false as const, error: authority.error };
185
+
186
+ // A `done` must cite the work it claims. Presence and shape only — resolving
187
+ // the ref against gh/git is a consumer's job, and the send path makes no
188
+ // network calls. Rejected as a value, not a throw, mirroring the
189
+ // identity-binding rejection in src/server.ts.
190
+ if (args.record?.type === "done") {
191
+ const hasPr = (args.record.cites ?? []).some((c) => c.kind === "pr" && c.ref.trim().length > 0);
192
+ if (!hasPr) {
193
+ return {
194
+ ok: false as const,
195
+ error:
196
+ "a 'done' record must carry at least one {kind:'pr'} citation — an uncited DONE is an unverifiable claim",
197
+ };
198
+ }
199
+ }
200
+
201
+ // `text` is what every consumer reads, so it must exist. The author's
202
+ // wording ALWAYS wins: a record renders only to fill an absent text, never
203
+ // to overwrite one.
204
+ const text = args.text ?? (args.record ? renderRecord(args.record) : null);
205
+ if (!text) {
206
+ return {
207
+ ok: false as const,
208
+ error: args.record
209
+ ? `record type '${args.record.type}' has no payload to render — supply 'text', or a payload the type's layout can render`
210
+ : "'text' is required when no record is supplied",
211
+ };
212
+ }
213
+
87
214
  // DM → inbox. Otherwise resolve the channel (default `general`), make sure it
88
215
  // exists in the registry, and tag the message with its channel.
89
216
  if (args.to) {
@@ -92,7 +219,8 @@ export async function sendMessageTool(args: {
92
219
  ts: Date.now(),
93
220
  from: args.from,
94
221
  to: args.to,
95
- text: args.text,
222
+ text,
223
+ ...(args.record ? { record: args.record } : {}),
96
224
  };
97
225
  const target = inboxFile(args.to);
98
226
  await appendJsonl(target, msg);
@@ -113,8 +241,9 @@ export async function sendMessageTool(args: {
113
241
  ts: Date.now(),
114
242
  from: args.from,
115
243
  room: chan,
116
- text: args.text,
244
+ text,
117
245
  ...(args.kind ? { kind: args.kind } : {}),
246
+ ...(args.record ? { record: args.record } : {}),
118
247
  };
119
248
  const target = roomFile(chan);
120
249
  await appendJsonl(target, msg);
@@ -154,7 +283,7 @@ async function maybeCompactRoom(chan: string): Promise<void> {
154
283
  const r = await archiveJsonl<Message>(
155
284
  file,
156
285
  archiveRoomFile(chan),
157
- (e) => e.ts >= boundaryTs || (e.kind === "decision" && e.ts > decisionCutoff)
286
+ (e) => e.ts >= boundaryTs || (isDecision(e) && e.ts > decisionCutoff)
158
287
  );
159
288
  if (r.removed > 0) await adjustCursors({ roomRemovedByChan: { [chan]: r.removed } });
160
289
  }
@@ -287,7 +416,7 @@ function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefine
287
416
  : `] — read without peek to get an expandable hash`;
288
417
  // Decisions are the one thing a digest must not bury — quote them verbatim
289
418
  // (capped) below the summary line.
290
- const decisions = over.filter((m): m is Message => (m as Message).kind === "decision");
419
+ const decisions = over.filter((m): m is Message => isDecision(m as Message));
291
420
  const quoted = decisions
292
421
  .slice(-5)
293
422
  .map((d) => ` [decision] ${d.from}: ${d.text.length > 200 ? d.text.slice(0, 200) + "…" : d.text}`);
@@ -297,6 +426,76 @@ function digestOverflow(over: (Message | StatusEntry)[], hash: string | undefine
297
426
  return head + tail + decisionBlock;
298
427
  }
299
428
 
429
+ // ---------- retrieve_message (Phase 8 Task 6) ----------
430
+
431
+ export const retrieveMessageSchema = {
432
+ agentId: z.string().min(1),
433
+ id: z.string().min(1),
434
+ };
435
+
436
+ // Expand a digest handle back into the full typed record.
437
+ //
438
+ // NOT a cache. The handle is the message's own `id`, and this reads the source
439
+ // of truth — rooms/<chan>.jsonl or inbox/<agent>.jsonl. That matters for two
440
+ // reasons the CCR history cache could not offer: nothing is duplicated, and
441
+ // nothing expires. A stashed copy would have carried HISTORY_TTL_MS and become
442
+ // permanently unrecoverable after 30 minutes, which is exactly when a handle
443
+ // that outlived a /clear would be expanded.
444
+ //
445
+ // It also sidesteps the cursor: the pusher SHARES the cursor file with
446
+ // read_messages, so anything already delivered to a pane is behind the cursor
447
+ // and re-reading the channel would not return it. A by-id lookup never
448
+ // consults a cursor.
449
+ //
450
+ // AUTHORITY BY CONSTRUCTION: we only ever open files this agent is entitled to
451
+ // read — its own inbox, and the rooms it is a member of. There is no separate
452
+ // permission check that could disagree with the search, and a handle for a
453
+ // message delivered somewhere else simply is not found. That is the same
454
+ // property the history cache spent `forAgent` scoping to get.
455
+ export async function retrieveMessageTool(args: { agentId: string; id: string }) {
456
+ const rooms = await memberRooms(args.agentId);
457
+ const scopes = [
458
+ {
459
+ source: "inbox" as const,
460
+ room: undefined as string | undefined,
461
+ live: inboxFile(args.agentId),
462
+ archived: archiveInboxFile(args.agentId),
463
+ },
464
+ ...rooms.map((r) => ({
465
+ source: "room" as const,
466
+ room: r as string | undefined,
467
+ live: roomFile(r),
468
+ archived: archiveRoomFile(r),
469
+ })),
470
+ ];
471
+
472
+ // Live files first. The archive is opened ONLY on a miss, so no normal
473
+ // retrieval touches it and compaction/prune semantics are unchanged — but a
474
+ // record that compaction moved is still recoverable, because archive/ is
475
+ // append-only and complete.
476
+ for (const pass of ["live", "archived"] as const) {
477
+ for (const s of scopes) {
478
+ const entries = await readJsonl<Message>(s[pass]);
479
+ const msg = entries.find((m) => m.id === args.id);
480
+ if (msg) {
481
+ return {
482
+ ok: true as const,
483
+ id: msg.id,
484
+ source: s.source,
485
+ room: s.room,
486
+ archived: pass === "archived",
487
+ message: msg,
488
+ record: msg.record,
489
+ };
490
+ }
491
+ }
492
+ }
493
+ return {
494
+ ok: false as const,
495
+ error: `no message '${args.id}' in any channel you can read — it may never have been delivered to you`,
496
+ };
497
+ }
498
+
300
499
  // ---------- retrieve_room_history ----------
301
500
 
302
501
  export const retrieveRoomHistorySchema = {