agent-coord-mcp 0.26.21 → 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 +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 +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/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +231 -38
- 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/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 +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 +17 -1
- package/src/tools/events.ts +33 -2
- package/src/tools/messaging.ts +63 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +248 -38
- 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/worktrees.ts +13 -0
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,16 +30,20 @@ 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";
|
|
44
48
|
|
|
45
49
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
@@ -160,6 +164,7 @@ const git = (repo: string, args: string[]): string =>
|
|
|
160
164
|
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
161
165
|
|
|
162
166
|
/** `#123` / `owner/repo#123` → "123". */
|
|
167
|
+
|
|
163
168
|
function prNumber(pr: string): string | null {
|
|
164
169
|
const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
|
|
165
170
|
return m ? (m[1] as string) : null;
|
|
@@ -287,18 +292,71 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
287
292
|
return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
288
293
|
}
|
|
289
294
|
|
|
295
|
+
/** What a board row says about an item it names as its SUBJECT. */
|
|
296
|
+
export type BoardHold = {
|
|
297
|
+
/** The state the seam read off the row's Status cell. */
|
|
298
|
+
state: WorkState;
|
|
299
|
+
/** The routing reason: the state, except `parked` is split into held · queued · parked. */
|
|
300
|
+
why: string;
|
|
301
|
+
/** The declaration the reason was read from — never the commentary after it. */
|
|
302
|
+
status: string;
|
|
303
|
+
/** Whether the row's Branch · Worktree cell names anything at all. */
|
|
304
|
+
builtWork: boolean;
|
|
305
|
+
branch: string;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
* ⭐ THE NUANCE THE SEAM DOES NOT SETTLE, SETTLED HERE (⟨q-a42503cb⟩): `⏸
|
|
310
|
+
* MERGE-HELD` and `⏸ Parked — awaits David` are BOTH `parked` to
|
|
311
|
+
* `workStateOf` and both `open` to `coarseOf`, yet one has BUILT WORK gated
|
|
312
|
+
* green in an open PR and the other has nothing behind it. Reporting them under
|
|
313
|
+
* one word re-creates the defect with a wider net: a reader told "parked" for
|
|
314
|
+
* `#292`'s row would go and build it.
|
|
315
|
+
*
|
|
316
|
+
* Two INDEPENDENT facts, deliberately not one: the WORD comes from the
|
|
317
|
+
* declaration (`held` · `queued` · `parked`), and `builtWork` comes from the
|
|
318
|
+
* Branch · Worktree cell. A row can say MERGE-HELD with an empty branch cell,
|
|
319
|
+
* and that disagreement is worth seeing rather than resolving.
|
|
320
|
+
*/
|
|
321
|
+
const STATUS_DECORATION = /^[\s*⭐]+/u;
|
|
322
|
+
function holdReasonOf(state: WorkState, status: string): string {
|
|
323
|
+
if (state !== "parked") return state;
|
|
324
|
+
const decl = declarationOf(String(status).replace(STATUS_DECORATION, ""));
|
|
325
|
+
if (/\bheld\b/i.test(decl)) return "held";
|
|
326
|
+
if (/\bqueued\b/i.test(decl)) return "queued";
|
|
327
|
+
return "parked";
|
|
328
|
+
}
|
|
329
|
+
|
|
290
330
|
/**
|
|
291
|
-
*
|
|
331
|
+
* What does the board say about this item, if it is the SUBJECT of any row?
|
|
332
|
+
*
|
|
333
|
+
* ⛔⛆ ANY ROW WHOSE SUBJECT IS THE ITEM EXCLUDES IT FROM ROUTING, AND SAYS WHY
|
|
334
|
+
* (⟨q-a42503cb⟩). This was a boolean gated on `isInFlightStatus` — 🚧 and 🔍
|
|
335
|
+
* only — so a `⏸ MERGE-HELD` row with its work gated green in `#292` left
|
|
336
|
+
* its item at the TOP of the pool: a seat taking it would have rebuilt the PR.
|
|
337
|
+
* Measured on the live board 2026-09-14: seven `⏸` rows, every one the
|
|
338
|
+
* coordinator's most careful bookkeeping, every one invisible to this join.
|
|
339
|
+
*
|
|
340
|
+
* ⚠ THIS SUPERSEDES HALF OF ⟨q-2ba7f0c5⟩'S RULING, AND KEEPS ITS REASON. That
|
|
341
|
+
* fix made a `⏸ Parked` row NOT silence its item, because the alternative
|
|
342
|
+
* then was a SILENT drop with no axis. The drop is no longer silent: the item
|
|
343
|
+
* lands on `delivered` with the row's state as its `why`, and an `AWAITS`
|
|
344
|
+
* item still reaches `awaitingDecision`, which is gathered over every open
|
|
345
|
+
* item before this join. What ⟨q-2ba7f0c5⟩ actually defended — a warning row
|
|
346
|
+
* must not make its item VANISH — still holds; what changes is that a warning
|
|
347
|
+
* row now also stops the item being HANDED OUT, which is what a warning is for.
|
|
292
348
|
*
|
|
293
|
-
*
|
|
294
|
-
* STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
|
|
295
|
-
* since #219 rather than a second glyph list). `⏸ Parked`,
|
|
296
|
-
* `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
|
|
297
|
-
* `🔻 Orphaned` describe work nobody holds, so they do not silence.
|
|
349
|
+
* Conditions:
|
|
298
350
|
* SUBJECT the row's Stream cell names the item — by recorded id, or by the
|
|
299
351
|
* text prefix `claim` has always written there. A mention in a Last
|
|
300
352
|
* note is one lane REFERRING to another item — the aide's live row
|
|
301
353
|
* does exactly this — and a status-only rule would still hide it.
|
|
354
|
+
* STATE whatever the seam reads: in-progress, in-review, parked (split
|
|
355
|
+
* into held · queued · parked, see `holdReasonOf`), blocked,
|
|
356
|
+
* orphaned, done, … An UNRECOGNISED glyph is `unknown` and STILL
|
|
357
|
+
* excludes: the row is on the board with this item as its subject,
|
|
358
|
+
* and offering it anyway is the silent path this exists to close.
|
|
359
|
+
* A `✅ Done` row excludes too — that item is offerable to nobody.
|
|
302
360
|
*
|
|
303
361
|
* THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
|
|
304
362
|
* would have shipped a transitional hole in its own negative control: every row
|
|
@@ -309,14 +367,24 @@ function rowSubjectIds(row: WorkstreamsV1Row): string[] {
|
|
|
309
367
|
* here carry the id (see `claim`), so the prefix arm is the compatibility half
|
|
310
368
|
* rather than the mechanism, and it is deliberately still SUBJECT-only.
|
|
311
369
|
*/
|
|
312
|
-
function
|
|
370
|
+
function boardHoldOf(rows: WorkstreamsV1Row[], id: string, text: string): BoardHold | null {
|
|
313
371
|
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;
|
|
372
|
+
for (const r of rows) {
|
|
317
373
|
const stream = String(r.stream).replace(/\s+/g, " ").trim();
|
|
318
|
-
|
|
319
|
-
|
|
374
|
+
const subject =
|
|
375
|
+
rowSubjectIds(r).includes(id) || (key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60))));
|
|
376
|
+
if (!subject) continue;
|
|
377
|
+
const state = workStateOf(r.status);
|
|
378
|
+
const branch = String(r.branchWorktree).replace(/[`*]/g, "").trim();
|
|
379
|
+
return {
|
|
380
|
+
state,
|
|
381
|
+
why: holdReasonOf(state, r.status),
|
|
382
|
+
status: declarationOf(String(r.status).replace(STATUS_DECORATION, "")),
|
|
383
|
+
builtWork: branch.length > 0 && branch !== "—" && branch !== "-",
|
|
384
|
+
branch,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return null;
|
|
320
388
|
}
|
|
321
389
|
|
|
322
390
|
/**
|
|
@@ -345,7 +413,7 @@ function boardHoldsItem(rows: WorkstreamsV1Row[], id: string, text: string): boo
|
|
|
345
413
|
* false positives, because open rows cite refs as EVIDENCE.
|
|
346
414
|
*
|
|
347
415
|
* ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
|
|
348
|
-
* CLASS — the same distinction that keeps `
|
|
416
|
+
* CLASS — the same distinction that keeps `boardHoldOf`. It does not infer
|
|
349
417
|
* delivery from prose: it requires the entry to carry the item's own
|
|
350
418
|
* deterministically composed text, `summarize(item.text)`, which a VERB writes.
|
|
351
419
|
* A citation cannot accidentally satisfy it.
|
|
@@ -434,7 +502,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
434
502
|
//
|
|
435
503
|
// ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
|
|
436
504
|
// a row is excluded BY a named cause, and the cause is what gets reported.
|
|
437
|
-
const deliveredBy = new Map<string, "board" | "done">();
|
|
505
|
+
const deliveredBy = new Map<string, { reason: "board"; hold: BoardHold } | { reason: "done" }>();
|
|
438
506
|
const doneDoc = readDoc(repo, DONE_DOC);
|
|
439
507
|
const boardDoc = readDoc(repo, BOARD_DOC);
|
|
440
508
|
const boardText = boardDoc?.text ?? "";
|
|
@@ -443,10 +511,12 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
443
511
|
for (const i of items) {
|
|
444
512
|
if (i.done) continue; // already off `open` by the checkbox; not an exclusion this axis owns
|
|
445
513
|
// 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
|
-
|
|
514
|
+
// routing: both causes exclude, but a board row is a different remedy
|
|
515
|
+
// (wait for the merge · ask its owner · ask the human it awaits, by its
|
|
516
|
+
// `why`) from a landed delivery (close the row).
|
|
517
|
+
const hold = boardHoldOf(boardRows, i.id, String(i.text));
|
|
518
|
+
if (hold) deliveredBy.set(i.id, { reason: "board", hold });
|
|
519
|
+
else if (doneRecordsDelivery(doneEntries, i.id, String(i.text))) deliveredBy.set(i.id, { reason: "done" });
|
|
450
520
|
}
|
|
451
521
|
|
|
452
522
|
const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
|
|
@@ -454,9 +524,21 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
454
524
|
// THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
|
|
455
525
|
// reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
|
|
456
526
|
// reported silently is the same defect as an incorrect one.
|
|
527
|
+
//
|
|
528
|
+
// ⛔ THE REASON NAMES THE STATE, NOT JUST THE DOCUMENT (⟨q-a42503cb⟩). "on
|
|
529
|
+
// the board" covered a live 🚧 lane and a `⏸ MERGE-HELD` PR with one word,
|
|
530
|
+
// and the two want opposite things from the reader — leave the first alone,
|
|
531
|
+
// MERGE the second. `why` is the seam's state (parked split into held ·
|
|
532
|
+
// queued · parked) and `builtWork` says whether the row names a branch at
|
|
533
|
+
// all, so "held with nothing behind it" is visible rather than resolved.
|
|
457
534
|
const delivered = items
|
|
458
535
|
.filter((i) => !i.done && deliveredBy.has(i.id))
|
|
459
|
-
.map((i) =>
|
|
536
|
+
.map((i) => {
|
|
537
|
+
const d = deliveredBy.get(i.id)!;
|
|
538
|
+
return d.reason === "board"
|
|
539
|
+
? { item: keyOf(i), id: i.id, reason: "board" as const, why: d.hold.why, state: d.hold.state, status: d.hold.status, builtWork: d.hold.builtWork, branch: d.hold.branch }
|
|
540
|
+
: { item: keyOf(i), id: i.id, reason: "done" as const, why: "recorded in DONE.md" };
|
|
541
|
+
});
|
|
460
542
|
|
|
461
543
|
// ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
|
|
462
544
|
// DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
|
|
@@ -590,7 +672,7 @@ export async function nextUnblockedTool(args: { project: string; repo?: string }
|
|
|
590
672
|
...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
|
|
591
673
|
...delivered.map((d) =>
|
|
592
674
|
d.reason === "board"
|
|
593
|
-
? `⏭ not offered —
|
|
675
|
+
? `⏭ not offered — on the board as ${d.why}${d.builtWork ? ` with built work on ${d.branch}` : " with no branch on the row"} (${d.id})`
|
|
594
676
|
: `⏭ not offered — delivery recorded in DONE.md (${d.id})`,
|
|
595
677
|
),
|
|
596
678
|
],
|
|
@@ -662,9 +744,16 @@ export const claimSchema = {
|
|
|
662
744
|
base: z.string().optional(),
|
|
663
745
|
task: z.string().optional(),
|
|
664
746
|
write: z.boolean().optional(),
|
|
747
|
+
/**
|
|
748
|
+
* ⟨q-5d1c8e04⟩ — A SLICE WITH NO CODE DELIVERABLE, said as a first-class value.
|
|
749
|
+
* What the slice delivers instead (a ruling, a canon edit, a measurement).
|
|
750
|
+
* No worktree is cut; the board cell carries the statement in words, which
|
|
751
|
+
* the grammar and `stall_check` both read as DELIBERATE rather than missing.
|
|
752
|
+
*/
|
|
753
|
+
noCodeDeliverable: z.string().min(1).optional(),
|
|
665
754
|
};
|
|
666
755
|
|
|
667
|
-
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean }) {
|
|
756
|
+
export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string; write?: boolean; noCodeDeliverable?: string }) {
|
|
668
757
|
const halt = haltState();
|
|
669
758
|
if (halt.halted) {
|
|
670
759
|
return {
|
|
@@ -698,6 +787,24 @@ export async function claimTool(args: { project: string; agentId: string; itemId
|
|
|
698
787
|
// A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
|
|
699
788
|
// item to an agent with nowhere isolated to work is the shared-checkout failure
|
|
700
789
|
// this pair exists to prevent.
|
|
790
|
+
// ⟨q-5d1c8e04⟩ — NO CODE DELIVERABLE: no tree, and the cell SAYS so. Five
|
|
791
|
+
// seats wrote this by hand in five spellings because the verb had no way to
|
|
792
|
+
// say it; `stall_check` reads the prose cell as deliberately branchless and
|
|
793
|
+
// keeps the row out of its population rather than calling it unmeasurable.
|
|
794
|
+
if (args.noCodeDeliverable) {
|
|
795
|
+
const cell = `no code deliverable · ${args.noCodeDeliverable.replace(/[|\n]/g, " ").trim()}`;
|
|
796
|
+
const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | ${cell} | 🚧 In Progress | — | claimed |`;
|
|
797
|
+
let board: { action: string; path?: string } = { action: "reported" };
|
|
798
|
+
const b = readDoc(repo, BOARD_DOC);
|
|
799
|
+
if (!b) board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
|
|
800
|
+
else if (args.write) {
|
|
801
|
+
const next = upsertBoardRow(b.text, args.agentId, boardHunk);
|
|
802
|
+
if (next.action !== "unchanged") writeFileSync(path.join(repo, BOARD_DOC), next.text);
|
|
803
|
+
board = { action: next.action, path: BOARD_DOC };
|
|
804
|
+
}
|
|
805
|
+
return { ok: true as const, project: args.project, agentId: args.agentId, item: { id: item.id, priority: item.priority, text: item.text }, boardHunk, board, worktreeEnsured: false, noCodeDeliverable: args.noCodeDeliverable };
|
|
806
|
+
}
|
|
807
|
+
|
|
701
808
|
const wt = await ensureWorktreeTool({
|
|
702
809
|
agentId: args.agentId,
|
|
703
810
|
repo,
|
|
@@ -841,23 +948,41 @@ export const landSchema = {
|
|
|
841
948
|
* What the recording step needs to know about a merge: which head actually
|
|
842
949
|
* landed, and when. Injected so the report is provable without the network.
|
|
843
950
|
*/
|
|
844
|
-
export type
|
|
951
|
+
export type PrComment = { body: string; createdAt?: string; author?: string };
|
|
952
|
+
/** ⟨q-5a93c2d7⟩ — the PR page is a verdict channel too, so its comments travel with the merge facts. */
|
|
953
|
+
export type MergeFacts = { headRefOid: string; mergedAt: string; comments?: PrComment[] } | null;
|
|
845
954
|
export type ReadMergeFacts = (repo: string, n: string) => MergeFacts;
|
|
846
955
|
const ghMergeFacts: ReadMergeFacts = (repo, n) => {
|
|
847
956
|
try {
|
|
848
|
-
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt"], {
|
|
957
|
+
const out = execFileSync("gh", ["pr", "view", n, "--json", "headRefOid,mergedAt,comments"], {
|
|
849
958
|
cwd: repo,
|
|
850
959
|
encoding: "utf8",
|
|
851
960
|
stdio: ["ignore", "pipe", "ignore"],
|
|
961
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
852
962
|
});
|
|
853
|
-
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string };
|
|
963
|
+
const j = JSON.parse(out) as { headRefOid?: string; mergedAt?: string; comments?: { body?: string; createdAt?: string; author?: { login?: string } }[] };
|
|
854
964
|
if (!j.headRefOid || !j.mergedAt) return null;
|
|
855
|
-
return {
|
|
965
|
+
return {
|
|
966
|
+
headRefOid: String(j.headRefOid),
|
|
967
|
+
mergedAt: String(j.mergedAt),
|
|
968
|
+
comments: (j.comments ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
969
|
+
};
|
|
856
970
|
} catch {
|
|
857
971
|
return null;
|
|
858
972
|
}
|
|
859
973
|
};
|
|
860
974
|
|
|
975
|
+
/** ⟨q-6b3af019⟩ — the row ids the landing commit of #n cites, read off its SUBJECT on the base (the `(#n)` squash marker). */
|
|
976
|
+
export function landingCitations(repo: string, n: string): string[] {
|
|
977
|
+
let subjects = "";
|
|
978
|
+
for (const base of ["origin/main", "main"]) {
|
|
979
|
+
try { subjects = git(repo, ["log", base, "--format=%s", "--fixed-strings", `--grep=(#${n})`, "-n", "20"]); break; } catch { /* next */ }
|
|
980
|
+
}
|
|
981
|
+
const marker = `(#${n})`;
|
|
982
|
+
const landing = subjects.split("\n").find((s) => s.trimEnd().endsWith(marker));
|
|
983
|
+
return landing ? [...new Set(landing.match(/q-[0-9a-f]{8}/g) ?? [])] : [];
|
|
984
|
+
}
|
|
985
|
+
|
|
861
986
|
export async function landTool(
|
|
862
987
|
args: {
|
|
863
988
|
project: string;
|
|
@@ -926,6 +1051,17 @@ export async function landTool(
|
|
|
926
1051
|
// `queueItemId` it closes nothing and reports the candidates for the caller to
|
|
927
1052
|
// pick, saying so.
|
|
928
1053
|
const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
|
|
1054
|
+
// ⟨q-6b3af019⟩ — SIBLINGS: open rows that NAME an id this PR cites. q-b4e7c209
|
|
1055
|
+
// shipped under #250/#251 (both citing q-0b8e5c47) and q-0c5e73a1 under #261
|
|
1056
|
+
// (cited as "slice A" of q-6ce4a8d0): the delivered row named the cited one.
|
|
1057
|
+
// The ids are read off the LANDING COMMIT'S SUBJECT on the base — the record
|
|
1058
|
+
// every check reads, local, no network — and listed beside the #N candidates
|
|
1059
|
+
// at the one moment both facts are in hand. Closing nothing: a sibling is a
|
|
1060
|
+
// question for the coordinator, not a claim.
|
|
1061
|
+
const citedIds = landingCitations(repo, n);
|
|
1062
|
+
const siblings = items
|
|
1063
|
+
.filter((i) => !i.done && !citedIds.includes(i.id) && i.id !== args.queueItemId && citedIds.some((c) => String(i.text).includes(c)))
|
|
1064
|
+
.map((i) => ({ id: i.id, priority: i.priority, names: citedIds.filter((c) => String(i.text).includes(c)) }));
|
|
929
1065
|
const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
|
|
930
1066
|
if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
|
|
931
1067
|
|
|
@@ -964,7 +1100,35 @@ export async function landTool(
|
|
|
964
1100
|
queueChanged = true;
|
|
965
1101
|
const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
|
|
966
1102
|
const contextRepo = originRepoOf(repo);
|
|
967
|
-
|
|
1103
|
+
/*
|
|
1104
|
+
* ⛔⛆ "ALREADY CITED" IS A QUESTION ABOUT THE CLOSING POSITION, NOT THE ROW (⟨q-217cc151⟩).
|
|
1105
|
+
*
|
|
1106
|
+
* This asked whether the PR number appears ANYWHERE in the row, so a row that
|
|
1107
|
+
* DISCUSSES its own closer read as already cited and `land` wrote no token.
|
|
1108
|
+
* The natural experiment that isolates it — three rows, one variable:
|
|
1109
|
+
*
|
|
1110
|
+
* ⟨q-8a7b04f2⟩ mentions #269 ×4, its closer #271 ZERO times -> TOKEN WRITTEN
|
|
1111
|
+
* ⟨q-5f27b1ae⟩ mentions its own closer #269 ×1 -> NO TOKEN
|
|
1112
|
+
* ⟨q-8db146d0⟩ mentions its own closer #272 ×2 -> NO TOKEN
|
|
1113
|
+
*
|
|
1114
|
+
* ⭐ THE VARIABLE IS SELF-REFERENCE, NOT REF DENSITY. A row naming four of
|
|
1115
|
+
* somebody else's PRs is cited normally; a row naming its own once is not. So the
|
|
1116
|
+
* better a row documents what closed it, the more certainly the closure goes
|
|
1117
|
+
* unwritten — and the ARTEFACT is what loses, silently.
|
|
1118
|
+
*
|
|
1119
|
+
* ⚠ RETIRED ONCE AS ⟨q-2b91c188⟩, AND THAT RETIREMENT WAS CORRECT FOR THE QUESTION
|
|
1120
|
+
* ASKED: the behaviour is harmless to every CONSUMER, because both readers accept
|
|
1121
|
+
* a line-1 prose mention and `doctor` falls back to the row. That was measured and
|
|
1122
|
+
* a patch was reverted rather than shipped. What nobody asked was what it does to
|
|
1123
|
+
* the ARTEFACT — a closure whose citation sits in no fixed place. Position is what
|
|
1124
|
+
* makes a ref a citation, and that rule applies to the verb that WRITES one.
|
|
1125
|
+
*
|
|
1126
|
+
* ⛔ THE READER IS NOT TOUCHED HERE. Fixing the writer forward does nothing for
|
|
1127
|
+
* rows already written, and narrowing the reader is what ⟨q-bf162723⟩ established
|
|
1128
|
+
* must never happen.
|
|
1129
|
+
*/
|
|
1130
|
+
const closingHere: ReturnType<typeof refsIn> = closingCitation(String(target.text))?.refs ?? [];
|
|
1131
|
+
const alreadyCited = closingHere.some((r) => refsMatch(r, prRef, { contextRepo }));
|
|
968
1132
|
if (!alreadyCited) {
|
|
969
1133
|
citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
|
|
970
1134
|
target.text = `${target.text}${citationAppended}`;
|
|
@@ -1129,10 +1293,32 @@ export async function landTool(
|
|
|
1129
1293
|
if (log === null) return { checked: false as const, note: `could not read the verdict log for '${args.project}' — whether #${n}'s merged head was gated is UNKNOWN, not clean.` };
|
|
1130
1294
|
const at = Date.parse(mf.mergedAt);
|
|
1131
1295
|
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
|
-
|
|
1296
|
+
const { verdicts: bus, unparsed } = verdictsFor(log, n);
|
|
1297
|
+
// ⟨q-5a93c2d7⟩ — both channels, one predicate: the bus's typed records and
|
|
1298
|
+
// the PR page's typed lines. Gatedness is (head sha, typed verdict); the
|
|
1299
|
+
// merge time only DISCLOSES lateness, in words, beside the answer.
|
|
1300
|
+
const verdicts = [...bus.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(mf.comments ?? [])];
|
|
1301
|
+
const a = gatedBy(verdicts, mf.headRefOid, at);
|
|
1134
1302
|
if (a.gated) {
|
|
1135
|
-
|
|
1303
|
+
const late = (a.lateByMs ?? 0) > 0;
|
|
1304
|
+
return {
|
|
1305
|
+
checked: true as const,
|
|
1306
|
+
gated: true as const,
|
|
1307
|
+
head: mf.headRefOid.slice(0, 8),
|
|
1308
|
+
// ⟨q-dcbaf544⟩ — `from` here is the GATER (`gatedBy ?? from`), never the
|
|
1309
|
+
// sender alone; `attribution` says whether that is a seat or the account.
|
|
1310
|
+
by: {
|
|
1311
|
+
from: a.gater,
|
|
1312
|
+
sha: a.by.head.slice(0, 8),
|
|
1313
|
+
channel: a.by.channel ?? "bus",
|
|
1314
|
+
attribution: a.attribution,
|
|
1315
|
+
...(a.by.scribe ?? a.seatRecord?.scribe ? { scribe: a.seatRecord?.scribe ?? a.by.scribe } : {}),
|
|
1316
|
+
...(a.seatRecord ? { firstSeenOnPr: new Date(a.by.ts).toISOString(), seatRecordedAt: new Date(a.seatRecord.ts).toISOString() } : {}),
|
|
1317
|
+
},
|
|
1318
|
+
recordedAfterMerge: late,
|
|
1319
|
+
verified: a.verified,
|
|
1320
|
+
...(late ? { note: `GATED, LATE RECORD: #${n} — ${a.verified}.` } : {}),
|
|
1321
|
+
};
|
|
1136
1322
|
}
|
|
1137
1323
|
return {
|
|
1138
1324
|
checked: true as const,
|
|
@@ -1142,7 +1328,7 @@ export async function landTool(
|
|
|
1142
1328
|
gatedInstead: (a.crossed ?? []).map((c) => c.gatedSha.slice(0, 8)),
|
|
1143
1329
|
note:
|
|
1144
1330
|
`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
|
|
1331
|
+
`The record is written — this is a report, not a refusal — but no typed verdict bound to the merged head exists in any channel.` +
|
|
1146
1332
|
(unparsed ? ` (${unparsed} log line(s) unreadable and skipped.)` : ""),
|
|
1147
1333
|
};
|
|
1148
1334
|
})();
|
|
@@ -1183,6 +1369,11 @@ export async function landTool(
|
|
|
1183
1369
|
candidates: target
|
|
1184
1370
|
? undefined
|
|
1185
1371
|
: candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
|
|
1372
|
+
// ⟨q-6b3af019⟩ — open rows naming an id this PR's landing commit cites; none closed.
|
|
1373
|
+
siblings,
|
|
1374
|
+
...(siblings.length
|
|
1375
|
+
? { note_siblings: `${siblings.length} open row(s) NAME an id #${n} cites (${citedIds.join(", ")}) and are not cited themselves — work shipped under another row's citation is how q-b4e7c209 and q-0c5e73a1 sat open; judge each, close none from here.` }
|
|
1376
|
+
: {}),
|
|
1186
1377
|
...(target || !candidates.length
|
|
1187
1378
|
? {}
|
|
1188
1379
|
: {
|
|
@@ -1271,6 +1462,8 @@ export type PrFacts = {
|
|
|
1271
1462
|
diff?: string;
|
|
1272
1463
|
/** Everything that will survive the merge as a citation: title + commit subjects + body. */
|
|
1273
1464
|
citationText?: string;
|
|
1465
|
+
/** ⟨q-5a93c2d7⟩ — the PR page's comments, a verdict channel. */
|
|
1466
|
+
comments?: PrComment[];
|
|
1274
1467
|
};
|
|
1275
1468
|
const ghFacts = (repo: string, n: string): PrFacts => {
|
|
1276
1469
|
const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup,headRefOid"], {
|
|
@@ -1290,7 +1483,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1290
1483
|
// history: the PR title (which becomes the squash subject) and the commit
|
|
1291
1484
|
// subjects. The body is included because a reviewer reads it, but a claim
|
|
1292
1485
|
// 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>;
|
|
1486
|
+
const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits,comments"]) || "{}") as Record<string, unknown>;
|
|
1294
1487
|
const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
|
|
1295
1488
|
.map((c) => c.messageHeadline ?? "")
|
|
1296
1489
|
.join("\n");
|
|
@@ -1298,6 +1491,7 @@ const ghFacts = (repo: string, n: string): PrFacts => {
|
|
|
1298
1491
|
state: String(j.state ?? ""),
|
|
1299
1492
|
mergeable: String(j.mergeable ?? ""),
|
|
1300
1493
|
headRefOid: String(j.headRefOid ?? ""),
|
|
1494
|
+
comments: ((meta.comments as { body?: string; createdAt?: string; author?: { login?: string } }[] | undefined) ?? []).map((c) => ({ body: String(c.body ?? ""), createdAt: c.createdAt, author: c.author?.login })),
|
|
1301
1495
|
checks: (j.statusCheckRollup as unknown[]) ?? [],
|
|
1302
1496
|
diff: gh(["pr", "diff", n]),
|
|
1303
1497
|
citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
|
|
@@ -1332,9 +1526,24 @@ const ghMerge: DoMerge = (repo, n, method) => {
|
|
|
1332
1526
|
* only one of them is safe to merge on.
|
|
1333
1527
|
*/
|
|
1334
1528
|
export type ReadVerdictLog = (project: string) => string | null;
|
|
1529
|
+
/**
|
|
1530
|
+
* ⟨q-5a93c2d7⟩ — EVERY room, not the project's alone: "any channel" means a
|
|
1531
|
+
* verdict recorded in another room still counts for this head. The project
|
|
1532
|
+
* room is read first so a missing bus is still a null (unknown), never an
|
|
1533
|
+
* empty string (nothing).
|
|
1534
|
+
*/
|
|
1335
1535
|
const roomLog: ReadVerdictLog = (project) => {
|
|
1336
1536
|
try {
|
|
1337
|
-
|
|
1537
|
+
const own = readFileSync(path.join(ROOT, "rooms", `${project}.jsonl`), "utf8");
|
|
1538
|
+
let others = "";
|
|
1539
|
+
try {
|
|
1540
|
+
const dir = path.join(ROOT, "rooms");
|
|
1541
|
+
others = readdirSync(dir)
|
|
1542
|
+
.filter((f) => f.endsWith(".jsonl") && f !== `${project}.jsonl`)
|
|
1543
|
+
.map((f) => readFileSync(path.join(dir, f), "utf8"))
|
|
1544
|
+
.join("\n");
|
|
1545
|
+
} catch { /* other rooms are optional */ }
|
|
1546
|
+
return others ? `${own}\n${others}` : own;
|
|
1338
1547
|
} catch {
|
|
1339
1548
|
return null;
|
|
1340
1549
|
}
|
|
@@ -1484,8 +1693,9 @@ export async function mergeTool(
|
|
|
1484
1693
|
verdict,
|
|
1485
1694
|
};
|
|
1486
1695
|
}
|
|
1487
|
-
const { verdicts, unparsed } = verdictsFor(log, n);
|
|
1488
|
-
|
|
1696
|
+
const { verdicts: busVerdicts, unparsed } = verdictsFor(log, n);
|
|
1697
|
+
// ⟨q-5a93c2d7⟩ — the pre-merge question, same predicate, both channels.
|
|
1698
|
+
const gate = gatedBy([...busVerdicts.map((v) => ({ ...v, channel: "bus" as const })), ...prVerdictsIn(f.comments ?? [])], head, null);
|
|
1489
1699
|
if (!gate.gated) {
|
|
1490
1700
|
const named = (gate.crossed ?? []).map((c) => c.gatedSha.slice(0, 7)).join(", ");
|
|
1491
1701
|
return {
|
|
@@ -1501,7 +1711,7 @@ export async function mergeTool(
|
|
|
1501
1711
|
}
|
|
1502
1712
|
|
|
1503
1713
|
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.
|
|
1714
|
+
return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass, and head ${head.slice(0, 7)} is gated by a PASS from ${gate.gater}${gate.attribution === "account" ? " (the PR's shared account — no bus verdict names a seat)" : ""}. Pass write:true to apply.` };
|
|
1505
1715
|
|
|
1506
1716
|
doMerge(repo, n, args.method ?? "squash");
|
|
1507
1717
|
return { ok: true as const, merged: true as const, verdict };
|
package/src/tools/registry.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { detachAgentTool } from "./transport.js";
|
|
|
2
2
|
import { isLocallyProbeable, isRemoteTmuxKind, targetOf } from "../transports/index.js";
|
|
3
3
|
import { readAway, secondCoordinatorRefusal } from "./away.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { existsSync, openSync, watch } from "node:fs";
|
|
5
|
+
import { existsSync, openSync, statSync, watch } from "node:fs";
|
|
6
6
|
import { promises as fsp } from "node:fs";
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { detectSpread, installedBuild } from "../server-spread.js";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import {
|
|
@@ -53,7 +54,8 @@ import {
|
|
|
53
54
|
type RoomRegistry,
|
|
54
55
|
type SessionBinding,
|
|
55
56
|
} from "../store.js";
|
|
56
|
-
import { recordAuthorityFor, resolveRole, CANONICAL_ROLE_IDS, roleInputSchema, type RoleArg } from "../roles.js";
|
|
57
|
+
import { recordAuthorityFor, resolveRole, CANONICAL_ROLE_IDS, roleInputSchema, isHuman, type RoleArg } from "../roles.js";
|
|
58
|
+
import { readHumans, recordHuman } from "../store.js";
|
|
57
59
|
import {
|
|
58
60
|
type AgentEntry,
|
|
59
61
|
type AgentRegistry,
|
|
@@ -180,6 +182,10 @@ export async function registerTool(args: {
|
|
|
180
182
|
return current;
|
|
181
183
|
});
|
|
182
184
|
const entry = reg[args.agentId];
|
|
185
|
+
// ⟨q-178878aa⟩ — a HUMAN registration is recorded durably: the registry entry itself is
|
|
186
|
+
// evicted after EVICT_MS (no heartbeat, no transport), and the send path's exemption
|
|
187
|
+
// must still see the human after that.
|
|
188
|
+
if (isHuman(entry)) await recordHuman(args.agentId, { displayName: entry.role, by: `register:${args.agentId}` });
|
|
183
189
|
// Echo the record types this role may and may not emit. Record authority is
|
|
184
190
|
// otherwise invisible until the first typed send is refused mid-work — this
|
|
185
191
|
// is how an agent whose role owns `go`/`scope`/`verdict` finds out at
|
|
@@ -287,6 +293,23 @@ export async function heartbeatTool(args: { agentId: string }) {
|
|
|
287
293
|
return current;
|
|
288
294
|
}
|
|
289
295
|
current[args.agentId].lastHeartbeat = Date.now();
|
|
296
|
+
/*
|
|
297
|
+
* ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`. This runs INSIDE the server that
|
|
298
|
+
* is answering, which is the only place these two facts exist: `capabilities`
|
|
299
|
+
* reports them solely to its own caller, and no seat can query another seat's
|
|
300
|
+
* server at all. Publishing them here is what makes a cross-seat comparison
|
|
301
|
+
* possible without every seat having to volunteer it in prose.
|
|
302
|
+
*
|
|
303
|
+
* `startedAt` is derived from uptime rather than read from a file: an mtime tracks
|
|
304
|
+
* writes (a reinstall of identical bytes moves it) while uptime is a fact about
|
|
305
|
+
* THIS process.
|
|
306
|
+
*/
|
|
307
|
+
current[args.agentId].serverPid = process.pid;
|
|
308
|
+
current[args.agentId].serverStartedAt = Date.now() - Math.round(process.uptime() * 1000);
|
|
309
|
+
// ⛔ WHAT this process is executing, not what it is labelled. Resolved from this
|
|
310
|
+
// module's own URL, so a server running a dev `dist/` says so instead of inheriting
|
|
311
|
+
// the installed path — the case that made a 3-build fleet read AGREED.
|
|
312
|
+
current[args.agentId].serverModule = installedBuild(import.meta.url, statSync)?.module;
|
|
290
313
|
return current;
|
|
291
314
|
});
|
|
292
315
|
if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` };
|
|
@@ -331,6 +354,8 @@ export async function listAgentsTool() {
|
|
|
331
354
|
// heartbeat. This stamp only ever overwrote a true value with a
|
|
332
355
|
// simultaneous one.
|
|
333
356
|
if (liveTransports.has(id)) continue;
|
|
357
|
+
// A human has no heartbeat to go stale; evicting one is how the exemption went blind.
|
|
358
|
+
if (isHuman(entry)) continue;
|
|
334
359
|
if (now - entry.lastHeartbeat > EVICT_MS) {
|
|
335
360
|
evicted.push(id);
|
|
336
361
|
delete current[id];
|
|
@@ -417,7 +442,33 @@ export async function listAgentsTool() {
|
|
|
417
442
|
"granted to the SENDER and paid by every READER: their multi-line messages cannot be slimmed. " +
|
|
418
443
|
"A rising share means the rule is decaying.",
|
|
419
444
|
};
|
|
420
|
-
|
|
445
|
+
/*
|
|
446
|
+
* ⛔⛆ AND WHETHER THOSE SEATS AGREE ABOUT WHAT THEY ARE RUNNING — `⟨q-cec42e20⟩`.
|
|
447
|
+
*
|
|
448
|
+
* Reported HERE because this is the verb that already answers "who is live", and the
|
|
449
|
+
* spread is a property of that same population: a reader asking who is on the bus is
|
|
450
|
+
* exactly the reader who needs to know their servers are not the same build.
|
|
451
|
+
*
|
|
452
|
+
* ⚠ IT NEVER REPORTS AGREEMENT IT DID NOT ESTABLISH. A seat that has not stamped its
|
|
453
|
+
* identity is UNCOMPARABLE and poisons the verdict to CANNOT_COMPARE rather than being
|
|
454
|
+
* dropped from the population — because "every seat I could read agrees" and "the
|
|
455
|
+
* fleet agrees" are different claims, and only the second is what a reader will act on.
|
|
456
|
+
*/
|
|
457
|
+
const spread = detectSpread(
|
|
458
|
+
Object.values(reg)
|
|
459
|
+
.filter((a) => liveTransports.has(a.agentId) || now - a.lastHeartbeat < STALE_MS)
|
|
460
|
+
.map((a) => ({
|
|
461
|
+
agentId: a.agentId,
|
|
462
|
+
serverPid: a.serverPid,
|
|
463
|
+
serverStartedAt: a.serverStartedAt,
|
|
464
|
+
serverModule: a.serverModule,
|
|
465
|
+
})),
|
|
466
|
+
installedBuild(import.meta.url, statSync),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// ⟨q-178878aa⟩ — the humans the bus knows: visible here, read at send time, never evicted.
|
|
470
|
+
const humans = Object.entries(await readHumans()).map(([id, h]) => ({ id, ...h }));
|
|
471
|
+
return { agents, evicted, proseOnly, humans, serverSpread: spread };
|
|
421
472
|
}
|
|
422
473
|
|
|
423
474
|
/**
|