agent-coord-mcp 0.26.16 → 0.26.18

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 (79) hide show
  1. package/hooks/replay.mjs +107 -0
  2. package/hooks/tier.mjs +11 -0
  3. package/hooks/tmux-pusher.mjs +27 -0
  4. package/package.json +1 -1
  5. package/scripts/coord-pusher.mjs +16 -32
  6. package/src/server.ts +40 -20
  7. package/src/tools/admin.ts +8 -1
  8. package/src/tools/away.ts +48 -4
  9. package/src/tools/board-ref.ts +47 -2
  10. package/src/tools/messaging.ts +43 -2
  11. package/src/tools/records.ts +381 -25
  12. package/src/tools/registry.ts +5 -2
  13. package/src/tools/rooms.ts +2 -1
  14. package/src/tools/shared.ts +28 -0
  15. package/src/tools/stall.ts +29 -1
  16. package/src/tools/transport.ts +79 -10
  17. package/src/tools/work.ts +70 -4
  18. package/src/tools/worktrees.ts +64 -2
  19. package/src/work.ts +4 -0
  20. package/dist/build.js +0 -113
  21. package/dist/build.js.map +0 -1
  22. package/dist/capabilities.js +0 -158
  23. package/dist/capabilities.js.map +0 -1
  24. package/dist/prefix.js +0 -64
  25. package/dist/prefix.js.map +0 -1
  26. package/dist/roles.js +0 -132
  27. package/dist/roles.js.map +0 -1
  28. package/dist/server-identity.js +0 -82
  29. package/dist/server-identity.js.map +0 -1
  30. package/dist/server.js +0 -625
  31. package/dist/server.js.map +0 -1
  32. package/dist/store.js +0 -553
  33. package/dist/store.js.map +0 -1
  34. package/dist/tools/admin.js +0 -317
  35. package/dist/tools/admin.js.map +0 -1
  36. package/dist/tools/attention.js +0 -73
  37. package/dist/tools/attention.js.map +0 -1
  38. package/dist/tools/away.js +0 -243
  39. package/dist/tools/away.js.map +0 -1
  40. package/dist/tools/board-ref.js +0 -164
  41. package/dist/tools/board-ref.js.map +0 -1
  42. package/dist/tools/event-kinds.js +0 -39
  43. package/dist/tools/event-kinds.js.map +0 -1
  44. package/dist/tools/events.js +0 -234
  45. package/dist/tools/events.js.map +0 -1
  46. package/dist/tools/index.js +0 -14
  47. package/dist/tools/index.js.map +0 -1
  48. package/dist/tools/logwatch.js +0 -85
  49. package/dist/tools/logwatch.js.map +0 -1
  50. package/dist/tools/messaging.js +0 -667
  51. package/dist/tools/messaging.js.map +0 -1
  52. package/dist/tools/record-events.js +0 -380
  53. package/dist/tools/record-events.js.map +0 -1
  54. package/dist/tools/records.js +0 -686
  55. package/dist/tools/records.js.map +0 -1
  56. package/dist/tools/registry.js +0 -497
  57. package/dist/tools/registry.js.map +0 -1
  58. package/dist/tools/render.js +0 -2
  59. package/dist/tools/render.js.map +0 -1
  60. package/dist/tools/rooms.js +0 -210
  61. package/dist/tools/rooms.js.map +0 -1
  62. package/dist/tools/rotate.js +0 -143
  63. package/dist/tools/rotate.js.map +0 -1
  64. package/dist/tools/scopes.js +0 -126
  65. package/dist/tools/scopes.js.map +0 -1
  66. package/dist/tools/shared.js +0 -86
  67. package/dist/tools/shared.js.map +0 -1
  68. package/dist/tools/stall.js +0 -387
  69. package/dist/tools/stall.js.map +0 -1
  70. package/dist/tools/transport.js +0 -1810
  71. package/dist/tools/transport.js.map +0 -1
  72. package/dist/tools/work.js +0 -319
  73. package/dist/tools/work.js.map +0 -1
  74. package/dist/tools/worktrees.js +0 -339
  75. package/dist/tools/worktrees.js.map +0 -1
  76. package/dist/typed-records.js +0 -174
  77. package/dist/typed-records.js.map +0 -1
  78. package/dist/work.js +0 -2
  79. package/dist/work.js.map +0 -1
