agent-coord-mcp 0.26.15 → 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
@@ -16,6 +16,8 @@ import {
16
16
  getTokenMap,
17
17
  reloadTokenMapSync,
18
18
  sessionFile,
19
+ listSessionFiles,
20
+ readJson,
19
21
  type SessionBinding,
20
22
  } from "./store.js";
21
23
  import {
@@ -106,6 +108,7 @@ import {
106
108
  listWorkTool,
107
109
  exportWorkSchema,
108
110
  exportWorkTool,
111
+ isPidAlive,
109
112
  } from "./tools/index.js";
110
113
 
111
114
  function jsonResult(data: unknown) {
@@ -132,8 +135,13 @@ function jsonResult(data: unknown) {
132
135
  // transport, or another live bound session) — that silently created a
133
136
  // second session acting as an already-running agent (hit live 2026-07-06:
134
137
  // a dev session bound itself to `<project>-liaison`). A live-id claim needs
135
- // the agent's token or an explicit force (join/register params). See
136
- // 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.
137
145
  // - `trackSession` (stdio only): each successful bind writes a
138
146
  // sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
139
147
  // sessions bound to one id — closure state alone cannot be inspected
@@ -146,6 +154,41 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
146
154
  let sessionMarker: string | undefined;
147
155
  let exitHooksInstalled = false;
148
156
 
157
+ // Task 16.1: the stdio half of the leak `#174` closed for HTTP. `#169`
158
+ // shipped an idle-timeout version of this and was HELD after a live test
159
+ // proved the failure mode it risked: killing a session's process out from
160
+ // under a still-attached client is SILENT, NON-RECOVERABLE loss, and idle
161
+ // is not abandoned. This reaps on the same signal `#174` uses instead — a
162
+ // NEW bind for the SAME agent id — which needs no timer and no guess.
163
+ //
164
+ // Why a new bind is PROOF rather than a guess, mirroring `#174`'s
165
+ // `agentSessions` reasoning one process boundary over: `recordSessionBinding`
166
+ // only runs after `guardFirstClaim` has already authorized this claim —
167
+ // tofu (verified the id was NOT live), token (the holder's own credential),
168
+ // force (an explicit override) or same-pane (the same physical seat
169
+ // restarting). In every one of those four paths, whoever controls this
170
+ // agent id has already moved to THIS session, so any other live marker for
171
+ // the same id left over is provably superseded, not merely idle — the
172
+ // exact distinction `#169` could not make with a timer.
173
+ async function reapSupersededStdioSessions(agentId: string): Promise<void> {
174
+ for (const file of await listSessionFiles()) {
175
+ const s = await readJson<SessionBinding | null>(file, null);
176
+ if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
177
+ if (!isPidAlive(s.pid)) continue; // already gone; nothing to signal
178
+ console.error(
179
+ `[agent-coord-mcp] reaping stdio session pid ${s.pid} for agent '${agentId}' — superseded by this session (pid ${process.pid})`,
180
+ );
181
+ try {
182
+ // SIGTERM, not a kill(9): the superseded process's OWN exit hook
183
+ // (below) unlinks its marker and exits 0 — the same graceful path
184
+ // an operator's `quit` or Ctrl-C would take, not a hard kill.
185
+ process.kill(s.pid, "SIGTERM");
186
+ } catch (err) {
187
+ console.error(`[agent-coord-mcp] reap FAILED for pid ${s.pid}:`, err);
188
+ }
189
+ }
190
+ }
191
+
149
192
  // Best-effort: a marker left behind by SIGKILL has a dead pid, which both
150
193
  // the guard's evidence read and doctor's duplicate-session-binding check
151
194
  // treat as garbage (doctor fix deletes it).
@@ -177,6 +220,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
177
220
  process.on(sig, () => { cleanup(); process.exit(0); });
178
221
  }
179
222
  }
223
+ reapSupersededStdioSessions(agentId).catch((err) =>
224
+ console.error("[agent-coord-mcp] stdio supersession reap threw:", err),
225
+ );
180
226
  } catch { /* marker is observability, never worth failing the bind */ }
181
227
  }
182
228
 
@@ -186,9 +232,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
186
232
  // - a presented token must MATCH or the claim fails loudly, even when the
187
233
  // id is not live — a wrong credential silently succeeding via the
188
234
  // not-live path would teach callers that garbage tokens work;
189
- // - force is an explicit human/agent decision, honored before evidence;
190
- // - evidence that exists but cannot be read REFUSES (cannot-verify ≠
191
- // 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);
192
243
  // - a live id refuses, except when its live pusher types into THIS
