agent-coord-mcp 0.26.16 → 0.26.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -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.
@@ -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
 
@@ -14,6 +14,11 @@ import { spawn, spawnSync } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { z } from "zod";
16
16
  import { readLog } from "./logwatch.js";
17
+ // The replay grammar is single-sourced in hooks/replay.mjs and shared with both
18
+ // pushers — see the note at the annotation site below. `hooks/` ships beside
19
+ // `dist/`, so this resolves the same in the built package as in source.
20
+ // @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
21
+ import { priorDeliveries, replayInfo } from "../../hooks/replay.mjs";
17
22
  import { renderRecord } from "./render.js";
18
23
  import path from "node:path";
19
24
  import {
@@ -270,6 +275,21 @@ async function messageIdExists(id: string): Promise<boolean> {
270
275
  return false;
271
276
  }
272
277
 
278
+ // q-314e0187: `process.pid` is this MCP process's own pid — the process
279
+ // writing the entry right now, always known, no lookup needed. It is what a
280
+ // disputed message can be cross-referenced against (sessions/*.json,
281
+ // `ps -p <pid>`) when two live sessions hold one identity. In stdio mode this
282
+ // pid uniquely identifies the session (one process per session); in HTTP mode
283
+ // many sessions can share a process, so it identifies the SERVER handling the
284
+ // call rather than the caller — still strictly more than nothing, and the
285
+ // same scoping `trackSession` already applies to session markers.
286
+ function messageProvenance(): { pid: number; tmuxPane?: string } {
287
+ return {
288
+ pid: process.pid,
289
+ ...(process.env.TMUX_PANE ? { tmuxPane: process.env.TMUX_PANE } : {}),
290
+ };
291
+ }
292
+
273
293
  export async function sendMessageTool(args: {
274
294
  from: string;
275
295
  to?: string;
@@ -375,6 +395,7 @@ export async function sendMessageTool(args: {
375
395
  text,
376
396
  ...(args.record ? { record: args.record } : {}),
377
397
  ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
398
+ provenance: messageProvenance(),
378
399
  };
379
400
  const target = inboxFile(args.to);
380
401
  await appendJsonl(target, msg);
@@ -400,6 +421,7 @@ export async function sendMessageTool(args: {
400
421
  ...(args.kind ? { kind: args.kind } : {}),
401
422
  ...(args.record ? { record: args.record } : {}),
402
423
  ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
424
+ provenance: messageProvenance(),
403
425
  };
404
426
  const target = roomFile(chan);
405
427
  await appendJsonl(target, msg);
@@ -540,11 +562,30 @@ export async function readMessagesTool(args: {
540
562
  ? recent.filter((e) => entryAuthor(e) !== args.agentId)
541
563
  : recent;
542
564
 
565
+ // Phase 5.3 Task 21.2 — ANNOTATE A MESSAGE THAT HAS ALREADY BEEN DELIVERED.
566
+ //
567
+ // The REMOTE pusher (scripts/coord-pusher.mjs) consumes the bus over the wire
568
+ // and cannot read this host's receipts/, so it cannot see for itself that a
569
+ // message it is about to paste was pasted before. Without this, the replay
570
+ // marker would appear in local panes only — HALF THE FLEET, and the half a
571
+ // reader could not identify from inside the pane, which is worse than no
572
+ // marker because its absence would read as "live".
573
+ //
574
+ // The two routes compute the same DATA and share ONE renderer
575
+ // (hooks/replay.mjs `replayMarker`), so neither pane can be handed a
576
+ // different vocabulary for the same fact.
577
+ const priorText = await fsp.readFile(receiptFile(args.agentId), "utf8").catch(() => "");
578
+ const prior = priorDeliveries(priorText);
579
+ const annotated = visible.map((e) => {
580
+ const replay = "id" in e ? replayInfo(prior, (e as Message).id) : undefined;
581
+ return replay ? { ...e, replay } : e;
582
+ });
583
+
543
584
  return {
544
585
  ok: true,
545
- messages: visible,
586
+ messages: annotated,
546
587
  totalNew,
547
- returned: visible.length,
588
+ returned: annotated.length,
548
589
  room: args.source === "room" ? normalizeRoom(args.room) : undefined,
549
590
  ...(history ? { history } : {}),
550
591
  };
@@ -22,12 +22,12 @@ import {
22
22
  doneEntriesOf,
23
23
  type QueueItem,
24
24
  type WorkDoc,
25
- phaseCitationsIn,
25
+ phaseCitationsDetailed,
26
26
  newlyTickedInDiff,
27
27
  sweepTagOf,
28
28
  } from "@davidbalzan/groundwork-seam";
29
29
  import { ensureWorktreeTool } from "./worktrees.js";
30
- import { boardRefFor } from "./board-ref.js";
30
+ import { boardRefFor, classifyBoardRef } from "./board-ref.js";
31
31
  import { haltState } from "./stall.js";
32
32
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
33
33
 
@@ -147,7 +147,34 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
147
147
  const q = readDoc(repo, QUEUE_DOC);
148
148
  if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
149
149
  const items = queueItemsOf(q.doc);
150
- const open = items.filter((i) => !i.done);
150
+
151
+ // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
152
+ // establish that.
153
+ //
154
+ // MEASURED, 2026-09-01: an item whose work had merged in #196 was still `[ ]`
155
+ // in the queue — closing it is a separate act from landing it — so it stayed
156
+ // eligible. A compaction then re-issued its id, it surfaced as the top item,
157
+ // and it was routed back to the worker that had closed it an hour earlier.
158
+ // That worker declined because it recognised its own acceptance criteria:
159
+ // RETAINED CONTEXT, which is not a control and which a `/clear` or a
160
+ // compaction removes silently.
161
+ //
162
+ // So delivery is read from the RECORD instead: an item cited in docs/DONE.md,
163
+ // or already carrying a 🚧 row on the board, has been handed out. Stable ids
164
+ // (Task 21.1) are what make this join reliable — with a content-hash id the
165
+ // board row and the DONE entry stopped matching the moment anyone reworded
166
+ // the item, which is how the memory was lost in the first place.
167
+ const delivered = new Set<string>();
168
+ const doneDoc = readDoc(repo, DONE_DOC);
169
+ const doneText = doneDoc?.text ?? "";
170
+ const boardDoc = readDoc(repo, BOARD_DOC);
171
+ const boardText = boardDoc?.text ?? "";
172
+ for (const i of items) {
173
+ // The id as written, so a recorded id matches the same token in either doc.
174
+ if (doneText.includes(i.id) || boardText.includes(i.id)) delivered.add(i.id);
175
+ }
176
+
177
+ const open = items.filter((i) => !i.done && !delivered.has(i.id));
151
178
  const ranked = open
152
179
  .map((i, idx) => ({ i, idx }))
153
180
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -352,9 +379,50 @@ export async function claimTool(args: { project: string; agentId: string; itemId
352
379
  // It does not resolve YET, because a freshly cut branch is unpushed. That is
353
380
  // correct and `stall_check` says so in those words: an unpushed lane has no
354
381
  // shared evidence of activity, which is an absence of evidence rather than a
355
- // stall. Enforced at the WRITE as well as the read (board-ref.ts) — a rule the
356
- // writer can defeat is a rule the writer defeats.
357
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${boardRefFor(wt.branch ?? "")}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
382
+ // stall.
383
+ const intendedRef = boardRefFor(wt.branch ?? "");
384
+
385
+ /*
386
+ * AND NOW ACTUALLY ENFORCED AT THE WRITE.
387
+ *
388
+ * The comment here used to claim exactly that — "enforced at the WRITE as well
389
+ * as the read, a rule the writer can defeat is a rule the writer defeats" —
390
+ * and it was FALSE. `boardRefFor` PREFIXES `origin/`; it refuses nothing. So
391
+ * `claim` could still write `origin/main` into a board cell, which
392
+ * `stall_check` then correctly refuses to measure, leaving a row that can
393
+ * never stall and never be measured. board-ref.ts's own header states the
394
+ * rule; #197 shipped the read half and I wrote the sentence and did not do it.
395
+ *
396
+ * A rule carrying a false mechanism is worse than an absent rule: the next
397
+ * reader derives from the mechanism, and this one asserted the very coverage
398
+ * it lacked.
399
+ *
400
+ * REFUSED: the four kinds that are structurally wrong however fresh the lane
401
+ * is — a shared ref (measures the fleet), a path (not a ref at all), another
402
+ * agent's branch (measures their work), and an empty cell.
403
+ *
404
+ * ALLOWED: `unpushed` and `local-only`, deliberately. A freshly cut branch is
405
+ * ALWAYS unpushed, so refusing those would refuse every legitimate claim —
406
+ * the over-narrowing I have now made twice in this classifier's history, where
407
+ * a rule meant to make a check honest disabled it instead. `merged` is allowed
408
+ * too but noted, since re-claiming onto a landed branch is unusual rather than
409
+ * structurally broken.
410
+ */
411
+ const refusable = new Set(["shared", "path", "unscoped", "empty"]);
412
+ const cellVerdict = classifyBoardRef(repo, args.agentId, `\`${intendedRef}\``, `origin/${args.base ?? "main"}`);
413
+ if (refusable.has(cellVerdict.kind)) {
414
+ return {
415
+ ok: false as const,
416
+ error:
417
+ `cannot claim: the board cell would name '${intendedRef}', which is not a per-agent activity signal ` +
418
+ `(${cellVerdict.kind}) — ${"why" in cellVerdict ? cellVerdict.why : ""} ` +
419
+ `The row would be unmeasurable the moment it was written, so it is refused here rather than reported later.`,
420
+ item: { id: item.id, priority: item.priority },
421
+ worktree: { path: wt.path, branch: wt.branch },
422
+ };
423
+ }
424
+
425
+ const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
358
426
 
359
427
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
360
428
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -757,15 +825,47 @@ export async function mergeTool(
757
825
  // copied, because a copied grammar is two grammars the moment one is fixed.
758
826
  const ticked = newlyTickedInDiff(f.diff ?? "");
759
827
  if (ticked.size) {
760
- const cited = phaseCitationsIn(f.citationText ?? "");
761
- const uncited = [...ticked].filter((k) => !cited.has(k));
828
+ /*
829
+ * A RELATIONAL CITATION DOES NOT EVIDENCE A TICK.
830
+ *
831
+ * "unblocks Phase 5.2 Task 15.6" names the task without claiming it is done,
832
+ * so a PR that ticks 15.6's box and cites it that way is exactly as
833
+ * unevidenced as one that never mentioned it. Measured on the other side of
834
+ * this grammar the same day: doctor read that sentence in DONE.md as a
835
+ * completion claim and red-gated main.
836
+ *
837
+ * The two cases are REPORTED SEPARATELY, because they need different fixes
838
+ * and "UNCITED" would send someone looking for a citation that is already
839
+ * there. A relational citation needs REWORDING; an absent one needs ADDING.
840
+ */
841
+ const detail = phaseCitationsDetailed(f.citationText ?? "");
842
+ const closes = new Set(detail.filter((c) => c.relation === "claim").map((c) => c.key));
843
+ const relational = new Map(detail.filter((c) => c.relation === "reference").map((c) => [c.key, c.marker]));
844
+ const uncited = [...ticked].filter((k) => !closes.has(k));
762
845
  if (uncited.length) {
763
- const names = uncited.map((k) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; });
846
+ const nameOf = (k: string) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; };
847
+ const names = uncited.map(nameOf);
848
+ const worded = uncited.filter((k) => relational.has(k));
849
+ const missing = uncited.filter((k) => !relational.has(k));
850
+ const parts: string[] = [];
851
+ if (missing.length) {
852
+ parts.push(
853
+ `${missing.length} NOT CITED AT ALL: ${missing.map(nameOf).join(", ")} — name them in the PR title or a commit ` +
854
+ `subject (e.g. "${nameOf(missing[0] as string)}"), the subject being what survives a squash merge`,
855
+ );
856
+ }
857
+ if (worded.length) {
858
+ parts.push(
859
+ `${worded.length} cited only RELATIONALLY: ` +
860
+ worded.map((k) => `${nameOf(k)} (after '${relational.get(k)}')`).join(", ") +
861
+ ` — that names the task without claiming it is done, so it evidences nothing. Reword it, or drop the tick`,
862
+ );
863
+ }
764
864
  return {
765
865
  ok: false as const,
766
866
  error:
767
- `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNCITED: ${names.join(", ")}. ` +
768
- `Once this merges, the tick claims progress that nothing in history evidences. Name them in the PR title or a commit subject (e.g. "${names[0]}") — the subject is what survives a squash merge.`,
867
+ `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNEVIDENCED. ` +
868
+ `${parts.join(". ")}. Once this merges, the tick claims progress nothing in history supports.`,
769
869
  verdict,
770
870
  uncited: names,
771
871
  };
@@ -79,8 +79,11 @@ export const registerSchema = {
79
79
  project: z.string().optional(),
80
80
  role: roleInputSchema.optional(),
81
81
  // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
82
- // that is LIVE on the bus refuses unless the call presents that agent's
83
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
82
+ // that is PROVABLY LIVE on the bus refuses unless the call presents that
83
+ // agent's token (tokens.json / coord-token) — `force` alone no longer
84
+ // overrides a provably live incumbent (q-314e0187). `force` still bypasses
85
+ // the guard when liveness cannot be verified, or the id is verified absent.
86
+ // Both ignored once bound.
84
87
  token: z.string().optional(),
85
88
  force: z.boolean().optional(),
86
89
  // PROSE-ONLY EXEMPTION (Phase 5.1 Task 12.8). `true` grants it, `false`
@@ -182,6 +182,16 @@ export type Message = {
182
182
  // (or any other message) carries the parent message id so consumers can
183
183
  // mark the packet answered. Absent on every v1/v2 message — those stay valid.
184
184
  inReplyTo?: string;
185
+ // PROVENANCE (q-314e0187): the pid of the process that wrote this entry,
186
+ // and its tmux pane when known. `from` names an IDENTITY, not a PROCESS —
187
+ // two live sessions bound to the same agent id write byte-identical `from`
188
+ // values, and that is exactly the condition that let worker-3 truthfully
189
+ // deny sending a message that exists under its name on 2026-08-31: both
190
+ // sessions' accounts were true, and nothing on the message could say which
191
+ // wrote it. Cross-reference against sessions/*.json (SessionBinding) or
192
+ // `ps -p <pid>` to attribute a disputed entry. Absent on every message
193
+ // written before this field existed — that is UNKNOWN, not "same process".
194
+ provenance?: { pid: number; tmuxPane?: string };
185
195
  };
186
196
 
187
197
  // THE retention predicate — one definition, three call sites (prune,
@@ -927,8 +927,11 @@ export const joinSchema = {
927
927
  attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
928
928
  readInbox: z.boolean().optional(),
929
929
  // First-claim guard overrides (server.ts guardFirstClaim): claiming an id
930
- // that is LIVE on the bus refuses unless the call presents that agent's
931
- // token (tokens.json / coord-token) or force:true. Ignored once bound.
930
+ // that is PROVABLY LIVE on the bus refuses unless the call presents that
931
+ // agent's token (tokens.json / coord-token) — `force` alone no longer
932
+ // overrides a provably live incumbent (q-314e0187). `force` still bypasses
933
+ // the guard when liveness cannot be verified, or the id is verified absent.
934
+ // Both ignored once bound.
932
935
  token: z.string().optional(),
933
936
  force: z.boolean().optional(),
934
937
  // Prose-only exemption from the typed-record rule — see registerSchema.
@@ -1724,27 +1727,85 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
1724
1727
  }
1725
1728
 
1726
1729
  // 6. Stale agents (registered, no live transport, heartbeat past EVICT_MS). Report only.
1730
+ //
1731
+ // Task 5.3/22.1: "27 stale" collapsed two different populations that look
1732
+ // identical in a flat heartbeat-age list — 14 agents whose PANE was still
1733
+ // genuinely current (their marker's pid had died, e.g. a `/mcp` reconnect
1734
+ // spawned a new process in the SAME pane, but the pane itself never went
1735
+ // anywhere) and 13 agents that were ORPHANS with no pane behind them at
1736
+ // all, aged 19–45h. An orphan is stale by construction and can NEVER
1737
+ // clear — reporting it beside a merely-slow-heartbeat agent manufactures a
1738
+ // permanent false alarm in the same bucket as a real one.
1739
+ //
1740
+ // THE DISCRIMINATOR: a marker file that still exists on disk (even though
1741
+ // its own pid/heartbeat check already failed `isMarkerLive`) and names a
1742
+ // `tmuxTarget` `tmux has-session` still confirms. `has-session` checks the
1743
+ // PANE, not the recorded pid — so it survives exactly the reconnect shape
1744
+ // above, the same probe `wedged-local-pushers` already uses one direction
1745
+ // over (there: a live pid whose pane died; here: a dead/expired marker
1746
+ // whose pane did not). No marker at all, or a marker whose pane is
1747
+ // confirmed gone, is the only shape left — and that is a genuine orphan.
1727
1748
  {
1728
1749
  // Compute liveness WITHOUT deleting dead markers — loadLiveTransports
1729
1750
  // prunes as a side effect, which would make this read-only check mutate
1730
1751
  // state (and pre-empt the orphan-marker fix in check 1).
1731
1752
  const live = new Set<string>();
1753
+ const markerByAgent = new Map<string, TransportMarker>();
1732
1754
  for (const fname of await listTransportFiles()) {
1733
1755
  const marker = await readJson<TransportMarker | null>(path.join(TRANSPORT_DIR, fname), null);
1734
- if (marker && isMarkerLive(marker, reg, now)) live.add(marker.agentId);
1756
+ if (!marker) continue;
1757
+ markerByAgent.set(marker.agentId, marker);
1758
+ if (isMarkerLive(marker, reg, now)) live.add(marker.agentId);
1735
1759
  }
1736
- const stale: string[] = [];
1760
+ // Without a tmux binary we cannot probe a pane at all — every stale
1761
+ // agent is reported as an orphan candidate rather than silently split,
1762
+ // same posture `wedged-local-pushers` takes.
1763
+ const tmuxAvailable = spawnSync("tmux", ["-V"]).status === 0;
1764
+ const paneAlive = (target: string): boolean =>
1765
+ tmuxAvailable && spawnSync("tmux", ["has-session", "-t", target]).status === 0;
1766
+
1767
+ const paneConfirmed: string[] = [];
1768
+ const orphans: string[] = [];
1737
1769
  for (const [id, a] of Object.entries(reg)) {
1738
1770
  if (live.has(id)) continue;
1739
- if (now - a.lastHeartbeat > EVICT_MS) stale.push(`${id} (${Math.floor((now - a.lastHeartbeat) / 3600000)}h)`);
1771
+ if (now - a.lastHeartbeat <= EVICT_MS) continue;
1772
+ const age = `${Math.floor((now - a.lastHeartbeat) / 3600000)}h`;
1773
+ const marker = markerByAgent.get(id);
1774
+ if (marker?.transport === "tmux-push" && marker.tmuxTarget && paneAlive(marker.tmuxTarget)) {
1775
+ paneConfirmed.push(`${id} (${age}, pane '${marker.tmuxTarget}' still current)`);
1776
+ } else {
1777
+ orphans.push(`${id} (${age})`);
1778
+ }
1740
1779
  }
1780
+ const stale = [...paneConfirmed, ...orphans];
1741
1781
  findings.push({
1742
1782
  check: "stale-agents",
1743
1783
  level: stale.length ? "warn" : "ok",
1744
- detail: stale.length ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them` : "no stale agents",
1784
+ detail: stale.length
1785
+ ? `${stale.length} agent(s) past the eviction window — next list_agents will drop them ` +
1786
+ `(${paneConfirmed.length} pane-confirmed current, ${orphans.length} genuine orphan(s) with no live pane behind them)`
1787
+ : "no stale agents",
1745
1788
  fixable: false,
1746
1789
  items: stale.length ? stale : undefined,
1747
1790
  });
1791
+ // A SEPARATE finding, not a sub-line: "stale-agents" answers "how many
1792
+ // will list_agents drop", which is true of both buckets equally — an
1793
+ // orphan and a pane-confirmed agent are dropped from the SAME list the
1794
+ // same way. "orphan-agents" answers the different question this task is
1795
+ // actually about — which of those, if any, can never clear on their
1796
+ // own — and reports it even when it is empty, so a checker that only
1797
+ // speaks when it fires cannot be told from one that never ran.
1798
+ findings.push({
1799
+ check: "orphan-agents",
1800
+ level: orphans.length ? "warn" : "ok",
1801
+ detail: orphans.length
1802
+ ? `${orphans.length} stale agent(s) have no live tmux pane behind them — permanent, will not clear on their own (unregister or let eviction drop them)`
1803
+ : tmuxAvailable
1804
+ ? `no orphans among ${stale.length} stale agent(s)${stale.length ? " — all pane-confirmed current" : ""}`
1805
+ : "tmux not available — could not distinguish orphans from pane-confirmed stale agents",
1806
+ fixable: false,
1807
+ items: orphans.length ? orphans : undefined,
1808
+ });
1748
1809
  }
1749
1810
 
1750
1811
  // 6b. STALENESS HAS TWO CAUSES AND THEY LOOK IDENTICAL IN A FLAT LIST.