agent-coord-mcp 0.26.18 → 0.26.20
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/README.md +82 -0
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.js +214 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/prefix.js +64 -0
- package/dist/prefix.js.map +1 -0
- package/dist/roles.js +132 -0
- package/dist/roles.js.map +1 -0
- package/dist/server-identity.js +82 -0
- package/dist/server-identity.js.map +1 -0
- package/dist/server.js +668 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +553 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/admin.js +323 -0
- package/dist/tools/admin.js.map +1 -0
- package/dist/tools/attention.js +73 -0
- package/dist/tools/attention.js.map +1 -0
- package/dist/tools/away.js +285 -0
- package/dist/tools/away.js.map +1 -0
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- package/dist/tools/events.js +234 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/logwatch.js +85 -0
- package/dist/tools/logwatch.js.map +1 -0
- package/dist/tools/messaging.js +706 -0
- package/dist/tools/messaging.js.map +1 -0
- package/dist/tools/record-events.js +380 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/records.js +1205 -0
- package/dist/tools/records.js.map +1 -0
- package/dist/tools/registry.js +529 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/render.js +2 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/rooms.js +210 -0
- package/dist/tools/rooms.js.map +1 -0
- package/dist/tools/rotate.js +143 -0
- package/dist/tools/rotate.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +104 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/stall.js +415 -0
- package/dist/tools/stall.js.map +1 -0
- package/dist/tools/transport.js +1918 -0
- package/dist/tools/transport.js.map +1 -0
- package/dist/tools/work.js +465 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/tools/worktrees.js +581 -0
- package/dist/tools/worktrees.js.map +1 -0
- package/dist/transports/config.js +82 -0
- package/dist/transports/config.js.map +1 -0
- package/dist/transports/index.js +113 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/transports/tmux.js +140 -0
- package/dist/transports/tmux.js.map +1 -0
- package/dist/transports/types.js +86 -0
- package/dist/transports/types.js.map +1 -0
- package/dist/typed-records.js +174 -0
- package/dist/typed-records.js.map +1 -0
- package/dist/work.js +2 -0
- package/dist/work.js.map +1 -0
- package/hooks/tmux-pusher.mjs +33 -3
- package/package.json +1 -1
- package/src/capabilities.ts +104 -1
- package/src/server.ts +30 -1
- package/src/tools/records.ts +221 -34
- package/src/tools/registry.ts +36 -5
- package/src/tools/shared.ts +12 -36
- package/src/tools/stall.ts +2 -1
- package/src/tools/transport.ts +96 -43
- package/src/tools/work.ts +95 -3
- package/src/tools/worktrees.ts +234 -32
- package/src/transports/config.ts +110 -0
- package/src/transports/index.ts +126 -0
- package/src/transports/tmux.ts +177 -0
- package/src/transports/types.ts +201 -0
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -288,7 +288,11 @@ function readJsonl(file) {
|
|
|
288
288
|
// Allowlisted control commands the bus may type into this CLI. Mirrors
|
|
289
289
|
// CONTROL_COMMANDS in src/tools/transport.ts and scripts/coord-pusher.mjs
|
|
290
290
|
// (test/allowlist-parity.test.mjs locks the three); kept tiny + literal so a bugged/compromised
|
|
291
|
-
// sender can't smuggle an arbitrary slash command
|
|
291
|
+
// sender can't smuggle an arbitrary slash command onto the RAW submit path.
|
|
292
|
+
// ⚠ THIS SET IS THE GATE. It used to be backed by a second check that dropped any
|
|
293
|
+
// text starting with "/"; that check was removed in ⟨q-f14692ca⟩ because it silently
|
|
294
|
+
// discarded legitimate messages and never saw the multi-line shape it was aimed at.
|
|
295
|
+
// Membership here, plus bracketed paste on the peer path, is now the whole defence.
|
|
292
296
|
const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
|
|
293
297
|
|
|
294
298
|
// Shells we refuse to inject into: if the pane's foreground command is one of
|
|
@@ -321,9 +325,35 @@ function isControl(m) {
|
|
|
321
325
|
function shouldInject(m) {
|
|
322
326
|
if (!m || m.from === AGENT_ID) return false;
|
|
323
327
|
if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
|
|
324
|
-
// Authorized control command —
|
|
328
|
+
// Authorized control command — injected RAW later, so the allowlist in
|
|
329
|
+
// `isControl` is the gate that matters. Checked first and unchanged.
|
|
325
330
|
if (isControl(m)) return true;
|
|
326
|
-
|
|
331
|
+
|
|
332
|
+
// ⛔ THE SLASH DROP USED TO LIVE HERE AND IT IS GONE ON PURPOSE (⟨q-f14692ca⟩).
|
|
333
|
+
//
|
|
334
|
+
// It returned false for any text starting with "/", which SILENTLY discarded
|
|
335
|
+
// the message: the sender was told nothing, and `collectSource` advances the
|
|
336
|
+
// offset past rejected lines, so it was never retried either. Measured in an
|
|
337
|
+
// isolated pane: a plain message and a trailing message both arrived, the
|
|
338
|
+
// slash-leading one between them did not, and the pusher log said nothing.
|
|
339
|
+
//
|
|
340
|
+
// AND IT WAS PROTECTING NOTHING THAT IS NOT ALREADY PROTECTED TWICE, measured
|
|
341
|
+
// rather than reasoned:
|
|
342
|
+
// 1. `formatBatch` prefixes every peer line with ` [DM hh:mm from] `, so a
|
|
343
|
+
// single-line "/foo" reaches the pane with ZERO lines starting with "/" —
|
|
344
|
+
// it is chat text, not a command.
|
|
345
|
+
// 2. Ordinary batches paste with `bracketed = true`, which makes the whole
|
|
346
|
+
// payload inert: embedded newlines cannot submit a line or smuggle a
|
|
347
|
+
// "/command". That is the defence that actually covers the multi-line
|
|
348
|
+
// case, which this check never caught — `trimStart()` only ever examined
|
|
349
|
+
// the FIRST line, so "opener\n/clear" passed straight through it.
|
|
350
|
+
//
|
|
351
|
+
// So the old guard was wrong in both directions at once: it dropped safe
|
|
352
|
+
// single-line messages, and it did not see the multi-line shape it was aimed
|
|
353
|
+
// at. Removing it restores delivery; the protection that matters is the
|
|
354
|
+
// allowlist above plus bracketed paste, and BOTH are pinned by tests in
|
|
355
|
+
// test/slash-message-delivery.test.mjs. ⚠ If anyone makes the peer path paste
|
|
356
|
+
// raw, those tests fail — which is the price of not having this check.
|
|
327
357
|
return true;
|
|
328
358
|
}
|
|
329
359
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.20",
|
|
4
4
|
"description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/capabilities.ts
CHANGED
|
@@ -39,6 +39,14 @@ import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
|
39
39
|
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
40
|
import { recordAuthorityFor } from "./roles.js";
|
|
41
41
|
import { subscriptionHealth } from "./tools/events.js";
|
|
42
|
+
import {
|
|
43
|
+
configuredTransport,
|
|
44
|
+
runningTransport,
|
|
45
|
+
isTmuxKind,
|
|
46
|
+
targetOf,
|
|
47
|
+
type TransportKind,
|
|
48
|
+
} from "./transports/index.js";
|
|
49
|
+
import { readAllTransportMarkers } from "./tools/registry.js";
|
|
42
50
|
|
|
43
51
|
export type ProbeResult = {
|
|
44
52
|
id: string;
|
|
@@ -129,6 +137,44 @@ const PROBES: Probe[] = [
|
|
|
129
137
|
},
|
|
130
138
|
];
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* TWO CLAIMS, AND THE TYPE MAKES YOU CARRY BOTH (Phase 5.4 Task 3.3).
|
|
142
|
+
*
|
|
143
|
+
* *A config value is a label someone typed.* `configured` is that label;
|
|
144
|
+
* `running` is answered by CALLING the transport in this process. They are
|
|
145
|
+
* separate fields because they are separate facts, and `agrees` exists so a
|
|
146
|
+
* reader cannot skim one and believe the other.
|
|
147
|
+
*
|
|
148
|
+
* ⛔ THIS IS THE MACHINE-READABLE FORM OF A MISTAKE MADE IN PROSE. An hour
|
|
149
|
+
* before this shipped, a Task 2 contract asserted that "every seat in this fleet
|
|
150
|
+
* is running on the code you are refactoring." Measured afterwards: every server
|
|
151
|
+
* and every pusher loads the global install at 0.26.19 while `main` declared
|
|
152
|
+
* 0.26.20 — the fleet was running PRE-refactor code and always had been. The
|
|
153
|
+
* claim came from what the repo said, not from what any process answered. That
|
|
154
|
+
* is `configured` reported as `running`, in English instead of in a type.
|
|
155
|
+
*
|
|
156
|
+
* `running` is `undefined` when nothing is wired, which is NOT "tmux by
|
|
157
|
+
* default": a process with no transport delivers nothing, and substituting a
|
|
158
|
+
* default there would restage the same substitution one layer down.
|
|
159
|
+
*/
|
|
160
|
+
export type TransportCapability = {
|
|
161
|
+
configured: TransportKind;
|
|
162
|
+
/** Answered by calling the transport, never by reading config. */
|
|
163
|
+
running: TransportKind | undefined;
|
|
164
|
+
agrees: boolean;
|
|
165
|
+
/** What was called and what came back. */
|
|
166
|
+
evidence: string;
|
|
167
|
+
/** Where `configured` came from — config file, env, or the built-in default. */
|
|
168
|
+
configuredSource: "config" | "env" | "default";
|
|
169
|
+
/**
|
|
170
|
+
* MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
|
|
171
|
+
* than the running one. Whole-fleet is the rule; this is the code noticing
|
|
172
|
+
* when reality disagrees with the rule rather than trusting it — at 2-of-5
|
|
173
|
+
* stall coverage a silent disagreement is a failure nobody can see.
|
|
174
|
+
*/
|
|
175
|
+
disagreeingAgents: { agentId: string; marker: string }[];
|
|
176
|
+
};
|
|
177
|
+
|
|
132
178
|
export type CapabilityReport = {
|
|
133
179
|
/**
|
|
134
180
|
* WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
|
|
@@ -140,6 +186,8 @@ export type CapabilityReport = {
|
|
|
140
186
|
probes: ProbeResult[];
|
|
141
187
|
missing: string[];
|
|
142
188
|
ok: boolean;
|
|
189
|
+
/** Absent only if the probe itself threw — see `probeTransport`. */
|
|
190
|
+
transport?: TransportCapability;
|
|
143
191
|
note: string;
|
|
144
192
|
};
|
|
145
193
|
|
|
@@ -182,7 +230,62 @@ import { resolveServerIdentity } from "./server-identity.js";
|
|
|
182
230
|
|
|
183
231
|
export const capabilitiesSchema = {} as const;
|
|
184
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Build the transport capability by CALLING things, then compare.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately async and deliberately separate from `probeCapabilities`, which
|
|
237
|
+
* is synchronous: the transport answer requires I/O (a `tmux -V`, a pane probe),
|
|
238
|
+
* and making the sync report do I/O to obtain it would have meant reading the
|
|
239
|
+
* config instead — which is the substitution this whole field exists to refuse.
|
|
240
|
+
*/
|
|
241
|
+
export async function probeTransport(): Promise<TransportCapability> {
|
|
242
|
+
const conf = configuredTransport();
|
|
243
|
+
const running = await runningTransport();
|
|
244
|
+
|
|
245
|
+
// 3.4 — a mixed fleet must be detectable and LOUD. Every marker on disk is
|
|
246
|
+
// read and any that names a different transport is listed by agent, because a
|
|
247
|
+
// count alone tells you something is wrong and not where to look.
|
|
248
|
+
const disagreeingAgents: { agentId: string; marker: string }[] = [];
|
|
249
|
+
try {
|
|
250
|
+
// READ-ONLY on purpose. The reaping loader would delete markers it judged
|
|
251
|
+
// not-live, so a diagnostic would mutate the fleet it is describing — and it
|
|
252
|
+
// would hide the remote kind, whose liveness needs a registry heartbeat.
|
|
253
|
+
for (const { marker, live } of await readAllTransportMarkers()) {
|
|
254
|
+
if (running.kind !== undefined && marker.transport !== running.kind) {
|
|
255
|
+
const where = isTmuxKind(marker.transport) ? targetOf(marker) : undefined;
|
|
256
|
+
disagreeingAgents.push({
|
|
257
|
+
agentId: marker.agentId,
|
|
258
|
+
marker: `${marker.transport}${where ? ` (${where})` : ""}${live ? "" : " [stale]"}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// An unreadable transports dir is not evidence of a uniform fleet. Left
|
|
264
|
+
// empty, and the caller can see `running` was still answered.
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
configured: conf.kind,
|
|
269
|
+
running: running.kind,
|
|
270
|
+
agrees: running.kind === conf.kind,
|
|
271
|
+
evidence: running.evidence,
|
|
272
|
+
configuredSource: conf.source,
|
|
273
|
+
disagreeingAgents,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
export async function capabilitiesTool() {
|
|
186
278
|
const id = resolveServerIdentity();
|
|
187
|
-
|
|
279
|
+
const report = probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
280
|
+
try {
|
|
281
|
+
return { ...report, transport: await probeTransport() };
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
|
|
284
|
+
// refuses at startup by design, and this verb is exactly what an operator
|
|
285
|
+
// reaches for to find out why — so it must still answer, and say what threw.
|
|
286
|
+
return {
|
|
287
|
+
...report,
|
|
288
|
+
transportError: `transport probe threw: ${(e as Error).message}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
188
291
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { initTransportFromConfig } from "./transports/index.js";
|
|
3
4
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
4
5
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -96,6 +97,8 @@ import {
|
|
|
96
97
|
ensureWorktreeTool,
|
|
97
98
|
refreshWorktreesSchema,
|
|
98
99
|
refreshWorktreesTool,
|
|
100
|
+
releaseWorktreeSchema,
|
|
101
|
+
releaseWorktreeTool,
|
|
99
102
|
claimSchema,
|
|
100
103
|
claimTool,
|
|
101
104
|
landSchema,
|
|
@@ -659,6 +662,12 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
659
662
|
refreshWorktreesSchema,
|
|
660
663
|
gate(null, refreshWorktreesTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
661
664
|
);
|
|
665
|
+
addTool(
|
|
666
|
+
"release_worktree",
|
|
667
|
+
"GIVE A WORKTREE BACK — the counterpart to ensure_worktree, which created trees while nothing released them, leaving `git worktree remove`/`prune` as a hand step in five role cards. Identify the tree by `agentId` or `path`. REFUSES the primary checkout (the path everyone already has); REFUSES a DIRTY tree with no override, because uncommitted work is what a diff cannot show and git cannot give back; and REFUSES a tree whose commits cannot be shown to have landed. Landedness is decided by PATCH ID, never ancestry — under squash merge a landed branch's shas never enter the base, so ancestry calls every finished tree mid-slice forever and would refuse exactly the trees that are safe to release. A patch-id negative is INCONCLUSIVE rather than a no, so it refuses and says so; `force:true` releases anyway and is recorded in the result. Removing and pruning are ONE act here, not two lines of prose the caller remembers. Reports by default; pass apply:true to remove.",
|
|
668
|
+
releaseWorktreeSchema,
|
|
669
|
+
gate(null, releaseWorktreeTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
670
|
+
);
|
|
662
671
|
|
|
663
672
|
addTool(
|
|
664
673
|
"next_unblocked",
|
|
@@ -669,7 +678,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
669
678
|
|
|
670
679
|
addTool(
|
|
671
680
|
"claim",
|
|
672
|
-
"Bind a queue item to an agent and produce the
|
|
681
|
+
"Bind a queue item to an agent, ENSURE THE AGENT HAS AN ISOLATED WORKTREE, and produce the \ud83d\udea7 board row. With no itemId it takes next_unblocked. `claim` CALLS `ensure_worktree` rather than telling you to — a worktree that cannot be ensured is a REFUSAL, not a warning, because binding an item to an agent with nowhere isolated to work is the shared-checkout failure this pair exists to prevent. A REUSED tree that is not at origin/<base> is also refused, naming how far behind it is and pointing at `refresh_worktrees`: `claim` means START SOMETHING NEW, and a tree left on last week's base produces confidently-wrong work with nothing about the result looking stale. A freshly created tree is cut from origin/<base> and needs no such check. Returns `worktreeEnsured` with the tree's path and branch, and writes the board cell as the REMOTE-TRACKING ref so the same row reads identically from every checkout.",
|
|
673
682
|
claimSchema,
|
|
674
683
|
gate(null, claimTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
675
684
|
);
|
|
@@ -799,6 +808,26 @@ async function main() {
|
|
|
799
808
|
loadTokenMap(true);
|
|
800
809
|
process.on("SIGHUP", () => loadTokenMap(false));
|
|
801
810
|
|
|
811
|
+
// TRANSPORT, RESOLVED ONCE, BEFORE ANYTHING CAN ATTACH (Phase 5.4 Task 3.1).
|
|
812
|
+
//
|
|
813
|
+
// Here rather than lazily on first use, because an unknown value must REFUSE
|
|
814
|
+
// AT STARTUP: a refusal at first-attach would leave a server that came up
|
|
815
|
+
// healthy and fails one agent, which looks like that agent's problem. It also
|
|
816
|
+
// fixes the value for the process lifetime — whole-fleet, no per-seat
|
|
817
|
+
// branching in send_command or attach.
|
|
818
|
+
try {
|
|
819
|
+
const t = initTransportFromConfig();
|
|
820
|
+
if (t.source !== "default") {
|
|
821
|
+
console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
|
|
822
|
+
}
|
|
823
|
+
} catch (e) {
|
|
824
|
+
// Loud, and fatal. A server that starts with an unusable transport config
|
|
825
|
+
// delivers nothing while reporting itself up, and a quiet transport is the
|
|
826
|
+
// one failure this fleet cannot distinguish from working.
|
|
827
|
+
console.error((e as Error).message);
|
|
828
|
+
process.exit(1);
|
|
829
|
+
}
|
|
830
|
+
|
|
802
831
|
// Transport selector. AGENT_COORD_HTTP_PORT set → run as a long-lived HTTP
|
|
803
832
|
// daemon (Streamable HTTP transport + bearer-token auth). Otherwise the
|
|
804
833
|
// historical stdio behavior (per-client subprocess spawned by Claude Code).
|
package/src/tools/records.ts
CHANGED
|
@@ -70,10 +70,85 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
|
|
|
70
70
|
* Returns what it stamped so the caller can report it rather than leaving an
|
|
71
71
|
* absorbing writer to find it in a diff.
|
|
72
72
|
*/
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
|
|
75
|
+
* that can be ignored is how the silent loss happened in the first place.
|
|
76
|
+
*/
|
|
77
|
+
export class StaleWriteError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
readonly rel: string,
|
|
80
|
+
readonly detail: string,
|
|
81
|
+
readonly alreadyWritten: string[],
|
|
82
|
+
) {
|
|
83
|
+
super(
|
|
84
|
+
`refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
|
|
85
|
+
`Nothing was overwritten. Re-read and retry.` +
|
|
86
|
+
(alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""),
|
|
87
|
+
);
|
|
88
|
+
this.name = "StaleWriteError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What moved, in terms a caller can act on without diffing the file itself. */
|
|
93
|
+
function describeDrift(original: string, current: string): string {
|
|
94
|
+
const ol = original.split("\n");
|
|
95
|
+
const cl = current.split("\n");
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < ol.length && i < cl.length && ol[i] === cl[i]) i++;
|
|
98
|
+
return (
|
|
99
|
+
`${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
|
|
100
|
+
(i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : "")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
|
|
106
|
+
*
|
|
107
|
+
* This function used to compare the rendered text against `original` — the
|
|
108
|
+
* caller's IN-MEMORY snapshot from when it read — and then write. It never looked
|
|
109
|
+
* at the file again. Anything written in between was clobbered with no error, no
|
|
110
|
+
* diff, and nothing in the return value: `{ written: true }` came back for a
|
|
111
|
+
* write that had destroyed someone else's row.
|
|
112
|
+
*
|
|
113
|
+
* Measured before fixing: two writers taking the same original and both
|
|
114
|
+
* committing leave TWO rows where three should be, and the first writer's row is
|
|
115
|
+
* simply gone. This is not hypothetical — four processes write these documents
|
|
116
|
+
* (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
|
|
117
|
+
* ran six times in one afternoon against a queue the aide was editing.
|
|
118
|
+
*
|
|
119
|
+
* So the file is re-read immediately before the write and must still match what
|
|
120
|
+
* the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
|
|
121
|
+
* what moved. A refused write is recoverable; a silent overwrite is not, and the
|
|
122
|
+
* loser of the race never learns it lost.
|
|
123
|
+
*
|
|
124
|
+
* What this does NOT claim: it is not a lock. Two processes can still interleave
|
|
125
|
+
* between this re-read and the `writeFileSync` a few microseconds later. The
|
|
126
|
+
* window goes from "the whole duration of the caller's work" — parsing, git
|
|
127
|
+
* calls, composing a line — down to two adjacent statements. That is a large
|
|
128
|
+
* reduction and not zero, and a lock is the stronger fix if this ever proves
|
|
129
|
+
* insufficient.
|
|
130
|
+
*
|
|
131
|
+
* EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
|
|
132
|
+
* the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
|
|
133
|
+
* read to its write, so no in-process test can interleave them. An end-to-end
|
|
134
|
+
* attempt passed identically with the guard removed, i.e. it proved nothing. A
|
|
135
|
+
* data-loss guard is worth a narrow export to be testable at the level it lives.
|
|
136
|
+
*/
|
|
137
|
+
export function writeDoc(
|
|
138
|
+
repo: string,
|
|
139
|
+
rel: string,
|
|
140
|
+
doc: WorkDoc,
|
|
141
|
+
original: string,
|
|
142
|
+
alreadyWritten: string[] = [],
|
|
143
|
+
): { written: boolean; stamped: string[] } {
|
|
74
144
|
const { text, stamped } = renderWorkDocForWrite(doc);
|
|
75
145
|
if (text === original) return { written: false, stamped: [] };
|
|
76
|
-
|
|
146
|
+
const p = path.join(repo, rel);
|
|
147
|
+
const current = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
148
|
+
if (current !== original) {
|
|
149
|
+
throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
|
|
150
|
+
}
|
|
151
|
+
writeFileSync(p, text);
|
|
77
152
|
return { written: true, stamped };
|
|
78
153
|
}
|
|
79
154
|
|
|
@@ -151,6 +226,15 @@ export function summarize(text: string, max = 96): string {
|
|
|
151
226
|
* the queue SAYS it waits on this", never "nothing waits on this".
|
|
152
227
|
*/
|
|
153
228
|
export function noDownstream(items: QueueItem[]): QueueItem[] {
|
|
229
|
+
// ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
|
|
230
|
+
// asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
|
|
231
|
+
// list, so this re-filter is a no-op on that path; it is kept because this
|
|
232
|
+
// function is EXPORTED and its contract is "given items, which have nothing
|
|
233
|
+
// waiting on them", which must hold for a caller that hands it raw items.
|
|
234
|
+
//
|
|
235
|
+
// The two are not the same predicate and must not be merged: this one is
|
|
236
|
+
// `!done`, while the router's also subtracts the delivery join. Collapsing
|
|
237
|
+
// them would make an exported helper inherit a routing policy.
|
|
154
238
|
const open = items.filter((i) => !i.done);
|
|
155
239
|
const named = new Set<string>();
|
|
156
240
|
for (const i of open) {
|
|
@@ -235,43 +319,53 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
|
|
|
235
319
|
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
236
320
|
* merely MENTIONS it?
|
|
237
321
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
322
|
+
* ⛔ THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
|
|
323
|
+
* appeared ANYWHERE in an entry, on a census that had inverted: today
|
|
324
|
+
* `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
|
|
325
|
+
* house spelling everything else teaches — so the COMPLIANT way to cite an item
|
|
326
|
+
* became the spelling that meant "delivered". Measured before removal, on 143
|
|
327
|
+
* open rows: ELEVEN false positives, ZERO independent true positives (every row
|
|
328
|
+
* it correctly excluded was already excluded by `!i.done`, which runs first), and
|
|
329
|
+
* a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
|
|
330
|
+
* merged under a different row's citation, stayed `[ ]`, and was offered as top
|
|
331
|
+
* P1 twice.
|
|
332
|
+
*
|
|
333
|
+
* ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
|
|
334
|
+
* BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
|
|
335
|
+
* reasons this repo's own canon REQUIRES — a residual gap must cite its queue
|
|
336
|
+
* item — so no reading of that file separates a closure from a mention. Three
|
|
337
|
+
* replacements were measured and each was worse or equal: position does not
|
|
338
|
+
* discriminate (a known-false and a known-true entry are structurally identical,
|
|
339
|
+
* and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
|
|
340
|
+
* id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
|
|
341
|
+
* false positives, because open rows cite refs as EVIDENCE.
|
|
241
342
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
343
|
+
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
344
|
+
* CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
|
|
345
|
+
* delivery from prose: it requires the entry to carry the item's own
|
|
346
|
+
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
347
|
+
* A citation cannot accidentally satisfy it.
|
|
245
348
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* canon (q-912a67e8) produces: a `DONE` entry may name a residual
|
|
253
|
-
* gap but MUST CITE A QUEUE ITEM for it. Obeying that rule wrote an
|
|
254
|
-
* open item's id into `DONE.md`, and the substring join read the
|
|
255
|
-
* citation as delivery — a canon rule and a code path in direct
|
|
256
|
-
* contradiction, where COMPLIANCE with the canon triggered the
|
|
257
|
-
* defect. LIVE on origin/main, not latent: the part (a) entry for
|
|
258
|
-
* q-507e80c4 cites it exactly this way while part (b) is open work.
|
|
349
|
+
* ⚠ IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS — 0 of 143 open, 0 of 9
|
|
350
|
+
* closed — AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
|
|
351
|
+
* code: rows here are closed with `land --write` and the composed line is then
|
|
352
|
+
* OVERWRITTEN with hand-written prose, so the structural record it would match is
|
|
353
|
+
* destroyed within the minute. **Do not re-derive "dead code" from another zero
|
|
354
|
+
* count.** Its input returns the moment a closing entry keeps what `land` wrote.
|
|
259
355
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
356
|
+
* ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
|
|
357
|
+
* cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
|
|
358
|
+
* CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
|
|
359
|
+
* Accepted knowingly — the arm that claimed to cover it did not, on the one live
|
|
360
|
+
* instance it had. Closing such a row is coordinator discipline, not a predicate.
|
|
264
361
|
*/
|
|
265
362
|
function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
|
|
266
363
|
const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
267
364
|
const target = norm(summarize(text)).replace(/…$/, "");
|
|
268
|
-
const sigil = `⟨${id}⟩`;
|
|
269
365
|
return entries.some((e) => {
|
|
270
|
-
const t = String(e.text);
|
|
271
|
-
if (t.includes(sigil)) return true;
|
|
272
366
|
// A leading recorded-id token is stripped before comparing, so an entry
|
|
273
367
|
// written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
|
|
274
|
-
const body = norm(
|
|
368
|
+
const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
|
|
275
369
|
return target.length >= 12 && body.startsWith(target);
|
|
276
370
|
});
|
|
277
371
|
}
|
|
@@ -296,6 +390,9 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
296
390
|
const q = readDoc(repo, QUEUE_DOC);
|
|
297
391
|
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
|
|
298
392
|
const items = queueItemsOf(q.doc);
|
|
393
|
+
// The seam's own count of unticked rows — the number every axis must add back
|
|
394
|
+
// up to. Taken BEFORE any exclusion so it cannot inherit one.
|
|
395
|
+
const parsedOpen = items.filter((i) => !i.done).length;
|
|
299
396
|
|
|
300
397
|
// ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
|
|
301
398
|
// establish that.
|
|
@@ -313,17 +410,66 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
313
410
|
// (Task 21.1) are what make this join reliable — with a content-hash id the
|
|
314
411
|
// board row and the DONE entry stopped matching the moment anyone reworded
|
|
315
412
|
// the item, which is how the memory was lost in the first place.
|
|
316
|
-
|
|
413
|
+
// ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
|
|
414
|
+
// be subtracted from `open` BEFORE any axis ran, so an item it excluded could
|
|
415
|
+
// appear on NO axis by construction — the one guarantee this verb's contract
|
|
416
|
+
// makes is that it never skips silently, and this was the one path that did.
|
|
417
|
+
//
|
|
418
|
+
// MEASURED on the live queue at `a032069`: the file held 143 open rows, the
|
|
419
|
+
// seam parsed all 143, and this verb reported `open: 129`. Its declared axes
|
|
420
|
+
// accounted for ONE. Fourteen rows were outside the router's universe and
|
|
421
|
+
// nothing in the response said so — invisible from the only seat that would
|
|
422
|
+
// notice, because a worker asking for the next item still gets a real one.
|
|
423
|
+
//
|
|
424
|
+
// ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
|
|
425
|
+
// a row is excluded BY a named cause, and the cause is what gets reported.
|
|
426
|
+
const deliveredBy = new Map<string, "board" | "done">();
|
|
317
427
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
318
428
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
319
429
|
const boardText = boardDoc?.text ?? "";
|
|
320
430
|
const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
|
|
321
431
|
const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
|
|
322
432
|
for (const i of items) {
|
|
323
|
-
if (
|
|
433
|
+
if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
434
|
+
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
435
|
+
// routing: both causes exclude, but a live 🚧 row is a different remedy
|
|
436
|
+
// (wait, or ask its owner) from a landed delivery (close the row).
|
|
437
|
+
if (boardHoldsItem(boardRows, i.id, String(i.text))) deliveredBy.set(i.id, "board");
|
|
438
|
+
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, "done");
|
|
324
439
|
}
|
|
325
440
|
|
|
326
|
-
const open = items.filter((i) => !i.done && !
|
|
441
|
+
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
442
|
+
|
|
443
|
+
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
444
|
+
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
445
|
+
// reported silently is the same defect as an incorrect one.
|
|
446
|
+
const delivered = items
|
|
447
|
+
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
448
|
+
.map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
|
|
449
|
+
|
|
450
|
+
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
451
|
+
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
452
|
+
// two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
|
|
453
|
+
// identical row` twice yields `q-c9f3ded8` twice.
|
|
454
|
+
//
|
|
455
|
+
// Everything downstream is keyed by id, so ONE delivery record then excludes
|
|
456
|
+
// BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
|
|
457
|
+
// to `offered: 1`. The accounting still reconciles — both are named — so the
|
|
458
|
+
// self-check above CANNOT catch it, which is exactly why it needs its own axis
|
|
459
|
+
// rather than a flag on `reconciles`.
|
|
460
|
+
//
|
|
461
|
+
// ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
|
|
462
|
+
// filter working as designed; a row reported TWICE cannot be anything but the
|
|
463
|
+
// accounting. It is the one symptom here that does not rest on a count.
|
|
464
|
+
const idCounts = new Map<string, number>();
|
|
465
|
+
for (const i of items) if (!i.done) idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
|
|
466
|
+
const duplicateIds = [...idCounts.entries()]
|
|
467
|
+
.filter(([, n]) => n > 1)
|
|
468
|
+
.map(([id, rows]) => ({
|
|
469
|
+
id,
|
|
470
|
+
rows,
|
|
471
|
+
why: `${rows} open rows share the id ${id} — stable ids are derived from row TEXT, so identical rows collide. Every axis here is keyed by id, so one delivery record excludes all ${rows}. Reword one row to separate them.`,
|
|
472
|
+
}));
|
|
327
473
|
const ranked = open
|
|
328
474
|
.map((i, idx) => ({ i, idx }))
|
|
329
475
|
.sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
|
|
@@ -390,6 +536,26 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
390
536
|
ok: true as const,
|
|
391
537
|
project: args.project,
|
|
392
538
|
open: open.length,
|
|
539
|
+
// ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
|
|
540
|
+
// EXPLAINED and the only thing that ever caught it was someone counting the
|
|
541
|
+
// file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
|
|
542
|
+
// is what routing considered; `excluded` is what the axes account for. When
|
|
543
|
+
// `reconciles` is false, rows have left the universe with no named cause —
|
|
544
|
+
// the exact condition that was previously unobservable from the response.
|
|
545
|
+
//
|
|
546
|
+
// ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
|
|
547
|
+
// because its own bookkeeping is off strands every lane, which is worse than
|
|
548
|
+
// the miscount. The caller gets a real item AND the discrepancy.
|
|
549
|
+
accounting: {
|
|
550
|
+
parsedOpen,
|
|
551
|
+
offered: open.length,
|
|
552
|
+
excluded: delivered.length,
|
|
553
|
+
reconciles: parsedOpen === open.length + delivered.length,
|
|
554
|
+
},
|
|
555
|
+
// A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
|
|
556
|
+
delivered,
|
|
557
|
+
// A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
|
|
558
|
+
duplicateIds,
|
|
393
559
|
next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
|
|
394
560
|
skipped,
|
|
395
561
|
// A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
|
|
@@ -406,6 +572,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
406
572
|
...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
|
|
407
573
|
...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
|
|
408
574
|
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
575
|
+
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
576
|
+
...delivered.map((d) =>
|
|
577
|
+
d.reason === "board"
|
|
578
|
+
? `⏭ not offered — already on the board (${d.id})`
|
|
579
|
+
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
|
|
580
|
+
),
|
|
409
581
|
],
|
|
410
582
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
411
583
|
noDownstream: undiscriminating
|
|
@@ -789,9 +961,14 @@ export async function landTool(args: {
|
|
|
789
961
|
// an entry added to the record model renders through the pinned glyph contract.
|
|
790
962
|
const wrote: string[] = [];
|
|
791
963
|
let stampedNotSupplied: string[] = [];
|
|
964
|
+
// A stale-base refusal is a RESULT, not a crash: the caller needs to know
|
|
965
|
+
// nothing was clobbered, what moved, and what this call had already written
|
|
966
|
+
// before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
|
|
967
|
+
// here so the verb still answers.
|
|
968
|
+
try {
|
|
792
969
|
if (args.write) {
|
|
793
970
|
if (queueChanged) {
|
|
794
|
-
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
|
|
971
|
+
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
|
|
795
972
|
if (w.written) wrote.push(QUEUE_DOC);
|
|
796
973
|
// Rows stamped that this call did not ask to touch. `queueItemId` is the
|
|
797
974
|
// one item the caller supplied, so anything else here is a row that
|
|
@@ -816,8 +993,18 @@ export async function landTool(args: {
|
|
|
816
993
|
return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
|
|
817
994
|
}
|
|
818
995
|
block.entries.push(entry);
|
|
819
|
-
if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
|
|
996
|
+
if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written) wrote.push(DONE_DOC);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
if (e instanceof StaleWriteError) {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: false as const,
|
|
1003
|
+
error: e.message,
|
|
1004
|
+
staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
|
|
1005
|
+
};
|
|
820
1006
|
}
|
|
1007
|
+
throw e;
|
|
821
1008
|
}
|
|
822
1009
|
|
|
823
1010
|
// 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
|