@@ -0,0 +1,107 @@
1
+ /*
2
+ * IS THIS INSTRUCTION ARRIVING FOR THE FIRST TIME, OR AGAIN?
3
+ *
4
+ * Phase 5.3 Task 21.2 asks for a property and deliberately names no mechanism:
5
+ * A REPLAYED INSTRUCTION MUST BE DISTINGUISHABLE FROM A LIVE ONE IN THE PANE,
6
+ * WITHOUT RELYING ON ANY AGENT'S RETAINED CONTEXT.
7
+ *
8
+ * WHY RETAINED CONTEXT CANNOT BE THE ANSWER — this is the whole reason the task
9
+ * forbids it. DM delivery is at-least-once by design (losing a message is worse
10
+ * than repeating one), so a crash, SIGTERM or dead pane between paste and cursor
11
+ * commit REDELIVERS. Today the redelivered bytes are IDENTICAL to the original,
12
+ * so the only thing that has ever caught a duplicate is an agent recognising the
13
+ * instruction — and that memory is destroyed two ways:
14
+ *
15
+ * /clear at a VISIBLE moment, at a task boundary, by an operator
16
+ * compaction SILENTLY and CONTINUOUSLY, with no command and no moment
17
+ * anyone notices
18
+ *
19
+ * The aide's framing, which is stronger than the one I brought: the second is
20
+ * the case that matters, because there is no point at which anyone knows the
21
+ * memory is gone. A control whose only failure mode is invisible is not a
22
+ * control. Both were hit twice in one reconnect.
23
+ *
24
+ * THE SIGNAL IS THE RECEIPT LOG, WHICH IS ALREADY ON DISK. Receipts are stamped
25
+ * AFTER the payload is typed into the pane and the push cursor advances only
26
+ * after that, so the redelivery window is EXACTLY the window in which a receipt
27
+ * exists and the cursor has not moved. The evidence of a prior delivery is
28
+ * therefore already written down, by construction, before the redelivery can
29
+ * happen — nothing new has to be recorded and no agent has to remember anything.
30
+ *
31
+ * WHAT THE ABSENCE OF A MARKER DOES NOT MEAN. `prune` deletes receipts (default
32
+ * 7 days), and a receipt file that has been swept cannot report the delivery it
33
+ * once held. So an unmarked line means "no earlier delivery IS RECORDED", never
34
+ * "this is provably the first". That gap is bounded and not load-bearing —
35
+ * redelivery follows the original by seconds to minutes, while the sweep cutoff
36
+ * is days — but it is stated here rather than left for a reader to assume
37
+ * totality, which is the recurring defect this phase exists to remove.
38
+ *
39
+ * ONE GRAMMAR, TWO PRODUCERS. The local pusher reads its own receipts file; the
40
+ * remote pusher cannot see the server's filesystem, so the server annotates the
41
+ * message instead (readMessagesTool). Those two compute the same DATA by
42
+ * different routes and both render it through `replayMarker` here, so the pane
43
+ * cannot receive two different vocabularies for one fact.
44
+ */
45
+
46
+ /**
47
+ * Prior deliveries per message id, from the text of a receipts JSONL file.
48
+ *
49
+ * Takes TEXT rather than a path so the parsing is exercisable without a
50
+ * filesystem — the reason cursor IO was moved into push-cursor.mjs.
51
+ * Unparseable lines are skipped: a corrupt tail must not blind the whole log.
52
+ */
53
+ export function priorDeliveries(text) {
54
+ const out = new Map();
55
+ for (const line of String(text ?? "").split("\n")) {
56
+ if (!line.trim()) continue;
57
+ let r;
58
+ try {
59
+ r = JSON.parse(line);
60
+ } catch {
61
+ continue;
62
+ }
63
+ if (!r || typeof r.id !== "string") continue;
64
+ const seen = out.get(r.id);
65
+ const ts = Number.isFinite(r.ts) ? r.ts : undefined;
66
+ if (!seen) out.set(r.id, { deliveries: 1, ...(ts !== undefined ? { firstAt: ts } : {}) });
67
+ else {
68
+ seen.deliveries += 1;
69
+ // The FIRST delivery is the useful one to name: it tells a reader how
70
+ // long ago the instruction they are looking at was already handled.
71
+ if (ts !== undefined && (seen.firstAt === undefined || ts < seen.firstAt)) seen.firstAt = ts;
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The `replay` annotation for a message about to be pasted, or undefined when
79
+ * no earlier delivery is recorded.
80
+ *
81
+ * `deliveries` counts receipts ALREADY on disk, so the attempt number of the
82
+ * delivery being rendered is that plus one.
83
+ */
84
+ export function replayInfo(prior, id) {
85
+ const seen = id ? prior?.get(id) : undefined;
86
+ if (!seen || !(seen.deliveries > 0)) return undefined;
87
+ return { attempt: seen.deliveries + 1, ...(seen.firstAt !== undefined ? { firstAt: seen.firstAt } : {}) };
88
+ }
89
+
90
+ /**
91
+ * The pane-visible marker for a replay, or "" for a first delivery.
92
+ *
93
+ * It says three things, and the third is the one that makes it a control rather
94
+ * than a label: THIS IS NOT THE FIRST TIME, WHEN THE FIRST TIME WAS, and WHAT TO
95
+ * DO ABOUT IT. A reader with no retained context cannot infer the third, and
96
+ * "you may ignore a duplicate, an executor may not" is exactly the case where
97
+ * guessing is expensive — re-running a merge or a push because the instruction
98
+ * arrived twice is the failure at-least-once delivery pays for.
99
+ */
100
+ export function replayMarker(replay) {
101
+ if (!replay || !(replay.attempt > 1)) return "";
102
+ const at = Number.isFinite(replay.firstAt) ? new Date(replay.firstAt) : null;
103
+ const when = at
104
+ ? ` · first delivered ${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")}Z`
105
+ : "";
106
+ return `⟲ REPLAY (delivery ${replay.attempt}${when}) — ALREADY DELIVERED BEFORE; check server truth before acting, do not re-run it · `;
107
+ }
package/hooks/tier.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // by convention, and a lowercase lookalike is chatter, not a work order.
11
11
 
12
12
  import { isGateRunner } from "./roles.mjs";
13
+ import { replayMarker } from "./replay.mjs";
13
14
 
14
15
  // Typed protocol record → tier (Phase 8). The prefix table below is the same
15
16
  // vocabulary parsed out of text; reading the field instead removes the parse
@@ -211,6 +212,16 @@ export function injectLine(m) {
211
212
  const held = text.split("\n").length - 1;
212
213
  text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
213
214
  }
215
+ // Phase 5.3 Task 21.2 — A REPLAY IS MARKED IN THE PANE, so telling it from a
216
+ // live instruction needs no retained context. Applied LAST so the marker is
217
+ // the first thing after the attribution header even when the record digest
218
+ // above has already rewritten `text` — a warning below the fold is an absent
219
+ // warning, and this line is often long.
220
+ //
221
+ // The header stays byte-identical, so the parse contract holds: attribution
222
+ // is still `[tag HH:MM from] ` and the marker is part of the TEXT, which is
223
+ // correct — it is something the reader must see, not metadata about routing.
224
+ text = `${replayMarker(m.replay)}${text}`;
214
225
  return ` [${tag} ${hhmm} ${m.from}] ${text}`;
215
226
  }
216
227
 
@@ -102,6 +102,7 @@ import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.
102
102
  import { isCi } from "./roles.mjs";
103
103
  import { mergeTransportMarker } from "./marker.mjs";
104
104
  import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
105
+ import { priorDeliveries, replayInfo } from "./replay.mjs";
105
106
  import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
106
107
 
107
108
  const AGENT_ID = process.env.AGENT_COORD_ID;
@@ -583,6 +584,32 @@ async function injectViaTmux(batch) {
583
584
  );
584
585
  return [...batch];
585
586
  }
587
+ // Phase 5.3 Task 21.2 — STAMP THE REPLAY FACT BEFORE PASTING.
588
+ //
589
+ // Read once per inject, not per message: the receipt log is append-only and a
590
+ // single read is a consistent snapshot for the whole batch.
591
+ //
592
+ // WHY THIS FILE IS THE RIGHT PLACE, and why it can work at all: receipts are
593
+ // stamped AFTER a payload is typed (writeReceipts, below) and the push cursor
594
+ // advances only after the whole batch lands (commitOffsets, in flush). So a
595
+ // death anywhere in that window redelivers a message whose receipt is ALREADY
596
+ // on disk — the evidence exists before the duplicate can, without anyone
597
+ // having recorded anything for this purpose or remembered anything at all.
598
+ //
599
+ // Best-effort: an unreadable receipt log yields an empty map and therefore no
600
+ // markers, which is the honest direction. A missing marker means "no earlier
601
+ // delivery is RECORDED", never "this is provably the first" — see replay.mjs.
602
+ let prior = new Map();
603
+ try {
604
+ prior = priorDeliveries(readFileSync(RECEIPTS_FILE, "utf8"));
605
+ } catch {
606
+ // no receipts yet, or unreadable — every message renders as a first delivery
607
+ }
608
+ batch = batch.map((m) => {
609
+ const replay = replayInfo(prior, m?.id);
610
+ return replay ? { ...m, replay } : m;
611
+ });
612
+
586
613
  const undelivered = [];
587
614
  let run = [];
588
615
  const flushRun = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.26.16",
3
+ "version": "0.26.18",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,8 @@
38
38
  import { hostname } from "node:os";
39
39
  import { spawn, spawnSync } from "node:child_process";
40
40
  import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
41
+ // The pane's parse-contract line, single-sourced — see the note above formatBatch.
42
+ import { injectLine } from "../hooks/tier.mjs";
41
43
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
42
44
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
43
45
 
@@ -248,38 +250,20 @@ async function flush() {
248
250
  }
249
251
  }
