agent-coord-mcp 0.26.18 → 0.26.20

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 (84) hide show
  1. package/README.md +82 -0
  2. package/dist/build.js +113 -0
  3. package/dist/build.js.map +1 -0
  4. package/dist/capabilities.js +214 -0
  5. package/dist/capabilities.js.map +1 -0
  6. package/dist/prefix.js +64 -0
  7. package/dist/prefix.js.map +1 -0
  8. package/dist/roles.js +132 -0
  9. package/dist/roles.js.map +1 -0
  10. package/dist/server-identity.js +82 -0
  11. package/dist/server-identity.js.map +1 -0
  12. package/dist/server.js +668 -0
  13. package/dist/server.js.map +1 -0
  14. package/dist/store.js +553 -0
  15. package/dist/store.js.map +1 -0
  16. package/dist/tools/admin.js +323 -0
  17. package/dist/tools/admin.js.map +1 -0
  18. package/dist/tools/attention.js +73 -0
  19. package/dist/tools/attention.js.map +1 -0
  20. package/dist/tools/away.js +285 -0
  21. package/dist/tools/away.js.map +1 -0
  22. package/dist/tools/board-ref.js +208 -0
  23. package/dist/tools/board-ref.js.map +1 -0
  24. package/dist/tools/event-kinds.js +39 -0
  25. package/dist/tools/event-kinds.js.map +1 -0
  26. package/dist/tools/events.js +234 -0
  27. package/dist/tools/events.js.map +1 -0
  28. package/dist/tools/index.js +14 -0
  29. package/dist/tools/index.js.map +1 -0
  30. package/dist/tools/logwatch.js +85 -0
  31. package/dist/tools/logwatch.js.map +1 -0
  32. package/dist/tools/messaging.js +706 -0
  33. package/dist/tools/messaging.js.map +1 -0
  34. package/dist/tools/record-events.js +380 -0
  35. package/dist/tools/record-events.js.map +1 -0
  36. package/dist/tools/records.js +1205 -0
  37. package/dist/tools/records.js.map +1 -0
  38. package/dist/tools/registry.js +529 -0
  39. package/dist/tools/registry.js.map +1 -0
  40. package/dist/tools/render.js +2 -0
  41. package/dist/tools/render.js.map +1 -0
  42. package/dist/tools/rooms.js +210 -0
  43. package/dist/tools/rooms.js.map +1 -0
  44. package/dist/tools/rotate.js +143 -0
  45. package/dist/tools/rotate.js.map +1 -0
  46. package/dist/tools/scopes.js +126 -0
  47. package/dist/tools/scopes.js.map +1 -0
  48. package/dist/tools/shared.js +104 -0
  49. package/dist/tools/shared.js.map +1 -0
  50. package/dist/tools/stall.js +415 -0
  51. package/dist/tools/stall.js.map +1 -0
  52. package/dist/tools/transport.js +1918 -0
  53. package/dist/tools/transport.js.map +1 -0
  54. package/dist/tools/work.js +465 -0
  55. package/dist/tools/work.js.map +1 -0
  56. package/dist/tools/worktrees.js +581 -0
  57. package/dist/tools/worktrees.js.map +1 -0
  58. package/dist/transports/config.js +82 -0
  59. package/dist/transports/config.js.map +1 -0
  60. package/dist/transports/index.js +113 -0
  61. package/dist/transports/index.js.map +1 -0
  62. package/dist/transports/tmux.js +140 -0
  63. package/dist/transports/tmux.js.map +1 -0
  64. package/dist/transports/types.js +86 -0
  65. package/dist/transports/types.js.map +1 -0
  66. package/dist/typed-records.js +174 -0
  67. package/dist/typed-records.js.map +1 -0
  68. package/dist/work.js +2 -0
  69. package/dist/work.js.map +1 -0
  70. package/hooks/tmux-pusher.mjs +33 -3
  71. package/package.json +1 -1
  72. package/src/capabilities.ts +104 -1
  73. package/src/server.ts +30 -1
  74. package/src/tools/records.ts +221 -34
  75. package/src/tools/registry.ts +36 -5
  76. package/src/tools/shared.ts +12 -36
  77. package/src/tools/stall.ts +2 -1
  78. package/src/tools/transport.ts +96 -43
  79. package/src/tools/work.ts +95 -3
  80. package/src/tools/worktrees.ts +234 -32
  81. package/src/transports/config.ts +110 -0
  82. package/src/transports/index.ts +126 -0
  83. package/src/transports/tmux.ts +177 -0
  84. package/src/transports/types.ts +201 -0
