@feigi/fleet-ctl 3.21.13 → 3.22.1

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.
@@ -452,7 +452,7 @@ const pr = (number, labels = [], closes = [number + 1000], headRefOid = HEAD_B)
452
452
  closingIssuesReferences: closes.map((n) => ({ number: n })),
453
453
  headRefOid,
454
454
  });
455
- const run = (ledger, prs = [], closed) => deriveRun({ rows: [], dispatched: [], drain: null, ...ledger }, prs, closed);
455
+ const run = (ledger, prs = [], closed, finished) => deriveRun({ rows: [], dispatched: [], drain: null, ...ledger }, prs, closed, finished);
456
456
 
457
457
  test("deriveRun: live implementers are the unsettled impl- tokens — the #1692 shape, read rather than recited", () => {
458
458
  // #1692 was two sources disagreeing about the same fleet. There is one
@@ -506,6 +506,44 @@ test("deriveRun: live names every unsettled member and each in-flight review by
506
506
  assert.deepEqual(r.live, ["fix-pr-21", "impl-15", "review:PR#20"], "run2 returned and run3 is dead, so neither is live");
507
507
  });
508
508
 
509
+ // A review whose own `reviewed=` write never landed stays in flight in the
510
+ // ledger forever. The ledger cannot say the PR has since merged; the caller's
511
+ // probe can, and hands the merged or closed PR numbers to the fold.
512
+ const ZOMBIE_ROWS = [20, 21, 22].map((n) => `#${n - 10} impl-${n - 10}=PR#${n} → PR#${n} · review=member:review-pr-${n}`);
513
+
514
+ test("deriveRun: an in-flight review whose PR has left the open list is named for a probe, and still counts until the probe answers", () => {
515
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(21)]);
516
+ assert.deepEqual(r.reviewsOffList, [20, 22], "only the in-flight reviews of PRs not on the open list");
517
+ assert.equal(r.reviewsLive, 3, "no probe answer: fail closed, every one still reads as live");
518
+ assert.deepEqual(r.live, ["review:PR#20", "review:PR#21", "review:PR#22"]);
519
+ });
520
+
521
+ test("deriveRun: reviewsOffList is ascending whatever order the ledger rows arrive in", () => {
522
+ const r = run({ rows: [...ZOMBIE_ROWS].reverse() }, [pr(21)]);
523
+ assert.deepEqual(r.reviewsOffList, [20, 22], "rows arrive 22, 21, 20: the sort, not ledger order, makes it ascending");
524
+ });
525
+
526
+ test("deriveRun: a review in flight on a PR the caller found merged or closed is not live", () => {
527
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(21)], undefined, new Set([20, 22]));
528
+ assert.equal(r.reviewsLive, 1, "only PR 21's review, on the open list, is live");
529
+ assert.deepEqual(r.live, ["review:PR#21"]);
530
+ });
531
+
532
+ test("deriveRun: a review in flight on an OPEN PR is live whatever the finished set says", () => {
533
+ // A probe answer that contradicts the open list is not trusted to retire a
534
+ // review: the open list is the fold's own read of which PRs are open.
535
+ const r = run({ rows: ZOMBIE_ROWS }, [pr(20), pr(21), pr(22)], undefined, new Set([20, 21, 22]));
536
+ assert.equal(r.reviewsLive, 3);
537
+ assert.deepEqual(r.reviewsOffList, []);
538
+ });
539
+
540
+ test("deriveRun: a finished PR's returned review and settled members read as before", () => {
541
+ const rows = [...ZOMBIE_ROWS, "#40 impl-40=PR#50 → PR#50 · review=member:review-pr-50 reviewed=abc1234:0/0/0"];
542
+ const r = run({ rows }, [], undefined, new Set([20, 21, 22, 50]));
543
+ assert.deepEqual([r.reviewsLive, r.reviewsOffList, r.live], [0, [20, 21, 22], []]);
544
+ assert.deepEqual(r.reviewed.map((x) => x.pr), [50], "the returned review is still on record");
545
+ });
546
+
509
547
  test("deriveRun: fix-pr is due on a returned review with survivors and no fix-applier since", () => {
510
548
  const r = run({
511
549
  rows: [
@@ -1267,7 +1305,9 @@ test("CLI: every synchronous spawn in this file goes through spawnBounded (#2320
1267
1305
  });
1268
1306
 
1269
1307
  // `pr list` for the open PRs, `issue view` for a behind-issue premise's state,
1270
- // `issue list --label in-progress` for the stall report's claimed count.
1308
+ // `pr view` for the state of a PR an in-flight review names that is no longer
1309
+ // on the open list, `issue list --label in-progress` for the stall report's
1310
+ // claimed count.
1271
1311
  const GH_STUB = `#!/bin/sh
1272
1312
  case "$1 $2" in
1273
1313
  "pr list") [ -n "$PR_FAIL" ] && { echo "boom" >&2; exit 1; }; cat "$FIXTURE_PRS" ;;
@@ -1280,6 +1320,15 @@ case "$1 $2" in
1280
1320
  # scrub them lifts an exclusion the case's own repository still holds.
1281
1321
  [ -n "$GIT_DIR$GIT_WORK_TREE$GH_REPO" ] && { echo '{"state":"CLOSED"}'; exit 0; }
1282
1322
  exec jq -c --arg n "$3" '{state: (.[$n] // error("no such issue"))}' "$FIXTURE_ISSUE_STATES" ;;
1323
+ "pr view")
1324
+ echo "$3" >> "$PR_VIEW_LOG"
1325
+ [ -n "$PR_VIEW_FAIL" ] && { echo "gh: pr view failed" >&2; exit 1; }
1326
+ [ -n "$PR_VIEW_BODY" ] && { printf '%s\n' "$PR_VIEW_BODY"; exit 0; }
1327
+ # Real gh answers for the repository an inherited GIT_DIR or GH_REPO names;
1328
+ # this one answers MERGED for every PR there, so a probe that forgot to
1329
+ # scrub them retires a review the case's own repository still holds.
1330
+ [ -n "$GIT_DIR$GIT_WORK_TREE$GH_REPO" ] && { echo '{"state":"MERGED"}'; exit 0; }
1331
+ exec jq -c --arg n "$3" '{state: (.[$n] // error("no such pull request"))}' "$FIXTURE_PR_STATES" ;;
1283
1332
  "issue list")
1284
1333
  [ -n "$CLAIMED_FAIL" ] && { echo "boom" >&2; exit 1; }
1285
1334
  expr=""
@@ -1320,7 +1369,7 @@ const shortlistText = (ns, scanned = ns.length) => JSON.stringify({ scanned, sho
1320
1369
  // through info/exclude so no tracked file says so.
1321
1370
  function runCli(args = [], {
1322
1371
  prs = [], ledger, shortlist, refresh = shortlistText([]), refreshFail = false,
1323
- issueStates = {}, claimed = [], env: extraEnv = {}, defaultState = false, keep = false, beforeRun = () => {},
1372
+ issueStates = {}, prStates = {}, claimed = [], env: extraEnv = {}, defaultState = false, keep = false, beforeRun = () => {},
1324
1373
  baseline = true, afterBaseline = () => {},
1325
1374
  } = {}) {
1326
1375
  // On macOS tmpdir() is /var -> /private/var; resolving it up front makes
@@ -1355,6 +1404,7 @@ function runCli(args = [], {
1355
1404
  afterBaseline(repo);
1356
1405
  const refreshLog = fx("refresh.log", "");
1357
1406
  const issueViewLog = fx("issue-view.log", "");
1407
+ const prViewLog = fx("pr-view.log", "");
1358
1408
  // Every case gets its own state file unless it names one: the default path
1359
1409
  // resolves against the git common dir, and a shared streak and digest would
1360
1410
  // make the fold cases order-dependent. `defaultState` opts out for the case
@@ -1367,14 +1417,16 @@ function runCli(args = [], {
1367
1417
  FIXTURE_PRS: fx("prs.json", JSON.stringify(prs)),
1368
1418
  FIXTURE_CLAIMED: fx("claimed.json", JSON.stringify(claimed)),
1369
1419
  FIXTURE_ISSUE_STATES: fx("issue-states.json", JSON.stringify(issueStates)),
1420
+ FIXTURE_PR_STATES: fx("pr-states.json", JSON.stringify(prStates)),
1370
1421
  FIXTURE_REFRESH: fx("refresh.json", refresh),
1371
- REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog,
1422
+ REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog, PR_VIEW_LOG: prViewLog,
1372
1423
  ...(refreshFail ? { REFRESH_FAIL: "1" } : {}),
1373
1424
  ...extraEnv,
1374
1425
  },
1375
1426
  });
1376
1427
  r.refreshed = readFileSync(refreshLog, "utf8").split("\n").filter(Boolean).length;
1377
1428
  r.issueViews = readFileSync(issueViewLog, "utf8").split("\n").filter(Boolean);
1429
+ r.prViews = readFileSync(prViewLog, "utf8").split("\n").filter(Boolean);
1378
1430
  r.repo = repo;
1379
1431
  r.dir = dir;
1380
1432
  returned = true;
@@ -1843,6 +1895,82 @@ test("CLI: an inherited GIT_DIR cannot retarget the behind-issue premise probe",
1843
1895
  assert.equal(r.refreshed, 0, "#9 is OPEN in the case's own repository — the exclusion stands");
1844
1896
  });
1845
1897
 
1898
+ // A review whose `reviewed=` write never landed stays in flight in the ledger
1899
+ // after its PR merges. Each such row holds one reviewer slot unless the tick
1900
+ // asks gh about the PR and finds it finished.
1901
+ const zombieRows = (numbers) => numbers.map((n) => `#${n - 90} impl-${n - 90}=PR#${n} → PR#${n} · review=member:review-pr-${n}`);
1902
+ const ZOMBIES = Array.from({ length: 10 }, (_, i) => 101 + i);
1903
+ const REVIEW_DUE = pr(500, [], [1500], HEAD_B);
1904
+ const CAPS = ["--reviewer-cap", "10", "--max-reviews", "10"];
1905
+
1906
+ test("CLI: dangling in-flight reviews of merged PRs do not starve review dispatch", () => {
1907
+ const r = runCli(CAPS, {
1908
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows(ZOMBIES) },
1909
+ prStates: Object.fromEntries(ZOMBIES.map((n) => [n, "MERGED"])),
1910
+ });
1911
+ assert.equal(r.status, 0, r.stderr);
1912
+ assert.deepEqual(r.prViews.map(Number).sort((a, b) => a - b), ZOMBIES, "each off-list PR is asked about");
1913
+ assert.match(r.stdout, /^reviewers +0\/10 → DISPATCH review PR#500/m);
1914
+ });
1915
+
1916
+ test("CLI: a CLOSED PR's dangling review is released too", () => {
1917
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1918
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "CLOSED" },
1919
+ });
1920
+ assert.equal(r.status, 0, r.stderr);
1921
+ assert.match(r.stdout, /^reviewers +0\/1 → DISPATCH review PR#500/m);
1922
+ });
1923
+
1924
+ test("CLI: an in-flight review on an open PR keeps its slot and is never probed", () => {
1925
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1926
+ shortlist: shortlistText([]), prs: [pr(101, [], [1101]), REVIEW_DUE], ledger: { rows: zombieRows([101]) },
1927
+ });
1928
+ assert.equal(r.status, 0, r.stderr);
1929
+ assert.deepEqual(r.prViews, [], "an open PR needs no probe");
1930
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
1931
+ assert.doesNotMatch(r.stdout, /DISPATCH review/);
1932
+ });
1933
+
1934
+ test("CLI: a PR off the open list that gh still calls OPEN keeps its review's slot", () => {
1935
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1936
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "OPEN" },
1937
+ });
1938
+ assert.equal(r.status, 0, r.stderr);
1939
+ assert.deepEqual(r.prViews, ["101"]);
1940
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
1941
+ assert.doesNotMatch(r.stdout, /DISPATCH review/);
1942
+ });
1943
+
1944
+ test("CLI: a PR gh cannot answer for is not read as merged - the slot is held and the failure disclosed", () => {
1945
+ const cases = [
1946
+ ["a nonzero exit", { PR_VIEW_FAIL: "1" }, /gh pr view 101 exited 1: gh: pr view failed/],
1947
+ ["a reply with no state", { PR_VIEW_BODY: "<html>proxy error</html>" }, /gh pr view 101 printed no PR state/],
1948
+ ["a state that is not MERGED or CLOSED", { PR_VIEW_BODY: JSON.stringify({ state: "merged" }) }, /gh pr view 101 printed no PR state/],
1949
+ ];
1950
+ for (const [what, env, shown] of cases) {
1951
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1952
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "MERGED" }, env,
1953
+ });
1954
+ assert.equal(r.status, 0, `${what}: ${r.stderr}`);
1955
+ assert.match(r.stderr, shown, what);
1956
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m, what);
1957
+ assert.doesNotMatch(r.stdout, /DISPATCH review/, what);
1958
+ }
1959
+ });
1960
+
1961
+ test("CLI: an inherited GIT_DIR cannot retarget the merged-PR review probe", (t) => {
1962
+ const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-pr-")));
1963
+ t.after(() => rmSync(decoy, { recursive: true, force: true }));
1964
+ spawnBounded("git", ["init", "-q", decoy]);
1965
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
1966
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "OPEN" },
1967
+ env: { GIT_DIR: join(decoy, ".git"), GH_REPO: "someone/else" },
1968
+ });
1969
+ assert.equal(r.status, 0, r.stderr);
1970
+ assert.deepEqual(r.prViews, ["101"], "the probe must still run");
1971
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m, "101 is OPEN in the case's own repository - its review keeps the slot");
1972
+ });
1973
+
1846
1974
  // #2210, end to end: a real repo, a real baseline, a real stray write.