250
252
 
251
- // The per-message PARSE CONTRACT line MUST stay byte-identical to
252
- // hooks/tier.mjs's injectLine (agent harnesses parse from/room/text out of
253
- // it). Compact form (v0.14.0): ` [<tag> <HH:MM> <from>] <text>`, tag drops
254
- // the leading "room ", timestamp is HH:MM UTC, no "from=" label. This pusher
255
- // is standalone (may deploy without hooks/), so the helper is duplicated
256
- // rather than imported; test/tier.test.mjs locks both to the same shape.
257
- function injectLine(m) {
258
- const tag = String(m.tag ?? "").replace(/^room /, "");
259
- const d = new Date(m.ts ?? 0);
260
- const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
261
- let text = m.text ?? "";
262
- // Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
263
- // ONE attributed line first line, a count of what was withheld, and the
264
- // message id as the retrieval handle. Continuation lines used to arrive bare,
265
- // with no `[tag HH:MM from]` header, so a parser could not attribute them.
266
- //
267
- // The handle is the message id, NOT a stashed copy: the full record is
268
- // already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
269
- // retrieve_message reads it back by id (falling through to the append-only
270
- // archive if compaction moved it). A cache would have had a TTL and lost the
271
- // record permanently on expiry.
272
- //
273
- // Gated on `m.record`: a record-LESS multi-line message is untouched and
274
- // still arrives unattributed past line 1, exactly as today. Task 6 does not
275
- // fix hand-typed multi-line messages, and must not change their bytes.
276
- const nl = text.indexOf("\n");
277
- if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
278
- const held = text.split("\n").length - 1;
279
- text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
280
- }
281
- return ` [${tag} ${hhmm} ${m.from}] ${text}`;
282
- }
253
+ // THE PER-MESSAGE PARSE CONTRACT LINE IS IMPORTED, NOT COPIED.
254
+ //
255
+ // This was a duplicate of hooks/tier.mjs's `injectLine`, kept "byte-identical"
256
+ // by a comment and locked by nothing, on the rationale that this pusher is
257
+ // standalone and "may deploy without hooks/". THAT RATIONALE IS ALREADY FALSE:
258
+ // the file imports ../hooks/submit.mjs at the top, so it cannot run without
259
+ // hooks/ today and has not been able to for several releases. A copy kept in
260
+ // step by a comment is two grammars the moment one is fixed — the same argument
261
+ // the seam's phase-citation grammar records, where a second copy would have
262
+ // kept a fixed bug alive in the other package with nothing to find it.
263
+ //
264
+ // Deleting the copy is what makes Task 21.2 land in BOTH panes: the replay
265
+ // marker had to be added once rather than twice-and-hopefully-identically.
266
+ // (the import itself sits with the other imports at the top of the file)
283
267
 