193
244
  // process's own tmux pane — two sessions cannot share a pane, so that
194
245
  // is the same seat restarting (the routine fleet-restart case), not a
@@ -205,9 +256,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
205
256
  `Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
206
257
  );
207
258
  }
208
- if (args["force"] === true) return "force";
209
259
  const ev = await liveClaimEvidence(claimed, Date.now());
210
260
  if (!ev.verifiable) {
261
+ if (args["force"] === true) return "force";
211
262
  throw new Error(
212
263
  `cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
213
264
  `Refusing to bind rather than treating unreadable evidence as absence. ` +
@@ -216,12 +267,41 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
216
267
  }
217
268
  if (ev.live) {
218
269
  if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
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.
283
+ //
284
+ // A live TRANSPORT marker is a different fact and is not called a leak:
285
+ // that pid is the pusher daemon, doing its job. Only a live SESSION
286
+ // binding that is not this process is a leftover.
287
+ const leaks = ev.leakedSessions ?? [];
288
+ const leakLine = leaks.length
289
+ ? ` LEFTOVER PROCESS${leaks.length > 1 ? "ES" : ""} HOLDING THIS IDENTITY: ` +
290
+ leaks.map((l) => `pid ${l.pid} (via ${l.via})`).join(", ") +
291
+ `. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\`.`
292
+ : "";
219
293
  throw new Error(
220
- `agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it. ` +
221
- `If you ARE '${claimed}' restarting, re-join from its tmux pane, or pass its token or force:true (join/register). ` +
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
+ `.` +
297
+ leakLine +
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" : ""}. ` +
222
301
  `If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
223
302
  );
224
303
  }
304
+ if (args["force"] === true) return "force";
225
305
  return "tofu";
226
306
  }
227
307
 
@@ -289,7 +369,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
289
369
 
290
370
  addTool(
291
371
  "join",
292
- "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`.",
293
373
  joinSchema,
294
374
  // join explicitly sets the session binding when unset, so each agent can
295
375
  // declare its identity via join rather than relying on env vars.
@@ -0,0 +1,239 @@
1
+ /*
2
+ * IS THIS BOARD CELL'S REF A PER-AGENT ACTIVITY SIGNAL?
3
+ *
4
+ * One classifier, used by the CHECKER that reads the cell and the VERB that
5
+ * writes it. A rule enforced at the read and not the write is a rule the write
6
+ * defeats: `claim` writes these rows, so if a bad ref is refusable by
7
+ * `stall_check` and writable by `claim`, the verb the fleet is being pushed
8
+ * toward becomes the thing that manufactures the bad row.
9
+ *
10
+ * FOUR WAYS THIS ONE CELL HAS BEEN WRONG, all measured on this fleet:
11
+ *
12
+ * `docs/*` a PATH — refused today, correctly
13
+ * `main` a SHARED ref — resolved, and reported the
14
+ * whole fleet's activity as one agent's, so the
15
+ * row could never stall
16
+ * `worker-3/task13-…` LOCAL-ONLY — resolved for its author and for
17
+ * nobody else, so the same board read
18
+ * differently from two checkouts
19
+ * `worker-1/task16-3-…` MERGED — resolves everywhere and is FROZEN by
20
+ * construction, so it reports an ever-growing
21
+ * stall on a lane that is finished
22
+ *
23
+ * THREE OF THOSE FOUR RESOLVED CLEANLY. So the test cannot be "does it
24
+ * resolve" — that check passed on every bad case except the path. The property
25
+ * is narrower and it is the one thing all four violate:
26
+ *
27
+ * THE CELL MUST NAME A REF WHOSE MOVEMENT IS THIS AGENT'S WORK.
28
+ *
29
+ * Which decomposes into three questions git can actually answer:
30
+ *
31
+ * scoped does the ref name begin with this agent's id? That is the ONLY
32
+ * per-agent signal this repo carries. Author cannot serve: measured
33
+ * on 60 commits of `origin/main`, every one is authored "David
34
+ * Balzan", so an `--author` filter would match every agent on every
35
+ * ref forever WHILE LOOKING SCOPED.
36
+ * shared is it resolvable from ANY checkout, not just the one that happens
37
+ * to have fetched it? That means the remote-tracking form.
38
+ * live is it still ahead of the base? A merged branch's ref never moves
39
+ * again, so measuring it produces a number that only grows.
40
+ *
41
+ * WHAT THIS DELIBERATELY DOES NOT DO: guess. Every rejection is reported with
42
+ * the reason and the population it judged, and an UNPUSHED branch is called
43
+ * unpushed rather than stalled — a freshly claimed lane has no shared evidence
44
+ * yet, and saying "stalled" there would be the manufactured-narrowing error
45
+ * pointed at a new lane.
46
+ */
47
+ import { execFileSync } from "node:child_process";
48
+
49
+ export type BoardRefVerdict =
50
+ | { kind: "measurable"; ref: string }
51
+ | { kind: "empty"; why: string }
52
+ | { kind: "path"; why: string }
53
+ | { kind: "shared"; why: string }
54
+ | { kind: "unscoped"; why: string }
55
+ | { kind: "local-only"; why: string }
56
+ | { kind: "unpushed"; why: string }
57
+ | { kind: "merged"; why: string };
58
+
59
+ const git = (repo: string, args: string[]): string | null => {
60
+ try {
61
+ return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+ const resolves = (repo: string, ref: string): boolean => !!git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
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
+
87
+ /** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
88
+ export function refInCell(cell: string): string {
89
+ return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
90
+ }
91
+
92
+ /** The remote-tracking form a board cell should name for `branch`. */
93
+ export const boardRefFor = (branch: string): string => (branch.startsWith("origin/") ? branch : `origin/${branch}`);
94
+
95
+ /**
96
+ * Classify one cell for one agent.
97
+ *
98
+ * `base` is the ref a merged branch would be contained in — `origin/main` by
99
+ * convention, passed rather than assumed so a consumer fleet on another default
100
+ * branch is not silently misjudged.
101
+ */
102
+ export function classifyBoardRef(
103
+ repo: string,
104
+ agentId: string,
105
+ cell: string,
106
+ base = "origin/main",
107
+ ): BoardRefVerdict {
108
+ const raw = refInCell(cell);
109
+ if (!raw) return { kind: "empty", why: "no ref in the Branch · Worktree cell" };
110
+
111
+ const bare = raw.replace(/^origin\//, "");
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
+
126
+ // A SHARED REF FIRST, because `main` resolves and would otherwise be measured.
127
+ // Named explicitly rather than inferred from scoping, so the reason a reader
128
+ // gets is the real one.
129
+ const shared = new Set(["main", "master", "develop", "trunk", "HEAD"]);
130
+ if (shared.has(bare)) {
131
+ return {
132
+ kind: "shared",
133
+ why:
134
+ `'${raw}' is a SHARED branch — every agent merges into it, so its movement is the fleet's activity and not '${agentId}'s. ` +
135
+ `A row pointing here can never stall, which is a manufactured green rather than a measurement.`,
136
+ };
137
+ }
138
+
139
+ // SCOPED BY NAME — but only refusing a ref that belongs to a DIFFERENT agent,
140
+ // never one that is merely unconventional.
141
+ //
142
+ // Authorship cannot carry ownership here (every commit in this repo is
143
+ // authored by the same person, so `--author` would match every agent on every
144
+ // ref while looking scoped), which leaves the branch-naming convention
145
+ // `<agentId>/<slice>`. But REQUIRING that convention would make a repo that
146
+ // does not use it universally blind — and a narrowing that disables the check
147
+ // everywhere is worse than the defect it fixes. So a plain `feature-x` is
148
+ // allowed and `<someone-else>/x` is refused: the second is positively
149
+ // someone else's work, the first is only unlabelled.
150
+ const owner = bare.includes("/") ? bare.slice(0, bare.indexOf("/")) : null;
151
+ if (owner && owner !== agentId && /^[\w.-]+-(worker|aide|qa|ci|coordinator)(-\d+)?$/.test(owner)) {
152
+ return {
153
+ kind: "unscoped",
154
+ why:
155
+ `'${raw}' is scoped to '${owner}', not '${agentId}' — its movement is that agent's work. ` +
156
+ `A per-agent signal needs a ref only this agent writes, and the branch name is the only ownership marker this repo carries.`,
157
+ };
158
+ }
159
+
160
+ const remote = `origin/${bare}`;
161
+ const remoteResolves = resolves(repo, remote);
162
+ const localResolves = resolves(repo, bare);
163
+
164
+ // NO SHARED FORM CAN EXIST, so the local ref is the best evidence there is.
165
+ // Without this a repo with no `origin` remote would report every row
166
+ // unmeasurable forever — the check disabled everywhere by a rule meant to
167
+ // make it honest. Preferring the shared form is right; requiring one that
168
+ // cannot exist is not.
169
+ if (!remoteResolves && !resolves(repo, "origin/HEAD") && git(repo, ["remote", "get-url", "origin"]) === null) {
170
+ if (!localResolves) {
171
+ return { kind: "path", why: `'${raw}' does not resolve as a git ref in a repo with no remote — it is a path or glob` };
172
+ }
173
+ return { kind: "measurable", ref: bare };
174
+ }
175
+
176
+ if (!remoteResolves) {
177
+ // Local-only vs never-pushed are different facts and only one is a defect
178
+ // in the CELL. Both are unmeasurable, and neither is a stall.
179
+ if (localResolves) {
180
+ return {
181
+ kind: "local-only",
182
+ why:
183
+ `'${raw}' resolves in THIS checkout and not on the remote, so the same board reads differently from another checkout — ` +
184
+ `whether it is measurable depends on who last ran \`git fetch\`, which is a property of the reader rather than of the work.`,
185
+ };
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.
198
+ return {
199
+ kind: "unpushed",
200
+ why:
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.`,
205
+ };
206
+ }
207
+
208
+ // MERGED IS FROZEN. Its ref can never move again, so any age computed from it
209
+ // only grows — an ever-worsening stall on a lane that is finished.
210
+ //
211
+ // `--is-ancestor` ALONE CANNOT SEE IT, and this fleet is the case that proves
212
+ // it: we SQUASH-merge, so the branch's commits never become ancestors of the
213
+ // base. Measured on a branch I knew was merged — `--is-ancestor` said NO
214
+ // while the work was plainly on main. A merged-detector that cannot detect
215
+ // this repo's own merge strategy is inert, which is worse than absent because
216
+ // it reads as covered.
217
+ //
218
+ // `git cherry` compares PATCH IDS, so a squashed single-commit branch reports
219
+ // `-` (already upstream). It is not complete either — squashing several
220
+ // commits into one changes the patch id — so BOTH tests run and either one
221
+ // answering yes is enough. Neither is asked to be sufficient alone.
222
+ if (resolves(repo, base)) {
223
+ const ancestor = git(repo, ["merge-base", "--is-ancestor", remote, base]) !== null;
224
+ const cherry = git(repo, ["cherry", base, remote]);
225
+ const allUpstream =
226
+ cherry !== null && cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
227
+ if (ancestor || allUpstream) {
228
+ return {
229
+ kind: "merged",
230
+ why:
231
+ `'${remote}' has already landed in ${base} (${ancestor ? "an ancestor" : "every commit's patch is upstream — a squash merge"}) — ` +
232
+ `so this ref is FROZEN and can never move again. ` +
233
+ `Measuring it reports a stall that grows forever on a finished lane. The row needs the agent's current slice, or an idle marker.`,
234
+ };
235
+ }
236
+ }
237
+
238
+ return { kind: "measurable", ref: remote };
239
+ }
@@ -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,11 +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, classifyBoardRef } from "./board-ref.js";
30
31
  import { haltState } from "./stall.js";
31
32
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
32
33
 
@@ -146,7 +147,34 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
146
147
  const q = readDoc(repo, QUEUE_DOC);
147
148
  if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
148
149
  const items = queueItemsOf(q.doc);
149
- 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));
150
178
  const ranked = open
151
179
  .map((i, idx) => ({ i, idx }))
152
180
  .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
@@ -340,7 +368,61 @@ export async function claimTool(args: { project: string; agentId: string; itemId
340
368
  };
341
369
  }
342
370
 
343
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
371
+ // THE CELL NAMES THE REMOTE-TRACKING REF, not the bare local branch.
372
+ //
373
+ // A bare name resolves in the checkout that created it and nowhere else, so
374
+ // the same board read differently from two checkouts and whether a row was
375
+ // measurable depended on who last ran `git fetch` — a property of the reader
376
+ // rather than of the work. Writing `origin/<branch>` makes the cell mean the
377
+ // same thing everywhere.
378
+ //
379
+ // It does not resolve YET, because a freshly cut branch is unpushed. That is
380
+ // correct and `stall_check` says so in those words: an unpushed lane has no
381
+ // shared evidence of activity, which is an absence of evidence rather than a
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 |`;
344
426
 
345
427
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
346
428
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -743,15 +825,47 @@ export async function mergeTool(
743
825
  // copied, because a copied grammar is two grammars the moment one is fixed.
744
826
  const ticked = newlyTickedInDiff(f.diff ?? "");
745
827
  if (ticked.size) {
746
- const cited = phaseCitationsIn(f.citationText ?? "");
747
- 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));
748
845
  if (uncited.length) {
749
- 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
+ }
750
864
  return {
751
865
  ok: false as const,
752
866
  error:
753
- `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNCITED: ${names.join(", ")}. ` +
754
- `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.`,
755
869
  verdict,
756
870
  uncited: names,
757
871
  };