agent-coord-mcp 0.19.1 → 0.24.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.
@@ -1,6 +1,6 @@
1
1
  import { adjustCursors } from "./admin.js";
2
2
  import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
3
- import { ARCHIVE_STATUS_FILE, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
3
+ import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { existsSync, openSync, watch } from "node:fs";
6
6
  import { promises as fsp } from "node:fs";
@@ -168,8 +168,40 @@ export const sendMessageSchema = {
168
168
  text: z.string().min(1).optional(),
169
169
  kind: z.enum(["decision", "status", "chatter"]).optional(),
170
170
  record: messageRecordSchema.optional(),
171
+ // Additive reply link. Validated in the tool (malformed → {ok:false};
172
+ // unknown → store + warn) so the rejection shape matches identity-binding
173
+ // / unknown-recipient, not a schema throw. Existing records unchanged.
174
+ inReplyTo: z.string().optional(),
171
175
  };
172
176
 
177
+ const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
178
+
179
+ async function messageIdExists(id: string): Promise<boolean> {
180
+ const files: string[] = [];
181
+ for (const n of await listInboxFiles()) files.push(path.join(INBOX_DIR, n));
182
+ files.push(ROOM_FILE);
183
+ if (existsSync(ROOMS_DIR)) {
184
+ for (const n of await fsp.readdir(ROOMS_DIR)) {
185
+ if (n.endsWith(".jsonl")) files.push(path.join(ROOMS_DIR, n));
186
+ }
187
+ }
188
+ if (existsSync(ARCHIVE_INBOX_DIR)) {
189
+ for (const n of await fsp.readdir(ARCHIVE_INBOX_DIR)) {
190
+ if (n.endsWith(".jsonl")) files.push(path.join(ARCHIVE_INBOX_DIR, n));
191
+ }
192
+ }
193
+ if (existsSync(ARCHIVE_ROOMS_DIR)) {
194
+ for (const n of await fsp.readdir(ARCHIVE_ROOMS_DIR)) {
195
+ if (n.endsWith(".jsonl")) files.push(path.join(ARCHIVE_ROOMS_DIR, n));
196
+ }
197
+ }
198
+ for (const f of files) {
199
+ const entries = await readJsonl<Message>(f);
200
+ if (entries.some((m) => m.id === id)) return true;
201
+ }
202
+ return false;
203
+ }
204
+
173
205
  export async function sendMessageTool(args: {
174
206
  from: string;
175
207
  to?: string;
@@ -177,6 +209,7 @@ export async function sendMessageTool(args: {
177
209
  text?: string;
178
210
  kind?: "decision" | "status" | "chatter";
179
211
  record?: MessageRecord;
212
+ inReplyTo?: string;
180
213
  }) {
181
214
  // Record authority first — a sender who may not emit this type is refused
182
215
  // before any other check runs, so a rejected record writes nothing anywhere.
@@ -211,6 +244,28 @@ export async function sendMessageTool(args: {
211
244
  };
212
245
  }
213
246
 
247
+ let replyWarning: string | undefined;
248
+ if (args.inReplyTo !== undefined) {
249
+ if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
250
+ return {
251
+ ok: false as const,
252
+ error: `inReplyTo '${args.inReplyTo}' is not a message id (expected uuid)`,
253
+ };
254
+ }
255
+ if (!(await messageIdExists(args.inReplyTo))) {
256
+ replyWarning =
257
+ `inReplyTo '${args.inReplyTo}' is not a known message — reply stored but no matching parent may exist`;
258
+ }
259
+ }
260
+
261
+ // The tmux pusher's injection guard (hooks/tmux-pusher.mjs `shouldInject`)
262
+ // never types leading-slash text into a pane — only `send_command`'s
263
+ // allowlist goes in raw. The message is still stored and readable, so say so
264
+ // here instead of letting the sender assume it landed on screen.
265
+ const slashWarning = /^\s*\//.test(text)
266
+ ? "text starts with '/' — stored, but tmux-push transports never type leading-slash text into a pane (injection guard); prefix it (e.g. `AGENT_ACTION: run /<skill> …`) or use `send_command` for /clear and /compact"
267
+ : undefined;
268
+
214
269
  // DM → inbox. Otherwise resolve the channel (default `general`), make sure it
215
270
  // exists in the registry, and tag the message with its channel.
216
271
  if (args.to) {
@@ -221,6 +276,7 @@ export async function sendMessageTool(args: {
221
276
  to: args.to,
222
277
  text,
223
278
  ...(args.record ? { record: args.record } : {}),
279
+ ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
224
280
  };
225
281
  const target = inboxFile(args.to);
226
282
  await appendJsonl(target, msg);
@@ -228,9 +284,10 @@ export async function sendMessageTool(args: {
228
284
  // typo'd recipient shouldn't vanish silently — surface a warning when the
229
285
  // target isn't a known agent so the caller can catch the mistake.
230
286
  const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
231
- const warning = reg[args.to]
287
+ const recipientWarning = reg[args.to]
232
288
  ? undefined
233
289
  : `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
290
+ const warning = [recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
234
291
  return { ok: true, id: msg.id, target, room: undefined, warning };
235
292
  }
236
293
 
@@ -244,11 +301,13 @@ export async function sendMessageTool(args: {
244
301
  text,
245
302
  ...(args.kind ? { kind: args.kind } : {}),
246
303
  ...(args.record ? { record: args.record } : {}),
304
+ ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
247
305
  };
248
306
  const target = roomFile(chan);
249
307
  await appendJsonl(target, msg);
250
308
  await maybeCompactRoom(chan);
251
- return { ok: true, id: msg.id, target, room: chan };
309
+ const roomWarning = [replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
310
+ return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
252
311
  }
253
312
 
254
313
  // ---------- live compaction (self-limiting streams) ----------
@@ -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;
@@ -198,6 +141,10 @@ export type Message = {
198
141
  kind?: "decision" | "status" | "chatter";
199
142
  // Typed protocol record (Phase 8). Optional and additive; see MessageRecord.
200
143
  record?: MessageRecord;
144
+ // Additive reply link (optional). A DM that answers a DAVID_DECISION
145
+ // (or any other message) carries the parent message id so consumers can
146
+ // mark the packet answered. Absent on every v1/v2 message — those stay valid.
147
+ inReplyTo?: string;
201
148
  };
202
149
 
203
150
  // THE retention predicate — one definition, three call sites (prune,
@@ -215,9 +162,7 @@ export type Message = {
215
162
  //
216
163
  // Deliberately structural about `record`: it takes anything with the two
217
164
  // 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
- }
165
+ export { isDecision } from "@davidbalzan/groundwork-seam/protocol";
221
166
 
222
167
  export type StatusEntry = {
223
168
  id: string;
@@ -5,7 +5,7 @@ import { roleInputSchema, type RoleArg } from "../roles.js";
5
5
  import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
6
6
  import { sendMessageTool, readMessagesTool } from "./messaging.js";
7
7
  import { randomUUID } from "node:crypto";
8
- import { existsSync, openSync, watch } from "node:fs";
8
+ import { existsSync, openSync, readFileSync, watch } from "node:fs";
9
9
  import { promises as fsp } from "node:fs";
10
10
  import { spawn, spawnSync } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
@@ -699,6 +699,63 @@ function resolvePusherPath(): string {
699
699
  return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
700
700
  }
701
701
 
702
+ /** Running server package + optional git identity. `fromDir` is a test seam. */
703
+ export function resolveServerIdentity(fromDir?: string): {
704
+ path: string;
705
+ version: string;
706
+ branch?: string;
707
+ sha?: string;
708
+ } {
709
+ const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
710
+ let dir = start;
711
+ let pkgFile: string | undefined;
712
+ for (let i = 0; i < 8; i++) {
713
+ const candidate = path.join(dir, "package.json");
714
+ if (existsSync(candidate)) {
715
+ pkgFile = candidate;
716
+ break;
717
+ }
718
+ const parent = path.dirname(dir);
719
+ if (parent === dir) break;
720
+ dir = parent;
721
+ }
722
+ const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
723
+ const pkgDir = path.dirname(pkgPath);
724
+ let version = "unknown";
725
+ try {
726
+ const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
727
+ if (typeof raw.version === "string" && raw.version) version = raw.version;
728
+ } catch {
729
+ /* leave unknown — never invent a version */
730
+ }
731
+
732
+ const out: { path: string; version: string; branch?: string; sha?: string } = {
733
+ path: pkgDir,
734
+ version,
735
+ };
736
+
737
+ dir = pkgDir;
738
+ let gitRoot: string | undefined;
739
+ for (let i = 0; i < 10; i++) {
740
+ if (existsSync(path.join(dir, ".git"))) {
741
+ gitRoot = dir;
742
+ break;
743
+ }
744
+ const parent = path.dirname(dir);
745
+ if (parent === dir) break;
746
+ dir = parent;
747
+ }
748
+ if (gitRoot && isGitRepo(gitRoot)) {
749
+ const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
750
+ const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
751
+ const b = branch.status === 0 ? String(branch.stdout).trim() : "";
752
+ const s = sha.status === 0 ? String(sha.stdout).trim() : "";
753
+ if (b) out.branch = b;
754
+ if (s) out.sha = s;
755
+ }
756
+ return out;
757
+ }
758
+
702
759
  // Freshness basis for the stale-pusher-script mechanism: the newest mtime
703
760
  // across the pusher's source dir (hooks/*.mjs), NOT just the entry file. The
704
761
  // pusher imports submit.mjs / tier.mjs / roles.mjs, so a fix touching only an
@@ -1586,18 +1643,34 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1586
1643
  });
1587
1644
  }
1588
1645
 
1589
- // 10. Environment sanity. Report only.
1646
+ // 10. Environment sanity. Report only. Path + version come from the running
1647
+ // module's package.json so a stale/decoy bin is named (LESSONS #35). Git
1648
+ // identity is walk-up-to-.git + rev-parse (kit monorepo root); omitted
1649
+ // entirely when there is no checkout — never invented.
1590
1650
  {
1591
1651
  const tmuxProbe = spawnSync("tmux", ["-V"]);
1592
1652
  const tmuxOk = tmuxProbe.status === 0;
1653
+ const ident = resolveServerIdentity();
1654
+ const loc = ident.branch && ident.sha
1655
+ ? `path=${ident.path} version=${ident.version} ${ident.branch}@${ident.sha.slice(0, 12)}`
1656
+ : `path=${ident.path} version=${ident.version}`;
1657
+ const items = [
1658
+ `root=${ROOT}`,
1659
+ `execPath=${process.execPath}`,
1660
+ `inTmux=${!!process.env.TMUX_PANE}`,
1661
+ `path=${ident.path}`,
1662
+ `version=${ident.version}`,
1663
+ ];
1664
+ if (ident.branch) items.push(`branch=${ident.branch}`);
1665
+ if (ident.sha) items.push(`sha=${ident.sha}`);
1593
1666
  findings.push({
1594
1667
  check: "environment",
1595
1668
  level: tmuxOk ? "ok" : "warn",
1596
1669
  detail: tmuxOk
1597
- ? `root=${ROOT}; node=${process.execPath}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
1598
- : `root=${ROOT}; node=${process.execPath}; tmux NOT on PATH — the tmux-push transport will not work`,
1670
+ ? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
1671
+ : `root=${ROOT}; node=${process.execPath}; ${loc}; tmux NOT on PATH — the tmux-push transport will not work`,
1599
1672
  fixable: false,
1600
- items: [`root=${ROOT}`, `execPath=${process.execPath}`, `inTmux=${!!process.env.TMUX_PANE}`],
1673
+ items,
1601
1674
  });
1602
1675
  }
1603
1676