agent-coord-mcp 0.26.21 → 0.26.23
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 +270 -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 +67 -20
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +2 -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 +40 -4
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +72 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +310 -44
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +67 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +182 -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 +71 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/dist/transports/herdr.js +297 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.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 +284 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +128 -26
- package/src/server-spread.ts +233 -0
- package/src/server.ts +2 -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 +19 -2
- package/src/tools/events.ts +42 -4
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +71 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +316 -44
- package/src/tools/registry.ts +68 -4
- package/src/tools/seat-build.ts +201 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +69 -2
- package/src/tools/worktrees.ts +13 -0
- package/src/transports/herdr.ts +311 -0
- package/src/transports/index.ts +10 -4
package/dist/tools/records.js
CHANGED
|
@@ -12,17 +12,18 @@
|
|
|
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
18
|
import { treeProvenance } from "./tree-provenance.js";
|
|
19
|
-
import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
|
|
19
|
+
import { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, closingCitation, refsMatch, workstreamsV1RowsOf, workStateOf, declarationOf, } from "@davidbalzan/groundwork-seam";
|
|
20
20
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
21
21
|
import { ROOT } from "../store.js";
|
|
22
|
-
import { verdictsFor,
|
|
22
|
+
import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
23
23
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
24
|
-
import { haltState
|
|
24
|
+
import { haltState } from "./stall.js";
|
|
25
25
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
|
|
26
|
+
import { prRefsIn } from "./record-events.js";
|
|
26
27
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
27
28
|
const DONE_DOC = "docs/DONE.md";
|
|
28
29
|
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
@@ -133,6 +134,62 @@ function prNumber(pr) {
|
|
|
133
134
|
const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
|
|
134
135
|
return m ? m[1] : null;
|
|
135
136
|
}
|
|
137
|
+
/** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */
|
|
138
|
+
function prNumbersOf(pr) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const m of String(pr).matchAll(/#(\d+)\b/g))
|
|
141
|
+
if (!out.includes(m[1]))
|
|
142
|
+
out.push(m[1]);
|
|
143
|
+
if (!out.length) {
|
|
144
|
+
const one = prNumber(pr);
|
|
145
|
+
if (one)
|
|
146
|
+
out.push(one);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/*
|
|
151
|
+
* ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed
|
|
152
|
+
* #245's squash 777 commits back on an 1806-commit main and the verb then
|
|
153
|
+
* asserted "#245 is not on origin/main" as a fact — a truncated search reported
|
|
154
|
+
* as an absence, at the verb that writes the delivery record, and worded with a
|
|
155
|
+
* mechanism (target tip vs merge base) that had nothing to do with it. At the
|
|
156
|
+
* fleet's speed (~200 record commits a day) 400 is about two days, so it fires
|
|
157
|
+
* precisely on the oldest rows, the ones closed late.
|
|
158
|
+
*
|
|
159
|
+
* Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged
|
|
160
|
+
* BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use:
|
|
161
|
+
* the landing is the commit whose subject ENDS with the forge's `(#N)` marker.
|
|
162
|
+
* A bare `#N` anywhere is a mention, never a landing — qa measured the first
|
|
163
|
+
* cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322
|
|
164
|
+
* squash-merged PRs resolved to their CLOSURE or board commit ("docs(record):
|
|
165
|
+
* close … by content on #245"), which carry the bare number in the subject and
|
|
166
|
+
* sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a
|
|
167
|
+
* hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's
|
|
168
|
+
* squash and would otherwise be taken for it. The one PR of 323 on this main
|
|
169
|
+
* that landed as a merge commit (#108, "Merge pull request #108 from …") is the
|
|
170
|
+
* second identity form — the forge's own, not a mention — and is the last
|
|
171
|
+
* resort, consulted only when no trailing form exists. A miss reports what was
|
|
172
|
+
* searched — every subject on the ref — never a mechanism it did not test.
|
|
173
|
+
*/
|
|
174
|
+
export function landingCommitOf(repo, ref, n) {
|
|
175
|
+
const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0;
|
|
176
|
+
// git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below.
|
|
177
|
+
const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]);
|
|
178
|
+
const trailing = new RegExp(`\\(#${n}\\)\\s*$`);
|
|
179
|
+
const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`);
|
|
180
|
+
let landing = null;
|
|
181
|
+
let merge = null;
|
|
182
|
+
for (const line of out.split("\n")) {
|
|
183
|
+
const [sha, subject = ""] = line.split("\x00");
|
|
184
|
+
if (!sha)
|
|
185
|
+
continue;
|
|
186
|
+
if (trailing.test(subject))
|
|
187
|
+
landing = sha; // newest first: the last hit is the OLDEST
|
|
188
|
+
else if (!merge && mergeForm.test(subject))
|
|
189
|
+
merge = sha;
|
|
190
|
+
}
|
|
191
|
+
return { sha: landing ?? merge, searched };
|
|
192
|
+
}
|
|
136
193
|
/**
|
|
137
194
|
* The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
|
|
138
195
|
* the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
|
|
@@ -252,18 +309,60 @@ export function noDownstream(items) {
|
|
|
252
309
|
function rowSubjectIds(row) {
|
|
253
310
|
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
|
|
254
311
|
}
|
|
312
|
+
/*
|
|
313
|
+
* ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
|
|
314
|
+
* MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
|
|
315
|
+
* `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
|
|
316
|
+
* green in an open PR and the other has nothing behind it. Reporting them under
|
|
317
|
+
* one word re-creates the defect with a wider net: a reader told "parked" for
|
|
318
|
+
* `#292`'s row would go and build it.
|
|
319
|
+
*
|
|
320
|
+
* Two INDEPENDENT facts, deliberately not one: the WORD comes from the
|
|
321
|
+
* declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
|
|
322
|
+
* Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
|
|
323
|
+
* and that disagreement is worth seeing rather than resolving.
|
|
324
|
+
*/
|
|
325
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
326
|
+
function holdReasonOf(state, status) {
|
|
327
|
+
if (state !== "parked")
|
|
328
|
+
return state;
|
|
329
|
+
const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
|
|
330
|
+
if (/\bheld\b/i.test(decl))
|
|
331
|
+
return "held";
|
|
332
|
+
if (/\bqueued\b/i.test(decl))
|
|
333
|
+
return "queued";
|
|
334
|
+
return "parked";
|
|
335
|
+
}
|
|
255
336
|
/**
|
|
256
|
-
*
|
|
337
|
+
* What does the board say about this item, if it is the SUBJECT of any row?
|
|
257
338
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
339
|
+
* ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
|
|
340
|
+
* (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
|
|
341
|
+
* only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
|
|
342
|
+
* its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
|
|
343
|
+
* Measured on the live board 2026-09-14: seven `⏸` rows, every one the
|
|
344
|
+
* coordinator's most careful bookkeeping, every one invisible to this join.
|
|
345
|
+
*
|
|
346
|
+
* ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
|
|
347
|
+
* fix made a `⏸ Parked` row NOT silence its item, because the alternative
|
|
348
|
+
* then was a SILENT drop with no axis. The drop is no longer silent: the item
|
|
349
|
+
* lands on `delivered` with the row's state as its `why`, and an `AWAITS`
|
|
350
|
+
* item still reaches `awaitingDecision`, which is gathered over every open
|
|
351
|
+
* item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
|
|
352
|
+
* must not make its item VANISH — still holds; what changes is that a warning
|
|
353
|
+
* row now also stops the item being HANDED OUT, which is what a warning is for.
|
|
354
|
+
*
|
|
355
|
+
* Conditions:
|
|
263
356
|
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
264
357
|
* text prefix `claim` has always written there. A mention in a Last
|
|
265
358
|
* note is one lane REFERRING to another item — the aide's live row
|
|
266
359
|
* does exactly this — and a status-only rule would still hide it.
|
|
360
|
+
* STATE whatever the seam reads: in-progress, in-review, parked (split
|
|
361
|
+
* into held · queued · parked, see `holdReasonOf`), blocked,
|
|
362
|
+
* orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
|
|
363
|
+
* excludes: the row is on the board with this item as its subject,
|
|
364
|
+
* and offering it anyway is the silent path this exists to close.
|
|
365
|
+
* A `✅ Done` row excludes too — that item is offerable to nobody.
|
|
267
366
|
*
|
|
268
367
|
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
269
368
|
* would have shipped a transitional hole in its own negative control: every row
|
|
@@ -274,16 +373,24 @@ function rowSubjectIds(row) {
|
|
|
274
373
|
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
275
374
|
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
276
375
|
*/
|
|
277
|
-
function
|
|
376
|
+
function boardHoldOf(rows, id, text) {
|
|
278
377
|
const key = keyOf({ text }).replace(/\s+/g, " ").trim();
|
|
279
|
-
|
|
280
|
-
if (!isInFlightStatus(r.status))
|
|
281
|
-
return false;
|
|
282
|
-
if (rowSubjectIds(r).includes(id))
|
|
283
|
-
return true;
|
|
378
|
+
for (const r of rows) {
|
|
284
379
|
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
285
|
-
|
|
286
|
-
|
|
380
|
+
const subject = rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
|
|
381
|
+
if (!subject)
|
|
382
|
+
continue;
|
|
383
|
+
const state = workStateOf(r.status);
|
|
384
|
+
const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
|
|
385
|
+
return {
|
|
386
|
+
state,
|
|
387
|
+
why: holdReasonOf(state, r.status),
|
|
388
|
+
status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
|
|
389
|
+
builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
|
|
390
|
+
branch,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
287
394
|
}
|
|
288
395
|
/**
|
|
289
396
|
* Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
|
|
@@ -311,7 +418,7 @@ function boardHoldsItem(rows, id, text) {
|
|
|
311
418
|
* false positives, because open rows cite refs as EVIDENCE.
|
|
312
419
|
*
|
|
313
420
|
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
314
|
-
* CLASS — the same distinction that keeps `
|
|
421
|
+
* CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
|
|
315
422
|
* delivery from prose: it requires the entry to carry the item's own
|
|
316
423
|
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
317
424
|
* A citation cannot accidentally satisfy it.
|
|
@@ -407,20 +514,34 @@ export async function nextUnblockedTool(args) {
|
|
|
407
514
|
if (i.done)
|
|
408
515
|
continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
409
516
|
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
410
|
-
// routing: both causes exclude, but a
|
|
411
|
-
// (wait
|
|
412
|
-
|
|
413
|
-
|
|
517
|
+
// routing: both causes exclude, but a board row is a different remedy
|
|
518
|
+
// (wait for the merge · ask its owner · ask the human it awaits, by its
|
|
519
|
+
// `why`) from a landed delivery (close the row).
|
|
520
|
+
const hold = boardHoldOf(boardRows, i.id, String(i.text));
|
|
521
|
+
if (hold)
|
|
522
|
+
deliveredBy.set(i.id, { reason: "board", hold });
|
|
414
523
|
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
|
|
415
|
-
deliveredBy.set(i.id, "done");
|
|
524
|
+
deliveredBy.set(i.id, { reason: "done" });
|
|
416
525
|
}
|
|
417
526
|
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
418
527
|
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
419
528
|
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
420
529
|
// reported silently is the same defect as an incorrect one.
|
|
530
|
+
//
|
|
531
|
+
// ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
|
|
532
|
+
// the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
|
|
533
|
+
// and the two want opposite things from the reader — leave the first alone,
|
|
534
|
+
// MERGE the second. `why` is the seam's state (parked split into held ·
|
|
535
|
+
// queued · parked) and `builtWork` says whether the row names a branch at
|
|
536
|
+
// all, so "held with nothing behind it" is visible rather than resolved.
|
|
421
537
|
const delivered = items
|
|
422
538
|
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
423
|
-
.map((i) =>
|
|
539
|
+
.map((i) => {
|
|
540
|
+
const d = deliveredBy.get(i.id);
|
|
541
|
+
return d.reason === "board"
|
|
542
|
+
? { 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 }
|
|
543
|
+
: { item: keyOf(i), id: i.id, reason: "done", why: "recorded in DONE.md" };
|
|
544
|
+
});
|
|
424
545
|
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
425
546
|
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
426
547
|
// two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
|
|
@@ -555,7 +676,7 @@ export async function nextUnblockedTool(args) {
|
|
|
555
676
|
...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
|
|
556
677
|
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
557
678
|
...delivered.map((d) => d.reason === "board"
|
|
558
|
-
? `⏭ not offered —
|
|
679
|
+
? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
|
|
559
680
|
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
|
|
560
681
|
],
|
|
561
682
|
// A SEPARATE AXIS, deliberately. See noDownstream().
|
|
@@ -622,6 +743,13 @@ export const claimSchema = {
|
|
|
622
743
|
base: z.string().optional(),
|
|
623
744
|
task: z.string().optional(),
|
|
624
745
|
write: z.boolean().optional(),
|
|
746
|
+
/**
|
|
747
|
+
* ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
|
|
748
|
+
* What the slice delivers instead (a ruling, a canon edit, a measurement).
|
|
749
|
+
* No worktree is cut; the board cell carries the statement in words, which
|
|
750
|
+
* the grammar and `stall_check` both read as DELIBERATE rather than missing.
|
|
751
|
+
*/
|
|
752
|
+
noCodeDeliverable: z.string().min(1).optional(),
|
|
625
753
|
};
|
|
626
754
|
export async function claimTool(args) {
|
|
627
755
|
const halt = haltState();
|
|
@@ -660,6 +788,25 @@ export async function claimTool(args) {
|
|
|
660
788
|
// A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
|
|
661
789
|
// item to an agent with nowhere isolated to work is the shared-checkout failure
|
|
662
790
|
// this pair exists to prevent.
|
|
791
|
+
// ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
|
|
792
|
+
// seats wrote this by hand in five spellings because the verb had no way to
|
|
793
|
+
// say it; `stall_check` reads the prose cell as deliberately branchless and
|
|
794
|
+
// keeps the row out of its population rather than calling it unmeasurable.
|
|
795
|
+
if (args.noCodeDeliverable) {
|
|
796
|
+
const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
|
|
797
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
|
|
798
|
+
let board = { action: "reported" };
|
|
799
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
800
|
+
if (!b)
|
|
801
|
+
board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
802
|
+
else if (args.write) {
|
|
803
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
804
|
+
if (next.action !== "unchanged")
|
|
805
|
+
writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
806
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
807
|
+
}
|
|
808
|
+
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 };
|
|
809
|
+
}
|
|
663
810
|
const wt = await ensureWorktreeTool({
|
|
664
811
|
agentId: args.agentId,
|
|
665
812
|
repo,
|
|
@@ -792,20 +939,39 @@ export const landSchema = {
|
|
|
792
939
|
};
|
|
793
940
|
const ghMergeFacts = (repo, n) => {
|
|
794
941
|
try {
|
|
795
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
|
|
942
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
|
|
796
943
|
cwd: repo,
|
|
797
944
|
encoding: "utf8",
|
|
798
945
|
stdio: ["ignore", "pipe", "ignore"],
|
|
946
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
799
947
|
});
|
|
800
948
|
const j = JSON.parse(out);
|
|
801
949
|
if (!j.headRefOid || !j.mergedAt)
|
|
802
950
|
return null;
|
|
803
|
-
return {
|
|
951
|
+
return {
|
|
952
|
+
headRefOid: String(j.headRefOid),
|
|
953
|
+
mergedAt: String(j.mergedAt),
|
|
954
|
+
comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
955
|
+
};
|
|
804
956
|
}
|
|
805
957
|
catch {
|
|
806
958
|
return null;
|
|
807
959
|
}
|
|
808
960
|
};
|
|
961
|
+
/** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
|
|
962
|
+
export function landingCitations(repo, n) {
|
|
963
|
+
let subjects = "";
|
|
964
|
+
for (const base of ["origin/main", "main"]) {
|
|
965
|
+
try {
|
|
966
|
+
subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
catch { /* next */ }
|
|
970
|
+
}
|
|
971
|
+
const marker = `(#${n})`;
|
|
972
|
+
const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
|
|
973
|
+
return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
|
|
974
|
+
}
|
|
809
975
|
export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictLog = roomLog) {
|
|
810
976
|
const repo = args.repo ?? process.cwd();
|
|
811
977
|
const base = args.base ?? "main";
|
|
@@ -825,21 +991,35 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
825
991
|
const ref = `origin/${base}`;
|
|
826
992
|
let landedIn = null;
|
|
827
993
|
let reason = null;
|
|
994
|
+
// ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first.
|
|
995
|
+
const numbers = prNumbersOf(args.pr);
|
|
996
|
+
const landings = {};
|
|
997
|
+
let searched = 0;
|
|
998
|
+
const missing = [];
|
|
828
999
|
try {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1000
|
+
for (const num of numbers) {
|
|
1001
|
+
const found = landingCommitOf(repo, ref, num);
|
|
1002
|
+
searched = found.searched;
|
|
1003
|
+
if (found.sha)
|
|
1004
|
+
landings[num] = found.sha;
|
|
1005
|
+
else
|
|
1006
|
+
missing.push(num);
|
|
1007
|
+
}
|
|
1008
|
+
landedIn = landings[n] ?? null;
|
|
832
1009
|
}
|
|
833
1010
|
catch (e) {
|
|
834
1011
|
reason = `could not read ${ref} (${String(e.message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
|
|
835
1012
|
}
|
|
836
1013
|
if (reason)
|
|
837
1014
|
return { ok: false, error: reason };
|
|
838
|
-
if (!landedIn) {
|
|
1015
|
+
if (!landedIn || missing.length) {
|
|
839
1016
|
return {
|
|
840
1017
|
ok: false,
|
|
841
|
-
error: `#${
|
|
1018
|
+
error: `${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` +
|
|
1019
|
+
`${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` +
|
|
1020
|
+
`a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`,
|
|
842
1021
|
comparedAgainst: ref,
|
|
1022
|
+
searched,
|
|
843
1023
|
};
|
|
844
1024
|
}
|
|
845
1025
|
const q = readDoc(repo, QUEUE_DOC);
|
|
@@ -861,6 +1041,17 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
861
1041
|
// `queueItemId` it closes nothing and reports the candidates for the caller to
|
|
862
1042
|
// pick, saying so.
|
|
863
1043
|
const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
|
|
1044
|
+
// ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
|
|
1045
|
+
// shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
|
|
1046
|
+
// (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
|
|
1047
|
+
// The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
|
|
1048
|
+
// every check reads, local, no network — and listed beside the #N candidates
|
|
1049
|
+
// at the one moment both facts are in hand. Closing nothing: a sibling is a
|
|
1050
|
+
// question for the coordinator, not a claim.
|
|
1051
|
+
const citedIds = landingCitations(repo, n);
|
|
1052
|
+
const siblings = items
|
|
1053
|
+
.filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
|
|
1054
|
+
.map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
|
|
864
1055
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
865
1056
|
if (args.queueItemId && !target)
|
|
866
1057
|
return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
|
|
@@ -899,7 +1090,35 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
899
1090
|
queueChanged = true;
|
|
900
1091
|
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
901
1092
|
const contextRepo = originRepoOf(repo);
|
|
902
|
-
|
|
1093
|
+
/*
|
|
1094
|
+
* ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
|
|
1095
|
+
*
|
|
1096
|
+
* This asked whether the PR number appears ANYWHERE in the row, so a row that
|
|
1097
|
+
* DISCUSSES its own closer read as already cited and `land` wrote no token.
|
|
1098
|
+
* The natural experiment that isolates it — three rows, one variable:
|
|
1099
|
+
*
|
|
1100
|
+
* ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
|
|
1101
|
+
* ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
|
|
1102
|
+
* ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
|
|
1103
|
+
*
|
|
1104
|
+
* ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
|
|
1105
|
+
* somebody else's PRs is cited normally; a row naming its own once is not. So the
|
|
1106
|
+
* better a row documents what closed it, the more certainly the closure goes
|
|
1107
|
+
* unwritten — and the ARTEFACT is what loses, silently.
|
|
1108
|
+
*
|
|
1109
|
+
* ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
|
|
1110
|
+
* ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
|
|
1111
|
+
* a line-1 prose mention and `doctor` falls back to the row. That was measured and
|
|
1112
|
+
* a patch was reverted rather than shipped. What nobody asked was what it does to
|
|
1113
|
+
* the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
|
|
1114
|
+
* makes a ref a citation, and that rule applies to the verb that WRITES one.
|
|
1115
|
+
*
|
|
1116
|
+
* ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
|
|
1117
|
+
* rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
|
|
1118
|
+
* must never happen.
|
|
1119
|
+
*/
|
|
1120
|
+
const closingHere = closingCitation(String(target.text))?.refs ?? [];
|
|
1121
|
+
const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
903
1122
|
if (!alreadyCited) {
|
|
904
1123
|
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
905
1124
|
target.text = `${target.text}${citationAppended}`;
|
|
@@ -1029,7 +1248,9 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
1029
1248
|
if (args.write && target) {
|
|
1030
1249
|
const after = readDoc(repo, DONE_DOC);
|
|
1031
1250
|
const recordText = after?.text ?? "";
|
|
1032
|
-
|
|
1251
|
+
// ⟨q-cbace757⟩ — one event, every cited ref; each checked against the record on its own.
|
|
1252
|
+
const refs = prRefsIn(args.pr);
|
|
1253
|
+
const ev = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) };
|
|
1033
1254
|
const derived = eventIsDerived(recordText, ev);
|
|
1034
1255
|
if (!derived.ok) {
|
|
1035
1256
|
events.refused.push(derived.error);
|
|
@@ -1066,10 +1287,32 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
1066
1287
|
const at = Date.parse(mf.mergedAt);
|
|
1067
1288
|
if (!Number.isFinite(at))
|
|
1068
1289
|
return { checked: false, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1069
|
-
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1070
|
-
|
|
1290
|
+
const { verdicts: bus, unparsed } = verdictsFor(log, n);
|
|
1291
|
+
// ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
|
|
1292
|
+
// the PR page's typed lines. Gatedness is (head sha, typed verdict); the
|
|
1293
|
+
// merge time only DISCLOSES lateness, in words, beside the answer.
|
|
1294
|
+
const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(mf.comments ?? [])];
|
|
1295
|
+
const a = gatedBy(verdicts, mf.headRefOid, at);
|
|
1071
1296
|
if (a.gated) {
|
|
1072
|
-
|
|
1297
|
+
const late = (a.lateByMs ?? 0) > 0;
|
|
1298
|
+
return {
|
|
1299
|
+
checked: true,
|
|
1300
|
+
gated: true,
|
|
1301
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1302
|
+
// ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
|
|
1303
|
+
// sender alone; `attribution` says whether that is a seat or the account.
|
|
1304
|
+
by: {
|
|
1305
|
+
from: a.gater,
|
|
1306
|
+
sha: a.by.head.slice(0, 8),
|
|
1307
|
+
channel: a.by.channel ?? "bus",
|
|
1308
|
+
attribution: a.attribution,
|
|
1309
|
+
...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
|
|
1310
|
+
...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
|
|
1311
|
+
},
|
|
1312
|
+
recordedAfterMerge: late,
|
|
1313
|
+
verified: a.verified,
|
|
1314
|
+
...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
|
|
1315
|
+
};
|
|
1073
1316
|
}
|
|
1074
1317
|
return {
|
|
1075
1318
|
checked: true,
|
|
@@ -1078,7 +1321,7 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
1078
1321
|
reason: a.reason,
|
|
1079
1322
|
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1080
1323
|
note: `UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1081
|
-
`The record is written — this is a report, not a refusal — but
|
|
1324
|
+
`The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
|
|
1082
1325
|
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1083
1326
|
};
|
|
1084
1327
|
})();
|
|
@@ -1117,6 +1360,11 @@ export async function landTool(args, readMergeFacts = ghMergeFacts, readVerdictL
|
|
|
1117
1360
|
candidates: target
|
|
1118
1361
|
? undefined
|
|
1119
1362
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
|
1363
|
+
// ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
|
|
1364
|
+
siblings,
|
|
1365
|
+
...(siblings.length
|
|
1366
|
+
? { 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.` }
|
|
1367
|
+
: {}),
|
|
1120
1368
|
...(target || !candidates.length
|
|
1121
1369
|
? {}
|
|
1122
1370
|
: {
|
|
@@ -1186,7 +1434,7 @@ const ghFacts = (repo, n) => {
|
|
|
1186
1434
|
// history: the PR title (which becomes the squash subject) and the commit
|
|
1187
1435
|
// subjects. The body is included because a reviewer reads it, but a claim
|
|
1188
1436
|
// that lives only in a comment thread is not a record.
|
|
1189
|
-
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}");
|
|
1437
|
+
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}");
|
|
1190
1438
|
const subjects = (meta.commits ?? [])
|
|
1191
1439
|
.map((c) => c.messageHeadline ?? "")
|
|
1192
1440
|
.join("\n");
|
|
@@ -1194,6 +1442,7 @@ const ghFacts = (repo, n) => {
|
|
|
1194
1442
|
state: String(j.state ?? ""),
|
|
1195
1443
|
mergeable: String(j.mergeable ?? ""),
|
|
1196
1444
|
headRefOid: String(j.headRefOid ?? ""),
|
|
1445
|
+
comments: (meta.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
1197
1446
|
checks: j.statusCheckRollup ?? [],
|
|
1198
1447
|
diff: gh(["pr", "diff", n]),
|
|
1199
1448
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1206,9 +1455,25 @@ const ghMerge = (repo, n, method) => {
|
|
|
1206
1455
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1207
1456
|
});
|
|
1208
1457
|
};
|
|
1458
|
+
/**
|
|
1459
|
+
* ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
|
|
1460
|
+
* verdict recorded in another room still counts for this head. The project
|
|
1461
|
+
* room is read first so a missing bus is still a null (unknown), never an
|
|
1462
|
+
* empty string (nothing).
|
|
1463
|
+
*/
|
|
1209
1464
|
const roomLog = (project) => {
|
|
1210
1465
|
try {
|
|
1211
|
-
|
|
1466
|
+
const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1467
|
+
let others = "";
|
|
1468
|
+
try {
|
|
1469
|
+
const dir = path.join(ROOT, "rooms");
|
|
1470
|
+
others = readdirSync(dir)
|
|
1471
|
+
.filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
|
|
1472
|
+
.map((f) => readFileSync(path.join(dir, f), "utf8"))
|
|
1473
|
+
.join("\n");
|
|
1474
|
+
}
|
|
1475
|
+
catch { /* other rooms are optional */ }
|
|
1476
|
+
return others ? `${own}\n${others}` : own;
|
|
1212
1477
|
}
|
|
1213
1478
|
catch {
|
|
1214
1479
|
return null;
|
|
@@ -1341,8 +1606,9 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
|
|
|
1341
1606
|
verdict,
|
|
1342
1607
|
};
|
|
1343
1608
|
}
|
|
1344
|
-
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1345
|
-
|
|
1609
|
+
const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
|
|
1610
|
+
// ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
|
|
1611
|
+
const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" })), ...prVerdictsIn(f.comments ?? [])], head, null);
|
|
1346
1612
|
if (!gate.gated) {
|
|
1347
1613
|
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1348
1614
|
return {
|
|
@@ -1356,7 +1622,7 @@ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge, readVe
|
|
|
1356
1622
|
};
|
|
1357
1623
|
}
|
|
1358
1624
|
if (!args.write)
|
|
1359
|
-
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.
|
|
1625
|
+
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.` };
|
|
1360
1626
|
doMerge(repo, n, args.method ?? "squash");
|
|
1361
1627
|
return { ok: true, merged: true, verdict };
|
|
1362
1628
|
}
|