agent-coord-mcp 0.26.20 → 0.26.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities.js +250 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/closing-line.js +83 -0
- package/dist/closing-line.js.map +1 -0
- package/dist/commit-cite.js +55 -0
- package/dist/commit-cite.js.map +1 -0
- package/dist/gated-head.js +177 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +4 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +32 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/away.js +67 -7
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/board-ref.js +44 -4
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/event-kinds.js +5 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +31 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/messaging.js +64 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +384 -33
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +52 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +173 -0
- package/dist/tools/seat-build.js.map +1 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +1095 -18
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +21 -2
- 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/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +1 -1
- package/scripts/coord-attention-clock.mjs +2 -0
- package/scripts/coord-stall-clock.mjs +52 -11
- package/src/capabilities.ts +264 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +236 -0
- package/src/server-spread.ts +233 -0
- package/src/server.ts +10 -2
- package/src/store.ts +32 -0
- package/src/tools/away.ts +82 -9
- package/src/tools/board-ref.ts +70 -3
- package/src/tools/event-kinds.ts +17 -1
- package/src/tools/events.ts +33 -2
- package/src/tools/messaging.ts +63 -6
- package/src/tools/queue-write.ts +485 -0
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +423 -31
- package/src/tools/registry.ts +54 -3
- package/src/tools/seat-build.ts +194 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +21 -2
- package/src/tools/tree-provenance.ts +136 -0
- package/src/tools/worktrees.ts +13 -0
package/src/tools/records.ts
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* that drifts.
|
|
13
13
|
*/
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, readdirSync, 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,
|
|
@@ -28,14 +30,20 @@ import {
|
|
|
28
30
|
sweepTagOf,
|
|
29
31
|
awaitingOf,
|
|
30
32
|
refsIn,
|
|
33
|
+
closingCitation,
|
|
31
34
|
refsMatch,
|
|
32
35
|
workstreamsV1RowsOf,
|
|
36
|
+
workStateOf,
|
|
37
|
+
declarationOf,
|
|
38
|
+
type WorkState,
|
|
33
39
|
type WorkstreamsV1Row,
|
|
34
40
|
type DoneEntry,
|
|
35
41
|
} from "@davidbalzan/groundwork-seam";
|
|
36
42
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
43
|
+
import { ROOT } from "../store.js";
|
|
44
|
+
import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
37
45
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
38
|
-
import { haltState
|
|
46
|
+
import { haltState } from "./stall.js";
|
|
39
47
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
40
48
|
|
|
41
49
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
@@ -156,6 +164,7 @@ const git = (repo: string, args: string[]): string =>
|
|
|
156
164
|
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
157
165
|
|
|
158
166
|
/** `#123` / `owner/repo#123` → "123". */
|
|
167
|
+
|
|
159
168
|
function prNumber(pr: string): string | null {
|
|
160
169
|
const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
|
|
161
170
|
return m ? (m[1] as string) : null;
|
|
@@ -283,18 +292,71 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
283
292
|
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
284
293
|
}
|
|
285
294
|
|
|
295
|
+
/** What a board row says about an item it names as its SUBJECT. */
|
|
296
|
+
export type BoardHold = {
|
|
297
|
+
/** The state the seam read off the row's Status cell. */
|
|
298
|
+
state: WorkState;
|
|
299
|
+
/** The routing reason: the state, except `parked` is split into held · queued · parked. */
|
|
300
|
+
why: string;
|
|
301
|
+
/** The declaration the reason was read from — never the commentary after it. */
|
|
302
|
+
status: string;
|
|
303
|
+
/** Whether the row's Branch · Worktree cell names anything at all. */
|
|
304
|
+
builtWork: boolean;
|
|
305
|
+
branch: string;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
* ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
|
|
310
|
+
* MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
|
|
311
|
+
* `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
|
|
312
|
+
* green in an open PR and the other has nothing behind it. Reporting them under
|
|
313
|
+
* one word re-creates the defect with a wider net: a reader told "parked" for
|
|
314
|
+
* `#292`'s row would go and build it.
|
|
315
|
+
*
|
|
316
|
+
* Two INDEPENDENT facts, deliberately not one: the WORD comes from the
|
|
317
|
+
* declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
|
|
318
|
+
* Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
|
|
319
|
+
* and that disagreement is worth seeing rather than resolving.
|
|
320
|
+
*/
|
|
321
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
322
|
+
function holdReasonOf(state: WorkState, status: string): string {
|
|
323
|
+
if (state !== "parked") return state;
|
|
324
|
+
const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
|
|
325
|
+
if (/\bheld\b/i.test(decl)) return "held";
|
|
326
|
+
if (/\bqueued\b/i.test(decl)) return "queued";
|
|
327
|
+
return "parked";
|
|
328
|
+
}
|
|
329
|
+
|
|
286
330
|
/**
|
|
287
|
-
*
|
|
331
|
+
* What does the board say about this item, if it is the SUBJECT of any row?
|
|
288
332
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
333
|
+
* ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
|
|
334
|
+
* (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
|
|
335
|
+
* only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
|
|
336
|
+
* its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
|
|
337
|
+
* Measured on the live board 2026-09-14: seven `⏸` rows, every one the
|
|
338
|
+
* coordinator's most careful bookkeeping, every one invisible to this join.
|
|
339
|
+
*
|
|
340
|
+
* ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
|
|
341
|
+
* fix made a `⏸ Parked` row NOT silence its item, because the alternative
|
|
342
|
+
* then was a SILENT drop with no axis. The drop is no longer silent: the item
|
|
343
|
+
* lands on `delivered` with the row's state as its `why`, and an `AWAITS`
|
|
344
|
+
* item still reaches `awaitingDecision`, which is gathered over every open
|
|
345
|
+
* item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
|
|
346
|
+
* must not make its item VANISH — still holds; what changes is that a warning
|
|
347
|
+
* row now also stops the item being HANDED OUT, which is what a warning is for.
|
|
348
|
+
*
|
|
349
|
+
* Conditions:
|
|
294
350
|
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
295
351
|
* text prefix `claim` has always written there. A mention in a Last
|
|
296
352
|
* note is one lane REFERRING to another item — the aide's live row
|
|
297
353
|
* does exactly this — and a status-only rule would still hide it.
|
|
354
|
+
* STATE whatever the seam reads: in-progress, in-review, parked (split
|
|
355
|
+
* into held · queued · parked, see `holdReasonOf`), blocked,
|
|
356
|
+
* orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
|
|
357
|
+
* excludes: the row is on the board with this item as its subject,
|
|
358
|
+
* and offering it anyway is the silent path this exists to close.
|
|
359
|
+
* A `✅ Done` row excludes too — that item is offerable to nobody.
|
|
298
360
|
*
|
|
299
361
|
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
300
362
|
* would have shipped a transitional hole in its own negative control: every row
|
|
@@ -305,14 +367,24 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
305
367
|
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
306
368
|
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
307
369
|
*/
|
|
308
|
-
function
|
|
370
|
+
function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null {
|
|
309
371
|
const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
|
|
310
|
-
|
|
311
|
-
if (!isInFlightStatus(r.status)) return false;
|
|
312
|
-
if (rowSubjectIds(r).includes(id)) return true;
|
|
372
|
+
for (const r of rows) {
|
|
313
373
|
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
314
|
-
|
|
315
|
-
|
|
374
|
+
const subject =
|
|
375
|
+
rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
|
|
376
|
+
if (!subject) continue;
|
|
377
|
+
const state = workStateOf(r.status);
|
|
378
|
+
const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
|
|
379
|
+
return {
|
|
380
|
+
state,
|
|
381
|
+
why: holdReasonOf(state, r.status),
|
|
382
|
+
status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
|
|
383
|
+
builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
|
|
384
|
+
branch,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
316
388
|
}
|
|
317
389
|
|
|
318
390
|
/**
|
|
@@ -341,7 +413,7 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
|
|
|
341
413
|
* false positives, because open rows cite refs as EVIDENCE.
|
|
342
414
|
*
|
|
343
415
|
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
344
|
-
* CLASS — the same distinction that keeps `
|
|
416
|
+
* CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
|
|
345
417
|
* delivery from prose: it requires the entry to carry the item's own
|
|
346
418
|
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
347
419
|
* A citation cannot accidentally satisfy it.
|
|
@@ -387,8 +459,15 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
387
459
|
};
|
|
388
460
|
}
|
|
389
461
|
const repo = args.repo ?? process.cwd();
|
|
462
|
+
// ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
|
|
463
|
+
// `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
|
|
464
|
+
// a confidently wrong routing decision in both directions inside ten minutes —
|
|
465
|
+
// a row ruled un-claimable from a stale file, and a worker told to hold on a
|
|
466
|
+
// row that was already split. The answer was well-formed and said nothing
|
|
467
|
+
// about its source, which is what made it invisible.
|
|
468
|
+
const tree = treeProvenance(repo);
|
|
390
469
|
const q = readDoc(repo, QUEUE_DOC);
|
|
391
|
-
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'
|
|
470
|
+
if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
|
|
392
471
|
const items = queueItemsOf(q.doc);
|
|
393
472
|
// The seam's own count of unticked rows — the number every axis must add back
|
|
394
473
|
// up to. Taken BEFORE any exclusion so it cannot inherit one.
|
|
@@ -423,7 +502,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
423
502
|
//
|
|
424
503
|
// ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
|
|
425
504
|
// a row is excluded BY a named cause, and the cause is what gets reported.
|
|
426
|
-
const deliveredBy = new Map<string, "board" | "done">();
|
|
505
|
+
const deliveredBy = new Map<string, { reason: "board"; hold: BoardHold } | { reason: "done" }>();
|
|
427
506
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
428
507
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
429
508
|
const boardText = boardDoc?.text ?? "";
|
|
@@ -432,10 +511,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
432
511
|
for (const i of items) {
|
|
433
512
|
if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
434
513
|
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
435
|
-
// routing: both causes exclude, but a
|
|
436
|
-
// (wait
|
|
437
|
-
|
|
438
|
-
|
|
514
|
+
// routing: both causes exclude, but a board row is a different remedy
|
|
515
|
+
// (wait for the merge · ask its owner · ask the human it awaits, by its
|
|
516
|
+
// `why`) from a landed delivery (close the row).
|
|
517
|
+
const hold = boardHoldOf(boardRows, i.id, String(i.text));
|
|
518
|
+
if (hold) deliveredBy.set(i.id, { reason: "board", hold });
|
|
519
|
+
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" });
|
|
439
520
|
}
|
|
440
521
|
|
|
441
522
|
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
@@ -443,9 +524,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
443
524
|
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
444
525
|
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
445
526
|
// reported silently is the same defect as an incorrect one.
|
|
527
|
+
//
|
|
528
|
+
// ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
|
|
529
|
+
// the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
|
|
530
|
+
// and the two want opposite things from the reader — leave the first alone,
|
|
531
|
+
// MERGE the second. `why` is the seam's state (parked split into held ·
|
|
532
|
+
// queued · parked) and `builtWork` says whether the row names a branch at
|
|
533
|
+
// all, so "held with nothing behind it" is visible rather than resolved.
|
|
446
534
|
const delivered = items
|
|
447
535
|
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
448
|
-
.map((i) =>
|
|
536
|
+
.map((i) => {
|
|
537
|
+
const d = deliveredBy.get(i.id)!;
|
|
538
|
+
return d.reason === "board"
|
|
539
|
+
? { item: keyOf(i), id: i.id, reason: "board" as const, why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch }
|
|
540
|
+
: { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" };
|
|
541
|
+
});
|
|
449
542
|
|
|
450
543
|
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
451
544
|
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
@@ -533,6 +626,10 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
533
626
|
// the severity finding one file over. Say the axis is uninformative instead.
|
|
534
627
|
const undiscriminating = silent.length === open.length && open.length > 1;
|
|
535
628
|
return {
|
|
629
|
+
// ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
|
|
630
|
+
// routing answer whose tree is unnamed is the defect this row exists for.
|
|
631
|
+
tree,
|
|
632
|
+
...(tree.warning ? { staleWarning: tree.warning } : {}),
|
|
536
633
|
ok: true as const,
|
|
537
634
|
project: args.project,
|
|
538
635
|
open: open.length,
|
|
@@ -575,7 +672,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
575
672
|
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
576
673
|
...delivered.map((d) =>
|
|
577
674
|
d.reason === "board"
|
|
578
|
-
? `⏭ not offered —
|
|
675
|
+
? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
|
|
579
676
|
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
|
|
580
677
|
),
|
|
581
678
|
],
|
|
@@ -647,9 +744,16 @@ export const claimSchema = {
|
|
|
647
744
|
base: z.string().optional(),
|
|
648
745
|
task: z.string().optional(),
|
|
649
746
|
write: z.boolean().optional(),
|
|
747
|
+
/**
|
|
748
|
+
* ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
|
|
749
|
+
* What the slice delivers instead (a ruling, a canon edit, a measurement).
|
|
750
|
+
* No worktree is cut; the board cell carries the statement in words, which
|
|
751
|
+
* the grammar and `stall_check` both read as DELIBERATE rather than missing.
|
|
752
|
+
*/
|
|
753
|
+
noCodeDeliverable: z.string().min(1).optional(),
|
|
650
754
|
};
|
|
651
755
|
|
|
652
|
-
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
|
|
756
|
+
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) {
|
|
653
757
|
const halt = haltState();
|
|
654
758
|
if (halt.halted) {
|
|
655
759
|
return {
|
|
@@ -683,6 +787,24 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
683
787
|
// A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
|
|
684
788
|
// item to an agent with nowhere isolated to work is the shared-checkout failure
|
|
685
789
|
// this pair exists to prevent.
|
|
790
|
+
// ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
|
|
791
|
+
// seats wrote this by hand in five spellings because the verb had no way to
|
|
792
|
+
// say it; `stall_check` reads the prose cell as deliberately branchless and
|
|
793
|
+
// keeps the row out of its population rather than calling it unmeasurable.
|
|
794
|
+
if (args.noCodeDeliverable) {
|
|
795
|
+
const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
|
|
796
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
|
|
797
|
+
let board: { action: string; path?: string } = { action: "reported" };
|
|
798
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
799
|
+
if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
800
|
+
else if (args.write) {
|
|
801
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
802
|
+
if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
803
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
804
|
+
}
|
|
805
|
+
return { ok: true as const, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable };
|
|
806
|
+
}
|
|
807
|
+
|
|
686
808
|
const wt = await ensureWorktreeTool({
|
|
687
809
|
agentId: args.agentId,
|
|
688
810
|
repo,
|
|
@@ -822,7 +944,47 @@ export const landSchema = {
|
|
|
822
944
|
result: z.string().optional(),
|
|
823
945
|
};
|
|
824
946
|
|
|
825
|
-
|
|
947
|
+
/**
|
|
948
|
+
* What the recording step needs to know about a merge: which head actually
|
|
949
|
+
* landed, and when. Injected so the report is provable without the network.
|
|
950
|
+
*/
|
|
951
|
+
export type PrComment = { body: string; createdAt?: string; author?: string };
|
|
952
|
+
/** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */
|
|
953
|
+
export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null;
|
|
954
|
+
export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
|
|
955
|
+
const ghMergeFacts: ReadMergeFacts = (repo, n) => {
|
|
956
|
+
try {
|
|
957
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
|
|
958
|
+
cwd: repo,
|
|
959
|
+
encoding: "utf8",
|
|
960
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
961
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
962
|
+
});
|
|
963
|
+
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] };
|
|
964
|
+
if (!j.headRefOid || !j.mergedAt) return null;
|
|
965
|
+
return {
|
|
966
|
+
headRefOid: String(j.headRefOid),
|
|
967
|
+
mergedAt: String(j.mergedAt),
|
|
968
|
+
comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
969
|
+
};
|
|
970
|
+
} catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
/** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
|
|
976
|
+
export function landingCitations(repo: string, n: string): string[] {
|
|
977
|
+
let subjects = "";
|
|
978
|
+
for (const base of ["origin/main", "main"]) {
|
|
979
|
+
try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ }
|
|
980
|
+
}
|
|
981
|
+
const marker = `(#${n})`;
|
|
982
|
+
const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
|
|
983
|
+
return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export async function landTool(
|
|
987
|
+
args: {
|
|
826
988
|
project: string;
|
|
827
989
|
pr: string;
|
|
828
990
|
queueItemId?: string;
|
|
@@ -830,7 +992,10 @@ export async function landTool(args: {
|
|
|
830
992
|
base?: string;
|
|
831
993
|
write?: boolean;
|
|
832
994
|
result?: string;
|
|
833
|
-
}
|
|
995
|
+
},
|
|
996
|
+
readMergeFacts: ReadMergeFacts = ghMergeFacts,
|
|
997
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
998
|
+
) {
|
|
834
999
|
const repo = args.repo ?? process.cwd();
|
|
835
1000
|
const base = args.base ?? "main";
|
|
836
1001
|
const n = prNumber(args.pr);
|
|
@@ -886,6 +1051,17 @@ export async function landTool(args: {
|
|
|
886
1051
|
// `queueItemId` it closes nothing and reports the candidates for the caller to
|
|
887
1052
|
// pick, saying so.
|
|
888
1053
|
const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
|
|
1054
|
+
// ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
|
|
1055
|
+
// shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
|
|
1056
|
+
// (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
|
|
1057
|
+
// The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
|
|
1058
|
+
// every check reads, local, no network — and listed beside the #N candidates
|
|
1059
|
+
// at the one moment both facts are in hand. Closing nothing: a sibling is a
|
|
1060
|
+
// question for the coordinator, not a claim.
|
|
1061
|
+
const citedIds = landingCitations(repo, n);
|
|
1062
|
+
const siblings = items
|
|
1063
|
+
.filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
|
|
1064
|
+
.map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
|
|
889
1065
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
890
1066
|
if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
|
|
891
1067
|
|
|
@@ -924,7 +1100,35 @@ export async function landTool(args: {
|
|
|
924
1100
|
queueChanged = true;
|
|
925
1101
|
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
926
1102
|
const contextRepo = originRepoOf(repo);
|
|
927
|
-
|
|
1103
|
+
/*
|
|
1104
|
+
* ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
|
|
1105
|
+
*
|
|
1106
|
+
* This asked whether the PR number appears ANYWHERE in the row, so a row that
|
|
1107
|
+
* DISCUSSES its own closer read as already cited and `land` wrote no token.
|
|
1108
|
+
* The natural experiment that isolates it — three rows, one variable:
|
|
1109
|
+
*
|
|
1110
|
+
* ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
|
|
1111
|
+
* ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
|
|
1112
|
+
* ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
|
|
1113
|
+
*
|
|
1114
|
+
* ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
|
|
1115
|
+
* somebody else's PRs is cited normally; a row naming its own once is not. So the
|
|
1116
|
+
* better a row documents what closed it, the more certainly the closure goes
|
|
1117
|
+
* unwritten — and the ARTEFACT is what loses, silently.
|
|
1118
|
+
*
|
|
1119
|
+
* ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
|
|
1120
|
+
* ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
|
|
1121
|
+
* a line-1 prose mention and `doctor` falls back to the row. That was measured and
|
|
1122
|
+
* a patch was reverted rather than shipped. What nobody asked was what it does to
|
|
1123
|
+
* the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
|
|
1124
|
+
* makes a ref a citation, and that rule applies to the verb that WRITES one.
|
|
1125
|
+
*
|
|
1126
|
+
* ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
|
|
1127
|
+
* rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
|
|
1128
|
+
* must never happen.
|
|
1129
|
+
*/
|
|
1130
|
+
const closingHere: ReturnType<typeof refsIn> = closingCitation(String(target.text))?.refs ?? [];
|
|
1131
|
+
const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
928
1132
|
if (!alreadyCited) {
|
|
929
1133
|
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
930
1134
|
target.text = `${target.text}${citationAppended}`;
|
|
@@ -945,8 +1149,41 @@ export async function landTool(args: {
|
|
|
945
1149
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
946
1150
|
const summary = originalText !== null ? summarize(originalText) : null;
|
|
947
1151
|
|
|
1152
|
+
/*
|
|
1153
|
+
* ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
|
|
1154
|
+
* — `⟨q-d046204d⟩`.
|
|
1155
|
+
*
|
|
1156
|
+
* `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
|
|
1157
|
+
* from these entries: IT WAS CUT, because in the row it sits later than the
|
|
1158
|
+
* truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
|
|
1159
|
+
* that names no row — and `closingRefs` needs BOTH halves, so the row reads
|
|
1160
|
+
* untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
|
|
1161
|
+
*
|
|
1162
|
+
* ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
|
|
1163
|
+
* out of the truncated region BY CONSTRUCTION, so it survives any headline length
|
|
1164
|
+
* — where raising `max` only moves the cliff. The row is explicit that "write
|
|
1165
|
+
* longer headlines" is not an acceptable fix, and it is right: a length that is
|
|
1166
|
+
* enough today is a truncation tomorrow.
|
|
1167
|
+
*
|
|
1168
|
+
* ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
|
|
1169
|
+
* a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
|
|
1170
|
+
* <summary>` and one written as `<summary>` agree". So the composed-summary arm
|
|
1171
|
+
* keeps matching and this needed no change there.
|
|
1172
|
+
*
|
|
1173
|
+
* ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
|
|
1174
|
+
* reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
|
|
1175
|
+
* in an entry — a reader-side guess that produced eleven false positives, because
|
|
1176
|
+
* this repo's canon requires entries to cite item ids for other reasons. This
|
|
1177
|
+
* writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
|
|
1178
|
+
* infers nothing, and re-reading a sigil as "delivered" is still wrong.
|
|
1179
|
+
*
|
|
1180
|
+
* The id is the ITEM's, taken from the record rather than re-derived: `target` is
|
|
1181
|
+
* non-null whenever `summary` is, since the summary comes from its text.
|
|
1182
|
+
*/
|
|
948
1183
|
const doneLine =
|
|
949
|
-
already || !summary
|
|
1184
|
+
already || !summary || !target
|
|
1185
|
+
? null
|
|
1186
|
+
: `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
950
1187
|
|
|
951
1188
|
// APPEND TO DONE.md — the half this verb exists for.
|
|
952
1189
|
//
|
|
@@ -1034,12 +1271,75 @@ export async function landTool(args: {
|
|
|
1034
1271
|
}
|
|
1035
1272
|
}
|
|
1036
1273
|
|
|
1274
|
+
/*
|
|
1275
|
+
* ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
|
|
1276
|
+
*
|
|
1277
|
+
* `merge` refuses this BEFORE the fact and is the control that prevents. This
|
|
1278
|
+
* one runs after, and its job is different: it is how the fleet LEARNS a
|
|
1279
|
+
* crossing happened anyway — through a merge done by hand, by `gh` directly,
|
|
1280
|
+
* or by a seat that never called the verb.
|
|
1281
|
+
*
|
|
1282
|
+
* It REPORTS and never refuses. A closure is not the right place to litigate a
|
|
1283
|
+
* merge that already happened: the verdict is QA's artefact, the merge may have
|
|
1284
|
+
* been someone else's, and blocking the record would leave the work landed and
|
|
1285
|
+
* unrecorded — strictly worse than landed and recorded with a flag on it.
|
|
1286
|
+
*
|
|
1287
|
+
* ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
|
|
1288
|
+
*/
|
|
1289
|
+
const gatedHead = (() => {
|
|
1290
|
+
const mf = readMergeFacts(repo, n);
|
|
1291
|
+
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.` };
|
|
1292
|
+
const log = readVerdictLog(args.project);
|
|
1293
|
+
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.` };
|
|
1294
|
+
const at = Date.parse(mf.mergedAt);
|
|
1295
|
+
if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1296
|
+
const { verdicts: bus, unparsed } = verdictsFor(log, n);
|
|
1297
|
+
// ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
|
|
1298
|
+
// the PR page's typed lines. Gatedness is (head sha, typed verdict); the
|
|
1299
|
+
// merge time only DISCLOSES lateness, in words, beside the answer.
|
|
1300
|
+
const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])];
|
|
1301
|
+
const a = gatedBy(verdicts, mf.headRefOid, at);
|
|
1302
|
+
if (a.gated) {
|
|
1303
|
+
const late = (a.lateByMs ?? 0) > 0;
|
|
1304
|
+
return {
|
|
1305
|
+
checked: true as const,
|
|
1306
|
+
gated: true as const,
|
|
1307
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1308
|
+
// ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
|
|
1309
|
+
// sender alone; `attribution` says whether that is a seat or the account.
|
|
1310
|
+
by: {
|
|
1311
|
+
from: a.gater,
|
|
1312
|
+
sha: a.by.head.slice(0, 8),
|
|
1313
|
+
channel: a.by.channel ?? "bus",
|
|
1314
|
+
attribution: a.attribution,
|
|
1315
|
+
...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
|
|
1316
|
+
...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
|
|
1317
|
+
},
|
|
1318
|
+
recordedAfterMerge: late,
|
|
1319
|
+
verified: a.verified,
|
|
1320
|
+
...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
return {
|
|
1324
|
+
checked: true as const,
|
|
1325
|
+
gated: false as const,
|
|
1326
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1327
|
+
reason: a.reason,
|
|
1328
|
+
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1329
|
+
note:
|
|
1330
|
+
`UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1331
|
+
`The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
|
|
1332
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1333
|
+
};
|
|
1334
|
+
})();
|
|
1335
|
+
|
|
1037
1336
|
return {
|
|
1038
1337
|
ok: true as const,
|
|
1039
1338
|
project: args.project,
|
|
1040
1339
|
pr: `#${n}`,
|
|
1041
1340
|
comparedAgainst: ref,
|
|
1042
1341
|
landedIn: landedIn.slice(0, 8),
|
|
1342
|
+
gatedHead,
|
|
1043
1343
|
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
1044
1344
|
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
1045
1345
|
// fixed, so a foreign row cannot pass silently in either direction.
|
|
@@ -1069,6 +1369,11 @@ export async function landTool(args: {
|
|
|
1069
1369
|
candidates: target
|
|
1070
1370
|
? undefined
|
|
1071
1371
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
|
1372
|
+
// ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
|
|
1373
|
+
siblings,
|
|
1374
|
+
...(siblings.length
|
|
1375
|
+
? { note_siblings: `${siblings.length} open row(s) NAME an id #${n} cites (${citedIds.join(", ")}) and are not cited themselves — work shipped under another row's citation is how q-b4e7c209 and q-0c5e73a1 sat open; judge each, close none from here.` }
|
|
1376
|
+
: {}),
|
|
1072
1377
|
...(target || !candidates.length
|
|
1073
1378
|
? {}
|
|
1074
1379
|
: {
|
|
@@ -1151,13 +1456,17 @@ export type PrFacts = {
|
|
|
1151
1456
|
state: string;
|
|
1152
1457
|
mergeable: string;
|
|
1153
1458
|
checks: unknown[];
|
|
1459
|
+
/** The branch tip RIGHT NOW — what would actually be merged (⟨q-6a4f0c38⟩). */
|
|
1460
|
+
headRefOid?: string;
|
|
1154
1461
|
/** Unified diff of the PR, for the ticked-box audit. */
|
|
1155
1462
|
diff?: string;
|
|
1156
1463
|
/** Everything that will survive the merge as a citation: title + commit subjects + body. */
|
|
1157
1464
|
citationText?: string;
|
|
1465
|
+
/** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */
|
|
1466
|
+
comments?: PrComment[];
|
|
1158
1467
|
};
|
|
1159
1468
|
const ghFacts = (repo: string, n: string): PrFacts => {
|
|
1160
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
|
|
1469
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
1161
1470
|
cwd: repo,
|
|
1162
1471
|
encoding: "utf8",
|
|
1163
1472
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -1174,13 +1483,15 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1174
1483
|
// history: the PR title (which becomes the squash subject) and the commit
|
|
1175
1484
|
// subjects. The body is included because a reviewer reads it, but a claim
|
|
1176
1485
|
// that lives only in a comment thread is not a record.
|
|
1177
|
-
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}") as Record<string, unknown>;
|
|
1486
|
+
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record<string, unknown>;
|
|
1178
1487
|
const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
|
|
1179
1488
|
.map((c) => c.messageHeadline ?? "")
|
|
1180
1489
|
.join("\n");
|
|
1181
1490
|
return {
|
|
1182
1491
|
state: String(j.state ?? ""),
|
|
1183
1492
|
mergeable: String(j.mergeable ?? ""),
|
|
1493
|
+
headRefOid: String(j.headRefOid ?? ""),
|
|
1494
|
+
comments: ((meta.comments as { body?: string; createdAt?: string; author?: { login?: string } }[] | undefined) ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
1184
1495
|
checks: (j.statusCheckRollup as unknown[]) ?? [],
|
|
1185
1496
|
diff: gh(["pr", "diff", n]),
|
|
1186
1497
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1206,10 +1517,43 @@ const ghMerge: DoMerge = (repo, n, method) => {
|
|
|
1206
1517
|
});
|
|
1207
1518
|
};
|
|
1208
1519
|
|
|
1520
|
+
/**
|
|
1521
|
+
* The room log, injected for the same reason `doMerge` is: a test must be able to
|
|
1522
|
+
* drive every refusal branch without this host's ~/agent-coord.
|
|
1523
|
+
*
|
|
1524
|
+
* Returns null when the log cannot be read — distinct from "" (read, and empty),
|
|
1525
|
+
* because "no verdicts recorded" and "could not look" are different answers and
|
|
1526
|
+
* only one of them is safe to merge on.
|
|
1527
|
+
*/
|
|
1528
|
+
export type ReadVerdictLog = (project: string) => string | null;
|
|
1529
|
+
/**
|
|
1530
|
+
* ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
|
|
1531
|
+
* verdict recorded in another room still counts for this head. The project
|
|
1532
|
+
* room is read first so a missing bus is still a null (unknown), never an
|
|
1533
|
+
* empty string (nothing).
|
|
1534
|
+
*/
|
|
1535
|
+
const roomLog: ReadVerdictLog = (project) => {
|
|
1536
|
+
try {
|
|
1537
|
+
const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1538
|
+
let others = "";
|
|
1539
|
+
try {
|
|
1540
|
+
const dir = path.join(ROOT, "rooms");
|
|
1541
|
+
others = readdirSync(dir)
|
|
1542
|
+
.filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
|
|
1543
|
+
.map((f) => readFileSync(path.join(dir, f), "utf8"))
|
|
1544
|
+
.join("\n");
|
|
1545
|
+
} catch { /* other rooms are optional */ }
|
|
1546
|
+
return others ? `${own}\n${others}` : own;
|
|
1547
|
+
} catch {
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1209
1552
|
export async function mergeTool(
|
|
1210
1553
|
args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
|
|
1211
1554
|
facts: (repo: string, n: string) => PrFacts = ghFacts,
|
|
1212
1555
|
doMerge: DoMerge = ghMerge,
|
|
1556
|
+
readVerdictLog: ReadVerdictLog = roomLog,
|
|
1213
1557
|
) {
|
|
1214
1558
|
const repo = args.repo ?? process.cwd();
|
|
1215
1559
|
const n = prNumber(args.pr);
|
|
@@ -1318,8 +1662,56 @@ export async function mergeTool(
|
|
|
1318
1662
|
if (f.mergeable === "CONFLICTING")
|
|
1319
1663
|
return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
|
|
1320
1664
|
|
|
1665
|
+
/*
|
|
1666
|
+
* ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
|
|
1667
|
+
*
|
|
1668
|
+
* A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
|
|
1669
|
+
* and took `1d6deab`, because the author force-pushed in between. Checks were
|
|
1670
|
+
* green on BOTH heads, so every refusal above passed honestly. Nothing asked
|
|
1671
|
+
* the only question that mattered: is the head in front of me the head the
|
|
1672
|
+
* verdict named?
|
|
1673
|
+
*
|
|
1674
|
+
* THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
|
|
1675
|
+
* only report afterwards. It sits before the dry-run return on purpose: a dry
|
|
1676
|
+
* run must say it would refuse, or the preview disagrees with the act.
|
|
1677
|
+
*/
|
|
1678
|
+
const head = f.headRefOid ?? "";
|
|
1679
|
+
if (!head) {
|
|
1680
|
+
return {
|
|
1681
|
+
ok: false as const,
|
|
1682
|
+
error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
|
|
1683
|
+
verdict,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const log = readVerdictLog(args.project);
|
|
1687
|
+
if (log === null) {
|
|
1688
|
+
return {
|
|
1689
|
+
ok: false as const,
|
|
1690
|
+
error:
|
|
1691
|
+
`could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
|
|
1692
|
+
`was ever gated is UNKNOWN, and unknown is not gated.`,
|
|
1693
|
+
verdict,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
|
|
1697
|
+
// ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
|
|
1698
|
+
const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null);
|
|
1699
|
+
if (!gate.gated) {
|
|
1700
|
+
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1701
|
+
return {
|
|
1702
|
+
ok: false as const,
|
|
1703
|
+
error:
|
|
1704
|
+
`#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
|
|
1705
|
+
(named ? `Gated instead: ${named}. ` : "") +
|
|
1706
|
+
`Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
|
|
1707
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1708
|
+
verdict,
|
|
1709
|
+
gatedHead: { head, gated: false as const, reason: gate.reason },
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1321
1713
|
if (!args.write)
|
|
1322
|
-
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.` };
|
|
1714
|
+
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.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` };
|
|
1323
1715
|
|
|
1324
1716
|
doMerge(repo, n, args.method ?? "squash");
|
|
1325
1717
|
return { ok: true as const, merged: true as const, verdict };
|