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/dist/tools/records.js
CHANGED
|
@@ -12,13 +12,16 @@
|
|
|
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
|
-
import {
|
|
18
|
+
import { treeProvenance } from "./tree-provenance.js";
|
|
19
|
+
import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, } from "@davidbalzan/groundwork-seam";
|
|
19
20
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
21
|
+
import { ROOT } from "../store.js";
|
|
22
|
+
import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
20
23
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
21
|
-
import { haltState
|
|
24
|
+
import { haltState } from "./stall.js";
|
|
22
25
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
23
26
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
24
27
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -249,18 +252,60 @@ export function noDownstream(items) {
|
|
|
249
252
|
function rowSubjectIds(row) {
|
|
250
253
|
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
|
|
251
254
|
}
|
|
255
|
+
/*
|
|
256
|
+
* ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
|
|
257
|
+
* MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
|
|
258
|
+
* `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
|
|
259
|
+
* green in an open PR and the other has nothing behind it. Reporting them under
|
|
260
|
+
* one word re-creates the defect with a wider net: a reader told "parked" for
|
|
261
|
+
* `#292`'s row would go and build it.
|
|
262
|
+
*
|
|
263
|
+
* Two INDEPENDENT facts, deliberately not one: the WORD comes from the
|
|
264
|
+
* declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
|
|
265
|
+
* Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
|
|
266
|
+
* and that disagreement is worth seeing rather than resolving.
|
|
267
|
+
*/
|
|
268
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
269
|
+
function holdReasonOf(state, status) {
|
|
270
|
+
if (state !== "parked")
|
|
271
|
+
return state;
|
|
272
|
+
const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
|
|
273
|
+
if (/\bheld\b/i.test(decl))
|
|
274
|
+
return "held";
|
|
275
|
+
if (/\bqueued\b/i.test(decl))
|
|
276
|
+
return "queued";
|
|
277
|
+
return "parked";
|
|
278
|
+
}
|
|
252
279
|
/**
|
|
253
|
-
*
|
|
280
|
+
* What does the board say about this item, if it is the SUBJECT of any row?
|
|
281
|
+
*
|
|
282
|
+
* ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
|
|
283
|
+
* (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
|
|
284
|
+
* only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
|
|
285
|
+
* its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
|
|
286
|
+
* Measured on the live board 2026-09-14: seven `⏸` rows, every one the
|
|
287
|
+
* coordinator's most careful bookkeeping, every one invisible to this join.
|
|
254
288
|
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
289
|
+
* ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
|
|
290
|
+
* fix made a `⏸ Parked` row NOT silence its item, because the alternative
|
|
291
|
+
* then was a SILENT drop with no axis. The drop is no longer silent: the item
|
|
292
|
+
* lands on `delivered` with the row's state as its `why`, and an `AWAITS`
|
|
293
|
+
* item still reaches `awaitingDecision`, which is gathered over every open
|
|
294
|
+
* item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
|
|
295
|
+
* must not make its item VANISH — still holds; what changes is that a warning
|
|
296
|
+
* row now also stops the item being HANDED OUT, which is what a warning is for.
|
|
297
|
+
*
|
|
298
|
+
* Conditions:
|
|
260
299
|
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
261
300
|
* text prefix `claim` has always written there. A mention in a Last
|
|
262
301
|
* note is one lane REFERRING to another item — the aide's live row
|
|
263
302
|
* does exactly this — and a status-only rule would still hide it.
|
|
303
|
+
* STATE whatever the seam reads: in-progress, in-review, parked (split
|
|
304
|
+
* into held · queued · parked, see `holdReasonOf`), blocked,
|
|
305
|
+
* orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
|
|
306
|
+
* excludes: the row is on the board with this item as its subject,
|
|
307
|
+
* and offering it anyway is the silent path this exists to close.
|
|
308
|
+
* A `✅ Done` row excludes too — that item is offerable to nobody.
|
|
264
309
|
*
|
|
265
310
|
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
266
311
|
* would have shipped a transitional hole in its own negative control: every row
|
|
@@ -271,16 +316,24 @@ function rowSubjectIds(row) {
|
|
|
271
316
|
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
272
317
|
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
273
318
|
*/
|
|
274
|
-
function
|
|
319
|
+
function boardHoldOf(rows, id, text) {
|
|
275
320
|
const key = keyOf({ text }).replace(/\s+/g, " ").trim();
|
|
276
|
-
|
|
277
|
-
if (!isInFlightStatus(r.status))
|
|
278
|
-
return false;
|
|
279
|
-
if (rowSubjectIds(r).includes(id))
|
|
280
|
-
return true;
|
|
321
|
+
for (const r of rows) {
|
|
281
322
|
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
282
|
-
|
|
283
|
-
|
|
323
|
+
const subject = rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
|
|
324
|
+
if (!subject)
|
|
325
|
+
continue;
|
|
326
|
+
const state = workStateOf(r.status);
|
|
327
|
+
const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
|
|
328
|
+
return {
|
|
329
|
+
state,
|
|
330
|
+
why: holdReasonOf(state, r.status),
|
|
331
|
+
status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
|
|
332
|
+
builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
|
|
333
|
+
branch,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
284
337
|
}
|
|
285
338
|
/**
|
|
286
339
|
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
@@ -308,7 +361,7 @@ function boardHoldsItem(rows, id, text) {
|
|
|
308
361
|
* false positives, because open rows cite refs as EVIDENCE.
|
|
309
362
|
*
|
|
310
363
|
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
311
|
-
* CLASS — the same distinction that keeps `
|
|
364
|
+
* CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
|
|
312
365
|
* delivery from prose: it requires the entry to carry the item's own
|
|
313
366
|
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
314
367
|
* A citation cannot accidentally satisfy it.
|
|
@@ -351,9 +404,16 @@ export async function nextUnblockedTool(args) {
|
|
|
351
404
|
};
|
|
352
405
|
}
|
|
353
406
|
const repo = args.repo ?? process.cwd();
|
|
407
|
+
// ⛔⛆ SAY WHICH TREE THIS ANSWER CAME FROM (⟨q-c1af2db3⟩). This verb reads
|
|
408
|
+
// `docs/QUEUE.md` out of a working tree nobody owns, and a stale one produced
|
|
409
|
+
// a confidently wrong routing decision in both directions inside ten minutes —
|
|
410
|
+
// a row ruled un-claimable from a stale file, and a worker told to hold on a
|
|
411
|
+
// row that was already split. The answer was well-formed and said nothing
|
|
412
|
+
// about its source, which is what made it invisible.
|
|
413
|
+
const tree = treeProvenance(repo);
|
|
354
414
|
const q = readDoc(repo, QUEUE_DOC);
|
|
355
415
|
if (!q)
|
|
356
|
-
return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'
|
|
416
|
+
return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'`, tree };
|
|
357
417
|
const items = queueItemsOf(q.doc);
|
|
358
418
|
// The seam's own count of unticked rows — the number every axis must add back
|
|
359
419
|
// up to. Taken BEFORE any exclusion so it cannot inherit one.
|
|
@@ -397,20 +457,34 @@ export async function nextUnblockedTool(args) {
|
|
|
397
457
|
if (i.done)
|
|
398
458
|
continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
399
459
|
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
400
|
-
// routing: both causes exclude, but a
|
|
401
|
-
// (wait
|
|
402
|
-
|
|
403
|
-
|
|
460
|
+
// routing: both causes exclude, but a board row is a different remedy
|
|
461
|
+
// (wait for the merge · ask its owner · ask the human it awaits, by its
|
|
462
|
+
// `why`) from a landed delivery (close the row).
|
|
463
|
+
const hold = boardHoldOf(boardRows, i.id, String(i.text));
|
|
464
|
+
if (hold)
|
|
465
|
+
deliveredBy.set(i.id, { reason: "board", hold });
|
|
404
466
|
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
|
|
405
|
-
deliveredBy.set(i.id, "done");
|
|
467
|
+
deliveredBy.set(i.id, { reason: "done" });
|
|
406
468
|
}
|
|
407
469
|
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
408
470
|
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
409
471
|
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
410
472
|
// reported silently is the same defect as an incorrect one.
|
|
473
|
+
//
|
|
474
|
+
// ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
|
|
475
|
+
// the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
|
|
476
|
+
// and the two want opposite things from the reader — leave the first alone,
|
|
477
|
+
// MERGE the second. `why` is the seam's state (parked split into held ·
|
|
478
|
+
// queued · parked) and `builtWork` says whether the row names a branch at
|
|
479
|
+
// all, so "held with nothing behind it" is visible rather than resolved.
|
|
411
480
|
const delivered = items
|
|
412
481
|
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
413
|
-
.map((i) =>
|
|
482
|
+
.map((i) => {
|
|
483
|
+
const d = deliveredBy.get(i.id);
|
|
484
|
+
return d.reason === "board"
|
|
485
|
+
? { item: keyOf(i), id: i.id, reason: "board", why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch }
|
|
486
|
+
: { item: keyOf(i), id: i.id, reason: "done", why: "recorded in DONE.md" };
|
|
487
|
+
});
|
|
414
488
|
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
415
489
|
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
416
490
|
// two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
|
|
@@ -500,6 +574,10 @@ export async function nextUnblockedTool(args) {
|
|
|
500
574
|
// the severity finding one file over. Say the axis is uninformative instead.
|
|
501
575
|
const undiscriminating = silent.length === open.length && open.length > 1;
|
|
502
576
|
return {
|
|
577
|
+
// ⛔ THE PROVENANCE TRAVELS WITH THE ANSWER, not in a second call. A
|
|
578
|
+
// routing answer whose tree is unnamed is the defect this row exists for.
|
|
579
|
+
tree,
|
|
580
|
+
...(tree.warning ? { staleWarning: tree.warning } : {}),
|
|
503
581
|
ok: true,
|
|
504
582
|
project: args.project,
|
|
505
583
|
open: open.length,
|
|
@@ -541,7 +619,7 @@ export async function nextUnblockedTool(args) {
|
|
|
541
619
|
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
542
620
|
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
543
621
|
...delivered.map((d) => d.reason === "board"
|
|
544
|
-
? `⏭ not offered —
|
|
622
|
+
? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
|
|
545
623
|
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
|
|
546
624
|
],
|
|
547
625
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
@@ -608,6 +686,13 @@ export const claimSchema = {
|
|
|
608
686
|
base: z.string().optional(),
|
|
609
687
|
task: z.string().optional(),
|
|
610
688
|
write: z.boolean().optional(),
|
|
689
|
+
/**
|
|
690
|
+
* ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
|
|
691
|
+
* What the slice delivers instead (a ruling, a canon edit, a measurement).
|
|
692
|
+
* No worktree is cut; the board cell carries the statement in words, which
|
|
693
|
+
* the grammar and `stall_check` both read as DELIBERATE rather than missing.
|
|
694
|
+
*/
|
|
695
|
+
noCodeDeliverable: z.string().min(1).optional(),
|
|
611
696
|
};
|
|
612
697
|
export async function claimTool(args) {
|
|
613
698
|
const halt = haltState();
|
|
@@ -646,6 +731,25 @@ export async function claimTool(args) {
|
|
|
646
731
|
// A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
|
|
647
732
|
// item to an agent with nowhere isolated to work is the shared-checkout failure
|
|
648
733
|
// this pair exists to prevent.
|
|
734
|
+
// ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
|
|
735
|
+
// seats wrote this by hand in five spellings because the verb had no way to
|
|
736
|
+
// say it; `stall_check` reads the prose cell as deliberately branchless and
|
|
737
|
+
// keeps the row out of its population rather than calling it unmeasurable.
|
|
738
|
+
if (args.noCodeDeliverable) {
|
|
739
|
+
const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
|
|
740
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
|
|
741
|
+
let board = { action: "reported" };
|
|
742
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
743
|
+
if (!b)
|
|
744
|
+
board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
745
|
+
else if (args.write) {
|
|
746
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
747
|
+
if (next.action !== "unchanged")
|
|
748
|
+
writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
749
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
750
|
+
}
|
|
751
|
+
return { ok: true, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable };
|
|
752
|
+
}
|
|
649
753
|
const wt = await ensureWorktreeTool({
|
|
650
754
|
agentId: args.agentId,
|
|
651
755
|
repo,
|
|
@@ -776,7 +880,42 @@ export const landSchema = {
|
|
|
776
880
|
/** One-line result, written after the citation on the closed item's line. */
|
|
777
881
|
result: z.string().optional(),
|
|
778
882
|
};
|
|
779
|
-
|
|
883
|
+
const ghMergeFacts = (repo, n) => {
|
|
884
|
+
try {
|
|
885
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
|
|
886
|
+
cwd: repo,
|
|
887
|
+
encoding: "utf8",
|
|
888
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
889
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
890
|
+
});
|
|
891
|
+
const j = JSON.parse(out);
|
|
892
|
+
if (!j.headRefOid || !j.mergedAt)
|
|
893
|
+
return null;
|
|
894
|
+
return {
|
|
895
|
+
headRefOid: String(j.headRefOid),
|
|
896
|
+
mergedAt: String(j.mergedAt),
|
|
897
|
+
comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
/** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
|
|
905
|
+
export function landingCitations(repo, n) {
|
|
906
|
+
let subjects = "";
|
|
907
|
+
for (const base of ["origin/main", "main"]) {
|
|
908
|
+
try {
|
|
909
|
+
subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]);
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
catch { /* next */ }
|
|
913
|
+
}
|
|
914
|
+
const marker = `(#${n})`;
|
|
915
|
+
const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
|
|
916
|
+
return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
|
|
917
|
+
}
|
|
918
|
+
export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
|
|
780
919
|
const repo = args.repo ?? process.cwd();
|
|
781
920
|
const base = args.base ?? "main";
|
|
782
921
|
const n = prNumber(args.pr);
|
|
@@ -831,6 +970,17 @@ export async function landTool(args) {
|
|
|
831
970
|
// `queueItemId` it closes nothing and reports the candidates for the caller to
|
|
832
971
|
// pick, saying so.
|
|
833
972
|
const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
|
|
973
|
+
// ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
|
|
974
|
+
// shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
|
|
975
|
+
// (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
|
|
976
|
+
// The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
|
|
977
|
+
// every check reads, local, no network — and listed beside the #N candidates
|
|
978
|
+
// at the one moment both facts are in hand. Closing nothing: a sibling is a
|
|
979
|
+
// question for the coordinator, not a claim.
|
|
980
|
+
const citedIds = landingCitations(repo, n);
|
|
981
|
+
const siblings = items
|
|
982
|
+
.filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
|
|
983
|
+
.map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
|
|
834
984
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
835
985
|
if (args.queueItemId && !target)
|
|
836
986
|
return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
|
|
@@ -869,7 +1019,35 @@ export async function landTool(args) {
|
|
|
869
1019
|
queueChanged = true;
|
|
870
1020
|
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
871
1021
|
const contextRepo = originRepoOf(repo);
|
|
872
|
-
|
|
1022
|
+
/*
|
|
1023
|
+
* ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
|
|
1024
|
+
*
|
|
1025
|
+
* This asked whether the PR number appears ANYWHERE in the row, so a row that
|
|
1026
|
+
* DISCUSSES its own closer read as already cited and `land` wrote no token.
|
|
1027
|
+
* The natural experiment that isolates it — three rows, one variable:
|
|
1028
|
+
*
|
|
1029
|
+
* ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
|
|
1030
|
+
* ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
|
|
1031
|
+
* ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
|
|
1032
|
+
*
|
|
1033
|
+
* ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
|
|
1034
|
+
* somebody else's PRs is cited normally; a row naming its own once is not. So the
|
|
1035
|
+
* better a row documents what closed it, the more certainly the closure goes
|
|
1036
|
+
* unwritten — and the ARTEFACT is what loses, silently.
|
|
1037
|
+
*
|
|
1038
|
+
* ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
|
|
1039
|
+
* ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
|
|
1040
|
+
* a line-1 prose mention and `doctor` falls back to the row. That was measured and
|
|
1041
|
+
* a patch was reverted rather than shipped. What nobody asked was what it does to
|
|
1042
|
+
* the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
|
|
1043
|
+
* makes a ref a citation, and that rule applies to the verb that WRITES one.
|
|
1044
|
+
*
|
|
1045
|
+
* ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
|
|
1046
|
+
* rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
|
|
1047
|
+
* must never happen.
|
|
1048
|
+
*/
|
|
1049
|
+
const closingHere = closingCitation(String(target.text))?.refs ?? [];
|
|
1050
|
+
const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
873
1051
|
if (!alreadyCited) {
|
|
874
1052
|
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
875
1053
|
target.text = `${target.text}${citationAppended}`;
|
|
@@ -887,7 +1065,40 @@ export async function landTool(args) {
|
|
|
887
1065
|
// as UNDER-QUALIFIED rather than silently written.
|
|
888
1066
|
const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
|
|
889
1067
|
const summary = originalText !== null ? summarize(originalText) : null;
|
|
890
|
-
|
|
1068
|
+
/*
|
|
1069
|
+
* ⛔⛆⛆ THE ROW'S ID LEADS THE ENTRY, BECAUSE A TRUNCATED HEADLINE CANNOT CARRY IT
|
|
1070
|
+
* — `⟨q-d046204d⟩`.
|
|
1071
|
+
*
|
|
1072
|
+
* `summarize` cuts at 96 characters and appends `…`. The sigil was never omitted
|
|
1073
|
+
* from these entries: IT WAS CUT, because in the row it sits later than the
|
|
1074
|
+
* truncation point. So the entry came out with a PERFECT `ref` slot and a `text`
|
|
1075
|
+
* that names no row — and `closingRefs` needs BOTH halves, so the row reads
|
|
1076
|
+
* untied and `main` goes red. `⟨q-c1af2db3⟩` at `17a9ffc` is that, measured.
|
|
1077
|
+
*
|
|
1078
|
+
* ⭐⭐ THE FIX IS POSITION, NOT LENGTH. Putting the id BEFORE the summary takes it
|
|
1079
|
+
* out of the truncated region BY CONSTRUCTION, so it survives any headline length
|
|
1080
|
+
* — where raising `max` only moves the cliff. The row is explicit that "write
|
|
1081
|
+
* longer headlines" is not an acceptable fix, and it is right: a length that is
|
|
1082
|
+
* enough today is a truncation tomorrow.
|
|
1083
|
+
*
|
|
1084
|
+
* ✅ AND THE CONSUMER ALREADY EXPECTS THIS SPELLING. `doneRecordsDelivery` strips
|
|
1085
|
+
* a leading `⟨q-…⟩` before comparing, and says why: "an entry written as `⟨id⟩
|
|
1086
|
+
* <summary>` and one written as `<summary>` agree". So the composed-summary arm
|
|
1087
|
+
* keeps matching and this needed no change there.
|
|
1088
|
+
*
|
|
1089
|
+
* ⚠ THIS IS NOT THE ARM `⟨q-4a1e70c5⟩` REMOVED, and the difference is the whole
|
|
1090
|
+
* reason this is safe. That arm INFERRED delivery from a sigil appearing ANYWHERE
|
|
1091
|
+
* in an entry — a reader-side guess that produced eleven false positives, because
|
|
1092
|
+
* this repo's canon requires entries to cite item ids for other reasons. This
|
|
1093
|
+
* writes the id in a DETERMINISTIC LEADING POSITION so the tie can be read. It
|
|
1094
|
+
* infers nothing, and re-reading a sigil as "delivered" is still wrong.
|
|
1095
|
+
*
|
|
1096
|
+
* The id is the ITEM's, taken from the record rather than re-derived: `target` is
|
|
1097
|
+
* non-null whenever `summary` is, since the summary comes from its text.
|
|
1098
|
+
*/
|
|
1099
|
+
const doneLine = already || !summary || !target
|
|
1100
|
+
? null
|
|
1101
|
+
: `- [x] ⟨${target.id}⟩ ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
|
|
891
1102
|
// APPEND TO DONE.md — the half this verb exists for.
|
|
892
1103
|
//
|
|
893
1104
|
// The first `write:true` run closed the queue item and wrote NOTHING to
|
|
@@ -978,12 +1189,76 @@ export async function landTool(args) {
|
|
|
978
1189
|
events = { emitted: [ev], deliveries, refused: [] };
|
|
979
1190
|
}
|
|
980
1191
|
}
|
|
1192
|
+
/*
|
|
1193
|
+
* ⛔⛆ SECOND LINE: WAS THE THING MERGED THE THING GATED? (kit#268, ⟨q-6a4f0c38⟩)
|
|
1194
|
+
*
|
|
1195
|
+
* `merge` refuses this BEFORE the fact and is the control that prevents. This
|
|
1196
|
+
* one runs after, and its job is different: it is how the fleet LEARNS a
|
|
1197
|
+
* crossing happened anyway — through a merge done by hand, by `gh` directly,
|
|
1198
|
+
* or by a seat that never called the verb.
|
|
1199
|
+
*
|
|
1200
|
+
* It REPORTS and never refuses. A closure is not the right place to litigate a
|
|
1201
|
+
* merge that already happened: the verdict is QA's artefact, the merge may have
|
|
1202
|
+
* been someone else's, and blocking the record would leave the work landed and
|
|
1203
|
+
* unrecorded — strictly worse than landed and recorded with a flag on it.
|
|
1204
|
+
*
|
|
1205
|
+
* ⚠ AND IT SAYS SO WHEN IT COULD NOT LOOK. An omitted field reads as "fine".
|
|
1206
|
+
*/
|
|
1207
|
+
const gatedHead = (() => {
|
|
1208
|
+
const mf = readMergeFacts(repo, n);
|
|
1209
|
+
if (!mf)
|
|
1210
|
+
return { checked: false, note: `could not read #${n}'s merged head and merge time — whether the merged head was gated is UNKNOWN, not clean.` };
|
|
1211
|
+
const log = readVerdictLog(args.project);
|
|
1212
|
+
if (log === null)
|
|
1213
|
+
return { checked: false, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
|
|
1214
|
+
const at = Date.parse(mf.mergedAt);
|
|
1215
|
+
if (!Number.isFinite(at))
|
|
1216
|
+
return { checked: false, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1217
|
+
const { verdicts: bus, unparsed } = verdictsFor(log, n);
|
|
1218
|
+
// ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
|
|
1219
|
+
// the PR page's typed lines. Gatedness is (head sha, typed verdict); the
|
|
1220
|
+
// merge time only DISCLOSES lateness, in words, beside the answer.
|
|
1221
|
+
const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(mf.comments ?? [])];
|
|
1222
|
+
const a = gatedBy(verdicts, mf.headRefOid, at);
|
|
1223
|
+
if (a.gated) {
|
|
1224
|
+
const late = (a.lateByMs ?? 0) > 0;
|
|
1225
|
+
return {
|
|
1226
|
+
checked: true,
|
|
1227
|
+
gated: true,
|
|
1228
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1229
|
+
// ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
|
|
1230
|
+
// sender alone; `attribution` says whether that is a seat or the account.
|
|
1231
|
+
by: {
|
|
1232
|
+
from: a.gater,
|
|
1233
|
+
sha: a.by.head.slice(0, 8),
|
|
1234
|
+
channel: a.by.channel ?? "bus",
|
|
1235
|
+
attribution: a.attribution,
|
|
1236
|
+
...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
|
|
1237
|
+
...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
|
|
1238
|
+
},
|
|
1239
|
+
recordedAfterMerge: late,
|
|
1240
|
+
verified: a.verified,
|
|
1241
|
+
...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
return {
|
|
1245
|
+
checked: true,
|
|
1246
|
+
gated: false,
|
|
1247
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1248
|
+
reason: a.reason,
|
|
1249
|
+
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1250
|
+
note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1251
|
+
`The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
|
|
1252
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1253
|
+
};
|
|
1254
|
+
})();
|
|
981
1255
|
return {
|
|
982
1256
|
ok: true,
|
|
983
1257
|
project: args.project,
|
|
984
1258
|
pr: `#${n}`,
|
|
985
1259
|
comparedAgainst: ref,
|
|
986
1260
|
landedIn: landedIn.slice(0, 8),
|
|
1261
|
+
gatedHead,
|
|
987
1262
|
// THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
|
|
988
1263
|
// diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
|
|
989
1264
|
// fixed, so a foreign row cannot pass silently in either direction.
|
|
@@ -1012,6 +1287,11 @@ export async function landTool(args) {
|
|
|
1012
1287
|
candidates: target
|
|
1013
1288
|
? undefined
|
|
1014
1289
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
|
1290
|
+
// ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
|
|
1291
|
+
siblings,
|
|
1292
|
+
...(siblings.length
|
|
1293
|
+
? { 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.` }
|
|
1294
|
+
: {}),
|
|
1015
1295
|
...(target || !candidates.length
|
|
1016
1296
|
? {}
|
|
1017
1297
|
: {
|
|
@@ -1063,7 +1343,7 @@ export const mergeSchema = {
|
|
|
1063
1343
|
write: z.boolean().optional(),
|
|
1064
1344
|
};
|
|
1065
1345
|
const ghFacts = (repo, n) => {
|
|
1066
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
|
|
1346
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
1067
1347
|
cwd: repo,
|
|
1068
1348
|
encoding: "utf8",
|
|
1069
1349
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -1081,13 +1361,15 @@ const ghFacts = (repo, n) => {
|
|
|
1081
1361
|
// history: the PR title (which becomes the squash subject) and the commit
|
|
1082
1362
|
// subjects. The body is included because a reviewer reads it, but a claim
|
|
1083
1363
|
// that lives only in a comment thread is not a record.
|
|
1084
|
-
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}");
|
|
1364
|
+
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}");
|
|
1085
1365
|
const subjects = (meta.commits ?? [])
|
|
1086
1366
|
.map((c) => c.messageHeadline ?? "")
|
|
1087
1367
|
.join("\n");
|
|
1088
1368
|
return {
|
|
1089
1369
|
state: String(j.state ?? ""),
|
|
1090
1370
|
mergeable: String(j.mergeable ?? ""),
|
|
1371
|
+
headRefOid: String(j.headRefOid ?? ""),
|
|
1372
|
+
comments: (meta.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
1091
1373
|
checks: j.statusCheckRollup ?? [],
|
|
1092
1374
|
diff: gh(["pr", "diff", n]),
|
|
1093
1375
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1100,7 +1382,31 @@ const ghMerge = (repo, n, method) => {
|
|
|
1100
1382
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1101
1383
|
});
|
|
1102
1384
|
};
|
|
1103
|
-
|
|
1385
|
+
/**
|
|
1386
|
+
* ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
|
|
1387
|
+
* verdict recorded in another room still counts for this head. The project
|
|
1388
|
+
* room is read first so a missing bus is still a null (unknown), never an
|
|
1389
|
+
* empty string (nothing).
|
|
1390
|
+
*/
|
|
1391
|
+
const roomLog = (project) => {
|
|
1392
|
+
try {
|
|
1393
|
+
const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1394
|
+
let others = "";
|
|
1395
|
+
try {
|
|
1396
|
+
const dir = path.join(ROOT, "rooms");
|
|
1397
|
+
others = readdirSync(dir)
|
|
1398
|
+
.filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
|
|
1399
|
+
.map((f) => readFileSync(path.join(dir, f), "utf8"))
|
|
1400
|
+
.join("\n");
|
|
1401
|
+
}
|
|
1402
|
+
catch { /* other rooms are optional */ }
|
|
1403
|
+
return others ? `${own}\n${others}` : own;
|
|
1404
|
+
}
|
|
1405
|
+
catch {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVerdictLog = roomLog) {
|
|
1104
1410
|
const repo = args.repo ?? process.cwd();
|
|
1105
1411
|
const n = prNumber(args.pr);
|
|
1106
1412
|
if (!n)
|
|
@@ -1197,8 +1503,53 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
|
|
|
1197
1503
|
}
|
|
1198
1504
|
if (f.mergeable === "CONFLICTING")
|
|
1199
1505
|
return { ok: false, error: `#${n} is CONFLICTING with its base.`, verdict };
|
|
1506
|
+
/*
|
|
1507
|
+
* ⛔⛆ THE THING MERGED MUST BE THE THING GATED — kit#268, ⟨q-6a4f0c38⟩.
|
|
1508
|
+
*
|
|
1509
|
+
* A PASS was posted for `6051193` at 10:23:14Z; the merge ran 8 seconds later
|
|
1510
|
+
* and took `1d6deab`, because the author force-pushed in between. Checks were
|
|
1511
|
+
* green on BOTH heads, so every refusal above passed honestly. Nothing asked
|
|
1512
|
+
* the only question that mattered: is the head in front of me the head the
|
|
1513
|
+
* verdict named?
|
|
1514
|
+
*
|
|
1515
|
+
* THIS IS THE FIRST-LINE CONTROL because it PREVENTS. The recording step can
|
|
1516
|
+
* only report afterwards. It sits before the dry-run return on purpose: a dry
|
|
1517
|
+
* run must say it would refuse, or the preview disagrees with the act.
|
|
1518
|
+
*/
|
|
1519
|
+
const head = f.headRefOid ?? "";
|
|
1520
|
+
if (!head) {
|
|
1521
|
+
return {
|
|
1522
|
+
ok: false,
|
|
1523
|
+
error: `could not read #${n}'s current head — NOT read, which is not the same as read and matching the verdict.`,
|
|
1524
|
+
verdict,
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
const log = readVerdictLog(args.project);
|
|
1528
|
+
if (log === null) {
|
|
1529
|
+
return {
|
|
1530
|
+
ok: false,
|
|
1531
|
+
error: `could not read the verdict log for project '${args.project}' — so whether #${n}'s head ${head.slice(0, 7)} ` +
|
|
1532
|
+
`was ever gated is UNKNOWN, and unknown is not gated.`,
|
|
1533
|
+
verdict,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
|
|
1537
|
+
// ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
|
|
1538
|
+
const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(f.comments ?? [])], head, null);
|
|
1539
|
+
if (!gate.gated) {
|
|
1540
|
+
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1541
|
+
return {
|
|
1542
|
+
ok: false,
|
|
1543
|
+
error: `#${n}'s head is ${head.slice(0, 7)} and ${gate.reason}. ` +
|
|
1544
|
+
(named ? `Gated instead: ${named}. ` : "") +
|
|
1545
|
+
`Re-gate this head before merging — a PASS that must be re-issued is cheap, a merge nobody gated is not.` +
|
|
1546
|
+
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1547
|
+
verdict,
|
|
1548
|
+
gatedHead: { head, gated: false, reason: gate.reason },
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1200
1551
|
if (!args.write)
|
|
1201
|
-
return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
|
|
1552
|
+
return { ok: true, merged: false, 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.` };
|
|
1202
1553
|
doMerge(repo, n, args.method ?? "squash");
|
|
1203
1554
|
return { ok: true, merged: true, verdict };
|
|
1204
1555
|
}
|