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/src/tools/records.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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
|
|
|
@@ -30,17 +30,22 @@ import {
|
|
|
30
30
|
sweepTagOf,
|
|
31
31
|
awaitingOf,
|
|
32
32
|
refsIn,
|
|
33
|
+
closingCitation,
|
|
33
34
|
refsMatch,
|
|
34
35
|
workstreamsV1RowsOf,
|
|
36
|
+
workStateOf,
|
|
37
|
+
declarationOf,
|
|
38
|
+
type WorkState,
|
|
35
39
|
type WorkstreamsV1Row,
|
|
36
40
|
type DoneEntry,
|
|
37
41
|
} from "@davidbalzan/groundwork-seam";
|
|
38
42
|
import { ensureWorktreeTool } from "./worktrees.js";
|
|
39
43
|
import { ROOT } from "../store.js";
|
|
40
|
-
import { verdictsFor,
|
|
44
|
+
import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
41
45
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
42
|
-
import { haltState
|
|
46
|
+
import { haltState } from "./stall.js";
|
|
43
47
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
48
|
+
import { prRefsIn } from "./record-events.js";
|
|
44
49
|
|
|
45
50
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
46
51
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -160,10 +165,58 @@ const git = (repo: string, args: string[]): string =>
|
|
|
160
165
|
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
161
166
|
|
|
162
167
|
/** `#123` / `owner/repo#123` → "123". */
|
|
168
|
+
|
|
163
169
|
function prNumber(pr: string): string | null {
|
|
164
170
|
const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
|
|
165
171
|
return m ? (m[1] as string) : null;
|
|
166
172
|
}
|
|
173
|
+
/** ⟨q-cbace757⟩ — EVERY PR number a citation names, in order; a multi-PR closer is N citations. */
|
|
174
|
+
function prNumbersOf(pr: string): string[] {
|
|
175
|
+
const out: string[] = [];
|
|
176
|
+
for (const m of String(pr).matchAll(/#(\d+)\b/g)) if (!out.includes(m[1]!)) out.push(m[1]!);
|
|
177
|
+
if (!out.length) { const one = prNumber(pr); if (one) out.push(one); }
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/*
|
|
181
|
+
* ⟨q-b7198479⟩ — THE LOOKUP IS NOT BOUNDED BY A WINDOW. `git log -n 400` missed
|
|
182
|
+
* #245's squash 777 commits back on an 1806-commit main and the verb then
|
|
183
|
+
* asserted "#245 is not on origin/main" as a fact — a truncated search reported
|
|
184
|
+
* as an absence, at the verb that writes the delivery record, and worded with a
|
|
185
|
+
* mechanism (target tip vs merge base) that had nothing to do with it. At the
|
|
186
|
+
* fleet's speed (~200 record commits a day) 400 is about two days, so it fires
|
|
187
|
+
* precisely on the oldest rows, the ones closed late.
|
|
188
|
+
*
|
|
189
|
+
* Now: `git log --grep` over the WHOLE of the ref, then the SUBJECT is judged
|
|
190
|
+
* BY IDENTITY, the rule #327's mergeOf and the closing-line grammar already use:
|
|
191
|
+
* the landing is the commit whose subject ENDS with the forge's `(#N)` marker.
|
|
192
|
+
* A bare `#N` anywhere is a mention, never a landing — qa measured the first
|
|
193
|
+
* cut of this (`\(#N\)|#N\b`, newest first) on live origin/main: 149 of 322
|
|
194
|
+
* squash-merged PRs resolved to their CLOSURE or board commit ("docs(record):
|
|
195
|
+
* close … by content on #245"), which carry the bare number in the subject and
|
|
196
|
+
* sit newer than the squash. Among trailing-(#N) subjects the OLDEST wins: a
|
|
197
|
+
* hand-written "docs: log Task 10 (#164)" copied the marker 36 s after #164's
|
|
198
|
+
* squash and would otherwise be taken for it. The one PR of 323 on this main
|
|
199
|
+
* that landed as a merge commit (#108, "Merge pull request #108 from …") is the
|
|
200
|
+
* second identity form — the forge's own, not a mention — and is the last
|
|
201
|
+
* resort, consulted only when no trailing form exists. A miss reports what was
|
|
202
|
+
* searched — every subject on the ref — never a mechanism it did not test.
|
|
203
|
+
*/
|
|
204
|
+
export function landingCommitOf(repo: string, ref: string, n: string): { sha: string | null; searched: number } {
|
|
205
|
+
const searched = Number(git(repo, ["rev-list", "--count", ref])) || 0;
|
|
206
|
+
// git's --grep is a basic regex with no `\b`; identity is decided on the SUBJECT below.
|
|
207
|
+
const out = git(repo, ["log", ref, "--format=%H%x00%s", `--grep=#${n}`]);
|
|
208
|
+
const trailing = new RegExp(`\\(#${n}\\)\\s*$`);
|
|
209
|
+
const mergeForm = new RegExp(`^Merge pull request #${n}(?:\\s|$)`);
|
|
210
|
+
let landing: string | null = null;
|
|
211
|
+
let merge: string | null = null;
|
|
212
|
+
for (const line of out.split("\n")) {
|
|
213
|
+
const [sha, subject = ""] = line.split("\x00");
|
|
214
|
+
if (!sha) continue;
|
|
215
|
+
if (trailing.test(subject)) landing = sha; // newest first: the last hit is the OLDEST
|
|
216
|
+
else if (!merge && mergeForm.test(subject)) merge = sha;
|
|
217
|
+
}
|
|
218
|
+
return { sha: landing ?? merge, searched };
|
|
219
|
+
}
|
|
167
220
|
|
|
168
221
|
/**
|
|
169
222
|
* The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
|
|
@@ -287,18 +340,71 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
287
340
|
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
288
341
|
}
|
|
289
342
|
|
|
343
|
+
/** What a board row says about an item it names as its SUBJECT. */
|
|
344
|
+
export type BoardHold = {
|
|
345
|
+
/** The state the seam read off the row's Status cell. */
|
|
346
|
+
state: WorkState;
|
|
347
|
+
/** The routing reason: the state, except `parked` is split into held · queued · parked. */
|
|
348
|
+
why: string;
|
|
349
|
+
/** The declaration the reason was read from — never the commentary after it. */
|
|
350
|
+
status: string;
|
|
351
|
+
/** Whether the row's Branch · Worktree cell names anything at all. */
|
|
352
|
+
builtWork: boolean;
|
|
353
|
+
branch: string;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/*
|
|
357
|
+
* ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
|
|
358
|
+
* MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
|
|
359
|
+
* `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
|
|
360
|
+
* green in an open PR and the other has nothing behind it. Reporting them under
|
|
361
|
+
* one word re-creates the defect with a wider net: a reader told "parked" for
|
|
362
|
+
* `#292`'s row would go and build it.
|
|
363
|
+
*
|
|
364
|
+
* Two INDEPENDENT facts, deliberately not one: the WORD comes from the
|
|
365
|
+
* declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
|
|
366
|
+
* Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
|
|
367
|
+
* and that disagreement is worth seeing rather than resolving.
|
|
368
|
+
*/
|
|
369
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
370
|
+
function holdReasonOf(state: WorkState, status: string): string {
|
|
371
|
+
if (state !== "parked") return state;
|
|
372
|
+
const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
|
|
373
|
+
if (/\bheld\b/i.test(decl)) return "held";
|
|
374
|
+
if (/\bqueued\b/i.test(decl)) return "queued";
|
|
375
|
+
return "parked";
|
|
376
|
+
}
|
|
377
|
+
|
|
290
378
|
/**
|
|
291
|
-
*
|
|
379
|
+
* What does the board say about this item, if it is the SUBJECT of any row?
|
|
380
|
+
*
|
|
381
|
+
* ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
|
|
382
|
+
* (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
|
|
383
|
+
* only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
|
|
384
|
+
* its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
|
|
385
|
+
* Measured on the live board 2026-09-14: seven `⏸` rows, every one the
|
|
386
|
+
* coordinator's most careful bookkeeping, every one invisible to this join.
|
|
292
387
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
388
|
+
* ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
|
|
389
|
+
* fix made a `⏸ Parked` row NOT silence its item, because the alternative
|
|
390
|
+
* then was a SILENT drop with no axis. The drop is no longer silent: the item
|
|
391
|
+
* lands on `delivered` with the row's state as its `why`, and an `AWAITS`
|
|
392
|
+
* item still reaches `awaitingDecision`, which is gathered over every open
|
|
393
|
+
* item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
|
|
394
|
+
* must not make its item VANISH — still holds; what changes is that a warning
|
|
395
|
+
* row now also stops the item being HANDED OUT, which is what a warning is for.
|
|
396
|
+
*
|
|
397
|
+
* Conditions:
|
|
298
398
|
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
299
399
|
* text prefix `claim` has always written there. A mention in a Last
|
|
300
400
|
* note is one lane REFERRING to another item — the aide's live row
|
|
301
401
|
* does exactly this — and a status-only rule would still hide it.
|
|
402
|
+
* STATE whatever the seam reads: in-progress, in-review, parked (split
|
|
403
|
+
* into held · queued · parked, see `holdReasonOf`), blocked,
|
|
404
|
+
* orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
|
|
405
|
+
* excludes: the row is on the board with this item as its subject,
|
|
406
|
+
* and offering it anyway is the silent path this exists to close.
|
|
407
|
+
* A `✅ Done` row excludes too — that item is offerable to nobody.
|
|
302
408
|
*
|
|
303
409
|
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
304
410
|
* would have shipped a transitional hole in its own negative control: every row
|
|
@@ -309,14 +415,24 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
309
415
|
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
310
416
|
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
311
417
|
*/
|
|
312
|
-
function
|
|
418
|
+
function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null {
|
|
313
419
|
const key = keyOf({ text } as QueueItem).replace(/\s+/g, " ").trim();
|
|
314
|
-
|
|
315
|
-
if (!isInFlightStatus(r.status)) return false;
|
|
316
|
-
if (rowSubjectIds(r).includes(id)) return true;
|
|
420
|
+
for (const r of rows) {
|
|
317
421
|
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
318
|
-
|
|
319
|
-
|
|
422
|
+
const subject =
|
|
423
|
+
rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
|
|
424
|
+
if (!subject) continue;
|
|
425
|
+
const state = workStateOf(r.status);
|
|
426
|
+
const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
|
|
427
|
+
return {
|
|
428
|
+
state,
|
|
429
|
+
why: holdReasonOf(state, r.status),
|
|
430
|
+
status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
|
|
431
|
+
builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
|
|
432
|
+
branch,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
320
436
|
}
|
|
321
437
|
|
|
322
438
|
/**
|
|
@@ -345,7 +461,7 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
|
|
|
345
461
|
* false positives, because open rows cite refs as EVIDENCE.
|
|
346
462
|
*
|
|
347
463
|
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
348
|
-
* CLASS — the same distinction that keeps `
|
|
464
|
+
* CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
|
|
349
465
|
* delivery from prose: it requires the entry to carry the item's own
|
|
350
466
|
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
351
467
|
* A citation cannot accidentally satisfy it.
|
|
@@ -434,7 +550,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
434
550
|
//
|
|
435
551
|
// ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
|
|
436
552
|
// a row is excluded BY a named cause, and the cause is what gets reported.
|
|
437
|
-
const deliveredBy = new Map<string, "board" | "done">();
|
|
553
|
+
const deliveredBy = new Map<string, { reason: "board"; hold: BoardHold } | { reason: "done" }>();
|
|
438
554
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
439
555
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
440
556
|
const boardText = boardDoc?.text ?? "";
|
|
@@ -443,10 +559,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
443
559
|
for (const i of items) {
|
|
444
560
|
if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
445
561
|
// Board first, and the order is load-bearing for the REPORT rather than the
|
|
446
|
-
// routing: both causes exclude, but a
|
|
447
|
-
// (wait
|
|
448
|
-
|
|
449
|
-
|
|
562
|
+
// routing: both causes exclude, but a board row is a different remedy
|
|
563
|
+
// (wait for the merge · ask its owner · ask the human it awaits, by its
|
|
564
|
+
// `why`) from a landed delivery (close the row).
|
|
565
|
+
const hold = boardHoldOf(boardRows, i.id, String(i.text));
|
|
566
|
+
if (hold) deliveredBy.set(i.id, { reason: "board", hold });
|
|
567
|
+
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" });
|
|
450
568
|
}
|
|
451
569
|
|
|
452
570
|
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
@@ -454,9 +572,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
454
572
|
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
455
573
|
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
456
574
|
// reported silently is the same defect as an incorrect one.
|
|
575
|
+
//
|
|
576
|
+
// ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
|
|
577
|
+
// the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
|
|
578
|
+
// and the two want opposite things from the reader — leave the first alone,
|
|
579
|
+
// MERGE the second. `why` is the seam's state (parked split into held ·
|
|
580
|
+
// queued · parked) and `builtWork` says whether the row names a branch at
|
|
581
|
+
// all, so "held with nothing behind it" is visible rather than resolved.
|
|
457
582
|
const delivered = items
|
|
458
583
|
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
459
|
-
.map((i) =>
|
|
584
|
+
.map((i) => {
|
|
585
|
+
const d = deliveredBy.get(i.id)!;
|
|
586
|
+
return d.reason === "board"
|
|
587
|
+
? { 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 }
|
|
588
|
+
: { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" };
|
|
589
|
+
});
|
|
460
590
|
|
|
461
591
|
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
462
592
|
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
@@ -590,7 +720,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
590
720
|
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
591
721
|
...delivered.map((d) =>
|
|
592
722
|
d.reason === "board"
|
|
593
|
-
? `⏭ not offered —
|
|
723
|
+
? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
|
|
594
724
|
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
|
|
595
725
|
),
|
|
596
726
|
],
|
|
@@ -662,9 +792,16 @@ export const claimSchema = {
|
|
|
662
792
|
base: z.string().optional(),
|
|
663
793
|
task: z.string().optional(),
|
|
664
794
|
write: z.boolean().optional(),
|
|
795
|
+
/**
|
|
796
|
+
* ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
|
|
797
|
+
* What the slice delivers instead (a ruling, a canon edit, a measurement).
|
|
798
|
+
* No worktree is cut; the board cell carries the statement in words, which
|
|
799
|
+
* the grammar and `stall_check` both read as DELIBERATE rather than missing.
|
|
800
|
+
*/
|
|
801
|
+
noCodeDeliverable: z.string().min(1).optional(),
|
|
665
802
|
};
|
|
666
803
|
|
|
667
|
-
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
|
|
804
|
+
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) {
|
|
668
805
|
const halt = haltState();
|
|
669
806
|
if (halt.halted) {
|
|
670
807
|
return {
|
|
@@ -698,6 +835,24 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
698
835
|
// A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
|
|
699
836
|
// item to an agent with nowhere isolated to work is the shared-checkout failure
|
|
700
837
|
// this pair exists to prevent.
|
|
838
|
+
// ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
|
|
839
|
+
// seats wrote this by hand in five spellings because the verb had no way to
|
|
840
|
+
// say it; `stall_check` reads the prose cell as deliberately branchless and
|
|
841
|
+
// keeps the row out of its population rather than calling it unmeasurable.
|
|
842
|
+
if (args.noCodeDeliverable) {
|
|
843
|
+
const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
|
|
844
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
|
|
845
|
+
let board: { action: string; path?: string } = { action: "reported" };
|
|
846
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
847
|
+
if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
848
|
+
else if (args.write) {
|
|
849
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
850
|
+
if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
851
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
852
|
+
}
|
|
853
|
+
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 };
|
|
854
|
+
}
|
|
855
|
+
|
|
701
856
|
const wt = await ensureWorktreeTool({
|
|
702
857
|
agentId: args.agentId,
|
|
703
858
|
repo,
|
|
@@ -841,23 +996,41 @@ export const landSchema = {
|
|
|
841
996
|
* What the recording step needs to know about a merge: which head actually
|
|
842
997
|
* landed, and when. Injected so the report is provable without the network.
|
|
843
998
|
*/
|
|
844
|
-
export type
|
|
999
|
+
export type PrComment = { body: string; createdAt?: string; author?: string };
|
|
1000
|
+
/** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */
|
|
1001
|
+
export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null;
|
|
845
1002
|
export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
|
|
846
1003
|
const ghMergeFacts: ReadMergeFacts = (repo, n) => {
|
|
847
1004
|
try {
|
|
848
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
|
|
1005
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
|
|
849
1006
|
cwd: repo,
|
|
850
1007
|
encoding: "utf8",
|
|
851
1008
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1009
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
852
1010
|
});
|
|
853
|
-
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
|
|
1011
|
+
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] };
|
|
854
1012
|
if (!j.headRefOid || !j.mergedAt) return null;
|
|
855
|
-
return {
|
|
1013
|
+
return {
|
|
1014
|
+
headRefOid: String(j.headRefOid),
|
|
1015
|
+
mergedAt: String(j.mergedAt),
|
|
1016
|
+
comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
1017
|
+
};
|
|
856
1018
|
} catch {
|
|
857
1019
|
return null;
|
|
858
1020
|
}
|
|
859
1021
|
};
|
|
860
1022
|
|
|
1023
|
+
/** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
|
|
1024
|
+
export function landingCitations(repo: string, n: string): string[] {
|
|
1025
|
+
let subjects = "";
|
|
1026
|
+
for (const base of ["origin/main", "main"]) {
|
|
1027
|
+
try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ }
|
|
1028
|
+
}
|
|
1029
|
+
const marker = `(#${n})`;
|
|
1030
|
+
const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
|
|
1031
|
+
return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
|
|
1032
|
+
}
|
|
1033
|
+
|
|
861
1034
|
export async function landTool(
|
|
862
1035
|
args: {
|
|
863
1036
|
project: string;
|
|
@@ -890,19 +1063,31 @@ export async function landTool(
|
|
|
890
1063
|
const ref = `origin/${base}`;
|
|
891
1064
|
let landedIn: string | null = null;
|
|
892
1065
|
let reason: string | null = null;
|
|
1066
|
+
// ⟨q-cbace757⟩ — every cited PR must be on the ref, not only the first.
|
|
1067
|
+
const numbers = prNumbersOf(args.pr);
|
|
1068
|
+
const landings: Record<string, string> = {};
|
|
1069
|
+
let searched = 0;
|
|
1070
|
+
const missing: string[] = [];
|
|
893
1071
|
try {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1072
|
+
for (const num of numbers) {
|
|
1073
|
+
const found = landingCommitOf(repo, ref, num);
|
|
1074
|
+
searched = found.searched;
|
|
1075
|
+
if (found.sha) landings[num] = found.sha; else missing.push(num);
|
|
1076
|
+
}
|
|
1077
|
+
landedIn = landings[n] ?? null;
|
|
897
1078
|
} catch (e) {
|
|
898
1079
|
reason = `could not read ${ref} (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
|
|
899
1080
|
}
|
|
900
1081
|
if (reason) return { ok: false as const, error: reason };
|
|
901
|
-
if (!landedIn) {
|
|
1082
|
+
if (!landedIn || missing.length) {
|
|
902
1083
|
return {
|
|
903
1084
|
ok: false as const,
|
|
904
|
-
error:
|
|
1085
|
+
error:
|
|
1086
|
+
`${missing.map((m) => `#${m}`).join(", ")} not found on ${ref}: none of the ${searched} commit subject(s) on ${ref} carries ` +
|
|
1087
|
+
`${missing.map((m) => `"(#${m})"`).join(" or ")} — refusing to record it as landed. The whole of ${ref} was searched, not a window; ` +
|
|
1088
|
+
`a PR merged under another number or never merged reads the same here — check \`gh pr view\`.`,
|
|
905
1089
|
comparedAgainst: ref,
|
|
1090
|
+
searched,
|
|
906
1091
|
};
|
|
907
1092
|
}
|
|
908
1093
|
|
|
@@ -926,6 +1111,17 @@ export async function landTool(
|
|
|
926
1111
|
// `queueItemId` it closes nothing and reports the candidates for the caller to
|
|
927
1112
|
// pick, saying so.
|
|
928
1113
|
const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
|
|
1114
|
+
// ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
|
|
1115
|
+
// shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
|
|
1116
|
+
// (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
|
|
1117
|
+
// The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
|
|
1118
|
+
// every check reads, local, no network — and listed beside the #N candidates
|
|
1119
|
+
// at the one moment both facts are in hand. Closing nothing: a sibling is a
|
|
1120
|
+
// question for the coordinator, not a claim.
|
|
1121
|
+
const citedIds = landingCitations(repo, n);
|
|
1122
|
+
const siblings = items
|
|
1123
|
+
.filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
|
|
1124
|
+
.map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
|
|
929
1125
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
930
1126
|
if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
|
|
931
1127
|
|
|
@@ -964,7 +1160,35 @@ export async function landTool(
|
|
|
964
1160
|
queueChanged = true;
|
|
965
1161
|
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
966
1162
|
const contextRepo = originRepoOf(repo);
|
|
967
|
-
|
|
1163
|
+
/*
|
|
1164
|
+
* ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
|
|
1165
|
+
*
|
|
1166
|
+
* This asked whether the PR number appears ANYWHERE in the row, so a row that
|
|
1167
|
+
* DISCUSSES its own closer read as already cited and `land` wrote no token.
|
|
1168
|
+
* The natural experiment that isolates it — three rows, one variable:
|
|
1169
|
+
*
|
|
1170
|
+
* ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
|
|
1171
|
+
* ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
|
|
1172
|
+
* ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
|
|
1173
|
+
*
|
|
1174
|
+
* ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
|
|
1175
|
+
* somebody else's PRs is cited normally; a row naming its own once is not. So the
|
|
1176
|
+
* better a row documents what closed it, the more certainly the closure goes
|
|
1177
|
+
* unwritten — and the ARTEFACT is what loses, silently.
|
|
1178
|
+
*
|
|
1179
|
+
* ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
|
|
1180
|
+
* ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
|
|
1181
|
+
* a line-1 prose mention and `doctor` falls back to the row. That was measured and
|
|
1182
|
+
* a patch was reverted rather than shipped. What nobody asked was what it does to
|
|
1183
|
+
* the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
|
|
1184
|
+
* makes a ref a citation, and that rule applies to the verb that WRITES one.
|
|
1185
|
+
*
|
|
1186
|
+
* ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
|
|
1187
|
+
* rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
|
|
1188
|
+
* must never happen.
|
|
1189
|
+
*/
|
|
1190
|
+
const closingHere: ReturnType<typeof refsIn> = closingCitation(String(target.text))?.refs ?? [];
|
|
1191
|
+
const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
968
1192
|
if (!alreadyCited) {
|
|
969
1193
|
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
970
1194
|
target.text = `${target.text}${citationAppended}`;
|
|
@@ -1095,7 +1319,9 @@ export async function landTool(
|
|
|
1095
1319
|
if (args.write && target) {
|
|
1096
1320
|
const after = readDoc(repo, DONE_DOC);
|
|
1097
1321
|
const recordText = after?.text ?? "";
|
|
1098
|
-
|
|
1322
|
+
// ⟨q-cbace757⟩ — one event, every cited ref; each checked against the record on its own.
|
|
1323
|
+
const refs = prRefsIn(args.pr);
|
|
1324
|
+
const ev: RecordEvent = { kind: "item", target: target.id, ref: refs.length ? refs.join(", ") : args.pr, summary: summarize(originalText ?? ""), ...(refs.length > 1 ? { refs } : {}) };
|
|
1099
1325
|
const derived = eventIsDerived(recordText, ev);
|
|
1100
1326
|
if (!derived.ok) {
|
|
1101
1327
|
events.refused.push(derived.error);
|
|
@@ -1129,10 +1355,32 @@ export async function landTool(
|
|
|
1129
1355
|
if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
|
|
1130
1356
|
const at = Date.parse(mf.mergedAt);
|
|
1131
1357
|
if (!Number.isFinite(at)) return { checked: false as const, note: `#${n} reports an unparseable mergedAt ('${mf.mergedAt}') — cannot place the merge in time.` };
|
|
1132
|
-
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1133
|
-
|
|
1358
|
+
const { verdicts: bus, unparsed } = verdictsFor(log, n);
|
|
1359
|
+
// ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
|
|
1360
|
+
// the PR page's typed lines. Gatedness is (head sha, typed verdict); the
|
|
1361
|
+
// merge time only DISCLOSES lateness, in words, beside the answer.
|
|
1362
|
+
const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])];
|
|
1363
|
+
const a = gatedBy(verdicts, mf.headRefOid, at);
|
|
1134
1364
|
if (a.gated) {
|
|
1135
|
-
|
|
1365
|
+
const late = (a.lateByMs ?? 0) > 0;
|
|
1366
|
+
return {
|
|
1367
|
+
checked: true as const,
|
|
1368
|
+
gated: true as const,
|
|
1369
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1370
|
+
// ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
|
|
1371
|
+
// sender alone; `attribution` says whether that is a seat or the account.
|
|
1372
|
+
by: {
|
|
1373
|
+
from: a.gater,
|
|
1374
|
+
sha: a.by.head.slice(0, 8),
|
|
1375
|
+
channel: a.by.channel ?? "bus",
|
|
1376
|
+
attribution: a.attribution,
|
|
1377
|
+
...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
|
|
1378
|
+
...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
|
|
1379
|
+
},
|
|
1380
|
+
recordedAfterMerge: late,
|
|
1381
|
+
verified: a.verified,
|
|
1382
|
+
...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
|
|
1383
|
+
};
|
|
1136
1384
|
}
|
|
1137
1385
|
return {
|
|
1138
1386
|
checked: true as const,
|
|
@@ -1142,7 +1390,7 @@ export async function landTool(
|
|
|
1142
1390
|
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1143
1391
|
note:
|
|
1144
1392
|
`UNGATED MERGE RECORDED: #${n} merged ${mf.headRefOid.slice(0, 8)} and ${a.reason}. ` +
|
|
1145
|
-
`The record is written — this is a report, not a refusal — but
|
|
1393
|
+
`The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
|
|
1146
1394
|
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1147
1395
|
};
|
|
1148
1396
|
})();
|
|
@@ -1183,6 +1431,11 @@ export async function landTool(
|
|
|
1183
1431
|
candidates: target
|
|
1184
1432
|
? undefined
|
|
1185
1433
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
|
1434
|
+
// ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
|
|
1435
|
+
siblings,
|
|
1436
|
+
...(siblings.length
|
|
1437
|
+
? { 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.` }
|
|
1438
|
+
: {}),
|
|
1186
1439
|
...(target || !candidates.length
|
|
1187
1440
|
? {}
|
|
1188
1441
|
: {
|
|
@@ -1271,6 +1524,8 @@ export type PrFacts = {
|
|
|
1271
1524
|
diff?: string;
|
|
1272
1525
|
/** Everything that will survive the merge as a citation: title + commit subjects + body. */
|
|
1273
1526
|
citationText?: string;
|
|
1527
|
+
/** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */
|
|
1528
|
+
comments?: PrComment[];
|
|
1274
1529
|
};
|
|
1275
1530
|
const ghFacts = (repo: string, n: string): PrFacts => {
|
|
1276
1531
|
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
@@ -1290,7 +1545,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1290
1545
|
// history: the PR title (which becomes the squash subject) and the commit
|
|
1291
1546
|
// subjects. The body is included because a reviewer reads it, but a claim
|
|
1292
1547
|
// that lives only in a comment thread is not a record.
|
|
1293
|
-
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}") as Record<string, unknown>;
|
|
1548
|
+
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record<string, unknown>;
|
|
1294
1549
|
const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
|
|
1295
1550
|
.map((c) => c.messageHeadline ?? "")
|
|
1296
1551
|
.join("\n");
|
|
@@ -1298,6 +1553,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1298
1553
|
state: String(j.state ?? ""),
|
|
1299
1554
|
mergeable: String(j.mergeable ?? ""),
|
|
1300
1555
|
headRefOid: String(j.headRefOid ?? ""),
|
|
1556
|
+
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 })),
|
|
1301
1557
|
checks: (j.statusCheckRollup as unknown[]) ?? [],
|
|
1302
1558
|
diff: gh(["pr", "diff", n]),
|
|
1303
1559
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1332,9 +1588,24 @@ const ghMerge: DoMerge = (repo, n, method) => {
|
|
|
1332
1588
|
* only one of them is safe to merge on.
|
|
1333
1589
|
*/
|
|
1334
1590
|
export type ReadVerdictLog = (project: string) => string | null;
|
|
1591
|
+
/**
|
|
1592
|
+
* ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
|
|
1593
|
+
* verdict recorded in another room still counts for this head. The project
|
|
1594
|
+
* room is read first so a missing bus is still a null (unknown), never an
|
|
1595
|
+
* empty string (nothing).
|
|
1596
|
+
*/
|
|
1335
1597
|
const roomLog: ReadVerdictLog = (project) => {
|
|
1336
1598
|
try {
|
|
1337
|
-
|
|
1599
|
+
const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1600
|
+
let others = "";
|
|
1601
|
+
try {
|
|
1602
|
+
const dir = path.join(ROOT, "rooms");
|
|
1603
|
+
others = readdirSync(dir)
|
|
1604
|
+
.filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
|
|
1605
|
+
.map((f) => readFileSync(path.join(dir, f), "utf8"))
|
|
1606
|
+
.join("\n");
|
|
1607
|
+
} catch { /* other rooms are optional */ }
|
|
1608
|
+
return others ? `${own}\n${others}` : own;
|
|
1338
1609
|
} catch {
|
|
1339
1610
|
return null;
|
|
1340
1611
|
}
|
|
@@ -1484,8 +1755,9 @@ export async function mergeTool(
|
|
|
1484
1755
|
verdict,
|
|
1485
1756
|
};
|
|
1486
1757
|
}
|
|
1487
|
-
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1488
|
-
|
|
1758
|
+
const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
|
|
1759
|
+
// ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
|
|
1760
|
+
const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null);
|
|
1489
1761
|
if (!gate.gated) {
|
|
1490
1762
|
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1491
1763
|
return {
|
|
@@ -1501,7 +1773,7 @@ export async function mergeTool(
|
|
|
1501
1773
|
}
|
|
1502
1774
|
|
|
1503
1775
|
if (!args.write)
|
|
1504
|
-
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.
|
|
1776
|
+
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.` };
|
|
1505
1777
|
|
|
1506
1778
|
doMerge(repo, n, args.method ?? "squash");
|
|
1507
1779
|
return { ok: true as const, merged: true as const, verdict };
|