agent-coord-mcp 0.26.18 → 0.26.19

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