@feigi/fleet-ctl 3.19.7 → 3.20.0

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.
@@ -0,0 +1,543 @@
1
+ #!/usr/bin/env node
2
+ // The per-ticket router: which implementer cell a Pull dispatches. One script
3
+ // for dispatch and for the offline fit, because the stratum rule is the one
4
+ // function that must never read differently between the two.
5
+ //
6
+ // route --session <dir|id> --ticket <N> --arm A|B --impl-row <k>
7
+ // --issue <issue.json> --guard <cost-guard.json>
8
+ // [--sizing <sizing.json>] [--table <router-table.json>]
9
+ // [--chain-head] [--pending <tsv>]
10
+ // Prints one line, `POLICY=<cell> CELL=<cell> DRAW=<k/K|->
11
+ // STRATUM=<light|heavy|unknown> REASON=<reason>`, and appends the Pull's
12
+ // ticket-features row to the pending TSV (by default
13
+ // `ticket-features.pending.tsv` beside `--guard`, in the run's `.fleet/`). Exit 0 on
14
+ // every data degradation — a missing or unreadable issue, sizing or guard
15
+ // file degrades the line, never the exit. Exit 2 only on a usage error or
16
+ // an unreadable/invalid table: the caller does not dispatch then.
17
+ // fit --table <json> --features <tsv> --members <tsv> --verdicts <tsv>
18
+ // --guard <cost-guard.json> [--due]
19
+ // Re-fits the table from the three TSVs and the guard and rewrites
20
+ // `--table`. With `--due`, writes nothing and prints `DUE=yes|no
21
+ // MERGED=<n>/<N>`: whether N merged PRs have landed since
22
+ // `fitted_through`.
23
+ // --check --table <json> --features <tsv> --members <tsv> --verdicts <tsv>
24
+ // Re-fits from the rows the table was fitted through, against the guard
25
+ // snapshot the table itself records, and exits 1 when the result is not
26
+ // the table: the table is a code carrier, never hand-edited.
27
+ //
28
+ // The script never touches the network: the issue, the sizing verdict and
29
+ // the guard are files the caller wrote.
30
+
31
+ import { readFileSync, writeFileSync, renameSync, existsSync, appendFileSync, mkdirSync } from "node:fs";
32
+ import { basename, dirname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { isDeepStrictEqual } from "node:util";
35
+ import { isCLI } from "./is-cli.mjs";
36
+ import { makeDie, defineFlags } from "./arg.mjs";
37
+ import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
38
+ import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
39
+ import { parseTierOutcomes } from "./tier-outcomes.mjs";
40
+
41
+ const NAME = "ticket-router";
42
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
43
+ export const DEFAULT_TABLE = join(SCRIPT_DIR, "router-table.json");
44
+
45
+ // The ticket-features row, one per Pull, in this order.
46
+ export const COLUMNS = Object.freeze([
47
+ "run_date", "session", "agent", "ticket", "policy_cell", "chosen_cell",
48
+ "exploration_draw", "sizing_src", "sizing_pre", "router_usd", "brief_chars",
49
+ "criteria", "comments", "age_days", "paths", "test_paths", "xrefs", "kind",
50
+ ]);
51
+
52
+ export const STRATA = Object.freeze(["light", "heavy", "unknown"]);
53
+ // The free classifier's thresholds: heavy past the median brief length of
54
+ // the tickets whose sizing verdict is known, or past five criteria.
55
+ const HEAVY_BRIEF_CHARS = 3483;
56
+ const HEAVY_CRITERIA = 5;
57
+ // Exploration cadence after burn-in: the Pull that creates `impl-` row 5k.
58
+ const EXPLORE_EVERY = 5;
59
+ // The fit's adoption floor (n per side), margin, and cadence.
60
+ const MIN_N = 20;
61
+ const ADOPT_RATIO = 0.75;
62
+ export const FIT_EVERY_MERGED = 50;
63
+ // Stage 2 adds the effort cells on the roles whose stage-1 cell survived.
64
+ const STAGE2 = Object.freeze([{ cell: "slow-medium", survives: "slow-high" }, { cell: "task-max", survives: "task-high" }]);
65
+ const KINDS = new Set(["bug", "enhancement", "documentation"]);
66
+ const DAY_MS = 86_400_000;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // metrics — every one free, deterministic and known before dispatch
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** The brief: the last comment carrying an `## Agent Brief` heading line, else the body. */
73
+ export function briefText(issue) {
74
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
75
+ for (let i = comments.length - 1; i >= 0; i--) {
76
+ const body = String(comments[i]?.body ?? "");
77
+ if (/^## Agent Brief/m.test(body)) return body;
78
+ }
79
+ return String(issue.body ?? "");
80
+ }
81
+
82
+ /** The metric columns for a parsed `gh issue view --json title,body,comments,labels,createdAt`, or null when it is not one. */
83
+ export function issueMetrics(issue, now = Date.now()) {
84
+ if (issue === null || typeof issue !== "object" || Array.isArray(issue) || typeof issue.body !== "string") return null;
85
+ const brief = briefText(issue).trim();
86
+ const lines = brief.split(/\r?\n/);
87
+ const paths = new Set();
88
+ for (const m of brief.matchAll(/`([^`\n]+)`/g)) {
89
+ const tok = m[1];
90
+ if (/\s/.test(tok)) continue;
91
+ if (tok.includes("/") || /\.[A-Za-z][A-Za-z0-9]*$/.test(tok)) paths.add(tok);
92
+ }
93
+ const created = Date.parse(issue.createdAt ?? "");
94
+ const kind = (Array.isArray(issue.labels) ? issue.labels : []).map((l) => l?.name).find((n) => KINDS.has(n)) ?? "";
95
+ return {
96
+ brief_chars: String([...brief].length),
97
+ criteria: String(lines.filter((l) => /^\s*[-*]\s*\[[ xX]\]/.test(l)).length),
98
+ comments: String(Array.isArray(issue.comments) ? issue.comments.length : 0),
99
+ age_days: Number.isFinite(created) ? String(Math.max(0, Math.floor((now - created) / DAY_MS))) : "",
100
+ paths: String(paths.size),
101
+ test_paths: String([...paths].filter((p) => /\.test\.|(^|\/)tests?\//.test(p)).length),
102
+ xrefs: String(new Set([...brief.matchAll(/#(\d+)/g)].map((m) => m[1])).size),
103
+ kind,
104
+ };
105
+ }
106
+
107
+ /** The `rule` classifier, over a row's metric columns: blank metrics are `unknown`. */
108
+ export function ruleStratum({ brief_chars, criteria }) {
109
+ if (brief_chars === "" || brief_chars === undefined || criteria === "" || criteria === undefined) return "unknown";
110
+ return Number(brief_chars) > HEAVY_BRIEF_CHARS || Number(criteria) > HEAVY_CRITERIA ? "heavy" : "light";
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // the table
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** A parsed table, or an Error saying why it is not one. */
118
+ export function validateTable(t) {
119
+ const bad = (why) => new Error(`router table: ${why}`);
120
+ if (t === null || typeof t !== "object" || Array.isArray(t)) return bad("not a JSON object");
121
+ if (!Array.isArray(t.cells) || t.cells.length === 0) return bad("`cells` is not a non-empty array");
122
+ for (const c of t.cells) if (typeof c !== "string" || !CELL.test(c)) return bad(`\`cells\` holds ${JSON.stringify(c)}, not a cell token`);
123
+ if (new Set(t.cells).size !== t.cells.length) return bad("`cells` repeats a cell");
124
+ if (t.rows === null || typeof t.rows !== "object" || Array.isArray(t.rows)) return bad("`rows` is not an object");
125
+ if (!Object.hasOwn(t.rows, "*")) return bad("`rows` has no `*` row");
126
+ for (const [k, v] of Object.entries(t.rows)) {
127
+ if (k !== "*" && !STRATA.includes(k)) return bad(`\`rows\` key ${JSON.stringify(k)} is not \`*\` or a stratum`);
128
+ if (typeof v !== "string" || !CELL.test(v)) return bad(`\`rows.${k}\` is ${JSON.stringify(v)}, not a cell token`);
129
+ }
130
+ if (typeof t.burn_in !== "boolean") return bad("`burn_in` is not a boolean");
131
+ if (!Number.isInteger(t.stage) || t.stage < 1) return bad("`stage` is not a positive integer");
132
+ const cl = t.classifier;
133
+ if (cl === null || typeof cl !== "object") return bad("`classifier` is not an object");
134
+ if (cl.b !== null && cl.b !== "haiku" && cl.b !== "jev") return bad("`classifier.b` is not null, \"haiku\" or \"jev\"");
135
+ if (cl.tau !== null && typeof cl.tau !== "number") return bad("`classifier.tau` is not null or a number");
136
+ const g = t.guard;
137
+ if (g === null || typeof g !== "object" || !Array.isArray(g.tripped) || g.n === null || typeof g.n !== "object") {
138
+ return bad("`guard` is not `{ tripped: [], n: {} }`");
139
+ }
140
+ return t;
141
+ }
142
+
143
+ /** Arm B runs only once a classifier has passed its gate and some row is not the default. */
144
+ export function bArmLive(table) {
145
+ return table.classifier.b !== null && Object.values(table.rows).some((c) => c !== POLICY_CELL);
146
+ }
147
+
148
+ const rowFor = (rows, stratum) => rows[stratum] ?? rows["*"];
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // route
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function readJson(path) {
155
+ if (!path) return null;
156
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
157
+ }
158
+
159
+ /** `cost-guard.json` reduced to what the router reads, or null when missing or unparseable. */
160
+ export function readGuard(raw) {
161
+ if (raw === null || typeof raw !== "object" || !Array.isArray(raw.tripped) || !Array.isArray(raw.cells)) return null;
162
+ const tripped = raw.tripped.map((t) => (typeof t === "string" ? t : t?.cell));
163
+ if (tripped.some((t) => typeof t !== "string" || !CELL.test(t))) return null;
164
+ const n = {};
165
+ for (const c of raw.cells) {
166
+ if (typeof c?.cell !== "string" || !Number.isFinite(c?.n)) return null;
167
+ n[c.cell] = c.n;
168
+ }
169
+ return { tripped: [...new Set(tripped)].sort(), n, window_start: typeof raw.window_start === "string" ? raw.window_start : null };
170
+ }
171
+
172
+ /** The sizing verdict on a live B Pull: `{ stratum, src, label, usd }`, or null when it is unusable. */
173
+ function readSizing(raw, classifier) {
174
+ if (raw === null || typeof raw !== "object" || raw.source !== classifier.b) return null;
175
+ const label = raw.label === "light" || raw.label === "heavy" ? raw.label : null;
176
+ if (label === null) return null;
177
+ const abstains = raw.source === "jev" && classifier.tau !== null && !(Number(raw.confidence) >= classifier.tau);
178
+ return {
179
+ stratum: abstains ? "unknown" : label,
180
+ src: raw.source === "jev" ? `jev:${raw.model ?? ""}` : "haiku",
181
+ label,
182
+ usd: Number.isFinite(raw.usd) ? String(raw.usd) : "",
183
+ };
184
+ }
185
+
186
+ /** The UTC date an omp session id (`<ISO>_<uuid>`) starts with, else blank. */
187
+ export function sessionDate(session) {
188
+ return /^(\d{4}-\d{2}-\d{2})T/.exec(session)?.[1] ?? "";
189
+ }
190
+
191
+ /**
192
+ * One Pull's routing. Pure: every input is already read.
193
+ * @returns {{ line: string, row: Record<string,string> }}
194
+ */
195
+ export function route({ session, ticket, arm, implRow, chainHead = false, issue, sizing = null, guard, table, now = Date.now() }) {
196
+ let reason = null;
197
+ const degrade = (r) => { reason ??= r; };
198
+
199
+ const metrics = issueMetrics(issue, now);
200
+ if (metrics === null) degrade("no-issue-json");
201
+ const blank = Object.fromEntries(["brief_chars", "criteria", "comments", "age_days", "paths", "test_paths", "xrefs", "kind"].map((c) => [c, ""]));
202
+ const m = metrics ?? blank;
203
+ const ruleS = ruleStratum(m);
204
+
205
+ let stratum = ruleS;
206
+ let sizingSrc = "rule";
207
+ let sizingPre = "";
208
+ let routerUsd = "";
209
+ const b = arm === "B" && bArmLive(table);
210
+ if (b) {
211
+ const s = readSizing(sizing, table.classifier);
212
+ if (s === null) {
213
+ degrade("no-sizing");
214
+ stratum = "unknown";
215
+ sizingSrc = "";
216
+ } else {
217
+ ({ stratum, src: sizingSrc, label: sizingPre, usd: routerUsd } = s);
218
+ }
219
+ }
220
+
221
+ let policy = rowFor(table.rows, ruleS);
222
+ let base = b ? rowFor(table.rows, stratum) : policy;
223
+ let cell;
224
+ let draw = "-";
225
+ if (guard === null) {
226
+ // The guard cannot be evaded by deleting it: no guard, default cell only.
227
+ degrade("guard-missing");
228
+ policy = POLICY_CELL;
229
+ cell = POLICY_CELL;
230
+ } else {
231
+ const tripped = new Set(guard.tripped);
232
+ if (tripped.has(policy) || tripped.has(base)) {
233
+ degrade("table-cell-tripped");
234
+ if (tripped.has(policy)) policy = POLICY_CELL;
235
+ if (tripped.has(base)) base = POLICY_CELL;
236
+ }
237
+ cell = base;
238
+ const exploring = table.burn_in || (implRow % EXPLORE_EVERY === 0 && !chainHead);
239
+ if (exploring) {
240
+ const policyCell = table.burn_in ? null : base;
241
+ if (table.cells.some((c) => c !== policyCell && tripped.has(c))) degrade("guard-tripped");
242
+ const d = drawCell({ session, ticket, policyCell, cells: table.cells.filter((c) => !tripped.has(c)) });
243
+ if (d.K > 0) {
244
+ cell = d.cell;
245
+ draw = `${d.k}/${d.K}`;
246
+ }
247
+ }
248
+ }
249
+ reason ??= "ok";
250
+
251
+ const row = {
252
+ run_date: sessionDate(session), session, agent: `impl-${ticket}`, ticket: String(ticket),
253
+ policy_cell: policy, chosen_cell: cell, exploration_draw: draw === "-" ? "" : draw,
254
+ sizing_src: sizingSrc, sizing_pre: sizingPre, router_usd: routerUsd, ...m,
255
+ };
256
+ return { line: `POLICY=${policy} CELL=${cell} DRAW=${draw} STRATUM=${stratum} REASON=${reason}`, row };
257
+ }
258
+
259
+ export function formatFeatureRow(row) {
260
+ return COLUMNS.map((c) => String(row[c] ?? "").replace(/[\t\r\n]/g, " ")).join("\t");
261
+ }
262
+
263
+ /** Data rows of a ticket-features TSV as objects; throws on a missing header or a short row. */
264
+ export function parseFeatures(text) {
265
+ const lines = String(text ?? "").split("\n").filter((l) => l.trim() && !l.startsWith("#"));
266
+ if (lines.length === 0) throw new Error("no header line");
267
+ if (lines[0] !== COLUMNS.join("\t")) throw new Error(`header is not the ${COLUMNS.length} ticket-features columns`);
268
+ return lines.slice(1).map((l) => {
269
+ const cells = l.split("\t");
270
+ if (cells.length !== COLUMNS.length) throw new Error(`malformed row: ${cells.length} fields, expected ${COLUMNS.length} — ${l.slice(0, 60)}`);
271
+ return Object.fromEntries(COLUMNS.map((c, i) => [c, cells[i]]));
272
+ });
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // fit
277
+ // ---------------------------------------------------------------------------
278
+
279
+ // Members whose cost does not vary with the implementer cell.
280
+ const UNBOOKED = (name) => name.startsWith("merge-bot-") || name === "memory" || name === "__advisor";
281
+ const round4 = (x) => Math.round(x * 1e4) / 1e4;
282
+
283
+ /** The last verdict row per ticket: `{ pr, fail }`. A verdict is a ruled PR; the floor fails on a minted false claim or an unclosed ticket. */
284
+ function verdictsByTicket(verdictRows) {
285
+ const by = new Map();
286
+ for (const r of verdictRows) {
287
+ for (const t of String(r.ticket ?? "").split("+").filter(Boolean)) {
288
+ by.set(t, { pr: String(r.pr ?? ""), fail: r.minted_false_claim === "yes" || r.closed_own_ticket === "no" });
289
+ }
290
+ }
291
+ return by;
292
+ }
293
+
294
+ /**
295
+ * Per-ticket input rows: the ticket's features rows inside [window, cutoff],
296
+ * attributed to its LAST row's cell and stratum, restricted to rows the
297
+ * free classifier routed plus exploration rows — a row a live B classifier
298
+ * routed is the A/B's test set, not the fit's. A fit over everything cuts at
299
+ * its latest features row's date; the verdict and member rows are cut at that
300
+ * same date, so `--check`'s re-fit at the recorded `fitted_through` reads the
301
+ * rows the fit read and a verdict or member row that lands later waits for the
302
+ * next fit instead of failing CI.
303
+ */
304
+ function fitTickets({ features, members, verdicts, window, cutoff }) {
305
+ const inRange = features.filter((r) => (!window || r.run_date >= window)
306
+ && (cutoff === undefined || (cutoff !== null && r.run_date <= cutoff)));
307
+ const through = cutoff === undefined ? inRange.map((r) => r.run_date).filter(Boolean).sort().at(-1) ?? null : cutoff;
308
+ const upToThrough = (r) => through === null || !r.run_date || r.run_date <= through;
309
+ members = members.filter(upToThrough);
310
+ const byTicket = new Map();
311
+ for (const r of inRange) {
312
+ if (!byTicket.has(r.ticket)) byTicket.set(r.ticket, []);
313
+ byTicket.get(r.ticket).push(r);
314
+ }
315
+ const v = verdictsByTicket(verdicts.filter(upToThrough));
316
+ const out = [];
317
+ for (const [ticket, rows] of byTicket) {
318
+ const last = rows[rows.length - 1];
319
+ if (last.exploration_draw === "" && last.sizing_src !== "rule") continue;
320
+ const verdict = v.get(ticket) ?? null;
321
+ const keys = new Set(rows.map((r) => `${r.session}\0${r.agent}`));
322
+ let cost = 0;
323
+ let costKnown = true;
324
+ let booked = 0;
325
+ let fixRounds = 0;
326
+ for (const mr of members) {
327
+ const name = String(mr.member ?? mr.agent ?? "");
328
+ if (UNBOOKED(name)) continue;
329
+ if (!(keys.has(`${mr.session}\0${mr.agent}`) || mr.ticket === ticket || (verdict && verdict.pr && mr.pr === verdict.pr))) continue;
330
+ booked++;
331
+ // `cost` is a priced member-outcomes column (#2132); a row without it makes
332
+ // the ticket's $ unknown, so the fit adopts nothing off a TSV that lacks the column.
333
+ if (mr.cost === undefined || mr.cost === "" || !Number.isFinite(Number(mr.cost))) costKnown = false;
334
+ else cost += Number(mr.cost);
335
+ if (parseMember(name)?.family === "fix-pr") fixRounds++;
336
+ }
337
+ // A ticket with no member row on record has an unknown cost, not a free one.
338
+ if (booked === 0) costKnown = false;
339
+ for (const r of rows) if (r.router_usd !== "") cost += Number(r.router_usd) || 0;
340
+ out.push({ ticket, cell: last.chosen_cell, stratum: ruleStratum(last), verdict, cost, costKnown, fixRounds });
341
+ }
342
+ return { tickets: out, used: inRange };
343
+ }
344
+
345
+ function estimate(tickets) {
346
+ const est = {};
347
+ for (const t of tickets) {
348
+ for (const s of [t.stratum, "*"]) {
349
+ est[s] ??= {};
350
+ const e = (est[s][t.cell] ??= { n: 0, merged: 0, fails: 0, cost: 0, costKnown: true, fix: 0 });
351
+ e.n++;
352
+ e.cost += t.cost;
353
+ e.costKnown &&= t.costKnown;
354
+ if (t.verdict) {
355
+ e.merged++;
356
+ if (t.verdict.fail) e.fails++;
357
+ e.fix += t.fixRounds;
358
+ }
359
+ }
360
+ }
361
+ const out = {};
362
+ for (const s of Object.keys(est).sort()) {
363
+ out[s] = {};
364
+ for (const c of Object.keys(est[s]).sort()) {
365
+ const e = est[s][c];
366
+ out[s][c] = {
367
+ n: e.n,
368
+ merged: e.merged,
369
+ usd_per_merged: e.merged && e.costKnown ? round4(e.cost / e.merged) : null,
370
+ fail_rate: e.merged ? round4(e.fails / e.merged) : null,
371
+ fix_rounds: e.merged ? round4(e.fix / e.merged) : null,
372
+ review_findings: null,
373
+ };
374
+ }
375
+ }
376
+ return out;
377
+ }
378
+
379
+ // Among cells with n >= MIN_N in a stratum and not tripped, the one with the
380
+ // lowest $ per merged PR — adopted only at or under ADOPT_RATIO of the
381
+ // default cell's. The quality floor is the guard's to enforce, never the
382
+ // fit's. null when the stratum cannot decide (no default cell at n, or no
383
+ // candidate at n).
384
+ function adopt(est, cells, tripped) {
385
+ const base = est?.[POLICY_CELL];
386
+ if (!base || base.n < MIN_N || base.usd_per_merged === null) return null;
387
+ const candidates = cells
388
+ .filter((c) => c !== POLICY_CELL && !tripped.has(c))
389
+ .map((c) => ({ c, e: est[c] }))
390
+ .filter(({ e }) => e && e.n >= MIN_N && e.usd_per_merged !== null)
391
+ .sort((a, b) => a.e.usd_per_merged - b.e.usd_per_merged || (a.c < b.c ? -1 : 1));
392
+ if (candidates.length === 0) return null;
393
+ return candidates[0].e.usd_per_merged <= ADOPT_RATIO * base.usd_per_merged ? candidates[0].c : POLICY_CELL;
394
+ }
395
+
396
+ /**
397
+ * The re-fit table. `guard` is `{ tripped, n, window_start }` (readGuard's
398
+ * shape); `cutoff` limits every input to rows with `run_date <= cutoff`
399
+ * (--check), or is undefined for a fit over everything, which cuts at its
400
+ * latest features row's date.
401
+ */
402
+ export function fitTable({ prior, features, members, verdicts, guard, cutoff }) {
403
+ const window = guard.window_start ?? prior.window_start ?? null;
404
+ const { tickets, used } = fitTickets({ features, members, verdicts, window, cutoff });
405
+ const estimates = estimate(tickets);
406
+ const tripped = new Set(guard.tripped);
407
+
408
+ let { stage, cells } = prior;
409
+ cells = [...cells];
410
+ const rows = { "*": adopt(estimates["*"], cells, tripped) ?? POLICY_CELL };
411
+ for (const s of STRATA) {
412
+ const a = adopt(estimates[s], cells, tripped);
413
+ if (a !== null) rows[s] = a;
414
+ }
415
+ const atN = (c) => (guard.n[c] ?? 0) >= MIN_N;
416
+ if (stage === 1 && cells.every(atN) && (rows["*"] !== POLICY_CELL || cells.some((c) => tripped.has(c)))) {
417
+ stage = 2;
418
+ for (const { cell, survives } of STAGE2) {
419
+ if (!cells.includes(cell) && cells.includes(survives) && !tripped.has(survives)) cells.push(cell);
420
+ }
421
+ }
422
+ const dates = used.map((r) => r.run_date).filter(Boolean).sort();
423
+ return {
424
+ window_start: window,
425
+ fitted_through: dates.length ? dates[dates.length - 1] : prior.fitted_through ?? null,
426
+ n_rows: tickets.length,
427
+ stage,
428
+ cells,
429
+ burn_in: !cells.every(atN),
430
+ rows,
431
+ classifier: prior.classifier,
432
+ estimates,
433
+ guard: { tripped: [...tripped].sort(), n: Object.fromEntries(Object.keys(guard.n).sort().map((c) => [c, guard.n[c]])) },
434
+ };
435
+ }
436
+
437
+ /** Merged PRs (tickets with a verdict) among the features rows dated after `fitted_through`. */
438
+ export function mergedSince({ table, features, verdicts }) {
439
+ const v = verdictsByTicket(verdicts);
440
+ const after = features.filter((r) => (!table.window_start || r.run_date >= table.window_start) && (!table.fitted_through || r.run_date > table.fitted_through));
441
+ return new Set(after.map((r) => r.ticket).filter((t) => v.has(t))).size;
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // CLI
446
+ // ---------------------------------------------------------------------------
447
+
448
+ function main() {
449
+ const die = makeDie(NAME);
450
+ const F = defineFlags(die, {
451
+ flags: {
452
+ session: "value", ticket: "value", arm: "value", "impl-row": "value", issue: "value",
453
+ sizing: "value", guard: "value", table: "value", pending: "value", "chain-head": "bool",
454
+ features: "value", members: "value", verdicts: "value", check: "bool", due: "bool",
455
+ },
456
+ positionals: ["route", "fit"],
457
+ });
458
+ F.sweep();
459
+ F.stray();
460
+ const argv = process.argv.slice(2);
461
+ const mode = F.has("check") ? "check" : argv.find((a) => a === "route" || a === "fit");
462
+ if (!mode || (F.has("check") && argv.some((a) => a === "route" || a === "fit"))) {
463
+ die("usage: ticket-router.mjs route … | fit … | --check … — exactly one mode");
464
+ }
465
+ const allowed = {
466
+ route: ["session", "ticket", "arm", "impl-row", "issue", "sizing", "guard", "table", "pending", "chain-head"],
467
+ fit: ["table", "features", "members", "verdicts", "guard", "due"],
468
+ check: ["check", "table", "features", "members", "verdicts"],
469
+ }[mode];
470
+ const given = argv.filter((a) => a.startsWith("--")).map((a) => a.slice(2).split("=")[0]);
471
+ const foreign = given.find((g) => !allowed.includes(g));
472
+ if (foreign) die(`--${foreign} does not apply to ${mode === "check" ? "--check" : mode}`);
473
+ const need = (name) => F.arg(name) ?? die(`${mode === "check" ? "--check" : mode} needs --${name}`);
474
+
475
+ const tablePath = mode === "route" ? (F.arg("table") ?? DEFAULT_TABLE) : need("table");
476
+ let table;
477
+ try { table = JSON.parse(readFileSync(tablePath, "utf8")); } catch (e) { die(`cannot read the router table ${tablePath}: ${e.message}`); }
478
+ const valid = validateTable(table);
479
+ if (valid instanceof Error) die(`${tablePath}: ${valid.message}`);
480
+
481
+ if (mode === "route") {
482
+ const sessionArg = need("session").replace(/\/+$/, "");
483
+ const session = basename(sessionArg);
484
+ const ticket = need("ticket");
485
+ if (!/^[1-9][0-9]*$/.test(ticket)) die(`--ticket ${ticket} is not an issue number`);
486
+ const arm = need("arm");
487
+ if (arm !== "A" && arm !== "B") die(`--arm ${arm} is not A or B`);
488
+ const implRowArg = need("impl-row");
489
+ if (!/^[1-9][0-9]*$/.test(implRowArg)) die(`--impl-row ${implRowArg} is not a positive row count`);
490
+ const issuePath = need("issue");
491
+ const guardPath = need("guard");
492
+ const { line, row } = route({
493
+ session, ticket: Number(ticket), arm, implRow: Number(implRowArg), chainHead: F.has("chain-head"),
494
+ issue: readJson(issuePath), sizing: readJson(F.arg("sizing")), guard: readGuard(readJson(guardPath)), table,
495
+ });
496
+ const pending = F.arg("pending") ?? join(dirname(guardPath), "ticket-features.pending.tsv");
497
+ try {
498
+ mkdirSync(dirname(pending), { recursive: true });
499
+ const header = existsSync(pending) ? "" : `${COLUMNS.join("\t")}\n`;
500
+ appendFileSync(pending, `${header}${formatFeatureRow(row)}\n`);
501
+ } catch (e) {
502
+ process.stderr.write(`${NAME}: WARNING could not append to ${pending}: ${e.message} — this Pull's ticket-features row is not recorded\n`);
503
+ }
504
+ process.stdout.write(`${line}\n`);
505
+ return;
506
+ }
507
+
508
+ const read = (flag, parse) => {
509
+ const p = need(flag);
510
+ try { return parse(readFileSync(p, "utf8")); } catch (e) { die(`cannot read --${flag} ${p}: ${e.message}`); }
511
+ };
512
+ const features = read("features", parseFeatures);
513
+ const members = read("members", parseMemberTsv);
514
+ const verdicts = read("verdicts", parseTierOutcomes);
515
+
516
+ if (mode === "check") {
517
+ const refit = fitTable({ prior: table, features, members, verdicts, guard: { ...table.guard, window_start: table.window_start }, cutoff: table.fitted_through });
518
+ const differ = Object.keys({ ...refit, ...table }).filter((k) => !isDeepStrictEqual(refit[k], table[k]));
519
+ if (differ.length) {
520
+ process.stderr.write(`${NAME}: ${tablePath} is not a re-fit of its own rows — differs in ${differ.map((k) => `\`${k}\``).join(", ")}; re-fitted:\n${JSON.stringify(refit, null, 2)}\n`);
521
+ // exitCode, not exit(): a piped stderr is written asynchronously and exit() can cut the payload off.
522
+ process.exitCode = 1;
523
+ return;
524
+ }
525
+ process.stdout.write(`${NAME}: ${tablePath} matches a re-fit of ${refit.n_rows} tickets through ${refit.fitted_through ?? "no rows"}\n`);
526
+ return;
527
+ }
528
+
529
+ const guardPath = need("guard");
530
+ const guard = readGuard(readJson(guardPath));
531
+ if (guard === null) die(`cannot read --guard ${guardPath} as a cost guard — the fit needs its tripped cells and per-cell n`);
532
+ if (F.has("due")) {
533
+ const n = mergedSince({ table, features, verdicts });
534
+ process.stdout.write(`DUE=${n >= FIT_EVERY_MERGED ? "yes" : "no"} MERGED=${n}/${FIT_EVERY_MERGED}\n`);
535
+ return;
536
+ }
537
+ const next = fitTable({ prior: table, features, members, verdicts, guard });
538
+ writeFileSync(`${tablePath}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
539
+ renameSync(`${tablePath}.tmp`, tablePath);
540
+ process.stdout.write(`${NAME}: fitted ${next.n_rows} tickets through ${next.fitted_through ?? "no rows"}; rows ${JSON.stringify(next.rows)}; burn_in ${next.burn_in}; stage ${next.stage}\n`);
541
+ }
542
+
543
+ if (isCLI(import.meta.url)) main();