@feigi/fleet-ctl 3.19.8 → 3.21.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.
- package/package.json +1 -1
- package/scripts/ambient-git-vars-mjs-prose.test.mjs +12 -0
- package/scripts/fleet-tick.mjs +65 -3
- package/scripts/fleet-tick.test.mjs +100 -1
- package/scripts/ledger-dispatch.test.mjs +34 -9
- package/scripts/ledger-grammar.mjs +10 -4
- package/scripts/ledger.mjs +6 -1
- package/scripts/member-outcomes.mjs +21 -12
- package/scripts/member-outcomes.test.mjs +68 -11
- package/scripts/member-record.mjs +43 -13
- package/scripts/member-record.test.mjs +24 -0
- package/scripts/pr-cost.mjs +493 -0
- package/scripts/pr-cost.test.mjs +521 -0
- package/scripts/review-core-booking.test.mjs +35 -0
- package/scripts/review-core-cwd-isolation.test.mjs +5 -5
- package/scripts/review-core-shared-test-run.test.mjs +1 -1
- package/scripts/review-core-testcmd.test.mjs +1 -1
- package/scripts/review-core.mjs +8 -4
- package/scripts/review-host-fixture.mjs +12 -3
- package/scripts/review-in-run-retry.test.mjs +4 -4
- package/scripts/router-table.json +24 -0
- package/scripts/select-dimensions.test.mjs +2 -2
- package/scripts/ticket-router.mjs +543 -0
- package/scripts/ticket-router.test.mjs +981 -0
- package/scripts/within-run-pair-prose.test.mjs +46 -10
- package/skills/run-team/SKILL.md +59 -32
package/package.json
CHANGED
|
@@ -255,6 +255,18 @@ const COVERED_MJS = {
|
|
|
255
255
|
// cache reader child gets the same env but unsets both itself, so no
|
|
256
256
|
// fixture can tell its env apart.
|
|
257
257
|
"recipe-prove.mjs": 1,
|
|
258
|
+
// TWO scrubbed children. `defaultGuardPath()`'s `rev-parse --git-common-dir`
|
|
259
|
+
// names the workspace the guard file is written into: an ambient GIT_DIR
|
|
260
|
+
// would answer for ANOTHER repository, and the verdict would land in its
|
|
261
|
+
// `.fleet/` at exit 0 while fleet-tick reads this one's. Measured in
|
|
262
|
+
// pr-cost.test.mjs, "an ambient GIT_DIR naming another repository cannot move
|
|
263
|
+
// the guard file out of the repository pr-cost runs in". The `gh pr list`
|
|
264
|
+
// child is the second: gh's remote resolution follows GIT_DIR too (measured
|
|
265
|
+
// for shortlist.mjs's `probeState()` above), so merged state could be read
|
|
266
|
+
// for another repository's PRs; the gh stub in pr-cost.test.mjs, "an ambient
|
|
267
|
+
// GIT_DIR does not reach the gh child that reads merged state", records
|
|
268
|
+
// that the child sees no GIT_DIR.
|
|
269
|
+
"pr-cost.mjs": 2,
|
|
258
270
|
};
|
|
259
271
|
|
|
260
272
|
// The two-name-only exemption list #1599's second design question answers
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
// Anything but `clean` — dirty, unknown, no baseline — prints one
|
|
46
46
|
// MAIN-CHECKOUT-* line and holds every dispatching row until the
|
|
47
47
|
// maintainer clears it; it never refuses the tick.
|
|
48
|
+
// guard `.fleet/cost-guard.json` beside the shortlist, pr-cost.mjs
|
|
49
|
+
// --guard's verdict on the implementer cells. Printed as the
|
|
50
|
+
// `router` row and never acted on here: a missing or unreadable
|
|
51
|
+
// file reads DEFAULT-ONLY, the way the router (not in the tree
|
|
52
|
+
// yet; ADR 0016) is to read it. pr-cost.mjs writes the main
|
|
53
|
+
// workspace's file by default, the one read here.
|
|
48
54
|
//
|
|
49
55
|
// The pure half below is `reconcile()` over the counts `deriveRun()` reads off
|
|
50
56
|
// the ledger; main() does the I/O. Split so the guard table and the reading are
|
|
@@ -61,7 +67,7 @@ import { parseMember, parseToken } from "./ledger-grammar.mjs";
|
|
|
61
67
|
// reads what they leave: the slots this tick's own dispatches fill.
|
|
62
68
|
export function reconcile(s) {
|
|
63
69
|
const rev = reviewers(s);
|
|
64
|
-
return [...mainCheckoutHold(s, [implementers(s, rev.left), ...rev.rows, mergeBot(s)]), ...shortlistRows(s)];
|
|
70
|
+
return [...mainCheckoutHold(s, [implementers(s, rev.left), ...rev.rows, mergeBot(s)]), ...shortlistRows(s), ...routerRows(s)];
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
// #2210: a main checkout changed since the run's baseline — or one the tick
|
|
@@ -254,6 +260,59 @@ function shortlistRows(s) {
|
|
|
254
260
|
}];
|
|
255
261
|
}
|
|
256
262
|
|
|
263
|
+
// The cost guard's verdict, as pr-cost.mjs --guard wrote it: OK, DEFAULT-ONLY
|
|
264
|
+
// (a cell tripped, or no readable guard file — the router then runs the
|
|
265
|
+
// default cell alone), or NO VERDICT (the baseline is short of its n). The
|
|
266
|
+
// controller has nothing to do about any of them inside a run, so the row
|
|
267
|
+
// never acts; it is here so the verdict is printed by code, not recalled.
|
|
268
|
+
// Every verdict carries the guard's own `computed_at`: a refresh that failed
|
|
269
|
+
// leaves the old file on disk, and only its age says the OK is not current.
|
|
270
|
+
// No `router` state at all (a caller that read no guard) prints no row.
|
|
271
|
+
export function routerRows(s) {
|
|
272
|
+
const g = s.router;
|
|
273
|
+
if (g === undefined) return [];
|
|
274
|
+
const row = (action, detail) => [{ role: "router", action, acts: false, detail }];
|
|
275
|
+
const run = "run pr-cost.mjs --guard";
|
|
276
|
+
if (g.status === "missing") return row("DEFAULT-ONLY", `cost-guard.json missing — ${run}`);
|
|
277
|
+
if (g.status !== "ok") return row("DEFAULT-ONLY", `cost-guard.json unreadable (${g.why}) — ${run}`);
|
|
278
|
+
const { baseline: b, cells, tripped, verdict, min_n: minN, computed_at: computed } = g.guard;
|
|
279
|
+
const age = `guard computed ${computed}`;
|
|
280
|
+
if (verdict === "none") return row("NO VERDICT", `baseline n=${b.n}/${minN}; ${age}`);
|
|
281
|
+
const pct = (x) => (x === null ? "n/a" : `${Math.round(x * 100)}%`);
|
|
282
|
+
const usd = (x) => (x === null ? "n/a" : `$${x.toFixed(2)}`);
|
|
283
|
+
const vs = (c) => `${c.cell} ${usd(c.mean_usd)} vs ${usd(b.mean_usd)}, fail ${pct(c.fail_rate)} vs ${pct(b.fail_rate)}, n=${c.n}/${b.n}`;
|
|
284
|
+
if (verdict === "tripped") {
|
|
285
|
+
const retired = g.guard.retire ? "; every non-default stage-1 cell tripped — the router retires" : "";
|
|
286
|
+
return row("DEFAULT-ONLY", `cost guard: ${cells.filter((c) => tripped.includes(c.cell)).map(vs).join("; ")}${retired}; ${age}`);
|
|
287
|
+
}
|
|
288
|
+
const others = cells.filter((c) => c.cell !== b.cell);
|
|
289
|
+
return row("OK", `${others.length ? others.map(vs).join("; ") : `baseline ${b.cell} ${usd(b.mean_usd)}, n=${b.n}`}; ${age}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// pr-cost.mjs's guard file, read as the router is to read it: anything but a
|
|
293
|
+
// well-formed, self-consistent verdict is not one.
|
|
294
|
+
export function readCostGuard(path) {
|
|
295
|
+
if (path === null) return { status: "missing" };
|
|
296
|
+
let text;
|
|
297
|
+
try {
|
|
298
|
+
text = readFileSync(path, "utf8");
|
|
299
|
+
} catch (e) {
|
|
300
|
+
return e.code === "ENOENT" ? { status: "missing" } : { status: "unreadable", why: e.code ?? e.message };
|
|
301
|
+
}
|
|
302
|
+
let guard;
|
|
303
|
+
try { guard = JSON.parse(text); } catch { return { status: "unreadable", why: "not JSON" }; }
|
|
304
|
+
const cellShaped = (c) => c && typeof c.cell === "string" && Number.isInteger(c.n)
|
|
305
|
+
&& (c.mean_usd === null || typeof c.mean_usd === "number") && (c.fail_rate === null || typeof c.fail_rate === "number");
|
|
306
|
+
const ok = guard && ["ok", "tripped", "none"].includes(guard.verdict) && typeof guard.computed_at === "string"
|
|
307
|
+
&& cellShaped(guard.baseline) && Array.isArray(guard.cells) && guard.cells.every(cellShaped) && Number.isInteger(guard.min_n)
|
|
308
|
+
&& Array.isArray(guard.tripped) && guard.tripped.every((c) => typeof c === "string")
|
|
309
|
+
// The verdict is "tripped" exactly when a cell is named, and only a reported cell can be named.
|
|
310
|
+
&& (guard.verdict === "tripped") === (guard.tripped.length > 0)
|
|
311
|
+
&& guard.tripped.every((c) => guard.cells.some((x) => x.cell === c))
|
|
312
|
+
&& (guard.retire === undefined || typeof guard.retire === "boolean");
|
|
313
|
+
return ok ? { status: "ok", guard } : { status: "unreadable", why: "not a guard verdict" };
|
|
314
|
+
}
|
|
315
|
+
|
|
257
316
|
export function formatLines(rows) {
|
|
258
317
|
const w = Math.max(...rows.map((r) => r.role.length));
|
|
259
318
|
return rows.map((r) => r.actual === undefined
|
|
@@ -1086,7 +1145,10 @@ function claimed() {
|
|
|
1086
1145
|
|
|
1087
1146
|
function main() {
|
|
1088
1147
|
const { fold, state: path, ...caps } = options();
|
|
1089
|
-
const
|
|
1148
|
+
const listPath = shortlistPath();
|
|
1149
|
+
const current = readShortlist(listPath);
|
|
1150
|
+
// Beside the shortlist, off the same git probe rather than a second one.
|
|
1151
|
+
const router = readCostGuard(listPath === null ? null : join(dirname(listPath), "cost-guard.json"));
|
|
1090
1152
|
|
|
1091
1153
|
// The PRIOR run's liveness, before anything that can refuse — #1597. The gh
|
|
1092
1154
|
// read and the ledger read below both exit 2 on failure, and a stall
|
|
@@ -1142,7 +1204,7 @@ function main() {
|
|
|
1142
1204
|
const mainCheckout = checkMainCheckout();
|
|
1143
1205
|
|
|
1144
1206
|
const rows = reconcile({
|
|
1145
|
-
...caps, ...run, heads, supply, shortlistStatus: current.status, refresh: fresh, mainCheckout,
|
|
1207
|
+
...caps, ...run, heads, supply, shortlistStatus: current.status, refresh: fresh, mainCheckout, router,
|
|
1146
1208
|
});
|
|
1147
1209
|
const said = describe(mainCheckout, run.live);
|
|
1148
1210
|
const lines = [...(said === null ? [] : [said]), ...formatLines(rows)];
|
|
@@ -278,6 +278,93 @@ test("no refresh, no shortlist row", () => {
|
|
|
278
278
|
assert.deepEqual(reconcile(state()).map((r) => r.role), ["implementers", "reviewers", "merge-bot"]);
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// The router row: pr-cost.mjs --guard's verdict, read off a guard file the way
|
|
283
|
+
// the tick reads it. One case per state, each against a fixture file.
|
|
284
|
+
|
|
285
|
+
import { routerRows, readCostGuard } from "./fleet-tick.mjs";
|
|
286
|
+
|
|
287
|
+
const BASE = { cell: "slow-high", n: 31, mean_usd: 31, fail_rate: 0.29 };
|
|
288
|
+
const guardJson = (over = {}) => JSON.stringify({
|
|
289
|
+
computed_at: "2026-10-03T00:00:00.000Z", window_start: "2026-10-03", baseline: BASE,
|
|
290
|
+
cells: [{ cell: "slow-high", n: 31, mean_usd: 31, fail_rate: 0.29 }, { cell: "task-high", n: 24, mean_usd: 12.3, fail_rate: 0.25 }],
|
|
291
|
+
tripped: [], verdict: "ok", retire: false, min_n: 20, ...over,
|
|
292
|
+
});
|
|
293
|
+
function routerRowOf(text) {
|
|
294
|
+
const dir = mkdtempSync(join(tmpdir(), "fleet-tick-guard-"));
|
|
295
|
+
try {
|
|
296
|
+
const path = join(dir, "cost-guard.json");
|
|
297
|
+
if (text !== null) writeFileSync(path, text);
|
|
298
|
+
const rows = reconcile(state({ router: readCostGuard(path) }));
|
|
299
|
+
assert.equal(actionable(rows), false, "no router state is work for the controller");
|
|
300
|
+
const r = rows.filter((x) => x.role === "router");
|
|
301
|
+
assert.equal(r.length, 1);
|
|
302
|
+
assert.equal(rows.at(-1), r[0], "the router row prints last");
|
|
303
|
+
return { ...r[0], line: formatLines(rows).at(-1) };
|
|
304
|
+
} finally {
|
|
305
|
+
rmSync(dir, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
test("router: a guard with no cell tripped is OK, naming each cell against the baseline", () => {
|
|
310
|
+
const r = routerRowOf(guardJson());
|
|
311
|
+
assert.equal(r.action, "OK");
|
|
312
|
+
assert.equal(r.acts, false);
|
|
313
|
+
assert.equal(r.line, "router → OK (task-high $12.30 vs $31.00, fail 25% vs 29%, n=24/31; guard computed 2026-10-03T00:00:00.000Z)");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("router: the row carries the guard's own computed_at, so a stale OK reads as old", () => {
|
|
317
|
+
const r = routerRowOf(guardJson({ computed_at: "2026-09-01T08:00:00.000Z" }));
|
|
318
|
+
assert.equal(r.action, "OK");
|
|
319
|
+
assert.match(r.detail, /; guard computed 2026-09-01T08:00:00\.000Z$/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("router: a tripped cell is DEFAULT-ONLY, naming the cost guard and the cell it tripped", () => {
|
|
323
|
+
const r = routerRowOf(guardJson({
|
|
324
|
+
verdict: "tripped", tripped: ["smol-high"],
|
|
325
|
+
cells: [{ ...BASE }, { cell: "smol-high", n: 21, mean_usd: 9.5, fail_rate: 0.45 }, { cell: "task-high", n: 24, mean_usd: 12.3, fail_rate: 0.25 }],
|
|
326
|
+
}));
|
|
327
|
+
assert.equal(r.action, "DEFAULT-ONLY");
|
|
328
|
+
assert.equal(r.detail, "cost guard: smol-high $9.50 vs $31.00, fail 45% vs 29%, n=21/31; guard computed 2026-10-03T00:00:00.000Z");
|
|
329
|
+
const retired = routerRowOf(guardJson({ verdict: "tripped", tripped: ["task-high"], retire: true }));
|
|
330
|
+
assert.match(retired.detail, /^cost guard: task-high .*; every non-default stage-1 cell tripped — the router retires; guard computed /);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("router: a baseline short of its n is NO VERDICT, with the count and the n it needs", () => {
|
|
334
|
+
const r = routerRowOf(guardJson({ verdict: "none", baseline: { ...BASE, n: 7 } }));
|
|
335
|
+
assert.equal(r.action, "NO VERDICT");
|
|
336
|
+
assert.equal(r.line, "router → NO VERDICT (baseline n=7/20; guard computed 2026-10-03T00:00:00.000Z)");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("router: no guard file is DEFAULT-ONLY, naming the command that writes one", () => {
|
|
340
|
+
const r = routerRowOf(null);
|
|
341
|
+
assert.equal(r.line, "router → DEFAULT-ONLY (cost-guard.json missing — run pr-cost.mjs --guard)");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("router: a guard file that is not a verdict reads DEFAULT-ONLY too — deleting or breaking it cannot evade the guard", () => {
|
|
345
|
+
const cell = { cell: "task-high", n: 24, mean_usd: 1, fail_rate: 0 };
|
|
346
|
+
for (const [text, why] of [["{", "not JSON"], [guardJson({ verdict: "maybe" }), "not a guard verdict"],
|
|
347
|
+
[guardJson({ tripped: "smol-high" }), "not a guard verdict"], [guardJson({ min_n: undefined }), "not a guard verdict"],
|
|
348
|
+
[guardJson({ computed_at: undefined }), "not a guard verdict"],
|
|
349
|
+
[guardJson({ cells: [{ ...cell, n: "24" }] }), "not a guard verdict"],
|
|
350
|
+
[guardJson({ cells: [{ ...cell, mean_usd: "12" }] }), "not a guard verdict"],
|
|
351
|
+
[guardJson({ cells: [{ ...cell, fail_rate: "0.2" }] }), "not a guard verdict"],
|
|
352
|
+
[guardJson({ baseline: { ...BASE, mean_usd: "31" } }), "not a guard verdict"],
|
|
353
|
+
// Self-contradicting files: a verdict that disagrees with tripped[], a tripped cell the file never reports, a non-boolean retire.
|
|
354
|
+
[guardJson({ verdict: "ok", tripped: ["task-high"] }), "not a guard verdict"],
|
|
355
|
+
[guardJson({ verdict: "tripped", tripped: [] }), "not a guard verdict"],
|
|
356
|
+
[guardJson({ verdict: "tripped", tripped: ["ghost"] }), "not a guard verdict"],
|
|
357
|
+
[guardJson({ retire: "yes" }), "not a guard verdict"]]) {
|
|
358
|
+
const r = routerRowOf(text);
|
|
359
|
+
assert.equal(r.action, "DEFAULT-ONLY", text);
|
|
360
|
+
assert.equal(r.detail, `cost-guard.json unreadable (${why}) — run pr-cost.mjs --guard`, text);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("router: with no guard state read at all, no router row", () => {
|
|
365
|
+
assert.deepEqual(routerRows(state()), []);
|
|
366
|
+
});
|
|
367
|
+
|
|
281
368
|
test("formatLines prints role, actual/target and the ACTION on one line each", () => {
|
|
282
369
|
assert.deepEqual(
|
|
283
370
|
formatLines(reconcile(state({
|
|
@@ -1054,7 +1141,7 @@ test("deriveRun: the row text it does not own is accepted as it stands", () => {
|
|
|
1054
1141
|
// The accept side of every refusal below: freeform row text, the arrow, the
|
|
1055
1142
|
// board's own tokens and an empty ledger are the ordinary case.
|
|
1056
1143
|
assert.doesNotThrow(() => run({
|
|
1057
|
-
rows: ["#324 impl-324=PR#346 → PR#346 · fix-pr-346=no-op · ruled:6-applies · ci=123:1:success · ports=16324 · tier=
|
|
1144
|
+
rows: ["#324 impl-324=PR#346 → PR#346 · fix-pr-346=no-op · ruled:6-applies · ci=123:1:success · ports=16324 · tier=task-high"],
|
|
1058
1145
|
dispatched: ["impl-324=PR#346", "fix-pr-346=no-op"],
|
|
1059
1146
|
}, [pr(346)]));
|
|
1060
1147
|
const empty = run({});
|
|
@@ -1320,6 +1407,18 @@ test("CLI: runCli with keep: true hands back a fixture dir that still exists", (
|
|
|
1320
1407
|
assert.equal(existsSync(runCli([]).dir), false, "without keep the dir is gone on return");
|
|
1321
1408
|
});
|
|
1322
1409
|
|
|
1410
|
+
test("CLI: the router row reads the run's .fleet/cost-guard.json, and prints DEFAULT-ONLY without one", () => {
|
|
1411
|
+
const none = runCli([], { shortlist: shortlistText([]) });
|
|
1412
|
+
assert.equal(none.status, 0, none.stderr);
|
|
1413
|
+
assert.deepEqual(lineOf(none, "router"), ["router → DEFAULT-ONLY (cost-guard.json missing — run pr-cost.mjs --guard)"]);
|
|
1414
|
+
const ok = runCli([], {
|
|
1415
|
+
shortlist: shortlistText([]),
|
|
1416
|
+
beforeRun: (repo) => writeFileSync(join(repo, ".fleet", "cost-guard.json"), guardJson({ verdict: "none", baseline: { ...BASE, n: 12 } })),
|
|
1417
|
+
});
|
|
1418
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
1419
|
+
assert.deepEqual(lineOf(ok, "router"), ["router → NO VERDICT (baseline n=12/20; guard computed 2026-10-03T00:00:00.000Z)"]);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1323
1422
|
test("CLI: the #1692 shape, read off the ledger — two live implementers at cap 2 pull nothing", () => {
|
|
1324
1423
|
const r = runCli([], {
|
|
1325
1424
|
ledger: { rows: ["#412 impl-412 · tier-ok=impl-412:fleet-implementer", "#415 impl-415 · tier-ok=impl-415:fleet-implementer"], dispatched: ["impl-412", "impl-415"] },
|
|
@@ -173,28 +173,53 @@ test("dispatch prints the agent definition the call names: the row's tier for an
|
|
|
173
173
|
// A `tier=` of the right shape whose definition has no file is refused the
|
|
174
174
|
// same way: a `task` call naming it cannot resolve. Two of those: a cell
|
|
175
175
|
// outside the shipped grid (`smol-max` — haiku has no `max`), and the
|
|
176
|
-
// pre-cell `alt
|
|
176
|
+
// pre-cell `alt`, whose definition is deleted. `row` refuses a non-cell
|
|
177
|
+
// `tier=` before it is written, so the rows `row` would refuse are written
|
|
178
|
+
// as a ledger edited by hand — the case dispatch's own check still meets.
|
|
177
179
|
test("dispatch refuses an implementer whose row names no single definition, or one with no file, before marking it live", (t) => {
|
|
178
|
-
const
|
|
179
|
-
ok
|
|
180
|
+
const rows = ["#7 class=routine · tier=alt · tier=slow-high", "#8 class=routine · tier=../../etc", "#9 class=routine · tier=", "#11 class=routine · tier=alt"];
|
|
181
|
+
const { ok, read, refused } = fixture(t, `# Fleet run ledger\n\n## Rows\n\n${rows.map((r) => `- ${r}\n`).join("")}\n## Filed\n\n## Ruled\n\n`);
|
|
180
182
|
refused(["dispatch", "7", "impl-7"], /impl-7: row carries conflicting tier= tokens \(tier=alt, tier=slow-high\) — fix the row with `ledger\.mjs row` — not dispatching impl-7/);
|
|
181
|
-
ok("row", "8", "class=routine · tier=../../etc");
|
|
182
183
|
refused(["dispatch", "8", "impl-8"], /impl-8: tier=\.\.\/\.\.\/etc is not a definition suffix — expected \[a-z0-9\] words joined by '-' — fix the row with `ledger\.mjs row` — not dispatching impl-8/);
|
|
183
|
-
ok("row", "9", "class=routine · tier=");
|
|
184
184
|
refused(["dispatch", "9", "impl-9"], /impl-9: tier= is not a definition suffix/);
|
|
185
185
|
ok("row", "10", "class=routine · tier=smol-max");
|
|
186
186
|
refused(["dispatch", "10", "impl-10"], /impl-10: row #10's tier= names fleet-implementer-smol-max, which has no agents\/fleet-implementer-smol-max\.agent\.md — fix the row with `ledger\.mjs row` — not dispatching impl-10/);
|
|
187
|
-
ok("row", "11", "class=routine · tier=alt");
|
|
188
187
|
refused(["dispatch", "11", "impl-11"], /impl-11: row #11's tier= names fleet-implementer-alt, which has no agents\/fleet-implementer-alt\.agent\.md/);
|
|
188
|
+
ok("row", "12", "class=routine · tier=task-high · tier=slow-high");
|
|
189
|
+
refused(["dispatch", "12", "impl-12"], /impl-12: row carries conflicting tier= tokens \(tier=task-high, tier=slow-high\)/);
|
|
189
190
|
assert.deepEqual(read().dispatched, []);
|
|
190
191
|
});
|
|
191
192
|
|
|
193
|
+
// `row` holds `tier=` to the cell grammar, so a retired or misspelt tier is
|
|
194
|
+
// refused at the Pull's own write, before the claim is dispatched on — and a
|
|
195
|
+
// cell token is accepted whether or not its definition has shipped, which is
|
|
196
|
+
// `dispatch`'s to check. The value is held to the whole grammar, not a prefix
|
|
197
|
+
// of it: a real family with an unknown level, a cell with trailing text, or a
|
|
198
|
+
// case-variant of a cell is refused. Only a token that STARTS with `tier=` is
|
|
199
|
+
// a tier token, so one that merely contains it (`x-tier=alt`) is written as is.
|
|
200
|
+
test("row refuses a tier= that is not a cell token, and accepts every cell token", (t) => {
|
|
201
|
+
const { ok, read, refused } = fixture(t);
|
|
202
|
+
const bads = [
|
|
203
|
+
"tier=alt", "tier=", "tier=../../etc", "tier=Task-High", "tier=task-high · tier=alt", "tier=haiku-high",
|
|
204
|
+
"tier=slow-ultra", "tier=task-", "tier=smol-", "tier=slow", "tier=task-high-x", "tier=task-high-", "tier=task-highx",
|
|
205
|
+
"tier==task-high", "tier=task-high,", "tier=TASK-HIGH", "tier=Slow-High", "tier=slow-HIGH", "tier=xslow-high",
|
|
206
|
+
];
|
|
207
|
+
for (const bad of bads) {
|
|
208
|
+
refused(["row", "7", `impl-7 · ${bad}`], /row #7: 'tier=[^']*' is not tier=<cell>/);
|
|
209
|
+
}
|
|
210
|
+
assert.deepEqual(read().rows, []);
|
|
211
|
+
const goods = ["tier=task-high", "tier=slow-high", "tier=smol-max", "tier-ok=impl-7:fleet-implementer-task-high", "x-tier=alt", "x-tier=Slow-ultra · tier=task-high"];
|
|
212
|
+
for (const good of goods) {
|
|
213
|
+
assert.equal(ok("row", "7", `impl-7 · ${good}`).line, `#7 impl-7 · ${good}`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
192
217
|
// The refusal is about the implementer's own definition: a member whose
|
|
193
218
|
// definition does not come off `tier=` must still be dispatchable on such a
|
|
194
219
|
// row, or one bad token would strand the PR's finisher and fix-applier.
|
|
195
220
|
test("a malformed tier= on a row does not refuse a member whose definition ignores it", (t) => {
|
|
196
221
|
const { ok } = fixture(t);
|
|
197
|
-
ok("row", "7", "impl-7=PR#70 · tier=
|
|
222
|
+
ok("row", "7", "impl-7=PR#70 · tier=task-high · tier=slow-high");
|
|
198
223
|
assert.equal(ok("dispatch", "70", "fix-pr-70").agent, null);
|
|
199
224
|
assert.equal(ok("dispatch", "7", "finisher-pr-70").agent, "fleet-finisher");
|
|
200
225
|
});
|
|
@@ -215,7 +240,7 @@ test("dispatch names a fix-applier on an unresolved conflict hold of its own PR
|
|
|
215
240
|
// `tier=` is not consulted — conflicting values neither change nor refuse it.
|
|
216
241
|
ok("row", "50", "impl-50=PR#51 · tier=task-high · conflict-hold:#51");
|
|
217
242
|
assert.equal(ok("dispatch", "51", "fix-pr-51").agent, "fleet-implementer-slow-high");
|
|
218
|
-
ok("row", "60", "impl-60=PR#61 · tier=
|
|
243
|
+
ok("row", "60", "impl-60=PR#61 · tier=task-high · tier=slow-high · conflict-hold:#61");
|
|
219
244
|
assert.equal(ok("dispatch", "61", "fix-pr-61").agent, "fleet-implementer-slow-high");
|
|
220
245
|
|
|
221
246
|
// The hold's other spelling the tick reads (fleet-tick.mjs's CONFLICT_HOLD).
|
|
@@ -387,7 +412,7 @@ test("row still writes well-formed member tokens and non-member keys", (t) => {
|
|
|
387
412
|
["7", "impl-7 · class=routine · ports=16007"],
|
|
388
413
|
["8", "impl-8=PR#9 · fix-pr-9=applied:73b356de · review=wf:r1=failed reviewed=abc1234:1/0/0 · ci=123:1:success"],
|
|
389
414
|
["10", "held-behind:#9 · merge-bot-1=done · review=member:review-pr-10"],
|
|
390
|
-
["11", "impl-11 · tier=
|
|
415
|
+
["11", "impl-11 · tier=task-high · tier-ok=impl-11:fleet-implementer-task-high"],
|
|
391
416
|
["12", "impl-12=bailed · tier-mismatch=impl-12:fleet-implementer · impl-12: dispatched task, expected fleet-implementer"],
|
|
392
417
|
];
|
|
393
418
|
for (const [ticket, text] of rows) ok("row", ticket, text);
|
|
@@ -99,8 +99,9 @@ export const CELL = /^(slow|task|smol)-(minimal|low|medium|high|xhigh|max)$/;
|
|
|
99
99
|
export const CELL_DEF = new RegExp(`^fleet-implementer-${CELL.source.slice(1)}`);
|
|
100
100
|
|
|
101
101
|
// The cell a row with no `tier=` runs at: `policy_cell`, `slow-high` on every
|
|
102
|
-
// Pull until the router
|
|
103
|
-
|
|
102
|
+
// Pull until the router's table picks one per stratum. Exported so
|
|
103
|
+
// ticket-router.mjs defaults to the same cell this file maps a bare row to.
|
|
104
|
+
export const POLICY_CELL = "slow-high";
|
|
104
105
|
|
|
105
106
|
// The Exploration Pull's draw (spec § 2): uniform over every cell but
|
|
106
107
|
// `policyCell`, keyed off the row's own `session` and `ticket`, so the draw
|
|
@@ -134,9 +135,14 @@ export function drawCell({ session, ticket, policyCell, cells }) {
|
|
|
134
135
|
// different definitions for one row and fail a dispatch made exactly as
|
|
135
136
|
// printed.
|
|
136
137
|
const TIER_SUFFIX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
138
|
+
|
|
139
|
+
/** Every `tier=` value a row's text carries, in order, duplicates kept. */
|
|
140
|
+
export function tierValues(rowText) {
|
|
141
|
+
return String(rowText ?? "").split(/\s+/).filter((t) => t.startsWith("tier=")).map((t) => t.slice("tier=".length));
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
export function expectedDefinition(rowText) {
|
|
138
|
-
const values = [...new Set(
|
|
139
|
-
.filter((t) => t.startsWith("tier=")).map((t) => t.slice("tier=".length)))];
|
|
145
|
+
const values = [...new Set(tierValues(rowText))];
|
|
140
146
|
if (values.length === 0) return `fleet-implementer-${POLICY_CELL}`;
|
|
141
147
|
if (values.length > 1) throw new Error(`row carries conflicting tier= tokens (${values.map((v) => `tier=${v}`).join(", ")}) — fix the row with \`ledger.mjs row\``);
|
|
142
148
|
if (!TIER_SUFFIX.test(values[0])) throw new Error(`tier=${values[0]} is not a definition suffix — expected [a-z0-9] words joined by '-' — fix the row with \`ledger.mjs row\``);
|
package/scripts/ledger.mjs
CHANGED
|
@@ -35,7 +35,7 @@ import { fileURLToPath } from "node:url";
|
|
|
35
35
|
import { spawnSync, execFileSync } from "node:child_process";
|
|
36
36
|
import { makeDie, isFlagLike, hasEqualsForm, isDigits } from "./arg.mjs";
|
|
37
37
|
import { gitEnv, workspaceDirFromGitCommonDir } from "./git-env.mjs";
|
|
38
|
-
import { parseMember, parseToken, memberTokens, nextMergeBot, agentDefinition } from "./ledger-grammar.mjs";
|
|
38
|
+
import { parseMember, parseToken, memberTokens, nextMergeBot, agentDefinition, CELL, tierValues } from "./ledger-grammar.mjs";
|
|
39
39
|
import { deriveRun, LedgerError, labelOffMember } from "./fleet-tick.mjs";
|
|
40
40
|
import { stateFileIn, readState, assessBeat, stallsAt } from "./fleet-state.mjs";
|
|
41
41
|
|
|
@@ -841,6 +841,11 @@ if (cmd === "read") {
|
|
|
841
841
|
// rewrite alike; a well-formed token, live or settled, still goes through.
|
|
842
842
|
const malformed = memberTokens(line).find((t) => t.error !== null);
|
|
843
843
|
if (malformed) die(`row ${key}: malformed member token '${malformed.name}=${malformed.outcome}' — ${malformed.error}`);
|
|
844
|
+
// `tier=` names the implementer cell an exploration Pull dispatched, so its
|
|
845
|
+
// value must be a cell token: a retired or misspelt tier written here would
|
|
846
|
+
// only be refused later, by `dispatch`, after the claim.
|
|
847
|
+
const badTier = tierValues(line).find((v) => !CELL.test(v));
|
|
848
|
+
if (badTier !== undefined) die(`row ${key}: 'tier=${badTier}' is not tier=<cell> — a cell is <slow|task|smol>-<level>`);
|
|
844
849
|
// `label-off=<attempt>` tells the tick a missing `ready-to-merge` is
|
|
845
850
|
// the controller's own removal, not a finisher's miss — so one naming no
|
|
846
851
|
// finisher-pr member this run has would mask a real miss behind a record of
|
|
@@ -77,24 +77,30 @@ export function rowsForSession(sessionDir, stats = {}) {
|
|
|
77
77
|
tokensCacheCreate: r.tokens_cache_create, tokensOut: r.tokens_out,
|
|
78
78
|
wallS: r.wall_s, turns: r.turns, agent: r.agent, torn: false,
|
|
79
79
|
subagentType: r.subagent_type,
|
|
80
|
+
tokensIn: r.tokens_in, tokensCacheRead: r.tokens_cache_read,
|
|
81
|
+
tokensCacheWrite1h: r.tokens_cache_write_1h ?? "", cost: r.cost == null ? "" : Number(r.cost.toFixed(6)),
|
|
80
82
|
}));
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// APPENDED TO, never inserted into: every read-out in the file's header and
|
|
84
|
-
// in
|
|
85
|
-
// silently repoints every `$n` a reader already wrote down.
|
|
86
|
-
//
|
|
86
|
+
// in the design specs indexes by position, so a column added anywhere but the
|
|
87
|
+
// end silently repoints every `$n` a reader already wrote down.
|
|
88
|
+
// `subagent_type` therefore follows `harness`, and the four priced columns
|
|
89
|
+
// follow it. `cost` is the provider's own per-turn `usage.cost.total`, summed
|
|
90
|
+
// to the micro-dollar — the $ of record; `tokens_cache_write_1h` and `cost`
|
|
91
|
+
// are blank where the transcript recorded no figure, never 0.
|
|
87
92
|
export const COLUMNS = [
|
|
88
93
|
"session", "run_date", "role", "member", "model", "effort", "ticket", "pr",
|
|
89
94
|
"tokens_cache_create", "tokens_out", "wall_s", "turns", "agent", "harness",
|
|
90
|
-
"subagent_type",
|
|
95
|
+
"subagent_type", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost",
|
|
91
96
|
];
|
|
92
97
|
|
|
93
98
|
// Row objects use camelCase; the file uses snake_case. One map, one direction
|
|
94
99
|
// each, so a rename cannot silently drop a column.
|
|
95
100
|
const FIELD = {
|
|
96
101
|
tokens_cache_create: "tokensCacheCreate", tokens_out: "tokensOut", wall_s: "wallS",
|
|
97
|
-
subagent_type: "subagentType",
|
|
102
|
+
subagent_type: "subagentType", tokens_in: "tokensIn", tokens_cache_read: "tokensCacheRead",
|
|
103
|
+
tokens_cache_write_1h: "tokensCacheWrite1h",
|
|
98
104
|
};
|
|
99
105
|
const field = (c) => FIELD[c] ?? c;
|
|
100
106
|
// Keyed on the transcript's path-relative stem, not the member name: `member`
|
|
@@ -118,13 +124,16 @@ export function formatTsv(rows) {
|
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
// REFUSES a row whose cell count is not exactly COLUMNS.length rather than
|
|
121
|
-
// padding it. Padding looked harmless and was not:
|
|
122
|
-
// so a short row parsed to `agent: ""` — a key
|
|
123
|
-
// produce, which means mergeRows can never REPLACE
|
|
124
|
-
// were measured, all previously exit 0: a
|
|
125
|
-
// permanent phantom that re-scraping could
|
|
126
|
-
// three data rows; and adding one column
|
|
127
|
-
// onto one key per session, 2,702 rows
|
|
127
|
+
// padding it. Padding looked harmless and was not: when this was written
|
|
128
|
+
// `agent` was the LAST column, so a short row parsed to `agent: ""` — a key
|
|
129
|
+
// rowsForSession can never produce, which means mergeRows can never REPLACE
|
|
130
|
+
// it. Three ways to reach that were measured, all previously exit 0: a
|
|
131
|
+
// transcript torn mid-write became a permanent phantom that re-scraping could
|
|
132
|
+
// not heal; git conflict markers became three data rows; and adding one column
|
|
133
|
+
// ahead of `agent` collapsed the corpus onto one key per session, 2,702 rows
|
|
134
|
+
// to 156. `agent` is no longer last — the priced columns follow it — and
|
|
135
|
+
// there a padded "" would read as "no figure was recorded" for a member whose
|
|
136
|
+
// transcript carried one, so a short row stays refused for that too.
|
|
128
137
|
export function parseTsv(text) {
|
|
129
138
|
return String(text ?? "").split("\n")
|
|
130
139
|
.filter((l) => l.trim() && !l.startsWith("#"))
|
|
@@ -137,6 +137,37 @@ test("a generated word pair that merely starts with a family word yields blanks
|
|
|
137
137
|
assert.deepEqual(parseMemberName("Impl1341/Impl1341.CwdProbe"), { ticket: "", pr: "" });
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
test("a review fan-out label books its PR, in the label's own spelling and in the stem omp writes for it", () => {
|
|
141
|
+
// omp names the member by deleting every character outside [A-Za-z0-9_-]
|
|
142
|
+
// from the label and appending -<n> from a label's second dispatch on.
|
|
143
|
+
for (const [label, stem] of [
|
|
144
|
+
["review:correctness:pr2132", "reviewcorrectnesspr2132"],
|
|
145
|
+
["review:silent-failure:pr2132", "reviewsilent-failurepr2132"],
|
|
146
|
+
["verify:comments:pr2132", "verifycommentspr2132"],
|
|
147
|
+
["snapshot:pr2132", "snapshotpr2132"],
|
|
148
|
+
["test-run:pr2132", "test-runpr2132"],
|
|
149
|
+
]) {
|
|
150
|
+
assert.deepEqual(parseMemberName(label), { ticket: "", pr: "2132" }, label);
|
|
151
|
+
assert.deepEqual(parseMemberName(stem), { ticket: "", pr: "2132" }, stem);
|
|
152
|
+
assert.deepEqual(parseMemberName(`${stem}-7`), { ticket: "", pr: "2132" }, `${stem}-7`);
|
|
153
|
+
// Nested under the reviewer that dispatched it: the last segment is the member.
|
|
154
|
+
assert.deepEqual(parseMemberName(`review-pr-2132/${stem}`), { ticket: "", pr: "2132" }, `nested ${stem}`);
|
|
155
|
+
}
|
|
156
|
+
// A dimension key that itself spells `pr` still yields the trailing number.
|
|
157
|
+
assert.deepEqual(parseMemberName("reviewprpr12"), { ticket: "", pr: "12" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a fan-out stem that carries no PR, or a name that only resembles one, books nothing", () => {
|
|
161
|
+
// Measured stems from before the label carried its PR.
|
|
162
|
+
for (const name of ["verifycorrectness-26", "reviewtests", "snapshot-8", "snapshot", "test-run",
|
|
163
|
+
// Generated or human names: PascalCase never matches, and a bare number is not `pr<n>`.
|
|
164
|
+
"ReviewCorrectness", "VerifyAndRepair", "Verify685", "ReviewTestsPr12", "review:correctness", "verify::pr", "reviewpr",
|
|
165
|
+
// Look-alikes of a fan-out stem: a prefix before the family word, a key-less `verify`, a hyphen before `pr`.
|
|
166
|
+
"xreviewfoopr12", "xsnapshotpr12", "verifypr12", "snapshot-pr12", "test-run-pr12", "verify-pr12", "reviewers-sprint-pr3"]) {
|
|
167
|
+
assert.deepEqual(parseMemberName(name), { ticket: "", pr: "" }, name);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
140
171
|
// ---------------------------------------------------------------------------
|
|
141
172
|
// omp fixtures — shaped like real ~/.omp/agent/sessions/**/*.jsonl lines
|
|
142
173
|
// ---------------------------------------------------------------------------
|
|
@@ -271,25 +302,37 @@ test("`subagent_type` is what the DISPATCH named, and blank when it named nothin
|
|
|
271
302
|
});
|
|
272
303
|
|
|
273
304
|
test("a row whose cell count is wrong is REFUSED, never padded", () => {
|
|
274
|
-
// Padding put "" in the
|
|
275
|
-
//
|
|
305
|
+
// Padding put "" in the trailing columns — `agent` was the LAST one when
|
|
306
|
+
// this was written, a key rowsForSession can never produce, so mergeRows
|
|
276
307
|
// could never replace it. Three measured routes there, all exit 0: a torn
|
|
277
308
|
// last line became a permanent phantom that re-scraping could not heal, git
|
|
278
309
|
// conflict markers became three data rows, and adding one column ahead of
|
|
279
|
-
// `agent` collapsed 2,702 rows to 156.
|
|
310
|
+
// `agent` collapsed 2,702 rows to 156. The columns that trail `agent` now
|
|
311
|
+
// are the priced ones, where a padded blank reads as "no figure recorded".
|
|
280
312
|
//
|
|
281
|
-
// The
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
313
|
+
// The 15-field spelling is the PREVIOUS schema, from before the four priced
|
|
314
|
+
// columns: it must be refused rather than padded with blank prices, which
|
|
315
|
+
// would read as "no figure was recorded" for members whose transcript
|
|
316
|
+
// carried one. Re-scraping is the migration. The 14-field one before it is
|
|
317
|
+
// refused the same way.
|
|
285
318
|
//
|
|
286
319
|
// Mutation this must survive: restoring `cells[i] ?? ""`.
|
|
287
320
|
const short = ["s1", "2026-08-25", "memory"].join("\t");
|
|
288
|
-
assert.throws(() => parseTsv(short), /malformed row: 3 fields, expected
|
|
321
|
+
assert.throws(() => parseTsv(short), /malformed row: 3 fields, expected 19/);
|
|
289
322
|
assert.throws(() => parseTsv("<<<<<<< HEAD"), /malformed row/);
|
|
290
|
-
|
|
323
|
+
const full = formatTsv([row()]).replace(/\n$/, "").split("\t");
|
|
324
|
+
assert.equal(full.length, 19);
|
|
325
|
+
assert.throws(() => parseTsv(full.slice(0, 15).join("\t")), /15 fields, expected 19/);
|
|
326
|
+
assert.throws(() => parseTsv(full.slice(0, 14).join("\t")), /14 fields, expected 19/);
|
|
291
327
|
// A long row is refused too — that is the schema-drift direction.
|
|
292
|
-
assert.throws(() => parseTsv(
|
|
328
|
+
assert.throws(() => parseTsv(full.join("\t") + "\textra"), /20 fields/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("the four priced columns follow subagent_type, and a scraped member fills them", () => {
|
|
332
|
+
assert.deepEqual(COLUMNS.slice(COLUMNS.indexOf("subagent_type")),
|
|
333
|
+
["subagent_type", "tokens_in", "tokens_cache_read", "tokens_cache_write_1h", "cost"]);
|
|
334
|
+
const [parsed] = parseTsv(formatTsv([row({ tokensIn: 12, tokensCacheRead: 3400, tokensCacheWrite1h: 900, cost: 0.123457 })]));
|
|
335
|
+
assert.deepEqual([parsed.tokensIn, parsed.tokensCacheRead, parsed.tokensCacheWrite1h, parsed.cost], ["12", "3400", "900", "0.123457"]);
|
|
293
336
|
});
|
|
294
337
|
|
|
295
338
|
const row = (o = {}) => ({
|
|
@@ -300,7 +343,8 @@ const row = (o = {}) => ({
|
|
|
300
343
|
// only `member` still get distinct transcript ids, and fixtures that share
|
|
301
344
|
// the default member (untouched) still key as the SAME agent.
|
|
302
345
|
agent: o.member ?? "impl-580", harness: "omp",
|
|
303
|
-
subagentType: "fleet-implementer",
|
|
346
|
+
subagentType: "fleet-implementer",
|
|
347
|
+
tokensIn: 0, tokensCacheRead: 0, tokensCacheWrite1h: 0, cost: 0.01, ...o,
|
|
304
348
|
});
|
|
305
349
|
|
|
306
350
|
test("re-scraping a session REPLACES its rows rather than appending duplicates", () => {
|
|
@@ -442,6 +486,19 @@ test("two separate sessions scraped in two runs merge into one TSV", () => {
|
|
|
442
486
|
assert.equal(rows.find((r) => r.member === "Solo").subagentType, "fleet-implementer-alt");
|
|
443
487
|
});
|
|
444
488
|
|
|
489
|
+
test("a scrape writes the priced columns off the transcript's usage, the 1h share off its cttl", () => {
|
|
490
|
+
const dir = fixture([["impl-580", [sessionEvt("/x"), thinkingEvt("high"),
|
|
491
|
+
assistantEvt("claude-opus-5", { input: 7, cacheRead: 500, cacheWrite: 300, cttl: { ephemeral1h: 300 }, cost: { total: 0.25 } }),
|
|
492
|
+
assistantEvt("claude-opus-5", { input: 3, cacheRead: 800, cacheWrite: 40, cttl: { ephemeral5m: 40 }, cost: { total: 0.0500004 } }, "2026-08-25T10:05:00.000Z"),
|
|
493
|
+
]]]);
|
|
494
|
+
const out = join(tempDir("mo-out-"), "member-outcomes.tsv");
|
|
495
|
+
const r = spawnSync(process.execPath, [CLI, dir, "--file", out], { encoding: "utf8" });
|
|
496
|
+
assert.equal(r.status, 0, r.stderr);
|
|
497
|
+
const [row] = parseTsv(readFileSync(out, "utf8"));
|
|
498
|
+
assert.deepEqual([row.tokensIn, row.tokensCacheRead, row.tokensCacheCreate, row.tokensCacheWrite1h, row.cost],
|
|
499
|
+
["10", "1300", "340", "300", "0.3"]);
|
|
500
|
+
});
|
|
501
|
+
|
|
445
502
|
test("a run writes rows, and a second run over the same session changes nothing", () => {
|
|
446
503
|
const dir = fixture([["impl-580", [sessionEvt("/x"), thinkingEvt("xhigh"), assistantEvt("claude-opus-5")]]]);
|
|
447
504
|
const out = join(tempDir("mo-out-"), "member-outcomes.tsv");
|