agent-coord-mcp 0.19.1 → 0.23.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/src/server.ts CHANGED
@@ -5,6 +5,7 @@ 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
7
  import { unlinkSync, writeFileSync } from "node:fs";
8
+ import { z, type ZodRawShape } from "zod";
8
9
  import {
9
10
  ensureDirs,
10
11
  getTokenMap,
@@ -240,10 +241,21 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
240
241
 
241
242
  const server = new McpServer({
242
243
  name: "agent-coord",
243
- version: "0.1.0",
244
+ version: "0.23.0",
244
245
  });
245
246
 
246
- server.tool(
247
+ const addTool = (
248
+ name: string,
249
+ description: string,
250
+ inputSchema: ZodRawShape,
251
+ cb: (args: Record<string, unknown>) => Promise<ReturnType<typeof jsonResult>>,
252
+ ) => {
253
+ server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) =>
254
+ cb((args ?? {}) as Record<string, unknown>),
255
+ );
256
+ };
257
+
258
+ addTool(
247
259
  "join",
248
260
  "Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token or force:true — diagnosing someone else's agent is what status/ping are for.",
249
261
  joinSchema,
@@ -266,147 +278,147 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
266
278
  },
267
279
  );
268
280
 
269
- server.tool(
281
+ addTool(
270
282
  "register",
271
283
  "Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
272
284
  registerSchema,
273
285
  gate("agentId", registerTool as (a: Record<string, unknown>) => Promise<unknown>),
274
286
  );
275
287
 
276
- server.tool(
288
+ addTool(
277
289
  "unregister",
278
290
  "Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
279
291
  unregisterSchema,
280
292
  gate("agentId", unregisterTool as (a: Record<string, unknown>) => Promise<unknown>),
281
293
  );
282
294
 
283
- server.tool(
295
+ addTool(
284
296
  "quit",
285
297
  "Clean shutdown: unregister this agent (detach transport, leave rooms, remove registry entry) then exit the MCP process. Only callable by the session's bound identity. Use this to cleanly hand off before a restart with a new name.",
286
298
  quitSchema,
287
299
  gate("agentId", quitTool as unknown as (a: Record<string, unknown>) => Promise<unknown>),
288
300
  );
289
301
 
290
- server.tool(
302
+ addTool(
291
303
  "status",
292
304
  "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.",
293
305
  statusSchema,
294
306
  gate("agentId", statusTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
295
307
  );
296
308
 
297
- server.tool(
309
+ addTool(
298
310
  "heartbeat",
299
311
  "Refresh this agent's lastHeartbeat timestamp.",
300
312
  heartbeatSchema,
301
313
  gate("agentId", heartbeatTool as (a: Record<string, unknown>) => Promise<unknown>),
302
314
  );
303
315
 
304
- server.tool(
316
+ addTool(
305
317
  "ping",
306
318
  "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.",
307
319
  pingSchema,
308
320
  gate("from", pingTool as (a: Record<string, unknown>) => Promise<unknown>, { bindOnClaim: false }),
309
321
  );
310
322
 
311
- server.tool(
323
+ addTool(
312
324
  "list_agents",
313
325
  "List all known agents and whether they appear online (heartbeat <5min).",
314
326
  listAgentsSchema,
315
327
  gate(null, listAgentsTool as () => Promise<unknown>),
316
328
  );
317
329
 
318
- server.tool(
330
+ addTool(
319
331
  "send_message",
320
332
  "Send a message. If 'to' is set, goes to that agent's inbox (DM); otherwise to a channel — pass 'room' (e.g. 'seo' or '#seo') to target a specific channel, or omit it for the default 'general' channel. For channel posts, tag 'kind': 'decision' for GOs/verdicts/agreements that must outlive routine cleanup (kept ~30 days, quoted verbatim in digests), 'status' for progress notes, omit for ordinary chatter. The 'from' field is enforced against the session's bound identity when binding is configured.",
321
333
  sendMessageSchema,
322
334
  gate("from", sendMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
323
335
  );
324
336
 
325
- server.tool(
337
+ addTool(
326
338
  "send_command",
327
339
  "Inject a context-management slash command (/clear or /compact) directly into a sub-agent's live tmux session — delivered RAW with no banner or prefix, so the agent's CLI runs it as a real slash command. Target one agent with 'to' or broadcast to a channel's tmux-attached members with 'room' (never the sender). Hard-gated to tmux: returns ok:false if the target has no live tmux-push(-remote) transport. By default BLOCKS until the receiving pusher confirms it actually typed the command into the pane (out-of-band delivery receipt, no added agent context) and returns delivery:'confirmed' with deliveredAt, or delivery:'pending'+warning if no receipt arrived within deliveryTimeoutMs (default 8000) — a stale/wedged pusher. Pass waitForDelivery:false for fire-and-forget. Intended for a lead agent to clear/compact sub-agent context and save tokens. The command allowlist is locked to /clear and /compact; nothing else is accepted. 'from' is enforced against the session's bound identity.",
328
340
  sendCommandSchema,
329
341
  gate("from", sendCommandTool as (a: Record<string, unknown>) => Promise<unknown>),
330
342
  );
331
343
 
332
- server.tool(
344
+ addTool(
333
345
  "read_messages",
334
346
  "Read new messages from inbox|room|status. For source='room', pass 'room' to read a specific channel (default 'general'). Room and status reads return the most recent 50 entries per call — pass limit to override (max 500). When the backlog exceeds the window, the older overflow is replaced by a compact `history` digest carrying a retrieval hash; call retrieve_room_history(hash) to expand it. Inbox drains fully by default. Advances the per-channel cursor unless peek=true.",
335
347
  readMessagesSchema,
336
348
  gate("agentId", readMessagesTool as (a: Record<string, unknown>) => Promise<unknown>),
337
349
  );
338
350
 
339
- server.tool(
351
+ addTool(
340
352
  "retrieve_room_history",
341
353
  "Expand a compressed channel-history digest returned by read_messages. Pass the `hash` from the `history` field; optionally pass `query` to return only matching messages (case-insensitive substring). Entries are scoped to the agent that produced them and expire after 30 minutes — if expired, re-read the channel with a higher limit instead.",
342
354
  retrieveRoomHistorySchema,
343
355
  gate("agentId", retrieveRoomHistoryTool as (a: Record<string, unknown>) => Promise<unknown>),
344
356
  );
345
357
 
346
- server.tool(
358
+ addTool(
347
359
  "retrieve_message",
348
360
  "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.",
349
361
  retrieveMessageSchema,
350
362
  gate("agentId", retrieveMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
351
363
  );
352
364
 
353
- server.tool(
365
+ addTool(
354
366
  "post_status",
355
367
  "Append a status broadcast to the shared status stream.",
356
368
  postStatusSchema,
357
369
  gate("agentId", postStatusTool as (a: Record<string, unknown>) => Promise<unknown>),
358
370
  );
359
371
 
360
- server.tool(
372
+ addTool(
361
373
  "prune",
362
374
  "Trim room/status/inbox JSONL to entries newer than `olderThanDays` (default 7); kind='decision' posts keep a longer `decisionDays` retention (default 30). Nothing is lost: aged-out entries are archived under archive/ (rooms/<chan>.jsonl, status.jsonl, inbox/<agent>.jsonl) — only receipts are truly deleted. `room` and `targets` compose: `room` scopes every sweep to that channel (and, alone, defaults the sweep to `rooms` only), while `targets` (rooms|status|inbox|receipts|members) selects which sweeps run and always wins over that default — so `{room, targets:['members']}` sweeps membership in that one channel. Sweeps room members that are unregistered or haven't heartbeated since the cutoff, and archives+removes non-default rooms left empty and inactive (disable via archiveEmptyRooms=false). Removes inbox files for agents no longer in the registry unless removeOrphanInboxes=false. Pass dryRun=true to preview.",
363
375
  pruneSchema,
364
376
  gate(null, pruneTool as (a: Record<string, unknown>) => Promise<unknown>),
365
377
  );
366
378
 
367
- server.tool(
379
+ addTool(
368
380
  "wait_for_message",
369
381
  "Block (max 60s) until a new message appears on the given source, then return it. For source='room', pass 'room' to wait on a specific channel (default 'general').",
370
382
  waitForMessageSchema,
371
383
  gate("agentId", waitForMessageTool as (a: Record<string, unknown>) => Promise<unknown>),
372
384
  );
373
385
 
374
- server.tool(
386
+ addTool(
375
387
  "list_rooms",
376
388
  "List all channels with their topic, MOTD (room rules), members, message count, and last activity.",
377
389
  listRoomsSchema,
378
390
  gate(null, listRoomsTool as () => Promise<unknown>),
379
391
  );
380
392
 
381
- server.tool(
393
+ addTool(
382
394
  "join_room",
383
395
  "Join a channel (creating it if new). Adds this agent to the channel's membership so the notification hooks push its messages. Posts a system join notice to the channel. Returns the channel's topic, MOTD, member list, and unread message count — but not the messages themselves. Call read_messages to fetch history if needed.",
384
396
  joinRoomSchema,
385
397
  gate("agentId", joinRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
386
398
  );
387
399
 
388
- server.tool(
400
+ addTool(
389
401
  "leave_room",
390
402
  "Leave a channel — removes this agent from its membership. Cannot leave the default 'general' channel.",
391
403
  leaveRoomSchema,
392
404
  gate("agentId", leaveRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
393
405
  );
394
406
 
395
- server.tool(
407
+ addTool(
396
408
  "set_room_topic",
397
409
  "Set a channel's topic (a short one-line description). Posts a system notice to the channel.",
398
410
  setRoomTopicSchema,
399
411
  gate("agentId", setRoomTopicTool as (a: Record<string, unknown>) => Promise<unknown>),
400
412
  );
401
413
 
402
- server.tool(
414
+ addTool(
403
415
  "set_room_motd",
404
416
  "Set a channel's MOTD / room rules (shown to agents on join). Posts a system notice to the channel.",
405
417
  setRoomMotdSchema,
406
418
  gate("agentId", setRoomMotdTool as (a: Record<string, unknown>) => Promise<unknown>),
407
419
  );
408
420
 
409
- server.tool(
421
+ addTool(
410
422
  "rename_agent",
411
423
  "Rename an agent (NICK): migrates its registry entry, inbox, cursor, and channel memberships to the new id, then broadcasts a rename notice to its channels. When tokens.json identity binding is on, the caller's bearer token is atomically rotated to the new id so the same session keeps authenticating after rename. If a live tmux-push transport is attached it is detached first (the pusher is bound to the old id) — re-attach as the new id (join/attach_agent) to restore real-time delivery; the response sets detachedTransport + a warning when this happens.",
412
424
  renameAgentSchema,
@@ -438,84 +450,84 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
438
450
  },
439
451
  );
440
452
 
441
- server.tool(
453
+ addTool(
442
454
  "attach_agent",
443
455
  "Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
444
456
  attachAgentSchema,
445
457
  gate("agentId", attachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
446
458
  );
447
459
 
448
- server.tool(
460
+ addTool(
449
461
  "detach_agent",
450
462
  "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
451
463
  detachAgentSchema,
452
464
  gate("agentId", detachAgentTool as (a: Record<string, unknown>) => Promise<unknown>),
453
465
  );
454
466
 
455
- server.tool(
467
+ addTool(
456
468
  "report_transport",
457
469
  "Publish a transport marker for an agent (used by the remote tmux pusher, scripts/coord-pusher.mjs, to surface itself in list_agents). Set transport='tmux-push-remote' and optionally host/tmuxTarget. Liveness for remote markers is heartbeat-based — keep calling heartbeat or this marker gets GC'd after staleness.",
458
470
  reportTransportSchema,
459
471
  gate("agentId", reportTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
460
472
  );
461
473
 
462
- server.tool(
474
+ addTool(
463
475
  "clear_transport",
464
476
  "Idempotent delete of an agent's transport marker. The wire-callable counterpart to detach_agent for remote pushers: it only removes the marker — there's no local process to kill.",
465
477
  clearTransportSchema,
466
478
  gate("agentId", clearTransportTool as (a: Record<string, unknown>) => Promise<unknown>),
467
479
  );
468
480
 
469
- server.tool(
481
+ addTool(
470
482
  "report_receipt",
471
483
  "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.",
472
484
  reportReceiptSchema,
473
485
  gate("agentId", reportReceiptTool as (a: Record<string, unknown>) => Promise<unknown>),
474
486
  );
475
487
 
476
- server.tool(
488
+ addTool(
477
489
  "doctor",
478
490
  "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.",
479
491
  doctorSchema,
480
492
  gate(null, doctorTool as (a: Record<string, unknown>) => Promise<unknown>),
481
493
  );
482
494
 
483
- server.tool(
495
+ addTool(
484
496
  "list_scopes",
485
497
  "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).",
486
498
  listScopesSchema,
487
499
  gate(null, listScopesTool as (a: Record<string, unknown>) => Promise<unknown>),
488
500
  );
489
501
 
490
- server.tool(
502
+ addTool(
491
503
  "import_work",
492
- "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.",
504
+ "Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md and optional docs/FACTS.md) into typed records: queue items {priority,text,done}, done entries {text,ref,date}, board rows, and facts {id,claim,verified,by,method}. The markdown stays authoritative — this store is a derived index, and export_work renders queue/done/board back byte-identically (FACTS is not an export write target).",
493
505
  importWorkSchema,
494
506
  gate(null, importWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
495
507
  );
496
508
 
497
- server.tool(
509
+ addTool(
498
510
  "list_work",
499
- "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.",
511
+ "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, the board's lane rows, and facts. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all.",
500
512
  listWorkSchema,
501
513
  gate(null, listWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
502
514
  );
503
515
 
504
- server.tool(
516
+ addTool(
505
517
  "export_work",
506
- "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.",
518
+ "Render a project's work documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last ' \u2014 ', date after a trailing ' \u00b7 '). Reports by default; pass write:true to rewrite the files. Refuses to export from an empty store rather than blanking a document. Refuses write:true when that write would emit a new 5-col lanes-v0 table (parse-only; write grammar is workstreams.v1). Any declared Task 4 write scope is REPORTED alongside the write, never enforced.",
507
519
  exportWorkSchema,
508
520
  gate(null, exportWorkTool as (a: Record<string, unknown>) => Promise<unknown>),
509
521
  );
510
522
 
511
- server.tool(
523
+ addTool(
512
524
  "delete_room",
513
525
  "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.",
514
526
  deleteRoomSchema,
515
527
  gate("agentId", deleteRoomTool as (a: Record<string, unknown>) => Promise<unknown>),
516
528
  );
517
529
 
518
- server.tool(
530
+ addTool(
519
531
  "force_unregister",
520
532
  "Admin eviction: unregisters any agent by targetAgentId regardless of the caller's identity. Detaches the agent's transport, removes it from all channel memberships, and drops its registry entry. Use after a reboot to clean up stale agents that can no longer unregister themselves.",
521
533
  forceUnregisterSchema,
@@ -1,80 +1 @@
1
- // Typed record → the text layout the fleet already reads (Phase 8 Task 3.3).
2
- //
3
- // Every consumer downstream of a message — hooks/tier.mjs's prefix table, the
4
- // UI's alert parser, a human reading a tmux pane — reads `text`. Phase 8 adds
5
- // `record` alongside it, so the rendering must reproduce the byte layout those
6
- // consumers already expect. Nothing downstream changes while agents migrate.
7
- //
8
- // This is a pure function of the record: no clock, no I/O, no registry. It
9
- // never sees `from`, `to`, or anything the sender could use it to forge.
10
-
11
- import type { DecisionPayload, MessageRecord, SummaryPayload, VerdictPayload } from "./shared.js";
12
-
13
- // The case-sensitive prefixes at byte 0 of `text`, exactly as classifyTier
14
- // matches them. `decision` is absent because it renders as a multi-line block,
15
- // not a one-liner.
16
- const PREFIX: Record<string, string> = {
17
- blocker: "BLOCKER",
18
- risk: "RISK",
19
- done: "DONE",
20
- fyi: "FYI",
21
- action: "AGENT_ACTION",
22
- go: "GO",
23
- scope: "SCOPE CHANGE",
24
- // `verdict` has no prefix in the v1 vocabulary — it is new in Phase 8. A gate
25
- // PASS/FAIL was previously posted as prose, which is why verdicts could not
26
- // be routed. Rendering it under its own prefix gives it one.
27
- verdict: "VERDICT",
28
- };
29
-
30
- function isNonEmpty(v: unknown): v is string {
31
- return typeof v === "string" && v.trim().length > 0;
32
- }
33
-
34
- // The playbook's decision packet (§Decision Packet Format), byte-for-byte —
35
- // the UI parses this into a clickable decision card, and a layout that drifts
36
- // still alerts loudly but loses the card. Returns null when any of the five
37
- // fields is missing, so the caller can reject rather than emit a half packet.
38
- function renderDecision(p: DecisionPayload): string | null {
39
- if (!isNonEmpty(p.title) || !isNonEmpty(p.context)) return null;
40
- if (!isNonEmpty(p.recommendation) || !isNonEmpty(p.ifNoAction)) return null;
41
- if (!Array.isArray(p.options) || p.options.length === 0) return null;
42
- if (!p.options.every(isNonEmpty)) return null;
43
- return [
44
- `DAVID_DECISION: ${p.title}`,
45
- `Context: ${p.context}`,
46
- "Options:",
47
- ...p.options.map((o, i) => `${i + 1}. ${o}`),
48
- `Recommendation: ${p.recommendation}`,
49
- `If no action: ${p.ifNoAction}`,
50
- ].join("\n");
51
- }
52
-
53
- // `VERDICT: PASS <sha> — <notes>`. The sha is not optional in the rendering:
54
- // a verdict that doesn't name the commit it was issued against is
55
- // unfalsifiable the moment the branch moves.
56
- function renderVerdict(p: VerdictPayload): string | null {
57
- if (p.result !== "pass" && p.result !== "fail") return null;
58
- if (!isNonEmpty(p.headRefOid)) return null;
59
- const head = `${PREFIX.verdict}: ${p.result.toUpperCase()} ${p.headRefOid}`;
60
- return isNonEmpty(p.notes) ? `${head} — ${p.notes}` : head;
61
- }
62
-
63
- // Render a record to its text form, or null when the payload can't support one
64
- // (absent, or missing a field the layout needs). Null is not an error here —
65
- // the caller decides whether a record without a rendering is fatal, and it only
66
- // is when there's no author-supplied `text` to fall back on.
67
- export function renderRecord(record: MessageRecord): string | null {
68
- if (!record || typeof record.type !== "string") return null;
69
- const payload = record.payload as unknown;
70
- if (payload === undefined || payload === null) return null;
71
- if (typeof payload !== "object" || Array.isArray(payload)) return null;
72
-
73
- if (record.type === "decision") return renderDecision(payload as DecisionPayload);
74
- if (record.type === "verdict") return renderVerdict(payload as VerdictPayload);
75
-
76
- const prefix = PREFIX[record.type];
77
- if (!prefix) return null;
78
- const { summary } = payload as SummaryPayload;
79
- return isNonEmpty(summary) ? `${prefix}: ${summary}` : null;
80
- }
1
+ export { renderRecord, PREFIX } from "@davidbalzan/groundwork-seam/protocol";
@@ -1,3 +1,12 @@
1
+ import type {
2
+ Citation,
3
+ DecisionPayload,
4
+ MessageRecord,
5
+ MessageRecordType,
6
+ SummaryPayload,
7
+ SummaryRecordType,
8
+ VerdictPayload,
9
+ } from "@davidbalzan/groundwork-seam/protocol";
1
10
  import { randomUUID } from "node:crypto";
2
11
  import { existsSync, openSync, watch } from "node:fs";
3
12
  import { promises as fsp } from "node:fs";
@@ -92,82 +101,16 @@ export type TransportMarker = {
92
101
 
93
102
  export type AgentRegistry = Record<string, AgentEntry>;
94
103
 
95
- // Where a claim can be independently verified. The point is that a consumer
96
- // resolves the ref (gh, git, fs) instead of trusting the message body — a
97
- // `done` record whose PR ref doesn't exist is a false claim, not a typo.
98
- export type Citation = {
99
- kind: "pr" | "file" | "commit" | "url";
100
- ref: string;
104
+ export type {
105
+ Citation,
106
+ MessageRecordType,
107
+ DecisionPayload,
108
+ VerdictPayload,
109
+ SummaryPayload,
110
+ SummaryRecordType,
111
+ MessageRecord,
101
112
  };
102
113
 
103
- // The protocol vocabulary the fleet already speaks. Today these live as
104
- // case-sensitive prefixes at byte 0 of `text` (hooks/tier.mjs), parsed a
105
- // second time by the UI for alert priority — so a greeting before the prefix
106
- // silently downgrades a production blocker, and the two parsers can disagree
107
- // about the same message. As a field there is nothing to mis-parse.
108
- export type MessageRecordType =
109
- | "blocker" // BLOCKER: work cannot continue
110
- | "decision" // DAVID_DECISION: the human must decide
111
- | "risk" // RISK: quality/security/cost/product risk
112
- | "done" // DONE: completed work, must cite
113
- | "fyi" // FYI: no action needed
114
- | "action" // AGENT_ACTION: another agent can handle it
115
- | "go" // GO: a work order
116
- | "scope" // SCOPE CHANGE: amends a contract in flight
117
- | "verdict"; // (new) a gate PASS/FAIL — has no prefix today
118
-
119
- // The five fields of the playbook's decision packet (§Decision Packet Format).
120
- // Named to match that layout because `renderRecord` reproduces it byte-for-byte
121
- // — the UI parses the rendering into a clickable decision card.
122
- export type DecisionPayload = {
123
- title: string;
124
- context: string;
125
- options: string[];
126
- recommendation: string;
127
- ifNoAction: string;
128
- };
129
-
130
- // A gate verdict. `headRefOid` pins WHICH commit was gated: a PASS that doesn't
131
- // name the sha it was issued against is unfalsifiable once the branch moves.
132
- export type VerdictPayload = {
133
- result: "pass" | "fail";
134
- headRefOid: string;
135
- notes?: string;
136
- };
137
-
138
- // Everything else carries prose. One line, because these render as
139
- // `<PREFIX>: <summary>` and a paragraph in a tmux pane is a wall, not a status.
140
- export type SummaryPayload = { summary: string };
141
-
142
- // Message types whose payload is just a summary.
143
- export type SummaryRecordType = "blocker" | "risk" | "fyi" | "action" | "go" | "scope";
144
-
145
- // Structured counterpart to `text`, NOT a replacement: `text` is the rendering,
146
- // and a tmux pane can only receive text. A v1 agent that has never heard of
147
- // `record` omits it entirely and behaves byte-identically.
148
- //
149
- // `payload` stays OPTIONAL on every arm — Phase 8 is additive, so no new
150
- // required field may appear on the wire. What is pinned is the shape *if* a
151
- // payload is supplied: a `decision` carrying three of its five fields is
152
- // structurally wrong for the type it claims and is rejected, while a payload
153
- // with extra unknown keys passes through untouched (a v3 sender must not be
154
- // broken by a v2 server, and stripping would silently drop data on the way to
155
- // disk).
156
- //
157
- // `cites` is likewise optional here. `done` requires a PR citation, but that is
158
- // enforced in sendMessageTool as a plain {ok:false,error} rather than a schema
159
- // rejection — see the identity-binding precedent in src/server.ts.
160
- //
161
- // UNTRUSTED, exactly like `from`. A peer can claim any type here, so trust
162
- // decisions (the SCOPE countersignature, gate-runner routing) must still
163
- // resolve the sender against the registry — typed is not the same as
164
- // authenticated, and `record` must never become a path to setting `urgent`.
165
- export type MessageRecord =
166
- | { type: "decision"; payload?: DecisionPayload; cites?: Citation[] }
167
- | { type: "verdict"; payload?: VerdictPayload; cites?: Citation[] }
168
- | { type: "done"; payload?: SummaryPayload; cites?: Citation[] }
169
- | { type: SummaryRecordType; payload?: SummaryPayload; cites?: Citation[] };
170
-
171
114
  export type Message = {
172
115
  id: string;
173
116
  ts: number;
@@ -215,9 +158,7 @@ export type Message = {
215
158
  //
216
159
  // Deliberately structural about `record`: it takes anything with the two
217
160
  // fields, so status entries and archive rows can be passed without a cast.
218
- export function isDecision(e: { kind?: string; record?: { type?: string } } | null | undefined): boolean {
219
- return e?.kind === "decision" || e?.record?.type === "decision";
220
- }
161
+ export { isDecision } from "@davidbalzan/groundwork-seam/protocol";
221
162
 
222
163
  export type StatusEntry = {
223
164
  id: string;