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/README.md +1 -1
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +39 -35
- package/dist/server.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +88 -34
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -291
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +10 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +50 -38
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +18 -77
- package/src/tools/work.ts +107 -40
- package/src/work.ts +31 -386
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
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.
|
|
244
|
+
version: "0.23.0",
|
|
244
245
|
});
|
|
245
246
|
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/tools/render.ts
CHANGED
|
@@ -1,80 +1 @@
|
|
|
1
|
-
|
|
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";
|
package/src/tools/shared.ts
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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;
|