284
268
  function formatBatch(batch) {
285
269
  const lines = ["[agent-coord] msgs (pre-consumed, don't re-read):"];
package/src/server.ts CHANGED
@@ -135,8 +135,13 @@ function jsonResult(data: unknown) {
135
135
  // transport, or another live bound session) — that silently created a
136
136
  // second session acting as an already-running agent (hit live 2026-07-06:
137
137
  // a dev session bound itself to `<project>-liaison`). A live-id claim needs
138
- // the agent's token or an explicit force (join/register params). See
139
- // guardFirstClaim for how absent vs unreadable evidence is decided.
138
+ // the agent's TOKEN `force` alone is refused against a provably live
139
+ // incumbent (q-314e0187, v0.26.0: `force` used to bypass this evidence
140
+ // check entirely, which is what let two live sessions hold
141
+ // `groundwork-kit-worker-3` at once on 2026-08-31). `force` still works
142
+ // for evidence the guard cannot verify at all, or an id verified absent.
143
+ // See guardFirstClaim for how absent vs unreadable vs live-but-unproven
144
+ // evidence is decided.
140
145
  // - `trackSession` (stdio only): each successful bind writes a
141
146
  // sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
142
147
  // sessions bound to one id — closure state alone cannot be inspected
@@ -227,9 +232,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
227
232
  // - a presented token must MATCH or the claim fails loudly, even when the
228
233
  // id is not live — a wrong credential silently succeeding via the
229
234
  // not-live path would teach callers that garbage tokens work;
230
- // - force is an explicit human/agent decision, honored before evidence;
231
- // - evidence that exists but cannot be read REFUSES (cannot-verify
232
- // verified-absent; unreadable state must not disable the guard);
235
+ // - evidence is gathered BEFORE `force` is consulted (q-314e0187): a live
236
+ // PROVABLE incumbent can no longer be superseded by `force` alone — see
237
+ // below. `force` is still honored, but only where it was never the
238
+ // defect: unverifiable evidence, or a verified-absent id;
239
+ // - evidence that exists but cannot be read REFUSES unless `force` is
240
+ // passed (cannot-verify ≠ verified-absent; unreadable state must not
241
+ // silently disable the guard, but an explicit override still works —
242
+ // there is no incumbent to protect from being duplicated here);
233
243
  // - a live id refuses, except when its live pusher types into THIS
234
244
  // process's own tmux pane — two sessions cannot share a pane, so that
235
245
  // is the same seat restarting (the routine fleet-restart case), not a
@@ -246,9 +256,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
246
256
  `Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
247
257
  );
248
258
  }
249
- if (args["force"] === true) return "force";
250
259
  const ev = await liveClaimEvidence(claimed, Date.now());
251
260
  if (!ev.verifiable) {
261
+ if (args["force"] === true) return "force";
252
262
  throw new Error(
253
263
  `cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
254
264
  `Refusing to bind rather than treating unreadable evidence as absence. ` +
@@ -257,14 +267,19 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
257
267
  }
258
268
  if (ev.live) {
259
269
  if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
260
- // A LEFTOVER SESSION IS NAMED AS ONE, AND force DOES NOT REMOVE IT.
261
- //
262
- // The old wording reported the same evidence and then offered `force:true`
263
- // as the way through, which reads as a procedural step. It is not: forcing
264
- // past a live session does not stop that process — it adds a second
265
- // claimant to one identity, which is the condition that had two sessions
266
- // holding worker-3's yesterday. The refusal papered over the thing it had
267
- // just detected.
270
+ // A LEFTOVER SESSION IS NAMED AS ONE, AND force NO LONGER REMOVES IT
271
+ // EITHER — q-314e0187: `force:true` used to be checked BEFORE this
272
+ // evidence was even gathered (line order, not this message), so a
273
+ // provably-live incumbent was never actually protected it just
274
+ // looked protected to anyone who did not pass `force`. Measured
275
+ // 2026-08-31: `doctor` found pid 7129 (via FORCE) and pid 9760 (via
276
+ // same-pane) both live and bound to `groundwork-kit-worker-3`
277
+ // simultaneously, and the bus log could not tell which one wrote a
278
+ // disputed message. `force` binding past a live session does not stop
279
+ // that process — it adds a second claimant to one identity, which is
280
+ // the condition that produced exactly that incident. So a provably
281
+ // live incumbent can now be superseded ONLY by its own token; `force`
282
+ // is refused here regardless of its value.
268
283
  //
269
284
  // A live TRANSPORT marker is a different fact and is not called a leak:
270
285
  // that pid is the pusher daemon, doing its job. Only a live SESSION
@@ -273,15 +288,20 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
273
288
  const leakLine = leaks.length
274
289
  ? ` LEFTOVER PROCESS${leaks.length > 1 ? "ES" : ""} HOLDING THIS IDENTITY: ` +
275
290
  leaks.map((l) => `pid ${l.pid} (via ${l.via})`).join(", ") +
276
- `. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\` — because force:true does NOT stop ${leaks.length > 1 ? "those processes" : "that process"}; it binds a SECOND session to one identity, and two sessions on one id is the defect this refusal exists to surface, not a step past it.`
291
+ `. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\`.`
277
292
  : "";
278
293
  throw new Error(
279
- `agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it.` +
294
+ `agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it` +
295
+ (args["force"] === true ? ", even with force:true" : "") +
296
+ `.` +
280
297
  leakLine +
281
- ` If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}, or pass its token. ` +
298
+ ` A live incumbent can only be superseded with ITS OWN TOKEN now (mint one: scripts/coord-token.mjs add ${claimed}, then SIGHUP the bus) ` +
299
+ `\`force:true\` binds a SECOND session to one identity rather than stopping the first, which is the defect this refusal exists to prevent, not a step past it. ` +
300
+ `If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}. ` +
282
301
  `If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
283
302
  );
284
303
  }
304
+ if (args["force"] === true) return "force";
285
305
  return "tofu";
286
306
  }
287
307
 
@@ -349,7 +369,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
349
369
 
350
370
  addTool(
351
371
  "join",
352
- "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. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
372
+ "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 `force:true` no longer overrides a provably live incumbent (only the token does); it still works when liveness cannot be verified at all or the id is verified absent. Diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
353
373
  joinSchema,
354
374
  // join explicitly sets the session binding when unset, so each agent can
355
375
  // declare its identity via join rather than relying on env vars.
@@ -607,7 +627,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
607
627
 
608
628
  addTool(
609
629
  "stall_check",
610
- "The stall predicate over the board and the bus: a 🚧 row whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
630
+ "The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
611
631
  stallCheckSchema,
612
632
  gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
613
633
  );
@@ -642,7 +662,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
642
662
 
643
663
  addTool(
644
664
  "next_unblocked",
645
- "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
665
+ "The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also skips — as its own axis, `awaitingDecision`, never silently — an item that DECLARES it awaits a named human (`**[AWAITS:<who>]**` leading the item line; never inferred from prose): dependency-free and unbuildable at once, so it is reported for the human and not routed to a lane. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
646
666
  nextUnblockedSchema,
647
667
  gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
648
668
  );
@@ -31,6 +31,7 @@ import {
31
31
  type Message,
32
32
  type StatusEntry,
33
33
  isDecision,
34
+ agentIdFromCursorFilename,
34
35
  } from "./shared.js";
35
36
 
36
37
  // ---------- prune ----------
@@ -73,7 +74,13 @@ export async function adjustCursors(removals: {
73
74
  const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
74
75
  const cursorsAdjusted: string[] = [];
75
76
  for (const cname of await listCursorFiles()) {
76
- const id = cname.replace(/\.json$/, "");
77
+ // q-40449919: this was `cname.replace(/\.json$/, "")`, which strips only
78
+ // the outer extension. A push cursor's `<agent>.push.json` then keyed
79
+ // `perAgentInboxRemoved` lookups below on `<agent>.push` — a key that
80
+ // never matches — so a push cursor's inboxOffset was silently never
81
+ // decremented after a prune shrank the inbox, drifting it toward a
82
+ // false past-EOF the next time doctor ran.
83
+ const { id } = agentIdFromCursorFilename(cname);
77
84
  const cursorPath = path.join(CURSOR_DIR, cname);
78
85
  let touched = false;
79
86
  await updateJson<Cursor>(cursorPath, {}, (current) => {
package/src/tools/away.ts CHANGED
@@ -155,7 +155,14 @@ export function secondCoordinatorRefusal(
155
155
 
156
156
  export const coordAwaySchema = {
157
157
  project: z.string().min(1),
158
- on: z.boolean(),
158
+ // Omitted → a QUERY: report current state and mutate nothing (q-1abbbdbf).
159
+ // `/coord-away` used to be a bare toggle read by a human deciding which
160
+ // direction to call it — the incident this fixes is David typing it
161
+ // meaning ARM while it was already armed, and the aide independently
162
+ // reading the same typed command as RELEASE, with no state visible to
163
+ // either of them before acting. State-it-aloud-first only works if there
164
+ // is a state to read that does not itself flip the switch.
165
+ on: z.boolean().optional(),
159
166
  /** The agent leading in David's absence — the aide. */
160
167
  leadId: z.string().optional(),
161
168
  /** Repo-relative path where decisions taken during the absence are written. */
@@ -184,7 +191,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
184
191
 
185
192
  export async function coordAwayTool(args: {
186
193
  project: string;
187
- on: boolean;
194
+ on?: boolean;
188
195
  leadId?: string;
189
196
  decisionLog?: string;
190
197
  until?: string;
@@ -195,6 +202,38 @@ export async function coordAwayTool(args: {
195
202
  const state = readAway();
196
203
  const prior = state[args.project];
197
204
 
205
+ // QUERY — `on` omitted. Reads, never writes: no coverage measurement, no
206
+ // refusal, no side effect. This is the call a card makes to state current
207
+ // status aloud BEFORE deciding which direction to arm/release, so the
208
+ // decision is never made blind the way it was on 2026-08-30/31.
209
+ if (args.on === undefined) {
210
+ if (!prior?.on) {
211
+ return {
212
+ ok: true as const,
213
+ query: true as const,
214
+ project: args.project,
215
+ armed: false,
216
+ state: prior ?? null,
217
+ summary: prior
218
+ ? `'${args.project}' is NOT armed — last released ${prior.at}.`
219
+ : `'${args.project}' is NOT armed — never armed.`,
220
+ };
221
+ }
222
+ return {
223
+ ok: true as const,
224
+ query: true as const,
225
+ project: args.project,
226
+ armed: true,
227
+ state: prior,
228
+ summary:
229
+ `'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
230
+ `${prior.until ? ` until ${prior.until}` : ""}. ` +
231
+ `Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
232
+ `${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
233
+ `Decisions logged to ${prior.decisionLog}.`,
234
+ };
235
+ }
236
+
198
237
  if (args.on) {
199
238
  // A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
200
239
  // worse than an absence everyone can see is uncovered.
@@ -261,8 +300,12 @@ export async function coordAwayTool(args: {
261
300
  state: state[args.project],
262
301
  refused: LEAD_REFUSED,
263
302
  parked: PARKED_CATEGORIES,
303
+ // A confirmation naming the transition, not just the imperative
304
+ // direction (q-1abbbdbf) — "ON" and "already ON" read the same at a
305
+ // glance; "OFF → ARMED" cannot be misread as a no-op or a repeat.
306
+ transition: "OFF → ARMED" as const,
264
307
  announce:
265
- `AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
308
+ `OFF → ARMED. AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
266
309
  `Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
267
310
  `Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
268
311
  `Decisions logged to ${args.decisionLog}. ` +
@@ -280,8 +323,9 @@ export async function coordAwayTool(args: {
280
323
  return {
281
324
  ok: true as const,
282
325
  state: state[args.project],
326
+ transition: "ARMED → RELEASED" as const,
283
327
  announce:
284
- `AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
328
+ `ARMED → RELEASED. AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
285
329
  `Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
286
330
  };
287
331
  }
@@ -65,6 +65,25 @@ const git = (repo: string, args: string[]): string | null => {
65
65
  };
66
66
  const resolves = (repo: string, ref: string): boolean => !!git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
67
67
 
68
+ // Task 5.3/22.1's confirmation half: `docs/*` — a real historical cell value
69
+ // — is a GLOB, and `git check-ref-format` refuses it as a branch name (a `*`
70
+ // is not legal in a ref). A plain path like `docs/QUEUE.md` is, frustratingly,
71
+ // ALSO a syntactically valid ref name (slashes and dots are both legal), so
72
+ // this catches the glob shape precisely and does not claim to catch every
73
+ // path — an honest partial fix is the property this task's own title asks
74
+ // for ("every classifier can say I cannot tell"), not a heuristic that
75
+ // pretends to be exhaustive.
76
+ // Pure syntax check — never needs to run INSIDE a repo, so no `cwd` (unlike
77
+ // every other helper here, which resolves refs against `repo`'s history).
78
+ const isSyntacticallyValidRef = (name: string): boolean => {
79
+ try {
80
+ execFileSync("git", ["check-ref-format", "--branch", name], { stdio: ["ignore", "pipe", "ignore"] });
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ };
86
+
68
87
  /** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
69
88
  export function refInCell(cell: string): string {
70
89
  return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
@@ -91,6 +110,19 @@ export function classifyBoardRef(
91
110
 
92
111
  const bare = raw.replace(/^origin\//, "");
93
112
 
113
+ // Task 5.3/22.1: CHECKED FIRST AND UNCONDITIONALLY — the original defect
114
+ // (`docs/*`) was previously only caught in a repo with NO origin remote at
115
+ // all (further down); confirmed live that in a repo WITH an origin — the
116
+ // normal case for every worktree this fleet actually runs — the same
117
+ // value fell through to the "unpushed" branch instead and was reported as
118
+ // "the branch has not been pushed", which is a confidently WRONG diagnosis
119
+ // for a path. `git check-ref-format` answers "could this even syntactically
120
+ // be a branch name" without touching the repo's history at all, so a glob
121
+ // is caught regardless of what else is true about this checkout.
122
+ if (!isSyntacticallyValidRef(bare)) {
123
+ return { kind: "path", why: `'${raw}' is not a syntactically valid branch name — it is a path or glob` };
124
+ }
125
+
94
126
  // A SHARED REF FIRST, because `main` resolves and would otherwise be measured.
95
127
  // Named explicitly rather than inferred from scoping, so the reason a reader
96
128
  // gets is the real one.
@@ -152,11 +184,24 @@ export function classifyBoardRef(
152
184
  `whether it is measurable depends on who last ran \`git fetch\`, which is a property of the reader rather than of the work.`,
153
185
  };
154
186
  }
187
+ // Task 5.3/22.2's fourth cause: "a pushed branch absent from this
188
+ // checkout" is NOT the same fact as "never pushed", and this check has
189
+ // no way to tell them apart without a network call it deliberately does
190
+ // not make (every other verdict here is a local git op only — adding
191
+ // one here would trade the offline property for one more cause). SAYING
192
+ // SO is the fix Task 22's own title asks for: a classifier that names
193
+ // an ABSENCE of evidence as a specific, confident cause is the exact
194
+ // shape "it is a path or glob" was wrong in — just moved one branch
195
+ // over. `'${remote}' does not exist` is the one fact this DOES know;
196
+ // everything past it is now framed as what it cannot distinguish rather
197
+ // than a guess dressed as a finding.
155
198
  return {
156
199
  kind: "unpushed",
157
200
  why:
158
- `'${remote}' does not exist — the branch has not been pushed, so there is no shared evidence of activity yet. ` +
159
- `That is an ABSENCE of evidence on a new lane, not a stall.`,
201
+ `'${remote}' does not resolve in THIS checkout either the branch has never been pushed, or it WAS pushed and this ` +
202
+ `checkout's remote-tracking ref is simply stale (nobody has run \`git fetch\` here since). Cannot distinguish the two ` +
203
+ `without asking the remote, which this check deliberately does not do. Either way there is no LOCAL evidence of ` +
204
+ `activity, so it is not scored as a stall.`,
160
205
  };
161
206
  }
162
207