agent-coord-mcp 0.26.4 → 0.26.6

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.
@@ -0,0 +1,639 @@
1
+ /*
2
+ * Record verbs: `next_unblocked` · `claim` · `land`.
3
+ *
4
+ * These turn coordinator RECIPES into bus VERBS, so a forgotten step fails closed
5
+ * instead of looking healthy. The measured cost of the recipe: twelve merges went
6
+ * 23 hours unlogged, and the queue/DONE loop failed three times in nine hours with
7
+ * the rule written down each time.
8
+ *
9
+ * THE MARKDOWN IS AUTHORITATIVE (ADR-003). These read and write the documents
10
+ * directly rather than the derived store: a store import is a second source of
11
+ * truth, and the failure this phase exists to remove is exactly a second source
12
+ * that drifts.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { z } from "zod";
18
+ import {
19
+ parseWorkDoc,
20
+ renderWorkDoc,
21
+ queueItemsOf,
22
+ doneEntriesOf,
23
+ type QueueItem,
24
+ type WorkDoc,
25
+ phaseCitationsIn,
26
+ newlyTickedInDiff,
27
+ } from "@davidbalzan/groundwork-seam";
28
+ import { ensureWorktreeTool } from "./worktrees.js";
29
+ import { haltState } from "./stall.js";
30
+
31
+ const QUEUE_DOC = "docs/QUEUE.md";
32
+ const DONE_DOC = "docs/DONE.md";
33
+ const BOARD_DOC = "docs/WORKSTREAMS.md";
34
+ const PRIORITY_ORDER = { P1: 0, P2: 1, P3: 2 } as const;
35
+
36
+ const readDoc = (repo: string, rel: string): { text: string; doc: WorkDoc } | null => {
37
+ const p = path.join(repo, rel);
38
+ if (!existsSync(p)) return null;
39
+ const text = readFileSync(p, "utf8");
40
+ return { text, doc: parseWorkDoc(text) };
41
+ };
42
+
43
+ /**
44
+ * Write a document back HUNK-FAITHFULLY: everything the seam did not model is
45
+ * replayed verbatim, and an UNCHANGED file is not written at all.
46
+ *
47
+ * The round trip is byte-exact on all three live documents (measured before this
48
+ * was built, not assumed). Re-rendering an untouched file would still be a write,
49
+ * and a write is a diff someone has to review — so the no-op case returns false.
50
+ */
51
+ function writeDoc(repo: string, rel: string, doc: WorkDoc, original: string): boolean {
52
+ const out = renderWorkDoc(doc);
53
+ if (out === original) return false;
54
+ writeFileSync(path.join(repo, rel), out);
55
+ return true;
56
+ }
57
+
58
+ const git = (repo: string, args: string[]): string =>
59
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
60
+
61
+ /** `#123` / `owner/repo#123` → "123". */
62
+ function prNumber(pr: string): string | null {
63
+ const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
64
+ return m ? (m[1] as string) : null;
65
+ }
66
+
67
+ // ---------- blocked / no-downstream ----------
68
+
69
+ const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i;
70
+
71
+ /** The blocker an item names, if it names one. */
72
+ export function blockedBy(item: QueueItem): string | null {
73
+ const m = BLOCKED_RE.exec(String(item.text));
74
+ return m ? (m[1] as string).replace(/[`*]/g, "") : null;
75
+ }
76
+
77
+ const keyOf = (i: QueueItem) => String(i.text).replace(/\s+/g, " ").slice(0, 60);
78
+
79
+ /**
80
+ * A DONE summary a human would have written.
81
+ *
82
+ * The first `write:true` run produced
83
+ * `- [x] Kit: **THE ROOT GATE HAND-LISTS ITS FOUR PACKAGES BY NAME WH — …`:
84
+ * truncated MID-WORD at 60 characters, leaving an unbalanced `**`. It parses, so
85
+ * the glyph contract accepts it, and it reads as a line someone abandoned
86
+ * half-way. Cut on a word boundary and drop emphasis markers rather than leaving
87
+ * half of one.
88
+ */
89
+ export function summarize(text: string, max = 96): string {
90
+ const flat = String(text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
91
+ if (flat.length <= max) return flat;
92
+ const cut = flat.slice(0, max);
93
+ const at = cut.lastIndexOf(" ");
94
+ return `${(at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s\-—·,;:]+$/, "")}…`;
95
+ }
96
+
97
+ /**
98
+ * Items nothing else waits on.
99
+ *
100
+ * "BLOCKS NOTHING" IS NOT "COSTS NOTHING TO DEFER" — and an item that blocks
101
+ * nothing also ANNOUNCES nothing when it stalls. Everything else surfaces through
102
+ * the thing waiting on it; these have no such witness, so they go missing silently
103
+ * and their absence is found by someone re-reading the plan. That is why they are
104
+ * a SEPARATE AXIS here and not folded into the skipped-because-blocked list: those
105
+ * are two different states with two different remedies, and one list covering both
106
+ * leaves the reader to infer which.
107
+ *
108
+ * LIMIT, stated because it bounds the claim: dependency is detected from the TEXT.
109
+ * An unstated dependency is invisible to this, so "no downstream" means "nothing in
110
+ * the queue SAYS it waits on this", never "nothing waits on this".
111
+ */
112
+ export function noDownstream(items: QueueItem[]): QueueItem[] {
113
+ const open = items.filter((i) => !i.done);
114
+ const named = new Set<string>();
115
+ for (const i of open) {
116
+ const b = blockedBy(i);
117
+ if (b) named.add(b.toLowerCase());
118
+ }
119
+ return open.filter((i) => {
120
+ if (blockedBy(i)) return false; // it waits on something: not this axis
121
+ const key = keyOf(i).toLowerCase();
122
+ for (const n of named) if (n.length > 3 && key.includes(n)) return false;
123
+ return true;
124
+ });
125
+ }
126
+
127
+ // ---------- next_unblocked ----------
128
+
129
+ export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() };
130
+
131
+ export async function nextUnblockedTool(args: { project: string; repo?: string }) {
132
+ // A HALT IS A NAMED STATE AND IT BLOCKS THE LANE, not a suggestion. Handing out
133
+ // the next item during a board cutover or a cited BLOCKER is how work lands on
134
+ // a base nobody meant to be building on.
135
+ const halt = haltState();
136
+ if (halt.halted) {
137
+ return {
138
+ ok: false as const,
139
+ error: `HALTED by ${halt.by}: ${halt.reason}. No item is handed out while a halt is set — clear it with set_halt{clear:true} when the named condition is gone.`,
140
+ halted: true,
141
+ };
142
+ }
143
+ const repo = args.repo ?? process.cwd();
144
+ const q = readDoc(repo, QUEUE_DOC);
145
+ if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
146
+ const items = queueItemsOf(q.doc);
147
+ const open = items.filter((i) => !i.done);
148
+ const ranked = open
149
+ .map((i, idx) => ({ i, idx }))
150
+ .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
151
+
152
+ const skipped: { item: string; blockedBy: string }[] = [];
153
+ let pick: QueueItem | null = null;
154
+ for (const { i } of ranked) {
155
+ const b = blockedBy(i);
156
+ // NEVER STALL THE LANE waiting on a reorder: a blocked top item is skipped,
157
+ // visibly, and the next unblocked one is taken.
158
+ if (b) {
159
+ skipped.push({ item: keyOf(i), blockedBy: b });
160
+ continue;
161
+ }
162
+ pick = i;
163
+ break;
164
+ }
165
+
166
+ const silent = noDownstream(open);
167
+ // AN AXIS THAT FIRES ON EVERYTHING IS NOISE. If no item in the queue declares a
168
+ // dependency, then "nothing waits on this" is true of every item and the axis
169
+ // cannot discriminate — listing all of them trains the reader to skim, which is
170
+ // the severity finding one file over. Say the axis is uninformative instead.
171
+ const undiscriminating = silent.length === open.length && open.length > 1;
172
+ return {
173
+ ok: true as const,
174
+ project: args.project,
175
+ open: open.length,
176
+ next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
177
+ skipped,
178
+ boardHunks: skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
179
+ // A SEPARATE AXIS, deliberately. See noDownstream().
180
+ noDownstream: undiscriminating
181
+ ? {
182
+ count: silent.length,
183
+ items: [],
184
+ why:
185
+ `NO ITEM IN THIS QUEUE DECLARES A DEPENDENCY, so "nothing waits on this" is true of all ${open.length} and this axis ` +
186
+ "cannot discriminate. Reporting every item would train you to skim it. Every item's absence here is equally silent, " +
187
+ "which is a fact about the QUEUE rather than about any item — declare dependencies (`blocked by <id>`) and this becomes useful.",
188
+ }
189
+ : {
190
+ count: silent.length,
191
+ items: silent.slice(0, 10).map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
192
+ why:
193
+ "nothing in the queue says it waits on these, so their absence is SILENT — they need an explicit check at each stage boundary. " +
194
+ "Detected from item text: an unstated dependency is invisible here.",
195
+ },
196
+ };
197
+ }
198
+
199
+ // ---------- claim ----------
200
+
201
+ export const claimSchema = {
202
+ project: z.string().min(1),
203
+ agentId: z.string().min(1),
204
+ itemId: z.string().optional(),
205
+ repo: z.string().optional(),
206
+ base: z.string().optional(),
207
+ task: z.string().optional(),
208
+ };
209
+
210
+ export async function claimTool(args: { project: string; agentId: string; itemId?: string; repo?: string; base?: string; task?: string }) {
211
+ const halt = haltState();
212
+ if (halt.halted) {
213
+ return {
214
+ ok: false as const,
215
+ error: `HALTED by ${halt.by}: ${halt.reason}. Claiming is refused while a halt is set.`,
216
+ halted: true,
217
+ };
218
+ }
219
+ const repo = args.repo ?? process.cwd();
220
+ const q = readDoc(repo, QUEUE_DOC);
221
+ if (!q) return { ok: false as const, error: `no ${QUEUE_DOC} under '${repo}'` };
222
+ const items = queueItemsOf(q.doc);
223
+ let item = args.itemId ? items.find((i) => i.id === args.itemId) : null;
224
+ if (args.itemId && !item) return { ok: false as const, error: `no queue item with id '${args.itemId}'` };
225
+ if (!item) {
226
+ const next = await nextUnblockedTool({ project: args.project, repo });
227
+ if (!next.ok || !next.next) return { ok: false as const, error: "no unblocked item to claim" };
228
+ item = items.find((i) => i.id === next.next!.id) ?? null;
229
+ }
230
+ if (!item) return { ok: false as const, error: "no unblocked item to claim" };
231
+
232
+ // TASK 1 LANDED, SO THE PLACEHOLDER IS GONE RATHER THAN LEFT SWITCHED OFF.
233
+ //
234
+ // While `ensure_worktree` did not exist this returned a loud warning that the
235
+ // worktree was NOT ensured — conditional on the verb's absence, because a
236
+ // warning that never clears stops being read. The verb exists now, so `claim`
237
+ // CALLS it: the interface is unchanged and the gap is closed rather than
238
+ // annotated. An interface with a placeholder nobody removes is how a temporary
239
+ // state becomes canon.
240
+ //
241
+ // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
242
+ // item to an agent with nowhere isolated to work is the shared-checkout failure
243
+ // this pair exists to prevent.
244
+ const wt = await ensureWorktreeTool({
245
+ agentId: args.agentId,
246
+ repo,
247
+ base: args.base ?? "main",
248
+ task: args.task,
249
+ });
250
+ if (!wt.ok) {
251
+ return {
252
+ ok: false as const,
253
+ error: `cannot claim: no isolated worktree — ${wt.error}`,
254
+ item: { id: item.id, priority: item.priority },
255
+ };
256
+ }
257
+
258
+ return {
259
+ ok: true as const,
260
+ project: args.project,
261
+ agentId: args.agentId,
262
+ item: { id: item.id, priority: item.priority, text: item.text },
263
+ boardHunk: `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`,
264
+ worktreeEnsured: true,
265
+ worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base },
266
+ };
267
+ }
268
+
269
+ // ---------- land ----------
270
+
271
+ export const landSchema = {
272
+ project: z.string().min(1),
273
+ pr: z.string().min(1),
274
+ queueItemId: z.string().optional(),
275
+ repo: z.string().optional(),
276
+ base: z.string().optional(),
277
+ write: z.boolean().optional(),
278
+ };
279
+
280
+ export async function landTool(args: {
281
+ project: string;
282
+ pr: string;
283
+ queueItemId?: string;
284
+ repo?: string;
285
+ base?: string;
286
+ write?: boolean;
287
+ }) {
288
+ const repo = args.repo ?? process.cwd();
289
+ const base = args.base ?? "main";
290
+ const n = prNumber(args.pr);
291
+ // A CITED PR OR REFUSE. A `DONE:` without a ref cannot be tied to anything by
292
+ // anyone, ever — which is its own finding, not a formatting preference.
293
+ if (!n) {
294
+ return { ok: false as const, error: `'${args.pr}' names no PR number — a DONE entry with no ref can never be tied to the work. Cite owner/repo#N.` };
295
+ }
296
+
297
+ // MERGED ON THE TARGET'S TIP, NOT THE MERGE BASE.
298
+ //
299
+ // "What does the thing I am merging INTO have that I do not?" is answered only
300
+ // by the target's tip: the merge base is the point the branch DIVERGED from, so
301
+ // anything landed after the cut is missing from it too — comparing against it is
302
+ // exactly as blind as comparing against the branch (kit#102/#103). Squash merges
303
+ // leave no ancestor either, so the citation in the merge SUBJECT is the tie.
304
+ const ref = `origin/${base}`;
305
+ let landedIn: string | null = null;
306
+ let reason: string | null = null;
307
+ try {
308
+ const subjects = git(repo, ["log", "-n", "400", "--format=%H %s", ref]);
309
+ const hit = subjects.split("\n").find((l) => new RegExp(`\\(#${n}\\)|#${n}\\b`).test(l));
310
+ landedIn = hit ? (hit.split(" ")[0] as string) : null;
311
+ } catch (e) {
312
+ reason = `could not read ${ref} (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
313
+ }
314
+ if (reason) return { ok: false as const, error: reason };
315
+ if (!landedIn) {
316
+ return {
317
+ ok: false as const,
318
+ error: `#${n} is not on ${ref} — refusing to record it as landed. Compared against the TARGET TIP (${ref}), not a merge base: an item merged after this branch was cut is missing from the base too.`,
319
+ comparedAgainst: ref,
320
+ };
321
+ }
322
+
323
+ const q = readDoc(repo, QUEUE_DOC);
324
+ const d = readDoc(repo, DONE_DOC);
325
+ if (!q || !d) return { ok: false as const, error: `need both ${QUEUE_DOC} and ${DONE_DOC} under '${repo}'` };
326
+
327
+ const items = queueItemsOf(q.doc);
328
+
329
+ // WHICH ITEM A PR CLOSES IS A JUDGEMENT, AND GUESSING IT CLOSED THE WRONG ONE.
330
+ //
331
+ // The first real use of this verb matched `#116` against an item that merely
332
+ // MENTIONED #116 in its body — an item about seam ids — and reported it closed.
333
+ // With write:true it would have closed unrelated, still-open work. That is the
334
+ // occurrence-vs-position defect I fixed for sweep tags, reintroduced here in a
335
+ // different matcher hours later: a citation IN an item's text is not a claim
336
+ // that the item is closed BY it.
337
+ //
338
+ // There is no positional convention to anchor on, so the honest fix is not a
339
+ // better guess: `land` CLOSES ONLY WHAT IT IS TOLD TO CLOSE. Without an explicit
340
+ // `queueItemId` it closes nothing and reports the candidates for the caller to
341
+ // pick, saying so.
342
+ const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
343
+ const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
344
+ if (args.queueItemId && !target) return { ok: false as const, error: `no queue item with id '${args.queueItemId}'` };
345
+
346
+ // CLOSE = STATUS ONLY. The item's priority and body BYTES are untouched: the
347
+ // seam re-renders `- [x] (P2) <text>` from the same text it parsed, so closing
348
+ // cannot reword an item. That is asserted by a fixture, not by inspection.
349
+ let queueChanged = false;
350
+ if (target) {
351
+ target.done = true;
352
+ queueChanged = true;
353
+ }
354
+
355
+ const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
356
+
357
+ // A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE.
358
+ //
359
+ // First real use produced `- [x] PR #117 — #117 · 2026-08-28`: no description of
360
+ // the work, and a BARE ref where every existing entry carries `owner/repo#N`.
361
+ // The glyph contract parses it, so a shape check would pass it — and it tells a
362
+ // reader nothing, which is the whole job of the file.
363
+ //
364
+ // So: the summary comes from the item being closed, and a bare `#N` is reported
365
+ // as UNDER-QUALIFIED rather than silently written.
366
+ const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
367
+ const summary = target ? summarize(target.text) : null;
368
+
369
+ const doneLine =
370
+ already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
371
+
372
+ // APPEND TO DONE.md — the half this verb exists for.
373
+ //
374
+ // The first `write:true` run closed the queue item and wrote NOTHING to
375
+ // DONE.md: it did half the loop, and the half it skipped is the one that failed
376
+ // three times in nine hours and left twelve merges unlogged for 23 hours. A verb
377
+ // built to close that gap that does not write DONE is the gap with a tool in
378
+ // front of it.
379
+ //
380
+ // Appended as TEXT to the last done block so the rest of the file is replayed
381
+ // byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
382
+ // an entry added to the record model renders through the pinned glyph contract.
383
+ const wrote: string[] = [];
384
+ if (args.write) {
385
+ if (queueChanged && writeDoc(repo, QUEUE_DOC, q.doc, q.text)) wrote.push(QUEUE_DOC);
386
+ if (doneLine) {
387
+ const blocks = d.doc.blocks;
388
+ let last = -1;
389
+ for (let i = 0; i < blocks.length; i++) if (blocks[i]?.kind === "done") last = i;
390
+ if (last === -1) {
391
+ return {
392
+ ok: false as const,
393
+ error: `${DONE_DOC} has no parsed done block to append to — refusing to guess where the entry goes. A DONE.md that parses to zero entries is a defect in the log, not an empty log.`,
394
+ };
395
+ }
396
+ const block = blocks[last] as { kind: "done"; entries: unknown[] };
397
+ const parsed = parseWorkDoc(`## Done\n${doneLine}\n`);
398
+ const entry = doneEntriesOf(parsed)[0];
399
+ if (!entry) {
400
+ return { ok: false as const, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
401
+ }
402
+ block.entries.push(entry);
403
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text)) wrote.push(DONE_DOC);
404
+ }
405
+ }
406
+
407
+ return {
408
+ ok: true as const,
409
+ project: args.project,
410
+ pr: `#${n}`,
411
+ comparedAgainst: ref,
412
+ landedIn: landedIn.slice(0, 8),
413
+ queueItem: target ? { id: target.id, closed: true, textUnchanged: true } : null,
414
+ candidates: target
415
+ ? undefined
416
+ : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
417
+ ...(target || !candidates.length
418
+ ? {}
419
+ : {
420
+ note_candidates:
421
+ `${candidates.length} open item(s) MENTION #${n}; none was closed. A citation in an item's text is not a claim ` +
422
+ `that the item is closed by it — pass queueItemId to close one deliberately.`,
423
+ }),
424
+ doneEntry: doneLine,
425
+ ...(bareRef
426
+ ? {
427
+ refWarning:
428
+ `'${args.pr}' is an UNDER-QUALIFIED ref — every entry in DONE.md carries owner/repo#N, and a bare #N does not ` +
429
+ `identify a repository. Pass the full ref; the glyph contract would parse the bare one and it would tell a reader nothing.`,
430
+ }
431
+ : {}),
432
+ ...(summary || already ? {} : { doneEntryWithheld: "no queue item named, so there is no description to write — a DONE line reading only 'PR #N' is not one a human would write" }),
433
+ alreadyLogged: already,
434
+ written: wrote,
435
+ note: args.write ? undefined : "reporting only — pass write:true to apply. The markdown is authoritative; a report is not a write.",
436
+ };
437
+ }
438
+
439
+ /* ────────────────────────────────────────────────────────────────────────────
440
+ * `merge` — THE MERGE STEP CONSUMES THE CHECK VERDICT, STRUCTURALLY.
441
+ *
442
+ * MEASURED COST: kit#123 was merged on a red CI. The wait-loop read `test fail`
443
+ * and the merge command ran anyway — the instrument RAN, its result was READ,
444
+ * and the control flow did not DEPEND on it. That is the same shape as the six
445
+ * shell-pattern mutations that reported clean the same day: a check whose
446
+ * outcome nothing branches on is decoration, and it trains the reader to skip
447
+ * it precisely because the outcome afterwards was fine.
448
+ *
449
+ * A rule saying "wait for green" cannot fix that, because the failure was not
450
+ * ignorance of the rule — the coordinator wrote the loop, read the failure, and
451
+ * merged. So the verdict is not returned for a caller to honour: the merge is
452
+ * DOWNSTREAM OF IT IN ONE CALL. There is no ordering of this verb in which the
453
+ * check runs and the merge ignores it.
454
+ *
455
+ * Naturally this cannot stop someone typing `gh pr merge`. It removes the
456
+ * unpoliced step from the path that is meant to be used, and it makes the
457
+ * bypass a visible choice rather than a loop that looked correct.
458
+ * ──────────────────────────────────────────────────────────────────────────── */
459
+
460
+ /** One check as we judge it, normalised across gh's two rollup shapes. */
461
+ export type CheckRow = { name: string; state: string; verdict: "pass" | "fail" | "pending" };
462
+
463
+ /**
464
+ * gh reports CheckRun as status+conclusion and StatusContext as a bare state,
465
+ * and a rollup routinely contains BOTH. Reading only one shape silently scores
466
+ * the other as unknown — so normalise explicitly and let anything unrecognised
467
+ * fall to `pending`, which refuses. An unreadable check is not a passing one.
468
+ */
469
+ export function normalizeChecks(rollup: unknown[]): CheckRow[] {
470
+ return (rollup ?? []).map((r) => {
471
+ const c = (r ?? {}) as Record<string, unknown>;
472
+ const name = String(c.name ?? c.context ?? "(unnamed)");
473
+ const status = String(c.status ?? "").toUpperCase();
474
+ const raw = String(c.conclusion ?? c.state ?? "").toUpperCase();
475
+ if (status && status !== "COMPLETED") return { name, state: status, verdict: "pending" as const };
476
+ if (raw === "SUCCESS") return { name, state: raw, verdict: "pass" as const };
477
+ // NEUTRAL and SKIPPED are deliberately NOT passes. A check that declined to
478
+ // run has not evidenced anything, and scoring it green is the unread-input
479
+ // defect: reporting clean on something never read.
480
+ if (raw === "FAILURE" || raw === "ERROR" || raw === "TIMED_OUT" || raw === "CANCELLED" || raw === "ACTION_REQUIRED")
481
+ return { name, state: raw, verdict: "fail" as const };
482
+ return { name, state: raw || status || "UNKNOWN", verdict: "pending" as const };
483
+ });
484
+ }
485
+
486
+ export const mergeSchema = {
487
+ project: z.string().min(1),
488
+ pr: z.string().min(1),
489
+ repo: z.string().optional(),
490
+ method: z.enum(["squash", "merge", "rebase"]).optional(),
491
+ write: z.boolean().optional(),
492
+ };
493
+
494
+ /** Injected so every refusal branch is provable offline; defaults to real gh. */
495
+ export type PrFacts = {
496
+ state: string;
497
+ mergeable: string;
498
+ checks: unknown[];
499
+ /** Unified diff of the PR, for the ticked-box audit. */
500
+ diff?: string;
501
+ /** Everything that will survive the merge as a citation: title + commit subjects + body. */
502
+ citationText?: string;
503
+ };
504
+ const ghFacts = (repo: string, n: string): PrFacts => {
505
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
506
+ cwd: repo,
507
+ encoding: "utf8",
508
+ stdio: ["ignore", "pipe", "ignore"],
509
+ });
510
+ const j = JSON.parse(out) as Record<string, unknown>;
511
+ const gh = (args: string[]) => {
512
+ try {
513
+ return execFileSync("gh", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
514
+ } catch {
515
+ return "";
516
+ }
517
+ };
518
+ // The citation must survive the MERGE, so what counts is what lands in
519
+ // history: the PR title (which becomes the squash subject) and the commit
520
+ // subjects. The body is included because a reviewer reads it, but a claim
521
+ // that lives only in a comment thread is not a record.
522
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}") as Record<string, unknown>;
523
+ const subjects = ((meta.commits as { messageHeadline?: string }[] | undefined) ?? [])
524
+ .map((c) => c.messageHeadline ?? "")
525
+ .join("\n");
526
+ return {
527
+ state: String(j.state ?? ""),
528
+ mergeable: String(j.mergeable ?? ""),
529
+ checks: (j.statusCheckRollup as unknown[]) ?? [],
530
+ diff: gh(["pr", "diff", n]),
531
+ citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
532
+ };
533
+ };
534
+
535
+ /**
536
+ * The act of merging, injected.
537
+ *
538
+ * NOT a testability nicety: with only `facts` injected, a test that supplied
539
+ * passing checks and `write:true` fell through to the REAL `gh pr merge` and
540
+ * tried to merge an actual PR. It failed only because that PR was already
541
+ * merged. A test suite that can merge a live pull request is a worse defect
542
+ * than anything this verb exists to catch, so the side effect is now something
543
+ * a caller hands in.
544
+ */
545
+ export type DoMerge = (repo: string, n: string, method: string) => void;
546
+ const ghMerge: DoMerge = (repo, n, method) => {
547
+ execFileSync("gh", ["pr", "merge", n, `--${method}`, "--delete-branch"], {
548
+ cwd: repo,
549
+ encoding: "utf8",
550
+ stdio: ["ignore", "pipe", "ignore"],
551
+ });
552
+ };
553
+
554
+ export async function mergeTool(
555
+ args: { project: string; pr: string; repo?: string; method?: string; write?: boolean },
556
+ facts: (repo: string, n: string) => PrFacts = ghFacts,
557
+ doMerge: DoMerge = ghMerge,
558
+ ) {
559
+ const repo = args.repo ?? process.cwd();
560
+ const n = prNumber(args.pr);
561
+ if (!n) return { ok: false as const, error: `'${args.pr}' names no PR number. Cite owner/repo#N.` };
562
+
563
+ let f: PrFacts;
564
+ try {
565
+ f = facts(repo, n);
566
+ } catch (e) {
567
+ // NOT CHECKED IS NOT CHECKED-AND-GREEN. If we cannot read the verdict we
568
+ // cannot have consumed it, and this verb's whole claim is that it did.
569
+ return {
570
+ ok: false as const,
571
+ error: `could not read the checks for #${n} (${String((e as Error).message).split("\n")[0]}) — NOT read, which is not the same as read and passing.`,
572
+ };
573
+ }
574
+
575
+ const checks = normalizeChecks(f.checks);
576
+ const failed = checks.filter((c) => c.verdict === "fail");
577
+ const pending = checks.filter((c) => c.verdict === "pending");
578
+ // EVERY RETURN CARRIES THE POPULATION IT JUDGED. "All passed" over an empty
579
+ // set is the sentence this verb exists to make unsayable.
580
+ const verdict = { population: checks.length, checks, failed: failed.map((c) => c.name), pending: pending.map((c) => c.name) };
581
+
582
+ if (f.state !== "OPEN") return { ok: false as const, error: `#${n} is ${f.state || "not OPEN"} — nothing to merge.`, verdict };
583
+
584
+ // NO CHECKS IS NOT PASSING CHECKS.
585
+ //
586
+ // Same invariant as the stall clock: no alerts is not no stalls, and a clock
587
+ // that stopped reads quiet exactly like a system that is fine. An empty
588
+ // rollup is the strongest-looking green there is — zero failures — and it is
589
+ // evidence of nothing at all.
590
+ if (checks.length === 0)
591
+ return { ok: false as const, error: `#${n} reports ZERO checks. No checks is not passing checks — an empty rollup has zero failures and evidences nothing.`, verdict };
592
+
593
+ if (failed.length)
594
+ return { ok: false as const, error: `#${n} has ${failed.length} of ${checks.length} check(s) FAILING: ${failed.map((c) => c.name).join(", ")}. Refusing to merge.`, verdict };
595
+
596
+ if (pending.length)
597
+ return { ok: false as const, error: `#${n} has ${pending.length} of ${checks.length} check(s) not yet terminal: ${pending.map((c) => c.name).join(", ")}. A check still running has not returned a verdict to consume.`, verdict };
598
+
599
+ // EVERY NEWLY-TICKED CHECKBOX MUST BE CITED, CHECKED AT THE MERGE.
600
+ //
601
+ // Twice in one day a PR carried two things and left one unrecorded: kit#126
602
+ // ticked 4.4 and named it nowhere, and all five of Task 5's boxes shipped
603
+ // inside kit#127 alongside a CI fix. The pattern is not carelessness — a PR
604
+ // that fixes an incident AND delivers planned work gets the incident
605
+ // remembered and the work forgotten, because the incident is what everyone
606
+ // is talking about. Review caught neither; the coordinator found both after
607
+ // the fact.
608
+ //
609
+ // So it is checked where the record is actually made. `doctor`'s
610
+ // `phase-checkbox` finds this too, but only AFTER the merge, against DONE.md
611
+ // and merged subjects — by which time the claim is already in history
612
+ // unevidenced. The citation grammar is shared through seam rather than
613
+ // copied, because a copied grammar is two grammars the moment one is fixed.
614
+ const ticked = newlyTickedInDiff(f.diff ?? "");
615
+ if (ticked.size) {
616
+ const cited = phaseCitationsIn(f.citationText ?? "");
617
+ const uncited = [...ticked].filter((k) => !cited.has(k));
618
+ if (uncited.length) {
619
+ const names = uncited.map((k) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; });
620
+ return {
621
+ ok: false as const,
622
+ error:
623
+ `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNCITED: ${names.join(", ")}. ` +
624
+ `Once this merges, the tick claims progress that nothing in history evidences. Name them in the PR title or a commit subject (e.g. "${names[0]}") — the subject is what survives a squash merge.`,
625
+ verdict,
626
+ uncited: names,
627
+ };
628
+ }
629
+ }
630
+
631
+ if (f.mergeable === "CONFLICTING")
632
+ return { ok: false as const, error: `#${n} is CONFLICTING with its base.`, verdict };
633
+
634
+ if (!args.write)
635
+ return { ok: true as const, merged: false as const, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
636
+
637
+ doMerge(repo, n, args.method ?? "squash");
638
+ return { ok: true as const, merged: true as const, verdict };
639
+ }
@@ -1,4 +1,5 @@
1
1
  import { detachAgentTool } from "./transport.js";
