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/dist/server.js +88 -12
- package/dist/server.js.map +1 -1
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/messaging.js +42 -3
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/records.js +114 -8
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +8 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +25 -24
- package/dist/tools/stall.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/check-test-count.mjs +152 -91
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +89 -9
- package/src/tools/board-ref.ts +239 -0
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +122 -8
- package/src/tools/registry.ts +22 -2
- package/src/tools/shared.ts +10 -0
- package/src/tools/stall.ts +38 -24
- package/src/tools/transport.ts +67 -6
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
|
|
136
|
-
//
|
|
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
|
-
// -
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
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,11 +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, 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
|
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
|
|
754
|
-
|
|
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
|
};
|