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
@@ -1,5 +1,8 @@
1
1
  import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
2
+ import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
2
3
  import { registerTool } from "./registry.js";
4
+ import { roleInputSchema } from "../roles.js";
5
+ import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
3
6
  import { sendMessageTool, readMessagesTool } from "./messaging.js";
4
7
  import { randomUUID } from "node:crypto";
5
8
  import { existsSync, openSync } from "node:fs";
@@ -117,9 +120,14 @@ export const sendCommandSchema = {
117
120
  deliveryTimeoutMs: z.number().int().min(0).max(30_000).optional(),
118
121
  };
119
122
  // Poll an agent's receipt log until a receipt for `msgId` appears or the
120
- // deadline passes. Returns the delivery timestamp, or null on timeout. The
121
- // receipt is written by the receiver's pusher AFTER send-keys, so a hit is
122
- // genuine proof the keystrokes reached the pane. File-only — no agent context.
123
+ // deadline passes. Returns the receipt, or null on timeout. File-only — no
124
+ // agent context.
125
+ //
126
+ // A receipt proves the pusher TYPED the payload into the pane. For a control
127
+ // command that is NOT proof it ran: the command can sit in the input behind an
128
+ // autocomplete menu, delivered and inert. `submitted` is the field that
129
+ // distinguishes them, and `deliveryOutcome` below is the only place allowed to
130
+ // turn a receipt into a "confirmed".
123
131
  async function waitForReceipt(agentId, msgId, timeoutMs) {
124
132
  const file = receiptFile(agentId);
125
133
  const deadline = Date.now() + timeoutMs;
@@ -128,12 +136,43 @@ async function waitForReceipt(agentId, msgId, timeoutMs) {
128
136
  const receipts = await readJsonl(file);
129
137
  const hit = receipts.find((r) => r.id === msgId);
130
138
  if (hit)
131
- return hit.ts ?? Date.now();
139
+ return { ...hit, ts: hit.ts ?? Date.now() };
132
140
  if (Date.now() >= deadline)
133
141
  return null;
134
142
  await new Promise((res) => setTimeout(res, 150));
135
143
  }
136
144
  }
