agent-coord-mcp 0.26.16 → 0.26.18

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