agent-coord-mcp 0.26.21 → 0.26.22
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/capabilities.js +250 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/closing-line.js +83 -0
- package/dist/closing-line.js.map +1 -0
- package/dist/commit-cite.js +55 -0
- package/dist/commit-cite.js.map +1 -0
- package/dist/gated-head.js +67 -20
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +195 -0
- package/dist/server-spread.js.map +1 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +32 -0
- package/dist/store.js.map +1 -1
- package/dist/tools/away.js +67 -7
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/board-ref.js +44 -4
- package/dist/tools/board-ref.js.map +1 -1
- package/dist/tools/event-kinds.js +5 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +31 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/messaging.js +64 -6
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/record-events.js +85 -5
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +231 -38
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +52 -2
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +173 -0
- package/dist/tools/seat-build.js.map +1 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +1095 -18
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +21 -2
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/package.json +1 -1
- package/scripts/coord-attention-clock.mjs +2 -0
- package/scripts/coord-stall-clock.mjs +52 -11
- package/src/capabilities.ts +264 -2
- package/src/closing-line.ts +85 -0
- package/src/commit-cite.ts +58 -0
- package/src/gated-head.ts +128 -26
- package/src/server-spread.ts +233 -0
- package/src/server.ts +2 -2
- package/src/store.ts +32 -0
- package/src/tools/away.ts +82 -9
- package/src/tools/board-ref.ts +70 -3
- package/src/tools/event-kinds.ts +17 -1
- package/src/tools/events.ts +33 -2
- package/src/tools/messaging.ts +63 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +248 -38
- package/src/tools/registry.ts +54 -3
- package/src/tools/seat-build.ts +194 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +21 -2
- package/src/tools/worktrees.ts +13 -0
package/dist/tools/stall.js
CHANGED
|
@@ -16,18 +16,646 @@
|
|
|
16
16
|
* a broken one.
|
|
17
17
|
*/
|
|
18
18
|
import { isLocallyProbeable } from "../transports/index.js";
|
|
19
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
20
20
|
import { execFileSync } from "node:child_process";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { z } from "zod";
|
|
23
|
-
import { parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
|
|
23
|
+
import { parseWorkDoc, workstreamsV1RowsOf, workstreamsExtensionsOf, isMalformedRow, queueItemsOf, workStateOf, coarseOf } from "@davidbalzan/groundwork-seam";
|
|
24
24
|
import { ROOT, AGENTS_FILE, readJson } from "../store.js";
|
|
25
|
-
import { classifyBoardRef, refInCell } from "./board-ref.js";
|
|
25
|
+
import { classifyBoardRef, refInCell, rowKindOf, cellKindOf } from "./board-ref.js";
|
|
26
26
|
import { loadLiveTransports } from "./registry.js";
|
|
27
|
+
import { verdictsFor, shaAgrees, verdictShasIn } from "../gated-head.js";
|
|
28
|
+
import { closingsIn, ACCEPTED_FORMS } from "../closing-line.js";
|
|
27
29
|
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
30
|
+
export function boardParseOf(text) {
|
|
31
|
+
const lines = String(text ?? "").split("\n");
|
|
32
|
+
const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
|
|
33
|
+
const doc = parseWorkDoc(String(text ?? ""));
|
|
34
|
+
const block = doc.blocks.find((b) => b.kind === "board" && b.schema === "workstreams.v1");
|
|
35
|
+
const blockRows = (block?.rows ?? []);
|
|
36
|
+
const rowsParsed = workstreamsV1RowsOf(doc).length;
|
|
37
|
+
const malformed = blockRows.filter((r) => isMalformedRow(r)).length;
|
|
38
|
+
if (header === -1) {
|
|
39
|
+
const pipeLines = lines.filter((l) => /^\s*\|/.test(l)).length;
|
|
40
|
+
const readable = pipeLines === 0 && rowsParsed === 0;
|
|
41
|
+
return {
|
|
42
|
+
section: "Active Streams", headerFound: false, rowsPresent: pipeLines, rowsParsed, malformed, unparsed: Math.max(0, pipeLines - rowsParsed - malformed), readable,
|
|
43
|
+
why: readable
|
|
44
|
+
? "no Active Streams table in the file and no table-shaped lines — an empty board, readable"
|
|
45
|
+
: `no \`| Stream |\` header found but ${pipeLines} table-shaped line(s) exist — the board is not a workstreams.v1 table this verb can read; UNPARSEABLE, which is not the same as empty`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let i = header + 1;
|
|
49
|
+
if (i < lines.length && /^\s*\|\s*:?-+/.test(lines[i]))
|
|
50
|
+
i++; // the alignment row is scaffold, not a row
|
|
51
|
+
let rowsPresent = 0;
|
|
52
|
+
for (; i < lines.length && /^\s*\|/.test(lines[i]); i++)
|
|
53
|
+
rowsPresent++;
|
|
54
|
+
const unparsed = Math.max(0, rowsPresent - rowsParsed - malformed);
|
|
55
|
+
// A MALFORMED ROW IS UNREADABLE TOO. Measured while writing the control: a
|
|
56
|
+
// short row that is not the table's first line is recorded by the seam as
|
|
57
|
+
// malformed (refused, kept verbatim) rather than ending the block — and a
|
|
58
|
+
// refused lane is exactly as invisible to this clock as a dropped one. Both
|
|
59
|
+
// conjuncts are required; the ad80825 shape trips the first, the mid-table
|
|
60
|
+
// shape trips the second.
|
|
61
|
+
const readable = unparsed === 0 && malformed === 0;
|
|
62
|
+
return {
|
|
63
|
+
section: "Active Streams", headerFound: true, rowsPresent, rowsParsed, malformed, unparsed, readable,
|
|
64
|
+
why: readable
|
|
65
|
+
? rowsPresent === 0
|
|
66
|
+
? "the Active Streams table is present and EMPTY — 0 rows present, 0 parsed; readable"
|
|
67
|
+
: `${rowsPresent} row(s) present, ${rowsParsed} parsed — every row is accounted for`
|
|
68
|
+
: `UNPARSEABLE — ${rowsPresent} row(s) present in the Active Streams table but only ${rowsParsed} parsed` +
|
|
69
|
+
`${malformed ? `; ${malformed} row(s) REFUSED by the parser as malformed (wrong column count) and therefore invisible to this clock` : ""}` +
|
|
70
|
+
`${unparsed ? `; ${unparsed} row(s) exist that no record describes — one bad row ends the table for the parser and nothing below it is measured` : ""}. ` +
|
|
71
|
+
`"Nothing measured" is not "nothing wrong".`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
28
74
|
const STALL_MS = 30 * 60 * 1000;
|
|
75
|
+
/**
|
|
76
|
+
* How long a lane may sit in review before it is a HIT. Four times the
|
|
77
|
+
* authoring window, not equal to it: a review is a queue on another seat, and
|
|
78
|
+
* the number that matters is how long that queue has held this row — it is not
|
|
79
|
+
* a tuned threshold on branch activity, which a review-frozen branch fails by
|
|
80
|
+
* design (⟨q-507e80c4⟩).
|
|
81
|
+
*/
|
|
82
|
+
const REVIEW_STALL_MINUTES = 120;
|
|
83
|
+
/** ⟨q-7b2f6c04⟩ — the claim window: a lane claimed this long ago with nothing on origin is a hit. Its own window, independent of the VCS one. */
|
|
84
|
+
export const CLAIM_STALL_MINUTES = 120;
|
|
29
85
|
const runFile = () => path.join(ROOT, "stall-check.json");
|
|
30
86
|
const haltFile = () => path.join(ROOT, "halt.json");
|
|
87
|
+
export const audienceOf = (kind) => kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population" ? "board-owner" : "duty";
|
|
88
|
+
const withAudience = (hits) => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }));
|
|
89
|
+
const NET_TIMEOUT_MS = 15_000;
|
|
90
|
+
export const realArtefacts = (repo) => {
|
|
91
|
+
let openPrs = null;
|
|
92
|
+
try {
|
|
93
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid,headRefName,updatedAt"], {
|
|
94
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
openPrs = JSON.parse(out).map((p) => ({
|
|
97
|
+
n: p.number, headRefOid: p.headRefOid, headRefName: p.headRefName, updatedAt: p.updatedAt,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
catch { /* reported as unmeasurable by the caller */ }
|
|
101
|
+
let mergedPrs = null;
|
|
102
|
+
try {
|
|
103
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "1000", "--json", "headRefName,headRefOid"], {
|
|
104
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
105
|
+
});
|
|
106
|
+
mergedPrs = JSON.parse(out);
|
|
107
|
+
}
|
|
108
|
+
catch { /* landedness falls back to patch ids; said so by the caller */ }
|
|
109
|
+
let recentMerges = null;
|
|
110
|
+
try {
|
|
111
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
|
|
112
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
113
|
+
});
|
|
114
|
+
recentMerges = JSON.parse(out).map((p) => ({
|
|
115
|
+
n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
catch { /* said by the caller */ }
|
|
119
|
+
let remoteHeads = null;
|
|
120
|
+
try {
|
|
121
|
+
const out = execFileSync("git", ["ls-remote", "--heads", "origin"], {
|
|
122
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
123
|
+
});
|
|
124
|
+
remoteHeads = out.split("\n").filter(Boolean).map((l) => {
|
|
125
|
+
const [sha, ref] = l.split(/\s+/);
|
|
126
|
+
return { sha: sha ?? "", name: (ref ?? "").replace(/^refs\/heads\//, "") };
|
|
127
|
+
}).filter((h) => h.sha && h.name);
|
|
128
|
+
}
|
|
129
|
+
catch { /* reported as unmeasurable by the caller */ }
|
|
130
|
+
return { openPrs, remoteHeads, mergedPrs, recentMerges };
|
|
131
|
+
};
|
|
132
|
+
/** Every inbox on this bus, concatenated — a GO delivered by DM is a routing record too. */
|
|
133
|
+
export const readAllInboxLogs = () => {
|
|
134
|
+
const dir = path.join(ROOT, "inbox");
|
|
135
|
+
try {
|
|
136
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
* ⟨q-8f1e604b⟩ — TWO RECORD-KEEPING CONVENTIONS BECOME CHECKS. Both were
|
|
144
|
+
* adopted on 2026-09-14 by seats that were cycled the same afternoon; a
|
|
145
|
+
* convention does not survive its holder, a check does.
|
|
146
|
+
*
|
|
147
|
+
* (e) VERDICTS ON THE PR. A merged PR must carry a verdict COMMENT bound to the
|
|
148
|
+
* head that merged. "Verdict" is a TYPED line — `QA GATE — **PASS** @ \`sha\``
|
|
149
|
+
* or the merge-time `**GATE: PASS** … @ \`sha\`` — never a substring: #299's
|
|
150
|
+
* page says "not a verdict", "PASS" and "my verdict above" in three review
|
|
151
|
+
* comments and carries no verdict at all. Bound by sha, prefix-tolerant
|
|
152
|
+
* (`shaAgrees`): a verdict on an OLDER head is the #268 breach, not a gate.
|
|
153
|
+
* (f) A GO WRITES THE BOARD ROW. An item is ROUTED when a `go` record names it
|
|
154
|
+
* ON THE GO LINE — `GO ⟨q-…⟩ —`, `GO: … ⟨q-…⟩` — read by POSITION, because a
|
|
155
|
+
* GO's prose mentions neighbouring items as context. A routed item that is
|
|
156
|
+
* still open and has no board row naming it as its SUBJECT is a hit:
|
|
157
|
+
* `next_unblocked` would offer it again. Room logs AND inboxes are read —
|
|
158
|
+
* this fleet routes by DM.
|
|
159
|
+
* Both are bounded by a window: a merge or a GO older than it is listed, not
|
|
160
|
+
* raised, so the day's real instances (#299) fire once and age out.
|
|
161
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
162
|
+
export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
|
|
163
|
+
/**
|
|
164
|
+
* WHEN (e) WAS ADOPTED, as a dated fact: qa posted at 2026-09-14 13:28Z that all
|
|
165
|
+
* five of its verdicts were now on their PRs. Measured live before this floor
|
|
166
|
+
* existed: 39 of the 50 most recent merges had no verdict comment — every one
|
|
167
|
+
* merged BEFORE that instant, when the convention did not exist. Raising them
|
|
168
|
+
* would make the check's first three days pure noise, which is how a check gets
|
|
169
|
+
* switched off. Merges before the floor are LISTED with `beforeAdoption`, never
|
|
170
|
+
* raised; the window subsumes the floor after three days.
|
|
171
|
+
*/
|
|
172
|
+
export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
|
|
173
|
+
// The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
|
|
174
|
+
export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
|
|
175
|
+
/** Items named ON A GO LINE of a `go` record — position, not mention. */
|
|
176
|
+
export function routedItemsIn(logText) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const l of logText.split("\n")) {
|
|
179
|
+
if (!l.trim())
|
|
180
|
+
continue;
|
|
181
|
+
let o;
|
|
182
|
+
try {
|
|
183
|
+
o = JSON.parse(l);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (o.record?.type !== "go")
|
|
189
|
+
continue;
|
|
190
|
+
for (const line of String(o.text ?? "").split("\n")) {
|
|
191
|
+
if (!/\bGO\b/.test(line))
|
|
192
|
+
continue;
|
|
193
|
+
for (const m of line.matchAll(/q-[0-9a-f]{8}/g))
|
|
194
|
+
out.push({ itemId: m[0], by: String(o.from ?? ""), ts: Number(o.ts ?? 0) });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
* ⟨q-4e08b3c1⟩ — THE MERGE WINDOW, JOINED. qa posts `MERGE WINDOW: #N — hold
|
|
201
|
+
* queue/board writes` as the trailing line of its typed PASS and `MERGE WINDOW
|
|
202
|
+
* CLOSED` as the trailing line of its MERGED done; a seat's write is a commit
|
|
203
|
+
* touching docs/QUEUE.md, docs/DONE.md or docs/WORKSTREAMS.md, timestamped by
|
|
204
|
+
* git. The incident (#312): two coordinator board commits at 16:34 and 16:37,
|
|
205
|
+
* before a window opened at ~16:40 — nobody violated anything, the protocol's
|
|
206
|
+
* party list was incomplete. `217a6af` bound every record writer; that is the
|
|
207
|
+
* dated floor. Measured today before this existed: five windows, each under
|
|
208
|
+
* 70 seconds, 67 record commits, none inside a window.
|
|
209
|
+
*
|
|
210
|
+
* ⚠ AN UNCLOSED WINDOW IS CAPPED, NEVER OPEN-ENDED (the ruling): at the next
|
|
211
|
+
* window's open or 30 minutes, whichever comes first, and reported `unclosed`
|
|
212
|
+
* by name — a window nobody closed must not condemn every write after it.
|
|
213
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
214
|
+
export const MERGE_WINDOW_ADOPTED_MS = Date.parse("2026-09-14T16:43:54Z");
|
|
215
|
+
export const MERGE_WINDOW_CAP_MS = 30 * 60 * 1000;
|
|
216
|
+
export const RECORD_DOCS = ["docs/QUEUE.md", "docs/DONE.md", "docs/WORKSTREAMS.md"];
|
|
217
|
+
/** Windows as the bus records them: an OPEN line and, for the same PR, the next CLOSED line after it. */
|
|
218
|
+
export function windowsIn(logText) {
|
|
219
|
+
const opens = [];
|
|
220
|
+
const closes = [];
|
|
221
|
+
for (const l of logText.split("\n")) {
|
|
222
|
+
if (!l.trim())
|
|
223
|
+
continue;
|
|
224
|
+
let o;
|
|
225
|
+
try {
|
|
226
|
+
o = JSON.parse(l);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const t = String(o.text ?? "");
|
|
232
|
+
const om = /MERGE WINDOW: #(\d+)/.exec(t);
|
|
233
|
+
if (om)
|
|
234
|
+
opens.push({ pr: Number(om[1]), ts: Number(o.ts ?? 0), from: String(o.from ?? "") });
|
|
235
|
+
if (/MERGE WINDOW CLOSED/.test(t)) {
|
|
236
|
+
// The PR is named wherever the message names it (`DONE: MERGED owner/repo#N … MERGE WINDOW CLOSED`); a close naming no PR closes the open window.
|
|
237
|
+
const pm = /#(\d+)/.exec(t);
|
|
238
|
+
closes.push({ pr: pm ? Number(pm[1]) : null, ts: Number(o.ts ?? 0) });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
opens.sort((a, b) => a.ts - b.ts);
|
|
242
|
+
return opens.map((w, i) => {
|
|
243
|
+
const close = closes.filter((c) => c.ts > w.ts && (c.pr === null || c.pr === w.pr)).sort((a, b) => a.ts - b.ts)[0]?.ts ?? null;
|
|
244
|
+
const nextOpen = opens[i + 1]?.ts ?? Infinity;
|
|
245
|
+
const end = close ?? Math.min(nextOpen, w.ts + MERGE_WINDOW_CAP_MS);
|
|
246
|
+
return { pr: w.pr, open: w.ts, close, end, unclosed: close === null, openedBy: w.from };
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** The PR a landing commit lands, from the forge's `(#N)` marker at the END of the subject; null for any other commit. */
|
|
250
|
+
export const mergeOf = (fullSubject) => { const m = /\(#(\d+)\)\s*$/.exec(fullSubject); return m ? Number(m[1]) : null; };
|
|
251
|
+
/**
|
|
252
|
+
* ⟨q-4e08b3c1⟩ follow-up, MEASURED LIVE 2026-09-14 19:38Z by this file's own
|
|
253
|
+
* live-population test: #325's SQUASH (3b8702e) touched docs/QUEUE.md — the PR
|
|
254
|
+
* migrated a queue receipt — and its author time is the merge time, 13s into
|
|
255
|
+
* MERGE WINDOW #325. The window's own landing commit is the MERGE the window
|
|
256
|
+
* exists to protect, not a seat's write into it; it is listed under the window
|
|
257
|
+
* with `landing: true` and never raised.
|
|
258
|
+
*/
|
|
259
|
+
export const isLandingOf = (write, pr) => write.mergeOf !== null && write.mergeOf === pr;
|
|
260
|
+
/** Commits touching a record document on the shared branch, by AUTHOR time — when the seat wrote, not when it landed. */
|
|
261
|
+
export function recordWritesOn(repo, sinceMs) {
|
|
262
|
+
const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
263
|
+
let base = null;
|
|
264
|
+
for (const cand of ["origin/main", "main", "origin/master", "master"]) {
|
|
265
|
+
try {
|
|
266
|
+
git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
|
|
267
|
+
base = cand;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
catch { /* next */ }
|
|
271
|
+
}
|
|
272
|
+
if (!base)
|
|
273
|
+
return null;
|
|
274
|
+
try {
|
|
275
|
+
const out = git(["log", base, `--since=${new Date(sinceMs).toISOString()}`, "--format=%H|%aI|%an|%s", "--", ...RECORD_DOCS]);
|
|
276
|
+
return out.split("\n").filter(Boolean).map((l) => {
|
|
277
|
+
const [sha, aI, author, ...rest] = l.split("|");
|
|
278
|
+
const full = rest.join("|");
|
|
279
|
+
return { sha: sha ?? "", at: Date.parse(aI ?? ""), author: author ?? "", subject: full.slice(0, 120), mergeOf: mergeOf(full) };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export function mergeWindowChecks(input) {
|
|
287
|
+
const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
|
|
288
|
+
const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
|
|
289
|
+
const hits = [];
|
|
290
|
+
const unmeasurable = [];
|
|
291
|
+
const windows = windowsIn(input.logText).filter((w) => input.now - w.open <= horizon);
|
|
292
|
+
if (input.writes === null) {
|
|
293
|
+
unmeasurable.push({ agentId: "-", value: "git log -- docs/{QUEUE,DONE,WORKSTREAMS}.md", why: "record-document writes could not be read from the shared branch — convention (g), the merge window, is UNMEASURED, which is not the same as kept" });
|
|
294
|
+
return { hits: [], windows: windows.map((w) => ({ ...w, writes: [] })), unmeasurable };
|
|
295
|
+
}
|
|
296
|
+
const out = windows.map((w) => {
|
|
297
|
+
const inside = input.writes.filter((c) => Number.isFinite(c.at) && c.at >= w.open && c.at <= w.end).map((c) => ({ ...c, beforeAdoption: c.at < floor, landing: isLandingOf(c, w.pr) }));
|
|
298
|
+
for (const c of inside) {
|
|
299
|
+
if (c.beforeAdoption || c.landing)
|
|
300
|
+
continue;
|
|
301
|
+
const secs = Math.round((c.at - w.open) / 1000);
|
|
302
|
+
hits.push({
|
|
303
|
+
kind: "merge-window-write", pr: w.pr, sha: c.sha, author: c.author, authoredAt: new Date(c.at).toISOString(), secondsIntoWindow: secs,
|
|
304
|
+
why: `${c.sha.slice(0, 7)} (${c.author}) wrote a record document ${secs}s into MERGE WINDOW #${w.pr}${w.unclosed ? " (window never closed; capped)" : ""}: "${c.subject}". Convention (g): queue/board/DONE writes pause between MERGE WINDOW and MERGE WINDOW CLOSED.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return { ...w, writes: inside };
|
|
308
|
+
});
|
|
309
|
+
return { hits: withAudience(hits), windows: out, unmeasurable };
|
|
310
|
+
}
|
|
311
|
+
export function conventionChecks(input) {
|
|
312
|
+
const windowMs = input.windowMs ?? CONVENTION_WINDOW_MS;
|
|
313
|
+
const hits = [];
|
|
314
|
+
const unmeasurable = [];
|
|
315
|
+
let merges = null;
|
|
316
|
+
if (input.recentMerges === null || input.recentMerges === undefined) {
|
|
317
|
+
unmeasurable.push({ agentId: "-", value: "gh pr list --state merged (with comments)", why: "recent merges could not be read from gh — convention (e), verdicts on the PR, is UNMEASURED, which is not the same as kept" });
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
merges = [];
|
|
321
|
+
for (const m of input.recentMerges) {
|
|
322
|
+
const mergedMs = Date.parse(m.mergedAt);
|
|
323
|
+
const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
|
|
324
|
+
const inWindow = input.now - mergedMs <= windowMs;
|
|
325
|
+
const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
|
|
326
|
+
const verdictAtHead = verdictShasIn(m.comments).some((v) => shaAgrees(v.sha, m.headRefOid));
|
|
327
|
+
merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption });
|
|
328
|
+
if (inWindow && !beforeAdoption && !verdictAtHead) {
|
|
329
|
+
hits.push({
|
|
330
|
+
kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
|
|
331
|
+
why: `#${m.n} merged at ${m.headRefOid.slice(0, 7)} with no verdict comment bound to that head on the PR (${m.comments.length} comment(s), none a typed \`QA GATE — **PASS|FAIL** @ sha\` line for it). Convention (e): the verdict lives on the PR, not only on the bus.`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const openIds = new Set();
|
|
337
|
+
const closedIds = new Set();
|
|
338
|
+
if (input.queueText !== null) {
|
|
339
|
+
for (const i of queueItemsOf(parseWorkDoc(input.queueText)))
|
|
340
|
+
(i.done ? closedIds : openIds).add(i.id);
|
|
341
|
+
}
|
|
342
|
+
const subjectOf = (r) => [...String(r.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((x) => x[1]);
|
|
343
|
+
const onBoard = new Set(input.boardRows.flatMap(subjectOf));
|
|
344
|
+
const routed = [];
|
|
345
|
+
const seen = new Set();
|
|
346
|
+
for (const r of routedItemsIn(input.routingLogText).sort((a, b) => b.ts - a.ts)) {
|
|
347
|
+
if (seen.has(r.itemId))
|
|
348
|
+
continue; // the latest GO for an item is the one that binds
|
|
349
|
+
seen.add(r.itemId);
|
|
350
|
+
const minutes = Math.max(0, Math.round((input.now - r.ts) / 60000));
|
|
351
|
+
const inWindow = input.now - r.ts <= windowMs;
|
|
352
|
+
const open = input.queueText === null ? null : openIds.has(r.itemId) ? true : closedIds.has(r.itemId) ? false : null;
|
|
353
|
+
const has = onBoard.has(r.itemId);
|
|
354
|
+
routed.push({ itemId: r.itemId, by: r.by, minutes, open, onBoard: has, inWindow });
|
|
355
|
+
if (inWindow && open === true && !has) {
|
|
356
|
+
hits.push({
|
|
357
|
+
kind: "routed-without-row", itemId: r.itemId, routedBy: r.by, routedAt: new Date(r.ts).toISOString(), minutes,
|
|
358
|
+
why: `⟨${r.itemId}⟩ was routed by a GO from ${r.by} ${minutes}m ago, is still open in the queue, and no board row names it as its subject — next_unblocked can offer it again. Convention (f): a GO writes the board row (claim does it for you).`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ⟨q-fee7239f⟩ — closing lines: every MERGED done on the bus, judged by the
|
|
363
|
+
// grammar; those at or before the floor are LISTED with beforeAdoption.
|
|
364
|
+
const closings = closingsIn(input.routingLogText).map((c) => ({ ...c, inWindow: input.now - c.ts <= windowMs }));
|
|
365
|
+
for (const c of closings) {
|
|
366
|
+
if (!c.inWindow || c.beforeAdoption || !c.claimsDelete || c.form !== null)
|
|
367
|
+
continue;
|
|
368
|
+
const minutes = Math.max(0, Math.round((input.now - c.ts) / 60000));
|
|
369
|
+
hits.push({
|
|
370
|
+
kind: "unread-delete-claim", agentId: c.from, pr: c.pr, closedAt: new Date(c.ts).toISOString(), minutes,
|
|
371
|
+
why: `${c.from}'s closing line for ${c.pr === null ? "a merge" : `#${c.pr}`} asserts a branch deletion in neither accepted form — ${ACCEPTED_FORMS}. The remote was not read; the record may be premature or false.`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return { hits: withAudience(hits), unmeasurable, merges, routed, closings };
|
|
375
|
+
}
|
|
376
|
+
/** Every room log on this bus, concatenated — a verdict for a PR counts from any room. */
|
|
377
|
+
export const readAllRoomLogs = () => {
|
|
378
|
+
const dir = path.join(ROOT, "rooms");
|
|
379
|
+
try {
|
|
380
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return "";
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
/** The seat a branch belongs to, by the prefix `claim` writes — identity is in the artefact, not the account. */
|
|
387
|
+
const seatOfBranch = (name) => (name.includes("/") ? name.split("/")[0] : "unknown");
|
|
388
|
+
const BASE_BRANCHES = new Set(["main", "master", "HEAD"]);
|
|
389
|
+
const memoFile = () => path.join(ROOT, "stall-stranded-memo.json");
|
|
390
|
+
const readMemo = () => {
|
|
391
|
+
try {
|
|
392
|
+
return JSON.parse(readFileSync(memoFile(), "utf8"));
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return {};
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const writeMemo = (m) => {
|
|
399
|
+
try {
|
|
400
|
+
mkdirSync(ROOT, { recursive: true });
|
|
401
|
+
writeFileSync(memoFile(), JSON.stringify(m));
|
|
402
|
+
}
|
|
403
|
+
catch { /* a lost memo costs time on the next run, never correctness */ }
|
|
404
|
+
};
|
|
405
|
+
export const STRANDED_BUDGET_MS = 15_000;
|
|
406
|
+
/**
|
|
407
|
+
* Did the WHOLE RANGE land as ONE commit? `git cherry` compares commit by
|
|
408
|
+
* commit, so a squash of several commits — one patch id for the range, none
|
|
409
|
+
* for the parts — reads `+` on every line and the branch looks unproposed
|
|
410
|
+
* forever. Measured live: 47 of 209 ahead-by-ancestry branches survived the
|
|
411
|
+
* cherry test, and every young one was a 2-commit branch merged that day.
|
|
412
|
+
*
|
|
413
|
+
* So index main's recent commits by the patch id of EACH commit — ONE process
|
|
414
|
+
* (`git log -p | git patch-id`), measured at 1.8s for 1000 commits — and ask,
|
|
415
|
+
* per branch, whether the patch id of `mergeBase..sha` as ONE diff is in it.
|
|
416
|
+
* A hit means main carries this branch's cumulative change as a single
|
|
417
|
+
* commit: a squash. Bounded to the last 1000 first-parent commits, so a
|
|
418
|
+
* branch that landed further back than that reads as not landed — the
|
|
419
|
+
* direction that over-reports, never the one that hides stranded work.
|
|
420
|
+
*/
|
|
421
|
+
const SQUASH_INDEX_DEPTH = 1000;
|
|
422
|
+
/** `git patch-id --stable` over a diff, without a shell: the inputs are shas and refs from origin, and data is not a command line. */
|
|
423
|
+
const patchIds = (repo, diff) => execFileSync("git", ["patch-id", "--stable"], { cwd: repo, encoding: "utf8", input: diff, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
424
|
+
function squashIndex(repo, base) {
|
|
425
|
+
const map = new Map();
|
|
426
|
+
try {
|
|
427
|
+
const log = execFileSync("git", ["log", "-p", "--first-parent", `--max-count=${SQUASH_INDEX_DEPTH}`, "--format=%H", base, "--"], {
|
|
428
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024,
|
|
429
|
+
});
|
|
430
|
+
const out = patchIds(repo, log);
|
|
431
|
+
for (const l of out.split("\n")) {
|
|
432
|
+
const [id, commit] = l.trim().split(/\s+/);
|
|
433
|
+
if (id && commit)
|
|
434
|
+
map.set(id, commit);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch { /* an empty index reads every branch as not landed — over-reports */ }
|
|
438
|
+
return map;
|
|
439
|
+
}
|
|
440
|
+
function landedBySquash(repo, base, sha, index) {
|
|
441
|
+
if (index.size === 0)
|
|
442
|
+
return false;
|
|
443
|
+
try {
|
|
444
|
+
const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
445
|
+
const mb = git(["merge-base", base, sha]).trim();
|
|
446
|
+
if (!mb)
|
|
447
|
+
return false;
|
|
448
|
+
const id = patchIds(repo, git(["diff", mb, sha, "--"])).trim().split(/\s+/)[0] ?? "";
|
|
449
|
+
return id.length > 0 && index.has(id);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
export function gateClaimsIn(logText, pr, head) {
|
|
456
|
+
const out = [];
|
|
457
|
+
for (const l of logText.split("\n")) {
|
|
458
|
+
if (!l.trim())
|
|
459
|
+
continue;
|
|
460
|
+
let o;
|
|
461
|
+
try {
|
|
462
|
+
o = JSON.parse(l);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const r = o.record;
|
|
468
|
+
if (!r || r.type === "verdict")
|
|
469
|
+
continue;
|
|
470
|
+
if (!(r.cites ?? []).some((c) => (String(c?.ref ?? "").match(/#(\d+)/) ?? [])[1] === pr))
|
|
471
|
+
continue;
|
|
472
|
+
for (const v of verdictShasIn([{ body: String(o.text ?? "") }])) {
|
|
473
|
+
if (shaAgrees(v.sha, head))
|
|
474
|
+
out.push({ from: String(o.from ?? ""), ts: Number(o.ts ?? 0), result: v.result, sha: v.sha, type: String(r.type ?? "") });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out.sort((a, b) => a.ts - b.ts);
|
|
478
|
+
}
|
|
479
|
+
export function strandedWork(repo, artefacts, logText, now, limitMs,
|
|
480
|
+
/** Seats the bus knows. A branch hit needs a recipient; a branch nobody owns is reported, not DMed. */
|
|
481
|
+
registeredSeats = new Set(),
|
|
482
|
+
/** Uncached branch classification stops here; the rest is DEFERRED and named. */
|
|
483
|
+
budgetMs = STRANDED_BUDGET_MS) {
|
|
484
|
+
const hits = [];
|
|
485
|
+
const unmeasurable = [];
|
|
486
|
+
const git = (args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
487
|
+
let openPrs = null;
|
|
488
|
+
if (artefacts.openPrs === null) {
|
|
489
|
+
unmeasurable.push({ agentId: "-", value: "gh pr list", why: "open PRs could not be read from gh — stranded PRs are UNMEASURED, which is not the same as none" });
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
openPrs = [];
|
|
493
|
+
for (const p of artefacts.openPrs) {
|
|
494
|
+
const { verdicts } = verdictsFor(logText, String(p.n));
|
|
495
|
+
const verdictAtHead = verdicts.some((v) => shaAgrees(v.head, p.headRefOid));
|
|
496
|
+
const minutes = Math.max(0, Math.round((now - Date.parse(p.updatedAt)) / 60000));
|
|
497
|
+
const claimAtHead = verdictAtHead ? null : gateClaimsIn(logText, String(p.n), p.headRefOid)[0] ?? null;
|
|
498
|
+
openPrs.push({ pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes, verdictAtHead, claimAtHead });
|
|
499
|
+
// A verdict bound to an OLDER head does not count: the head moved, and a
|
|
500
|
+
// PASS on what used to be there is the #268 breach one step earlier.
|
|
501
|
+
// ⟨q-dcbaf544⟩ — an UNSCRIBED gate report fires at once; "nobody gated" waits the limit.
|
|
502
|
+
if (!verdictAtHead && (claimAtHead || now - Date.parse(p.updatedAt) > limitMs)) {
|
|
503
|
+
hits.push({
|
|
504
|
+
kind: "unverdicted-pr", agentId: seatOfBranch(p.headRefName), pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes,
|
|
505
|
+
...(claimAtHead
|
|
506
|
+
? { why: "reported-unscribed", claimedBy: claimAtHead.from, claimedAt: new Date(claimAtHead.ts).toISOString(), claimedResult: claimAtHead.result }
|
|
507
|
+
: { why: "nobody-gated" }),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
let pushedBranches = null;
|
|
513
|
+
if (artefacts.remoteHeads === null) {
|
|
514
|
+
unmeasurable.push({ agentId: "-", value: "git ls-remote --heads origin", why: "origin's branches could not be read — pushed-but-unproposed work is UNMEASURED, which is not the same as none" });
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
pushedBranches = [];
|
|
518
|
+
const prHeads = new Set((artefacts.openPrs ?? []).map((p) => p.headRefOid));
|
|
519
|
+
// RANK 1, docs/LANDEDNESS.md: the forge's own record. Measured live before it
|
|
520
|
+
// was asked: a 15-day-old branch whose PR #170 had merged with this very tip
|
|
521
|
+
// as its head read "not landed" — its squash sat beyond the patch index's
|
|
522
|
+
// window. A patch-id negative is inconclusive by construction; the forge's
|
|
523
|
+
// positive is not.
|
|
524
|
+
const forgeLanded = new Set((artefacts.mergedPrs ?? []).map((p) => p.headRefOid));
|
|
525
|
+
if (artefacts.mergedPrs === null || artefacts.mergedPrs === undefined) {
|
|
526
|
+
unmeasurable.push({ agentId: "-", value: "gh pr list --state merged", why: "the forge's merged-PR record could not be read — landedness falls back to patch ids, whose NEGATIVES are inconclusive in a squash-merging repo (docs/LANDEDNESS.md); a branch reported unproposed here may have landed" });
|
|
527
|
+
}
|
|
528
|
+
const prBranches = new Set((artefacts.openPrs ?? []).map((p) => p.headRefName));
|
|
529
|
+
let base = null;
|
|
530
|
+
for (const cand of ["origin/main", "main", "origin/master", "master"]) {
|
|
531
|
+
try {
|
|
532
|
+
git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
|
|
533
|
+
base = cand;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
catch { /* next */ }
|
|
537
|
+
}
|
|
538
|
+
let squashes = null; // built once per run, only if a branch needs it
|
|
539
|
+
const memo = readMemo();
|
|
540
|
+
let baseSha = "";
|
|
541
|
+
try {
|
|
542
|
+
baseSha = base ? git(["rev-parse", base]) : "";
|
|
543
|
+
}
|
|
544
|
+
catch { /* handled below as no base */ }
|
|
545
|
+
const started = Date.now();
|
|
546
|
+
let deferred = 0;
|
|
547
|
+
// The budget is spent on the branches that can RAISE something first: a
|
|
548
|
+
// registered seat's branch is the only kind that becomes a hit, and on a
|
|
549
|
+
// 212-head origin the alphabet puts `docs/…` and `fix/…` ahead of every
|
|
550
|
+
// `groundwork-kit-worker-*/…`. Stable within each group.
|
|
551
|
+
const ordered = [...artefacts.remoteHeads].sort((x, y) => Number(registeredSeats.has(seatOfBranch(y.name))) - Number(registeredSeats.has(seatOfBranch(x.name))));
|
|
552
|
+
for (const h of ordered) {
|
|
553
|
+
if (BASE_BRANCHES.has(h.name))
|
|
554
|
+
continue;
|
|
555
|
+
const hasPr = prHeads.has(h.sha) || prBranches.has(h.name);
|
|
556
|
+
if (!base) {
|
|
557
|
+
unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: "no main/master to measure distance from" });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
// ⛔ THE FORGE BEFORE THE MEMO — QA's row B (#307 FAIL @ 0f5f334): a branch
|
|
561
|
+
// memoised as not-landed against a base that has not moved stayed a hit
|
|
562
|
+
// AFTER the forge said its PR had merged, because the memo answered first.
|
|
563
|
+
// Live shape: PR merged, local origin/main not yet fetched, owner DMed that
|
|
564
|
+
// merged work is stranded — the instrument mirroring the aide's clearance
|
|
565
|
+
// error. The forge is rank 1 and a set lookup; it is asked first, always,
|
|
566
|
+
// and its answer overwrites the memo.
|
|
567
|
+
const cached = memo[h.sha];
|
|
568
|
+
let ahead;
|
|
569
|
+
let landed;
|
|
570
|
+
let landedBy;
|
|
571
|
+
let committedAt;
|
|
572
|
+
if (forgeLanded.has(h.sha) && cached && !cached.landed) {
|
|
573
|
+
({ ahead, committedAt } = cached);
|
|
574
|
+
landed = true;
|
|
575
|
+
landedBy = "forge";
|
|
576
|
+
memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
|
|
577
|
+
}
|
|
578
|
+
else if (cached && (cached.landed || cached.base === baseSha)) {
|
|
579
|
+
({ ahead, landed, landedBy, committedAt } = cached);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
if (Date.now() - started > budgetMs) {
|
|
583
|
+
deferred++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
git(["cat-file", "-e", `${h.sha}^{commit}`]);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: `origin/${h.name} @ ${h.sha.slice(0, 7)} is not fetched here — its age and distance from main cannot be read; not counted as clean` });
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
ahead = Number(git(["rev-list", "--count", `${base}..${h.sha}`]) || 0);
|
|
594
|
+
committedAt = git(["log", "-1", "--format=%cI", h.sha, "--"]);
|
|
595
|
+
// ⛔⛆ ANCESTRY CANNOT SAY "LANDED" ON A SQUASH-MERGING FLEET. Measured live
|
|
596
|
+
// before this line existed: 205 hits, and every young one was a branch
|
|
597
|
+
// whose PR had merged that afternoon — `rev-list --count main..sha` reads
|
|
598
|
+
// a squash-landed branch as ahead forever. `git cherry` compares PATCH
|
|
599
|
+
// IDS, the same instrument `classifyBoardRef` and `refresh_worktrees` use:
|
|
600
|
+
// every line `-` means every patch is upstream. Same caveat as theirs: a
|
|
601
|
+
// squash of SEVERAL commits into one changes the combined patch id, so a
|
|
602
|
+
// multi-commit landed branch can still read as unproposed — the direction
|
|
603
|
+
// that over-reports, never the one that hides stranded work.
|
|
604
|
+
landed = false;
|
|
605
|
+
landedBy = null;
|
|
606
|
+
if (ahead > 0) {
|
|
607
|
+
if (forgeLanded.has(h.sha)) {
|
|
608
|
+
landed = true;
|
|
609
|
+
landedBy = "forge";
|
|
610
|
+
}
|
|
611
|
+
if (!landed) {
|
|
612
|
+
try {
|
|
613
|
+
const cherry = git(["cherry", base, h.sha]);
|
|
614
|
+
landed = cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
|
|
615
|
+
if (landed)
|
|
616
|
+
landedBy = "patch";
|
|
617
|
+
}
|
|
618
|
+
catch { /* unreadable: inconclusive, which over-reports */ }
|
|
619
|
+
}
|
|
620
|
+
if (!landed) {
|
|
621
|
+
if (!squashes)
|
|
622
|
+
squashes = squashIndex(repo, base);
|
|
623
|
+
landed = landedBySquash(repo, base, h.sha, squashes);
|
|
624
|
+
if (landed)
|
|
625
|
+
landedBy = "squash";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
|
|
629
|
+
}
|
|
630
|
+
const minutes = Math.max(0, Math.round((now - Date.parse(committedAt)) / 60000));
|
|
631
|
+
pushedBranches.push({ branch: h.name, head: h.sha, ahead, landed, landedBy, minutes, hasPr });
|
|
632
|
+
// ⚠ A BRANCH HIT IS RAISED ONLY FOR A SEAT THE BUS KNOWS. Measured live
|
|
633
|
+
// after the two landedness tests: the survivors were `docs/…`, `fix/…`
|
|
634
|
+
// branches from before the seat-prefix convention — abandoned work with no
|
|
635
|
+
// owner to DM. 40+ hits every run is how a signal gets switched off. They
|
|
636
|
+
// stay VISIBLE in `pushedBranches` (ahead, not landed, no PR); the DM goes
|
|
637
|
+
// only where there is a lane to answer it. The PR half is NOT filtered:
|
|
638
|
+
// an open PR is somebody's by construction, and it is the acceptance.
|
|
639
|
+
const seat = seatOfBranch(h.name);
|
|
640
|
+
if (ahead > 0 && !landed && !hasPr && registeredSeats.has(seat) && now - Date.parse(committedAt) > limitMs) {
|
|
641
|
+
hits.push({
|
|
642
|
+
kind: "unproposed-branch", agentId: seat, branch: h.name, head: h.sha, ahead, minutes,
|
|
643
|
+
note: "STRANDED, not necessarily LOST: nobody has cited this artefact. Its owner classifies it — superseded draft (delete) · abandoned (say so) · unlanded work (open the PR).",
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Forget shas origin no longer has, so the memo cannot grow without bound.
|
|
648
|
+
const live = new Set(artefacts.remoteHeads.map((h) => h.sha));
|
|
649
|
+
for (const k of Object.keys(memo))
|
|
650
|
+
if (!live.has(k))
|
|
651
|
+
delete memo[k];
|
|
652
|
+
writeMemo(memo);
|
|
653
|
+
if (deferred > 0) {
|
|
654
|
+
unmeasurable.push({ agentId: "-", value: `${deferred} branch(es)`, why: `DEFERRED — the ${budgetMs}ms budget for uncached branch classification ran out; these are UNMEASURED this run, not clean, and the next run continues from the memo` });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return { hits: withAudience(hits), unmeasurable, openPrs, pushedBranches };
|
|
658
|
+
}
|
|
31
659
|
// ---------- halt ----------
|
|
32
660
|
export const setHaltSchema = {
|
|
33
661
|
reason: z.string().min(1),
|
|
@@ -84,6 +712,35 @@ export function markRunFailure(reason) {
|
|
|
84
712
|
history.push({ at: Date.now(), hits: 0, checked: 0, failed: reason });
|
|
85
713
|
writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
|
|
86
714
|
}
|
|
715
|
+
export function lastPopulationFor(repo) {
|
|
716
|
+
let history = [];
|
|
717
|
+
try {
|
|
718
|
+
history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? [];
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
724
|
+
const h = history[i];
|
|
725
|
+
if (!h.failed && h.population && h.population.repo === repo)
|
|
726
|
+
return { at: h.at, population: h.population };
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
/** `docs/WORKSTREAMS.md@<sha>`, with `+uncommitted` when the working copy differs from that commit. */
|
|
731
|
+
export function boardSourceOf(repo) {
|
|
732
|
+
let sha = "unknown";
|
|
733
|
+
try {
|
|
734
|
+
sha = gitOut(repo, ["log", "-1", "--format=%h", "--", BOARD_DOC]) || "uncommitted";
|
|
735
|
+
}
|
|
736
|
+
catch { /* no history */ }
|
|
737
|
+
let dirty = false;
|
|
738
|
+
try {
|
|
739
|
+
dirty = gitOut(repo, ["status", "--porcelain", "--", BOARD_DOC]).length > 0;
|
|
740
|
+
}
|
|
741
|
+
catch { /* unknown */ }
|
|
742
|
+
return `${BOARD_DOC}@${sha}${dirty ? "+uncommitted" : ""}`;
|
|
743
|
+
}
|
|
87
744
|
function markRun(result) {
|
|
88
745
|
mkdirSync(ROOT, { recursive: true });
|
|
89
746
|
let history = [];
|
|
@@ -98,7 +755,7 @@ function markRun(result) {
|
|
|
98
755
|
// reported `runs 8, failures 0` — all green — while every one of those runs
|
|
99
756
|
// had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
|
|
100
757
|
// instrument meant to cover an absence.
|
|
101
|
-
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
|
|
758
|
+
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0, ...(result.population ? { population: result.population } : {}) });
|
|
102
759
|
// A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
|
|
103
760
|
// list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
|
|
104
761
|
// are different states and only the first is a healthy fleet.
|
|
@@ -187,7 +844,106 @@ export async function stallClockStatusTool(args) {
|
|
|
187
844
|
};
|
|
188
845
|
}
|
|
189
846
|
// ---------- the predicate ----------
|
|
190
|
-
export const stallCheckSchema = {
|
|
847
|
+
export const stallCheckSchema = {
|
|
848
|
+
repo: z.string().optional(),
|
|
849
|
+
stallMinutes: z.number().optional(),
|
|
850
|
+
/** The 🔍 window, in minutes. Default REVIEW_STALL_MINUTES. Independent of stallMinutes on purpose. */
|
|
851
|
+
reviewMinutes: z.number().optional(),
|
|
852
|
+
/** ⟨q-7b2f6c04⟩ — the claim-axis window, in minutes. Default CLAIM_STALL_MINUTES. */
|
|
853
|
+
claimMinutes: z.number().optional(),
|
|
854
|
+
};
|
|
855
|
+
const gitOut = (repo, args) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
856
|
+
/** The key a row keeps while its cells are edited: its stream text. */
|
|
857
|
+
export const rowKey = (r) => r.stream.replace(/\s+/g, " ").trim();
|
|
858
|
+
/**
|
|
859
|
+
* ⛔⛆ AN `🔍 In Review` ROW IS NOT SCORED ON BRANCH ACTIVITY AT ALL (⟨q-507e80c4⟩).
|
|
860
|
+
*
|
|
861
|
+
* Measured 2026-09-14: `no-vcs-activity` fired on a review lane at 41m, again at
|
|
862
|
+
* 45m, and would have fired on every correctly-behaving review lane given an
|
|
863
|
+
* hour — a branch under review is SUPPOSED to stop moving, so that predicate
|
|
864
|
+
* reaches its guaranteed end state on every healthy lane. The stall question in
|
|
865
|
+
* review is a DIFFERENT one: how long has the row been in review.
|
|
866
|
+
*
|
|
867
|
+
* The board carries no timestamp column, but it is a git-tracked document, so
|
|
868
|
+
* the answer is in its history: walk the commits that touched the board from
|
|
869
|
+
* newest to oldest while this row (by stream key) still declares in-review; the
|
|
870
|
+
* oldest such commit is when the row ENTERED review. A row that declares review
|
|
871
|
+
* only in the working tree entered it just now (`since: "uncommitted"`).
|
|
872
|
+
*
|
|
873
|
+
* Returns `null` only when the board's history cannot be read — which is
|
|
874
|
+
* reported as unmeasurable, never as healthy.
|
|
875
|
+
*/
|
|
876
|
+
export function reviewEnteredAt(repo, key, now = Date.now()) {
|
|
877
|
+
return stateEnteredAt(repo, key, now, (row) => workStateOf(row.status) === "in-review");
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* ⟨q-7b2f6c04⟩ — when the row was CLAIMED: the oldest board commit in which it
|
|
881
|
+
* declares an in-flight state. The claim axis reads the board's history the
|
|
882
|
+
* way the review axis does, so a lane is observable from its claim, not from
|
|
883
|
+
* its first push.
|
|
884
|
+
*/
|
|
885
|
+
export function claimEnteredAt(repo, key, now = Date.now()) {
|
|
886
|
+
return stateEnteredAt(repo, key, now, (row) => coarseOf(workStateOf(row.status)) === "in-flight");
|
|
887
|
+
}
|
|
888
|
+
function stateEnteredAt(repo, key, now, matches) {
|
|
889
|
+
let log;
|
|
890
|
+
try {
|
|
891
|
+
log = gitOut(repo, ["log", "-n", "300", "--format=%H%x09%cI", "--", BOARD_DOC]);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
let since = null;
|
|
897
|
+
let commits = 0;
|
|
898
|
+
for (const line of log.split("\n").filter(Boolean)) {
|
|
899
|
+
const [sha, iso] = line.split("\t");
|
|
900
|
+
let text;
|
|
901
|
+
try {
|
|
902
|
+
text = gitOut(repo, ["show", `${sha}:${BOARD_DOC}`]);
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
const row = workstreamsV1RowsOf(parseWorkDoc(text)).find((r) => rowKey(r) === key);
|
|
908
|
+
if (!row || !matches(row))
|
|
909
|
+
break;
|
|
910
|
+
since = iso;
|
|
911
|
+
commits++;
|
|
912
|
+
}
|
|
913
|
+
if (!since)
|
|
914
|
+
return { since: "uncommitted", minutes: 0, commits: 0 };
|
|
915
|
+
return { since, minutes: Math.round((now - Date.parse(since)) / 60000), commits };
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* ⛆ THE BOARD'S OWN FRESHNESS, PER ROW — the acceptance clause the original row
|
|
919
|
+
* lacked. A predicate reading stale cells is not measuring lanes, it is
|
|
920
|
+
* measuring the board's maintenance; on 2026-09-14 one silent seat froze the
|
|
921
|
+
* board and the clock then fired on three HEALTHY lanes, and nothing in its
|
|
922
|
+
* output could tell the two apart. So every scored row now says when its line
|
|
923
|
+
* was last written, read from `git blame` on the row's own bytes.
|
|
924
|
+
*/
|
|
925
|
+
export function rowFreshness(repo, boardText, rows, now = Date.now()) {
|
|
926
|
+
const lines = boardText.split("\n");
|
|
927
|
+
return rows.map((r) => {
|
|
928
|
+
const base = { stream: r.stream.slice(0, 60), owner: r.owner.replace(/[`*]/g, "").trim(), state: workStateOf(r.status) };
|
|
929
|
+
const idx = r.raw ? lines.indexOf(r.raw) : -1;
|
|
930
|
+
if (idx < 0)
|
|
931
|
+
return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
|
|
932
|
+
try {
|
|
933
|
+
const out = gitOut(repo, ["blame", "-L", `${idx + 1},${idx + 1}`, "--porcelain", "--", BOARD_DOC]);
|
|
934
|
+
const sha = out.split(/\s/)[0] ?? "";
|
|
935
|
+
if (/^0+$/.test(sha))
|
|
936
|
+
return { ...base, lastUpdated: "uncommitted", minutesAgo: 0, commit: null };
|
|
937
|
+
const t = Number(/^committer-time (\d+)/m.exec(out)?.[1]);
|
|
938
|
+
if (!Number.isFinite(t))
|
|
939
|
+
return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: sha.slice(0, 8) };
|
|
940
|
+
return { ...base, lastUpdated: new Date(t * 1000).toISOString(), minutesAgo: Math.round((now - t * 1000) / 60000), commit: sha.slice(0, 8) };
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
191
947
|
/**
|
|
192
948
|
* A row somebody is WORKING — the population the stall clock watches.
|
|
193
949
|
*
|
|
@@ -206,27 +962,150 @@ export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.n
|
|
|
206
962
|
*
|
|
207
963
|
* NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
|
|
208
964
|
* `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
|
|
209
|
-
* would be the 0/0 defect inverted.
|
|
965
|
+
* would be the 0/0 defect inverted. They are REPORTED, though — see
|
|
966
|
+
* `notWatched` below — because a row the clock declines to watch and a row
|
|
967
|
+
* the clock cannot see produce the same silence.
|
|
210
968
|
*
|
|
211
969
|
* One predicate, exported: `coord_away` measures coverage by calling
|
|
212
970
|
* `stall_check` and reading `checked`/`measurable`, so it inherits this
|
|
213
971
|
* population without a change of its own. Anything else that asks "which rows
|
|
214
972
|
* are in flight" should ask here rather than re-derive it from a glyph.
|
|
973
|
+
*
|
|
974
|
+
* ⛔⛆ DERIVED FROM THE SEAM, NOT MATCHED ON A GLYPH (⟨q-a42503cb⟩). This was
|
|
975
|
+
* `IN_FLIGHT_STATUS = /🚧|🔍/`, and the sentence above it — "ask here rather
|
|
976
|
+
* than re-derive it from a glyph" — described a single authority that WAS a
|
|
977
|
+
* glyph regex. The board's vocabulary grew to eleven documented states
|
|
978
|
+
* (`workStateOf`, seam ⟨q-5b3e9a04⟩) and this predicate stayed at two, so
|
|
979
|
+
* every `⏸` row was invisible here AND in `next_unblocked`'s routing
|
|
980
|
+
* exclusion, which shares it: a seat that wrote `⏸ MERGE-HELD` instead of a
|
|
981
|
+
* stale `🚧` was doing the right thing and punished for it by a silent drop
|
|
982
|
+
* from both populations.
|
|
983
|
+
*
|
|
984
|
+
* The seam reads the LEADING glyph after stripping decoration, so
|
|
985
|
+
* `⛔ Blocked — was 🚧 yesterday` is blocked here and was in-flight under the
|
|
986
|
+
* regex. Measured on the live board at origin/main 687a74f (25 rows): the two
|
|
987
|
+
* predicates agree on every row, 4 in flight under each — the swap changes no
|
|
988
|
+
* verdict today; it changes what the file can SAY about the other 21.
|
|
215
989
|
*/
|
|
216
|
-
export const
|
|
217
|
-
|
|
218
|
-
export
|
|
990
|
+
export const isInFlightStatus = (status) => coarseOf(workStateOf(status)) === "in-flight";
|
|
991
|
+
/** A hold the board declares for this lane, or null. Blocker cell first (the lane's own statement), then the Cutover Gates table. */
|
|
992
|
+
export function holdOf(row, cutoverGates) {
|
|
993
|
+
const blocker = String(row.blocker ?? "").replace(/[`*]/g, "").trim();
|
|
994
|
+
if (blocker && !/^(—|-|–|none|n\/a)$/i.test(blocker))
|
|
995
|
+
return { text: blocker.slice(0, 160), by: "blocker" };
|
|
996
|
+
const ids = [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1]);
|
|
997
|
+
const prs = [...String(row.stream).matchAll(/#(\d{2,})\b/g)].map((m) => `#${m[1]}`);
|
|
998
|
+
const ref = refInCell(row.branchWorktree);
|
|
999
|
+
for (const g of cutoverGates) {
|
|
1000
|
+
const gate = String(g["Gate"] ?? Object.values(g)[0] ?? "");
|
|
1001
|
+
if (ids.some((id) => gate.includes(id)) || prs.some((p) => gate.includes(p)) || (ref && gate.includes(ref))) {
|
|
1002
|
+
return { text: gate.replace(/[`*]/g, "").trim().slice(0, 160), by: "cutover-gate" };
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* ⟨q-7e3b10c9⟩ — THE BOARD NAMES ITS OWNER. The Rooms table's owner cell says
|
|
1009
|
+
* who holds the topic — this fleet's `groundwork-kit-coordinator (topic owner)`
|
|
1010
|
+
* — so the clock can route bookkeeping hits without a bus-meta dependency.
|
|
1011
|
+
* Null when the board does not say; the clock then says so and falls back.
|
|
1012
|
+
*/
|
|
1013
|
+
export function boardOwnerOf(boardText) {
|
|
1014
|
+
const ext = workstreamsExtensionsOf(parseWorkDoc(boardText));
|
|
1015
|
+
for (const r of ext.rooms) {
|
|
1016
|
+
for (const v of Object.values(r)) {
|
|
1017
|
+
const m = /([\w.-]+-coordinator)\b/.exec(String(v));
|
|
1018
|
+
if (m)
|
|
1019
|
+
return m[1];
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
export async function stallCheckTool(args, artefacts = realArtefacts, readRooms = readAllRoomLogs, readInboxes = readAllInboxLogs) {
|
|
219
1025
|
const repo = args.repo ?? process.cwd();
|
|
220
1026
|
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
1027
|
+
const reviewLimit = args.reviewMinutes ?? REVIEW_STALL_MINUTES;
|
|
1028
|
+
const claimLimit = args.claimMinutes ?? CLAIM_STALL_MINUTES;
|
|
221
1029
|
const board = path.join(repo, BOARD_DOC);
|
|
222
1030
|
if (!existsSync(board))
|
|
223
1031
|
return { ok: false, error: `no ${BOARD_DOC} under '${repo}'` };
|
|
224
1032
|
const liveTransports = await loadLiveTransports();
|
|
225
|
-
const
|
|
226
|
-
const
|
|
1033
|
+
const boardText = readFileSync(board, "utf8");
|
|
1034
|
+
const boardDoc = parseWorkDoc(boardText);
|
|
1035
|
+
const rows = workstreamsV1RowsOf(boardDoc);
|
|
1036
|
+
// ⟨q-7e3b10c9⟩ — holds DECLARED on the board: the Cutover Gates table's Gate
|
|
1037
|
+
// cell names what is held; a lane row's Blocker cell names what it waits on.
|
|
1038
|
+
const cutoverGates = workstreamsExtensionsOf(boardDoc).cutoverGates;
|
|
1039
|
+
// ⟨q-3d82f1a9⟩ — SAY THE PARSE STATE FIRST. An unreadable board is refused
|
|
1040
|
+
// as a measurement, not answered as a quiet fleet: ok:false, dm:true, and
|
|
1041
|
+
// the counts named, so the duty officer is told the board broke rather than
|
|
1042
|
+
// shown an empty list that reads as healthy.
|
|
1043
|
+
const boardParse = boardParseOf(boardText);
|
|
1044
|
+
if (!boardParse.readable) {
|
|
1045
|
+
markRunFailure(`unparseable board — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed`);
|
|
1046
|
+
return {
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: `${BOARD_DOC} is ${boardParse.why}`,
|
|
1049
|
+
boardParse,
|
|
1050
|
+
checked: 0,
|
|
1051
|
+
measurable: 0,
|
|
1052
|
+
blind: [],
|
|
1053
|
+
hits: [],
|
|
1054
|
+
unmeasurable: [],
|
|
1055
|
+
halted: haltState().halted,
|
|
1056
|
+
dm: true,
|
|
1057
|
+
note: `UNPARSEABLE BOARD — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed. This is NOT a quiet fleet: nothing was measured. Fix the row the parser stopped at and re-run.`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
// ⟨q-5d1c8e04⟩ — LANES ARE SCORED; ROLES ARE REPORTED. A role row (🪑, branch
|
|
1061
|
+
// `—`) is a standing seat with nothing to stall; it is listed as present or
|
|
1062
|
+
// absent from the registry and live or not on its transport, and never
|
|
1063
|
+
// enters `checked`. Two roles and zero lanes read 0 of 0, not 0 of 2. A 🪑
|
|
1064
|
+
// row that carries a ref is a lane in disguise and is scored below.
|
|
227
1065
|
const reg = await readJson(AGENTS_FILE, {});
|
|
1066
|
+
const kinds = rows.map((r) => ({ row: r, kind: rowKindOf(r) }));
|
|
1067
|
+
const roles = kinds
|
|
1068
|
+
.filter((k) => k.kind === "role")
|
|
1069
|
+
.map(({ row }) => {
|
|
1070
|
+
const owner = row.owner.replace(/[`*]/g, "").trim();
|
|
1071
|
+
return { stream: row.stream.slice(0, 60), owner, present: !!reg[owner], active: liveTransports.has(owner) };
|
|
1072
|
+
});
|
|
1073
|
+
const disguised = kinds.filter((k) => k.kind === "role-with-ref").map((k) => k.row);
|
|
1074
|
+
const inFlight = [...rows.filter((r) => isInFlightStatus(r.status)), ...disguised];
|
|
1075
|
+
// THE ROWS THIS CLOCK DOES NOT WATCH, NAMED AS THEIR OWN STATE (⟨q-a42503cb⟩).
|
|
1076
|
+
// A parked row is not a stalled one, so it is not in `checked` — but a
|
|
1077
|
+
// clock that cannot name what it declined to watch reads the same as one that
|
|
1078
|
+
// never saw it. Finished rows are left out: there is nothing to watch or
|
|
1079
|
+
// route behind a `✅`. Everything else that is not in flight is listed with
|
|
1080
|
+
// the state the seam read, so a reader can tell "held pending a merge" from
|
|
1081
|
+
// "blocked" from "parked awaiting a human" without opening the board.
|
|
1082
|
+
const byState = {};
|
|
1083
|
+
for (const r of rows) {
|
|
1084
|
+
const s = workStateOf(r.status);
|
|
1085
|
+
byState[s] = (byState[s] ?? 0) + 1;
|
|
1086
|
+
}
|
|
1087
|
+
const notWatched = rows
|
|
1088
|
+
.filter((r) => {
|
|
1089
|
+
const c = coarseOf(workStateOf(r.status));
|
|
1090
|
+
return c !== "in-flight" && c !== "finished";
|
|
1091
|
+
})
|
|
1092
|
+
.map((r) => ({
|
|
1093
|
+
stream: r.stream.slice(0, 60),
|
|
1094
|
+
owner: r.owner.replace(/[`*]/g, "").trim(),
|
|
1095
|
+
state: workStateOf(r.status),
|
|
1096
|
+
status: r.status.slice(0, 80),
|
|
1097
|
+
}));
|
|
228
1098
|
const now = Date.now();
|
|
229
1099
|
const hits = [];
|
|
1100
|
+
/** The base's tip, for telling an empty claim-time push from a lane with commits. */
|
|
1101
|
+
let baseTip = null;
|
|
1102
|
+
for (const cand of ["origin/main", "origin/master", "main", "master"]) {
|
|
1103
|
+
try {
|
|
1104
|
+
baseTip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]);
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
catch { /* next */ }
|
|
1108
|
+
}
|
|
230
1109
|
/** Rows whose VCS activity could not be measured. NOT stall claims. */
|
|
231
1110
|
const unmeasurable = [];
|
|
232
1111
|
/**
|
|
@@ -239,8 +1118,60 @@ export async function stallCheckTool(args) {
|
|
|
239
1118
|
* skip and a clean pass produce the same number.
|
|
240
1119
|
*/
|
|
241
1120
|
const measured = new Set();
|
|
1121
|
+
/**
|
|
1122
|
+
* ⟨q-5d1c8e04⟩ — DELIBERATELY BRANCHLESS LANES, out of the population. The
|
|
1123
|
+
* cell holds a statement (`docs-direct · no per-agent branch`) rather than a
|
|
1124
|
+
* ref; the seat said so in words. Reported by name, subtracted from
|
|
1125
|
+
* `checked`, never "unmeasurable" — the coverage ratio stops carrying rows
|
|
1126
|
+
* that were never measurable by design. A blank or `—` on a lane is NOT
|
|
1127
|
+
* this: that lane should carry a branch and names none, and stays unmeasurable.
|
|
1128
|
+
*/
|
|
1129
|
+
const deliberate = [];
|
|
1130
|
+
/**
|
|
1131
|
+
* ⟨q-7e3b10c9⟩ — HELD, NOT STALLED AND NOT UNATTENDED. A lane ageing behind a
|
|
1132
|
+
* hold the board DECLARES — its Blocker cell names a hold, or a Cutover Gates
|
|
1133
|
+
* row names the lane — is reported as held, with the hold, and is not scored:
|
|
1134
|
+
* a frozen branch behind a PASS-HOLD is the gate working, not a seat stuck.
|
|
1135
|
+
* Measured as a verdict (coverage counts it). An UNDECLARED wait is not a
|
|
1136
|
+
* hold: the same frozen branch with an empty Blocker cell still fires.
|
|
1137
|
+
*/
|
|
1138
|
+
const held = [];
|
|
1139
|
+
const scored = [];
|
|
1140
|
+
const axes = [];
|
|
1141
|
+
const onAxis = (agentId, row, axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
|
|
1142
|
+
const offAxis = (agentId, row, why) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
|
|
1143
|
+
/** The claim axis: scored from the board's history; fires past the claim window. */
|
|
1144
|
+
const scoreClaim = (agentId, row, branch, because) => {
|
|
1145
|
+
const age = claimEnteredAt(repo, rowKey(row), now);
|
|
1146
|
+
if (!age) {
|
|
1147
|
+
unmeasurable.push({ agentId, value: BOARD_DOC, why: "the board's git history could not be read, so the claim time is unknown — unknown is not healthy" });
|
|
1148
|
+
offAxis(agentId, row, "board history unreadable");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
onAxis(agentId, row, "claim");
|
|
1152
|
+
if (age.minutes > claimLimit) {
|
|
1153
|
+
hits.push({
|
|
1154
|
+
kind: "unpushed-claim", agentId, stream: row.stream.slice(0, 60), branch, minutes: age.minutes, since: age.since,
|
|
1155
|
+
why: `claimed ${age.minutes}m ago (row in flight since ${age.since}) and ${because} — nothing from this lane has reached origin inside the ${claimLimit}m claim window. A worker thinking hard and a worker whose model has wedged look the same from here; say which.`,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
242
1159
|
for (const row of inFlight) {
|
|
243
1160
|
const agentId = row.owner.replace(/[`*]/g, "").trim();
|
|
1161
|
+
// Only a row the VCS half would score can declare itself branchless. A 🔍
|
|
1162
|
+
// row is scored on time-in-review from the board (#306), whatever its cell
|
|
1163
|
+
// says — so its prose is a note, not an exemption.
|
|
1164
|
+
if (cellKindOf(row.branchWorktree) === "prose" && workStateOf(row.status) !== "in-review") {
|
|
1165
|
+
deliberate.push({ agentId, stream: row.stream.slice(0, 60), cell: String(row.branchWorktree).trim().slice(0, 80) });
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
scored.push(row);
|
|
1169
|
+
const hold = holdOf(row, cutoverGates);
|
|
1170
|
+
if (hold) {
|
|
1171
|
+
held.push({ agentId, stream: row.stream.slice(0, 60), hold: hold.text, declaredBy: hold.by });
|
|
1172
|
+
measured.add(agentId);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
244
1175
|
const entry = reg[agentId];
|
|
245
1176
|
if (!entry) {
|
|
246
1177
|
// NOT SKIPPED IN SILENCE. An owner with no registry entry has no
|
|
@@ -359,17 +1290,59 @@ export async function stallCheckTool(args) {
|
|
|
359
1290
|
// ref. Resolution was never the property; the property is whether the ref's
|
|
360
1291
|
// movement is THIS AGENT'S WORK.
|
|
361
1292
|
const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
|
|
1293
|
+
// ⟨q-7b2f6c04⟩ — a ref sitting EXACTLY on the base's tip is the empty push
|
|
1294
|
+
// `ensure_worktree` makes at claim: zero commits of its own, so the
|
|
1295
|
+
// classifier reads it as landed and the VCS axis would read the base's
|
|
1296
|
+
// age. It is a CLAIM, and the claim axis scores it — checked before either.
|
|
1297
|
+
if ((verdict.kind === "merged" || verdict.kind === "measurable") && baseTip) {
|
|
1298
|
+
const cellRef = refInCell(row.branchWorktree).replace(/^origin\//, "");
|
|
1299
|
+
let tip = null;
|
|
1300
|
+
try {
|
|
1301
|
+
tip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `origin/${cellRef}^{commit}`]);
|
|
1302
|
+
}
|
|
1303
|
+
catch { /* not on origin */ }
|
|
1304
|
+
if (tip && tip === baseTip) {
|
|
1305
|
+
scoreClaim(agentId, row, `origin/${cellRef}`, "its branch sits on the base with no commits of its own (the empty claim-time push)");
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
362
1309
|
if (verdict.kind === "merged") {
|
|
363
1310
|
// A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
|
|
364
1311
|
// did learn something about this lane: its work landed and its row is
|
|
365
1312
|
// stale. Reported as its own kind rather than as a stall, which is what
|
|
366
1313
|
// the frozen ref was reporting it as.
|
|
367
1314
|
hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
|
|
368
|
-
|
|
1315
|
+
onAxis(agentId, row, "landed");
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (workStateOf(row.status) === "in-review") {
|
|
1319
|
+
// ⛔ NOT THE VCS PREDICATE. A review-frozen branch is frozen for the
|
|
1320
|
+
// correct reason; scoring it on activity fires on every healthy review
|
|
1321
|
+
// lane eventually. The question here is how long the row has been in
|
|
1322
|
+
// review, read from the board's own history — which also means a 🔍 row
|
|
1323
|
+
// whose cell holds a path is MEASURED here rather than unmeasurable: the
|
|
1324
|
+
// predicate needs the board, not the ref.
|
|
1325
|
+
const age = reviewEnteredAt(repo, rowKey(row), now);
|
|
1326
|
+
if (!age) {
|
|
1327
|
+
unmeasurable.push({ agentId, value: BOARD_DOC, why: "the board's git history could not be read, so time in review is unknown — unknown is not healthy" });
|
|
1328
|
+
offAxis(agentId, row, "board history unreadable");
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
onAxis(agentId, row, "review");
|
|
1332
|
+
if (age.minutes > reviewLimit) {
|
|
1333
|
+
hits.push({ kind: "in-review-too-long", agentId, stream: row.stream.slice(0, 60), minutes: age.minutes, since: age.since });
|
|
1334
|
+
}
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
// ⟨q-7b2f6c04⟩ — a branch with nothing on origin is not unmeasurable: it is
|
|
1338
|
+
// a lane between claim and first push, and the CLAIM axis measures it.
|
|
1339
|
+
if (verdict.kind === "unpushed" || verdict.kind === "local-only") {
|
|
1340
|
+
scoreClaim(agentId, row, refInCell(row.branchWorktree), `its branch is not on origin (${verdict.kind})`);
|
|
369
1341
|
continue;
|
|
370
1342
|
}
|
|
371
1343
|
if (verdict.kind !== "measurable") {
|
|
372
1344
|
unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
|
|
1345
|
+
offAxis(agentId, row, verdict.why);
|
|
373
1346
|
continue;
|
|
374
1347
|
}
|
|
375
1348
|
try {
|
|
@@ -382,24 +1355,128 @@ export async function stallCheckTool(args) {
|
|
|
382
1355
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
383
1356
|
}).trim();
|
|
384
1357
|
const age = now - Date.parse(iso);
|
|
385
|
-
|
|
1358
|
+
onAxis(agentId, row, "vcs");
|
|
386
1359
|
if (age > limit) {
|
|
387
1360
|
hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
|
|
388
1361
|
}
|
|
389
1362
|
}
|
|
390
1363
|
catch {
|
|
391
1364
|
unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
|
|
1365
|
+
offAxis(agentId, row, "resolved as a ref but its log could not be read");
|
|
392
1366
|
}
|
|
393
1367
|
}
|
|
394
1368
|
// COVERAGE, not "did it run". A row is COVERED when a predicate produced a
|
|
395
1369
|
// verdict about it; a row where every predicate came back unmeasurable was
|
|
396
1370
|
// looked at and not measured, and counting it as checked is how "the clock
|
|
397
1371
|
// ran" gets mistaken for "the fleet is observed".
|
|
398
|
-
const owners =
|
|
1372
|
+
const owners = scored.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
|
|
399
1373
|
const blind = [...new Set(owners)].filter((id) => !measured.has(id));
|
|
400
|
-
const measurable = Math.max(0,
|
|
401
|
-
|
|
402
|
-
|
|
1374
|
+
const measurable = Math.max(0, scored.length - blind.length);
|
|
1375
|
+
// ⟨q-d0e83b41⟩ (09-14 amend) — the population's source and its delta since
|
|
1376
|
+
// the last run of THIS repo. A lane that left with no closing state on the
|
|
1377
|
+
// board (its row is gone) is a board-owner hit, never an improved ratio.
|
|
1378
|
+
const runPopulation = {
|
|
1379
|
+
repo, source: boardSourceOf(repo),
|
|
1380
|
+
scored: scored.map(rowKey), roles: roles.map((r) => r.stream), deliberate: deliberate.map((d) => d.stream), held: held.map((h) => h.stream),
|
|
1381
|
+
};
|
|
1382
|
+
const prev = lastPopulationFor(repo);
|
|
1383
|
+
const ownerOf = (key) => (rows.find((r) => rowKey(r) === key)?.owner ?? "").replace(/[`*]/g, "").trim();
|
|
1384
|
+
const whereNow = (key) => {
|
|
1385
|
+
const row = rows.find((r) => rowKey(r) === key);
|
|
1386
|
+
if (!row)
|
|
1387
|
+
return "gone";
|
|
1388
|
+
if (runPopulation.held.includes(row.stream.slice(0, 60)))
|
|
1389
|
+
return "held";
|
|
1390
|
+
if (runPopulation.deliberate.includes(row.stream.slice(0, 60)))
|
|
1391
|
+
return "deliberate";
|
|
1392
|
+
return workStateOf(row.status);
|
|
1393
|
+
};
|
|
1394
|
+
const delta = prev
|
|
1395
|
+
? {
|
|
1396
|
+
since: new Date(prev.at).toISOString(),
|
|
1397
|
+
left: prev.population.scored.filter((k) => !runPopulation.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), to: whereNow(k) })),
|
|
1398
|
+
joined: runPopulation.scored.filter((k) => !prev.population.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), owner: ownerOf(k) })),
|
|
1399
|
+
}
|
|
1400
|
+
: { since: null, left: [], joined: [] };
|
|
1401
|
+
for (const l of delta.left) {
|
|
1402
|
+
if (l.to !== "gone")
|
|
1403
|
+
continue;
|
|
1404
|
+
hits.push({
|
|
1405
|
+
kind: "lane-left-population", agentId: boardOwnerOf(boardText) ?? "-", stream: l.stream,
|
|
1406
|
+
why: `"${l.stream}" was in the scored population at the last run (${delta.since}) and its row is GONE from ${runPopulation.source} with no closing state — the coverage ratio would otherwise read as improved. A lane leaves the board through a state (✅, ⏸, ⛔), never by deletion.`,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
// THE BOARD'S OWN FRESHNESS BESIDE EVERY VERDICT. A stall report over stale
|
|
1410
|
+
// cells is a report about the board's maintenance, and the reader must be
|
|
1411
|
+
// able to see which it is without opening git.
|
|
1412
|
+
const freshness = rowFreshness(repo, boardText, scored, now);
|
|
1413
|
+
const aged = freshness.map((f) => f.minutesAgo).filter((m) => typeof m === "number");
|
|
1414
|
+
// ⟨q-6f0a3d81⟩ — THE AXIS THAT NEEDS NOBODY TO SPEAK. Computed over the
|
|
1415
|
+
// repository, not the board: a stranded PR has no row, which is the point.
|
|
1416
|
+
// `checked`/`measurable` stay the lane population `coord_away` reads; these
|
|
1417
|
+
// hits join `hits` so the duty officer is DMed. Its unreadable SOURCES are
|
|
1418
|
+
// reported under its own key, not in the per-row `unmeasurable` list: that
|
|
1419
|
+
// list is a population of lane rows (a test pins "two halves of one row"),
|
|
1420
|
+
// and an unreachable gh is not a row. Separate populations, separate keys.
|
|
1421
|
+
const arte = artefacts(repo);
|
|
1422
|
+
const roomText = readRooms();
|
|
1423
|
+
const stranded = strandedWork(repo, arte, roomText, now, limit, new Set(Object.keys(reg)));
|
|
1424
|
+
hits.push(...stranded.hits);
|
|
1425
|
+
// ⟨q-8f1e604b⟩ — the two conventions, checked from the artefacts: PR pages
|
|
1426
|
+
// for (e), the bus's own routing records against the board and queue for (f).
|
|
1427
|
+
const queuePath = path.join(repo, "docs/QUEUE.md");
|
|
1428
|
+
const conventions = conventionChecks({
|
|
1429
|
+
recentMerges: arte.recentMerges,
|
|
1430
|
+
routingLogText: `${roomText}\n${readInboxes()}`,
|
|
1431
|
+
queueText: existsSync(queuePath) ? readFileSync(queuePath, "utf8") : null,
|
|
1432
|
+
boardRows: rows,
|
|
1433
|
+
now,
|
|
1434
|
+
});
|
|
1435
|
+
hits.push(...conventions.hits);
|
|
1436
|
+
// ⟨q-4e08b3c1⟩ — convention (g): the bus's windows joined to git's write times.
|
|
1437
|
+
const mergeWindows = mergeWindowChecks({ logText: roomText, writes: recordWritesOn(repo, now - CONVENTION_WINDOW_MS), now });
|
|
1438
|
+
hits.push(...mergeWindows.hits);
|
|
1439
|
+
const stamped = withAudience(hits);
|
|
1440
|
+
const dmBy = { duty: stamped.filter((h) => h.audience === "duty").length, "board-owner": stamped.filter((h) => h.audience === "board-owner").length };
|
|
1441
|
+
const result = {
|
|
1442
|
+
hits: stamped,
|
|
1443
|
+
// ⟨q-7e3b10c9⟩ — who each hit is for, counted, so a relayer can split.
|
|
1444
|
+
dmBy,
|
|
1445
|
+
held,
|
|
1446
|
+
// ⟨q-3d82f1a9⟩ — the parse state travels with every answer, readable or not.
|
|
1447
|
+
boardParse,
|
|
1448
|
+
// The population the clock SCORES: lanes with a ref position to read. Roles
|
|
1449
|
+
// and deliberately branchless lanes are reported beside it, not inside it.
|
|
1450
|
+
checked: scored.length,
|
|
1451
|
+
unmeasurable,
|
|
1452
|
+
measurable,
|
|
1453
|
+
blind,
|
|
1454
|
+
roles,
|
|
1455
|
+
deliberate,
|
|
1456
|
+
disguisedRoles: disguised.map((r) => r.stream.slice(0, 60)),
|
|
1457
|
+
predicates: {
|
|
1458
|
+
"in-progress": `heartbeat (where a source exists) + branch activity, window ${Math.round(limit / 60000)}m`,
|
|
1459
|
+
"in-review": `time in review from the board's git history, window ${reviewLimit}m — branch activity is NOT scored`,
|
|
1460
|
+
claimed: `time since the row entered in-flight, from the board's git history, for a lane whose branch has nothing on origin (unpushed, local-only, or pushed empty at claim); window ${claimLimit}m`,
|
|
1461
|
+
},
|
|
1462
|
+
boardFreshness: {
|
|
1463
|
+
rows: freshness,
|
|
1464
|
+
stalestMinutes: aged.length ? Math.max(...aged) : null,
|
|
1465
|
+
uncommitted: freshness.filter((f) => f.lastUpdated === "uncommitted").length,
|
|
1466
|
+
note: "when each scored row's line was last written (git blame). A stall over a row nobody has updated for hours may be the board's maintenance, not the lane.",
|
|
1467
|
+
},
|
|
1468
|
+
// Population beside every count: `checked` is the in-flight rows; this is
|
|
1469
|
+
// the rest of the board by the state the seam read, and the not-in-flight,
|
|
1470
|
+
// not-finished rows by name.
|
|
1471
|
+
population: { rows: rows.length, inFlight: inFlight.length, byState, source: runPopulation.source, delta },
|
|
1472
|
+
notWatched,
|
|
1473
|
+
// ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
|
|
1474
|
+
axes,
|
|
1475
|
+
stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
|
|
1476
|
+
conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
|
|
1477
|
+
mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
|
|
1478
|
+
};
|
|
1479
|
+
markRun({ hits: stamped, checked: scored.length, measurable, population: runPopulation });
|
|
403
1480
|
return {
|
|
404
1481
|
ok: true,
|
|
405
1482
|
...result,
|
|
@@ -408,7 +1485,7 @@ export async function stallCheckTool(args) {
|
|
|
408
1485
|
// never silent to the record, which markRun just wrote.
|
|
409
1486
|
dm: hits.length > 0,
|
|
410
1487
|
note: hits.length === 0
|
|
411
|
-
? `MISS — ${
|
|
1488
|
+
? `MISS — ${scored.length} lane(s) scored${held.length ? `, ${held.length} held behind a declared hold` : ""}${roles.length ? `, ${roles.length} role(s) present and not scored` : ""}${deliberate.length ? `, ${deliberate.length} deliberately branchless lane(s) out of population` : ""}, none stalled${unmeasurable.length ? `; ${unmeasurable.length} row(s) UNMEASURABLE for VCS activity (${unmeasurable.map((u) => u.agentId).join(", ")}) — reported, not counted as healthy` : ""}. No DM. The run IS recorded: read it with stall_clock_status, because no alert and nothing running look identical from here.`
|
|
412
1489
|
: undefined,
|
|
413
1490
|
};
|
|
414
1491
|
}
|