145
+ // Turn a receipt (or its absence) into the delivery verdict for a CONTROL
146
+ // command. Only `submitted === true` earns "confirmed" — anything else is
147
+ // pending with a reason the caller can act on. Reporting an unverified
148
+ // submission as confirmed is the defect this exists to remove: a check that
149
+ // cannot fail loudly is worse than no check.
150
+ export function deliveryOutcome(agentId, receipt, timeoutMs) {
151
+ if (!receipt) {
152
+ return {
153
+ delivery: "pending",
154
+ reason: `no delivery receipt from '${agentId}' within ${timeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
155
+ };
156
+ }
157
+ if (receipt.submitted === true)
158
+ return { delivery: "confirmed", at: receipt.ts };
159
+ if (receipt.submitted === false) {
160
+ return {
161
+ delivery: "pending",
162
+ at: receipt.ts,
163
+ reason: receipt.reason ??
164
+ `'${agentId}' pasted the command but could not confirm it was submitted — it may be sitting in the input.`,
165
+ };
166
+ }
167
+ // No `submitted` field: a pre-v0.19.0 pusher that stamps on paste. It may
168
+ // well have worked — but it cannot tell us, and guessing "confirmed" is the
169
+ // lie we are removing. Say what is actually known.
170
+ return {
171
+ delivery: "pending",
172
+ at: receipt.ts,
173
+ reason: `'${agentId}' typed the command into its pane, but its pusher predates submit verification and cannot confirm the command ran — re-attach the agent (detach_agent + attach_agent) to upgrade it.`,
174
+ };
175
+ }
137
176
  function defaultReminderText(agentId) {
138
177
  return (`[agent-coord] context reset by /clear. ` +
139
178
  `Your bus identity is '${agentId}'. You remain registered and attached — call ` +
@@ -212,8 +251,9 @@ export async function sendCommandTool(args) {
212
251
  // confirmation rides back in THIS tool result, costing no extra agent context.
213
252
  const wait = args.waitForDelivery ?? true;
214
253
  const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
215
- const confirmedAt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
216
- const confirmed = confirmedAt !== null;
254
+ const receipt = wait ? await waitForReceipt(args.to, msg.id, deliveryTimeoutMs) : null;
255
+ const outcome = wait ? deliveryOutcome(args.to, receipt, deliveryTimeoutMs) : null;
256
+ const confirmed = outcome?.delivery === "confirmed";
217
257
  // After /clear the receiver forgets its identity and that it's bus-attached
218
258
  // (the system prompt isn't re-applied because /clear isn't a session
219
259
  // start). Schedule a follow-up DM as a re-anchor; opt out with reminderMs:0.
@@ -230,16 +270,12 @@ export async function sendCommandTool(args) {
230
270
  // delivery: confirmed = pusher typed it into the pane; pending = written but
231
271
  // unconfirmed within the timeout (stale/wedged pusher — run doctor). Absent
232
272
  // when waitForDelivery:false.
233
- ...(wait
273
+ ...(wait && outcome
234
274
  ? {
235
- delivery: confirmed ? "confirmed" : "pending",
275
+ delivery: outcome.delivery,
236
276
  confirmed,
237
- ...(confirmedAt !== null ? { deliveredAt: confirmedAt } : {}),
238
- ...(confirmed
239
- ? {}
240
- : {
241
- warning: `no delivery receipt from '${args.to}' within ${deliveryTimeoutMs}ms — the command was written but may not have reached the pane (stale/wedged pusher). Run doctor or re-attach the agent.`,
242
- }),
277
+ ...(outcome.at !== undefined ? { deliveredAt: outcome.at } : {}),
278
+ ...(confirmed ? {} : { warning: outcome.reason }),
243
279
  }
244
280
  : {}),
245
281
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: [args.to] } } : {}),
@@ -273,10 +309,17 @@ export async function sendCommandTool(args) {
273
309
  const deliveryTimeoutMs = args.deliveryTimeoutMs ?? 8000;
274
310
  let confirmed = [];
275
311
  let pending = [];
312
+ let pendingReasons = [];
276
313
  if (wait) {
277
- const results = await Promise.all(delivered.map(async (m) => ({ m, at: await waitForReceipt(m, msg.id, deliveryTimeoutMs) })));
278
- confirmed = results.filter((r) => r.at !== null).map((r) => r.m);
279
- pending = results.filter((r) => r.at === null).map((r) => r.m);
314
+ const results = await Promise.all(delivered.map(async (m) => ({
315
+ m,
316
+ outcome: deliveryOutcome(m, await waitForReceipt(m, msg.id, deliveryTimeoutMs), deliveryTimeoutMs),
317
+ })));
318
+ confirmed = results.filter((r) => r.outcome.delivery === "confirmed").map((r) => r.m);
319
+ pending = results.filter((r) => r.outcome.delivery !== "confirmed").map((r) => r.m);
320
+ pendingReasons = results
321
+ .filter((r) => r.outcome.delivery !== "confirmed")
322
+ .map((r) => `${r.m}: ${r.outcome.reason}`);
280
323
  }
281
324
  // Same post-/clear re-anchor as the DM path — one reminder per delivered
282
325
  // member, in their own inbox, with their own agentId in the body.
@@ -298,7 +341,7 @@ export async function sendCommandTool(args) {
298
341
  ...(pending.length
299
342
  ? {
300
343
  pending,
301
- warning: `no delivery receipt within ${deliveryTimeoutMs}ms from: ${pending.join(", ")} — written but may not have reached their panes (stale/wedged pusher). Run doctor.`,
344
+ warning: `not confirmed as submitted within ${deliveryTimeoutMs}ms — ${pendingReasons.join(" | ")}`,
302
345
  }
303
346
  : {}),
304
347
  }
@@ -306,6 +349,25 @@ export async function sendCommandTool(args) {
306
349
  ...(reminderMs > 0 ? { reminderScheduled: { delayMs: reminderMs, recipients: delivered } } : {}),
307
350
  };
308
351
  }
352
+ // Does `pid` actually belong to one of our tmux pushers? A transport marker
353
+ // records a pid, but a marker can outlive its process and pids get recycled —
354
+ // so "pid is alive" is NOT evidence the pid is still the pusher. Anything that
355
+ // SIGTERMs a marker's pid must confirm identity first or it will eventually
356
+ // kill an unrelated process on the user's machine.
357
+ //
358
+ // Pushers are spawned as `<node> <.../hooks/tmux-pusher.mjs>` (see
359
+ // attachAgentTool), so the script path in the process's argv is the signature.
360
+ // Returns false when we cannot confirm — including when `ps` is unavailable.
361
+ // Refusing to kill an unverifiable pid is the safe failure: a wedged pusher
362
+ // that survives is a nuisance, a wrong SIGTERM is not.
363
+ export function isPusherProcess(pid) {
364
+ if (!Number.isInteger(pid) || pid <= 0)
365
+ return false;
366
+ const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
367
+ if (ps.status !== 0)
368
+ return false; // pid gone, or no usable ps
369
+ return (ps.stdout ?? "").includes(path.basename(resolvePusherPath()));
370
+ }
309
371
  // ---------- attach_agent / detach_agent (tmux push transport) ----------
310
372
  export const attachAgentSchema = {
311
373
  agentId: z.string().min(1),
@@ -361,7 +423,12 @@ export async function attachAgentTool(args) {
361
423
  // server is often launched via an absolute path (nvm/Homebrew/bundled
362
424
  // runtime) that isn't on the spawned child's PATH, which would silently fail
363
425
  // the pusher launch ("attached but nothing arrives").
364
- const child = spawn(process.execPath, [pusher], {
426
+ // `--agent <id>` is inert to the pusher (env stays authoritative) but puts
427
+ // the agentId in argv, so a pattern kill can be scoped to ONE pusher
428
+ // (`pkill -f "tmux-pusher.mjs --agent <id>"`). Without it the only matchable
429
+ // pattern was the script path, and a `pkill -f tmux-pusher.mjs` during one
430
+ // agent's cleanup silently detached every live agent on the bus (2026-07-28).
431
+ const child = spawn(process.execPath, [pusher, "--agent", args.agentId], {
365
432
  detached: true,
366
433
  stdio: ["ignore", logFd, logFd],
367
434
  env: {
@@ -381,14 +448,10 @@ export async function attachAgentTool(args) {
381
448
  return { ok: false, error: "spawn returned no pid" };
382
449
  // Write pid file (for scripts) and transport marker (for list_agents).
383
450
  await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
384
- // Stamp the script's mtime so doctor() can flag a stale daemon if it
385
- // outlives a later upgrade of the on-disk script (see v0.8.1 → v0.8.2 bug
451
+ // Stamp the pusher source's freshness so doctor() can flag a stale daemon if
452
+ // it outlives a later upgrade of the on-disk code (see v0.8.1 → v0.8.2 bug
386
453
  // report: control commands silently dropped by pre-v0.8 in-memory code).
387
- let scriptMtime;
388
- try {
389
- scriptMtime = (await fsp.stat(pusher)).mtimeMs;
390
- }
391
- catch { /* non-fatal */ }
454
+ const scriptMtime = newestPusherSourceMtime();
392
455
  const marker = {
393
456
  agentId: args.agentId,
394
457
  transport: "tmux-push",
@@ -396,6 +459,11 @@ export async function attachAgentTool(args) {
396
459
  tmuxTarget: target,
397
460
  since: Date.now(),
398
461
  scriptMtime,
462
+ // Provenance: the build identity THIS server loaded at startup — not a
463
+ // fresh stat of dist/, because the code doing the stamping is the loaded
464
+ // code, and after an in-place rebuild the two differ (that difference is
465
+ // exactly what doctor's provenance check exists to surface).
466
+ serverBuildMtime: SERVER_BUILD_MTIME,
399
467
  };
400
468
  // Use updateJson so it lockfile-protects and creates the file atomically.
401
469
  await updateJson(transportFile(args.agentId), marker, () => marker);
@@ -468,6 +536,20 @@ function resolvePusherPath() {
468
536
  const here = path.dirname(fileURLToPath(import.meta.url));
469
537
  return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
470
538
  }
539
+ // Freshness basis for the stale-pusher-script mechanism: the newest mtime
540
+ // across the pusher's source dir (hooks/*.mjs), NOT just the entry file. The
541
+ // pusher imports submit.mjs / tier.mjs / roles.mjs, so a fix touching only an
542
+ // import (the #21/#25 control-submit fixes did) leaves tmux-pusher.mjs's own
543
+ // mtime unchanged — a single-file stamp/compare reports ok on a pusher running
544
+ // exactly the code the fix replaced. Used by both the attach-time stamp and
545
+ // doctor's on-disk comparison so the two sides can never drift apart.
546
+ // AGENT_COORD_HOOKS_DIR is a test seam only: it redirects what freshness
547
+ // MEASURES (against a temp copy of hooks/) so tests never touch the mtimes of
548
+ // real sources shared with live pushers — it never changes what attach SPAWNS.
549
+ function newestPusherSourceMtime() {
550
+ const dir = process.env.AGENT_COORD_HOOKS_DIR ?? path.dirname(resolvePusherPath());
551
+ return newestMtimeUnder(dir, [".mjs"]);
552
+ }
471
553
  // ---------- status / whoami ----------
472
554
  export const statusSchema = { agentId: z.string().min(1) };
473
555
  export async function statusTool(args) {
@@ -501,7 +583,9 @@ const joinAttachOptionsSchema = z.object({
501
583
  export const joinSchema = {
502
584
  agentId: z.string().min(1),
503
585
  project: z.string().optional(),
504
- role: z.string().optional(),
586
+ // Free text, or a declared identity ({roleId, displayName}) — see
587
+ // roleInputSchema. A frozen roleId cannot be changed by re-joining.
588
+ role: roleInputSchema.optional(),
505
589
  // attach: undefined → auto-attach if $TMUX_PANE is set; true → always try;
506
590
  // false → never; object → attach with overrides.
507
591
  attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
@@ -513,6 +597,10 @@ export async function joinTool(args) {
513
597
  project: args.project,
514
598
  role: args.role,
515
599
  });
600
+ // A refused role update (frozen roleId) fails the whole join rather than
601
+ // silently attaching a transport under the wrong identity.
602
+ if (!reg.ok)
603
+ return reg;
516
604
  // Decide attach behavior.
517
605
  const wantAttach = args.attach === false
518
606
  ? false
@@ -585,6 +673,44 @@ export async function clearTransportTool(args) {
585
673
  const removed = await deleteFile(transportFile(args.agentId));
586
674
  return { ok: true, removed };
587
675
  }
676
+ export const reportReceiptSchema = {
677
+ agentId: z.string().min(1),
678
+ id: z.string().min(1),
679
+ from: z.string().optional(),
680
+ control: z.boolean().optional(),
681
+ submitted: z.boolean().optional(),
682
+ verified: z.boolean().optional(),
683
+ reason: z.string().optional(),
684
+ };
685
+ // Wire-callable counterpart to the local pusher's receipt stamp (writeReceipts
686
+ // in hooks/tmux-pusher.mjs). A remote pusher types into a pane on ANOTHER
687
+ // machine and cannot append to this host's receipts/<id>.jsonl, so before this
688
+ // existed a control command to a tmux-push-remote agent was never confirmable:
689
+ // send_command waited out deliveryTimeoutMs and reported delivery:"pending"
690
+ // even when the command demonstrably ran.
691
+ //
692
+ // The receipt is appended in the exact shape the local pusher writes, so
693
+ // waitForReceipt/deliveryOutcome need no remote-specific branch. `submitted`
694
+ // is recorded only when the caller reports it — absence means "typed but
695
+ // unverified", which deliveryOutcome refuses to call confirmed. The server
696
+ // cannot see the remote pane, so it stores what the pusher observed and
697
+ // nothing more; defaulting the field here would recreate assume-success one
698
+ // layer up. Trust matches report_transport: the identity gate binds agentId
699
+ // to the session, so a pusher can only stamp its own agent's receipt file.
700
+ export async function reportReceiptTool(args) {
701
+ const receipt = {
702
+ id: args.id,
703
+ agentId: args.agentId,
704
+ ts: Date.now(),
705
+ ...(args.from !== undefined ? { from: args.from } : {}),
706
+ control: args.control === true,
707
+ ...(args.submitted !== undefined ? { submitted: args.submitted } : {}),
708
+ ...(args.verified !== undefined ? { verified: args.verified } : {}),
709
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
710
+ };
711
+ await appendJsonl(receiptFile(args.agentId), receipt);
712
+ return { ok: true, receipt };
713
+ }
588
714
  // Count non-empty lines vs successfully-parsed entries in a JSONL file.
589
715
  // Offsets index the PARSED entries (see readJsonl), so `parsed` is the figure
590
716
  // cursor math is compared against; `malformed` is the desync risk.
@@ -683,15 +809,14 @@ export async function doctorTool(args) {
683
809
  // Pre-v0.8.2 pushers had no `control:true` awareness and silently
684
810
  // dropped /clear /compact at the slash-guard — ack:true with no
685
811
  // keystrokes ever reaching the pane. Comparing the marker's stamped
686
- // scriptMtime against the on-disk script's current mtime catches it.
812
+ // scriptMtime against the newest on-disk mtime across hooks/*.mjs
813
+ // catches it — the whole module graph, not just the entry file, because
814
+ // the control-submit logic lives in submit.mjs and a fix landing there
815
+ // alone leaves tmux-pusher.mjs's mtime (and a single-file stamp) intact.
687
816
  // Local tmux-push only — for tmux-push-remote the script lives on a
688
817
  // different host so we can't stat it from here.
689
818
  {
690
- let localPusherMtime;
691
- try {
692
- localPusherMtime = (await fsp.stat(resolvePusherPath())).mtimeMs;
693
- }
694
- catch { /* not packaged? skip */ }
819
+ const localPusherMtime = newestPusherSourceMtime();
695
820
  const stale = [];
696
821
  for (const fname of await listTransportFiles()) {
697
822
  const file = path.join(TRANSPORT_DIR, fname);
@@ -720,6 +845,163 @@ export async function doctorTool(args) {
720
845
  items: stale.length ? stale : undefined,
721
846
  });
722
847
  }
848
+ // 1b². The same staleness class one layer up: doctor itself runs inside an
849
+ // MCP server process that imported dist/ at startup. `npm run build`
850
+ // rewrites dist/ under the still-running server, which then keeps
851
+ // spawning pushers and stamping markers with logic the rebuild
852
+ // replaced — merging is not deploying, and until the session restarts
853
+ // no on-disk artifact reflects what this process will actually do.
854
+ // Self-scoped by construction: each session's doctor reports on the
855
+ // server it is running in, which is the only process whose loaded
856
+ // build it can truthfully know.
857
+ {
858
+ const onDisk = onDiskBuildMtime();
859
+ const drifted = SERVER_BUILD_MTIME !== undefined && onDisk !== undefined && SERVER_BUILD_MTIME < onDisk - 1;
860
+ const identity = `${SERVER_BUILD_SHA ?? "unknown-sha"} @ ${BUILD_DIR}`;
861
+ findings.push({
862
+ check: "server-build-drift",
863
+ level: drifted ? "warn" : "ok",
864
+ detail: drifted
865
+ ? `this MCP server loaded its build at ${new Date(SERVER_BUILD_MTIME).toISOString()} but the on-disk build is newer (${new Date(onDisk).toISOString()}) — the session is running pre-rebuild code and everything it stamps or spawns uses replaced logic. Restart this agent's session. (${identity})`
866
+ : `server is running the current on-disk build (${identity})`,
867
+ fixable: false,
868
+ });
869
+ }
870
+ // 1b²ᵇ. The affirmative catch for merged-but-never-rebuilt: src/ newer than
871
+ // the compiled build means no restart can help — the artifact every
872
+ // future session will load is already behind the code. Distinct from
873
+ // 1b² (a process behind its dist); this is the DISK being behind
874
+ // itself, which is why it can fire on a bus with zero live sessions.
875
+ // Not inferred from marker state: both sides are statted directly.
876
+ {
877
+ const srcMtime = onDiskSourceMtime();
878
+ const distMtime = onDiskBuildMtime();
879
+ const behind = srcMtime !== undefined && distMtime !== undefined && distMtime < srcMtime - 1;
880
+ findings.push({
881
+ check: "dist-behind-source",
882
+ level: behind ? "warn" : "ok",
883
+ detail: behind
884
+ ? `src/ is newer than the compiled build (src ${new Date(srcMtime).toISOString()}, dist ${new Date(distMtime).toISOString()}) — the checkout was updated but never rebuilt, so every session (current and future) runs pre-update code. \`npm run build\`, then restart sessions.`
885
+ : srcMtime === undefined
886
+ ? "no src/ to compare (packaged install) — dist is the only artifact"
887
+ : "compiled build is at least as new as src/",
888
+ fixable: false,
889
+ });
890
+ }
891
+ // 1b³. Marker provenance — which server BUILD stamped each marker. A live
892
+ // pusher can be perfectly fresh while the marker's stamps were
893
+ // computed by an outdated server (observed live 2026-07-29: a stale
894
+ // server's attach stamped single-file freshness that agreed with the
895
+ // new on-disk check only because tmux-pusher.mjs happened to be the
896
+ // newest hooks file). Local tmux-push only, same as 1b.
897
+ {
898
+ const onDisk = onDiskBuildMtime();
899
+ const outdated = [];
900
+ for (const fname of await listTransportFiles()) {
901
+ const file = path.join(TRANSPORT_DIR, fname);
902
+ const marker = await readJson(file, null);
903
+ if (!marker || !isMarkerLive(marker, reg, now))
904
+ continue;
905
+ if (marker.transport !== "tmux-push")
906
+ continue; // remote = can't verify
907
+ // Absent field = pre-upgrade marker: SKIP, deliberately mirroring the
908
+ // scriptMtime semantics in 1b. Two checks holding different opinions
909
+ // about the same absence would be harder to see than either behaviour
910
+ // alone — the queued P2 ("missing scriptMtime is a skip, not a warn")
911
+ // flips BOTH together. Do not "fix" one half here.
912
+ if (marker.serverBuildMtime === undefined)
913
+ continue;
914
+ if (onDisk === undefined)
915
+ continue;
916
+ if (marker.serverBuildMtime < onDisk - 1) {
917
+ const stamped = new Date(marker.serverBuildMtime).toISOString();
918
+ const current = new Date(onDisk).toISOString();
919
+ outdated.push(`${marker.agentId} (stamped by server build ${stamped}, on-disk build ${current})`);
920
+ }
921
+ }
922
+ findings.push({
923
+ check: "marker-server-provenance",
924
+ level: outdated.length ? "warn" : "ok",
925
+ detail: outdated.length
926
+ ? `${outdated.length} transport marker(s) stamped by a server build older than dist/ — the stamping/spawn logic (freshness basis, pusher argv) predates the current code. Restart that agent's session, then detach_agent + attach_agent.`
927
+ : "all local transport markers were stamped by the current server build",
928
+ fixable: false,
929
+ items: outdated.length ? outdated : undefined,
930
+ });
931
+ }
932
+ // 1c. Wedged local pushers (pid-alive, pane-dead). v0.8.0 made pushers
933
+ // self-exit when their own tmux-target probe finds the pane gone, but
934
+ // that only fires from inside the pusher's own poll loop — if the pane
935
+ // is killed in a way that loop never observes (or the loop itself is
936
+ // wedged), the pid stays alive, isMarkerLive's pid-alive check keeps
937
+ // treating it as live, and list_agents reports it "live" while nothing
938
+ // can actually be delivered. Local tmux-push only — a tmux-push-remote
939
+ // marker's pane lives on a different host, unprobeable from here.
940
+ {
941
+ // Without a tmux binary we can't tell "wedged" from "can't probe" — skip
942
+ // rather than flag every local marker as dead.
943
+ const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
944
+ const wedged = [];
945
+ if (tmuxAvailable) {
946
+ for (const fname of await listTransportFiles()) {
947
+ const file = path.join(TRANSPORT_DIR, fname);
948
+ const marker = await readJson(file, null);
949
+ if (!marker || !isMarkerLive(marker, reg, now))
950
+ continue;
951
+ if (marker.transport !== "tmux-push")
952
+ continue; // remote = no local pane to probe
953
+ if (!marker.tmuxTarget)
954
+ continue; // no target recorded, can't probe
955
+ // has-session actually validates the target and fails on a dead
956
+ // pane/session; `display-message -p -t <target> <literal>` does NOT
957
+ // (tmux 3.6b exits 0 for any target, even a just-killed one, when
958
+ // the format string has no #{...} needing that target resolved).
959
+ const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
960
+ if (probe.status === 0)
961
+ continue; // pane alive
962
+ // The marker's pid being alive does not make it OUR pid — see
963
+ // isPusherProcess. Record the verdict now so `fix` only ever signals
964
+ // a confirmed pusher.
965
+ wedged.push({
966
+ agentId: marker.agentId,
967
+ pid: marker.pid,
968
+ file,
969
+ target: marker.tmuxTarget,
970
+ isPusher: isPusherProcess(marker.pid),
971
+ });
972
+ }
973
+ }
974
+ if (fix) {
975
+ for (const w of wedged) {
976
+ // Clearing the marker is always safe — the pane is gone either way, so
977
+ // nothing can be delivered through it. Signalling is not: an
978
+ // unverifiable pid is some other process that inherited this number.
979
+ if (w.isPusher) {
980
+ try {
981
+ process.kill(w.pid, "SIGTERM");
982
+ }
983
+ catch { /* already gone */ }
984
+ }
985
+ await deleteFile(w.file);
986
+ fixed.push(w.isPusher
987
+ ? `reaped wedged pusher for ${w.agentId} (pid ${w.pid}, tmux target '${w.target}' gone)`
988
+ : `cleared stale transport marker for ${w.agentId} (tmux target '${w.target}' gone; pid ${w.pid} is not a tmux-pusher — not signalled)`);
989
+ }
990
+ }
991
+ findings.push({
992
+ check: "wedged-local-pushers",
993
+ level: wedged.length ? "warn" : "ok",
994
+ detail: wedged.length
995
+ ? `${wedged.length} local pusher(s) alive (pid) but their tmux pane is gone — looks attached, delivers nothing. ${fix ? "Reaped (SIGTERM + marker cleared)." : "Run doctor with fix:true to SIGTERM and clear the marker."}`
996
+ : tmuxAvailable
997
+ ? "no wedged local pushers (pid-alive, pane-dead)"
998
+ : "tmux not available — skipped wedged-pusher pane probe",
999
+ fixable: true,
1000
+ items: wedged.length
1001
+ ? wedged.map((w) => `${w.agentId} (pid ${w.pid}, tmux target '${w.target}')${w.isPusher ? "" : " — pid is not a tmux-pusher, marker will be cleared without signalling"}`)
1002
+ : undefined,
1003
+ });
1004
+ }
723
1005
  // 2. Orphan room memberships (member not in the registry).