@@ -0,0 +1,1205 @@
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 { parseWorkDoc, renderWorkDocForWrite, queueItemsOf, doneEntriesOf, phaseCitationsDetailed, newlyTickedInDiff, sweepTagOf, awaitingOf, refsIn, refsMatch, workstreamsV1RowsOf, } from "@davidbalzan/groundwork-seam";
19
+ import { ensureWorktreeTool } from "./worktrees.js";
20
+ import { boardRefFor, classifyBoardRef } from "./board-ref.js";
21
+ import { haltState, isInFlightStatus } from "./stall.js";
22
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
23
+ const QUEUE_DOC = "docs/QUEUE.md";
24
+ const DONE_DOC = "docs/DONE.md";
25
+ const BOARD_DOC = "docs/WORKSTREAMS.md";
26
+ const PRIORITY_ORDER = { P1: 0, P2: 1, P3: 2 };
27
+ const readDoc = (repo, rel) => {
28
+ const p = path.join(repo, rel);
29
+ if (!existsSync(p))
30
+ return null;
31
+ const text = readFileSync(p, "utf8");
32
+ return { text, doc: parseWorkDoc(text) };
33
+ };
34
+ /**
35
+ * Write a document back HUNK-FAITHFULLY: everything the seam did not model is
36
+ * replayed verbatim, and an UNCHANGED file is not written at all.
37
+ *
38
+ * The round trip is byte-exact on all three live documents (measured before this
39
+ * was built, not assumed). Re-rendering an untouched file would still be a write,
40
+ * and a write is a diff someone has to review — so the no-op case returns false.
41
+ */
42
+ /**
43
+ * EVERY SUPPORTED WRITE OF A WORK DOC GOES THROUGH HERE, and it stamps.
44
+ *
45
+ * `renderWorkDocForWrite` records identity for any queue item that lacks it,
46
+ * including rows this caller did not author (q-c50e9b83) — the absorbed raw
47
+ * append is exactly the case, and stamping only the caller's own rows would keep
48
+ * the defect and move the blame. Every id written is the one the item already
49
+ * has, so nothing changes value.
50
+ *
51
+ * Returns what it stamped so the caller can report it rather than leaving an
52
+ * absorbing writer to find it in a diff.
53
+ */
54
+ /**
55
+ * A WRITE WHOSE BASE MOVED UNDER IT. Thrown, never returned, because a refusal
56
+ * that can be ignored is how the silent loss happened in the first place.
57
+ */
58
+ export class StaleWriteError extends Error {
59
+ rel;
60
+ detail;
61
+ alreadyWritten;
62
+ constructor(rel, detail, alreadyWritten) {
63
+ super(`refusing to write ${rel}: it changed on disk after this call read it (${detail}). ` +
64
+ `Nothing was overwritten. Re-read and retry.` +
65
+ (alreadyWritten.length ? ` ALREADY WRITTEN by this call: ${alreadyWritten.join(", ")} — that write stands.` : ""));
66
+ this.rel = rel;
67
+ this.detail = detail;
68
+ this.alreadyWritten = alreadyWritten;
69
+ this.name = "StaleWriteError";
70
+ }
71
+ }
72
+ /** What moved, in terms a caller can act on without diffing the file itself. */
73
+ function describeDrift(original, current) {
74
+ const ol = original.split("\n");
75
+ const cl = current.split("\n");
76
+ let i = 0;
77
+ while (i < ol.length && i < cl.length && ol[i] === cl[i])
78
+ i++;
79
+ return (`${ol.length} → ${cl.length} line(s), ${original.length} → ${current.length} byte(s)` +
80
+ (i < Math.max(ol.length, cl.length) ? `, first difference at line ${i + 1}` : ""));
81
+ }
82
+ /**
83
+ * ⛔ COMPARE-AND-SWAP, AND THE RE-READ IS THE WHOLE POINT.
84
+ *
85
+ * This function used to compare the rendered text against `original` — the
86
+ * caller's IN-MEMORY snapshot from when it read — and then write. It never looked
87
+ * at the file again. Anything written in between was clobbered with no error, no
88
+ * diff, and nothing in the return value: `{ written: true }` came back for a
89
+ * write that had destroyed someone else's row.
90
+ *
91
+ * Measured before fixing: two writers taking the same original and both
92
+ * committing leave TWO rows where three should be, and the first writer's row is
93
+ * simply gone. This is not hypothetical — four processes write these documents
94
+ * (the aide from its own clone, `claim`, `land`, worker seats), and `land --write`
95
+ * ran six times in one afternoon against a queue the aide was editing.
96
+ *
97
+ * So the file is re-read immediately before the write and must still match what
98
+ * the caller read. On a mismatch this REFUSES — loudly, by throwing — and names
99
+ * what moved. A refused write is recoverable; a silent overwrite is not, and the
100
+ * loser of the race never learns it lost.
101
+ *
102
+ * What this does NOT claim: it is not a lock. Two processes can still interleave
103
+ * between this re-read and the `writeFileSync` a few microseconds later. The
104
+ * window goes from "the whole duration of the caller's work" — parsing, git
105
+ * calls, composing a line — down to two adjacent statements. That is a large
106
+ * reduction and not zero, and a lock is the stronger fix if this ever proves
107
+ * insufficient.
108
+ *
109
+ * EXPORTED FOR TESTS, deliberately. The race it guards is BETWEEN PROCESSES —
110
+ * the aide's clone, `claim`, `land` — and `landTool` runs synchronously from its
111
+ * read to its write, so no in-process test can interleave them. An end-to-end
112
+ * attempt passed identically with the guard removed, i.e. it proved nothing. A
113
+ * data-loss guard is worth a narrow export to be testable at the level it lives.
114
+ */
115
+ export function writeDoc(repo, rel, doc, original, alreadyWritten = []) {
116
+ const { text, stamped } = renderWorkDocForWrite(doc);
117
+ if (text === original)
118
+ return { written: false, stamped: [] };
119
+ const p = path.join(repo, rel);
120
+ const current = existsSync(p) ? readFileSync(p, "utf8") : "";
121
+ if (current !== original) {
122
+ throw new StaleWriteError(rel, describeDrift(original, current), alreadyWritten);
123
+ }
124
+ writeFileSync(p, text);
125
+ return { written: true, stamped };
126
+ }
127
+ const git = (repo, args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
128
+ /** `#123` / `owner/repo#123` → "123". */
129
+ function prNumber(pr) {
130
+ const m = /#(\d+)\b/.exec(String(pr)) ?? /^(\d+)$/.exec(String(pr).trim());
131
+ return m ? m[1] : null;
132
+ }
133
+ /**
134
+ * The `owner/repo` a BARE `#N` in this repo's docs should be read as naming —
135
+ * the same derivation `groundwork doctor` makes (`originRepoOf`) before it ties
136
+ * a closed item's citation to DONE.md. `land` must decide "already cited" with
137
+ * the SAME rule the auditor will apply, or the two disagree again one level
138
+ * down: a bare `#77` in an item body is a tie for doctor only when the origin
139
+ * URL says which repo, and it is a tie here under exactly that condition.
140
+ * Null (no remote, not a repo) means an unknown context, never a guessed one.
141
+ */
142
+ function originRepoOf(repo) {
143
+ try {
144
+ const url = git(repo, ["remote", "get-url", "origin"]);
145
+ const m = /[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
146
+ return m ? m[1] : null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ // ---------- blocked / no-downstream ----------
153
+ const BLOCKED_RE = /\bblocked (?:by|on)\b[:\s]*([^\s·,.;]+)/i;
154
+ /** The blocker an item names, if it names one. */
155
+ export function blockedBy(item) {
156
+ const m = BLOCKED_RE.exec(String(item.text));
157
+ return m ? m[1].replace(/[`*]/g, "") : null;
158
+ }
159
+ const keyOf = (i) => String(i.text).replace(/\s+/g, " ").slice(0, 60);
160
+ /**
161
+ * A DONE summary a human would have written.
162
+ *
163
+ * The first `write:true` run produced
164
+ * `- [x] Kit: **THE ROOT GATE HAND-LISTS ITS FOUR PACKAGES BY NAME WH — …`:
165
+ * truncated MID-WORD at 60 characters, leaving an unbalanced `**`. It parses, so
166
+ * the glyph contract accepts it, and it reads as a line someone abandoned
167
+ * half-way. Cut on a word boundary and drop emphasis markers rather than leaving
168
+ * half of one.
169
+ */
170
+ export function summarize(text, max = 96) {
171
+ const flat = String(text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
172
+ if (flat.length <= max)
173
+ return flat;
174
+ const cut = flat.slice(0, max);
175
+ const at = cut.lastIndexOf(" ");
176
+ return `${(at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s\-—·,;:]+$/, "")}…`;
177
+ }
178
+ /**
179
+ * Items nothing else waits on.
180
+ *
181
+ * "BLOCKS NOTHING" IS NOT "COSTS NOTHING TO DEFER" — and an item that blocks
182
+ * nothing also ANNOUNCES nothing when it stalls. Everything else surfaces through
183
+ * the thing waiting on it; these have no such witness, so they go missing silently
184
+ * and their absence is found by someone re-reading the plan. That is why they are
185
+ * a SEPARATE AXIS here and not folded into the skipped-because-blocked list: those
186
+ * are two different states with two different remedies, and one list covering both
187
+ * leaves the reader to infer which.
188
+ *
189
+ * LIMIT, stated because it bounds the claim: dependency is detected from the TEXT.
190
+ * An unstated dependency is invisible to this, so "no downstream" means "nothing in
191
+ * the queue SAYS it waits on this", never "nothing waits on this".
192
+ */
193
+ export function noDownstream(items) {
194
+ // ⚠ THE SECOND `!i.done` IN THIS FILE, AND IT IS DELIBERATE — the reconciliation
195
+ // asked for by ⟨q-7d2e04b8⟩. `nextUnblockedTool` passes an ALREADY-FILTERED
196
+ // list, so this re-filter is a no-op on that path; it is kept because this
197
+ // function is EXPORTED and its contract is "given items, which have nothing
198
+ // waiting on them", which must hold for a caller that hands it raw items.
199
+ //
200
+ // The two are not the same predicate and must not be merged: this one is
201
+ // `!done`, while the router's also subtracts the delivery join. Collapsing
202
+ // them would make an exported helper inherit a routing policy.
203
+ const open = items.filter((i) => !i.done);
204
+ const named = new Set();
205
+ for (const i of open) {
206
+ const b = blockedBy(i);
207
+ if (b)
208
+ named.add(b.toLowerCase());
209
+ }
210
+ return open.filter((i) => {
211
+ if (blockedBy(i))
212
+ return false; // it waits on something: not this axis
213
+ const key = keyOf(i).toLowerCase();
214
+ for (const n of named)
215
+ if (n.length > 3 && key.includes(n))
216
+ return false;
217
+ return true;
218
+ });
219
+ }
220
+ /* ────────────────────────────────────────────────────────────────────────────
221
+ * DELIVERED — READ THE RECORD'S STRUCTURE, NOT A SUBSTRING OF THE FILE.
222
+ *
223
+ * This was `doneText.includes(id) || boardText.includes(id)` over two whole
224
+ * documents, so ANY MENTION ANYWHERE counted as "handed out" — including a row
225
+ * written specifically to warn that an item must NOT be built (q-2ba7f0c5).
226
+ * The inversion is the finding: the more carefully a coordinator documents why
227
+ * something must not be routed, the more completely it disappears from the verb
228
+ * that routes.
229
+ *
230
+ * MEASURED ON `origin/main` @17b72ff, and the numbers moved the fix twice:
231
+ *
232
+ * q-11257590 hidden by the AIDE'S OWN `🚧 In Progress` ROW, which merely
233
+ * MENTIONS it in its Last note. A status-only rule leaves it
234
+ * hidden — the occurrence-vs-position defect one level in — so
235
+ * identification has to be the row's SUBJECT, not any of its cells.
236
+ * q-ad0fc8d4 hidden by its own `⏸ Returned to the aide unbuilt` row.
237
+ * q-507e80c4 hidden with NO PARSED ROW AT ALL: the id sits in board prose
238
+ * outside the table, which a whole-file substring test cannot
239
+ * distinguish from a lane.
240
+ * q-507e80c4 ALSO hidden by a DONE entry that CITES it as the still-open
241
+ * part (b) of work whose part (a) landed — the canon case
242
+ * q-912a67e8 requires, LIVE today rather than latent.
243
+ *
244
+ * Neither half is fixed by relabelling a row: that is satisfying a guard by
245
+ * editing what it reads (q-507e80c4 constraint 1) and it would destroy the
246
+ * warning the row exists to give.
247
+ */
248
+ /** The ids a board row names as ITS OWN SUBJECT — the Stream cell only. */
249
+ function rowSubjectIds(row) {
250
+ return [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
251
+ }
252
+ /**
253
+ * Does the board show this item as IN SOMEBODY'S HANDS?
254
+ *
255
+ * Two conditions, and both are needed — measured, not reasoned:
256
+ * STATUS the row is in flight (`isInFlightStatus`, shared with `stall_check`
257
+ * since #219 rather than a second glyph list). `⏸ Parked`,
258
+ * `⏸ Returned`, `⏳ Queued`, `⛔ Blocked`, `🚫 Unstaffable`,
259
+ * `🔻 Orphaned` describe work nobody holds, so they do not silence.
260
+ * SUBJECT the row's Stream cell names the item — by recorded id, or by the
261
+ * text prefix `claim` has always written there. A mention in a Last
262
+ * note is one lane REFERRING to another item — the aide's live row
263
+ * does exactly this — and a status-only rule would still hide it.
264
+ *
265
+ * THE PREFIX ARM IS FOR THE ROWS ALREADY ON THE BOARD, and without it this fix
266
+ * would have shipped a transitional hole in its own negative control: every row
267
+ * `claim` wrote before this change carries `keyOf(item)` — a 60-character text
268
+ * prefix — and no id at all, so an id-only rule leaves a lane's OWN live item
269
+ * routable. Measured: q-2ba7f0c5's row, written minutes earlier by the
270
+ * pre-merge server, was offered back while it was in hand. Rows written from
271
+ * here carry the id (see `claim`), so the prefix arm is the compatibility half
272
+ * rather than the mechanism, and it is deliberately still SUBJECT-only.
273
+ */
274
+ function boardHoldsItem(rows, id, text) {
275
+ const key = keyOf({ text }).replace(/\s+/g, " ").trim();
276
+ return rows.some((r) => {
277
+ if (!isInFlightStatus(r.status))
278
+ return false;
279
+ if (rowSubjectIds(r).includes(id))
280
+ return true;
281
+ const stream = String(r.stream).replace(/\s+/g, " ").trim();
282
+ return key.length >= 20 && stream.startsWith(key.slice(0, Math.min(key.length, 60)));
283
+ });
284
+ }
285
+ /**
286
+ * Is this DONE entry the RECORD OF THIS ITEM CLOSING, rather than an entry that
287
+ * merely MENTIONS it?
288
+ *
289
+ * ⛔ THE SIGIL ARM WAS REMOVED ⟨q-4a1e70c5⟩. It answered yes whenever `⟨id⟩`
290
+ * appeared ANYWHERE in an entry, on a census that had inverted: today
291
+ * `docs/DONE.md` carries more sigil mentions than bare ones, because `⟨id⟩` is the
292
+ * house spelling everything else teaches — so the COMPLIANT way to cite an item
293
+ * became the spelling that meant "delivered". Measured before removal, on 143
294
+ * open rows: ELEVEN false positives, ZERO independent true positives (every row
295
+ * it correctly excluded was already excluded by `!i.done`, which runs first), and
296
+ * a MISS on its own founding case — the row recorded as q-b4e7c209 had its work
297
+ * merged under a different row's citation, stayed `[ ]`, and was offered as top
298
+ * P1 twice.
299
+ *
300
+ * ⚠ THE SPELLING RULE DID NOT FAIL BECAUSE THE SPELLING FLIPPED. IT FAILED
301
+ * BECAUSE SPELLING WAS NEVER THE SIGNAL. Entries name item ids for several
302
+ * reasons this repo's own canon REQUIRES — a residual gap must cite its queue
303
+ * item — so no reading of that file separates a closure from a mention. Three
304
+ * replacements were measured and each was worse or equal: position does not
305
+ * discriminate (a known-false and a known-true entry are structurally identical,
306
+ * and only 2 of 252 entries lead with a sigil); `DoneEntry.id` is a derived `d-`
307
+ * id, not the item's; and the citation-slot tie `queue-done-loop` uses gives 23
308
+ * false positives, because open rows cite refs as EVIDENCE.
309
+ *
310
+ * ✅ WHAT SURVIVES IS THE COMPOSED-SUMMARY ARM, and it is a DIFFERENT EVIDENCE
311
+ * CLASS — the same distinction that keeps `boardHoldsItem`. It does not infer
312
+ * delivery from prose: it requires the entry to carry the item's own
313
+ * deterministically composed text, `summarize(item.text)`, which a VERB writes.
314
+ * A citation cannot accidentally satisfy it.
315
+ *
316
+ * ⚠ IT IS CURRENTLY UNEXERCISED BY THIS REPO'S CORPUS — 0 of 143 open, 0 of 9
317
+ * closed — AND THAT IS NOT EVIDENCE THAT IT IS DEAD. The cause is practice, not
318
+ * code: rows here are closed with `land --write` and the composed line is then
319
+ * OVERWRITTEN with hand-written prose, so the structural record it would match is
320
+ * destroyed within the minute. **Do not re-derive "dead code" from another zero
321
+ * count.** Its input returns the moment a closing entry keeps what `land` wrote.
322
+ *
323
+ * ⛔ AND WHAT IS NO LONGER GUARDED, because a removal that does not name its own
324
+ * cost is worse than the guard: A ROW WHOSE WORK MERGED UNDER ANOTHER ROW'S
325
+ * CITATION IS ELIGIBLE AGAIN, AND NOTHING IN `next_unblocked` WILL NOTICE.
326
+ * Accepted knowingly — the arm that claimed to cover it did not, on the one live
327
+ * instance it had. Closing such a row is coordinator discipline, not a predicate.
328
+ */
329
+ function doneRecordsDelivery(entries, id, text) {
330
+ const norm = (v) => v.replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
331
+ const target = norm(summarize(text)).replace(/…$/, "");
332
+ return entries.some((e) => {
333
+ // A leading recorded-id token is stripped before comparing, so an entry
334
+ // written as `⟨id⟩ <summary>` and one written as `<summary>` agree.
335
+ const body = norm(String(e.text)).replace(/^⟨q-[0-9a-f]{8}⟩\s*/, "");
336
+ return target.length >= 12 && body.startsWith(target);
337
+ });
338
+ }
339
+ // ---------- next_unblocked ----------
340
+ export const nextUnblockedSchema = { project: z.string().min(1), repo: z.string().optional() };
341
+ export async function nextUnblockedTool(args) {
342
+ // A HALT IS A NAMED STATE AND IT BLOCKS THE LANE, not a suggestion. Handing out
343
+ // the next item during a board cutover or a cited BLOCKER is how work lands on
344
+ // a base nobody meant to be building on.
345
+ const halt = haltState();
346
+ if (halt.halted) {
347
+ return {
348
+ ok: false,
349
+ 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.`,
350
+ halted: true,
351
+ };
352
+ }
353
+ const repo = args.repo ?? process.cwd();
354
+ const q = readDoc(repo, QUEUE_DOC);
355
+ if (!q)
356
+ return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'` };
357
+ const items = queueItemsOf(q.doc);
358
+ // The seam's own count of unticked rows — the number every axis must add back
359
+ // up to. Taken BEFORE any exclusion so it cannot inherit one.
360
+ const parsedOpen = items.filter((i) => !i.done).length;
361
+ // ALREADY-DELIVERED ITEMS ARE NEVER RE-OFFERED, and `!i.done` alone does not
362
+ // establish that.
363
+ //
364
+ // MEASURED, 2026-09-01: an item whose work had merged in #196 was still `[ ]`
365
+ // in the queue — closing it is a separate act from landing it — so it stayed
366
+ // eligible. A compaction then re-issued its id, it surfaced as the top item,
367
+ // and it was routed back to the worker that had closed it an hour earlier.
368
+ // That worker declined because it recognised its own acceptance criteria:
369
+ // RETAINED CONTEXT, which is not a control and which a `/clear` or a
370
+ // compaction removes silently.
371
+ //
372
+ // So delivery is read from the RECORD instead: an item cited in docs/DONE.md,
373
+ // or already carrying a 🚧 row on the board, has been handed out. Stable ids
374
+ // (Task 21.1) are what make this join reliable — with a content-hash id the
375
+ // board row and the DONE entry stopped matching the moment anyone reworded
376
+ // the item, which is how the memory was lost in the first place.
377
+ // ⛔⛆ `delivered` IS A REASON, NOT AN ERASURE (⟨q-7d2e04b8⟩). This set used to
378
+ // be subtracted from `open` BEFORE any axis ran, so an item it excluded could
379
+ // appear on NO axis by construction — the one guarantee this verb's contract
380
+ // makes is that it never skips silently, and this was the one path that did.
381
+ //
382
+ // MEASURED on the live queue at `a032069`: the file held 143 open rows, the
383
+ // seam parsed all 143, and this verb reported `open: 129`. Its declared axes
384
+ // accounted for ONE. Fourteen rows were outside the router's universe and
385
+ // nothing in the response said so — invisible from the only seat that would
386
+ // notice, because a worker asking for the next item still gets a real one.
387
+ //
388
+ // ⚠ THE REASON IS NOW CARRIED, so exclusion and explanation cannot drift apart:
389
+ // a row is excluded BY a named cause, and the cause is what gets reported.
390
+ const deliveredBy = new Map();
391
+ const doneDoc = readDoc(repo, DONE_DOC);
392
+ const boardDoc = readDoc(repo, BOARD_DOC);
393
+ const boardText = boardDoc?.text ?? "";
394
+ const doneEntries = doneDoc ? doneEntriesOf(doneDoc.doc) : [];
395
+ const boardRows = boardDoc ? workstreamsV1RowsOf(boardDoc.doc) : [];
396
+ for (const i of items) {
397
+ if (i.done)
398
+ continue; // already off `open` by the checkbox; not an exclusion this axis owns
399
+ // Board first, and the order is load-bearing for the REPORT rather than the
400
+ // routing: both causes exclude, but a live 🚧 row is a different remedy
401
+ // (wait, or ask its owner) from a landed delivery (close the row).
402
+ if (boardHoldsItem(boardRows, i.id, String(i.text)))
403
+ deliveredBy.set(i.id, "board");
404
+ else if (doneRecordsDelivery(doneEntries, i.id, String(i.text)))
405
+ deliveredBy.set(i.id, "done");
406
+ }
407
+ const open = items.filter((i) => !i.done && !deliveredBy.has(i.id));
408
+ // THE AXIS THE SUBTRACTION USED TO SKIP. Every row absent from `open` for this
409
+ // reason is named here, INCLUDING correctly-delivered ones: a correct exclusion
410
+ // reported silently is the same defect as an incorrect one.
411
+ const delivered = items
412
+ .filter((i) => !i.done && deliveredBy.has(i.id))
413
+ .map((i) => ({ item: keyOf(i), id: i.id, reason: deliveredBy.get(i.id) }));
414
+ // ⛔⛆ A DUPLICATED ID IS A SILENT DOUBLE-EXCLUSION, AND IT IS THIS ROW'S OWN
415
+ // DEFECT ONE LEVEL DOWN. Queue ids are STABLE, derived from the row's text, so
416
+ // two rows with identical text carry the SAME id — verified: `- [ ] (P1) an
417
+ // identical row` twice yields `q-c9f3ded8` twice.
418
+ //
419
+ // Everything downstream is keyed by id, so ONE delivery record then excludes
420
+ // BOTH rows: measured on a fixture, a single DONE entry took `parsedOpen: 3`
421
+ // to `offered: 1`. The accounting still reconciles — both are named — so the
422
+ // self-check above CANNOT catch it, which is exactly why it needs its own axis
423
+ // rather than a flag on `reconciles`.
424
+ //
425
+ // ⭐ WHY THIS AXIS AND NOT A COUNT: a MISSING row can always be argued to be a
426
+ // filter working as designed; a row reported TWICE cannot be anything but the
427
+ // accounting. It is the one symptom here that does not rest on a count.
428
+ const idCounts = new Map();
429
+ for (const i of items)
430
+ if (!i.done)
431
+ idCounts.set(i.id, (idCounts.get(i.id) ?? 0) + 1);
432
+ const duplicateIds = [...idCounts.entries()]
433
+ .filter(([, n]) => n > 1)
434
+ .map(([id, rows]) => ({
435
+ id,
436
+ rows,
437
+ why: `${rows} open rows share the id ${id} — stable ids are derived from row TEXT, so identical rows collide. Every axis here is keyed by id, so one delivery record excludes all ${rows}. Reword one row to separate them.`,
438
+ }));
439
+ const ranked = open
440
+ .map((i, idx) => ({ i, idx }))
441
+ .sort((a, b) => (PRIORITY_ORDER[a.i.priority ?? "P3"] ?? 3) - (PRIORITY_ORDER[b.i.priority ?? "P3"] ?? 3) || a.idx - b.idx);
442
+ const skipped = [];
443
+ // NOT WORKER-CLAIMABLE (Task 15.4). Measured: 13-14 of the open queue is
444
+ // `[SWEEP:canon]`/`[SWEEP:canon.N]` — canon prose the aide and coordinator
445
+ // author directly into the playbook, never assigned as code work. Handing
446
+ // one out here is the exact defect: `next_unblocked` offered one outside
447
+ // the caller's lane while the rest of what remained was this same family.
448
+ // Skipped VISIBLY, same discipline as a blocked top item — never silently
449
+ // dropped, so a caller can tell "nothing left for me" from "nothing left".
450
+ const notClaimable = [];
451
+ // AWAITING A HUMAN (q-e334937d). "Unblocked" means dependency-free, and an
452
+ // item awaiting a ruling is dependency-free and unbuildable at once. Measured
453
+ // 2026-09-02: this verb offered q-11257590 as next; the lane read the
454
+ // acceptance PROSE to learn it awaited David, declined, and picked by hand —
455
+ // a read every lane offered it would pay again. The fact is now a FIELD on
456
+ // the item (`**[AWAITS:<who>]**`, read by the seam), never inferred from
457
+ // prose. Its own axis, checked FIRST: it is the most specific thing that can
458
+ // be true of an item, and "three items wait on a human" is a fact David can
459
+ // act on where "nothing to route" is not. Skipped for routing, never dropped.
460
+ //
461
+ // GATHERED OVER EVERY OPEN ITEM, NOT OVER `open` — measured on the real queue
462
+ // the first time this ran: q-11257590 carried the marker and appeared in NO
463
+ // bucket, because the board holds a "⏸ Parked — awaits David" row naming it
464
+ // and the delivered-join above reads any board mention as "handed out". The
465
+ // row written to WARN about the item was what hid it. Routing still honours
466
+ // the join; this report does not, because its reader is the human the item
467
+ // waits on, and a pending decision does not stop being pending when someone
468
+ // writes it on the board.
469
+ const awaitingDecision = [];
470
+ for (const i of items) {
471
+ if (i.done)
472
+ continue;
473
+ const who = awaitingOf(i);
474
+ if (who)
475
+ awaitingDecision.push({ item: keyOf(i), id: i.id, who, onBoard: boardText.includes(i.id) });
476
+ }
477
+ let pick = null;
478
+ for (const { i } of ranked) {
479
+ if (awaitingOf(i))
480
+ continue; // reported above; never routed
481
+ const tag = sweepTagOf(i);
482
+ if (tag && /^canon(\.\d+)?$/.test(tag)) {
483
+ notClaimable.push({ item: keyOf(i), sweepTag: tag });
484
+ continue;
485
+ }
486
+ const b = blockedBy(i);
487
+ // NEVER STALL THE LANE waiting on a reorder: a blocked top item is skipped,
488
+ // visibly, and the next unblocked one is taken.
489
+ if (b) {
490
+ skipped.push({ item: keyOf(i), blockedBy: b });
491
+ continue;
492
+ }
493
+ pick = i;
494
+ break;
495
+ }
496
+ const silent = noDownstream(open);
497
+ // AN AXIS THAT FIRES ON EVERYTHING IS NOISE. If no item in the queue declares a
498
+ // dependency, then "nothing waits on this" is true of every item and the axis
499
+ // cannot discriminate — listing all of them trains the reader to skim, which is
500
+ // the severity finding one file over. Say the axis is uninformative instead.
501
+ const undiscriminating = silent.length === open.length && open.length > 1;
502
+ return {
503
+ ok: true,
504
+ project: args.project,
505
+ open: open.length,
506
+ // ⛔ THE SELF-CHECK, because the defect this replaces was a NUMBER NOTHING
507
+ // EXPLAINED and the only thing that ever caught it was someone counting the
508
+ // file by hand. `parsedOpen` is every unticked row the seam sees; `offered`
509
+ // is what routing considered; `excluded` is what the axes account for. When
510
+ // `reconciles` is false, rows have left the universe with no named cause —
511
+ // the exact condition that was previously unobservable from the response.
512
+ //
513
+ // ⚠ IT REPORTS RATHER THAN THROWS: a router that refuses to hand out work
514
+ // because its own bookkeeping is off strands every lane, which is worse than
515
+ // the miscount. The caller gets a real item AND the discrepancy.
516
+ accounting: {
517
+ parsedOpen,
518
+ offered: open.length,
519
+ excluded: delivered.length,
520
+ reconciles: parsedOpen === open.length + delivered.length,
521
+ },
522
+ // A NAMED AXIS, not a subtraction. See the comment at `deliveredBy`.
523
+ delivered,
524
+ // A ROW REPORTED TWICE CANNOT BE A FILTER WORKING AS DESIGNED. See above.
525
+ duplicateIds,
526
+ next: pick ? { id: pick.id, priority: pick.priority, text: pick.text } : null,
527
+ skipped,
528
+ // A SEPARATE AXIS from `skipped` (blocked) — this is "not this caller's
529
+ // to take" rather than "blocked on something else". Collapsing the two
530
+ // would read a claimability gap as a dependency, which is a different
531
+ // remedy (route it, don't wait for it).
532
+ notClaimable,
533
+ // A THIRD AXIS, and not a variant of either above: `skipped` waits on
534
+ // another ITEM, `notClaimable` is not this caller's to take, and this waits
535
+ // on a HUMAN. The remedy differs each time (wait · route · ask), so one
536
+ // list covering them would leave the reader to infer which applies.
537
+ awaitingDecision,
538
+ boardHunks: [
539
+ ...skipped.map((s) => `⏭ skipped — blocked by ${s.blockedBy}`),
540
+ ...notClaimable.map((s) => `⏭ skipped — not worker-claimable (${s.sweepTag})`),
541
+ ...awaitingDecision.map((a) => `⏸ skipped — awaiting decision from ${a.who} (${a.id})`),
542
+ ...duplicateIds.map((d) => `⚠ ${d.rows} open rows share the id ${d.id} — one record excludes all of them`),
543
+ ...delivered.map((d) => d.reason === "board"
544
+ ? `⏭ not offered — already on the board (${d.id})`
545
+ : `⏭ not offered — delivery recorded in DONE.md (${d.id})`),
546
+ ],
547
+ // A SEPARATE AXIS, deliberately. See noDownstream().
548
+ noDownstream: undiscriminating
549
+ ? {
550
+ count: silent.length,
551
+ items: [],
552
+ why: `NO ITEM IN THIS QUEUE DECLARES A DEPENDENCY, so "nothing waits on this" is true of all ${open.length} and this axis ` +
553
+ "cannot discriminate. Reporting every item would train you to skim it. Every item's absence here is equally silent, " +
554
+ "which is a fact about the QUEUE rather than about any item — declare dependencies (`blocked by <id>`) and this becomes useful.",
555
+ }
556
+ : {
557
+ count: silent.length,
558
+ items: silent.slice(0, 10).map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
559
+ why: "nothing in the queue says it waits on these, so their absence is SILENT — they need an explicit check at each stage boundary. " +
560
+ "Detected from item text: an unstated dependency is invisible here.",
561
+ },
562
+ };
563
+ }
564
+ // ---------- claim ----------
565
+ /**
566
+ * Insert or replace ONE row in the workstreams table, as TEXT.
567
+ *
568
+ * 2.4b: "a verb only fails closed if it is the ONLY path — nothing currently
569
+ * stops a coordinator editing the board directly instead of calling `claim`."
570
+ * `claim` used to RETURN a `boardHunk` for someone to paste by hand, which is
571
+ * the same discipline with a nicer API. Every legacy row on the board today is
572
+ * hand-written, and that is why `stall_check`'s vcs half has nothing to
573
+ * resolve: a pasted row carries a path, a `claim`-written row carries a real
574
+ * branch ref.
575
+ *
576
+ * Text insertion rather than a re-render: the rest of the file stays
577
+ * byte-identical, the same reason `land` appends its DONE line as text. A board
578
+ * this verb rewrote wholesale would be a diff nobody could review.
579
+ */
580
+ export function upsertBoardRow(text, agentId, row) {
581
+ const lines = text.split("\n");
582
+ const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
583
+ if (header === -1)
584
+ return { text, action: "unchanged" };
585
+ // The table ends at the first line that is not a row.
586
+ let end = header + 1;
587
+ while (end < lines.length && /^\s*\|/.test(lines[end]))
588
+ end++;
589
+ // OWNER MATCHED ON THE CELL, NOT ON THE WHOLE LINE. An agent id appearing in
590
+ // a "Last note" cell is not that agent's row — the occurrence-vs-position
591
+ // defect this repo has re-derived at four granularities.
592
+ const ownerOf = (l) => (l.split("|")[2] ?? "").replace(/[`*\s]/g, "");
593
+ const existing = lines.findIndex((l, i) => i > header + 1 && i < end && ownerOf(l) === agentId);
594
+ if (existing !== -1) {
595
+ if (lines[existing] === row)
596
+ return { text, action: "unchanged" };
597
+ lines[existing] = row;
598
+ return { text: lines.join("\n"), action: "replaced" };
599
+ }
600
+ lines.splice(end, 0, row);
601
+ return { text: lines.join("\n"), action: "inserted" };
602
+ }
603
+ export const claimSchema = {
604
+ project: z.string().min(1),
605
+ agentId: z.string().min(1),
606
+ itemId: z.string().optional(),
607
+ repo: z.string().optional(),
608
+ base: z.string().optional(),
609
+ task: z.string().optional(),
610
+ write: z.boolean().optional(),
611
+ };
612
+ export async function claimTool(args) {
613
+ const halt = haltState();
614
+ if (halt.halted) {
615
+ return {
616
+ ok: false,
617
+ error: `HALTED by ${halt.by}: ${halt.reason}. Claiming is refused while a halt is set.`,
618
+ halted: true,
619
+ };
620
+ }
621
+ const repo = args.repo ?? process.cwd();
622
+ const q = readDoc(repo, QUEUE_DOC);
623
+ if (!q)
624
+ return { ok: false, error: `no ${QUEUE_DOC} under '${repo}'` };
625
+ const items = queueItemsOf(q.doc);
626
+ let item = args.itemId ? items.find((i) => i.id === args.itemId) : null;
627
+ if (args.itemId && !item)
628
+ return { ok: false, error: `no queue item with id '${args.itemId}'` };
629
+ if (!item) {
630
+ const next = await nextUnblockedTool({ project: args.project, repo });
631
+ if (!next.ok || !next.next)
632
+ return { ok: false, error: "no unblocked item to claim" };
633
+ item = items.find((i) => i.id === next.next.id) ?? null;
634
+ }
635
+ if (!item)
636
+ return { ok: false, error: "no unblocked item to claim" };
637
+ // TASK 1 LANDED, SO THE PLACEHOLDER IS GONE RATHER THAN LEFT SWITCHED OFF.
638
+ //
639
+ // While `ensure_worktree` did not exist this returned a loud warning that the
640
+ // worktree was NOT ensured — conditional on the verb's absence, because a
641
+ // warning that never clears stops being read. The verb exists now, so `claim`
642
+ // CALLS it: the interface is unchanged and the gap is closed rather than
643
+ // annotated. An interface with a placeholder nobody removes is how a temporary
644
+ // state becomes canon.
645
+ //
646
+ // A worktree that cannot be ensured is a REFUSAL, not a warning. Binding an
647
+ // item to an agent with nowhere isolated to work is the shared-checkout failure
648
+ // this pair exists to prevent.
649
+ const wt = await ensureWorktreeTool({
650
+ agentId: args.agentId,
651
+ repo,
652
+ base: args.base ?? "main",
653
+ task: args.task,
654
+ });
655
+ if (!wt.ok) {
656
+ return {
657
+ ok: false,
658
+ error: `cannot claim: no isolated worktree — ${wt.error}`,
659
+ item: { id: item.id, priority: item.priority },
660
+ };
661
+ }
662
+ // A NEW TASK DOES NOT START FROM A STALE TREE.
663
+ //
664
+ // `ensure_worktree` is idempotent and reuses the agent's existing tree, which
665
+ // is right mid-slice (1.3) and wrong here: `claim` MEANS "start something
666
+ // new", and a tree left on last week's base produces the confidently-wrong
667
+ // inventories the worker card warns about, with nothing about the result
668
+ // looking stale.
669
+ //
670
+ // A freshly CREATED tree is cut from origin/<base> and needs no check — this
671
+ // only ever fires on reuse.
672
+ if (!wt.created && wt.atBase === false) {
673
+ return {
674
+ ok: false,
675
+ error: `cannot claim: the worktree at ${wt.path} is NOT at ${wt.base}` +
676
+ `${wt.behindBy ? ` (${wt.behindBy} commit(s) behind)` : ""} — it is on '${wt.branch}' at ${String(wt.sha).slice(0, 8)}. ` +
677
+ `A new task started here would be based on stale content, and nothing about the result would look stale. ` +
678
+ `Run \`refresh_worktrees\` to fast-forward idle trees, or finish and land the work already in it.`,
679
+ item: { id: item.id, priority: item.priority },
680
+ worktree: { path: wt.path, branch: wt.branch, sha: wt.sha, base: wt.base, atBase: false },
681
+ };
682
+ }
683
+ // THE CELL NAMES THE REMOTE-TRACKING REF, not the bare local branch.
684
+ //
685
+ // A bare name resolves in the checkout that created it and nowhere else, so
686
+ // the same board read differently from two checkouts and whether a row was
687
+ // measurable depended on who last ran `git fetch` — a property of the reader
688
+ // rather than of the work. Writing `origin/<branch>` makes the cell mean the
689
+ // same thing everywhere.
690
+ //
691
+ // It does not resolve YET, because a freshly cut branch is unpushed. That is
692
+ // correct and `stall_check` says so in those words: an unpushed lane has no
693
+ // shared evidence of activity, which is an absence of evidence rather than a
694
+ // stall.
695
+ const intendedRef = boardRefFor(wt.branch ?? "");
696
+ /*
697
+ * AND NOW ACTUALLY ENFORCED AT THE WRITE.
698
+ *
699
+ * The comment here used to claim exactly that — "enforced at the WRITE as well
700
+ * as the read, a rule the writer can defeat is a rule the writer defeats" —
701
+ * and it was FALSE. `boardRefFor` PREFIXES `origin/`; it refuses nothing. So
702
+ * `claim` could still write `origin/main` into a board cell, which
703
+ * `stall_check` then correctly refuses to measure, leaving a row that can
704
+ * never stall and never be measured. board-ref.ts's own header states the
705
+ * rule; #197 shipped the read half and I wrote the sentence and did not do it.
706
+ *
707
+ * A rule carrying a false mechanism is worse than an absent rule: the next
708
+ * reader derives from the mechanism, and this one asserted the very coverage
709
+ * it lacked.
710
+ *
711
+ * REFUSED: the four kinds that are structurally wrong however fresh the lane
712
+ * is — a shared ref (measures the fleet), a path (not a ref at all), another
713
+ * agent's branch (measures their work), and an empty cell.
714
+ *
715
+ * ALLOWED: `unpushed` and `local-only`, deliberately. A freshly cut branch is
716
+ * ALWAYS unpushed, so refusing those would refuse every legitimate claim —
717
+ * the over-narrowing I have now made twice in this classifier's history, where
718
+ * a rule meant to make a check honest disabled it instead. `merged` is allowed
719
+ * too but noted, since re-claiming onto a landed branch is unusual rather than
720
+ * structurally broken.
721
+ */
722
+ const refusable = new Set(["shared", "path", "unscoped", "empty"]);
723
+ const cellVerdict = classifyBoardRef(repo, args.agentId, `\`${intendedRef}\``, `origin/${args.base ?? "main"}`);
724
+ if (refusable.has(cellVerdict.kind)) {
725
+ return {
726
+ ok: false,
727
+ error: `cannot claim: the board cell would name '${intendedRef}', which is not a per-agent activity signal ` +
728
+ `(${cellVerdict.kind}) — ${"why" in cellVerdict ? cellVerdict.why : ""} ` +
729
+ `The row would be unmeasurable the moment it was written, so it is refused here rather than reported later.`,
730
+ item: { id: item.id, priority: item.priority },
731
+ worktree: { path: wt.path, branch: wt.branch },
732
+ };
733
+ }
734
+ // THE ROW NAMES THE ITEM BY ID, so the delivered-join can recognise a row this
735
+ // verb itself wrote. Measured on the live board: `keyOf` is the item's text
736
+ // prefix and the recorded `⟨id⟩` token is not part of `text`, so NO
737
+ // claim-written row has ever carried an id — the join could only ever match
738
+ // HAND-WRITTEN rows, which is the opposite of what it was for. Stamping it
739
+ // makes the identification recorded rather than derived from a 60-character
740
+ // prose prefix that any rewording changes.
741
+ const boardHunk = `| ⟨${item.id}⟩ ${keyOf(item)} | ${args.agentId} | \`${intendedRef}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
742
+ // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
743
+ // write:true, matching `land`. A returned hunk that a human pastes is the
744
+ // same discipline with a nicer API, and the pasted rows are why the board
745
+ // carries paths where a claim would have carried a resolvable branch ref.
746
+ let board = { action: "reported" };
747
+ const b = readDoc(repo, BOARD_DOC);
748
+ if (!b) {
749
+ board = { action: `no ${BOARD_DOC} under '${repo}' — row NOT written` };
750
+ }
751
+ else if (args.write) {
752
+ const next = upsertBoardRow(b.text, args.agentId, boardHunk);
753
+ if (next.action !== "unchanged")
754
+ writeFileSync(path.join(repo, BOARD_DOC), next.text);
755
+ board = { action: next.action, path: BOARD_DOC };
756
+ }
757
+ return {
758
+ ok: true,
759
+ project: args.project,
760
+ agentId: args.agentId,
761
+ item: { id: item.id, priority: item.priority, text: item.text },
762
+ boardHunk,
763
+ board,
764
+ worktreeEnsured: true,
765
+ worktree: { path: wt.path, sha: wt.sha, branch: wt.branch, created: wt.created, base: wt.base },
766
+ };
767
+ }
768
+ // ---------- land ----------
769
+ export const landSchema = {
770
+ project: z.string().min(1),
771
+ pr: z.string().min(1),
772
+ queueItemId: z.string().optional(),
773
+ repo: z.string().optional(),
774
+ base: z.string().optional(),
775
+ write: z.boolean().optional(),
776
+ /** One-line result, written after the citation on the closed item's line. */
777
+ result: z.string().optional(),
778
+ };
779
+ export async function landTool(args) {
780
+ const repo = args.repo ?? process.cwd();
781
+ const base = args.base ?? "main";
782
+ const n = prNumber(args.pr);
783
+ // A CITED PR OR REFUSE. A `DONE:` without a ref cannot be tied to anything by
784
+ // anyone, ever — which is its own finding, not a formatting preference.
785
+ if (!n) {
786
+ return { ok: false, error: `'${args.pr}' names no PR number — a DONE entry with no ref can never be tied to the work. Cite owner/repo#N.` };
787
+ }
788
+ // MERGED ON THE TARGET'S TIP, NOT THE MERGE BASE.
789
+ //
790
+ // "What does the thing I am merging INTO have that I do not?" is answered only
791
+ // by the target's tip: the merge base is the point the branch DIVERGED from, so
792
+ // anything landed after the cut is missing from it too — comparing against it is
793
+ // exactly as blind as comparing against the branch (kit#102/#103). Squash merges
794
+ // leave no ancestor either, so the citation in the merge SUBJECT is the tie.
795
+ const ref = `origin/${base}`;
796
+ let landedIn = null;
797
+ let reason = null;
798
+ try {
799
+ const subjects = git(repo, ["log", "-n", "400", "--format=%H %s", ref]);
800
+ const hit = subjects.split("\n").find((l) => new RegExp(`\\(#${n}\\)|#${n}\\b`).test(l));
801
+ landedIn = hit ? hit.split(" ")[0] : null;
802
+ }
803
+ catch (e) {
804
+ reason = `could not read ${ref} (${String(e.message).split("\n")[0]}) — NOT checked, which is not the same as checked and absent`;
805
+ }
806
+ if (reason)
807
+ return { ok: false, error: reason };
808
+ if (!landedIn) {
809
+ return {
810
+ ok: false,
811
+ 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.`,
812
+ comparedAgainst: ref,
813
+ };
814
+ }
815
+ const q = readDoc(repo, QUEUE_DOC);
816
+ const d = readDoc(repo, DONE_DOC);
817
+ if (!q || !d)
818
+ return { ok: false, error: `need both ${QUEUE_DOC} and ${DONE_DOC} under '${repo}'` };
819
+ const items = queueItemsOf(q.doc);
820
+ // WHICH ITEM A PR CLOSES IS A JUDGEMENT, AND GUESSING IT CLOSED THE WRONG ONE.
821
+ //
822
+ // The first real use of this verb matched `#116` against an item that merely
823
+ // MENTIONED #116 in its body — an item about seam ids — and reported it closed.
824
+ // With write:true it would have closed unrelated, still-open work. That is the
825
+ // occurrence-vs-position defect I fixed for sweep tags, reintroduced here in a
826
+ // different matcher hours later: a citation IN an item's text is not a claim
827
+ // that the item is closed BY it.
828
+ //
829
+ // There is no positional convention to anchor on, so the honest fix is not a
830
+ // better guess: `land` CLOSES ONLY WHAT IT IS TOLD TO CLOSE. Without an explicit
831
+ // `queueItemId` it closes nothing and reports the candidates for the caller to
832
+ // pick, saying so.
833
+ const candidates = items.filter((i) => !i.done && new RegExp(`#${n}\\b`).test(String(i.text)));
834
+ const target = args.queueItemId ? items.find((i) => i.id === args.queueItemId) : null;
835
+ if (args.queueItemId && !target)
836
+ return { ok: false, error: `no queue item with id '${args.queueItemId}'` };
837
+ // CLOSE = STATUS + CITATION. The item's priority and body BYTES are untouched:
838
+ // the seam re-renders `- [x] (P2) <text>` from the same text it parsed, so
839
+ // closing cannot reword an item. That is asserted by a fixture, not by
840
+ // inspection.
841
+ //
842
+ // AND THE LINE CITES WHAT CLOSED IT (q-6d21ff84). `doctor:queue-done-loop`
843
+ // requires a `[x]` item's FIRST LINE to cite the PR that DONE.md records —
844
+ // that strictness is what makes a closure machine-checkable, and the repo
845
+ // paid for it. This verb ticked the box and wrote DONE.md and left line 1
846
+ // uncited, so EVERY successful `land` turned main red at the next root gate
847
+ // and the coordinator hand-repaired a file the verb had just written
848
+ // (`c29becd`, and again on q-e334937d). The writer and the auditor were built
849
+ // to different contracts; the fix belongs in the writer, because loosening
850
+ // the auditor to accept what the writer emits is how a check stops being one.
851
+ //
852
+ // A SUFFIX, NOT A REWRITE. The existing text is preserved byte-for-byte as a
853
+ // prefix; ` · **closed by <ref>[ — <result>]**` is appended — the exact form
854
+ // q-40449919 carries after its manual repair. Line 1 only, because line 1 is
855
+ // all `item.text` is and all the check reads; a citation on a continuation
856
+ // line satisfies nothing (the occurrence-vs-position trap, hit twice).
857
+ //
858
+ // IDEMPOTENT: an item whose line already cites this PR (in any spelling
859
+ // `refsIn` ties to it) is left byte-identical, so a second `land` cannot
860
+ // double-cite and a hand-cited item is not re-cited.
861
+ let queueChanged = false;
862
+ let citationAppended = null;
863
+ // The DONE entry and the emitted event describe the WORK, from the text the
864
+ // item had BEFORE the citation was appended — otherwise the summary would
865
+ // carry the citation the DONE line already ends with, twice on one line.
866
+ const originalText = target ? String(target.text) : null;
867
+ if (target) {
868
+ target.done = true;
869
+ queueChanged = true;
870
+ const prRef = refsIn(args.pr)[0] ?? { raw: args.pr, repo: null, number: n };
871
+ const contextRepo = originRepoOf(repo);
872
+ const alreadyCited = refsIn(String(target.text)).some((r) => refsMatch(r, prRef, { contextRepo }));
873
+ if (!alreadyCited) {
874
+ citationAppended = ` · **closed by ${args.pr}${args.result ? ` — ${args.result.trim()}` : ""}**`;
875
+ target.text = `${target.text}${citationAppended}`;
876
+ }
877
+ }
878
+ const already = doneEntriesOf(d.doc).some((e) => new RegExp(`#${n}\\b`).test(String(e.ref ?? "")));
879
+ // A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE.
880
+ //
881
+ // First real use produced `- [x] PR #117 — #117 · 2026-08-28`: no description of
882
+ // the work, and a BARE ref where every existing entry carries `owner/repo#N`.
883
+ // The glyph contract parses it, so a shape check would pass it — and it tells a
884
+ // reader nothing, which is the whole job of the file.
885
+ //
886
+ // So: the summary comes from the item being closed, and a bare `#N` is reported
887
+ // as UNDER-QUALIFIED rather than silently written.
888
+ const bareRef = !/[\w.-]+\/[\w.-]+#\d+/.test(String(args.pr));
889
+ const summary = originalText !== null ? summarize(originalText) : null;
890
+ const doneLine = already || !summary ? null : `- [x] ${summary} — ${args.pr} · ${new Date().toISOString().slice(0, 10)}`;
891
+ // APPEND TO DONE.md — the half this verb exists for.
892
+ //
893
+ // The first `write:true` run closed the queue item and wrote NOTHING to
894
+ // DONE.md: it did half the loop, and the half it skipped is the one that failed
895
+ // three times in nine hours and left twelve merges unlogged for 23 hours. A verb
896
+ // built to close that gap that does not write DONE is the gap with a tool in
897
+ // front of it.
898
+ //
899
+ // Appended as TEXT to the last done block so the rest of the file is replayed
900
+ // byte-for-byte: `renderWorkDoc` reproduces every unmodelled line verbatim, and
901
+ // an entry added to the record model renders through the pinned glyph contract.
902
+ const wrote = [];
903
+ let stampedNotSupplied = [];
904
+ // A stale-base refusal is a RESULT, not a crash: the caller needs to know
905
+ // nothing was clobbered, what moved, and what this call had already written
906
+ // before it stopped. Thrown inside writeDoc so it cannot be ignored; converted
907
+ // here so the verb still answers.
908
+ try {
909
+ if (args.write) {
910
+ if (queueChanged) {
911
+ const w = writeDoc(repo, QUEUE_DOC, q.doc, q.text, wrote);
912
+ if (w.written)
913
+ wrote.push(QUEUE_DOC);
914
+ // Rows stamped that this call did not ask to touch. `queueItemId` is the
915
+ // one item the caller supplied, so anything else here is a row that
916
+ // arrived unstamped from somewhere: the absorption this verb would
917
+ // otherwise carry to main under its own name.
918
+ stampedNotSupplied = w.stamped.filter((id) => id !== args.queueItemId);
919
+ }
920
+ if (doneLine) {
921
+ const blocks = d.doc.blocks;
922
+ let last = -1;
923
+ for (let i = 0; i < blocks.length; i++)
924
+ if (blocks[i]?.kind === "done")
925
+ last = i;
926
+ if (last === -1) {
927
+ return {
928
+ ok: false,
929
+ 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.`,
930
+ };
931
+ }
932
+ const block = blocks[last];
933
+ const parsed = parseWorkDoc(`## Done\n${doneLine}\n`);
934
+ const entry = doneEntriesOf(parsed)[0];
935
+ if (!entry) {
936
+ return { ok: false, error: `the composed DONE line does not parse as done.v1: ${doneLine}` };
937
+ }
938
+ block.entries.push(entry);
939
+ if (writeDoc(repo, DONE_DOC, d.doc, d.text, wrote).written)
940
+ wrote.push(DONE_DOC);
941
+ }
942
+ }
943
+ }
944
+ catch (e) {
945
+ if (e instanceof StaleWriteError) {
946
+ return {
947
+ ok: false,
948
+ error: e.message,
949
+ staleWrite: { doc: e.rel, drift: e.detail, alreadyWritten: e.alreadyWritten },
950
+ };
951
+ }
952
+ throw e;
953
+ }
954
+ // 6.2 — EMIT ONLY AS A CONSEQUENCE OF THE RECORD CHANGING.
955
+ //
956
+ // Read back from disk, AFTER the write, and refuse to emit anything whose ref
957
+ // is not there. The ordering is the guarantee: an event cannot exist without
958
+ // the record entry that caused it, because the record is what is consulted to
959
+ // decide whether to emit. An event stream that can say "task X complete"
960
+ // while DONE.md does not is a second source of truth, and record-vs-state
961
+ // divergence is the defect this fleet hit most this week.
962
+ //
963
+ // Reported, never thrown: a delivery failure must not undo a merge that has
964
+ // already happened. `land` is a RECORDER.
965
+ let events = { emitted: [], deliveries: [], refused: [] };
966
+ if (args.write && target) {
967
+ const after = readDoc(repo, DONE_DOC);
968
+ const recordText = after?.text ?? "";
969
+ const ev = { kind: "item", target: target.id, ref: args.pr, summary: summarize(originalText ?? "") };
970
+ const derived = eventIsDerived(recordText, ev);
971
+ if (!derived.ok) {
972
+ events.refused.push(derived.error);
973
+ }
974
+ else {
975
+ const now = Date.now();
976
+ const { subs, deliveries } = evaluate(readSubs(), ev, now);
977
+ commitEvaluation(subs);
978
+ events = { emitted: [ev], deliveries, refused: [] };
979
+ }
980
+ }
981
+ return {
982
+ ok: true,
983
+ project: args.project,
984
+ pr: `#${n}`,
985
+ comparedAgainst: ref,
986
+ landedIn: landedIn.slice(0, 8),
987
+ // THE ABSORBING WRITER LEARNS IT ABSORBED SOMETHING. The aide's
988
+ // diff-before-rename guard REFUSES on a foreign change; this REPORTS one it
989
+ // fixed, so a foreign row cannot pass silently in either direction.
990
+ ...(stampedNotSupplied.length
991
+ ? {
992
+ stampedNotSupplied,
993
+ stampNote: `recorded an id for ${stampedNotSupplied.length} queue item(s) this call did not supply (${stampedNotSupplied.join(", ")}) — ` +
994
+ `they arrived unstamped, most likely a raw text append, and would have reddened doctor:queue-id-recorded on whoever committed next. ` +
995
+ `Each id written is the one the item already had, so no value changed.`,
996
+ }
997
+ : {}),
998
+ queueItem: target
999
+ ? {
1000
+ id: target.id,
1001
+ closed: true,
1002
+ // `textUnchanged` keeps its meaning — the priority and the body bytes
1003
+ // the item had are all still there, in place. What is new is a SUFFIX
1004
+ // on line 1, reported separately so a reader can tell "reworded" from
1005
+ // "cited": null when the line already cited this PR.
1006
+ textUnchanged: citationAppended === null,
1007
+ bodyPreserved: true,
1008
+ citationAppended,
1009
+ }
1010
+ : null,
1011
+ events,
1012
+ candidates: target
1013
+ ? undefined
1014
+ : candidates.map((i) => ({ id: i.id, priority: i.priority, key: keyOf(i) })),
1015
+ ...(target || !candidates.length
1016
+ ? {}
1017
+ : {
1018
+ note_candidates: `${candidates.length} open item(s) MENTION #${n}; none was closed. A citation in an item's text is not a claim ` +
1019
+ `that the item is closed by it — pass queueItemId to close one deliberately.`,
1020
+ }),
1021
+ doneEntry: doneLine,
1022
+ ...(bareRef
1023
+ ? {
1024
+ refWarning: `'${args.pr}' is an UNDER-QUALIFIED ref — every entry in DONE.md carries owner/repo#N, and a bare #N does not ` +
1025
+ `identify a repository. Pass the full ref; the glyph contract would parse the bare one and it would tell a reader nothing.`,
1026
+ }
1027
+ : {}),
1028
+ ...(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" }),
1029
+ alreadyLogged: already,
1030
+ written: wrote,
1031
+ note: args.write ? undefined : "reporting only — pass write:true to apply. The markdown is authoritative; a report is not a write.",
1032
+ };
1033
+ }
1034
+ /**
1035
+ * gh reports CheckRun as status+conclusion and StatusContext as a bare state,
1036
+ * and a rollup routinely contains BOTH. Reading only one shape silently scores
1037
+ * the other as unknown — so normalise explicitly and let anything unrecognised
1038
+ * fall to `pending`, which refuses. An unreadable check is not a passing one.
1039
+ */
1040
+ export function normalizeChecks(rollup) {
1041
+ return (rollup ?? []).map((r) => {
1042
+ const c = (r ?? {});
1043
+ const name = String(c.name ?? c.context ?? "(unnamed)");
1044
+ const status = String(c.status ?? "").toUpperCase();
1045
+ const raw = String(c.conclusion ?? c.state ?? "").toUpperCase();
1046
+ if (status && status !== "COMPLETED")
1047
+ return { name, state: status, verdict: "pending" };
1048
+ if (raw === "SUCCESS")
1049
+ return { name, state: raw, verdict: "pass" };
1050
+ // NEUTRAL and SKIPPED are deliberately NOT passes. A check that declined to
1051
+ // run has not evidenced anything, and scoring it green is the unread-input
1052
+ // defect: reporting clean on something never read.
1053
+ if (raw === "FAILURE" || raw === "ERROR" || raw === "TIMED_OUT" || raw === "CANCELLED" || raw === "ACTION_REQUIRED")
1054
+ return { name, state: raw, verdict: "fail" };
1055
+ return { name, state: raw || status || "UNKNOWN", verdict: "pending" };
1056
+ });
1057
+ }
1058
+ export const mergeSchema = {
1059
+ project: z.string().min(1),
1060
+ pr: z.string().min(1),
1061
+ repo: z.string().optional(),
1062
+ method: z.enum(["squash", "merge", "rebase"]).optional(),
1063
+ write: z.boolean().optional(),
1064
+ };
1065
+ const ghFacts = (repo, n) => {
1066
+ const out = execFileSync("gh", ["pr", "view", n, "--json", "state,mergeable,statusCheckRollup"], {
1067
+ cwd: repo,
1068
+ encoding: "utf8",
1069
+ stdio: ["ignore", "pipe", "ignore"],
1070
+ });
1071
+ const j = JSON.parse(out);
1072
+ const gh = (args) => {
1073
+ try {
1074
+ return execFileSync("gh", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1075
+ }
1076
+ catch {
1077
+ return "";
1078
+ }
1079
+ };
1080
+ // The citation must survive the MERGE, so what counts is what lands in
1081
+ // history: the PR title (which becomes the squash subject) and the commit
1082
+ // subjects. The body is included because a reviewer reads it, but a claim
1083
+ // that lives only in a comment thread is not a record.
1084
+ const meta = JSON.parse(gh(["pr", "view", n, "--json", "title,body,commits"]) || "{}");
1085
+ const subjects = (meta.commits ?? [])
1086
+ .map((c) => c.messageHeadline ?? "")
1087
+ .join("\n");
1088
+ return {
1089
+ state: String(j.state ?? ""),
1090
+ mergeable: String(j.mergeable ?? ""),
1091
+ checks: j.statusCheckRollup ?? [],
1092
+ diff: gh(["pr", "diff", n]),
1093
+ citationText: [String(meta.title ?? ""), subjects, String(meta.body ?? "")].join("\n"),
1094
+ };
1095
+ };
1096
+ const ghMerge = (repo, n, method) => {
1097
+ execFileSync("gh", ["pr", "merge", n, `--${method}`, "--delete-branch"], {
1098
+ cwd: repo,
1099
+ encoding: "utf8",
1100
+ stdio: ["ignore", "pipe", "ignore"],
1101
+ });
1102
+ };
1103
+ export async function mergeTool(args, facts = ghFacts, doMerge = ghMerge) {
1104
+ const repo = args.repo ?? process.cwd();
1105
+ const n = prNumber(args.pr);
1106
+ if (!n)
1107
+ return { ok: false, error: `'${args.pr}' names no PR number. Cite owner/repo#N.` };
1108
+ let f;
1109
+ try {
1110
+ f = facts(repo, n);
1111
+ }
1112
+ catch (e) {
1113
+ // NOT CHECKED IS NOT CHECKED-AND-GREEN. If we cannot read the verdict we
1114
+ // cannot have consumed it, and this verb's whole claim is that it did.
1115
+ return {
1116
+ ok: false,
1117
+ error: `could not read the checks for #${n} (${String(e.message).split("\n")[0]}) — NOT read, which is not the same as read and passing.`,
1118
+ };
1119
+ }
1120
+ const checks = normalizeChecks(f.checks);
1121
+ const failed = checks.filter((c) => c.verdict === "fail");
1122
+ const pending = checks.filter((c) => c.verdict === "pending");
1123
+ // EVERY RETURN CARRIES THE POPULATION IT JUDGED. "All passed" over an empty
1124
+ // set is the sentence this verb exists to make unsayable.
1125
+ const verdict = { population: checks.length, checks, failed: failed.map((c) => c.name), pending: pending.map((c) => c.name) };
1126
+ if (f.state !== "OPEN")
1127
+ return { ok: false, error: `#${n} is ${f.state || "not OPEN"} — nothing to merge.`, verdict };
1128
+ // NO CHECKS IS NOT PASSING CHECKS.
1129
+ //
1130
+ // Same invariant as the stall clock: no alerts is not no stalls, and a clock
1131
+ // that stopped reads quiet exactly like a system that is fine. An empty
1132
+ // rollup is the strongest-looking green there is — zero failures — and it is
1133
+ // evidence of nothing at all.
1134
+ if (checks.length === 0)
1135
+ return { ok: false, error: `#${n} reports ZERO checks. No checks is not passing checks — an empty rollup has zero failures and evidences nothing.`, verdict };
1136
+ if (failed.length)
1137
+ return { ok: false, error: `#${n} has ${failed.length} of ${checks.length} check(s) FAILING: ${failed.map((c) => c.name).join(", ")}. Refusing to merge.`, verdict };
1138
+ if (pending.length)
1139
+ return { ok: false, 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 };
1140
+ // EVERY NEWLY-TICKED CHECKBOX MUST BE CITED, CHECKED AT THE MERGE.
1141
+ //
1142
+ // Twice in one day a PR carried two things and left one unrecorded: kit#126
1143
+ // ticked 4.4 and named it nowhere, and all five of Task 5's boxes shipped
1144
+ // inside kit#127 alongside a CI fix. The pattern is not carelessness — a PR
1145
+ // that fixes an incident AND delivers planned work gets the incident
1146
+ // remembered and the work forgotten, because the incident is what everyone
1147
+ // is talking about. Review caught neither; the coordinator found both after
1148
+ // the fact.
1149
+ //
1150
+ // So it is checked where the record is actually made. `doctor`'s
1151
+ // `phase-checkbox` finds this too, but only AFTER the merge, against DONE.md
1152
+ // and merged subjects — by which time the claim is already in history
1153
+ // unevidenced. The citation grammar is shared through seam rather than
1154
+ // copied, because a copied grammar is two grammars the moment one is fixed.
1155
+ const ticked = newlyTickedInDiff(f.diff ?? "");
1156
+ if (ticked.size) {
1157
+ /*
1158
+ * A RELATIONAL CITATION DOES NOT EVIDENCE A TICK.
1159
+ *
1160
+ * "unblocks Phase 5.2 Task 15.6" names the task without claiming it is done,
1161
+ * so a PR that ticks 15.6's box and cites it that way is exactly as
1162
+ * unevidenced as one that never mentioned it. Measured on the other side of
1163
+ * this grammar the same day: doctor read that sentence in DONE.md as a
1164
+ * completion claim and red-gated main.
1165
+ *
1166
+ * The two cases are REPORTED SEPARATELY, because they need different fixes
1167
+ * and "UNCITED" would send someone looking for a citation that is already
1168
+ * there. A relational citation needs REWORDING; an absent one needs ADDING.
1169
+ */
1170
+ const detail = phaseCitationsDetailed(f.citationText ?? "");
1171
+ const closes = new Set(detail.filter((c) => c.relation === "claim").map((c) => c.key));
1172
+ const relational = new Map(detail.filter((c) => c.relation === "reference").map((c) => [c.key, c.marker]));
1173
+ const uncited = [...ticked].filter((k) => !closes.has(k));
1174
+ if (uncited.length) {
1175
+ const nameOf = (k) => { const [p, id] = k.split(":"); return `Phase ${p} Task ${id}`; };
1176
+ const names = uncited.map(nameOf);
1177
+ const worded = uncited.filter((k) => relational.has(k));
1178
+ const missing = uncited.filter((k) => !relational.has(k));
1179
+ const parts = [];
1180
+ if (missing.length) {
1181
+ parts.push(`${missing.length} NOT CITED AT ALL: ${missing.map(nameOf).join(", ")} — name them in the PR title or a commit ` +
1182
+ `subject (e.g. "${nameOf(missing[0])}"), the subject being what survives a squash merge`);
1183
+ }
1184
+ if (worded.length) {
1185
+ parts.push(`${worded.length} cited only RELATIONALLY: ` +
1186
+ worded.map((k) => `${nameOf(k)} (after '${relational.get(k)}')`).join(", ") +
1187
+ ` — that names the task without claiming it is done, so it evidences nothing. Reword it, or drop the tick`);
1188
+ }
1189
+ return {
1190
+ ok: false,
1191
+ error: `#${n} ticks ${ticked.size} phase checkbox(es) and ${uncited.length} of them are UNEVIDENCED. ` +
1192
+ `${parts.join(". ")}. Once this merges, the tick claims progress nothing in history supports.`,
1193
+ verdict,
1194
+ uncited: names,
1195
+ };
1196
+ }
1197
+ }
1198
+ if (f.mergeable === "CONFLICTING")
1199
+ return { ok: false, error: `#${n} is CONFLICTING with its base.`, verdict };
1200
+ if (!args.write)
1201
+ return { ok: true, merged: false, verdict, note: `#${n} would merge: all ${checks.length} check(s) pass. Pass write:true to apply.` };
1202
+ doMerge(repo, n, args.method ?? "squash");
1203
+ return { ok: true, merged: true, verdict };
1204
+ }
1205
+ //# sourceMappingURL=records.js.map