agent-coord-mcp 0.26.5 → 0.26.7

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