724
1006
  {
725
1007
  const orphans = new Set();
@@ -971,6 +1253,58 @@ export async function doctorTool(args) {
971
1253
  items: orphanFiles.length ? orphanFiles : undefined,
972
1254
  });
973
1255
  }
1256
+ // 9b. Document scope drift (Phase 8 Task 4). For each document declared in
1257
+ // scopes.json, compare git's last writer against the declared owner.
1258
+ //
1259
+ // DETECTION ONLY, never fixable — rewriting or reverting someone else's
1260
+ // file is not a safe automatic repair, and the bus cannot prevent the
1261
+ // write in the first place (agents edit these with ordinary file tools;
1262
+ // enforcement waits for Task 5). Skips silently when no scopes.json
1263
+ // exists (opt-in) or when the declared repo isn't a git checkout, the
1264
+ // same way the wedged-pusher check skips without tmux — a check that
1265
+ // can't observe anything must not guess.
1266
+ {
1267
+ const scopes = await loadScopes();
1268
+ const drift = [];
1269
+ const unattributed = [];
1270
+ let detail;
1271
+ if (!scopes.documents.length) {
1272
+ detail = scopes.configured
1273
+ ? `${path.basename(scopes.file)} declares no documents`
1274
+ : `no ${path.basename(scopes.file)} — document scopes are opt-in and none are declared`;
1275
+ }
1276
+ else if (!isGitRepo(scopes.repo)) {
1277
+ detail = `${scopes.documents.length} document(s) declared but '${scopes.repo}' is not a git checkout — last writer is unknowable, check skipped`;
1278
+ }
1279
+ else {
1280
+ for (const doc of scopes.documents) {
1281
+ const writer = lastWriterOf(scopes.repo, doc.path);
1282
+ if (!writer)
1283
+ continue; // never committed — nothing has written it yet
1284
+ const who = attributeWriter(writer, reg);
1285
+ if (!who) {
1286
+ // Commits are authored by humans/machine accounts, not agent ids, so
1287
+ // an unmappable author is the normal case — reported, never flagged.
1288
+ unattributed.push(`${doc.path}: last written by '${writer.author}' (${writer.commit}), not attributable to a registered agent`);
1289
+ continue;
1290
+ }
1291
+ if (ownsDocument(who.agentId, reg[who.agentId], doc.owner))
1292
+ continue;
1293
+ drift.push(`${doc.path}: declared owner '${doc.owner}' (${doc.mode}) but last written by '${who.agentId}'` +
1294
+ `${who.roleId ? ` [role ${who.roleId}]` : ""} in ${writer.commit} (${writer.when})`);
1295
+ }
1296
+ detail = drift.length
1297
+ ? `${drift.length} document(s) last written by someone other than their declared owner — advisory: coordinate ownership, doctor will not rewrite anyone's file`
1298
+ : `${scopes.documents.length} declared document(s) agree with their scope`;
1299
+ }
1300
+ findings.push({
1301
+ check: "document-scope-drift",
1302
+ level: drift.length ? "warn" : "ok",
1303
+ detail,
1304
+ fixable: false,
1305
+ items: drift.length ? drift : unattributed.length ? unattributed : undefined,
1306
+ });
1307
+ }
974
1308
  // 10. Environment sanity. Report only.
975
1309
  {
976
1310
  const tmuxProbe = spawnSync("tmux", ["-V"]);