agent-coord-mcp 0.26.21 → 0.26.23
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 +270 -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 +40 -4
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +72 -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 +310 -44
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +67 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +182 -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 +71 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/worktrees.js +14 -0
- package/dist/tools/worktrees.js.map +1 -1
- package/dist/transports/herdr.js +297 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.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 +284 -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 +19 -2
- package/src/tools/events.ts +42 -4
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +71 -6
- package/src/tools/record-events.ts +78 -5
- package/src/tools/records.ts +316 -44
- package/src/tools/registry.ts +68 -4
- package/src/tools/seat-build.ts +201 -0
- package/src/tools/shared.ts +22 -0
- package/src/tools/stall.ts +1266 -23
- package/src/tools/transport.ts +69 -2
- package/src/tools/worktrees.ts +13 -0
- package/src/transports/herdr.ts +311 -0
- package/src/transports/index.ts +10 -4
package/src/tools/stall.ts
CHANGED
|
@@ -16,24 +16,146 @@
|
|
|
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, type WorkState, type WorkstreamsV1Row } 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, type Closing } from "../closing-line.js";
|
|
27
29
|
|
|
28
30
|
const BOARD_DOC = "docs/WORKSTREAMS.md";
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* ⟨q-3d82f1a9⟩ — A BOARD THAT FAILS TO PARSE RENDERS AS A QUIET FLEET.
|
|
34
|
+
*
|
|
35
|
+
* Measured live 2026-09-14: ad80825 rewrote line 27 — the FIRST row of the
|
|
36
|
+
* table — from 6 columns to 5. The seam ended the workstreams.v1 block at
|
|
37
|
+
* that line and recorded NO malformed row (rows:1, malformed:0), so the other
|
|
38
|
+
* 24 rows fell out of every count, and stall_check answered checked:0 with an
|
|
39
|
+
* EMPTY unmeasurable list and an EMPTY blind list: the healthiest reading the
|
|
40
|
+
* instrument can produce, while two seats were working. Every other
|
|
41
|
+
* degradation here pushes an `unmeasurable` entry; an unreadable board pushes
|
|
42
|
+
* nothing, because there is no row to attach the complaint to.
|
|
43
|
+
*
|
|
44
|
+
* So the parse state is a STATEMENT in this verb's own output, on its own key —
|
|
45
|
+
* never an inference a careful reader must know to draw from the role count.
|
|
46
|
+
* The discriminator is TEXT-LEVEL because the seam's is not: rows PRESENT in
|
|
47
|
+
* the Active Streams table (pipe-lines after the header and separator) versus
|
|
48
|
+
* rows the v1 block PARSED, with the block's own malformed rows counted so a
|
|
49
|
+
* refused-but-recorded row is not double-charged. `unparsed > 0` means rows
|
|
50
|
+
* exist that no record describes. A genuinely EMPTY board — header, separator,
|
|
51
|
+
* nothing — is 0 present / 0 parsed and reads as readable-empty, not suspect.
|
|
52
|
+
*/
|
|
53
|
+
export type BoardParse = {
|
|
54
|
+
section: "Active Streams";
|
|
55
|
+
headerFound: boolean;
|
|
56
|
+
rowsPresent: number;
|
|
57
|
+
rowsParsed: number;
|
|
58
|
+
malformed: number;
|
|
59
|
+
unparsed: number;
|
|
60
|
+
readable: boolean;
|
|
61
|
+
why: string;
|
|
62
|
+
};
|
|
63
|
+
export function boardParseOf(text: string): BoardParse {
|
|
64
|
+
const lines = String(text ?? "").split("\n");
|
|
65
|
+
const header = lines.findIndex((l) => /^\|\s*Stream\s*\|/i.test(l));
|
|
66
|
+
const doc = parseWorkDoc(String(text ?? ""));
|
|
67
|
+
const block = doc.blocks.find((b) => b.kind === "board" && (b as { schema?: string }).schema === "workstreams.v1") as
|
|
68
|
+
| { rows?: unknown[] }
|
|
69
|
+
| undefined;
|
|
70
|
+
const blockRows = (block?.rows ?? []) as Parameters<typeof isMalformedRow>[0][];
|
|
71
|
+
const rowsParsed = workstreamsV1RowsOf(doc).length;
|
|
72
|
+
const malformed = blockRows.filter((r) => isMalformedRow(r)).length;
|
|
73
|
+
if (header === -1) {
|
|
74
|
+
const pipeLines = lines.filter((l) => /^\s*\|/.test(l)).length;
|
|
75
|
+
const readable = pipeLines === 0 && rowsParsed === 0;
|
|
76
|
+
return {
|
|
77
|
+
section: "Active Streams", headerFound: false, rowsPresent: pipeLines, rowsParsed, malformed, unparsed: Math.max(0, pipeLines - rowsParsed - malformed), readable,
|
|
78
|
+
why: readable
|
|
79
|
+
? "no Active Streams table in the file and no table-shaped lines — an empty board, readable"
|
|
80
|
+
: `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`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
let i = header + 1;
|
|
84
|
+
if (i < lines.length && /^\s*\|\s*:?-+/.test(lines[i]!)) i++; // the alignment row is scaffold, not a row
|
|
85
|
+
let rowsPresent = 0;
|
|
86
|
+
for (; i < lines.length && /^\s*\|/.test(lines[i]!); i++) rowsPresent++;
|
|
87
|
+
const unparsed = Math.max(0, rowsPresent - rowsParsed - malformed);
|
|
88
|
+
// A MALFORMED ROW IS UNREADABLE TOO. Measured while writing the control: a
|
|
89
|
+
// short row that is not the table's first line is recorded by the seam as
|
|
90
|
+
// malformed (refused, kept verbatim) rather than ending the block — and a
|
|
91
|
+
// refused lane is exactly as invisible to this clock as a dropped one. Both
|
|
92
|
+
// conjuncts are required; the ad80825 shape trips the first, the mid-table
|
|
93
|
+
// shape trips the second.
|
|
94
|
+
const readable = unparsed === 0 && malformed === 0;
|
|
95
|
+
return {
|
|
96
|
+
section: "Active Streams", headerFound: true, rowsPresent, rowsParsed, malformed, unparsed, readable,
|
|
97
|
+
why: readable
|
|
98
|
+
? rowsPresent === 0
|
|
99
|
+
? "the Active Streams table is present and EMPTY — 0 rows present, 0 parsed; readable"
|
|
100
|
+
: `${rowsPresent} row(s) present, ${rowsParsed} parsed — every row is accounted for`
|
|
101
|
+
: `UNPARSEABLE — ${rowsPresent} row(s) present in the Active Streams table but only ${rowsParsed} parsed` +
|
|
102
|
+
`${malformed ? `; ${malformed} row(s) REFUSED by the parser as malformed (wrong column count) and therefore invisible to this clock` : ""}` +
|
|
103
|
+
`${unparsed ? `; ${unparsed} row(s) exist that no record describes — one bad row ends the table for the parser and nothing below it is measured` : ""}. ` +
|
|
104
|
+
`"Nothing measured" is not "nothing wrong".`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
29
107
|
const STALL_MS = 30 * 60 * 1000;
|
|
108
|
+
/**
|
|
109
|
+
* How long a lane may sit in review before it is a HIT. Four times the
|
|
110
|
+
* authoring window, not equal to it: a review is a queue on another seat, and
|
|
111
|
+
* the number that matters is how long that queue has held this row — it is not
|
|
112
|
+
* a tuned threshold on branch activity, which a review-frozen branch fails by
|
|
113
|
+
* design (⟨q-507e80c4⟩).
|
|
114
|
+
*/
|
|
115
|
+
const REVIEW_STALL_MINUTES = 120;
|
|
116
|
+
/** ⟨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. */
|
|
117
|
+
export const CLAIM_STALL_MINUTES = 120;
|
|
30
118
|
|
|
31
119
|
const runFile = () => path.join(ROOT, "stall-check.json");
|
|
32
120
|
const haltFile = () => path.join(ROOT, "halt.json");
|
|
33
121
|
|
|
34
|
-
|
|
122
|
+
/*
|
|
123
|
+
* ⟨q-7e3b10c9⟩ — EVERY HIT CARRIES ITS AUDIENCE. Measured 2026-09-11: the
|
|
124
|
+
* clock's first alarm of the day was a `stale-row` — a squash-landed branch
|
|
125
|
+
* under a 🚧 row — and the WORKER was told about a lane it had finished. The
|
|
126
|
+
* predicate had already said "not a stall, a stale row"; the delivery ignored
|
|
127
|
+
* it. A stall alarm that cries on finished lanes is disabled by its third
|
|
128
|
+
* firing. So the hit says who it is FOR, and every relayer — the clock script,
|
|
129
|
+
* a seat forwarding `dm:true` — routes on that rather than on `agentId`:
|
|
130
|
+
* duty the lane's owner may be stuck — no heartbeat, no commits, too
|
|
131
|
+
* long in review, an open PR nobody verdicted, a pushed branch
|
|
132
|
+
* nobody proposed. Somebody must look at the WORK.
|
|
133
|
+
* board-owner bookkeeping: a row pointing at a landed branch, a routed item
|
|
134
|
+
* with no row, a merge with no verdict comment. Somebody must
|
|
135
|
+
* fix the RECORD; the lane's owner did nothing wrong.
|
|
136
|
+
*/
|
|
137
|
+
export type HitAudience = "duty" | "board-owner";
|
|
138
|
+
export const audienceOf = (kind: StallHitBody["kind"]): HitAudience =>
|
|
139
|
+
kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population" ? "board-owner" : "duty";
|
|
140
|
+
const withAudience = (hits: StallHitBody[]): StallHit[] => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }) as StallHit);
|
|
141
|
+
export type StallHit = StallHitBody & { audience: HitAudience };
|
|
142
|
+
export type StallHitBody =
|
|
35
143
|
| { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
|
|
36
144
|
| { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
|
|
145
|
+
/**
|
|
146
|
+
* ⟨q-7b2f6c04⟩ — THE CLAIM AXIS: a lane claimed (its row entered in-flight,
|
|
147
|
+
* read from the board's git history) whose branch has nothing on origin —
|
|
148
|
+
* not pushed, or pushed empty at claim — for longer than the claim window.
|
|
149
|
+
* The window between `claim` and the first push used to be UNMEASURABLE by
|
|
150
|
+
* construction; a 35-hour fleet-wide stop read as healthy inside it.
|
|
151
|
+
*/
|
|
152
|
+
| { kind: "unpushed-claim"; agentId: string; stream: string; branch: string; minutes: number; since: string; why: string }
|
|
153
|
+
/**
|
|
154
|
+
* ⟨q-d0e83b41⟩ (09-14 amend) — a lane LEFT the scored population since the
|
|
155
|
+
* last run with no closing state on the board: its row is gone. Without this
|
|
156
|
+
* a seat leaving the denominator reads as an IMPROVED ratio.
|
|
157
|
+
*/
|
|
158
|
+
| { kind: "lane-left-population"; agentId: string; stream: string; why: string }
|
|
37
159
|
/**
|
|
38
160
|
* A 🚧 row whose branch HAS ALREADY LANDED. Not a stall — the opposite: the
|
|
39
161
|
* work finished and nobody closed the row.
|
|
@@ -45,7 +167,672 @@ export type StallHit =
|
|
|
45
167
|
* stall honest would have silently removed the signal, so the signal gets its
|
|
46
168
|
* own name instead of inheriting a wrong one.
|
|
47
169
|
*/
|
|
48
|
-
| { kind: "stale-row"; agentId: string; branch: string; why: string }
|
|
170
|
+
| { kind: "stale-row"; agentId: string; branch: string; why: string }
|
|
171
|
+
/**
|
|
172
|
+
* A 🔍 row that has DECLARED itself in review for longer than the review
|
|
173
|
+
* window. Measured from the board's own git history — the commit that first
|
|
174
|
+
* (contiguously) put this row in review — never from the branch, which is
|
|
175
|
+
* frozen for the correct reason while a gate reads it.
|
|
176
|
+
*/
|
|
177
|
+
| { kind: "in-review-too-long"; agentId: string; stream: string; minutes: number; since: string }
|
|
178
|
+
/**
|
|
179
|
+
* ⟨q-6f0a3d81⟩ — A REPOSITORY FACT, NOT A BUS EMISSION. An open PR whose head
|
|
180
|
+
* sha has no verdict record in any room log, older than the stall limit. It
|
|
181
|
+
* needs no board row, no DONE:, no heartbeat: it is read from `gh` and the
|
|
182
|
+
* log, so it fires when every seat stays silent — which is the one case every
|
|
183
|
+
* other signal here is blind to by construction.
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* ⟨q-dcbaf544⟩ — `why` splits the one predicate (open PR, no typed verdict at
|
|
187
|
+
* its head) into its two causes: NOBODY gated it, or someone whose role cannot
|
|
188
|
+
* emit `verdict` reported a typed gate line for this head and no gate-runner
|
|
189
|
+
* scribed it. The second fires without waiting: the claim exists, the record
|
|
190
|
+
* does not, and every minute it stands the audit trail is wrong, not late.
|
|
191
|
+
*/
|
|
192
|
+
| { kind: "unverdicted-pr"; agentId: string; pr: number; head: string; branch: string; minutes: number; why: "nobody-gated" | "reported-unscribed"; claimedBy?: string; claimedAt?: string; claimedResult?: string }
|
|
193
|
+
/**
|
|
194
|
+
* ⟨q-8f1e604b⟩ — CONVENTION (e) AS A CHECK: a PR merged within the window whose
|
|
195
|
+
* page carries NO verdict comment bound to the head that merged. #299 is the
|
|
196
|
+
* live instance (David's own merge, no verdict). A comment that merely says
|
|
197
|
+
* PASS or FAIL in prose is not a verdict — the aide measured that a substring
|
|
198
|
+
* scan would have counted #299's "not a verdict" review.
|
|
199
|
+
*/
|
|
200
|
+
| { kind: "unverdicted-merge"; pr: number; head: string; mergedAt: string; minutes: number; why: string }
|
|
201
|
+
/** ⟨q-fee7239f⟩ — a merge closing on the bus asserting a deletion in neither accepted form, after the floor. The send path refuses new ones; this lists what got through elsewhere. */
|
|
202
|
+
| { kind: "unread-delete-claim"; agentId: string; pr: number | null; closedAt: string; minutes: number; why: string }
|
|
203
|
+
/**
|
|
204
|
+
* ⟨q-8f1e604b⟩ — CONVENTION (f) AS A CHECK: an item ROUTED by a GO on the bus
|
|
205
|
+
* (named on the GO line, not merely mentioned) that is still open in the queue
|
|
206
|
+
* and has no board row naming it as its subject. `next_unblocked` can offer
|
|
207
|
+
* such an item again; the coordinator's cold free-set derivation nearly did.
|
|
208
|
+
*/
|
|
209
|
+
| { kind: "routed-without-row"; itemId: string; routedBy: string; routedAt: string; minutes: number; why: string }
|
|
210
|
+
/**
|
|
211
|
+
* ⟨q-4e08b3c1⟩ — CONVENTION (g) AS A CHECK: a commit touching a record
|
|
212
|
+
* document, authored INSIDE an open MERGE WINDOW. The window is read from
|
|
213
|
+
* the bus (qa's typed lines), the write from git — the one convention whose
|
|
214
|
+
* data lives in two places at once, which is why it had no check.
|
|
215
|
+
*/
|
|
216
|
+
| { kind: "merge-window-write"; pr: number; sha: string; author: string; authoredAt: string; secondsIntoWindow: number; why: string }
|
|
217
|
+
/**
|
|
218
|
+
* A pushed branch ahead of main with no open PR, older than the limit. Same
|
|
219
|
+
* class, one step earlier. ⚠ STRANDED IS NOT LOST: the first two live hits
|
|
220
|
+
* were superseded drafts of a file that landed under other PRs (measured by
|
|
221
|
+
* the aide, same seven tests on all three). The axis can see that nobody
|
|
222
|
+
* cited the artefact; only its owner can say whether it is superseded,
|
|
223
|
+
* abandoned or genuinely unlanded — so the hit SAYS so, or a true alarm
|
|
224
|
+
* with a false implication teaches the reader to ignore the axis.
|
|
225
|
+
*/
|
|
226
|
+
| { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string };
|
|
227
|
+
|
|
228
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
* ⟨q-6f0a3d81⟩ — THE HANDOVER IS THE THING BEING MEASURED, AND EVERY INSTRUMENT
|
|
230
|
+
* KEYED ON AN ARTEFACT PRODUCED *BY* A HANDOVER. A cited DONE: proves work
|
|
231
|
+
* ARRIVED; its absence proves nothing. Measured 2026-09-11: #244 sat open,
|
|
232
|
+
* green and never cited, and stall_check (lane rows), QA's queue (cited
|
|
233
|
+
* arrivals) and the board (a row nobody updated) each said "nothing wrong" —
|
|
234
|
+
* each correctly. Three instruments, one blind spot, because all three begin
|
|
235
|
+
* "when someone reports…".
|
|
236
|
+
*
|
|
237
|
+
* So this axis reads the ARTEFACT: what `gh` says is open, what origin says is
|
|
238
|
+
* pushed, and whether the bus holds a verdict bound to that head. No seat has
|
|
239
|
+
* to speak for it to fire. Injected, like `rotate`'s facts and `merge`'s PR
|
|
240
|
+
* facts, so the negative control the item demands — fires with every seat
|
|
241
|
+
* silent — is provable offline.
|
|
242
|
+
*
|
|
243
|
+
* ⛔ AND "COULD NOT READ" IS NEVER "NOTHING STRANDED": an unreachable `gh` or
|
|
244
|
+
* origin lands on `unmeasurable`, named, exactly as an unreadable branch does.
|
|
245
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
246
|
+
export type OpenPrFact = { n: number; headRefOid: string; headRefName: string; updatedAt: string };
|
|
247
|
+
export type RepoArtefacts = {
|
|
248
|
+
/** `gh pr list --state open`, or null when gh could not answer. */
|
|
249
|
+
openPrs: OpenPrFact[] | null;
|
|
250
|
+
/** `git ls-remote --heads origin`, or null when origin could not be read. */
|
|
251
|
+
remoteHeads: { name: string; sha: string }[] | null;
|
|
252
|
+
/**
|
|
253
|
+
* `gh pr list --state merged` — the FORGE's record of what landed, rank 1 in
|
|
254
|
+
* docs/LANDEDNESS.md. A branch whose tip is a merged PR's head is landed,
|
|
255
|
+
* whatever ancestry or patch ids say. Null when gh could not answer.
|
|
256
|
+
*/
|
|
257
|
+
mergedPrs?: { headRefName: string; headRefOid: string }[] | null;
|
|
258
|
+
/**
|
|
259
|
+
* ⟨q-8f1e604b⟩ — the RECENT merges with their PR comments, so convention (e)
|
|
260
|
+
* can be checked: `gh pr list --state merged --limit 50 --json number,headRefOid,mergedAt,comments`.
|
|
261
|
+
* Null when gh could not answer.
|
|
262
|
+
*/
|
|
263
|
+
recentMerges?: RecentMerge[] | null;
|
|
264
|
+
};
|
|
265
|
+
export type RecentMerge = { n: number; headRefOid: string; mergedAt: string; comments: { body: string }[] };
|
|
266
|
+
const NET_TIMEOUT_MS = 15_000;
|
|
267
|
+
export const realArtefacts = (repo: string): RepoArtefacts => {
|
|
268
|
+
let openPrs: OpenPrFact[] | null = null;
|
|
269
|
+
try {
|
|
270
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid,headRefName,updatedAt"], {
|
|
271
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
272
|
+
});
|
|
273
|
+
openPrs = (JSON.parse(out) as { number: number; headRefOid: string; headRefName: string; updatedAt: string }[]).map((p) => ({
|
|
274
|
+
n: p.number, headRefOid: p.headRefOid, headRefName: p.headRefName, updatedAt: p.updatedAt,
|
|
275
|
+
}));
|
|
276
|
+
} catch { /* reported as unmeasurable by the caller */ }
|
|
277
|
+
let mergedPrs: { headRefName: string; headRefOid: string }[] | null = null;
|
|
278
|
+
try {
|
|
279
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "1000", "--json", "headRefName,headRefOid"], {
|
|
280
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
281
|
+
});
|
|
282
|
+
mergedPrs = JSON.parse(out) as { headRefName: string; headRefOid: string }[];
|
|
283
|
+
} catch { /* landedness falls back to patch ids; said so by the caller */ }
|
|
284
|
+
let recentMerges: RecentMerge[] | null = null;
|
|
285
|
+
try {
|
|
286
|
+
const out = execFileSync("gh", ["pr", "list", "--state", "merged", "--limit", "50", "--json", "number,headRefOid,mergedAt,comments"], {
|
|
287
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
|
|
288
|
+
});
|
|
289
|
+
recentMerges = (JSON.parse(out) as { number: number; headRefOid: string; mergedAt: string; comments?: { body?: string }[] }[]).map((p) => ({
|
|
290
|
+
n: p.number, headRefOid: p.headRefOid, mergedAt: p.mergedAt, comments: (p.comments ?? []).map((c) => ({ body: String(c.body ?? "") })),
|
|
291
|
+
}));
|
|
292
|
+
} catch { /* said by the caller */ }
|
|
293
|
+
let remoteHeads: { name: string; sha: string }[] | null = null;
|
|
294
|
+
try {
|
|
295
|
+
const out = execFileSync("git", ["ls-remote", "--heads", "origin"], {
|
|
296
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: NET_TIMEOUT_MS,
|
|
297
|
+
});
|
|
298
|
+
remoteHeads = out.split("\n").filter(Boolean).map((l) => {
|
|
299
|
+
const [sha, ref] = l.split(/\s+/);
|
|
300
|
+
return { sha: sha ?? "", name: (ref ?? "").replace(/^refs\/heads\//, "") };
|
|
301
|
+
}).filter((h) => h.sha && h.name);
|
|
302
|
+
} catch { /* reported as unmeasurable by the caller */ }
|
|
303
|
+
return { openPrs, remoteHeads, mergedPrs, recentMerges };
|
|
304
|
+
};
|
|
305
|
+
/** Every inbox on this bus, concatenated — a GO delivered by DM is a routing record too. */
|
|
306
|
+
export const readAllInboxLogs = (): string => {
|
|
307
|
+
const dir = path.join(ROOT, "inbox");
|
|
308
|
+
try {
|
|
309
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
|
|
310
|
+
} catch {
|
|
311
|
+
return "";
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
* ⟨q-8f1e604b⟩ — TWO RECORD-KEEPING CONVENTIONS BECOME CHECKS. Both were
|
|
317
|
+
* adopted on 2026-09-14 by seats that were cycled the same afternoon; a
|
|
318
|
+
* convention does not survive its holder, a check does.
|
|
319
|
+
*
|
|
320
|
+
* (e) VERDICTS ON THE PR. A merged PR must carry a verdict COMMENT bound to the
|
|
321
|
+
* head that merged. "Verdict" is a TYPED line — `QA GATE — **PASS** @ \`sha\``
|
|
322
|
+
* or the merge-time `**GATE: PASS** … @ \`sha\`` — never a substring: #299's
|
|
323
|
+
* page says "not a verdict", "PASS" and "my verdict above" in three review
|
|
324
|
+
* comments and carries no verdict at all. Bound by sha, prefix-tolerant
|
|
325
|
+
* (`shaAgrees`): a verdict on an OLDER head is the #268 breach, not a gate.
|
|
326
|
+
* (f) A GO WRITES THE BOARD ROW. An item is ROUTED when a `go` record names it
|
|
327
|
+
* ON THE GO LINE — `GO ⟨q-…⟩ —`, `GO: … ⟨q-…⟩` — read by POSITION, because a
|
|
328
|
+
* GO's prose mentions neighbouring items as context. A routed item that is
|
|
329
|
+
* still open and has no board row naming it as its SUBJECT is a hit:
|
|
330
|
+
* `next_unblocked` would offer it again. Room logs AND inboxes are read —
|
|
331
|
+
* this fleet routes by DM.
|
|
332
|
+
* Both are bounded by a window: a merge or a GO older than it is listed, not
|
|
333
|
+
* raised, so the day's real instances (#299) fire once and age out.
|
|
334
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
335
|
+
export const CONVENTION_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
|
|
336
|
+
/**
|
|
337
|
+
* WHEN (e) WAS ADOPTED, as a dated fact: qa posted at 2026-09-14 13:28Z that all
|
|
338
|
+
* five of its verdicts were now on their PRs. Measured live before this floor
|
|
339
|
+
* existed: 39 of the 50 most recent merges had no verdict comment — every one
|
|
340
|
+
* merged BEFORE that instant, when the convention did not exist. Raising them
|
|
341
|
+
* would make the check's first three days pure noise, which is how a check gets
|
|
342
|
+
* switched off. Merges before the floor are LISTED with `beforeAdoption`, never
|
|
343
|
+
* raised; the window subsumes the floor after three days.
|
|
344
|
+
*/
|
|
345
|
+
export const CONVENTION_E_ADOPTED_MS = Date.parse("2026-09-14T13:28:00Z");
|
|
346
|
+
// The verdict grammar lives with the gate predicate now (⟨q-5a93c2d7⟩); re-exported so nothing that reached it here breaks.
|
|
347
|
+
export { VERDICT_COMMENT, verdictShasIn } from "../gated-head.js";
|
|
348
|
+
/** Items named ON A GO LINE of a `go` record — position, not mention. */
|
|
349
|
+
export function routedItemsIn(logText: string): { itemId: string; by: string; ts: number }[] {
|
|
350
|
+
const out: { itemId: string; by: string; ts: number }[] = [];
|
|
351
|
+
for (const l of logText.split("\n")) {
|
|
352
|
+
if (!l.trim()) continue;
|
|
353
|
+
let o: { ts?: number; from?: string; text?: string; record?: { type?: string } };
|
|
354
|
+
try { o = JSON.parse(l); } catch { continue; }
|
|
355
|
+
if (o.record?.type !== "go") continue;
|
|
356
|
+
for (const line of String(o.text ?? "").split("\n")) {
|
|
357
|
+
if (!/\bGO\b/.test(line)) continue;
|
|
358
|
+
for (const m of line.matchAll(/q-[0-9a-f]{8}/g)) out.push({ itemId: m[0], by: String(o.from ?? ""), ts: Number(o.ts ?? 0) });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
* ⟨q-4e08b3c1⟩ — THE MERGE WINDOW, JOINED. qa posts `MERGE WINDOW: #N — hold
|
|
365
|
+
* queue/board writes` as the trailing line of its typed PASS and `MERGE WINDOW
|
|
366
|
+
* CLOSED` as the trailing line of its MERGED done; a seat's write is a commit
|
|
367
|
+
* touching docs/QUEUE.md, docs/DONE.md or docs/WORKSTREAMS.md, timestamped by
|
|
368
|
+
* git. The incident (#312): two coordinator board commits at 16:34 and 16:37,
|
|
369
|
+
* before a window opened at ~16:40 — nobody violated anything, the protocol's
|
|
370
|
+
* party list was incomplete. `217a6af` bound every record writer; that is the
|
|
371
|
+
* dated floor. Measured today before this existed: five windows, each under
|
|
372
|
+
* 70 seconds, 67 record commits, none inside a window.
|
|
373
|
+
*
|
|
374
|
+
* ⚠ AN UNCLOSED WINDOW IS CAPPED, NEVER OPEN-ENDED (the ruling): at the next
|
|
375
|
+
* window's open or 30 minutes, whichever comes first, and reported `unclosed`
|
|
376
|
+
* by name — a window nobody closed must not condemn every write after it.
|
|
377
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
378
|
+
export const MERGE_WINDOW_ADOPTED_MS = Date.parse("2026-09-14T16:43:54Z");
|
|
379
|
+
export const MERGE_WINDOW_CAP_MS = 30 * 60 * 1000;
|
|
380
|
+
export const RECORD_DOCS = ["docs/QUEUE.md", "docs/DONE.md", "docs/WORKSTREAMS.md"];
|
|
381
|
+
export type MergeWindow = { pr: number; open: number; close: number | null; end: number; unclosed: boolean; openedBy: string };
|
|
382
|
+
/** Windows as the bus records them: an OPEN line and, for the same PR, the next CLOSED line after it. */
|
|
383
|
+
export function windowsIn(logText: string): MergeWindow[] {
|
|
384
|
+
const opens: { pr: number; ts: number; from: string }[] = [];
|
|
385
|
+
const closes: { pr: number | null; ts: number }[] = [];
|
|
386
|
+
for (const l of logText.split("\n")) {
|
|
387
|
+
if (!l.trim()) continue;
|
|
388
|
+
let o: { ts?: number; from?: string; text?: string };
|
|
389
|
+
try { o = JSON.parse(l); } catch { continue; }
|
|
390
|
+
const t = String(o.text ?? "");
|
|
391
|
+
const om = /MERGE WINDOW: #(\d+)/.exec(t);
|
|
392
|
+
if (om) opens.push({ pr: Number(om[1]), ts: Number(o.ts ?? 0), from: String(o.from ?? "") });
|
|
393
|
+
if (/MERGE WINDOW CLOSED/.test(t)) {
|
|
394
|
+
// 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.
|
|
395
|
+
const pm = /#(\d+)/.exec(t);
|
|
396
|
+
closes.push({ pr: pm ? Number(pm[1]) : null, ts: Number(o.ts ?? 0) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
opens.sort((a, b) => a.ts - b.ts);
|
|
400
|
+
return opens.map((w, i) => {
|
|
401
|
+
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;
|
|
402
|
+
const nextOpen = opens[i + 1]?.ts ?? Infinity;
|
|
403
|
+
const end = close ?? Math.min(nextOpen, w.ts + MERGE_WINDOW_CAP_MS);
|
|
404
|
+
return { pr: w.pr, open: w.ts, close, end, unclosed: close === null, openedBy: w.from };
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** `mergeOf`: the PR whose landing commit this is, read off the FULL subject's trailing `(#N)` before any display slicing — the live #325 subject is 119 chars and the marker sat past an 80-char cut. */
|
|
408
|
+
export type RecordWrite = { sha: string; at: number; author: string; subject: string; mergeOf: number | null };
|
|
409
|
+
/** The PR a landing commit lands, from the forge's `(#N)` marker at the END of the subject; null for any other commit. */
|
|
410
|
+
export const mergeOf = (fullSubject: string): number | null => { const m = /\(#(\d+)\)\s*$/.exec(fullSubject); return m ? Number(m[1]) : null; };
|
|
411
|
+
/**
|
|
412
|
+
* ⟨q-4e08b3c1⟩ follow-up, MEASURED LIVE 2026-09-14 19:38Z by this file's own
|
|
413
|
+
* live-population test: #325's SQUASH (3b8702e) touched docs/QUEUE.md — the PR
|
|
414
|
+
* migrated a queue receipt — and its author time is the merge time, 13s into
|
|
415
|
+
* MERGE WINDOW #325. The window's own landing commit is the MERGE the window
|
|
416
|
+
* exists to protect, not a seat's write into it; it is listed under the window
|
|
417
|
+
* with `landing: true` and never raised.
|
|
418
|
+
*/
|
|
419
|
+
export const isLandingOf = (write: Pick<RecordWrite, "mergeOf">, pr: number): boolean => write.mergeOf !== null && write.mergeOf === pr;
|
|
420
|
+
/** Commits touching a record document on the shared branch, by AUTHOR time — when the seat wrote, not when it landed. */
|
|
421
|
+
export function recordWritesOn(repo: string, sinceMs: number): RecordWrite[] | null {
|
|
422
|
+
const git = (args: string[]) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
423
|
+
let base: string | null = null;
|
|
424
|
+
for (const cand of ["origin/main", "main", "origin/master", "master"]) {
|
|
425
|
+
try { git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); base = cand; break; } catch { /* next */ }
|
|
426
|
+
}
|
|
427
|
+
if (!base) return null;
|
|
428
|
+
try {
|
|
429
|
+
const out = git(["log", base, `--since=${new Date(sinceMs).toISOString()}`, "--format=%H|%aI|%an|%s", "--", ...RECORD_DOCS]);
|
|
430
|
+
return out.split("\n").filter(Boolean).map((l) => {
|
|
431
|
+
const [sha, aI, author, ...rest] = l.split("|");
|
|
432
|
+
const full = rest.join("|");
|
|
433
|
+
return { sha: sha ?? "", at: Date.parse(aI ?? ""), author: author ?? "", subject: full.slice(0, 120), mergeOf: mergeOf(full) };
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
export function mergeWindowChecks(input: { logText: string; writes: RecordWrite[] | null; now: number; floorMs?: number; horizonMs?: number }): {
|
|
440
|
+
hits: StallHit[];
|
|
441
|
+
windows: (MergeWindow & { writes: (RecordWrite & { beforeAdoption: boolean })[] })[];
|
|
442
|
+
unmeasurable: { agentId: string; value: string; why: string }[];
|
|
443
|
+
} {
|
|
444
|
+
const floor = input.floorMs ?? MERGE_WINDOW_ADOPTED_MS;
|
|
445
|
+
const horizon = input.horizonMs ?? CONVENTION_WINDOW_MS;
|
|
446
|
+
const hits: StallHitBody[] = [];
|
|
447
|
+
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
448
|
+
const windows = windowsIn(input.logText).filter((w) => input.now - w.open <= horizon);
|
|
449
|
+
if (input.writes === null) {
|
|
450
|
+
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" });
|
|
451
|
+
return { hits: [], windows: windows.map((w) => ({ ...w, writes: [] })), unmeasurable };
|
|
452
|
+
}
|
|
453
|
+
const out = windows.map((w) => {
|
|
454
|
+
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) }));
|
|
455
|
+
for (const c of inside) {
|
|
456
|
+
if (c.beforeAdoption || c.landing) continue;
|
|
457
|
+
const secs = Math.round((c.at - w.open) / 1000);
|
|
458
|
+
hits.push({
|
|
459
|
+
kind: "merge-window-write", pr: w.pr, sha: c.sha, author: c.author, authoredAt: new Date(c.at).toISOString(), secondsIntoWindow: secs,
|
|
460
|
+
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.`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return { ...w, writes: inside };
|
|
464
|
+
});
|
|
465
|
+
return { hits: withAudience(hits), windows: out, unmeasurable };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function conventionChecks(input: {
|
|
469
|
+
recentMerges: RecentMerge[] | null | undefined;
|
|
470
|
+
routingLogText: string;
|
|
471
|
+
queueText: string | null;
|
|
472
|
+
boardRows: WorkstreamsV1Row[];
|
|
473
|
+
now: number;
|
|
474
|
+
windowMs?: number;
|
|
475
|
+
}): {
|
|
476
|
+
hits: StallHit[];
|
|
477
|
+
unmeasurable: { agentId: string; value: string; why: string }[];
|
|
478
|
+
merges: { pr: number; head: string; minutes: number; verdictAtHead: boolean; inWindow: boolean; beforeAdoption: boolean }[] | null;
|
|
479
|
+
routed: { itemId: string; by: string; minutes: number; open: boolean | null; onBoard: boolean; inWindow: boolean }[];
|
|
480
|
+
closings: (Closing & { inWindow: boolean })[];
|
|
481
|
+
} {
|
|
482
|
+
const windowMs = input.windowMs ?? CONVENTION_WINDOW_MS;
|
|
483
|
+
const hits: StallHitBody[] = [];
|
|
484
|
+
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
485
|
+
let merges: ReturnType<typeof conventionChecks>["merges"] = null;
|
|
486
|
+
if (input.recentMerges === null || input.recentMerges === undefined) {
|
|
487
|
+
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" });
|
|
488
|
+
} else {
|
|
489
|
+
merges = [];
|
|
490
|
+
for (const m of input.recentMerges) {
|
|
491
|
+
const mergedMs = Date.parse(m.mergedAt);
|
|
492
|
+
const minutes = Math.max(0, Math.round((input.now - mergedMs) / 60000));
|
|
493
|
+
const inWindow = input.now - mergedMs <= windowMs;
|
|
494
|
+
const beforeAdoption = mergedMs < CONVENTION_E_ADOPTED_MS;
|
|
495
|
+
const verdictAtHead = verdictShasIn(m.comments).some((v) => shaAgrees(v.sha, m.headRefOid));
|
|
496
|
+
merges.push({ pr: m.n, head: m.headRefOid, minutes, verdictAtHead, inWindow, beforeAdoption });
|
|
497
|
+
if (inWindow && !beforeAdoption && !verdictAtHead) {
|
|
498
|
+
hits.push({
|
|
499
|
+
kind: "unverdicted-merge", pr: m.n, head: m.headRefOid, mergedAt: m.mergedAt, minutes,
|
|
500
|
+
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.`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const openIds = new Set<string>();
|
|
506
|
+
const closedIds = new Set<string>();
|
|
507
|
+
if (input.queueText !== null) {
|
|
508
|
+
for (const i of queueItemsOf(parseWorkDoc(input.queueText))) (i.done ? closedIds : openIds).add(i.id);
|
|
509
|
+
}
|
|
510
|
+
const subjectOf = (r: WorkstreamsV1Row) => [...String(r.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((x) => x[1] as string);
|
|
511
|
+
const onBoard = new Set(input.boardRows.flatMap(subjectOf));
|
|
512
|
+
const routed: ReturnType<typeof conventionChecks>["routed"] = [];
|
|
513
|
+
const seen = new Set<string>();
|
|
514
|
+
for (const r of routedItemsIn(input.routingLogText).sort((a, b) => b.ts - a.ts)) {
|
|
515
|
+
if (seen.has(r.itemId)) continue; // the latest GO for an item is the one that binds
|
|
516
|
+
seen.add(r.itemId);
|
|
517
|
+
const minutes = Math.max(0, Math.round((input.now - r.ts) / 60000));
|
|
518
|
+
const inWindow = input.now - r.ts <= windowMs;
|
|
519
|
+
const open = input.queueText === null ? null : openIds.has(r.itemId) ? true : closedIds.has(r.itemId) ? false : null;
|
|
520
|
+
const has = onBoard.has(r.itemId);
|
|
521
|
+
routed.push({ itemId: r.itemId, by: r.by, minutes, open, onBoard: has, inWindow });
|
|
522
|
+
if (inWindow && open === true && !has) {
|
|
523
|
+
hits.push({
|
|
524
|
+
kind: "routed-without-row", itemId: r.itemId, routedBy: r.by, routedAt: new Date(r.ts).toISOString(), minutes,
|
|
525
|
+
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).`,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// ⟨q-fee7239f⟩ — closing lines: every MERGED done on the bus, judged by the
|
|
530
|
+
// grammar; those at or before the floor are LISTED with beforeAdoption.
|
|
531
|
+
const closings = closingsIn(input.routingLogText).map((c) => ({ ...c, inWindow: input.now - c.ts <= windowMs }));
|
|
532
|
+
for (const c of closings) {
|
|
533
|
+
if (!c.inWindow || c.beforeAdoption || !c.claimsDelete || c.form !== null) continue;
|
|
534
|
+
const minutes = Math.max(0, Math.round((input.now - c.ts) / 60000));
|
|
535
|
+
hits.push({
|
|
536
|
+
kind: "unread-delete-claim", agentId: c.from, pr: c.pr, closedAt: new Date(c.ts).toISOString(), minutes,
|
|
537
|
+
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.`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return { hits: withAudience(hits), unmeasurable, merges, routed, closings };
|
|
541
|
+
}
|
|
542
|
+
/** Every room log on this bus, concatenated — a verdict for a PR counts from any room. */
|
|
543
|
+
export const readAllRoomLogs = (): string => {
|
|
544
|
+
const dir = path.join(ROOT, "rooms");
|
|
545
|
+
try {
|
|
546
|
+
return readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => readFileSync(path.join(dir, f), "utf8")).join("\n");
|
|
547
|
+
} catch {
|
|
548
|
+
return "";
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
/** The seat a branch belongs to, by the prefix `claim` writes — identity is in the artefact, not the account. */
|
|
552
|
+
const seatOfBranch = (name: string): string => (name.includes("/") ? name.split("/")[0]! : "unknown");
|
|
553
|
+
const BASE_BRANCHES = new Set(["main", "master", "HEAD"]);
|
|
554
|
+
|
|
555
|
+
/*
|
|
556
|
+
* ⚠ A CLOCK VERB CANNOT TAKE A MINUTE. Measured live: 212 remote heads × ~5 git
|
|
557
|
+
* processes each = 65s per run. Landedness is MONOTONE — once a sha's patches
|
|
558
|
+
* are on main they stay there — so it is memoised per sha across runs, and a
|
|
559
|
+
* not-landed answer is memoised against the base sha it was measured at and
|
|
560
|
+
* re-measured when main moves. A per-run BUDGET bounds the uncached work; what
|
|
561
|
+
* does not fit is reported as deferred, never silently skipped, and the next
|
|
562
|
+
* run picks it up. Steady state is ls-remote + a handful of new branches.
|
|
563
|
+
*/
|
|
564
|
+
type LandedBy = "forge" | "patch" | "squash" | null;
|
|
565
|
+
type StrandedMemo = Record<string, { landed: boolean; landedBy: LandedBy; ahead: number; committedAt: string; base: string }>;
|
|
566
|
+
const memoFile = () => path.join(ROOT, "stall-stranded-memo.json");
|
|
567
|
+
const readMemo = (): StrandedMemo => {
|
|
568
|
+
try {
|
|
569
|
+
return JSON.parse(readFileSync(memoFile(), "utf8")) as StrandedMemo;
|
|
570
|
+
} catch {
|
|
571
|
+
return {};
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const writeMemo = (m: StrandedMemo) => {
|
|
575
|
+
try {
|
|
576
|
+
mkdirSync(ROOT, { recursive: true });
|
|
577
|
+
writeFileSync(memoFile(), JSON.stringify(m));
|
|
578
|
+
} catch { /* a lost memo costs time on the next run, never correctness */ }
|
|
579
|
+
};
|
|
580
|
+
export const STRANDED_BUDGET_MS = 15_000;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Did the WHOLE RANGE land as ONE commit? `git cherry` compares commit by
|
|
584
|
+
* commit, so a squash of several commits — one patch id for the range, none
|
|
585
|
+
* for the parts — reads `+` on every line and the branch looks unproposed
|
|
586
|
+
* forever. Measured live: 47 of 209 ahead-by-ancestry branches survived the
|
|
587
|
+
* cherry test, and every young one was a 2-commit branch merged that day.
|
|
588
|
+
*
|
|
589
|
+
* So index main's recent commits by the patch id of EACH commit — ONE process
|
|
590
|
+
* (`git log -p | git patch-id`), measured at 1.8s for 1000 commits — and ask,
|
|
591
|
+
* per branch, whether the patch id of `mergeBase..sha` as ONE diff is in it.
|
|
592
|
+
* A hit means main carries this branch's cumulative change as a single
|
|
593
|
+
* commit: a squash. Bounded to the last 1000 first-parent commits, so a
|
|
594
|
+
* branch that landed further back than that reads as not landed — the
|
|
595
|
+
* direction that over-reports, never the one that hides stranded work.
|
|
596
|
+
*/
|
|
597
|
+
const SQUASH_INDEX_DEPTH = 1000;
|
|
598
|
+
/** `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. */
|
|
599
|
+
const patchIds = (repo: string, diff: string): string =>
|
|
600
|
+
execFileSync("git", ["patch-id", "--stable"], { cwd: repo, encoding: "utf8", input: diff, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
601
|
+
function squashIndex(repo: string, base: string): Map<string, string> {
|
|
602
|
+
const map = new Map<string, string>();
|
|
603
|
+
try {
|
|
604
|
+
const log = execFileSync("git", ["log", "-p", "--first-parent", `--max-count=${SQUASH_INDEX_DEPTH}`, "--format=%H", base, "--"], {
|
|
605
|
+
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024,
|
|
606
|
+
});
|
|
607
|
+
const out = patchIds(repo, log);
|
|
608
|
+
for (const l of out.split("\n")) {
|
|
609
|
+
const [id, commit] = l.trim().split(/\s+/);
|
|
610
|
+
if (id && commit) map.set(id, commit);
|
|
611
|
+
}
|
|
612
|
+
} catch { /* an empty index reads every branch as not landed — over-reports */ }
|
|
613
|
+
return map;
|
|
614
|
+
}
|
|
615
|
+
function landedBySquash(repo: string, base: string, sha: string, index: Map<string, string>): boolean {
|
|
616
|
+
if (index.size === 0) return false;
|
|
617
|
+
try {
|
|
618
|
+
const git = (args: string[]) =>
|
|
619
|
+
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
|
|
620
|
+
const mb = git(["merge-base", base, sha]).trim();
|
|
621
|
+
if (!mb) return false;
|
|
622
|
+
const id = patchIds(repo, git(["diff", mb, sha, "--"])).trim().split(/\s+/)[0] ?? "";
|
|
623
|
+
return id.length > 0 && index.has(id);
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The stranded-work axis. Pure over its inputs so it is testable without a
|
|
631
|
+
* network; the caller supplies the artefacts and the log.
|
|
632
|
+
*/
|
|
633
|
+
/*
|
|
634
|
+
* ⟨q-dcbaf544⟩ — A GATE RESULT REPORTED BY A SEAT THAT CANNOT RECORD IT. The
|
|
635
|
+
* typed gate line (#313's grammar, `GATE: PASS @ sha`) inside a NON-verdict
|
|
636
|
+
* record that cites the PR — a repo-owner's or console's `done`/`fyi` — is a
|
|
637
|
+
* claim that the head was gated. If no typed `verdict` names that head, the
|
|
638
|
+
* claim was never scribed, and `unverdicted-pr` says so instead of "nobody".
|
|
639
|
+
* Measured live 2026-09-14 before this existed: 0 such claims in 14 rooms +
|
|
640
|
+
* inboxes — the convention was applied by prose, which this cannot read, and
|
|
641
|
+
* that is the point: the typed line is what makes a report scribable.
|
|
642
|
+
*/
|
|
643
|
+
export type GateClaim = { from: string; ts: number; result: string; sha: string; type: string };
|
|
644
|
+
export function gateClaimsIn(logText: string, pr: string, head: string): GateClaim[] {
|
|
645
|
+
const out: GateClaim[] = [];
|
|
646
|
+
for (const l of logText.split("\n")) {
|
|
647
|
+
if (!l.trim()) continue;
|
|
648
|
+
let o: { ts?: number; from?: string; text?: string; record?: { type?: string; cites?: { ref?: string }[] } };
|
|
649
|
+
try { o = JSON.parse(l); } catch { continue; }
|
|
650
|
+
const r = o.record;
|
|
651
|
+
if (!r || r.type === "verdict") continue;
|
|
652
|
+
if (!(r.cites ?? []).some((c) => (String(c?.ref ?? "").match(/#(\d+)/) ?? [])[1] === pr)) continue;
|
|
653
|
+
for (const v of verdictShasIn([{ body: String(o.text ?? "") }])) {
|
|
654
|
+
if (shaAgrees(v.sha, head)) out.push({ from: String(o.from ?? ""), ts: Number(o.ts ?? 0), result: v.result, sha: v.sha, type: String(r.type ?? "") });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return out.sort((a, b) => a.ts - b.ts);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export function strandedWork(
|
|
661
|
+
repo: string,
|
|
662
|
+
artefacts: RepoArtefacts,
|
|
663
|
+
logText: string,
|
|
664
|
+
now: number,
|
|
665
|
+
limitMs: number,
|
|
666
|
+
/** Seats the bus knows. A branch hit needs a recipient; a branch nobody owns is reported, not DMed. */
|
|
667
|
+
registeredSeats: ReadonlySet<string> = new Set(),
|
|
668
|
+
/** Uncached branch classification stops here; the rest is DEFERRED and named. */
|
|
669
|
+
budgetMs: number = STRANDED_BUDGET_MS,
|
|
670
|
+
): {
|
|
671
|
+
hits: StallHit[];
|
|
672
|
+
unmeasurable: { agentId: string; value: string; why: string }[];
|
|
673
|
+
openPrs: { pr: number; head: string; branch: string; minutes: number; verdictAtHead: boolean; claimAtHead: GateClaim | null }[] | null;
|
|
674
|
+
pushedBranches: { branch: string; head: string; ahead: number; landed: boolean; landedBy: LandedBy; minutes: number; hasPr: boolean }[] | null;
|
|
675
|
+
} {
|
|
676
|
+
const hits: StallHitBody[] = [];
|
|
677
|
+
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
678
|
+
const git = (args: string[]) =>
|
|
679
|
+
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
680
|
+
|
|
681
|
+
let openPrs: ReturnType<typeof strandedWork>["openPrs"] = null;
|
|
682
|
+
if (artefacts.openPrs === null) {
|
|
683
|
+
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" });
|
|
684
|
+
} else {
|
|
685
|
+
openPrs = [];
|
|
686
|
+
for (const p of artefacts.openPrs) {
|
|
687
|
+
const { verdicts } = verdictsFor(logText, String(p.n));
|
|
688
|
+
const verdictAtHead = verdicts.some((v) => shaAgrees(v.head, p.headRefOid));
|
|
689
|
+
const minutes = Math.max(0, Math.round((now - Date.parse(p.updatedAt)) / 60000));
|
|
690
|
+
const claimAtHead = verdictAtHead ? null : gateClaimsIn(logText, String(p.n), p.headRefOid)[0] ?? null;
|
|
691
|
+
openPrs.push({ pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes, verdictAtHead, claimAtHead });
|
|
692
|
+
// A verdict bound to an OLDER head does not count: the head moved, and a
|
|
693
|
+
// PASS on what used to be there is the #268 breach one step earlier.
|
|
694
|
+
// ⟨q-dcbaf544⟩ — an UNSCRIBED gate report fires at once; "nobody gated" waits the limit.
|
|
695
|
+
if (!verdictAtHead && (claimAtHead || now - Date.parse(p.updatedAt) > limitMs)) {
|
|
696
|
+
hits.push({
|
|
697
|
+
kind: "unverdicted-pr", agentId: seatOfBranch(p.headRefName), pr: p.n, head: p.headRefOid, branch: p.headRefName, minutes,
|
|
698
|
+
...(claimAtHead
|
|
699
|
+
? { why: "reported-unscribed" as const, claimedBy: claimAtHead.from, claimedAt: new Date(claimAtHead.ts).toISOString(), claimedResult: claimAtHead.result }
|
|
700
|
+
: { why: "nobody-gated" as const }),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let pushedBranches: ReturnType<typeof strandedWork>["pushedBranches"] = null;
|
|
707
|
+
if (artefacts.remoteHeads === null) {
|
|
708
|
+
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" });
|
|
709
|
+
} else {
|
|
710
|
+
pushedBranches = [];
|
|
711
|
+
const prHeads = new Set((artefacts.openPrs ?? []).map((p) => p.headRefOid));
|
|
712
|
+
// RANK 1, docs/LANDEDNESS.md: the forge's own record. Measured live before it
|
|
713
|
+
// was asked: a 15-day-old branch whose PR #170 had merged with this very tip
|
|
714
|
+
// as its head read "not landed" — its squash sat beyond the patch index's
|
|
715
|
+
// window. A patch-id negative is inconclusive by construction; the forge's
|
|
716
|
+
// positive is not.
|
|
717
|
+
const forgeLanded = new Set((artefacts.mergedPrs ?? []).map((p) => p.headRefOid));
|
|
718
|
+
if (artefacts.mergedPrs === null || artefacts.mergedPrs === undefined) {
|
|
719
|
+
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" });
|
|
720
|
+
}
|
|
721
|
+
const prBranches = new Set((artefacts.openPrs ?? []).map((p) => p.headRefName));
|
|
722
|
+
let base: string | null = null;
|
|
723
|
+
for (const cand of ["origin/main", "main", "origin/master", "master"]) {
|
|
724
|
+
try { git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); base = cand; break; } catch { /* next */ }
|
|
725
|
+
}
|
|
726
|
+
let squashes: Map<string, string> | null = null; // built once per run, only if a branch needs it
|
|
727
|
+
const memo = readMemo();
|
|
728
|
+
let baseSha = "";
|
|
729
|
+
try { baseSha = base ? git(["rev-parse", base]) : ""; } catch { /* handled below as no base */ }
|
|
730
|
+
const started = Date.now();
|
|
731
|
+
let deferred = 0;
|
|
732
|
+
// The budget is spent on the branches that can RAISE something first: a
|
|
733
|
+
// registered seat's branch is the only kind that becomes a hit, and on a
|
|
734
|
+
// 212-head origin the alphabet puts `docs/…` and `fix/…` ahead of every
|
|
735
|
+
// `groundwork-kit-worker-*/…`. Stable within each group.
|
|
736
|
+
const ordered = [...artefacts.remoteHeads].sort(
|
|
737
|
+
(x, y) => Number(registeredSeats.has(seatOfBranch(y.name))) - Number(registeredSeats.has(seatOfBranch(x.name))),
|
|
738
|
+
);
|
|
739
|
+
for (const h of ordered) {
|
|
740
|
+
if (BASE_BRANCHES.has(h.name)) continue;
|
|
741
|
+
const hasPr = prHeads.has(h.sha) || prBranches.has(h.name);
|
|
742
|
+
if (!base) {
|
|
743
|
+
unmeasurable.push({ agentId: seatOfBranch(h.name), value: h.name, why: "no main/master to measure distance from" });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// ⛔ THE FORGE BEFORE THE MEMO — QA's row B (#307 FAIL @ 0f5f334): a branch
|
|
747
|
+
// memoised as not-landed against a base that has not moved stayed a hit
|
|
748
|
+
// AFTER the forge said its PR had merged, because the memo answered first.
|
|
749
|
+
// Live shape: PR merged, local origin/main not yet fetched, owner DMed that
|
|
750
|
+
// merged work is stranded — the instrument mirroring the aide's clearance
|
|
751
|
+
// error. The forge is rank 1 and a set lookup; it is asked first, always,
|
|
752
|
+
// and its answer overwrites the memo.
|
|
753
|
+
const cached = memo[h.sha];
|
|
754
|
+
let ahead: number;
|
|
755
|
+
let landed: boolean;
|
|
756
|
+
let landedBy: LandedBy;
|
|
757
|
+
let committedAt: string;
|
|
758
|
+
if (forgeLanded.has(h.sha) && cached && !cached.landed) {
|
|
759
|
+
({ ahead, committedAt } = cached);
|
|
760
|
+
landed = true;
|
|
761
|
+
landedBy = "forge";
|
|
762
|
+
memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
|
|
763
|
+
} else if (cached && (cached.landed || cached.base === baseSha)) {
|
|
764
|
+
({ ahead, landed, landedBy, committedAt } = cached);
|
|
765
|
+
} else {
|
|
766
|
+
if (Date.now() - started > budgetMs) {
|
|
767
|
+
deferred++;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
try {
|
|
771
|
+
git(["cat-file", "-e", `${h.sha}^{commit}`]);
|
|
772
|
+
} catch {
|
|
773
|
+
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` });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
ahead = Number(git(["rev-list", "--count", `${base}..${h.sha}`]) || 0);
|
|
777
|
+
committedAt = git(["log", "-1", "--format=%cI", h.sha, "--"]);
|
|
778
|
+
// ⛔⛆ ANCESTRY CANNOT SAY "LANDED" ON A SQUASH-MERGING FLEET. Measured live
|
|
779
|
+
// before this line existed: 205 hits, and every young one was a branch
|
|
780
|
+
// whose PR had merged that afternoon — `rev-list --count main..sha` reads
|
|
781
|
+
// a squash-landed branch as ahead forever. `git cherry` compares PATCH
|
|
782
|
+
// IDS, the same instrument `classifyBoardRef` and `refresh_worktrees` use:
|
|
783
|
+
// every line `-` means every patch is upstream. Same caveat as theirs: a
|
|
784
|
+
// squash of SEVERAL commits into one changes the combined patch id, so a
|
|
785
|
+
// multi-commit landed branch can still read as unproposed — the direction
|
|
786
|
+
// that over-reports, never the one that hides stranded work.
|
|
787
|
+
landed = false;
|
|
788
|
+
landedBy = null;
|
|
789
|
+
if (ahead > 0) {
|
|
790
|
+
if (forgeLanded.has(h.sha)) {
|
|
791
|
+
landed = true;
|
|
792
|
+
landedBy = "forge";
|
|
793
|
+
}
|
|
794
|
+
if (!landed) {
|
|
795
|
+
try {
|
|
796
|
+
const cherry = git(["cherry", base, h.sha]);
|
|
797
|
+
landed = cherry.length > 0 && cherry.split("\n").every((l) => l.trim().startsWith("-"));
|
|
798
|
+
if (landed) landedBy = "patch";
|
|
799
|
+
} catch { /* unreadable: inconclusive, which over-reports */ }
|
|
800
|
+
}
|
|
801
|
+
if (!landed) {
|
|
802
|
+
if (!squashes) squashes = squashIndex(repo, base);
|
|
803
|
+
landed = landedBySquash(repo, base, h.sha, squashes);
|
|
804
|
+
if (landed) landedBy = "squash";
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
memo[h.sha] = { landed, landedBy, ahead, committedAt, base: baseSha };
|
|
808
|
+
}
|
|
809
|
+
const minutes = Math.max(0, Math.round((now - Date.parse(committedAt)) / 60000));
|
|
810
|
+
pushedBranches.push({ branch: h.name, head: h.sha, ahead, landed, landedBy, minutes, hasPr });
|
|
811
|
+
// ⚠ A BRANCH HIT IS RAISED ONLY FOR A SEAT THE BUS KNOWS. Measured live
|
|
812
|
+
// after the two landedness tests: the survivors were `docs/…`, `fix/…`
|
|
813
|
+
// branches from before the seat-prefix convention — abandoned work with no
|
|
814
|
+
// owner to DM. 40+ hits every run is how a signal gets switched off. They
|
|
815
|
+
// stay VISIBLE in `pushedBranches` (ahead, not landed, no PR); the DM goes
|
|
816
|
+
// only where there is a lane to answer it. The PR half is NOT filtered:
|
|
817
|
+
// an open PR is somebody's by construction, and it is the acceptance.
|
|
818
|
+
const seat = seatOfBranch(h.name);
|
|
819
|
+
if (ahead > 0 && !landed && !hasPr && registeredSeats.has(seat) && now - Date.parse(committedAt) > limitMs) {
|
|
820
|
+
hits.push({
|
|
821
|
+
kind: "unproposed-branch", agentId: seat, branch: h.name, head: h.sha, ahead, minutes,
|
|
822
|
+
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).",
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// Forget shas origin no longer has, so the memo cannot grow without bound.
|
|
827
|
+
const live = new Set(artefacts.remoteHeads.map((h) => h.sha));
|
|
828
|
+
for (const k of Object.keys(memo)) if (!live.has(k)) delete memo[k];
|
|
829
|
+
writeMemo(memo);
|
|
830
|
+
if (deferred > 0) {
|
|
831
|
+
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` });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { hits: withAudience(hits), unmeasurable, openPrs, pushedBranches };
|
|
835
|
+
}
|
|
49
836
|
|
|
50
837
|
// ---------- halt ----------
|
|
51
838
|
|
|
@@ -107,9 +894,34 @@ export function markRunFailure(reason: string): void {
|
|
|
107
894
|
writeFileSync(runFile(), JSON.stringify({ history: history.slice(-200) }, null, 2));
|
|
108
895
|
}
|
|
109
896
|
|
|
110
|
-
|
|
897
|
+
/**
|
|
898
|
+
* ⟨q-d0e83b41⟩ (09-14 amend) — THE POPULATION TRAVELS WITH THE RUN. A ratio is
|
|
899
|
+
* a claim about a population; a run that records only the ratio cannot tell
|
|
900
|
+
* "a lane got measurable" from "a lane left the denominator". So each run
|
|
901
|
+
* records WHO was scored, from which board (path@sha), and the next run
|
|
902
|
+
* reports the delta as its own signal beside the ratio.
|
|
903
|
+
*/
|
|
904
|
+
export type RunPopulation = { repo: string; source: string; scored: string[]; roles: string[]; deliberate: string[]; held: string[] };
|
|
905
|
+
type RunMark = { at: number; hits: number; checked: number; measurable?: number; failed?: string; population?: RunPopulation };
|
|
906
|
+
export function lastPopulationFor(repo: string): { at: number; population: RunPopulation } | null {
|
|
907
|
+
let history: RunMark[] = [];
|
|
908
|
+
try { history = JSON.parse(readFileSync(runFile(), "utf8")).history ?? []; } catch { return null; }
|
|
909
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
910
|
+
const h = history[i]!;
|
|
911
|
+
if (!h.failed && h.population && h.population.repo === repo) return { at: h.at, population: h.population };
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
/** `docs/WORKSTREAMS.md@<sha>`, with `+uncommitted` when the working copy differs from that commit. */
|
|
916
|
+
export function boardSourceOf(repo: string): string {
|
|
917
|
+
let sha = "unknown";
|
|
918
|
+
try { sha = gitOut(repo, ["log", "-1", "--format=%h", "--", BOARD_DOC]) || "uncommitted"; } catch { /* no history */ }
|
|
919
|
+
let dirty = false;
|
|
920
|
+
try { dirty = gitOut(repo, ["status", "--porcelain", "--", BOARD_DOC]).length > 0; } catch { /* unknown */ }
|
|
921
|
+
return `${BOARD_DOC}@${sha}${dirty ? "+uncommitted" : ""}`;
|
|
922
|
+
}
|
|
111
923
|
|
|
112
|
-
function markRun(result: { hits: StallHit[]; checked: number; measurable?: number }) {
|
|
924
|
+
function markRun(result: { hits: StallHit[]; checked: number; measurable?: number; population?: RunPopulation }) {
|
|
113
925
|
mkdirSync(ROOT, { recursive: true });
|
|
114
926
|
let history: RunMark[] = [];
|
|
115
927
|
try {
|
|
@@ -122,7 +934,7 @@ function markRun(result: { hits: StallHit[]; checked: number; measurable?: numbe
|
|
|
122
934
|
// reported `runs 8, failures 0` — all green — while every one of those runs
|
|
123
935
|
// had covered 0 of 3 agents. Nothing in the mark could say so, and this is the
|
|
124
936
|
// instrument meant to cover an absence.
|
|
125
|
-
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0 });
|
|
937
|
+
history.push({ at: Date.now(), hits: result.hits.length, checked: result.checked, measurable: result.measurable ?? 0, ...(result.population ? { population: result.population } : {}) });
|
|
126
938
|
// A RUN OF MISSES MUST BE VISIBLE AS RUNS, not as absence — so the marks are a
|
|
127
939
|
// list, not a single timestamp. "Ten quiet checks" and "one check ten hours ago"
|
|
128
940
|
// are different states and only the first is a healthy fleet.
|
|
@@ -217,7 +1029,115 @@ export async function stallClockStatusTool(args: { maxAgeMinutes?: number }) {
|
|
|
217
1029
|
|
|
218
1030
|
// ---------- the predicate ----------
|
|
219
1031
|
|
|
220
|
-
export const stallCheckSchema = {
|
|
1032
|
+
export const stallCheckSchema = {
|
|
1033
|
+
repo: z.string().optional(),
|
|
1034
|
+
stallMinutes: z.number().optional(),
|
|
1035
|
+
/** The 🔍 window, in minutes. Default REVIEW_STALL_MINUTES. Independent of stallMinutes on purpose. */
|
|
1036
|
+
reviewMinutes: z.number().optional(),
|
|
1037
|
+
/** ⟨q-7b2f6c04⟩ — the claim-axis window, in minutes. Default CLAIM_STALL_MINUTES. */
|
|
1038
|
+
claimMinutes: z.number().optional(),
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const gitOut = (repo: string, args: string[]) =>
|
|
1042
|
+
execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1043
|
+
|
|
1044
|
+
/** The key a row keeps while its cells are edited: its stream text. */
|
|
1045
|
+
export const rowKey = (r: { stream: string }) => r.stream.replace(/\s+/g, " ").trim();
|
|
1046
|
+
|
|
1047
|
+
export type ReviewAge = { since: string; minutes: number; commits: number };
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* ⛔⛆ AN `🔍 In Review` ROW IS NOT SCORED ON BRANCH ACTIVITY AT ALL (⟨q-507e80c4⟩).
|
|
1051
|
+
*
|
|
1052
|
+
* Measured 2026-09-14: `no-vcs-activity` fired on a review lane at 41m, again at
|
|
1053
|
+
* 45m, and would have fired on every correctly-behaving review lane given an
|
|
1054
|
+
* hour — a branch under review is SUPPOSED to stop moving, so that predicate
|
|
1055
|
+
* reaches its guaranteed end state on every healthy lane. The stall question in
|
|
1056
|
+
* review is a DIFFERENT one: how long has the row been in review.
|
|
1057
|
+
*
|
|
1058
|
+
* The board carries no timestamp column, but it is a git-tracked document, so
|
|
1059
|
+
* the answer is in its history: walk the commits that touched the board from
|
|
1060
|
+
* newest to oldest while this row (by stream key) still declares in-review; the
|
|
1061
|
+
* oldest such commit is when the row ENTERED review. A row that declares review
|
|
1062
|
+
* only in the working tree entered it just now (`since: "uncommitted"`).
|
|
1063
|
+
*
|
|
1064
|
+
* Returns `null` only when the board's history cannot be read — which is
|
|
1065
|
+
* reported as unmeasurable, never as healthy.
|
|
1066
|
+
*/
|
|
1067
|
+
export function reviewEnteredAt(repo: string, key: string, now = Date.now()): ReviewAge | null {
|
|
1068
|
+
return stateEnteredAt(repo, key, now, (row) => workStateOf(row.status) === "in-review");
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* ⟨q-7b2f6c04⟩ — when the row was CLAIMED: the oldest board commit in which it
|
|
1072
|
+
* declares an in-flight state. The claim axis reads the board's history the
|
|
1073
|
+
* way the review axis does, so a lane is observable from its claim, not from
|
|
1074
|
+
* its first push.
|
|
1075
|
+
*/
|
|
1076
|
+
export function claimEnteredAt(repo: string, key: string, now = Date.now()): ReviewAge | null {
|
|
1077
|
+
return stateEnteredAt(repo, key, now, (row) => coarseOf(workStateOf(row.status)) === "in-flight");
|
|
1078
|
+
}
|
|
1079
|
+
function stateEnteredAt(repo: string, key: string, now: number, matches: (row: WorkstreamsV1Row) => boolean): ReviewAge | null {
|
|
1080
|
+
let log: string;
|
|
1081
|
+
try {
|
|
1082
|
+
log = gitOut(repo, ["log", "-n", "300", "--format=%H%x09%cI", "--", BOARD_DOC]);
|
|
1083
|
+
} catch {
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
let since: string | null = null;
|
|
1087
|
+
let commits = 0;
|
|
1088
|
+
for (const line of log.split("\n").filter(Boolean)) {
|
|
1089
|
+
const [sha, iso] = line.split("\t");
|
|
1090
|
+
let text: string;
|
|
1091
|
+
try {
|
|
1092
|
+
text = gitOut(repo, ["show", `${sha}:${BOARD_DOC}`]);
|
|
1093
|
+
} catch {
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
const row = workstreamsV1RowsOf(parseWorkDoc(text)).find((r) => rowKey(r) === key);
|
|
1097
|
+
if (!row || !matches(row)) break;
|
|
1098
|
+
since = iso;
|
|
1099
|
+
commits++;
|
|
1100
|
+
}
|
|
1101
|
+
if (!since) return { since: "uncommitted", minutes: 0, commits: 0 };
|
|
1102
|
+
return { since, minutes: Math.round((now - Date.parse(since)) / 60000), commits };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
export type RowFreshness = {
|
|
1106
|
+
stream: string;
|
|
1107
|
+
owner: string;
|
|
1108
|
+
state: WorkState;
|
|
1109
|
+
/** ISO of the commit that last touched this row's line, `uncommitted` for a working-tree edit, `unknown` if blame failed. */
|
|
1110
|
+
lastUpdated: string;
|
|
1111
|
+
minutesAgo: number | null;
|
|
1112
|
+
commit: string | null;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* ⛆ THE BOARD'S OWN FRESHNESS, PER ROW — the acceptance clause the original row
|
|
1117
|
+
* lacked. A predicate reading stale cells is not measuring lanes, it is
|
|
1118
|
+
* measuring the board's maintenance; on 2026-09-14 one silent seat froze the
|
|
1119
|
+
* board and the clock then fired on three HEALTHY lanes, and nothing in its
|
|
1120
|
+
* output could tell the two apart. So every scored row now says when its line
|
|
1121
|
+
* was last written, read from `git blame` on the row's own bytes.
|
|
1122
|
+
*/
|
|
1123
|
+
export function rowFreshness(repo: string, boardText: string, rows: { stream: string; owner: string; status: string; raw?: string }[], now = Date.now()): RowFreshness[] {
|
|
1124
|
+
const lines = boardText.split("\n");
|
|
1125
|
+
return rows.map((r) => {
|
|
1126
|
+
const base = { stream: r.stream.slice(0, 60), owner: r.owner.replace(/[`*]/g, "").trim(), state: workStateOf(r.status) };
|
|
1127
|
+
const idx = r.raw ? lines.indexOf(r.raw) : -1;
|
|
1128
|
+
if (idx < 0) return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
|
|
1129
|
+
try {
|
|
1130
|
+
const out = gitOut(repo, ["blame", "-L", `${idx + 1},${idx + 1}`, "--porcelain", "--", BOARD_DOC]);
|
|
1131
|
+
const sha = out.split(/\s/)[0] ?? "";
|
|
1132
|
+
if (/^0+$/.test(sha)) return { ...base, lastUpdated: "uncommitted", minutesAgo: 0, commit: null };
|
|
1133
|
+
const t = Number(/^committer-time (\d+)/m.exec(out)?.[1]);
|
|
1134
|
+
if (!Number.isFinite(t)) return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: sha.slice(0, 8) };
|
|
1135
|
+
return { ...base, lastUpdated: new Date(t * 1000).toISOString(), minutesAgo: Math.round((now - t * 1000) / 60000), commit: sha.slice(0, 8) };
|
|
1136
|
+
} catch {
|
|
1137
|
+
return { ...base, lastUpdated: "unknown", minutesAgo: null, commit: null };
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
221
1141
|
|
|
222
1142
|
/**
|
|
223
1143
|
* A row somebody is WORKING — the population the stall clock watches.
|
|
@@ -237,28 +1157,151 @@ export const stallCheckSchema = { repo: z.string().optional(), stallMinutes: z.n
|
|
|
237
1157
|
*
|
|
238
1158
|
* NOT widened further, deliberately: `⏸ Parked`, `⏳ Queued`, `⛔ Blocked`,
|
|
239
1159
|
* `🚫 Unstaffable`, `✅ Done` have no lane to stall. Widening to everything
|
|
240
|
-
* would be the 0/0 defect inverted.
|
|
1160
|
+
* would be the 0/0 defect inverted. They are REPORTED, though — see
|
|
1161
|
+
* `notWatched` below — because a row the clock declines to watch and a row
|
|
1162
|
+
* the clock cannot see produce the same silence.
|
|
241
1163
|
*
|
|
242
1164
|
* One predicate, exported: `coord_away` measures coverage by calling
|
|
243
1165
|
* `stall_check` and reading `checked`/`measurable`, so it inherits this
|
|
244
1166
|
* population without a change of its own. Anything else that asks "which rows
|
|
245
1167
|
* are in flight" should ask here rather than re-derive it from a glyph.
|
|
1168
|
+
*
|
|
1169
|
+
* ⛔⛆ DERIVED FROM THE SEAM, NOT MATCHED ON A GLYPH (⟨q-a42503cb⟩). This was
|
|
1170
|
+
* `IN_FLIGHT_STATUS = /🚧|🔍/`, and the sentence above it — "ask here rather
|
|
1171
|
+
* than re-derive it from a glyph" — described a single authority that WAS a
|
|
1172
|
+
* glyph regex. The board's vocabulary grew to eleven documented states
|
|
1173
|
+
* (`workStateOf`, seam ⟨q-5b3e9a04⟩) and this predicate stayed at two, so
|
|
1174
|
+
* every `⏸` row was invisible here AND in `next_unblocked`'s routing
|
|
1175
|
+
* exclusion, which shares it: a seat that wrote `⏸ MERGE-HELD` instead of a
|
|
1176
|
+
* stale `🚧` was doing the right thing and punished for it by a silent drop
|
|
1177
|
+
* from both populations.
|
|
1178
|
+
*
|
|
1179
|
+
* The seam reads the LEADING glyph after stripping decoration, so
|
|
1180
|
+
* `⛔ Blocked — was 🚧 yesterday` is blocked here and was in-flight under the
|
|
1181
|
+
* regex. Measured on the live board at origin/main 687a74f (25 rows): the two
|
|
1182
|
+
* predicates agree on every row, 4 in flight under each — the swap changes no
|
|
1183
|
+
* verdict today; it changes what the file can SAY about the other 21.
|
|
246
1184
|
*/
|
|
247
|
-
export const
|
|
248
|
-
|
|
1185
|
+
export const isInFlightStatus = (status: string): boolean => coarseOf(workStateOf(status)) === "in-flight";
|
|
1186
|
+
|
|
1187
|
+
/** A hold the board declares for this lane, or null. Blocker cell first (the lane's own statement), then the Cutover Gates table. */
|
|
1188
|
+
export function holdOf(row: WorkstreamsV1Row, cutoverGates: Record<string, string>[]): { text: string; by: "blocker" | "cutover-gate" } | null {
|
|
1189
|
+
const blocker = String(row.blocker ?? "").replace(/[`*]/g, "").trim();
|
|
1190
|
+
if (blocker && !/^(—|-|–|none|n\/a)$/i.test(blocker)) return { text: blocker.slice(0, 160), by: "blocker" };
|
|
1191
|
+
const ids = [...String(row.stream).matchAll(/\b(q-[0-9a-f]{8})\b/g)].map((m) => m[1] as string);
|
|
1192
|
+
const prs = [...String(row.stream).matchAll(/#(\d{2,})\b/g)].map((m) => `#${m[1]}`);
|
|
1193
|
+
const ref = refInCell(row.branchWorktree);
|
|
1194
|
+
for (const g of cutoverGates) {
|
|
1195
|
+
const gate = String(g["Gate"] ?? Object.values(g)[0] ?? "");
|
|
1196
|
+
if (ids.some((id) => gate.includes(id)) || prs.some((p) => gate.includes(p)) || (ref && gate.includes(ref))) {
|
|
1197
|
+
return { text: gate.replace(/[`*]/g, "").trim().slice(0, 160), by: "cutover-gate" };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* ⟨q-7e3b10c9⟩ — THE BOARD NAMES ITS OWNER. The Rooms table's owner cell says
|
|
1204
|
+
* who holds the topic — this fleet's `groundwork-kit-coordinator (topic owner)`
|
|
1205
|
+
* — so the clock can route bookkeeping hits without a bus-meta dependency.
|
|
1206
|
+
* Null when the board does not say; the clock then says so and falls back.
|
|
1207
|
+
*/
|
|
1208
|
+
export function boardOwnerOf(boardText: string): string | null {
|
|
1209
|
+
const ext = workstreamsExtensionsOf(parseWorkDoc(boardText));
|
|
1210
|
+
for (const r of ext.rooms) {
|
|
1211
|
+
for (const v of Object.values(r)) {
|
|
1212
|
+
const m = /([\w.-]+-coordinator)\b/.exec(String(v));
|
|
1213
|
+
if (m) return m[1]!;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
249
1218
|
|
|
250
|
-
export async function stallCheckTool(
|
|
1219
|
+
export async function stallCheckTool(
|
|
1220
|
+
args: { repo?: string; stallMinutes?: number; reviewMinutes?: number; claimMinutes?: number },
|
|
1221
|
+
artefacts: (repo: string) => RepoArtefacts = realArtefacts,
|
|
1222
|
+
readRooms: () => string = readAllRoomLogs,
|
|
1223
|
+
readInboxes: () => string = readAllInboxLogs,
|
|
1224
|
+
) {
|
|
251
1225
|
const repo = args.repo ?? process.cwd();
|
|
252
1226
|
const limit = (args.stallMinutes ?? 30) * 60 * 1000;
|
|
1227
|
+
const reviewLimit = args.reviewMinutes ?? REVIEW_STALL_MINUTES;
|
|
1228
|
+
const claimLimit = args.claimMinutes ?? CLAIM_STALL_MINUTES;
|
|
253
1229
|
const board = path.join(repo, BOARD_DOC);
|
|
254
1230
|
if (!existsSync(board)) return { ok: false as const, error: `no ${BOARD_DOC} under '${repo}'` };
|
|
255
1231
|
|
|
256
1232
|
const liveTransports = await loadLiveTransports();
|
|
257
|
-
const
|
|
258
|
-
const
|
|
1233
|
+
const boardText = readFileSync(board, "utf8");
|
|
1234
|
+
const boardDoc = parseWorkDoc(boardText);
|
|
1235
|
+
const rows = workstreamsV1RowsOf(boardDoc);
|
|
1236
|
+
// ⟨q-7e3b10c9⟩ — holds DECLARED on the board: the Cutover Gates table's Gate
|
|
1237
|
+
// cell names what is held; a lane row's Blocker cell names what it waits on.
|
|
1238
|
+
const cutoverGates = workstreamsExtensionsOf(boardDoc).cutoverGates;
|
|
1239
|
+
// ⟨q-3d82f1a9⟩ — SAY THE PARSE STATE FIRST. An unreadable board is refused
|
|
1240
|
+
// as a measurement, not answered as a quiet fleet: ok:false, dm:true, and
|
|
1241
|
+
// the counts named, so the duty officer is told the board broke rather than
|
|
1242
|
+
// shown an empty list that reads as healthy.
|
|
1243
|
+
const boardParse = boardParseOf(boardText);
|
|
1244
|
+
if (!boardParse.readable) {
|
|
1245
|
+
markRunFailure(`unparseable board — ${boardParse.rowsPresent} row(s) present, ${boardParse.rowsParsed} parsed`);
|
|
1246
|
+
return {
|
|
1247
|
+
ok: false as const,
|
|
1248
|
+
error: `${BOARD_DOC} is ${boardParse.why}`,
|
|
1249
|
+
boardParse,
|
|
1250
|
+
checked: 0,
|
|
1251
|
+
measurable: 0,
|
|
1252
|
+
blind: [] as string[],
|
|
1253
|
+
hits: [] as StallHit[],
|
|
1254
|
+
unmeasurable: [] as { agentId: string; value: string; why: string }[],
|
|
1255
|
+
halted: haltState().halted,
|
|
1256
|
+
dm: true,
|
|
1257
|
+
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.`,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
// ⟨q-5d1c8e04⟩ — LANES ARE SCORED; ROLES ARE REPORTED. A role row (🪑, branch
|
|
1261
|
+
// `—`) is a standing seat with nothing to stall; it is listed as present or
|
|
1262
|
+
// absent from the registry and live or not on its transport, and never
|
|
1263
|
+
// enters `checked`. Two roles and zero lanes read 0 of 0, not 0 of 2. A 🪑
|
|
1264
|
+
// row that carries a ref is a lane in disguise and is scored below.
|
|
259
1265
|
const reg = await readJson<Record<string, { lastHeartbeat: number }>>(AGENTS_FILE, {});
|
|
1266
|
+
const kinds = rows.map((r) => ({ row: r, kind: rowKindOf(r) }));
|
|
1267
|
+
const roles = kinds
|
|
1268
|
+
.filter((k) => k.kind === "role")
|
|
1269
|
+
.map(({ row }) => {
|
|
1270
|
+
const owner = row.owner.replace(/[`*]/g, "").trim();
|
|
1271
|
+
return { stream: row.stream.slice(0, 60), owner, present: !!reg[owner], active: liveTransports.has(owner) };
|
|
1272
|
+
});
|
|
1273
|
+
const disguised = kinds.filter((k) => k.kind === "role-with-ref").map((k) => k.row);
|
|
1274
|
+
const inFlight = [...rows.filter((r) => isInFlightStatus(r.status)), ...disguised];
|
|
1275
|
+
// THE ROWS THIS CLOCK DOES NOT WATCH, NAMED AS THEIR OWN STATE (⟨q-a42503cb⟩).
|
|
1276
|
+
// A parked row is not a stalled one, so it is not in `checked` — but a
|
|
1277
|
+
// clock that cannot name what it declined to watch reads the same as one that
|
|
1278
|
+
// never saw it. Finished rows are left out: there is nothing to watch or
|
|
1279
|
+
// route behind a `✅`. Everything else that is not in flight is listed with
|
|
1280
|
+
// the state the seam read, so a reader can tell "held pending a merge" from
|
|
1281
|
+
// "blocked" from "parked awaiting a human" without opening the board.
|
|
1282
|
+
const byState: Partial<Record<WorkState, number>> = {};
|
|
1283
|
+
for (const r of rows) {
|
|
1284
|
+
const s = workStateOf(r.status);
|
|
1285
|
+
byState[s] = (byState[s] ?? 0) + 1;
|
|
1286
|
+
}
|
|
1287
|
+
const notWatched = rows
|
|
1288
|
+
.filter((r) => {
|
|
1289
|
+
const c = coarseOf(workStateOf(r.status));
|
|
1290
|
+
return c !== "in-flight" && c !== "finished";
|
|
1291
|
+
})
|
|
1292
|
+
.map((r) => ({
|
|
1293
|
+
stream: r.stream.slice(0, 60),
|
|
1294
|
+
owner: r.owner.replace(/[`*]/g, "").trim(),
|
|
1295
|
+
state: workStateOf(r.status),
|
|
1296
|
+
status: r.status.slice(0, 80),
|
|
1297
|
+
}));
|
|
260
1298
|
const now = Date.now();
|
|
261
|
-
const hits:
|
|
1299
|
+
const hits: StallHitBody[] = [];
|
|
1300
|
+
/** The base's tip, for telling an empty claim-time push from a lane with commits. */
|
|
1301
|
+
let baseTip: string | null = null;
|
|
1302
|
+
for (const cand of ["origin/main", "origin/master", "main", "master"]) {
|
|
1303
|
+
try { baseTip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `${cand}^{commit}`]); break; } catch { /* next */ }
|
|
1304
|
+
}
|
|
262
1305
|
/** Rows whose VCS activity could not be measured. NOT stall claims. */
|
|
263
1306
|
const unmeasurable: { agentId: string; value: string; why: string }[] = [];
|
|
264
1307
|
/**
|
|
@@ -271,9 +1314,67 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
271
1314
|
* skip and a clean pass produce the same number.
|
|
272
1315
|
*/
|
|
273
1316
|
const measured = new Set<string>();
|
|
1317
|
+
/**
|
|
1318
|
+
* ⟨q-5d1c8e04⟩ — DELIBERATELY BRANCHLESS LANES, out of the population. The
|
|
1319
|
+
* cell holds a statement (`docs-direct · no per-agent branch`) rather than a
|
|
1320
|
+
* ref; the seat said so in words. Reported by name, subtracted from
|
|
1321
|
+
* `checked`, never "unmeasurable" — the coverage ratio stops carrying rows
|
|
1322
|
+
* that were never measurable by design. A blank or `—` on a lane is NOT
|
|
1323
|
+
* this: that lane should carry a branch and names none, and stays unmeasurable.
|
|
1324
|
+
*/
|
|
1325
|
+
const deliberate: { agentId: string; stream: string; cell: string }[] = [];
|
|
1326
|
+
/**
|
|
1327
|
+
* ⟨q-7e3b10c9⟩ — HELD, NOT STALLED AND NOT UNATTENDED. A lane ageing behind a
|
|
1328
|
+
* hold the board DECLARES — its Blocker cell names a hold, or a Cutover Gates
|
|
1329
|
+
* row names the lane — is reported as held, with the hold, and is not scored:
|
|
1330
|
+
* a frozen branch behind a PASS-HOLD is the gate working, not a seat stuck.
|
|
1331
|
+
* Measured as a verdict (coverage counts it). An UNDECLARED wait is not a
|
|
1332
|
+
* hold: the same frozen branch with an empty Blocker cell still fires.
|
|
1333
|
+
*/
|
|
1334
|
+
const held: { agentId: string; stream: string; hold: string; declaredBy: "blocker" | "cutover-gate" }[] = [];
|
|
1335
|
+
const scored: typeof inFlight = [];
|
|
1336
|
+
/**
|
|
1337
|
+
* ⟨q-7b2f6c04⟩ — WHICH AXIS EACH ROW WAS MEASURED ON. One number over three
|
|
1338
|
+
* axes is how all three stayed hidden; every scored row now says which
|
|
1339
|
+
* predicate reached a verdict about it, or that none could.
|
|
1340
|
+
*/
|
|
1341
|
+
type Axis = "vcs" | "review" | "claim" | "landed";
|
|
1342
|
+
const axes: { agentId: string; stream: string; axis: Axis | null; measurable: boolean; why?: string }[] = [];
|
|
1343
|
+
const onAxis = (agentId: string, row: { stream: string }, axis: Axis) => { measured.add(agentId); axes.push({ agentId, stream: row.stream.slice(0, 60), axis, measurable: true }); };
|
|
1344
|
+
const offAxis = (agentId: string, row: { stream: string }, why: string) => axes.push({ agentId, stream: row.stream.slice(0, 60), axis: null, measurable: false, why });
|
|
1345
|
+
/** The claim axis: scored from the board's history; fires past the claim window. */
|
|
1346
|
+
const scoreClaim = (agentId: string, row: WorkstreamsV1Row, branch: string, because: string) => {
|
|
1347
|
+
const age = claimEnteredAt(repo, rowKey(row), now);
|
|
1348
|
+
if (!age) {
|
|
1349
|
+
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" });
|
|
1350
|
+
offAxis(agentId, row, "board history unreadable");
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
onAxis(agentId, row, "claim");
|
|
1354
|
+
if (age.minutes > claimLimit) {
|
|
1355
|
+
hits.push({
|
|
1356
|
+
kind: "unpushed-claim", agentId, stream: row.stream.slice(0, 60), branch, minutes: age.minutes, since: age.since,
|
|
1357
|
+
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.`,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
274
1361
|
|
|
275
1362
|
for (const row of inFlight) {
|
|
276
1363
|
const agentId = row.owner.replace(/[`*]/g, "").trim();
|
|
1364
|
+
// Only a row the VCS half would score can declare itself branchless. A 🔍
|
|
1365
|
+
// row is scored on time-in-review from the board (#306), whatever its cell
|
|
1366
|
+
// says — so its prose is a note, not an exemption.
|
|
1367
|
+
if (cellKindOf(row.branchWorktree) === "prose" && workStateOf(row.status) !== "in-review") {
|
|
1368
|
+
deliberate.push({ agentId, stream: row.stream.slice(0, 60), cell: String(row.branchWorktree).trim().slice(0, 80) });
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
scored.push(row);
|
|
1372
|
+
const hold = holdOf(row, cutoverGates);
|
|
1373
|
+
if (hold) {
|
|
1374
|
+
held.push({ agentId, stream: row.stream.slice(0, 60), hold: hold.text, declaredBy: hold.by });
|
|
1375
|
+
measured.add(agentId);
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
277
1378
|
const entry = reg[agentId];
|
|
278
1379
|
if (!entry) {
|
|
279
1380
|
// NOT SKIPPED IN SILENCE. An owner with no registry entry has no
|
|
@@ -391,17 +1492,56 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
391
1492
|
// ref. Resolution was never the property; the property is whether the ref's
|
|
392
1493
|
// movement is THIS AGENT'S WORK.
|
|
393
1494
|
const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
|
|
1495
|
+
// ⟨q-7b2f6c04⟩ — a ref sitting EXACTLY on the base's tip is the empty push
|
|
1496
|
+
// `ensure_worktree` makes at claim: zero commits of its own, so the
|
|
1497
|
+
// classifier reads it as landed and the VCS axis would read the base's
|
|
1498
|
+
// age. It is a CLAIM, and the claim axis scores it — checked before either.
|
|
1499
|
+
if ((verdict.kind === "merged" || verdict.kind === "measurable") && baseTip) {
|
|
1500
|
+
const cellRef = refInCell(row.branchWorktree).replace(/^origin\//, "");
|
|
1501
|
+
let tip: string | null = null;
|
|
1502
|
+
try { tip = gitOut(repo, ["rev-parse", "--verify", "--quiet", `origin/${cellRef}^{commit}`]); } catch { /* not on origin */ }
|
|
1503
|
+
if (tip && tip === baseTip) {
|
|
1504
|
+
scoreClaim(agentId, row, `origin/${cellRef}`, "its branch sits on the base with no commits of its own (the empty claim-time push)");
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
394
1508
|
if (verdict.kind === "merged") {
|
|
395
1509
|
// A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
|
|
396
1510
|
// did learn something about this lane: its work landed and its row is
|
|
397
1511
|
// stale. Reported as its own kind rather than as a stall, which is what
|
|
398
1512
|
// the frozen ref was reporting it as.
|
|
399
1513
|
hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
|
|
400
|
-
|
|
1514
|
+
onAxis(agentId, row, "landed");
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
if (workStateOf(row.status) === "in-review") {
|
|
1518
|
+
// ⛔ NOT THE VCS PREDICATE. A review-frozen branch is frozen for the
|
|
1519
|
+
// correct reason; scoring it on activity fires on every healthy review
|
|
1520
|
+
// lane eventually. The question here is how long the row has been in
|
|
1521
|
+
// review, read from the board's own history — which also means a 🔍 row
|
|
1522
|
+
// whose cell holds a path is MEASURED here rather than unmeasurable: the
|
|
1523
|
+
// predicate needs the board, not the ref.
|
|
1524
|
+
const age = reviewEnteredAt(repo, rowKey(row), now);
|
|
1525
|
+
if (!age) {
|
|
1526
|
+
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" });
|
|
1527
|
+
offAxis(agentId, row, "board history unreadable");
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
onAxis(agentId, row, "review");
|
|
1531
|
+
if (age.minutes > reviewLimit) {
|
|
1532
|
+
hits.push({ kind: "in-review-too-long", agentId, stream: row.stream.slice(0, 60), minutes: age.minutes, since: age.since });
|
|
1533
|
+
}
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
// ⟨q-7b2f6c04⟩ — a branch with nothing on origin is not unmeasurable: it is
|
|
1537
|
+
// a lane between claim and first push, and the CLAIM axis measures it.
|
|
1538
|
+
if (verdict.kind === "unpushed" || verdict.kind === "local-only") {
|
|
1539
|
+
scoreClaim(agentId, row, refInCell(row.branchWorktree), `its branch is not on origin (${verdict.kind})`);
|
|
401
1540
|
continue;
|
|
402
1541
|
}
|
|
403
1542
|
if (verdict.kind !== "measurable") {
|
|
404
1543
|
unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
|
|
1544
|
+
offAxis(agentId, row, verdict.why);
|
|
405
1545
|
continue;
|
|
406
1546
|
}
|
|
407
1547
|
try {
|
|
@@ -414,12 +1554,13 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
414
1554
|
cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
415
1555
|
}).trim();
|
|
416
1556
|
const age = now - Date.parse(iso);
|
|
417
|
-
|
|
1557
|
+
onAxis(agentId, row, "vcs");
|
|
418
1558
|
if (age > limit) {
|
|
419
1559
|
hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
|
|
420
1560
|
}
|
|
421
1561
|
} catch {
|
|
422
1562
|
unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
|
|
1563
|
+
offAxis(agentId, row, "resolved as a ref but its log could not be read");
|
|
423
1564
|
}
|
|
424
1565
|
}
|
|
425
1566
|
|
|
@@ -427,11 +1568,113 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
427
1568
|
// verdict about it; a row where every predicate came back unmeasurable was
|
|
428
1569
|
// looked at and not measured, and counting it as checked is how "the clock
|
|
429
1570
|
// ran" gets mistaken for "the fleet is observed".
|
|
430
|
-
const owners =
|
|
1571
|
+
const owners = scored.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
|
|
431
1572
|
const blind = [...new Set(owners)].filter((id) => !measured.has(id));
|
|
432
|
-
const measurable = Math.max(0,
|
|
433
|
-
|
|
434
|
-
|
|
1573
|
+
const measurable = Math.max(0, scored.length - blind.length);
|
|
1574
|
+
// ⟨q-d0e83b41⟩ (09-14 amend) — the population's source and its delta since
|
|
1575
|
+
// the last run of THIS repo. A lane that left with no closing state on the
|
|
1576
|
+
// board (its row is gone) is a board-owner hit, never an improved ratio.
|
|
1577
|
+
const runPopulation: RunPopulation = {
|
|
1578
|
+
repo, source: boardSourceOf(repo),
|
|
1579
|
+
scored: scored.map(rowKey), roles: roles.map((r) => r.stream), deliberate: deliberate.map((d) => d.stream), held: held.map((h) => h.stream),
|
|
1580
|
+
};
|
|
1581
|
+
const prev = lastPopulationFor(repo);
|
|
1582
|
+
const ownerOf = (key: string) => (rows.find((r) => rowKey(r) === key)?.owner ?? "").replace(/[`*]/g, "").trim();
|
|
1583
|
+
const whereNow = (key: string): string => {
|
|
1584
|
+
const row = rows.find((r) => rowKey(r) === key);
|
|
1585
|
+
if (!row) return "gone";
|
|
1586
|
+
if (runPopulation.held.includes(row.stream.slice(0, 60))) return "held";
|
|
1587
|
+
if (runPopulation.deliberate.includes(row.stream.slice(0, 60))) return "deliberate";
|
|
1588
|
+
return workStateOf(row.status);
|
|
1589
|
+
};
|
|
1590
|
+
const delta = prev
|
|
1591
|
+
? {
|
|
1592
|
+
since: new Date(prev.at).toISOString(),
|
|
1593
|
+
left: prev.population.scored.filter((k) => !runPopulation.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), to: whereNow(k) })),
|
|
1594
|
+
joined: runPopulation.scored.filter((k) => !prev.population.scored.includes(k)).map((k) => ({ stream: k.slice(0, 60), owner: ownerOf(k) })),
|
|
1595
|
+
}
|
|
1596
|
+
: { since: null, left: [], joined: [] };
|
|
1597
|
+
for (const l of delta.left) {
|
|
1598
|
+
if (l.to !== "gone") continue;
|
|
1599
|
+
hits.push({
|
|
1600
|
+
kind: "lane-left-population", agentId: boardOwnerOf(boardText) ?? "-", stream: l.stream,
|
|
1601
|
+
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.`,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
// THE BOARD'S OWN FRESHNESS BESIDE EVERY VERDICT. A stall report over stale
|
|
1605
|
+
// cells is a report about the board's maintenance, and the reader must be
|
|
1606
|
+
// able to see which it is without opening git.
|
|
1607
|
+
const freshness = rowFreshness(repo, boardText, scored, now);
|
|
1608
|
+
const aged = freshness.map((f) => f.minutesAgo).filter((m): m is number => typeof m === "number");
|
|
1609
|
+
|
|
1610
|
+
// ⟨q-6f0a3d81⟩ — THE AXIS THAT NEEDS NOBODY TO SPEAK. Computed over the
|
|
1611
|
+
// repository, not the board: a stranded PR has no row, which is the point.
|
|
1612
|
+
// `checked`/`measurable` stay the lane population `coord_away` reads; these
|
|
1613
|
+
// hits join `hits` so the duty officer is DMed. Its unreadable SOURCES are
|
|
1614
|
+
// reported under its own key, not in the per-row `unmeasurable` list: that
|
|
1615
|
+
// list is a population of lane rows (a test pins "two halves of one row"),
|
|
1616
|
+
// and an unreachable gh is not a row. Separate populations, separate keys.
|
|
1617
|
+
const arte = artefacts(repo);
|
|
1618
|
+
const roomText = readRooms();
|
|
1619
|
+
const stranded = strandedWork(repo, arte, roomText, now, limit, new Set(Object.keys(reg)));
|
|
1620
|
+
hits.push(...stranded.hits);
|
|
1621
|
+
// ⟨q-8f1e604b⟩ — the two conventions, checked from the artefacts: PR pages
|
|
1622
|
+
// for (e), the bus's own routing records against the board and queue for (f).
|
|
1623
|
+
const queuePath = path.join(repo, "docs/QUEUE.md");
|
|
1624
|
+
const conventions = conventionChecks({
|
|
1625
|
+
recentMerges: arte.recentMerges,
|
|
1626
|
+
routingLogText: `${roomText}\n${readInboxes()}`,
|
|
1627
|
+
queueText: existsSync(queuePath) ? readFileSync(queuePath, "utf8") : null,
|
|
1628
|
+
boardRows: rows,
|
|
1629
|
+
now,
|
|
1630
|
+
});
|
|
1631
|
+
hits.push(...conventions.hits);
|
|
1632
|
+
// ⟨q-4e08b3c1⟩ — convention (g): the bus's windows joined to git's write times.
|
|
1633
|
+
const mergeWindows = mergeWindowChecks({ logText: roomText, writes: recordWritesOn(repo, now - CONVENTION_WINDOW_MS), now });
|
|
1634
|
+
hits.push(...mergeWindows.hits);
|
|
1635
|
+
|
|
1636
|
+
const stamped = withAudience(hits);
|
|
1637
|
+
const dmBy = { duty: stamped.filter((h) => h.audience === "duty").length, "board-owner": stamped.filter((h) => h.audience === "board-owner").length };
|
|
1638
|
+
const result = {
|
|
1639
|
+
hits: stamped,
|
|
1640
|
+
// ⟨q-7e3b10c9⟩ — who each hit is for, counted, so a relayer can split.
|
|
1641
|
+
dmBy,
|
|
1642
|
+
held,
|
|
1643
|
+
// ⟨q-3d82f1a9⟩ — the parse state travels with every answer, readable or not.
|
|
1644
|
+
boardParse,
|
|
1645
|
+
// The population the clock SCORES: lanes with a ref position to read. Roles
|
|
1646
|
+
// and deliberately branchless lanes are reported beside it, not inside it.
|
|
1647
|
+
checked: scored.length,
|
|
1648
|
+
unmeasurable,
|
|
1649
|
+
measurable,
|
|
1650
|
+
blind,
|
|
1651
|
+
roles,
|
|
1652
|
+
deliberate,
|
|
1653
|
+
disguisedRoles: disguised.map((r) => r.stream.slice(0, 60)),
|
|
1654
|
+
predicates: {
|
|
1655
|
+
"in-progress": `heartbeat (where a source exists) + branch activity, window ${Math.round(limit / 60000)}m`,
|
|
1656
|
+
"in-review": `time in review from the board's git history, window ${reviewLimit}m — branch activity is NOT scored`,
|
|
1657
|
+
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`,
|
|
1658
|
+
},
|
|
1659
|
+
boardFreshness: {
|
|
1660
|
+
rows: freshness,
|
|
1661
|
+
stalestMinutes: aged.length ? Math.max(...aged) : null,
|
|
1662
|
+
uncommitted: freshness.filter((f) => f.lastUpdated === "uncommitted").length,
|
|
1663
|
+
note:
|
|
1664
|
+
"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.",
|
|
1665
|
+
},
|
|
1666
|
+
// Population beside every count: `checked` is the in-flight rows; this is
|
|
1667
|
+
// the rest of the board by the state the seam read, and the not-in-flight,
|
|
1668
|
+
// not-finished rows by name.
|
|
1669
|
+
population: { rows: rows.length, inFlight: inFlight.length, byState, source: runPopulation.source, delta },
|
|
1670
|
+
notWatched,
|
|
1671
|
+
// ⟨q-7b2f6c04⟩ — per-row axis, beside the one number.
|
|
1672
|
+
axes,
|
|
1673
|
+
stranded: { openPrs: stranded.openPrs, pushedBranches: stranded.pushedBranches, unmeasurable: stranded.unmeasurable },
|
|
1674
|
+
conventions: { merges: conventions.merges, routed: conventions.routed, closings: conventions.closings, unmeasurable: conventions.unmeasurable },
|
|
1675
|
+
mergeWindows: { windows: mergeWindows.windows, floor: new Date(MERGE_WINDOW_ADOPTED_MS).toISOString(), unmeasurable: mergeWindows.unmeasurable },
|
|
1676
|
+
};
|
|
1677
|
+
markRun({ hits: stamped, checked: scored.length, measurable, population: runPopulation });
|
|
435
1678
|
return {
|
|
436
1679
|
ok: true as const,
|
|
437
1680
|
...result,
|
|
@@ -441,7 +1684,7 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
|
|
|
441
1684
|
dm: hits.length > 0,
|
|
442
1685
|
note:
|
|
443
1686
|
hits.length === 0
|
|
444
|
-
? `MISS — ${
|
|
1687
|
+
? `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.`
|
|
445
1688
|
: undefined,
|
|
446
1689
|
};
|
|
447
1690
|
}
|