1847
1975
  const LIVE = {
1848
1976
  ledger: { rows: ["#412 impl-412 · tier-ok=impl-412:fleet-implementer"], dispatched: ["impl-412"] },
@@ -1,10 +1,10 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { readFileSync, writeFileSync } from "node:fs";
4
- import { tempDir } from "./temp-dir.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
5
4
  import { spawnSync } from "node:child_process";
6
5
  import { join } from "node:path";
7
6
  import { COLUMNS } from "./member-outcomes.mjs";
7
+ import { GATE } from "./cell-readout.mjs";
8
8
 
9
9
  const REPO = join(import.meta.dirname, "..", "..");
10
10
  const TSV = readFileSync(join(REPO, "docs", "metrics", "member-outcomes.tsv"), "utf8");
@@ -55,94 +55,54 @@ test("the header states the blank, hand-edit, and superseded-generation rules",
55
55
  assert.match(HEADER, /workflows\/wf_/);
56
56
  });
57
57
 
58
- test("the header's awk read-outs index the columns they name", () => {
59
- // This test asserted COLUMNS[0]/[2]/[4] — a fact about the CODE, not about
60
- // the queries it is named for. Mutation-proven: repointing the header's pair
61
- // query from $1/$3/$5 to $2/$4/$6 left it GREEN, so the query the header
62
- // calls "the only unconfounded comparison" could silently group on run_date
63
- // and distinguish by effort with nothing to catch it.
64
- //
65
- // So read the `$n` references out of the header's own awk lines and resolve
66
- // each against COLUMNS. Mutation this must survive: changing any `$n` in the
67
- // header without changing COLUMNS.
68
- const awkLines = headerLines.filter((l) => l.includes("awk -F"));
69
- assert.ok(awkLines.length >= 2, "header lost its awk read-outs");
70
-
71
- const pair = pairQuery();
72
- // BOTH arms are identified by the dispatch record (#1066), never by the role
73
- // classifier and never by "the models differ" — so both `$n ~ /…/` tests
74
- // must land on subagent_type, and there must be two of them.
75
- const arms = [...pair.matchAll(/\$(\d+) ~ \/\(\^\|:\)fleet-implementer(-alt)?\$\//g)];
76
- assert.equal(arms.length, 2, "the pair query no longer tests BOTH implementer definitions");
77
- assert.deepEqual(arms.map((m) => Boolean(m[2])), [true, false], "the -alt arm must be tested FIRST — /fleet-implementer$/ is checked in the else branch");
78
- for (const [, n] of arms) assert.equal(COLUMNS[n - 1], "subagent_type", "deliberateness comes from the dispatch record");
79
-
80
- const keys = [...pair.matchAll(/[at]\[\$(\d+) FS \$(\d+)\]/g)];
81
- assert.equal(keys.length, 2, "the pair query no longer keys both arms");
82
- for (const [, session, model] of keys) {
83
- assert.equal(COLUMNS[session - 1], "session", "a pair is WITHIN one session");
84
- assert.equal(COLUMNS[model - 1], "model", "the two arms must have run different MODELS");
85
- }
86
-
87
- const [, dateKey, dateValue] = /d\[\$(\d+)\]=\$(\d+)/.exec(pair);
88
- assert.equal(COLUMNS[dateKey - 1], "session");
89
- assert.equal(COLUMNS[dateValue - 1], "run_date", "the gate's second number is distinct run_dateS");
90
-
58
+ test("the header's awk read-out indexes the column it names", () => {
59
+ // Read the `$n` reference out of the header's own awk line and resolve it
60
+ // against COLUMNS — a fact about the CODE alone (COLUMNS[4]) stays green when
61
+ // the header is repointed. Mutation this must survive: changing the `$n` in
62
+ // the header without changing COLUMNS.
91
63
  const byModel = headerLines.find((l) => /\{n\[\$\d+\]\+\+\}/.test(l));
92
64
  assert.ok(byModel, "header lost its rows-by-model read-out");
93
65
  assert.equal(COLUMNS[/\{n\[\$(\d+)\]\+\+\}/.exec(byModel)[1] - 1], "model");
94
66
  });
95
67
 
96
- // The pair query as a runnable awk program: the header block from `awk -F` to
97
- // the path it reads, `#` stripped, the shell quoting removed. Index pins alone
98
- // cannot see a query that indexes the right columns and still counts the wrong
99
- // thing — which is precisely the defect #1066 was filed for.
100
- function pairQuery() {
101
- const start = headerLines.findIndex((l) => l.includes("awk -F") && l.includes("fleet-implementer-alt"));
102
- assert.ok(start >= 0, "header lost its within-run pair query");
103
- let end = start;
104
- while (end < headerLines.length && !headerLines[end].endsWith("docs/metrics/member-outcomes.tsv")) end++;
105
- assert.ok(end < headerLines.length, "the pair query never reaches the file it reads");
106
- return headerLines.slice(start, end + 1).map((l) => l.replace(/^#\s{0,3}/, "")).join("\n");
107
- }
68
+ // The comparison count is cell-readout.mjs's, and the header only points at it:
69
+ // a second copy of the definition as a runnable query is two countings free to
70
+ // drift apart, which is how the pre-cell pair query came to count pairs no cell
71
+ // owns once the cell grid replaced the two definitions it keyed on.
72
+ test("the header points at cell-readout.mjs for a cell's comparisons and carries no query of its own", () => {
73
+ const invocation = headerLines.find((l) => /^#\s+node \S*cell-readout\.mjs\s*$/.test(l));
74
+ assert.ok(invocation, "header lost its `node plugin/scripts/cell-readout.mjs` pointer");
75
+ const script = invocation.replace(/^#\s+node\s+/, "").trim();
76
+ assert.equal(script, "plugin/scripts/cell-readout.mjs");
77
+ assert.ok(existsSync(join(REPO, script)), `the header points at ${script}, which does not exist`);
78
+ // The pointer is runnable as written, from the repo root, against this
79
+ // repo's own metrics files.
80
+ const r = spawnSync(process.execPath, [script], { cwd: REPO, encoding: "utf8" });
81
+ assert.equal(r.status, 0, r.stderr);
108
82
 
109
- test("the header's pair query counts DELIBERATE pairs, run against a corpus where every near-miss is present", () => {
110
- // #1066: the query this replaces counted any session+role carrying two
111
- // models, reported 198 keys against 17 real pairs, and so reported run-team's
112
- // ten-across-five gate MET while the controlled comparison did not exist.
113
- //
114
- // Mutations this must survive, all measured against this fixture and all of
115
- // which leave the column indexes (and therefore the test above) intact:
116
- // dropping the `x[2]!=y[2]` model test reads 4 2, dropping the `-alt` anchor
117
- // so both arms match one definition reads 0 0, and going back to #1066's own
118
- // query — arms identified by `$3=="implementer"` rather than by the dispatch
119
- // — reads 4 2, counting the untyped session and missing the omp one.
120
- const rows = [
121
- // a real pair: alt at sonnet against the top definition at opus
122
- ["sA", "2026-09-01", "implementer", "impl-1", "claude-sonnet-5", "high", "1", "", "0", "0", "0", "1", "agent-a1", "claude", "fleet-implementer-alt"],
123
- ["sA", "2026-09-01", "implementer", "impl-2", "claude-opus-5", "high", "2", "", "0", "0", "0", "1", "agent-a2", "claude", "fleet-implementer"],
124
- // deliberate dispatch, empty comparison: both arms resolved to one model
125
- ["sB", "2026-09-01", "other", "impl-3", "claude-sonnet-5", "high", "3", "", "0", "0", "0", "1", "agent-a3", "omp", "fleet-implementer-alt"],
126
- ["sB", "2026-09-01", "other", "impl-4", "claude-sonnet-5", "high", "4", "", "0", "0", "0", "1", "agent-a4", "omp", "fleet-implementer"],
127
- // two models, no deliberate dispatch: the accidental pair #1066 counted
128
- ["sC", "2026-09-01", "implementer", "impl-5", "claude-sonnet-5", "high", "5", "", "0", "0", "0", "1", "agent-a5", "claude", ""],
129
- ["sC", "2026-09-01", "implementer", "impl-6", "claude-opus-5", "high", "6", "", "0", "0", "0", "1", "agent-a6", "claude", ""],
130
- // a second pair on the SAME day — two pairs, one run_date
131
- ["sD", "2026-09-01", "implementer", "impl-7", "claude-sonnet-5", "high", "7", "", "0", "0", "0", "1", "agent-a7", "claude", "fleet-implementer-alt"],
132
- ["sD", "2026-09-01", "implementer", "impl-8", "claude-opus-5", "high", "8", "", "0", "0", "0", "1", "agent-a8", "claude", "fleet-implementer"],
133
- // and one on another day, so the second number is not the first
134
- ["sE", "2026-09-02", "implementer", "impl-9", "claude-sonnet-5", "high", "9", "", "0", "0", "0", "1", "agent-a9", "claude", "fleet-implementer-alt"],
135
- ["sE", "2026-09-02", "implementer", "impl-10", "claude-opus-5", "high", "10", "", "0", "0", "0", "1", "agent-a10", "claude", "fleet-implementer"],
136
- // a typed non-implementer at a third model inside a pair session: the
137
- // review fan-out runs one on every session and must not inflate anything
138
- ["sE", "2026-09-02", "specialist", "rev", "claude-haiku-4-5", "", "", "", "0", "0", "0", "1", "agent-a11", "claude", "fleet-review-tests"],
139
- ];
140
- const corpus = join(tempDir("mo-hdr-"), "member-outcomes.tsv");
141
- writeFileSync(corpus, "# header line, skipped by the query\n" + rows.map((r) => r.join("\t")).join("\n") + "\n");
83
+ assert.doesNotMatch(HEADER, /fleet-implementer-alt\$\//, "the header still carries the pre-cell pair query");
84
+ const subagentType = new RegExp(`\\$${COLUMNS.indexOf("subagent_type") + 1}(?!\\d)`);
85
+ assert.ok(
86
+ !headerLines.some((l) => subagentType.test(l)),
87
+ "an awk read-out over `subagent_type` is a second counting of comparisons",
88
+ );
142
89
 
143
- const program = /awk -F'\\t' '([\s\S]*)' docs\/metrics\/member-outcomes\.tsv$/.exec(pairQuery());
144
- assert.ok(program, "the pair query is no longer a runnable `awk -F'\\t' '<program>' <file>` line");
145
- const r = spawnSync("awk", ["-F", "\t", program[1], corpus], { encoding: "utf8" });
146
- assert.equal(r.status, 0, r.stderr);
147
- assert.equal(r.stdout.trim(), "3 2", "three deliberate pairs across two distinct run_dates");
90
+ // The definition the header states is the one the script applies.
91
+ const block = HEADER.replace(/^#\s?/gm, "").replace(/\s+/g, " ");
92
+ assert.match(block, /a comparison is one session holding an ADMISSIBLE row at X and an admissible slow-high row whose resolved \(`model`, `effort`\) differ from the X row's/);
93
+ assert.match(block, /admissible only when its `subagent_type` is `fleet-implementer-<chosen_cell>` and its `effort` is the cell's level/);
94
+ assert.match(block, /joined to its row here on `session`\+`agent`/);
95
+ const gate = /at least (\w+) comparisons across (\w+) or more distinct `run_date`s/.exec(block);
96
+ assert.ok(gate, "the header no longer states the gate");
97
+ const words = { five: 5, ten: 10 };
98
+ assert.deepEqual({ comparisons: words[gate[1]], runDates: words[gate[2]] }, { ...GATE }, "the header's gate is not cell-readout.mjs's GATE");
99
+ assert.match(block, /`<cell> <comparisons> <run_dates> <resolved models>`/);
100
+ assert.match(block, /`mixed \(<model-a> n=…, <model-b> n=…\)`/);
101
+ });
102
+
103
+ test("the header names a producer script that exists", () => {
104
+ const producer = /produced by (\S+\.mjs)/.exec(HEADER);
105
+ assert.ok(producer, "the header no longer names the script that produces its rows");
106
+ assert.ok(existsSync(join(REPO, producer[1])), `the header names ${producer[1]} as its producer, which does not exist`);
107
+ assert.equal(producer[1], "plugin/scripts/member-outcomes.mjs");
148
108
  });
@@ -35,8 +35,8 @@ export { normalizeModel, parseMemberName };
35
35
  //
36
36
  // `subagentType` is the record's `subagent_type` unchanged — `""` stays
37
37
  // `""` here, because unlike `effort` it does NOT mean unknown: it means the
38
- // dispatch named no agent definition, which is the reading the pair query
39
- // in this file's header depends on.
38
+ // dispatch named no agent definition, which is the reading cell-readout.mjs's
39
+ // admissibility test depends on: a blank is never any cell's definition.
40
40
  //
41
41
  // A member is never torn in the sense this file historically tracked (no
42
42
  // fold-back to tear mid-turn); `false` says so plainly rather than leaving
@@ -269,9 +269,9 @@ test("a nested member's fan-out is scraped too, keyed on its path-relative stem"
269
269
  });
270
270
 
271
271
  test("role comes from classifyRole and is not invented here", () => {
272
- // `role` had no assertion anywhere: it is the GROUPING column of the header's
273
- // pair query, so a classifyRole regression moved every bucket in the read-out
274
- // while the suite stayed green.
272
+ // `role` had no assertion anywhere while it was the GROUPING column of the
273
+ // header's former pair query, so a classifyRole regression moved every
274
+ // bucket in that read-out while the suite stayed green.
275
275
  const dir = fixture([
276
276
  ["impl-580", [sessionEvt("/x"), thinkingEvt("xhigh"), assistantEvt("claude-opus-5")]],
277
277
  ["merge-bot-3", [sessionEvt("/x"), thinkingEvt("xhigh"), sessionInitEvt("merge pass 3", "fleet-merge-bot"), assistantEvt("claude-opus-5")]],
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { between, phrase, sentences, stripHashGutter, unemphasized } from "./prose-pin.mjs";
6
- import { COLUMNS as MEMBER_COLUMNS } from "./member-outcomes.mjs";
6
+ import { GATE, readout } from "./cell-readout.mjs";
7
7
  import { COLUMNS } from "./tier-outcomes.mjs";
8
8
 
9
9
  // #472: this file's header has never been pinned, while SKILL.md's guard tells
@@ -381,26 +381,20 @@ test("the ruling step refuses to record a sizing verdict it cannot date", () =>
381
381
  });
382
382
 
383
383
  // `run_date` is DERIVED in `member-outcomes.tsv` (a transcript mtime) and
384
- // STAMPED AT RULING here, and phase 2's pairing floor counts distinct
385
- // `run_date`s — ten pairs across five dates — over the other file, never this
386
- // one. Read as a count over THIS file, it answers a different question, and
387
- // nothing in this header used to say so.
384
+ // STAMPED AT RULING here, and phase 2's per-cell gate counts distinct
385
+ // `run_date`s — ten comparisons across five dates — over the other file, never
386
+ // this one. Read as a count over THIS file, it answers a different question,
387
+ // and nothing in this header used to say so.
388
388
  //
389
389
  // The paragraph that now says it makes claims about files this one does not
390
390
  // contain, which is the shape that rots silently: it stays true only while
391
- // member-outcomes.tsv keeps carrying the pairing query, `member-outcomes.mjs`
392
- // keeps stamping dates off an mtime, and SKILL.md keeps the floor's numbers.
393
- // So the pins below DERIVE each claim from its real source — the same reason
394
- // the `profile` pin above derives the enum instead of restating it.
395
- const MEMBER_TSV = readFileSync(join(REPO, "docs", "metrics", "member-outcomes.tsv"), "utf8");
391
+ // cell-readout.mjs keeps dating comparisons off member-outcomes.tsv,
392
+ // `member-outcomes.mjs` keeps stamping dates off an mtime, and SKILL.md keeps
393
+ // the floor's numbers. So the pins below DERIVE each claim from its real
394
+ // source — the same reason the `profile` pin above derives the enum instead of
395
+ // restating it.
396
396
  const MEMBER_SRC = readFileSync(join(REPO, "plugin", "scripts", "member-outcomes.mjs"), "utf8");
397
397
 
398
- const memberHeaderLines = [];
399
- for (const l of MEMBER_TSV.split("\n")) {
400
- if (!l.startsWith("#") && l.trim()) break;
401
- memberHeaderLines.push(l);
402
- }
403
-
404
398
  // Bounded at both ends, for `between`'s own stated reason: this header is 140
405
399
  // lines about exactly these words, so an unbounded match is satisfiable from
406
400
  // the provenance block above the paragraph or the column legend below it. The
@@ -418,9 +412,9 @@ test("the header says which file each distinct-`run_date` count is read from, an
418
412
  assert.match(
419
413
  block,
420
414
  phrase(
421
- "at least ten within-run pairs across five or more distinct `run_date`s, the floor that forbids reading the pairs early — is NOT read here at all. It comes from the pairing query in `docs/metrics/member-outcomes.tsv`'s own header",
415
+ "at least ten comparisons across five or more distinct `run_date`s, the floor that forbids reading a cell early — is NOT read here at all. It comes from `plugin/scripts/cell-readout.mjs`, which dates each comparison by its session's `run_date` in `docs/metrics/member-outcomes.tsv`",
422
416
  ),
423
- "the header no longer says the pairing floor's distinct-date count is read from member-outcomes.tsv, not here",
417
+ "the header no longer says the per-cell gate's distinct-date count is read from member-outcomes.tsv through cell-readout.mjs, not here",
424
418
  );
425
419
 
426
420
  // The definitional halves, each joined to what it makes the column MEAN. A
@@ -457,47 +451,27 @@ test("the header says which file each distinct-`run_date` count is read from, an
457
451
  );
458
452
  });
459
453
 
460
- test("the header's `run_date` source claims still hold against the query, the scraper and the guard", () => {
454
+ test("the header's `run_date` source claims still hold against the readout, the scraper and the guard", () => {
461
455
  const block = runDateSources();
462
456
 
463
- // The pairing half: the query lives in the OTHER file's header and keys its
464
- // dates off the other file's own columns, so resolve it against that file's
465
- // COLUMNS export. This is the claim that makes the paragraph load-bearing —
466
- // if the query moves or is rekeyed, "read it from there" stops being true.
467
- // Bounded to the query BLOCK, not the whole 140-line header —
468
- // member-outcomes-header.test.mjs's own `pairQuery()` bounds it the same
469
- // way, for the same reason: an unbounded match is satisfiable by a stale
470
- // `d[$1]=$2` idiom anywhere in that header even after the real query moved
471
- // or was rekeyed, which is exactly what would make "read it from there"
472
- // stop being true without this pin noticing.
473
- const pairQueryStart = memberHeaderLines.findIndex((l) => l.includes("awk -F") && l.includes("fleet-implementer-alt"));
474
- assert.ok(pairQueryStart >= 0, "member-outcomes.tsv's header lost its within-run pair query");
475
- let pairQueryEnd = pairQueryStart;
476
- while (pairQueryEnd < memberHeaderLines.length && !memberHeaderLines[pairQueryEnd].endsWith("docs/metrics/member-outcomes.tsv")) pairQueryEnd++;
477
- assert.ok(pairQueryEnd < memberHeaderLines.length, "the pairing query never reaches the file it reads");
478
- const pairQueryBlock = memberHeaderLines.slice(pairQueryStart, pairQueryEnd + 1).join("\n");
479
-
480
- const pairKey = /d\[\$(\d+)\]=\$(\d+)/.exec(pairQueryBlock);
481
- assert.ok(pairKey, "member-outcomes.tsv's header no longer carries the pairing query this header points at");
482
- assert.equal(
483
- MEMBER_COLUMNS[pairKey[2] - 1],
484
- "run_date",
485
- "the pairing query's distinct-date count is not member-outcomes.tsv's `run_date`",
486
- );
487
- assert.ok(
488
- block.includes(pairKey[0]),
489
- `the header cites an idiom for the pairing query's date key that member-outcomes.tsv no longer uses (\`${pairKey[0]}\`)`,
490
- );
491
-
492
- // The header also cites the query's distinct-date ACCUMULATOR
493
- // (`length(r)`) — resolve the variable name against the query's own
494
- // `length(...)` call rather than trusting the header's word for it.
495
- const pairAccumulator = /print n\+0, length\((\w+)\)/.exec(pairQueryBlock);
496
- assert.ok(pairAccumulator, "member-outcomes.tsv's pair query no longer prints its distinct-date count via `length(...)`");
497
- assert.ok(
498
- block.includes(`length(${pairAccumulator[1]})`),
499
- `the header cites the wrong accumulator for the pairing query's distinct-date count (expected \`length(${pairAccumulator[1]})\`)`,
500
- );
457
+ // The gate half: the count lives in cell-readout.mjs and dates each
458
+ // comparison off the OTHER file's `run_date`. Run, not read: one comparison
459
+ // whose ticket-features date and member-outcomes date differ, and the date
460
+ // the readout counts must be the member-outcomes one. If the readout ever
461
+ // keys its dates elsewhere, "it comes from there" stops being true.
462
+ const session = "s-tier-hdr";
463
+ const pull = (agent, chosen_cell) => ({ run_date: "2026-01-01", session, agent, chosen_cell });
464
+ const member = (agent, cell, model) => ({
465
+ session, agent, run_date: "2026-02-02", model, effort: "high", subagentType: `fleet-implementer-${cell}`,
466
+ });
467
+ const { cells: [task] } = readout({
468
+ features: [pull("impl-1", "task-high"), pull("impl-2", "slow-high")],
469
+ members: [member("impl-1", "task-high", "m-task"), member("impl-2", "slow-high", "m-slow")],
470
+ });
471
+ assert.deepEqual([task.cell, task.comparisons, task.runDates], ["task-high", 1, 1]);
472
+ assert.deepEqual([...task.dates], ["2026-02-02"], "cell-readout.mjs no longer dates a comparison by member-outcomes.tsv's `run_date`");
473
+ assert.match(block, /`plugin\/scripts\/cell-readout\.mjs`/);
474
+ assert.match(block, /`docs\/metrics\/member-outcomes\.tsv`/);
501
475
 
502
476
  // The DERIVED half as code, not prose: the date comes off a transcript
503
477
  // mtime. A scraper switched to a clock read would make this header's
@@ -528,16 +502,17 @@ test("the header's `run_date` source claims still hold against the query, the sc
528
502
  // writes to the day it ran.
529
503
 
530
504
  // The floor's numbers, as the paragraph quotes them. It names figures it
531
- // does not own; a threshold changed in SKILL.md alone leaves this header
532
- // quietly citing the old one.
505
+ // does not own; a threshold changed in SKILL.md or in the readout alone
506
+ // leaves this header quietly citing the old one.
533
507
  assert.match(
534
508
  unemphasized(RUN_TEAM),
535
509
  phrase("there are at least ten of them across five or more distinct `run_date`s"),
536
- "SKILL.md's pairing floor is no longer ten pairs across five dates — the header quotes those numbers",
510
+ "SKILL.md's per-cell floor is no longer ten comparisons across five dates — the header quotes those numbers",
537
511
  );
512
+ assert.deepEqual({ ...GATE }, { comparisons: 10, runDates: 5 }, "cell-readout.mjs's gate is no longer ten across five — the header quotes those numbers");
538
513
  assert.match(
539
514
  block,
540
- phrase("at least ten within-run pairs across five or more"),
541
- "the header no longer states the pairing floor it attributes to the other file",
515
+ phrase("at least ten comparisons across five or more"),
516
+ "the header no longer states the per-cell floor it attributes to the other file",
542
517
  );
543
518
  });