agent-coord-mcp 0.26.16 → 0.26.18
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/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 +40 -20
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/board-ref.ts +47 -2
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +381 -25
- package/src/tools/registry.ts +5 -2
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +28 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +79 -10
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +64 -2
- package/src/work.ts +4 -0
- package/dist/build.js +0 -113
- package/dist/build.js.map +0 -1
- package/dist/capabilities.js +0 -158
- package/dist/capabilities.js.map +0 -1
- package/dist/prefix.js +0 -64
- package/dist/prefix.js.map +0 -1
- package/dist/roles.js +0 -132
- package/dist/roles.js.map +0 -1
- package/dist/server-identity.js +0 -82
- package/dist/server-identity.js.map +0 -1
- package/dist/server.js +0 -625
- package/dist/server.js.map +0 -1
- package/dist/store.js +0 -553
- package/dist/store.js.map +0 -1
- package/dist/tools/admin.js +0 -317
- package/dist/tools/admin.js.map +0 -1
- package/dist/tools/attention.js +0 -73
- package/dist/tools/attention.js.map +0 -1
- package/dist/tools/away.js +0 -243
- package/dist/tools/away.js.map +0 -1
- package/dist/tools/board-ref.js +0 -164
- package/dist/tools/board-ref.js.map +0 -1
- package/dist/tools/event-kinds.js +0 -39
- package/dist/tools/event-kinds.js.map +0 -1
- package/dist/tools/events.js +0 -234
- package/dist/tools/events.js.map +0 -1
- package/dist/tools/index.js +0 -14
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/logwatch.js +0 -85
- package/dist/tools/logwatch.js.map +0 -1
- package/dist/tools/messaging.js +0 -667
- package/dist/tools/messaging.js.map +0 -1
- package/dist/tools/record-events.js +0 -380
- package/dist/tools/record-events.js.map +0 -1
- package/dist/tools/records.js +0 -686
- package/dist/tools/records.js.map +0 -1
- package/dist/tools/registry.js +0 -497
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/render.js +0 -2
- package/dist/tools/render.js.map +0 -1
- package/dist/tools/rooms.js +0 -210
- package/dist/tools/rooms.js.map +0 -1
- package/dist/tools/rotate.js +0 -143
- package/dist/tools/rotate.js.map +0 -1
- package/dist/tools/scopes.js +0 -126
- package/dist/tools/scopes.js.map +0 -1
- package/dist/tools/shared.js +0 -86
- package/dist/tools/shared.js.map +0 -1
- package/dist/tools/stall.js +0 -387
- package/dist/tools/stall.js.map +0 -1
- package/dist/tools/transport.js +0 -1810
- package/dist/tools/transport.js.map +0 -1
- package/dist/tools/work.js +0 -319
- package/dist/tools/work.js.map +0 -1
- package/dist/tools/worktrees.js +0 -339
- package/dist/tools/worktrees.js.map +0 -1
- package/dist/typed-records.js +0 -174
- package/dist/typed-records.js.map +0 -1
- package/dist/work.js +0 -2
- package/dist/work.js.map +0 -1
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
|
@@ -18,17 +18,24 @@ import { z } from "zod";
|
|
|
18
18
|
import {
|
|
19
19
|
parseWorkDoc,
|
|
20
20
|
renderWorkDoc,
|
|
21
|
+
renderWorkDocForWrite,
|
|
21
22
|
queueItemsOf,
|
|
22
23
|
doneEntriesOf,
|
|
23
24
|
type QueueItem,
|
|
24
25
|
type WorkDoc,
|
|
25
|
-
|
|
26
|
+
phaseCitationsDetailed,
|
|
26
27
|
newlyTickedInDiff,
|
|
27
28
|
sweepTagOf,
|
|
29
|
+
awaitingOf,
|
|
30
|
+
refsIn,
|
|
31
|
+
refsMatch,
|
|
32
|
+
workstreamsV1RowsOf,
|
|
33
|
+
type WorkstreamsV1Row,
|
|
34
|
+
type DoneEntry,
|
|
28
35
|
} from "@davidbalzan/groundwork-seam";
|
|
29
36
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
30
|
-
import { boardRefFor } from "./board-ref.js";
|
|
31
|
-
import { haltState } from "./stall.js";
|
|
37
|
+
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
38
|
+
import { haltState, isInFlightStatus } from "./stall.js";
|
|
32
39
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
33
40
|
|
|
34
41
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
@@ -51,11 +58,23 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
|
|
|
51
58
|
* was built, not assumed). Re-rendering an untouched file would still be a write,
|
|
52
59
|
* and a write is a diff someone has to review — so the no-op case returns false.
|
|
53
60
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
/**
|
|
62
|
+
* EVERY SUPPORTED WRITE OF A WORK DOC GOES THROUGH HERE, and it stamps.
|
|
63
|
+
*
|
|
64
|
+
* `renderWorkDocForWrite` records identity for any queue item that lacks it,
|
|
65
|
+
* including rows this caller did not author (q-c50e9b83) — the absorbed raw
|
|
66
|
+
* append is exactly the case, and stamping only the caller's own rows would keep
|
|
67
|
+
* the defect and move the blame. Every id written is the one the item already
|
|
68
|
+
* has, so nothing changes value.
|
|
69
|
+
*
|
|
70
|
+
* Returns what it stamped so the caller can report it rather than leaving an
|
|
71
|
+
* absorbing writer to find it in a diff.
|
|
72
|
+
*/
|
|
73
|
+
function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): { written: boolean; stamped: string[] } {
|
|
74
|
+
const { text, stamped } = renderWorkDocForWrite(doc);
|
|
75
|
+
if (text === original) return { written: false, stamped: [] };
|
|
76
|
+
writeFileSync(path.join(repo, rel), text);
|
|
77
|
+
return { written: true, stamped };
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
const git = (repo: string, args: string[]): string =>
|
|
@@ -67,6 +86,25 @@ function prNumber(pr: string): string | null {
|
|
|
67
86
|
return m ? (m[1] as string) : null;
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
|
|
91
|
+
* the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
|
|
92
|
+
* a closed item's citation to DONE.md. `land` must decide "already cited" with
|
|
93
|
+
* the SAME rule the auditor will apply, or the two disagree again one level
|
|
94
|
+
* down: a bare `#77` in an item body is a tie for doctor only when the origin
|
|
95
|
+
* URL says which repo, and it is a tie here under exactly that condition.
|
|
96
|
+
* Null (no remote, not a repo) means an unknown context, never a guessed one.
|
|
97
|
+
*/
|
|
98
|
+
function originRepoOf(repo: string): string | null {
|
|
99
|
+
try {
|
|
100
|
+
const url = git(repo, ["remote", "get-url", "origin"]);
|
|
101
|
+
const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
|
|
102
|
+
return m ? (m[1] as string) : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
// ---------- blocked / no-downstream ----------
|
|
71
109
|
|
|
72
110
|
const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i;
|
|
@@ -127,6 +165,117 @@ export function noDownstream(items: QueueItem[]): QueueItem[] {
|
|
|
127
165
|
});
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
* DELIVERED — READ THE RECORD'S STRUCTURE, NOT A SUBSTRING OF THE FILE.
|
|
170
|
+
*
|
|
171
|
+
* This was `doneText.includes(id) || boardText.includes(id)` over two whole
|
|
172
|
+
* documents, so ANY MENTION ANYWHERE counted as "handed out" — including a row
|
|
173
|
+
* written specifically to warn that an item must NOT be built (q-2ba7f0c5).
|
|
174
|
+
* The inversion is the finding: the more carefully a coordinator documents why
|
|
175
|
+
* something must not be routed, the more completely it disappears from the verb
|
|
176
|
+
* that routes.
|
|
177
|
+
*
|
|
178
|
+
* MEASURED ON `origin/main` @17b72ff, and the numbers moved the fix twice:
|
|
179
|
+
*
|
|
180
|
+
* q-11257590 hidden by the AIDE'S OWN `🚧 In Progress` ROW, which merely
|
|
181
|
+
* MENTIONS it in its Last note. A status-only rule leaves it
|
|
182
|
+
* hidden — the occurrence-vs-position defect one level in — so
|
|
183
|
+
* identification has to be the row's SUBJECT, not any of its cells.
|
|
184
|
+
* q-ad0fc8d4 hidden by its own `⏸ Returned to the aide unbuilt` row.
|
|
185
|
+
* q-507e80c4 hidden with NO PARSED ROW AT ALL: the id sits in board prose
|
|
186
|
+
* outside the table, which a whole-file substring test cannot
|
|
187
|
+
* distinguish from a lane.
|
|
188
|
+
* q-507e80c4 ALSO hidden by a DONE entry that CITES it as the still-open
|
|
189
|
+
* part (b) of work whose part (a) landed — the canon case
|
|
190
|
+
* q-912a67e8 requires, LIVE today rather than latent.
|
|
191
|
+
*
|
|
192
|
+
* Neither half is fixed by relabelling a row: that is satisfying a guard by
|
|
193
|
+
* editing what it reads (q-507e80c4 constraint 1) and it would destroy the
|
|
194
|
+
* warning the row exists to give.
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
/** The ids a board row names as ITS OWN SUBJECT — the Stream cell only. */
|
|
198
|
+
function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
199
|
+
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Does the board show this item as IN SOMEBODY'S HANDS?
|
|
204
|
+
*
|
|
205
|
+
* Two conditions, and both are needed — measured, not reasoned:
|
|
206
|
+
* STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
|
|
207
|
+
* since #219 rather than a second glyph list). `⏸ Parked`,
|
|
208
|
+
* `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
|
|
209
|
+
* `🔻 Orphaned` describe work nobody holds, so they do not silence.
|
|
210
|
+
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
211
|
+
* text prefix `claim` has always written there. A mention in a Last
|
|
212
|
+
* note is one lane REFERRING to another item — the aide's live row
|
|
213
|
+
* does exactly this — and a status-only rule would still hide it.
|
|
214
|
+
*
|
|
215
|
+
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
216
|
+
* would have shipped a transitional hole in its own negative control: every row
|
|
217
|
+
* `claim` wrote before this change carries `keyOf(item)` — a 60-character text
|
|
218
|
+
* prefix — and no id at all, so an id-only rule leaves a lane's OWN live item
|
|
219
|
+
* routable. Measured: q-2ba7f0c5's row, written minutes earlier by the
|
|
220
|
+
* pre-merge server, was offered back while it was in hand. Rows written from
|
|
221
|
+
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
222
|
+
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
223
|
+
*/
|
|
224
|
+
function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boolean {
|
|
225
|
+
const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
|
|
226
|
+
return rows.some((r) => {
|
|
227
|
+
if (!isInFlightStatus(r.status)) return false;
|
|
228
|
+
if (rowSubjectIds(r).includes(id)) return true;
|
|
229
|
+
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
230
|
+
return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
236
|
+
* merely MENTIONS it?
|
|
237
|
+
*
|
|
238
|
+
* `DONE.md` has no status cells, so the board's answer does not transfer and
|
|
239
|
+
* this needs its own — and the item's own words for what it needs are that "the
|
|
240
|
+
* canon rule must name its citations in a form the join can tell apart".
|
|
241
|
+
*
|
|
242
|
+
* MEASURED ON THE REAL `DONE.md` @17b72ff, which settles which form is which:
|
|
243
|
+
* BRACKETED ids appear ZERO times; all ELEVEN id mentions are BARE, in prose,
|
|
244
|
+
* inside backticks — every one of them a citation. So:
|
|
245
|
+
*
|
|
246
|
+
* DELIVERY the entry carries the item's own COMPOSED SUMMARY — the
|
|
247
|
+
* deterministic form `land` writes, `summarize(item.text)` — or it
|
|
248
|
+
* names the item in the recorded-id sigil `⟨id⟩`, which is this
|
|
249
|
+
* corpus's "this IS that item" marker (it is how QUEUE.md records
|
|
250
|
+
* identity, and a human writing it in DONE.md means exactly that).
|
|
251
|
+
* CITATION a BARE `q-xxxxxxxx` anywhere. This is the form our own shipped
|
|
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.
|
|
259
|
+
*
|
|
260
|
+
* Note what is deliberately NOT the rule: a shared PR ref. `land` never writes
|
|
261
|
+
* the item id into `DONE.md` at all (the summary excludes the `⟨id⟩` token), and
|
|
262
|
+
* the live entry for q-507e80c4 cites `#219` while the item's own line cites no
|
|
263
|
+
* PR — so a ref-tie would have marked a genuinely-open part (b) as delivered.
|
|
264
|
+
*/
|
|
265
|
+
function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
|
|
266
|
+
const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
267
|
+
const target = norm(summarize(text)).replace(/…$/, "");
|
|
268
|
+
const sigil = `⟨${id}⟩`;
|
|
269
|
+
return entries.some((e) => {
|
|
270
|
+
const t = String(e.text);
|
|
271
|
+
if (t.includes(sigil)) return true;
|
|
272
|
+
// A leading recorded-id token is stripped before comparing, so an entry
|
|
273
|
+
// written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
|
|
274
|
+
const body = norm(t).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
|
|
275
|
+
return target.length >= 12 && body.startsWith(target);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
130
279
|
// ---------- next_unblocked ----------
|
|
131
280
|
|
|
132
281
|
export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() };
|
|
@@ -147,7 +296,34 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
147
296
|
const q = readDoc(repo, QUEUE_DOC);
|
|
148
297
|
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
|
|
149
298
|
const items = queueItemsOf(q.doc);
|
|
150
|
-
|
|
299
|
+
|
|
300
|
+
// ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
|
|
301
|
+
// establish that.
|
|
302
|
+
//
|
|
303
|
+
// MEASURED, 2026-09-01: an item whose work had merged in #196 was still `[ ]`
|
|
304
|
+
// in the queue — closing it is a separate act from landing it — so it stayed
|
|
305
|
+
// eligible. A compaction then re-issued its id, it surfaced as the top item,
|
|
306
|
+
// and it was routed back to the worker that had closed it an hour earlier.
|
|
307
|
+
// That worker declined because it recognised its own acceptance criteria:
|
|
308
|
+
// RETAINED CONTEXT, which is not a control and which a `/clear` or a
|
|
309
|
+
// compaction removes silently.
|
|
310
|
+
//
|
|
311
|
+
// So delivery is read from the RECORD instead: an item cited in docs/DONE.md,
|
|
312
|
+
// or already carrying a 🚧 row on the board, has been handed out. Stable ids
|
|
313
|
+
// (Task 21.1) are what make this join reliable — with a content-hash id the
|
|
314
|
+
// board row and the DONE entry stopped matching the moment anyone reworded
|
|
315
|
+
// the item, which is how the memory was lost in the first place.
|
|
316
|
+
const delivered = new Set<string>();
|
|
317
|
+
const doneDoc = readDoc(repo, DONE_DOC);
|
|
318
|
+
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
319
|
+
const boardText = boardDoc?.text ?? "";
|
|
320
|
+
const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
|
|
321
|
+
const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
|
|
322
|
+
for (const i of items) {
|
|
323
|
+
if (boardHoldsItem(boardRows, i.id, String(i.text)) || doneRecordsDelivery(doneEntries, i.id, String(i.text))) delivered.add(i.id);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const open = items.filter((i) => !i.done && !delivered.has(i.id));
|
|
151
327
|
const ranked = open
|
|
152
328
|
.map((i, idx) => ({ i, idx }))
|
|
153
329
|
.sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
|
|
@@ -161,8 +337,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
161
337
|
// Skipped VISIBLY, same discipline as a blocked top item — never silently
|
|
162
338
|
// dropped, so a caller can tell "nothing left for me" from "nothing left".
|
|
163
339
|
const notClaimable: { item: string; sweepTag: string }[] = [];
|
|
340
|
+
// AWAITING A HUMAN (q-e334937d). "Unblocked" means dependency-free, and an
|
|
341
|
+
// item awaiting a ruling is dependency-free and unbuildable at once. Measured
|
|
342
|
+
// 2026-09-02: this verb offered q-11257590 as next; the lane read the
|
|
343
|
+
// acceptance PROSE to learn it awaited David, declined, and picked by hand —
|
|
344
|
+
// a read every lane offered it would pay again. The fact is now a FIELD on
|
|
345
|
+
// the item (`**[AWAITS:<who>]**`, read by the seam), never inferred from
|
|
346
|
+
// prose. Its own axis, checked FIRST: it is the most specific thing that can
|
|
347
|
+
// be true of an item, and "three items wait on a human" is a fact David can
|
|
348
|
+
// act on where "nothing to route" is not. Skipped for routing, never dropped.
|
|
349
|
+
//
|
|
350
|
+
// GATHERED OVER EVERY OPEN ITEM, NOT OVER `open` — measured on the real queue
|
|
351
|
+
// the first time this ran: q-11257590 carried the marker and appeared in NO
|
|
352
|
+
// bucket, because the board holds a "⏸ Parked — awaits David" row naming it
|
|
353
|
+
// and the delivered-join above reads any board mention as "handed out". The
|
|
354
|
+
// row written to WARN about the item was what hid it. Routing still honours
|
|
355
|
+
// the join; this report does not, because its reader is the human the item
|
|
356
|
+
// waits on, and a pending decision does not stop being pending when someone
|
|
357
|
+
// writes it on the board.
|
|
358
|
+
const awaitingDecision: { item: string; id: string; who: string; onBoard: boolean }[] = [];
|
|
359
|
+
for (const i of items) {
|
|
360
|
+
if (i.done) continue;
|
|
361
|
+
const who = awaitingOf(i);
|
|
362
|
+
if (who) awaitingDecision.push({ item: keyOf(i), id: i.id, who, onBoard: boardText.includes(i.id) });
|
|
363
|
+
}
|
|
164
364
|
let pick: QueueItem | null = null;
|
|
165
365
|
for (const { i } of ranked) {
|
|
366
|
+
if (awaitingOf(i)) continue; // reported above; never routed
|
|
166
367
|
const tag = sweepTagOf(i);
|
|
167
368
|
if (tag && /^canon(\.\d+)?$/.test(tag)) {
|
|
168
369
|
notClaimable.push({ item: keyOf(i), sweepTag: tag });
|
|
@@ -196,9 +397,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
196
397
|
// would read a claimability gap as a dependency, which is a different
|
|
197
398
|
// remedy (route it, don't wait for it).
|
|
198
399
|
notClaimable,
|
|
400
|
+
// A THIRD AXIS, and not a variant of either above: `skipped` waits on
|
|
401
|
+
// another ITEM, `notClaimable` is not this caller's to take, and this waits
|
|
402
|
+
// on a HUMAN. The remedy differs each time (wait · route · ask), so one
|
|
403
|
+
// list covering them would leave the reader to infer which applies.
|
|
404
|
+
awaitingDecision,
|
|
199
405
|
boardHunks: [
|
|
200
406
|
...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
|
|
201
407
|
...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
|
|
408
|
+
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
202
409
|
],
|
|
203
410
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
204
411
|
noDownstream: undiscriminating
|
|
@@ -352,9 +559,57 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
352
559
|
// It does not resolve YET, because a freshly cut branch is unpushed. That is
|
|
353
560
|
// correct and `stall_check` says so in those words: an unpushed lane has no
|
|
354
561
|
// shared evidence of activity, which is an absence of evidence rather than a
|
|
355
|
-
// stall.
|
|
356
|
-
|
|
357
|
-
|
|
562
|
+
// stall.
|
|
563
|
+
const intendedRef = boardRefFor(wt.branch ?? "");
|
|
564
|
+
|
|
565
|
+
/*
|
|
566
|
+
* AND NOW ACTUALLY ENFORCED AT THE WRITE.
|
|
567
|
+
*
|
|
568
|
+
* The comment here used to claim exactly that — "enforced at the WRITE as well
|
|
569
|
+
* as the read, a rule the writer can defeat is a rule the writer defeats" —
|
|
570
|
+
* and it was FALSE. `boardRefFor` PREFIXES `origin/`; it refuses nothing. So
|
|
571
|
+
* `claim` could still write `origin/main` into a board cell, which
|
|
572
|
+
* `stall_check` then correctly refuses to measure, leaving a row that can
|
|
573
|
+
* never stall and never be measured. board-ref.ts's own header states the
|
|
574
|
+
* rule; #197 shipped the read half and I wrote the sentence and did not do it.
|
|
575
|
+
*
|
|
576
|
+
* A rule carrying a false mechanism is worse than an absent rule: the next
|
|
577
|
+
* reader derives from the mechanism, and this one asserted the very coverage
|
|
578
|
+
* it lacked.
|
|
579
|
+
*
|
|
580
|
+
* REFUSED: the four kinds that are structurally wrong however fresh the lane
|
|
581
|
+
* is — a shared ref (measures the fleet), a path (not a ref at all), another
|
|
582
|
+
* agent's branch (measures their work), and an empty cell.
|
|
583
|
+
*
|
|
584
|
+
* ALLOWED: `unpushed` and `local-only`, deliberately. A freshly cut branch is
|
|
585
|
+
* ALWAYS unpushed, so refusing those would refuse every legitimate claim —
|
|
586
|
+
* the over-narrowing I have now made twice in this classifier's history, where
|
|
587
|
+
* a rule meant to make a check honest disabled it instead. `merged` is allowed
|
|
588
|
+
* too but noted, since re-claiming onto a landed branch is unusual rather than
|
|
589
|
+
* structurally broken.
|
|
590
|
+
*/
|
|
591
|
+
const refusable = new Set(["shared", "path", "unscoped", "empty"]);
|
|
592
|
+
const cellVerdict = classifyBoardRef(repo, args.agentId, `\`${intendedRef}\``, `origin/${args.base ?? "main"}`);
|
|
593
|
+
if (refusable.has(cellVerdict.kind)) {
|
|
594
|
+
return {
|
|
595
|
+
ok: false as const,
|
|
596
|
+
error:
|
|
597
|
+
`cannot claim: the board cell would name '${intendedRef}', which is not a per-agent activity signal ` +
|
|
598
|
+
`(${cellVerdict.kind}) — ${"why" in cellVerdict ? cellVerdict.why : ""} ` +
|
|
599
|
+
`The row would be unmeasurable the moment it was written, so it is refused here rather than reported later.`,
|
|
600
|
+
item: { id: item.id, priority: item.priority },
|
|
601
|
+
worktree: { path: wt.path, branch: wt.branch },
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// THE ROW NAMES THE ITEM BY ID, so the delivered-join can recognise a row this
|
|
606
|
+
// verb itself wrote. Measured on the live board: `keyOf` is the item's text
|
|
607
|
+
// prefix and the recorded `⟨id⟩` token is not part of `text`, so NO
|
|
608
|
+
// claim-written row has ever carried an id — the join could only ever match
|
|
609
|
+
// HAND-WRITTEN rows, which is the opposite of what it was for. Stamping it
|
|
610
|
+
// makes the identification recorded rather than derived from a 60-character
|
|
611
|
+
// prose prefix that any rewording changes.
|
|
612
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
|
|
358
613
|
|
|
359
614
|
// 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
|
|
360
615
|
// write:true, matching `land`. A returned hunk that a human pastes is the
|
|
@@ -391,6 +646,8 @@ export const landSchema = {
|
|
|
391
646
|
repo: z.string().optional(),
|
|
392
647
|
base: z.string().optional(),
|
|
393
648
|
write: z.boolean().optional(),
|
|
649
|
+
/** One-line result, written after the citation on the closed item's line. */
|
|
650
|
+
result: z.string().optional(),
|
|
394
651
|
};
|
|
395
652
|
|
|
396
653
|
export async function landTool(args: {
|
|
@@ -400,6 +657,7 @@ export async function landTool(args: {
|
|
|
400
657
|
repo?: string;
|
|
401
658
|
base?: string;
|
|
402
659
|
write?: boolean;
|
|
660
|
+
result?: string;
|
|
403
661
|
}) {
|
|
404
662
|
const repo = args.repo ?? process.cwd();
|
|
405
663
|
const base = args.base ?? "main";
|
|
@@ -459,13 +717,46 @@ export async function landTool(args: {
|
|
|
459
717
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
460
718
|
if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
|
|
461
719
|
|
|
462
|
-
// CLOSE = STATUS
|
|
463
|
-
// seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
|
|
464
|
-
// cannot reword an item. That is asserted by a fixture, not by
|
|
720
|
+
// CLOSE = STATUS + CITATION. The item's priority and body BYTES are untouched:
|
|
721
|
+
// the seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
|
|
722
|
+
// closing cannot reword an item. That is asserted by a fixture, not by
|
|
723
|
+
// inspection.
|
|
724
|
+
//
|
|
725
|
+
// AND THE LINE CITES WHAT CLOSED IT (q-6d21ff84). `doctor:queue-done-loop`
|
|
726
|
+
// requires a `[x]` item's FIRST LINE to cite the PR that DONE.md records —
|
|
727
|
+
// that strictness is what makes a closure machine-checkable, and the repo
|
|
728
|
+
// paid for it. This verb ticked the box and wrote DONE.md and left line 1
|
|
729
|
+
// uncited, so EVERY successful `land` turned main red at the next root gate
|
|
730
|
+
// and the coordinator hand-repaired a file the verb had just written
|
|
731
|
+
// (`c29becd`, and again on q-e334937d). The writer and the auditor were built
|
|
732
|
+
// to different contracts; the fix belongs in the writer, because loosening
|
|
733
|
+
// the auditor to accept what the writer emits is how a check stops being one.
|
|
734
|
+
//
|
|
735
|
+
// A SUFFIX, NOT A REWRITE. The existing text is preserved byte-for-byte as a
|
|
736
|
+
// prefix; ` · **closed by <ref>[ — <result>]**` is appended — the exact form
|
|
737
|
+
// q-40449919 carries after its manual repair. Line 1 only, because line 1 is
|
|
738
|
+
// all `item.text` is and all the check reads; a citation on a continuation
|
|
739
|
+
// line satisfies nothing (the occurrence-vs-position trap, hit twice).
|
|
740
|
+
//
|
|
741
|
+
// IDEMPOTENT: an item whose line already cites this PR (in any spelling
|
|
742
|
+
// `refsIn` ties to it) is left byte-identical, so a second `land` cannot
|
|
743
|
+
// double-cite and a hand-cited item is not re-cited.
|
|
465
744
|
let queueChanged = false;
|
|
745
|
+
let citationAppended: string | null = null;
|
|
746
|
+
// The DONE entry and the emitted event describe the WORK, from the text the
|
|
747
|
+
// item had BEFORE the citation was appended — otherwise the summary would
|
|
748
|
+
// carry the citation the DONE line already ends with, twice on one line.
|
|
749
|
+
const originalText = target ? String(target.text) : null;
|
|
466
750
|
if (target) {
|
|
467
751
|
target.done = true;
|
|
468
752
|
queueChanged = true;
|
|
753
|
+
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
754
|
+
const contextRepo = originRepoOf(repo);
|
|
755
|
+
const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
756
|
+
if (!alreadyCited) {
|
|
757
|
+
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
758
|
+
target.text = `${target.text}${citationAppended}`;
|
|
759
|
+
}
|
|
469
760
|
}
|
|
470
761
|
|
|
471
762
|
const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
|
|
@@ -480,7 +771,7 @@ export async function landTool(args: {
|
|
|
480
771
|
// So: the summary comes from the item being closed, and a bare `#N` is reported
|
|
481
772
|
// as UNDER-QUALIFIED rather than silently written.
|
|
482
773
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
483
|
-
const summary =
|
|
774
|
+
const summary = originalText !== null ? summarize(originalText) : null;
|
|
484
775
|
|
|
485
776
|
const doneLine =
|
|
486
777
|
already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
@@ -497,8 +788,17 @@ export async function landTool(args: {
|
|
|
497
788
|
// byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
|
|
498
789
|
// an entry added to the record model renders through the pinned glyph contract.
|
|
499
790
|
const wrote: string[] = [];
|
|
791
|
+
let stampedNotSupplied: string[] = [];
|
|
500
792
|
if (args.write) {
|
|
501
|
-
if (queueChanged
|
|
793
|
+
if (queueChanged) {
|
|
794
|
+
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
|
|
795
|
+
if (w.written) wrote.push(QUEUE_DOC);
|
|
796
|
+
// Rows stamped that this call did not ask to touch. `queueItemId` is the
|
|
797
|
+
// one item the caller supplied, so anything else here is a row that
|
|
798
|
+
// arrived unstamped from somewhere: the absorption this verb would
|
|
799
|
+
// otherwise carry to main under its own name.
|
|
800
|
+
stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
|
|
801
|
+
}
|
|
502
802
|
if (doneLine) {
|
|
503
803
|
const blocks = d.doc.blocks;
|
|
504
804
|
let last = -1;
|
|
@@ -516,7 +816,7 @@ export async function landTool(args: {
|
|
|
516
816
|
return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
|
|
517
817
|
}
|
|
518
818
|
block.entries.push(entry);
|
|
519
|
-
if (writeDoc(repo, DONE_DOC, d.doc, d.text)) wrote.push(DONE_DOC);
|
|
819
|
+
if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
|
|
520
820
|
}
|
|
521
821
|
}
|
|
522
822
|
|
|
@@ -535,7 +835,7 @@ export async function landTool(args: {
|
|
|
535
835
|
if (args.write && target) {
|
|
536
836
|
const after = readDoc(repo, DONE_DOC);
|
|
537
837
|
const recordText = after?.text ?? "";
|
|
538
|
-
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(
|
|
838
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
|
|
539
839
|
const derived = eventIsDerived(recordText, ev);
|
|
540
840
|
if (!derived.ok) {
|
|
541
841
|
events.refused.push(derived.error);
|
|
@@ -553,7 +853,31 @@ export async function landTool(args: {
|
|
|
553
853
|
pr: `#${n}`,
|
|
554
854
|
comparedAgainst: ref,
|
|
555
855
|
landedIn: landedIn.slice(0, 8),
|
|
556
|
-
|
|
856
|
+
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
857
|
+
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
858
|
+
// fixed, so a foreign row cannot pass silently in either direction.
|
|
859
|
+
...(stampedNotSupplied.length
|
|
860
|
+
? {
|
|
861
|
+
stampedNotSupplied,
|
|
862
|
+
stampNote:
|
|
863
|
+
`recorded an id for ${stampedNotSupplied.length} queue item(s) this call did not supply (${stampedNotSupplied.join(", ")}) — ` +
|
|
864
|
+
`they arrived unstamped, most likely a raw text append, and would have reddened doctor:queue-id-recorded on whoever committed next. ` +
|
|
865
|
+
`Each id written is the one the item already had, so no value changed.`,
|
|
866
|
+
}
|
|
867
|
+
: {}),
|
|
868
|
+
queueItem: target
|
|
869
|
+
? {
|
|
870
|
+
id: target.id,
|
|
871
|
+
closed: true,
|
|
872
|
+
// `textUnchanged` keeps its meaning — the priority and the body bytes
|
|
873
|
+
// the item had are all still there, in place. What is new is a SUFFIX
|
|
874
|
+
// on line 1, reported separately so a reader can tell "reworded" from
|
|
875
|
+
// "cited": null when the line already cited this PR.
|
|
876
|
+
textUnchanged: citationAppended === null,
|
|
877
|
+
bodyPreserved: true,
|
|
878
|
+
citationAppended,
|
|
879
|
+
}
|
|
880
|
+
: null,
|
|
557
881
|
events,
|
|
558
882
|
candidates: target
|
|
559
883
|
? undefined
|
|
@@ -757,15 +1081,47 @@ export async function mergeTool(
|
|
|
757
1081
|
// copied, because a copied grammar is two grammars the moment one is fixed.
|
|
758
1082
|
const ticked = newlyTickedInDiff(f.diff ?? "");
|
|
759
1083
|
if (ticked.size) {
|
|
760
|
-
|
|
761
|
-
|
|
1084
|
+
/*
|
|
1085
|
+
* A RELATIONAL CITATION DOES NOT EVIDENCE A TICK.
|
|
1086
|
+
*
|
|
1087
|
+
* "unblocks Phase 5.2 Task 15.6" names the task without claiming it is done,
|
|
1088
|
+
* so a PR that ticks 15.6's box and cites it that way is exactly as
|
|
1089
|
+
* unevidenced as one that never mentioned it. Measured on the other side of
|
|
1090
|
+
* this grammar the same day: doctor read that sentence in DONE.md as a
|
|
1091
|
+
* completion claim and red-gated main.
|
|
1092
|
+
*
|
|
1093
|
+
* The two cases are REPORTED SEPARATELY, because they need different fixes
|
|
1094
|
+
* and "UNCITED" would send someone looking for a citation that is already
|
|
1095
|
+
* there. A relational citation needs REWORDING; an absent one needs ADDING.
|
|
1096
|
+
*/
|
|
1097
|
+
const detail = phaseCitationsDetailed(f.citationText ?? "");
|
|
1098
|
+
const closes = new Set(detail.filter((c) => c.relation === "claim").map((c) => c.key));
|
|
1099
|
+
const relational = new Map(detail.filter((c) => c.relation === "reference").map((c) => [c.key, c.marker]));
|
|
1100
|
+
const uncited = [...ticked].filter((k) => !closes.has(k));
|
|
762
1101
|
if (uncited.length) {
|
|
763
|
-
const
|
|
1102
|
+
const nameOf = (k: string) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; };
|
|
1103
|
+
const names = uncited.map(nameOf);
|
|
1104
|
+
const worded = uncited.filter((k) => relational.has(k));
|
|
1105
|
+
const missing = uncited.filter((k) => !relational.has(k));
|
|
1106
|
+
const parts: string[] = [];
|
|
1107
|
+
if (missing.length) {
|
|
1108
|
+
parts.push(
|
|
1109
|
+
`${missing.length} NOT CITED AT ALL: ${missing.map(nameOf).join(", ")} — name them in the PR title or a commit ` +
|
|
1110
|
+
`subject (e.g. "${nameOf(missing[0] as string)}"), the subject being what survives a squash merge`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
if (worded.length) {
|
|
1114
|
+
parts.push(
|
|
1115
|
+
`${worded.length} cited only RELATIONALLY: ` +
|
|
1116
|
+
worded.map((k) => `${nameOf(k)} (after '${relational.get(k)}')`).join(", ") +
|
|
1117
|
+
` — that names the task without claiming it is done, so it evidences nothing. Reword it, or drop the tick`,
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
764
1120
|
return {
|
|
765
1121
|
ok: false as const,
|
|
766
1122
|
error:
|
|
767
|
-
`#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are
|
|
768
|
-
|
|
1123
|
+
`#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNEVIDENCED. ` +
|
|
1124
|
+
`${parts.join(". ")}. Once this merges, the tick claims progress nothing in history supports.`,
|
|
769
1125
|
verdict,
|
|
770
1126
|
uncited: names,
|
|
771
1127
|
};
|
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/rooms.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
setOffset,
|
|
61
61
|
sysMsg,
|
|
62
62
|
moveFile,
|
|
63
|
+
agentIdFromCursorFilename,
|
|
63
64
|
STALE_MS,
|
|
64
65
|
EVICT_MS,
|
|
65
66
|
MAX_WAIT_MS,
|
|
@@ -284,7 +285,7 @@ export async function deleteRoomTool(args: { agentId: string; room: string; forc
|
|
|
284
285
|
// a now-deleted file.
|
|
285
286
|
const cursorsAdjusted: string[] = [];
|
|
286
287
|
for (const fname of await listCursorFiles()) {
|
|
287
|
-
const id = fname
|
|
288
|
+
const { id } = agentIdFromCursorFilename(fname);
|
|
288
289
|
const cursorPath = path.join(CURSOR_DIR, fname);
|
|
289
290
|
let touched = false;
|
|
290
291
|
await updateJson<Cursor>(cursorPath, {}, (current) => {
|