2
+ import { readAway, secondCoordinatorRefusal } from "./away.js";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { existsSync, openSync, watch } from "node:fs";
4
5
  import { promises as fsp } from "node:fs";
@@ -60,6 +61,7 @@ import {
60
61
  type Cursor,
61
62
  type Source,
62
63
  type TransportMarker,
64
+ roomFeedOf,
63
65
  sourceFile,
64
66
  getOffset,
65
67
  setOffset,
@@ -123,6 +125,13 @@ export async function registerTool(args: { agentId: string; project?: string; ro
123
125
  const roleUpdate = resolveRoleUpdate(args.agentId, before[args.agentId], args.role);
124
126
  if (!roleUpdate.ok) return { ok: false as const, error: roleUpdate.error };
125
127
 
128
+ // 4.2 — a SECOND coordinator may not register while the first is away.
129
+ // Checked on the RESOLVED role, not the string the caller passed: the refusal
130
+ // has to see what the registry will actually record, or a spelling slips past
131
+ // the guard and lands as `coordinator` anyway.
132
+ const second = secondCoordinatorRefusal(readAway(), args.agentId, roleUpdate.roleId);
133
+ if (second) return { ok: false as const, error: second };
134
+
126
135
  const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
127
136
  const now = Date.now();
128
137
  const existing = current[args.agentId];
@@ -300,7 +309,19 @@ export async function listAgentsTool() {
300
309
  ...heartbeatFields,
301
310
  capabilities: merged.length > 0 ? merged : undefined,
302
311
  transport: transport
303
- ? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid }
312
+ ? {
313
+ kind: transport.transport,
314
+ tmuxTarget: transport.tmuxTarget,
315
+ pid: transport.pid,
316
+ // `attached` alone said nothing about WHAT is attached. A pusher
317
+ // started `--no-room` delivers DMs only, and its marker used to be
318
+ // indistinguishable from a full one — so an agent could sit with
319
+ // its room feed off while status read healthy (worker-2).
320
+ //
321
+ // Absent is UNKNOWN, never "on": an older pusher's marker cannot
322
+ // answer, and answering for it is how the original defect worked.
323
+ rooms: roomFeedOf(transport),
324
+ }
304
325
  : undefined,
305
326
  };
306
327
  });