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.
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.js +158 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/prefix.js +64 -0
- package/dist/prefix.js.map +1 -0
- package/dist/roles.js +132 -0
- package/dist/roles.js.map +1 -0
- package/dist/server-identity.js +82 -0
- package/dist/server-identity.js.map +1 -0
- package/dist/server.js +647 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +553 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/admin.js +323 -0
- package/dist/tools/admin.js.map +1 -0
- package/dist/tools/attention.js +73 -0
- package/dist/tools/attention.js.map +1 -0
- package/dist/tools/away.js +285 -0
- package/dist/tools/away.js.map +1 -0
- package/dist/tools/board-ref.js +208 -0
- package/dist/tools/board-ref.js.map +1 -0
- package/dist/tools/event-kinds.js +39 -0
- package/dist/tools/event-kinds.js.map +1 -0
- package/dist/tools/events.js +234 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/logwatch.js +85 -0
- package/dist/tools/logwatch.js.map +1 -0
- package/dist/tools/messaging.js +706 -0
- package/dist/tools/messaging.js.map +1 -0
- package/dist/tools/record-events.js +380 -0
- package/dist/tools/record-events.js.map +1 -0
- package/dist/tools/records.js +1027 -0
- package/dist/tools/records.js.map +1 -0
- package/dist/tools/registry.js +500 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/render.js +2 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/rooms.js +210 -0
- package/dist/tools/rooms.js.map +1 -0
- package/dist/tools/rotate.js +143 -0
- package/dist/tools/rotate.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +104 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools/stall.js +414 -0
- package/dist/tools/stall.js.map +1 -0
- package/dist/tools/transport.js +1878 -0
- package/dist/tools/transport.js.map +1 -0
- package/dist/tools/work.js +373 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/tools/worktrees.js +581 -0
- package/dist/tools/worktrees.js.map +1 -0
- package/dist/typed-records.js +174 -0
- package/dist/typed-records.js.map +1 -0
- package/dist/work.js +2 -0
- package/dist/work.js.map +1 -0
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +0 -0
- package/package.json +11 -14
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/server.ts +9 -1
- 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
|