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/dist/server.js +40 -19
- package/dist/server.js.map +1 -1
- package/dist/tools/board-ref.js +46 -2
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/messaging.js +42 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +103 -11
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +5 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +68 -7
- package/dist/tools/transport.js.map +1 -1
- package/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +38 -18
- package/src/tools/board-ref.ts +47 -2
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +111 -11
- package/src/tools/registry.ts +5 -2
- package/src/tools/shared.ts +10 -0
- package/src/tools/transport.ts +67 -6
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
|
|
139
|
-
//
|
|
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
|
-
// -
|
|
231
|
-
//
|
|
232
|
-
//
|
|
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
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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(",")}
|
|
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
|
-
`
|
|
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
|
|
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.
|
package/src/tools/board-ref.ts
CHANGED
|
@@ -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
|
|
159
|
-
`
|
|
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
|
|
package/src/tools/messaging.ts
CHANGED
|
@@ -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:
|
|
586
|
+
messages: annotated,
|
|
546
587
|
totalNew,
|
|
547
|
-
returned:
|
|
588
|
+
returned: annotated.length,
|
|
548
589
|
room: args.source === "room" ? normalizeRoom(args.room) : undefined,
|
|
549
590
|
...(history ? { history } : {}),
|
|
550
591
|
};
|
package/src/tools/records.ts
CHANGED
|
@@ -22,12 +22,12 @@ import {
|
|
|
22
22
|
doneEntriesOf,
|
|
23
23
|
type QueueItem,
|
|
24
24
|
type WorkDoc,
|
|
25
|
-
|
|
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
|
-
|
|
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.
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
|
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
|
|
768
|
-
|
|
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
|
};
|
package/src/tools/registry.ts
CHANGED
|
@@ -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
|
|
83
|
-
// token (tokens.json / coord-token)
|
|
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`
|
package/src/tools/shared.ts
CHANGED
|
@@ -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,
|
package/src/tools/transport.ts
CHANGED
|
@@ -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
|
|
931
|
-
// token (tokens.json / coord-token)
|
|
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
|
|
1756
|
+
if (!marker) continue;
|
|
1757
|
+
markerByAgent.set(marker.agentId, marker);
|
|
1758
|
+
if (isMarkerLive(marker, reg, now)) live.add(marker.agentId);
|
|
1735
1759
|
}
|
|
1736
|
-
|
|
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
|
|
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
|
|
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.
|