agent-coord-mcp 0.26.19 → 0.26.21
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/capabilities.js +57 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +130 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server.js +23 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/records.js +406 -70
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +34 -5
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +2 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +82 -42
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/tree-provenance.js +107 -0
- package/dist/tools/tree-provenance.js.map +1 -0
- package/dist/tools/work.js +95 -3
- package/dist/tools/work.js.map +1 -1
- 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/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +33 -3
- package/package.json +14 -11
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/capabilities.ts +104 -1
- package/src/gated-head.ts +134 -0
- package/src/server.ts +29 -0
- package/src/tools/queue-write.ts +485 -0
- package/src/tools/records.ts +409 -40
- 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/tree-provenance.ts +136 -0
- package/src/tools/work.ts +95 -3
- 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/src/tools/records.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { execFileSync } from "node:child_process";
|
|
|
15
15
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
+
|
|
19
|
+
import { treeProvenance } from "./tree-provenance.js";
|
|
18
20
|
import {
|
|
19
21
|
parseWorkDoc,
|
|
20
22
|
renderWorkDoc,
|
|
@@ -34,6 +36,8 @@ import {
|
|
|
34
36
|
type DoneEntry,
|
|
35
37
|
} from "@davidbalzan/groundwork-seam";
|
|
36
38
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
39
|
+
import { ROOT } from "../store.js";
|
|
40
|
+
import { verdictsFor, gatedAt } from "../gated-head.js";
|
|
37
41
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
38
42
|
import { haltState, isInFlightStatus } from "./stall.js";
|
|
39
43
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
@@ -70,10 +74,85 @@ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | nu
|
|
|
70
74
|
* Returns what it stamped so the caller can report it rather than leaving an
|
|
71
75
|
* absorbing writer to find it in a diff.
|
|
72
76
|
*/
|
|
73
|
-
|
|
77
|
+
/**
|
|
78
|
+
* A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
|
|
79
|
+
* that can be ignored is how the silent loss happened in the first place.
|
|
80
|
+
*/
|
|
81
|
+
export class StaleWriteError extends Error {
|
|
82
|
+
constructor(
|
|
83
|
+
readonly rel: string,
|
|
84
|
+
readonly detail: string,
|
|
85
|
+
readonly alreadyWritten: string[],
|
|
86
|
+
) {
|
|
87
|
+
super(
|
|
88
|
+
`refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
|
|
89
|
+
`Nothing was overwritten. Re-read and retry.` +
|
|
90
|
+
(alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""),
|
|
91
|
+
);
|
|
92
|
+
this.name = "StaleWriteError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What moved, in terms a caller can act on without diffing the file itself. */
|
|
97
|
+
function describeDrift(original: string, current: string): string {
|
|
98
|
+
const ol = original.split("\n");
|
|
99
|
+
const cl = current.split("\n");
|
|
100
|
+
let i = 0;
|
|
101
|
+
while (i < ol.length && i < cl.length && ol[i] === cl[i]) i++;
|
|
102
|
+
return (
|
|
103
|
+
`${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
|
|
104
|
+
(i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : "")
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
|
|
110
|
+
*
|
|
111
|
+
* This function used to compare the rendered text against `original` — the
|
|
112
|
+
* caller's IN-MEMORY snapshot from when it read — and then write. It never looked
|
|
113
|
+
* at the file again. Anything written in between was clobbered with no error, no
|
|
114
|
+
* diff, and nothing in the return value: `{ written: true }` came back for a
|
|
115
|
+
* write that had destroyed someone else's row.
|
|
116
|
+
*
|
|
117
|
+
* Measured before fixing: two writers taking the same original and both
|
|
118
|
+
* committing leave TWO rows where three should be, and the first writer's row is
|
|
119
|
+
* simply gone. This is not hypothetical — four processes write these documents
|
|
120
|
+
* (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
|
|
121
|
+
* ran six times in one afternoon against a queue the aide was editing.
|
|
122
|
+
*
|
|
123
|
+
* So the file is re-read immediately before the write and must still match what
|
|
124
|
+
* the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
|
|
125
|
+
* what moved. A refused write is recoverable; a silent overwrite is not, and the
|
|
126
|
+
* loser of the race never learns it lost.
|
|
127
|
+
*
|
|
128
|
+
* What this does NOT claim: it is not a lock. Two processes can still interleave
|
|
129
|
+
* between this re-read and the `writeFileSync` a few microseconds later. The
|
|
130
|
+
* window goes from "the whole duration of the caller's work" — parsing, git
|
|
131
|
+
* calls, composing a line — down to two adjacent statements. That is a large
|
|
132
|
+
* reduction and not zero, and a lock is the stronger fix if this ever proves
|
|
133
|
+
* insufficient.
|
|
134
|
+
*
|
|
135
|
+
* EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
|
|
136
|
+
* the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
|
|
137
|
+
* read to its write, so no in-process test can interleave them. An end-to-end
|
|
138
|
+
* attempt passed identically with the guard removed, i.e. it proved nothing. A
|
|
139
|
+
* data-loss guard is worth a narrow export to be testable at the level it lives.
|
|
140
|
+
*/
|
|
141
|
+
export function writeDoc(
|
|
142
|
+
repo: string,
|
|
143
|
+
rel: string,
|
|
144
|
+
doc: WorkDoc,
|
|
145
|
+
original: string,
|
|
146
|
+
alreadyWritten: string[] = [],
|
|
147
|
+
): { written: boolean; stamped: string[] } {
|
|
74
148
|
const { text, stamped } = renderWorkDocForWrite(doc);
|
|
75
149
|
if (text === original) return { written: false, stamped: [] };
|
|
76
|
-
|
|
150
|
+
const p = path.join(repo, rel);
|
|
151
|
+
const current = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
152
|
+
if (current !== original) {
|
|
153
|
+
throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
|
|
154
|
+
}
|
|
155
|
+
writeFileSync(p, text);
|
|
77
156
|
return { written: true, stamped };
|
|
78
157
|
}
|
|
79
158
|
|
|
@@ -151,6 +230,15 @@ export function summarize(text: string, max = 96): string {
|
|
|
151
230
|
* the queue SAYS it waits on this", never "nothing waits on this".
|
|
152
231
|
*/
|
|
153
232
|
export function noDownstream(items: QueueItem[]): QueueItem[] {
|
|
233
|
+
// ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
|
|
234
|
+
// asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
|
|
235
|
+
// list, so this re-filter is a no-op on that path; it is kept because this
|
|
236
|
+
// function is EXPORTED and its contract is "given items, which have nothing
|
|
237
|
+
// waiting on them", which must hold for a caller that hands it raw items.
|
|
238
|
+
//
|
|
239
|
+
// The two are not the same predicate and must not be merged: this one is
|
|
240
|
+
// `!done`, while the router's also subtracts the delivery join. Collapsing
|
|
241
|
+
// them would make an exported helper inherit a routing policy.
|
|
154
242
|
const open = items.filter((i) => !i.done);
|
|
155
243
|
const named = new Set<string>();
|
|
156
244
|
for (const i of open) {
|
|
@@ -235,43 +323,53 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
|
|
|
235
323
|
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
236
324
|
* merely MENTIONS it?
|
|
237
325
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
326
|
+
* ⛔ THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
|
|
327
|
+
* appeared ANYWHERE in an entry, on a census that had inverted: today
|
|
328
|
+
* `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
|
|
329
|
+
* house spelling everything else teaches — so the COMPLIANT way to cite an item
|
|
330
|
+
* became the spelling that meant "delivered". Measured before removal, on 143
|
|
331
|
+
* open rows: ELEVEN false positives, ZERO independent true positives (every row
|
|
332
|
+
* it correctly excluded was already excluded by `!i.done`, which runs first), and
|
|
333
|
+
* a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
|
|
334
|
+
* merged under a different row's citation, stayed `[ ]`, and was offered as top
|
|
335
|
+
* P1 twice.
|
|
336
|
+
*
|
|
337
|
+
* ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
|
|
338
|
+
* BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
|
|
339
|
+
* reasons this repo's own canon REQUIRES — a residual gap must cite its queue
|
|
340
|
+
* item — so no reading of that file separates a closure from a mention. Three
|
|
341
|
+
* replacements were measured and each was worse or equal: position does not
|
|
342
|
+
* discriminate (a known-false and a known-true entry are structurally identical,
|
|
343
|
+
* and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
|
|
344
|
+
* id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
|
|
345
|
+
* false positives, because open rows cite refs as EVIDENCE.
|
|
241
346
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
347
|
+
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
348
|
+
* CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
|
|
349
|
+
* delivery from prose: it requires the entry to carry the item's own
|
|
350
|
+
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
351
|
+
* A citation cannot accidentally satisfy it.
|
|
245
352
|
*
|
|
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.
|
|
353
|
+
* ⚠ IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS — 0 of 143 open, 0 of 9
|
|
354
|
+
* closed — AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
|
|
355
|
+
* code: rows here are closed with `land --write` and the composed line is then
|
|
356
|
+
* OVERWRITTEN with hand-written prose, so the structural record it would match is
|
|
357
|
+
* destroyed within the minute. **Do not re-derive "dead code" from another zero
|
|
358
|
+
* count.** Its input returns the moment a closing entry keeps what `land` wrote.
|
|
259
359
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
360
|
+
* ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
|
|
361
|
+
* cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
|
|
362
|
+
* CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
|
|
363
|
+
* Accepted knowingly — the arm that claimed to cover it did not, on the one live
|
|
364
|
+
* instance it had. Closing such a row is coordinator discipline, not a predicate.
|
|
264
365
|
*/
|
|
265
366
|
function doneRecordsDelivery(entries: DoneEntry[], id: string, text: string): boolean {
|
|
266
367
|
const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
|
|
267
368
|
const target = norm(summarize(text)).replace(/…$/, "");
|
|
268
|
-
const sigil = `⟨${id}⟩`;
|
|
269
369
|
return entries.some((e) => {
|
|
270
|
-
const t = String(e.text);
|
|
271
|
-
if (t.includes(sigil)) return true;
|
|
272
370
|
// A leading recorded-id token is stripped before comparing, so an entry
|
|
273
371
|
// written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
|
|
274
|
-
const body = norm(
|
|
372
|
+
const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
|
|
275
373
|
return target.length >= 12 && body.startsWith(target);
|
|
276
374
|
});
|
|
277
375
|
}
|
|
@@ -293,9 +391,19 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
293
391
|
};
|
|
294
392
|
}
|
|
295
393
|
const repo = args.repo ?? process.cwd();
|
|
394
|
+
// ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
|
|
395
|
+
// `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
|
|
396
|
+
// a confidently wrong routing decision in both directions inside ten minutes —
|
|
397
|
+
// a row ruled un-claimable from a stale file, and a worker told to hold on a
|
|
398
|
+
// row that was already split. The answer was well-formed and said nothing
|
|
399
|
+
// about its source, which is what made it invisible.
|
|
400
|
+
const tree = treeProvenance(repo);
|
|
296
401
|
const q = readDoc(repo, QUEUE_DOC);
|
|
297
|
-
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'
|
|
402
|
+
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
|
|
298
403
|
const items = queueItemsOf(q.doc);
|
|
404
|
+
// The seam's own count of unticked rows — the number every axis must add back
|
|
405
|
+
// up to. Taken BEFORE any exclusion so it cannot inherit one.
|
|
406
|
+
const parsedOpen = items.filter((i) => !i.done).length;
|
|
299
407
|
|
|
300
408
|
// ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
|
|
301
409
|
// establish that.
|
|
@@ -313,17 +421,66 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
313
421
|
// (Task 21.1) are what make this join reliable — with a content-hash id the
|
|
314
422
|
// board row and the DONE entry stopped matching the moment anyone reworded
|
|
315
423
|
// the item, which is how the memory was lost in the first place.
|
|
316
|
-
|
|
424
|
+
// ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
|
|
425
|
+
// be subtracted from `open` BEFORE any axis ran, so an item it excluded could
|
|
426
|
+
// appear on NO axis by construction — the one guarantee this verb's contract
|
|
427
|
+
// makes is that it never skips silently, and this was the one path that did.
|
|
428
|
+
//
|
|
429
|
+
// MEASURED on the live queue at `a032069`: the file held 143 open rows, the
|
|
430
|
+
// seam parsed all 143, and this verb reported `open: 129`. Its declared axes
|
|
431
|
+
// accounted for ONE. Fourteen rows were outside the router's universe and
|
|
432
|
+
// nothing in the response said so — invisible from the only seat that would
|
|
433
|
+
// notice, because a worker asking for the next item still gets a real one.
|
|
434
|
+
//
|
|
435
|
+
// ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
|
|
436
|
+
// a row is excluded BY a named cause, and the cause is what gets reported.
|
|
437
|
+
const deliveredBy = new Map<string, "board" | "done">();
|
|
317
438
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
318
439
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
319
440
|
const boardText = boardDoc?.text ?? "";
|
|
320
441
|
const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
|
|
321
442
|
const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
|
|
322
443
|
for (const i of items) {
|
|
323
|
-
if (
|
|
444
|
+
if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
445
|
+
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
446
|
+
// routing: both causes exclude, but a live 🚧 row is a different remedy
|
|
447
|
+
// (wait, or ask its owner) from a landed delivery (close the row).
|
|
448
|
+
if (boardHoldsItem(boardRows, i.id, String(i.text))) deliveredBy.set(i.id, "board");
|
|
449
|
+
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, "done");
|
|
324
450
|
}
|
|
325
451
|
|
|
326
|
-
const open = items.filter((i) => !i.done && !
|
|
452
|
+
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
453
|
+
|
|
454
|
+
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
455
|
+
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
456
|
+
// reported silently is the same defect as an incorrect one.
|
|
457
|
+
const delivered = items
|
|
458
|
+
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
459
|
+
.map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id)! }));
|
|
460
|
+
|
|
461
|
+
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
462
|
+
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
463
|
+
// two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
|
|
464
|
+
// identical row` twice yields `q-c9f3ded8` twice.
|
|
465
|
+
//
|
|
466
|
+
// Everything downstream is keyed by id, so ONE delivery record then excludes
|
|
467
|
+
// BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
|
|
468
|
+
// to `offered: 1`. The accounting still reconciles — both are named — so the
|
|
469
|
+
// self-check above CANNOT catch it, which is exactly why it needs its own axis
|
|
470
|
+
// rather than a flag on `reconciles`.
|
|
471
|
+
//
|
|
472
|
+
// ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
|
|
473
|
+
// filter working as designed; a row reported TWICE cannot be anything but the
|
|
474
|
+
// accounting. It is the one symptom here that does not rest on a count.
|
|
475
|
+
const idCounts = new Map<string, number>();
|
|
476
|
+
for (const i of items) if (!i.done) idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
|
|
477
|
+
const duplicateIds = [...idCounts.entries()]
|
|
478
|
+
.filter(([, n]) => n > 1)
|
|
479
|
+
.map(([id, rows]) => ({
|
|
480
|
+
id,
|
|
481
|
+
rows,
|
|
482
|
+
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.`,
|
|
483
|
+
}));
|
|
327
484
|
const ranked = open
|
|
328
485
|
.map((i, idx) => ({ i, idx }))
|
|
329
486
|
.sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
|
|
@@ -387,9 +544,33 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
387
544
|
// the severity finding one file over. Say the axis is uninformative instead.
|
|
388
545
|
const undiscriminating = silent.length === open.length && open.length > 1;
|
|
389
546
|
return {
|
|
547
|
+
// ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
|
|
548
|
+
// routing answer whose tree is unnamed is the defect this row exists for.
|
|
549
|
+
tree,
|
|
550
|
+
...(tree.warning ? { staleWarning: tree.warning } : {}),
|
|
390
551
|
ok: true as const,
|
|
391
552
|
project: args.project,
|
|
392
553
|
open: open.length,
|
|
554
|
+
// ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
|
|
555
|
+
// EXPLAINED and the only thing that ever caught it was someone counting the
|
|
556
|
+
// file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
|
|
557
|
+
// is what routing considered; `excluded` is what the axes account for. When
|
|
558
|
+
// `reconciles` is false, rows have left the universe with no named cause —
|
|
559
|
+
// the exact condition that was previously unobservable from the response.
|
|
560
|
+
//
|
|
561
|
+
// ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
|
|
562
|
+
// because its own bookkeeping is off strands every lane, which is worse than
|
|
563
|
+
// the miscount. The caller gets a real item AND the discrepancy.
|
|
564
|
+
accounting: {
|
|
565
|
+
parsedOpen,
|
|
566
|
+
offered: open.length,
|
|
567
|
+
excluded: delivered.length,
|
|
568
|
+
reconciles: parsedOpen === open.length + delivered.length,
|
|
569
|
+
},
|
|
570
|
+
// A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
|
|
571
|
+
delivered,
|
|
572
|
+
// A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
|
|
573
|
+
duplicateIds,
|
|
393
574
|
next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
|
|
394
575
|
skipped,
|
|
395
576
|
// A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
|
|
@@ -406,6 +587,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
406
587
|
...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
|
|
407
588
|
...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
|
|
408
589
|
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
590
|
+
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
591
|
+
...delivered.map((d) =>
|
|
592
|
+
d.reason === "board"
|
|
593
|
+
? `⏭ not offered — already on the board (${d.id})`
|
|
594
|
+
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
|
|
595
|
+
),
|
|
409
596
|
],
|
|
410
597
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
411
598
|
noDownstream: undiscriminating
|
|
@@ -650,7 +837,29 @@ export const landSchema = {
|
|
|
650
837
|
result: z.string().optional(),
|
|
651
838
|
};
|
|
652
839
|
|
|
653
|
-
|
|
840
|
+
/**
|
|
841
|
+
* What the recording step needs to know about a merge: which head actually
|
|
842
|
+
* landed, and when. Injected so the report is provable without the network.
|
|
843
|
+
*/
|
|
844
|
+
export type MergeFacts = { headRefOid: string; mergedAt: string } | null;
|
|
845
|
+
export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
|
|
846
|
+
const ghMergeFacts: ReadMergeFacts = (repo, n) => {
|
|
847
|
+
try {
|
|
848
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
|
|
849
|
+
cwd: repo,
|
|
850
|
+
encoding: "utf8",
|
|
851
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
852
|
+
});
|
|
853
|
+
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
|
|
854
|
+
if (!j.headRefOid || !j.mergedAt) return null;
|
|
855
|
+
return { headRefOid: String(j.headRefOid), mergedAt: String(j.mergedAt) };
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
export async function landTool(
|
|
862
|
+
args: {
|
|
654
863
|
project: string;
|
|
655
864
|
pr: string;
|
|
656
865
|
queueItemId?: string;
|
|
@@ -658,7 +867,10 @@ export async function landTool(args: {
|
|
|
658
867
|
base?: string;
|
|
659
868
|
write?: boolean;
|
|
660
869
|
result?: string;
|
|
661
|
-
}
|
|
870
|
+
},
|
|
871
|
+
readMergeFacts: ReadMergeFacts = ghMergeFacts,
|
|
872
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
873
|
+
) {
|
|
662
874
|
const repo = args.repo ?? process.cwd();
|
|
663
875
|
const base = args.base ?? "main";
|
|
664
876
|
const n = prNumber(args.pr);
|
|
@@ -773,8 +985,41 @@ export async function landTool(args: {
|
|
|
773
985
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
774
986
|
const summary = originalText !== null ? summarize(originalText) : null;
|
|
775
987
|
|
|
988
|
+
/*
|
|
989
|
+
* ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
|
|
990
|
+
* — `⟨q-d046204d⟩`.
|
|
991
|
+
*
|
|
992
|
+
* `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
|
|
993
|
+
* from these entries: IT WAS CUT, because in the row it sits later than the
|
|
994
|
+
* truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
|
|
995
|
+
* that names no row — and `closingRefs` needs BOTH halves, so the row reads
|
|
996
|
+
* untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
|
|
997
|
+
*
|
|
998
|
+
* ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
|
|
999
|
+
* out of the truncated region BY CONSTRUCTION, so it survives any headline length
|
|
1000
|
+
* — where raising `max` only moves the cliff. The row is explicit that "write
|
|
1001
|
+
* longer headlines" is not an acceptable fix, and it is right: a length that is
|
|
1002
|
+
* enough today is a truncation tomorrow.
|
|
1003
|
+
*
|
|
1004
|
+
* ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
|
|
1005
|
+
* a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
|
|
1006
|
+
* <summary>` and one written as `<summary>` agree". So the composed-summary arm
|
|
1007
|
+
* keeps matching and this needed no change there.
|
|
1008
|
+
*
|
|
1009
|
+
* ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
|
|
1010
|
+
* reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
|
|
1011
|
+
* in an entry — a reader-side guess that produced eleven false positives, because
|
|
1012
|
+
* this repo's canon requires entries to cite item ids for other reasons. This
|
|
1013
|
+
* writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
|
|
1014
|
+
* infers nothing, and re-reading a sigil as "delivered" is still wrong.
|
|
1015
|
+
*
|
|
1016
|
+
* The id is the ITEM's, taken from the record rather than re-derived: `target` is
|
|
1017
|
+
* non-null whenever `summary` is, since the summary comes from its text.
|
|
1018
|
+
*/
|
|
776
1019
|
const doneLine =
|
|
777
|
-
already || !summary
|
|
1020
|
+
already || !summary || !target
|
|
1021
|
+
? null
|
|
1022
|
+
: `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
778
1023
|
|
|
779
1024
|
// APPEND TO DONE.md — the half this verb exists for.
|
|
780
1025
|
//
|
|
@@ -789,9 +1034,14 @@ export async function landTool(args: {
|
|
|
789
1034
|
// an entry added to the record model renders through the pinned glyph contract.
|
|
790
1035
|
const wrote: string[] = [];
|
|
791
1036
|
let stampedNotSupplied: string[] = [];
|
|
1037
|
+
// A stale-base refusal is a RESULT, not a crash: the caller needs to know
|
|
1038
|
+
// nothing was clobbered, what moved, and what this call had already written
|
|
1039
|
+
// before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
|
|
1040
|
+
// here so the verb still answers.
|
|
1041
|
+
try {
|
|
792
1042
|
if (args.write) {
|
|
793
1043
|
if (queueChanged) {
|
|
794
|
-
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text);
|
|
1044
|
+
const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
|
|
795
1045
|
if (w.written) wrote.push(QUEUE_DOC);
|
|
796
1046
|
// Rows stamped that this call did not ask to touch. `queueItemId` is the
|
|
797
1047
|
// one item the caller supplied, so anything else here is a row that
|
|
@@ -816,9 +1066,19 @@ export async function landTool(args: {
|
|
|
816
1066
|
return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
|
|
817
1067
|
}
|
|
818
1068
|
block.entries.push(entry);
|
|
819
|
-
if (writeDoc(repo, DONE_DOC, d.doc, d.text).written) wrote.push(DONE_DOC);
|
|
1069
|
+
if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written) wrote.push(DONE_DOC);
|
|
820
1070
|
}
|
|
821
1071
|
}
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
if (e instanceof StaleWriteError) {
|
|
1074
|
+
return {
|
|
1075
|
+
ok: false as const,
|
|
1076
|
+
error: e.message,
|
|
1077
|
+
staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
throw e;
|
|
1081
|
+
}
|
|
822
1082
|
|
|
823
1083
|
// 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
|
|
824
1084
|
//
|
|
@@ -847,12 +1107,53 @@ export async function landTool(args: {
|
|
|
847
1107
|
}
|
|
848
1108
|
}
|
|
849
1109
|
|
|
1110
|
+
/*
|
|
1111
|
+
* ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
|
|
1112
|
+
*
|
|
1113
|
+
* `merge` refuses this BEFORE the fact and is the control that prevents. This
|
|
1114
|
+
* one runs after, and its job is different: it is how the fleet LEARNS a
|
|
1115
|
+
* crossing happened anyway — through a merge done by hand, by `gh` directly,
|
|
1116
|
+
* or by a seat that never called the verb.
|
|
1117
|
+
*
|
|
1118
|
+
* It REPORTS and never refuses. A closure is not the right place to litigate a
|
|
1119
|
+
* merge that already happened: the verdict is QA's artefact, the merge may have
|
|
1120
|
+
* been someone else's, and blocking the record would leave the work landed and
|
|
1121
|
+
* unrecorded — strictly worse than landed and recorded with a flag on it.
|
|
1122
|
+
*
|
|
1123
|
+
* ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
|
|
1124
|
+
*/
|
|
1125
|
+
const gatedHead = (() => {
|
|
1126
|
+
const mf = readMergeFacts(repo, n);
|
|
1127
|
+
if (!mf) return { checked: false as const, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
|
|
1128
|
+
const log = readVerdictLog(args.project);
|
|
1129
|
+
if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
|
|
1130
|
+
const at = Date.parse(mf.mergedAt);
|
|
1131
|
+
if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1132
|
+
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1133
|
+
const a = gatedAt(verdicts, mf.headRefOid, at);
|
|
1134
|
+
if (a.gated) {
|
|
1135
|
+
return { checked: true as const, gated: true as const, head: mf.headRefOid.slice(0, 8), by: { from: a.by.from, sha: a.by.head.slice(0, 8) } };
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
checked: true as const,
|
|
1139
|
+
gated: false as const,
|
|
1140
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1141
|
+
reason: a.reason,
|
|
1142
|
+
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1143
|
+
note:
|
|
1144
|
+
`UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1145
|
+
`The record is written — this is a report, not a refusal — but the merge was not covered by a verdict when it happened.` +
|
|
1146
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1147
|
+
};
|
|
1148
|
+
})();
|
|
1149
|
+
|
|
850
1150
|
return {
|
|
851
1151
|
ok: true as const,
|
|
852
1152
|
project: args.project,
|
|
853
1153
|
pr: `#${n}`,
|
|
854
1154
|
comparedAgainst: ref,
|
|
855
1155
|
landedIn: landedIn.slice(0, 8),
|
|
1156
|
+
gatedHead,
|
|
856
1157
|
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
857
1158
|
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
858
1159
|
// fixed, so a foreign row cannot pass silently in either direction.
|
|
@@ -964,13 +1265,15 @@ export type PrFacts = {
|
|
|
964
1265
|
state: string;
|
|
965
1266
|
mergeable: string;
|
|
966
1267
|
checks: unknown[];
|
|
1268
|
+
/** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
|
|
1269
|
+
headRefOid?: string;
|
|
967
1270
|
/** Unified diff of the PR, for the ticked-box audit. */
|
|
968
1271
|
diff?: string;
|
|
969
1272
|
/** Everything that will survive the merge as a citation: title + commit subjects + body. */
|
|
970
1273
|
citationText?: string;
|
|
971
1274
|
};
|
|
972
1275
|
const ghFacts = (repo: string, n: string): PrFacts => {
|
|
973
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
|
|
1276
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
974
1277
|
cwd: repo,
|
|
975
1278
|
encoding: "utf8",
|
|
976
1279
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -994,6 +1297,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
994
1297
|
return {
|
|
995
1298
|
state: String(j.state ?? ""),
|
|
996
1299
|
mergeable: String(j.mergeable ?? ""),
|
|
1300
|
+
headRefOid: String(j.headRefOid ?? ""),
|
|
997
1301
|
checks: (j.statusCheckRollup as unknown[]) ?? [],
|
|
998
1302
|
diff: gh(["pr", "diff", n]),
|
|
999
1303
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1019,10 +1323,28 @@ const ghMerge: DoMerge = (repo, n, method) => {
|
|
|
1019
1323
|
});
|
|
1020
1324
|
};
|
|
1021
1325
|
|
|
1326
|
+
/**
|
|
1327
|
+
* The room log, injected for the same reason `doMerge` is: a test must be able to
|
|
1328
|
+
* drive every refusal branch without this host's ~/agent-coord.
|
|
1329
|
+
*
|
|
1330
|
+
* Returns null when the log cannot be read — distinct from "" (read, and empty),
|
|
1331
|
+
* because "no verdicts recorded" and "could not look" are different answers and
|
|
1332
|
+
* only one of them is safe to merge on.
|
|
1333
|
+
*/
|
|
1334
|
+
export type ReadVerdictLog = (project: string) => string | null;
|
|
1335
|
+
const roomLog: ReadVerdictLog = (project) => {
|
|
1336
|
+
try {
|
|
1337
|
+
return readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1338
|
+
} catch {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1022
1343
|
export async function mergeTool(
|
|
1023
1344
|
args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
|
|
1024
1345
|
facts: (repo: string, n: string) => PrFacts = ghFacts,
|
|
1025
1346
|
doMerge: DoMerge = ghMerge,
|
|
1347
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
1026
1348
|
) {
|
|
1027
1349
|
const repo = args.repo ?? process.cwd();
|
|
1028
1350
|
const n = prNumber(args.pr);
|
|
@@ -1131,8 +1453,55 @@ export async function mergeTool(
|
|
|
1131
1453
|
if (f.mergeable === "CONFLICTING")
|
|
1132
1454
|
return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
|
|
1133
1455
|
|
|
1456
|
+
/*
|
|
1457
|
+
* ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
|
|
1458
|
+
*
|
|
1459
|
+
* A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
|
|
1460
|
+
* and took `1d6deab`, because the author force-pushed in between. Checks were
|
|
1461
|
+
* green on BOTH heads, so every refusal above passed honestly. Nothing asked
|
|
1462
|
+
* the only question that mattered: is the head in front of me the head the
|
|
1463
|
+
* verdict named?
|
|
1464
|
+
*
|
|
1465
|
+
* THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
|
|
1466
|
+
* only report afterwards. It sits before the dry-run return on purpose: a dry
|
|
1467
|
+
* run must say it would refuse, or the preview disagrees with the act.
|
|
1468
|
+
*/
|
|
1469
|
+
const head = f.headRefOid ?? "";
|
|
1470
|
+
if (!head) {
|
|
1471
|
+
return {
|
|
1472
|
+
ok: false as const,
|
|
1473
|
+
error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
|
|
1474
|
+
verdict,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
const log = readVerdictLog(args.project);
|
|
1478
|
+
if (log === null) {
|
|
1479
|
+
return {
|
|
1480
|
+
ok: false as const,
|
|
1481
|
+
error:
|
|
1482
|
+
`could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
|
|
1483
|
+
`was ever gated is UNKNOWN, and unknown is not gated.`,
|
|
1484
|
+
verdict,
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1488
|
+
const gate = gatedAt(verdicts, head, Date.now());
|
|
1489
|
+
if (!gate.gated) {
|
|
1490
|
+
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1491
|
+
return {
|
|
1492
|
+
ok: false as const,
|
|
1493
|
+
error:
|
|
1494
|
+
`#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
|
|
1495
|
+
(named ? `Gated instead: ${named}. ` : "") +
|
|
1496
|
+
`Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
|
|
1497
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1498
|
+
verdict,
|
|
1499
|
+
gatedHead: { head, gated: false as const, reason: gate.reason },
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1134
1503
|
if (!args.write)
|
|
1135
|
-
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
|
|
1504
|
+
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.by.from}. Pass write:true to apply.` };
|
|
1136
1505
|
|
|
1137
1506
|
doMerge(repo, n, args.method ?? "squash");
|
|
1138
1507
|
return { ok: true as const, merged: true as const, verdict };
|