@feigi/fleet-ctl 3.22.9 → 3.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.22.9",
3
+ "version": "3.23.1",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,6 +37,16 @@
37
37
  // `below the gate` count on stderr. Nothing on either stream names a session,
38
38
  // ticket or member: the gate forbids reading a comparison early.
39
39
  //
40
+ // STOPPING RULE (exported as `stoppingRule`, not printed by this CLI). For a
41
+ // cell X other than slow-high whose definition is live, a VERDICT is a ticket
42
+ // with an admissible Pull at X dated on or after the day X's
43
+ // `fleet-implementer-<cell>` definition was most recently added, ruled by its
44
+ // last tier-outcomes.tsv row (a `+`-joined `ticket` field rules each ticket
45
+ // it names); a ticket counts once however many Pulls it took. A verdict FAILS
46
+ // the quality floor on `minted_false_claim=yes` or `closed_own_ticket=no`.
47
+ // X is to be withdrawn once it has at least STOP.verdicts verdicts and
48
+ // floor failures ÷ verdicts is at least STOP.failRate.
49
+ //
40
50
  // Exit 0 whatever the counts; 2 on a usage error or an unreadable or
41
51
  // malformed input, with nothing on stdout.
42
52
 
@@ -49,6 +59,7 @@ import { parseFeatures } from "./pr-cost.mjs";
49
59
 
50
60
  const NAME = "cell-readout";
51
61
  export const GATE = Object.freeze({ comparisons: 10, runDates: 5 });
62
+ export const STOP = Object.freeze({ verdicts: 10, failRate: 0.8 });
52
63
 
53
64
  const key = (session, agent) => `${session}\0${agent}`;
54
65
  const levelOf = (cell) => CELL.exec(cell)?.[2] ?? null;
@@ -104,6 +115,37 @@ export function readout({ features, members }) {
104
115
  return { cells: out, unjoined };
105
116
  }
106
117
 
118
+ /**
119
+ * One entry per cell named in `added` (cell → the `YYYY-MM-DD` its definition
120
+ * was most recently added) other than the policy cell, sorted by cell:
121
+ * `{ cell, since, verdicts, failures, stop }`. `verdicts` is
122
+ * `[{ ticket, pr, run_date, closed_own_ticket, minted_false_claim, failed }]`
123
+ * sorted by ticket, `run_date` the ruling's; `stop` is whether the stopping
124
+ * rule withdraws the cell. `verdicts` are parsed tier-outcomes.tsv rows.
125
+ */
126
+ export function stoppingRule({ features, members, verdicts, added }) {
127
+ const byKey = new Map(members.map((m) => [key(m.session, m.agent), m]));
128
+ const ruling = new Map();
129
+ for (const v of verdicts) for (const t of String(v.ticket).split("+")) ruling.set(t, v);
130
+ const judged = new Map(Object.keys(added).filter((c) => c !== POLICY_CELL).map((c) => [c, new Map()]));
131
+ for (const p of features) {
132
+ const tickets = judged.get(p.chosen_cell);
133
+ if (!tickets || p.run_date < added[p.chosen_cell] || !ruling.has(p.ticket) || !admissibleMember(p, byKey)) continue;
134
+ const v = ruling.get(p.ticket);
135
+ const failed = v.minted_false_claim === "yes" || v.closed_own_ticket === "no";
136
+ tickets.set(p.ticket, {
137
+ ticket: p.ticket, pr: v.pr, run_date: v.run_date,
138
+ closed_own_ticket: v.closed_own_ticket, minted_false_claim: v.minted_false_claim, failed,
139
+ });
140
+ }
141
+ return [...judged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([cell, tickets]) => {
142
+ const list = [...tickets.values()].sort((a, b) => Number(a.ticket) - Number(b.ticket));
143
+ const failures = list.filter((v) => v.failed).length;
144
+ const stop = list.length >= STOP.verdicts && failures / list.length >= STOP.failRate;
145
+ return { cell, since: added[cell], verdicts: list, failures, stop };
146
+ });
147
+ }
148
+
107
149
  // Only a gated cell is formatted, and a comparison needs an admissible row at
108
150
  // the cell, so `models` is never empty here.
109
151
  function formatModels(models) {
@@ -10,7 +10,7 @@ import { tempDir } from "./temp-dir.mjs";
10
10
  import { paragraph, phrase, unemphasized } from "./prose-pin.mjs";
11
11
  import { formatTsv } from "./member-outcomes.mjs";
12
12
  import { FEATURE_COLUMNS } from "./pr-cost.mjs";
13
- import { readout, GATE } from "./cell-readout.mjs";
13
+ import { readout, stoppingRule, GATE, STOP } from "./cell-readout.mjs";
14
14
 
15
15
  const SCRIPT = fileURLToPath(new URL("./cell-readout.mjs", import.meta.url));
16
16
 
@@ -339,3 +339,105 @@ function parsed(w) {
339
339
  members: w.members.map((r) => ({ ...r })),
340
340
  };
341
341
  }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // The per-cell stopping rule: admissible Pulls at a cell, joined to their
345
+ // ticket's last tier-outcomes row.
346
+
347
+ const verdict = (o) => ({
348
+ run_date: "2026-10-03", pr: "", ticket: "", class: "", tier: "", closed_own_ticket: "yes",
349
+ minted_false_claim: "no", note: "n", sizing: "", profile: "", loc: "", files: "", ...o,
350
+ });
351
+ // `n` admissible Pulls at `cell` on `date`, each ruled; the first `failures` fail the floor,
352
+ // alternating between its two halves.
353
+ function addVerdicts(w, cell, n, failures, { date = "2026-10-02", ...rowOpts } = {}) {
354
+ w.verdicts ??= [];
355
+ for (let i = 0; i < n; i++) {
356
+ addRow(w, { session: `2026-10-02T00-00-00-000Z_v${nextSession++}`, date, cell, ...rowOpts });
357
+ const p = w.features.at(-1);
358
+ p.run_date = date;
359
+ const fail = i < failures;
360
+ w.verdicts.push(verdict({
361
+ ticket: p.ticket, pr: String(Number(p.ticket) + 5000),
362
+ minted_false_claim: fail && i % 2 === 0 ? "yes" : "no", closed_own_ticket: fail && i % 2 === 1 ? "no" : "yes",
363
+ }));
364
+ }
365
+ }
366
+ const stop = (w, added) => stoppingRule({ ...parsed(w), verdicts: (w.verdicts ?? []).map((r) => ({ ...r })), added });
367
+ const judged = (w, added, cell) => stop(w, added).find((c) => c.cell === cell);
368
+
369
+ test("stopping rule: ten verdicts with eight floor failures stop the cell; seven, or nine verdicts all failing, do not", () => {
370
+ const added = { "smol-high": "2026-09-01" };
371
+ const eight = world();
372
+ addVerdicts(eight, "smol-high", 10, 8);
373
+ const at = judged(eight, added, "smol-high");
374
+ assert.equal(at.verdicts.length, STOP.verdicts);
375
+ assert.equal(at.failures, 8, "both floor halves count: minted_false_claim=yes and closed_own_ticket=no");
376
+ assert.equal(at.stop, true);
377
+ assert.equal(at.since, "2026-09-01");
378
+ assert.deepEqual(Object.keys(at.verdicts[0]).sort(), ["closed_own_ticket", "failed", "minted_false_claim", "pr", "run_date", "ticket"]);
379
+
380
+ const seven = world();
381
+ addVerdicts(seven, "smol-high", 10, 7);
382
+ assert.equal(judged(seven, added, "smol-high").stop, false, "70% is under the 80% floor-failure rate");
383
+
384
+ const nine = world();
385
+ addVerdicts(nine, "smol-high", 9, 9);
386
+ assert.equal(judged(nine, added, "smol-high").stop, false, "nine verdicts are under the ten the rule needs");
387
+
388
+ // Past ten verdicts the rate alone decides: 28 of 35 is 80%, 27 of 35 under it.
389
+ const exact = world();
390
+ addVerdicts(exact, "smol-high", 35, 28);
391
+ assert.equal(judged(exact, added, "smol-high").stop, true);
392
+ const under = world();
393
+ addVerdicts(under, "smol-high", 35, 27);
394
+ assert.equal(judged(under, added, "smol-high").stop, false);
395
+ });
396
+
397
+ test("stopping rule: a cell's verdicts are listed by ascending ticket number however the corpus orders them", () => {
398
+ const w = world();
399
+ w.ticket = 95; // 95..104 crosses a digit boundary: a string sort would put 100 before 95
400
+ addVerdicts(w, "smol-high", 10, 0);
401
+ w.features.reverse();
402
+ w.verdicts.reverse();
403
+ const at = judged(w, { "smol-high": "2026-09-01" }, "smol-high");
404
+ assert.deepEqual(at.verdicts.map((v) => v.ticket), ["95", "96", "97", "98", "99", "100", "101", "102", "103", "104"]);
405
+ });
406
+
407
+ test("stopping rule: only admissible Pulls on or after the definition was last added count, at a cell with a live definition other than the policy cell", () => {
408
+ const w = world();
409
+ addVerdicts(w, "smol-high", 10, 10, { date: "2026-10-02" });
410
+ addVerdicts(w, "task-high", 10, 10, { effort: "medium" });
411
+ addVerdicts(w, "task-high", 2, 2, { subagentType: "task" });
412
+ addVerdicts(w, "slow-high", 10, 10);
413
+ addVerdicts(w, "task-max", 10, 10);
414
+ const out = stop(w, { "smol-high": "2026-10-03", "task-high": "2026-09-01", "slow-high": "2026-09-01" });
415
+ assert.deepEqual(out.map((c) => c.cell), ["smol-high", "task-high"], "slow-high is the policy cell; task-max has no definition");
416
+ assert.equal(out[0].verdicts.length, 0, "every Pull predates the definition's re-add");
417
+ assert.equal(out[1].verdicts.length, 0, "a clamped effort or a generic dispatch is not admissible");
418
+ assert.equal(out[0].stop, false);
419
+
420
+ const reAdded = stop(w, { "smol-high": "2026-10-02" });
421
+ assert.equal(reAdded[0].verdicts.length, 10, "a Pull dated the day the definition was added counts");
422
+ assert.equal(reAdded[0].stop, true);
423
+ });
424
+
425
+ test("stopping rule: a ticket counts once, judged by its last tier-outcomes row, and a `+`-joined ticket field rules each ticket", () => {
426
+ const w = world();
427
+ addVerdicts(w, "smol-high", 10, 10);
428
+ const [first, second] = w.features;
429
+ // A re-dispatch of the first ticket in its own session: same ticket, same cell.
430
+ addRow(w, { session: first.session, date: "2026-10-02", cell: "smol-high" });
431
+ Object.assign(w.features.at(-1), { ticket: first.ticket, agent: `impl-${first.ticket}-2`, run_date: "2026-10-02" });
432
+ Object.assign(w.members.at(-1), { ticket: first.ticket, agent: `impl-${first.ticket}-2`, member: `impl-${first.ticket}-2` });
433
+ // A later ruling of the first ticket passes the floor, and the second ticket's
434
+ // only ruling now shares a row with another ticket.
435
+ w.verdicts.push(verdict({ ticket: first.ticket, pr: "9001" }));
436
+ w.verdicts.find((v) => v.ticket === second.ticket).ticket = `77+${second.ticket}`;
437
+ const at = judged(w, { "smol-high": "2026-09-01" }, "smol-high");
438
+ assert.equal(at.verdicts.length, 10, "the re-dispatch adds no second verdict");
439
+ assert.equal(at.failures, 9, "the first ticket's last ruling passed");
440
+ assert.equal(at.verdicts.find((v) => v.ticket === first.ticket).pr, "9001");
441
+ assert.equal(at.verdicts.find((v) => v.ticket === second.ticket).failed, true);
442
+ assert.equal(at.stop, true);
443
+ });
@@ -912,6 +912,22 @@ const PR_LIMIT = 200;
912
912
  const CLAIMED_LIMIT = 200;
913
913
  const MAX_BUFFER = 64 * 1024 * 1024;
914
914
  const GIT_TIMEOUT_MS = 10_000;
915
+ // Every gh spawn below is killed at this bound, so a gh that never answers is
916
+ // a failed read on that call's own failure path — the open-PR read refuses the
917
+ // tick, a probe leaves standing what it could not confirm, the claimed count
918
+ // reads unknown — never a tick that does not return. The bound is per gh call,
919
+ // not per tick: the probes run one after another, so a tick whose probes all
920
+ // hang waits one bound for each of them. 20 s, shortlist.mjs's own gh bound.
921
+ // FLEET_TICK_GH_TIMEOUT overrides it, in seconds, and can only ever
922
+ // SHORTEN it — ledger.mjs's LEDGER_GIT_TIMEOUT rule: a value that is not a
923
+ // positive whole number below the default leaves the default standing, in
924
+ // silence. Exported for its own test; it touches nothing.
925
+ export function ghBudget(defaultSeconds, override) {
926
+ const seconds = isDigits(String(override)) ? Number(override) : 0;
927
+ return (seconds > 0 && seconds < defaultSeconds ? seconds : defaultSeconds) * 1000;
928
+ }
929
+ const GH_TIMEOUT_MS = ghBudget(20, process.env.FLEET_TICK_GH_TIMEOUT);
930
+ const TIMED_OUT = `timed out after ${GH_TIMEOUT_MS / 1000}s`;
915
931
 
916
932
  // die() shared with the other fleet scripts (writeSync-based, pipe-safe —
917
933
  // see arg.mjs for the #176/#328/#363 rationale). This file parses its own
@@ -979,12 +995,12 @@ function openPrs() {
979
995
  let out;
980
996
  try {
981
997
  out = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", String(PR_LIMIT),
982
- "--json", "number,labels,closingIssuesReferences,headRefOid"], { encoding: "utf8" });
998
+ "--json", "number,labels,closingIssuesReferences,headRefOid"], { encoding: "utf8", timeout: GH_TIMEOUT_MS });
983
999
  } catch (e) {
984
1000
  // Never interpolates e.stderr or e.message: execFileSync already forwarded
985
1001
  // the child's stderr to ours, and Node builds e.message out of it, so
986
1002
  // either one emits every byte a second time (#176).
987
- die(`gh pr list failed: ${e.code ?? (e.signal ? `killed by ${e.signal}` : `exit ${e.status}`)} — a failed read is not an empty queue`);
1003
+ die(`gh pr list failed: ${e.code === "ETIMEDOUT" ? TIMED_OUT : e.code ?? (e.signal ? `killed by ${e.signal}` : `exit ${e.status}`)} — a failed read is not an empty queue`);
988
1004
  }
989
1005
  let prs;
990
1006
  try {
@@ -1006,8 +1022,10 @@ function openPrs() {
1006
1022
  return prs;
1007
1023
  }
1008
1024
 
1009
- // A child that did not exit 0, named with the last line it printed.
1025
+ // A child that did not exit 0, named with the last line it printed. A gh
1026
+ // killed at GH_TIMEOUT_MS ran and overran: "did not run" would misstate it.
1010
1027
  function failure(r, what) {
1028
+ if (r.error?.code === "ETIMEDOUT") return `${what} ${TIMED_OUT}`;
1011
1029
  if (r.error) return `${what} did not run: ${r.error.code ?? r.error.message}`;
1012
1030
  const last = String(r.stderr ?? "").trim().split("\n").at(-1);
1013
1031
  return `${what} ${r.signal ? `killed by ${r.signal}` : `exited ${r.status}`}${last ? `: ${last}` : ""}`;
@@ -1077,7 +1095,7 @@ function liftedPremise(excluded, entries, prs) {
1077
1095
  if (kind === "issue") {
1078
1096
  // Scrubbed as shortlist.mjs's probeState() is: gh's remote resolution
1079
1097
  // follows GIT_DIR/GIT_WORK_TREE, and GH_REPO outranks both.
1080
- const r = spawnSync("gh", ["issue", "view", target, "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
1098
+ const r = spawnSync("gh", ["issue", "view", target, "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1081
1099
  if (r.error || r.status !== 0) {
1082
1100
  // Disclosed, not dropped: every other gh call in this file either
1083
1101
  // dies or logs its failure — a bare empty catch here would be the
@@ -1104,7 +1122,7 @@ function liftedPremise(excluded, entries, prs) {
1104
1122
  function closedTickets(mismatched) {
1105
1123
  const closed = new Set();
1106
1124
  for (const { number } of mismatched.map(parseMember)) {
1107
- const r = spawnSync("gh", ["issue", "view", String(number), "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
1125
+ const r = spawnSync("gh", ["issue", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1108
1126
  if (r.error || r.status !== 0) {
1109
1127
  console.error(`${NAME}: ${failure(r, `gh issue view ${number}`)} — tier mismatch on #${number} unconfirmed closed, hold stands`);
1110
1128
  continue;
@@ -1131,7 +1149,7 @@ function closedTickets(mismatched) {
1131
1149
  function finishedReviewPrs(offList) {
1132
1150
  const finished = new Set();
1133
1151
  for (const number of offList) {
1134
- const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", env: gitEnv({ GH_REPO: "" }) });
1152
+ const r = spawnSync("gh", ["pr", "view", String(number), "--json", "state"], { encoding: "utf8", timeout: GH_TIMEOUT_MS, env: gitEnv({ GH_REPO: "" }) });
1135
1153
  if (r.error || r.status !== 0) {
1136
1154
  console.error(`${NAME}: ${failure(r, `gh pr view ${number}`)} — review on PR#${number} unconfirmed finished, its slot stands`);
1137
1155
  continue;
@@ -1166,12 +1184,12 @@ function refresh() {
1166
1184
  // reporting "0 claimed" says the dead run stranded nothing.
1167
1185
  function claimed() {
1168
1186
  const r = spawnSync("gh", ["issue", "list", "--label", "in-progress", "--state", "open",
1169
- "--limit", String(CLAIMED_LIMIT), "--json", "number", "--jq", "length"], { encoding: "utf8" });
1187
+ "--limit", String(CLAIMED_LIMIT), "--json", "number", "--jq", "length"], { encoding: "utf8", timeout: GH_TIMEOUT_MS });
1170
1188
  if (r.error || r.status !== 0) {
1171
1189
  // Disclosed, not dropped: a real gh failure must be distinguishable from
1172
1190
  // the deliberately-undiagnosed case.
1173
- const why = r.error
1174
- ? `gh did not run: ${r.error.code ?? r.error.message}`
1191
+ const why = r.error?.code === "ETIMEDOUT" ? `gh ${TIMED_OUT}`
1192
+ : r.error ? `gh did not run: ${r.error.code ?? r.error.message}`
1175
1193
  : `gh ${r.signal ? `killed by ${r.signal}` : `exited ${r.status}`}`;
1176
1194
  const tail = (r.stderr ?? "").trim().split("\n").slice(-5).join("\n");
1177
1195
  console.error(`${NAME}: ${why} — claimed ticket count unknown${tail ? `\n${tail}` : ""}`);
@@ -1307,8 +1307,13 @@ test("CLI: every synchronous spawn in this file goes through spawnBounded (#2320
1307
1307
  // `pr list` for the open PRs, `issue view` for a behind-issue premise's state,
1308
1308
  // `pr view` for the state of a PR an in-flight review names that is no longer
1309
1309
  // on the open list, `issue list --label in-progress` for the stall report's
1310
- // claimed count.
1310
+ // claimed count. `GH_HANG` names one of those (`pr list`, `issue view`, …) to
1311
+ // stall for `GH_HANG_S` seconds before answering as usual, so a tick that waits
1312
+ // a hang out still reads a normal reply. The sleep holds none of the stub's
1313
+ // pipes: killed at the tick's bound, the stub leaves no child keeping the
1314
+ // tick's read open.
1311
1315
  const GH_STUB = `#!/bin/sh
1316
+ [ -n "$GH_HANG" ] && [ "$1 $2" = "$GH_HANG" ] && sleep "$GH_HANG_S" </dev/null >/dev/null 2>&1
1312
1317
  case "$1 $2" in
1313
1318
  "pr list") [ -n "$PR_FAIL" ] && { echo "boom" >&2; exit 1; }; cat "$FIXTURE_PRS" ;;
1314
1319
  "issue view")
@@ -2329,3 +2334,111 @@ test("CLI: a busy, fully-staffed fleet is not reported STALLED just because the
2329
2334
  assert.doesNotMatch(r.stdout, /STALLED/);
2330
2335
  assert.match(r.stdout, /^implementers 2\/2 → AT CAP/m);
2331
2336
  });
2337
+
2338
+ // Every gh spawn in the tick carries a bound: a gh that never answers is a
2339
+ // failed read on that call's own failure path, never a tick that hangs. Each
2340
+ // case stalls one gh call for 30s under a 1s override, so a spawn that lost its
2341
+ // bound waits the stall out, reads the stub's normal reply, and fails the
2342
+ // assertions on the disclosed timeout — inside spawnBounded's own backstop.
2343
+ import { ghBudget } from "./fleet-tick.mjs";
2344
+
2345
+ const hang = (call, seconds = "30", budget = "1") => ({ GH_HANG: call, GH_HANG_S: seconds, FLEET_TICK_GH_TIMEOUT: budget });
2346
+
2347
+ test("CLI: a hung gh pr list refuses the tick at the bound — a read that timed out is not an empty queue", () => {
2348
+ const r = runCli([], { shortlist: shortlistText([]), env: hang("pr list") });
2349
+ assert.equal(r.status, 2, r.stderr);
2350
+ assert.equal(r.stdout.trim(), "");
2351
+ assert.match(r.stderr, /gh pr list failed: timed out after 1s — a failed read is not an empty queue/);
2352
+ });
2353
+
2354
+ test("CLI: a hung probe of a mismatched ticket is disclosed at the bound and the hold stands", () => {
2355
+ const r = runCli([], {
2356
+ shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#7 impl-7=tier-mismatch"], dispatched: ["impl-7=tier-mismatch"] },
2357
+ issueStates: { 7: "CLOSED" }, env: hang("issue view"),
2358
+ });
2359
+ assert.equal(r.status, 0, r.stderr);
2360
+ assert.match(r.stdout, /^implementers 0\/2 → HOLD \(tier mismatch impl-7\)/m);
2361
+ assert.match(r.stderr, /fleet-tick: gh issue view 7 timed out after 1s — tier mismatch on #7 unconfirmed closed, hold stands/);
2362
+ });
2363
+
2364
+ test("CLI: a hung behind-issue probe is disclosed at the bound and the exclusion stands", () => {
2365
+ const r = runCli([], {
2366
+ shortlist: shortlistText([1, 2, 3]), refresh: shortlistText([1, 2, 3, 50]),
2367
+ ledger: { rows: ["#50 excluded · behind-issue:#9"] }, issueStates: { 9: "CLOSED" }, env: hang("issue view"),
2368
+ });
2369
+ assert.equal(r.status, 0, r.stderr);
2370
+ assert.equal(r.refreshed, 0, "a probe that timed out cannot confirm the lift");
2371
+ assert.match(r.stderr, /fleet-tick: gh issue view 9 timed out after 1s — behind-issue:#9 premise unconfirmed, exclusion stands/);
2372
+ });
2373
+
2374
+ test("CLI: a hung probe of a PR off the open list is disclosed at the bound and its review keeps the slot", () => {
2375
+ const r = runCli(["--reviewer-cap", "1", "--max-reviews", "1"], {
2376
+ shortlist: shortlistText([]), prs: [REVIEW_DUE], ledger: { rows: zombieRows([101]) }, prStates: { 101: "MERGED" },
2377
+ env: hang("pr view"),
2378
+ });
2379
+ assert.equal(r.status, 0, r.stderr);
2380
+ assert.match(r.stderr, /fleet-tick: gh pr view 101 timed out after 1s — review on PR#101 unconfirmed finished, its slot stands/);
2381
+ assert.match(r.stdout, /^reviewers +1\/1 → AT CAP/m);
2382
+ });
2383
+
2384
+ test("CLI: a hung claimed-count read makes the stall report's count unknown at the bound", (t) => {
2385
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-hung-")));
2386
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
2387
+ const path = join(dir, "heartbeat.json");
2388
+ writeFileSync(path, beat(90 * 60_000, 1200));
2389
+ const r = runCli(["--state", path], { ...IDLE, claimed: [{ number: 41 }], env: hang("issue list") });
2390
+ assert.equal(r.status, 0, r.stderr);
2391
+ assert.match(r.stdout, /unknown ticket\(s\) claimed and in flight/);
2392
+ assert.match(r.stderr, /fleet-tick: gh timed out after 1s — claimed ticket count unknown/);
2393
+ });
2394
+
2395
+ // The one case that proves the DEFAULT is a bound, and pays it in full: an
2396
+ // override past the default is refused, so the hang is cut at 20s rather than
2397
+ // waited out at 40s.
2398
+ test("CLI: FLEET_TICK_GH_TIMEOUT cannot lengthen the default bound", () => {
2399
+ const r = runCli([], { shortlist: shortlistText([]), env: hang("pr list", "40", "600") });
2400
+ assert.equal(r.status, 2, r.stderr);
2401
+ assert.match(r.stderr, /gh pr list failed: timed out after 20s/);
2402
+ });
2403
+
2404
+ // What the bound must ACCEPT: a gh that answers slowly, inside the bound, is a
2405
+ // normal read, not a failure.
2406
+ test("CLI: a slow gh that answers inside the bound is read as usual", () => {
2407
+ const r = runCli([], {
2408
+ shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#7 impl-7=tier-mismatch"], dispatched: ["impl-7=tier-mismatch"] },
2409
+ issueStates: { 7: "CLOSED" }, env: hang("issue view", "2", "10"),
2410
+ });
2411
+ assert.equal(r.status, 0, r.stderr);
2412
+ assert.doesNotMatch(r.stderr, /timed out/);
2413
+ assert.deepEqual(r.issueViews, ["7"]);
2414
+ assert.match(r.stdout, /^implementers 0\/2 → PULL #1 #2 /m, "the slow CLOSED answer was read and lifted the hold");
2415
+ });
2416
+
2417
+ // The cases above pin the gh spawns the tick has today; this pins the next
2418
+ // one, on the script's code with its comments blanked. Every call through
2419
+ // spawnSync or execFileSync that does not name process.execPath or git must
2420
+ // pass the shared bound — as `timeout: GH_TIMEOUT_MS` closed by a comma or a
2421
+ // brace, never a longer expression — before its call closes. A gh spawned
2422
+ // through any other function must name child_process again, and the import pin
2423
+ // fails on every line that does.
2424
+ test("CLI: every gh spawn in fleet-tick.mjs passes the shared bound", () => {
2425
+ const code = readFileSync(SCRIPT, "utf8").replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (c) => c.replace(/[^\n]/g, " "));
2426
+ assert.deepEqual(code.split("\n").map((l) => l.trimEnd()).filter((l) => l.includes("child_process")),
2427
+ ['import { execFileSync, spawnSync } from "node:child_process";'],
2428
+ "the script spawns through something new — extend this sweep to it");
2429
+ const lineOf = (i) => code.slice(0, i).split("\n").length;
2430
+ const calls = [...code.matchAll(/\b(?:spawnSync|execFileSync)\(\s*(?=\S)(?!process\.execPath\b|["']git["'])/g)];
2431
+ assert.ok(calls.length > 0, "no gh spawn found; this sweep has nothing to measure");
2432
+ const unbounded = calls
2433
+ .filter((m) => !/\btimeout:\s*GH_TIMEOUT_MS\s*[,}]/.test(code.slice(m.index, code.indexOf(");", m.index))))
2434
+ .map((m) => `line ${lineOf(m.index)}`);
2435
+ assert.deepEqual(unbounded, []);
2436
+ });
2437
+
2438
+ test("ghBudget: the override only ever shortens, and anything but digits leaves the default", () => {
2439
+ const cases = [
2440
+ [undefined, 20_000], ["", 20_000], ["5", 5_000], ["1", 1_000], ["19", 19_000],
2441
+ ["20", 20_000], ["600", 20_000], ["0", 20_000], ["-1", 20_000], ["3.5", 20_000], ["1e1", 20_000], [" 5", 20_000], ["abc", 20_000],
2442
+ ];
2443
+ for (const [override, ms] of cases) assert.equal(ghBudget(20, override), ms, JSON.stringify(override));
2444
+ });
@@ -0,0 +1,432 @@
1
+ // The repo-local run-team hook, .omp/skills/run-team-local/hook.mjs, run as
2
+ // the CLI against a fixture workspace: a clone of a bare origin whose main
3
+ // carries the metrics corpus, the router table and the cell definitions, and
4
+ // a stub `gh` that answers from a fixture file and logs every call it gets.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync, spawnSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { tempDir } from "./temp-dir.mjs";
12
+ import { writeExecStub } from "./exec-stub.mjs";
13
+ import { COLUMNS as MEMBER_COLUMNS, formatTsv } from "./member-outcomes.mjs";
14
+ import { COLUMNS as TIER_COLUMNS, formatRow } from "./tier-outcomes.mjs";
15
+ import { FEATURE_COLUMNS, WINDOW_START, MIN_N } from "./pr-cost.mjs";
16
+ import { FIT_EVERY_MERGED } from "./ticket-router.mjs";
17
+ import { STOP } from "./cell-readout.mjs";
18
+
19
+ const HOOK = fileURLToPath(new URL("../../.omp/skills/run-team-local/hook.mjs", import.meta.url));
20
+ const DAY = WINDOW_START;
21
+ const SESSION = "2026-10-03T10-00-00-000Z_01a00000-0000-7000-8000-000000000000";
22
+ const STAGE1 = ["slow-high", "task-high", "smol-high"];
23
+ const PENDING = join(".fleet", "ticket-features.pending.tsv");
24
+ const FEATURES = join("docs", "metrics", "ticket-features.tsv");
25
+ const GUARD = join(".fleet", "cost-guard.json");
26
+ const TABLE = join("plugin", "scripts", "router-table.json");
27
+
28
+ // Pinned identity, no developer config, no inherited repo pointers.
29
+ const ENV = {
30
+ ...process.env,
31
+ GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, GIT_TEMPLATE_DIR: undefined,
32
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com",
33
+ GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null",
34
+ };
35
+ const git = (cwd, args, env = {}) => execFileSync("git", ["-C", cwd, ...args], { env: { ...ENV, ...env }, encoding: "utf8" }).trim();
36
+
37
+ const MODEL_ROLES = { slow: "anthropic/claude-opus-5", task: "anthropic/claude-sonnet-5", smol: "anthropic/claude-haiku-4-5" };
38
+ const CATALOG = {
39
+ models: [
40
+ { selector: "anthropic/claude-opus-5", thinking: ["low", "medium", "high", "xhigh", "max"] },
41
+ { selector: "anthropic/claude-sonnet-5", thinking: ["low", "medium", "high", "xhigh", "max"] },
42
+ { selector: "anthropic/claude-haiku-4-5", thinking: ["minimal", "low", "medium", "high", "xhigh"] },
43
+ ],
44
+ };
45
+ const MODEL_OF = { slow: "claude-opus-5", task: "claude-sonnet-5", smol: "claude-haiku-4-5" };
46
+
47
+ const pull = (o) => ({
48
+ run_date: DAY, session: SESSION, policy_cell: "slow-high", chosen_cell: "slow-high", exploration_draw: "",
49
+ sizing_src: "rule", sizing_pre: "", router_usd: "", brief_chars: "100", criteria: "1", comments: "0",
50
+ age_days: "0", paths: "0", test_paths: "0", xrefs: "0", kind: "enhancement", ...o,
51
+ });
52
+ const member = (o) => ({
53
+ session: SESSION, run_date: DAY, role: "implementer", member: o.agent, model: "claude-opus-5", effort: "high",
54
+ ticket: "", pr: "", tokensCacheCreate: 0, tokensOut: 0, wallS: 0, turns: 1, harness: "omp", subagentType: "",
55
+ tokensIn: 0, tokensCacheRead: 0, tokensCacheWrite1h: 0, cost: 0, ...o,
56
+ });
57
+ const tier = (o) => ({
58
+ run_date: DAY, class: "", tier: "", closed_own_ticket: "yes", minted_false_claim: "no", note: "n",
59
+ sizing: "light", profile: "small", loc: "10", files: "1", ...o,
60
+ });
61
+ const corpus = () => ({ features: [], members: [], tiers: [] });
62
+ // One ruled Pull at `cell`: its features row, its admissible member row, its verdict.
63
+ function ruled(rows, { ticket, cell = "slow-high", fail = false, date = DAY }) {
64
+ const [role, level] = cell.split("-");
65
+ const agent = `impl-${ticket}`;
66
+ rows.features.push(pull({ ticket: String(ticket), agent, chosen_cell: cell, run_date: date }));
67
+ rows.members.push(member({ agent, ticket: String(ticket), run_date: date, effort: level, model: MODEL_OF[role], subagentType: `fleet-implementer-${cell}` }));
68
+ rows.tiers.push(tier({ ticket: String(ticket), pr: String(ticket + 5000), run_date: date, minted_false_claim: fail ? "yes" : "no" }));
69
+ }
70
+
71
+ const featuresTsv = (rows, cols = FEATURE_COLUMNS) => [cols.join("\t"), ...rows.map((r) => cols.map((c) => r[c] ?? "").join("\t"))].join("\n") + "\n";
72
+ const membersTsv = (rows) => `# ${MEMBER_COLUMNS.join("\t")}\n${rows.length ? formatTsv(rows) : ""}`;
73
+ const tiersTsv = (rows) => `# ${TIER_COLUMNS.join("\t")}\n${rows.map((r) => `${formatRow(r)}\n`).join("")}`;
74
+ const routerTable = (over = {}) => ({
75
+ window_start: null, fitted_through: "2026-10-01", n_rows: 0, stage: 1, cells: [...STAGE1], burn_in: true,
76
+ rows: { "*": "slow-high" }, classifier: { b: null, tau: null }, estimates: {}, guard: { tripped: [], n: {} }, ...over,
77
+ });
78
+ const agentMd = (cell) => {
79
+ const [role, level] = cell.split("-");
80
+ return `---\nname: fleet-implementer-${cell}\ndescription: fixture\nmodel: "@${role}:${level}"\n---\n\nbody\n`;
81
+ };
82
+
83
+ // Answers `pr list` (pr-cost's merged-state read, or the open re-fit PR read),
84
+ // `issue list` (raw text instead of JSON when the fixture has `issuesRaw`),
85
+ // and the two writes, from GH_FIXTURE; logs each argv to GH_LOG.
86
+ const GH_STUB = `#!/usr/bin/env node
87
+ const fs = require("node:fs");
88
+ const args = process.argv.slice(2);
89
+ fs.appendFileSync(process.env.GH_LOG, JSON.stringify(args) + "\\n");
90
+ const fx = JSON.parse(fs.readFileSync(process.env.GH_FIXTURE, "utf8"));
91
+ const json = args[args.indexOf("--json") + 1];
92
+ const out = (v) => process.stdout.write(JSON.stringify(v));
93
+ const cmd = args.slice(0, 2).join(" ");
94
+ if (cmd === "pr list") out(json === "number,headRefName" ? fx.openPrs : fx.prs);
95
+ else if (cmd === "issue list") { if (fx.issuesRaw !== undefined) process.stdout.write(fx.issuesRaw); else out(fx.issues); }
96
+ else if (cmd === "issue create") console.log("https://github.com/o/r/issues/901");
97
+ else if (cmd === "pr create") console.log("https://github.com/o/r/pull/902");
98
+ else { process.stderr.write("gh stub: unexpected " + args.join(" ")); process.exit(9); }
99
+ `;
100
+
101
+ function write(root, rel, text) {
102
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
103
+ writeFileSync(join(root, rel), text);
104
+ }
105
+
106
+ // `readded`: cell → the date its definition is deleted and added back on main.
107
+ function fixture({ rows = corpus(), table = routerTable(), cells = STAGE1, readded = {}, pending = null, guardBefore = null, gh = {} } = {}) {
108
+ const root = tempDir("run-team-local-");
109
+ const origin = join(root, "origin.git");
110
+ const seed = join(root, "seed");
111
+ git(root, ["init", "--quiet", "--bare", "-b", "main", origin]);
112
+ git(root, ["init", "--quiet", "-b", "main", seed]);
113
+ const commit = (msg, at) => git(seed, ["commit", "--quiet", "-m", msg], { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at });
114
+ write(seed, ".gitignore", ".fleet/\n");
115
+ for (const cell of cells) write(seed, `plugin/agents/fleet-implementer-${cell}.agent.md`, agentMd(cell));
116
+ git(seed, ["add", "-A"]);
117
+ commit("cells", "2026-09-01T00:00:00Z");
118
+ for (const [cell, at] of Object.entries(readded)) {
119
+ git(seed, ["rm", "--quiet", `plugin/agents/fleet-implementer-${cell}.agent.md`]);
120
+ commit(`withdraw ${cell}`, at);
121
+ write(seed, `plugin/agents/fleet-implementer-${cell}.agent.md`, agentMd(cell));
122
+ git(seed, ["add", "-A"]);
123
+ commit(`reinstate ${cell}`, at);
124
+ }
125
+ write(seed, FEATURES, featuresTsv(rows.features));
126
+ write(seed, "docs/metrics/member-outcomes.tsv", membersTsv(rows.members));
127
+ write(seed, "docs/metrics/tier-outcomes.tsv", tiersTsv(rows.tiers));
128
+ write(seed, TABLE, `${JSON.stringify(table, null, 2)}\n`);
129
+ git(seed, ["add", "-A"]);
130
+ commit("corpus", "2026-10-03T12:00:00Z");
131
+ git(seed, ["push", "--quiet", origin, "main"]);
132
+ const repo = join(root, "repo");
133
+ git(root, ["clone", "--quiet", origin, repo]);
134
+ if (pending !== null) write(repo, PENDING, pending);
135
+ if (guardBefore !== null) write(repo, GUARD, JSON.stringify(guardBefore));
136
+ mkdirSync(join(root, "bin"));
137
+ writeExecStub(join(root, "bin", "gh"), GH_STUB);
138
+ writeFileSync(join(root, "gh.json"), JSON.stringify({ prs: [], openPrs: [], issues: [], ...gh }));
139
+ writeFileSync(join(root, "model-roles.json"), JSON.stringify(MODEL_ROLES));
140
+ writeFileSync(join(root, "catalog.json"), JSON.stringify(CATALOG));
141
+ const log = join(root, "gh.log");
142
+ return {
143
+ root, origin, repo,
144
+ run(phase, extra = [], { cwd = repo, repoFlag = true } = {}) {
145
+ const args = [HOOK, phase, ...(repoFlag ? ["--repo", repo] : []),
146
+ "--model-roles", join(root, "model-roles.json"), "--catalog", join(root, "catalog.json"), ...extra];
147
+ return spawnSync(process.execPath, args, {
148
+ cwd, encoding: "utf8",
149
+ env: { ...ENV, PATH: `${join(root, "bin")}:${process.env.PATH}`, GH_FIXTURE: join(root, "gh.json"), GH_LOG: log },
150
+ });
151
+ },
152
+ ghCalls: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map((l) => JSON.parse(l)) : []),
153
+ };
154
+ }
155
+ const calls = (f, cmd) => f.ghCalls().filter((c) => c.slice(0, 2).join(" ") === cmd);
156
+ const worktrees = (repo) => git(repo, ["worktree", "list", "--porcelain"]).split("\n").filter((l) => l.startsWith("worktree ")).length;
157
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // phase-0
161
+
162
+ test("phase-0: drains the pending TSV into ticket-features.tsv, deduped on session + agent, and writes a fresh cost-guard.json", () => {
163
+ const rows = corpus();
164
+ ruled(rows, { ticket: 1 });
165
+ const fresh = [pull({ ticket: "2", agent: "impl-2" }), pull({ ticket: "3", agent: "impl-3", chosen_cell: "task-high", exploration_draw: "2/3" })];
166
+ // Read by column name: a pending header in another order drains the same rows.
167
+ const pending = featuresTsv([rows.features[0], fresh[0], ...fresh], [...FEATURE_COLUMNS].reverse());
168
+ const f = fixture({ rows, pending, guardBefore: { computed_at: "2000-01-01T00:00:00.000Z", verdict: "ok", tripped: [], cells: [] } });
169
+ const started = Date.now();
170
+ const r = f.run("phase-0");
171
+ assert.equal(r.status, 0, r.stderr);
172
+ assert.equal(readFileSync(join(f.repo, FEATURES), "utf8"), featuresTsv([rows.features[0], ...fresh]));
173
+ assert.equal(existsSync(join(f.repo, PENDING)), false, "the drained pending file is gone");
174
+ assert.match(r.stdout, /drain: 2 rows from \.fleet\/ticket-features\.pending\.tsv into docs\/metrics\/ticket-features\.tsv \(2 already there\)/);
175
+ const guard = JSON.parse(readFileSync(join(f.repo, GUARD), "utf8"));
176
+ assert.ok(Date.parse(guard.computed_at) >= started - 1000, `a fresh guard, not the stale one: ${guard.computed_at}`);
177
+ assert.equal(guard.verdict, "none");
178
+ assert.match(r.stdout, /cost guard: verdict none, pr-cost\.mjs exit 4/, "no verdict yet is a verdict, not a failure");
179
+ assert.match(r.stdout, /drift notice: every cell's last admissible row ran its role's current target/);
180
+ assert.deepEqual(f.ghCalls().map((c) => c.slice(0, 2).join(" ")), ["pr list"], "phase 0 writes nothing to GitHub");
181
+ });
182
+
183
+ test("phase-0: with no --repo it works on the main checkout of the cwd's repository; a second run finds nothing pending and changes nothing", () => {
184
+ const rows = corpus();
185
+ ruled(rows, { ticket: 1 });
186
+ const fresh = pull({ ticket: "2", agent: "impl-2" });
187
+ const f = fixture({ rows, pending: featuresTsv([fresh]) });
188
+ const wt = join(f.root, "wt");
189
+ git(f.repo, ["worktree", "add", "--quiet", wt]);
190
+ const r = f.run("phase-0", [], { cwd: wt, repoFlag: false });
191
+ assert.equal(r.status, 0, r.stderr);
192
+ const drained = readFileSync(join(f.repo, FEATURES), "utf8");
193
+ assert.equal(drained, featuresTsv([rows.features[0], fresh]));
194
+ assert.equal(existsSync(join(f.repo, GUARD)), true);
195
+ assert.equal(existsSync(join(wt, ".fleet")), false, "nothing is written under the linked worktree");
196
+
197
+ const again = f.run("phase-0", [], { cwd: wt, repoFlag: false });
198
+ assert.equal(again.status, 0, again.stderr);
199
+ assert.match(again.stdout, /drain: nothing pending in \.fleet\/ticket-features\.pending\.tsv/);
200
+ assert.equal(readFileSync(join(f.repo, FEATURES), "utf8"), drained);
201
+ });
202
+
203
+ test("phase-0: a tripped guard is a verdict and the hook goes on; a guard that cannot be computed fails the hook there", () => {
204
+ const rows = corpus();
205
+ const prs = [];
206
+ for (let i = 0; i < MIN_N; i++) {
207
+ ruled(rows, { ticket: 1000 + i, cell: "slow-high", fail: i < 6 });
208
+ ruled(rows, { ticket: 2000 + i, cell: "task-high", fail: i < 9 });
209
+ prs.push({ number: 6000 + i, state: "MERGED", mergedAt: `${DAY}T12:00:00Z` }, { number: 7000 + i, state: "MERGED", mergedAt: `${DAY}T12:00:00Z` });
210
+ }
211
+ const tripped = fixture({ rows, gh: { prs } });
212
+ const t = tripped.run("phase-0");
213
+ assert.equal(t.status, 0, t.stderr);
214
+ assert.match(t.stdout, /cost guard: verdict tripped \(tripped: task-high\), pr-cost\.mjs exit 3/);
215
+ assert.match(t.stdout, /drift notice:/, "the duty after the guard ran");
216
+
217
+ const broken = fixture({ rows: corpus() });
218
+ writeFileSync(join(broken.repo, "docs", "metrics", "member-outcomes.tsv"), "short\trow\n");
219
+ const b = broken.run("phase-0");
220
+ assert.equal(b.status, 1);
221
+ assert.match(b.stderr, /run-team-local: cost guard failed: pr-cost\.mjs --guard exited 2/);
222
+ assert.doesNotMatch(b.stdout, /drift notice/, "the duties after a failed one do not run");
223
+ });
224
+
225
+ test("phase-0: the drift notice names a cell whose last admissible row ran another model than its role now resolves, and a cell whose level that target does not run", () => {
226
+ const rows = corpus();
227
+ rows.members.push(
228
+ member({ agent: "impl-1", run_date: "2026-10-01", session: "s1", model: "claude-sonnet-5", effort: "high", subagentType: "fleet-implementer-task-high" }),
229
+ member({ agent: "impl-2", run_date: "2026-10-02", session: "s2", model: "claude-sonnet-4-5", effort: "high", subagentType: "fleet-implementer-task-high" }),
230
+ // Newer, but clamped to another effort: never admissible, so never the last admissible row.
231
+ member({ agent: "impl-3", run_date: "2026-10-05", session: "s3", model: "claude-sonnet-5", effort: "medium", subagentType: "fleet-implementer-task-high" }),
232
+ // The role's target spelled with its provider prefix is the same model.
233
+ member({ agent: "impl-4", run_date: "2026-10-02", session: "s4", model: "claude-opus-5", effort: "high", subagentType: "fleet-implementer-slow-high" }),
234
+ member({ agent: "impl-5", run_date: "2026-10-01", session: "s5", model: "claude-haiku-4-5", effort: "high", subagentType: "fleet-implementer-smol-high" }),
235
+ member({ agent: "impl-6", run_date: "2026-10-09", session: "s6", model: "claude-haiku-3", effort: "high", subagentType: "task" }),
236
+ );
237
+ const f = fixture({ rows, cells: [...STAGE1, "smol-max"] });
238
+ const r = f.run("phase-0");
239
+ assert.equal(r.status, 0, r.stderr);
240
+ assert.deepEqual(r.stdout.split("\n").filter((l) => l.startsWith("tier-roles: notice: ")), [
241
+ "tier-roles: notice: fleet-implementer-smol-max runs at max; modelRoles.smol now resolves anthropic/claude-haiku-4-5, which does not run at max (thinking: minimal, low, medium, high, xhigh)",
242
+ "tier-roles: notice: fleet-implementer-task-high last ran claude-sonnet-4-5; modelRoles.task now resolves anthropic/claude-sonnet-5 — cell history spans two models",
243
+ ]);
244
+ });
245
+
246
+ test("phase-0: a role with no target in modelRoles raises no notice for its cells, and the cells whose role resolves are still checked", () => {
247
+ const rows = corpus();
248
+ rows.members.push(
249
+ member({ agent: "impl-1", run_date: "2026-10-01", session: "s1", model: "claude-haiku-3", effort: "high", subagentType: "fleet-implementer-smol-high" }),
250
+ member({ agent: "impl-2", run_date: "2026-10-01", session: "s2", model: "claude-sonnet-4-5", effort: "high", subagentType: "fleet-implementer-task-high" }),
251
+ );
252
+ const f = fixture({ rows });
253
+ const { smol, ...withoutSmol } = MODEL_ROLES;
254
+ writeFileSync(join(f.root, "model-roles.json"), JSON.stringify(withoutSmol));
255
+ const r = f.run("phase-0");
256
+ assert.equal(r.status, 0, r.stderr);
257
+ assert.deepEqual(r.stdout.split("\n").filter((l) => l.startsWith("tier-roles: notice: ")), [
258
+ "tier-roles: notice: fleet-implementer-task-high last ran claude-sonnet-4-5; modelRoles.task now resolves anthropic/claude-sonnet-5 — cell history spans two models",
259
+ ]);
260
+ });
261
+
262
+ test("phase-0: of the rows dated the same day the drift notice follows the higher session, in whichever order the rows come", () => {
263
+ const rows = corpus();
264
+ const sameDay = (agent, session, model, cell) => member({ agent, run_date: "2026-10-02", session, model, effort: "high", subagentType: `fleet-implementer-${cell}` });
265
+ rows.members.push(
266
+ // The lower session ran another model and comes first.
267
+ sameDay("impl-1", "s1", "claude-haiku-3", "smol-high"),
268
+ sameDay("impl-2", "s2", "claude-haiku-4-5", "smol-high"),
269
+ // The higher session comes first.
270
+ sameDay("impl-3", "s4", "claude-sonnet-5", "task-high"),
271
+ sameDay("impl-4", "s3", "claude-sonnet-4-5", "task-high"),
272
+ );
273
+ const r = fixture({ rows }).run("phase-0");
274
+ assert.equal(r.status, 0, r.stderr);
275
+ assert.doesNotMatch(r.stdout, /tier-roles: notice:/);
276
+ assert.match(r.stdout, /drift notice: every cell's last admissible row ran its role's current target/);
277
+ });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // close-out: the stopping rule
281
+
282
+ // smol-high at ten verdicts, eight failing the floor; task-high at three, all failing.
283
+ function floorFailing() {
284
+ const rows = corpus();
285
+ for (let i = 0; i < STOP.verdicts; i++) ruled(rows, { ticket: 100 + i, cell: "smol-high", fail: i < 8 });
286
+ for (let i = 0; i < 3; i++) ruled(rows, { ticket: 200 + i, cell: "task-high", fail: true });
287
+ return rows;
288
+ }
289
+ const TITLE = "Withdraw exploration cell smol-high: 8/10 floor failures";
290
+
291
+ test("close-out --dry-run: a cell at ten verdicts and eight floor failures would file its withdrawal issue; a cell under the rule continues", () => {
292
+ const f = fixture({ rows: floorFailing() });
293
+ const r = f.run("close-out", ["--dry-run"]);
294
+ assert.equal(r.status, 0, r.stderr);
295
+ assert.match(r.stdout, new RegExp(`stopping rule: smol-high 8/10 floor failures since 2026-09-01 — would file "${escape(TITLE)}" \\(ready-for-human\\)`));
296
+ assert.match(r.stdout, /stopping rule: task-high 3\/3 floor failures since 2026-09-01 — continues/);
297
+ const lists = calls(f, "issue list");
298
+ assert.equal(lists.length, 1, "only the stopping cell is looked up");
299
+ assert.deepEqual(lists[0].slice(2, 6), ["--state", "open", "--search", `"${TITLE}" in:title`]);
300
+ assert.deepEqual(calls(f, "issue create"), [], "a dry run files nothing");
301
+ });
302
+
303
+ test("close-out: an open issue with the same title dedupes the withdrawal; an open one with other counts does not, and the issue is filed", () => {
304
+ const decoy = { number: 78, title: "Withdraw exploration cell smol-high: 7/9 floor failures" };
305
+ const open = { number: 77, title: TITLE };
306
+ const dup = fixture({ rows: floorFailing(), gh: { issues: [decoy, open] } });
307
+ const d = dup.run("close-out");
308
+ assert.equal(d.status, 0, d.stderr);
309
+ assert.match(d.stdout, new RegExp(`stopping rule: smol-high 8/10 floor failures since 2026-09-01 — withdrawal issue already open, #${open.number}$`, "m"));
310
+ assert.deepEqual(calls(dup, "issue create"), []);
311
+
312
+ const fresh = fixture({ rows: floorFailing(), gh: { issues: [decoy] } });
313
+ const r = fresh.run("close-out");
314
+ assert.equal(r.status, 0, r.stderr);
315
+ assert.match(r.stdout, /stopping rule: smol-high 8\/10 floor failures since 2026-09-01 — filed https:\/\/github\.com\/o\/r\/issues\/901/);
316
+ const [create] = calls(fresh, "issue create");
317
+ const flag = (name) => create[create.indexOf(name) + 1];
318
+ assert.equal(flag("--title"), TITLE);
319
+ assert.equal(flag("--label"), "ready-for-human");
320
+ // One verdict-table row per ticket: ticket and PR as references, the ruling's date and both floor fields.
321
+ const row = (ticket, minted, floor) => `| #${ticket} | #${ticket + 5000} | 2026-10-03 | yes | ${minted} | ${floor} |`;
322
+ const body = flag("--body").split("\n");
323
+ assert.ok(body.includes(row(100, "yes", "fail")), flag("--body"));
324
+ assert.ok(body.includes(row(109, "no", "pass")), flag("--body"));
325
+ const tickets = body.filter((l) => l.startsWith("| #")).map((l) => Number(l.match(/^\| #(\d+) /)[1]));
326
+ assert.deepEqual(tickets, [100, 101, 102, 103, 104, 105, 106, 107, 108, 109], "rows run in ascending ticket order");
327
+ });
328
+
329
+ test("close-out: the count restarts on the day a cell's definition was most recently added", () => {
330
+ const f = fixture({ rows: floorFailing(), readded: { "smol-high": "2026-10-04T00:00:00Z" } });
331
+ const r = f.run("close-out", ["--dry-run"]);
332
+ assert.equal(r.status, 0, r.stderr);
333
+ assert.match(r.stdout, /stopping rule: smol-high 0\/0 floor failures since 2026-10-04 — continues/);
334
+ assert.deepEqual(calls(f, "issue list"), []);
335
+ });
336
+
337
+ test("close-out: a definition no commit adds fails the stopping rule naming it, after the cells that can be judged are reported, and the re-fit does not run", () => {
338
+ const f = fixture({ rows: floorFailing() });
339
+ // On disk beside the committed definitions, never committed.
340
+ writeFileSync(join(f.repo, "plugin", "agents", "fleet-implementer-smol-max.agent.md"), agentMd("smol-max"));
341
+ const r = f.run("close-out", ["--dry-run"]);
342
+ assert.equal(r.status, 1, r.stdout);
343
+ assert.match(r.stdout, new RegExp(`stopping rule: smol-high 8/10 floor failures since 2026-09-01 — would file "${escape(TITLE)}"`));
344
+ assert.match(r.stderr, /run-team-local: stopping rule failed: not judged — no commit adds fleet-implementer-smol-max\.agent\.md$/m);
345
+ assert.doesNotMatch(r.stdout, /router fit/);
346
+ });
347
+
348
+ test("close-out: a gh that prints something other than JSON fails the duty naming the gh command and what it printed", () => {
349
+ const f = fixture({ rows: floorFailing(), gh: { issuesRaw: "<html>rate limited</html>" } });
350
+ const r = f.run("close-out", ["--dry-run"]);
351
+ assert.equal(r.status, 1, r.stdout);
352
+ assert.match(r.stderr, /stopping rule failed: gh issue list printed non-JSON \(.*\): "<html>rate limited<\/html>"$/m);
353
+ });
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // close-out: the router re-fit cadence
357
+
358
+ // FIT_EVERY_MERGED Pulls on main dated after the table's fitted_through, `n` of them ruled.
359
+ function merged(n) {
360
+ const rows = corpus();
361
+ for (let i = 1; i <= FIT_EVERY_MERGED; i++) {
362
+ rows.features.push(pull({ ticket: String(i), agent: `impl-${i}`, run_date: "2026-10-05" }));
363
+ if (i <= n) rows.tiers.push(tier({ ticket: String(i), pr: String(500 + i), run_date: "2026-10-05" }));
364
+ }
365
+ return rows;
366
+ }
367
+
368
+ test("close-out --dry-run: fifty merged PRs since fitted_through run ticket-router.mjs fit in a throwaway checkout; forty-nine do not", () => {
369
+ const below = fixture({ rows: merged(FIT_EVERY_MERGED - 1) });
370
+ const b = below.run("close-out", ["--dry-run"]);
371
+ assert.equal(b.status, 0, b.stderr);
372
+ assert.match(b.stdout, /router fit: DUE=no MERGED=49\/50 — not due/);
373
+ assert.doesNotMatch(b.stdout, /ticket-router: fitted/);
374
+
375
+ const at = fixture({ rows: merged(FIT_EVERY_MERGED) });
376
+ const before = readFileSync(join(at.repo, TABLE), "utf8");
377
+ const a = at.run("close-out", ["--dry-run"]);
378
+ assert.equal(a.status, 0, a.stderr);
379
+ assert.match(a.stdout, /^run-team-local: router fit: DUE=yes MERGED=50\/50 — ticket-router: fitted \d+ tickets through 2026-10-05; .*; would open a chore PR from chore\/router-fit-\d{4}-\d{2}-\d{2}$/m);
380
+ assert.equal(readFileSync(join(at.repo, TABLE), "utf8"), before, "the checkout's own table is untouched");
381
+ assert.equal(worktrees(at.repo), 1, "the throwaway checkout is gone");
382
+ assert.equal(git(at.origin, ["branch", "--list", "chore/*"]), "", "a dry run pushes nothing");
383
+ assert.deepEqual(calls(at, "pr create"), []);
384
+ });
385
+
386
+ test("close-out: a relative --repo reaches the re-fit as the absolute one does", () => {
387
+ const f = fixture({ rows: merged(FIT_EVERY_MERGED) });
388
+ const r = f.run("close-out", ["--repo", ".", "--dry-run"], { cwd: f.repo, repoFlag: false });
389
+ assert.equal(r.status, 0, r.stderr);
390
+ assert.match(r.stdout, /router fit: DUE=yes MERGED=50\/50 — .*; would open a chore PR from chore\/router-fit-/);
391
+ });
392
+
393
+ test("close-out: a due re-fit is pushed to its own chore branch and opened as a PR labelled patch, never onto main — and not while an earlier re-fit PR is open", () => {
394
+ const f = fixture({ rows: merged(FIT_EVERY_MERGED) });
395
+ const r = f.run("close-out");
396
+ assert.equal(r.status, 0, r.stderr);
397
+ const branch = /opened https:\/\/github\.com\/o\/r\/pull\/902 from (chore\/router-fit-\S+)$/m.exec(r.stdout)?.[1];
398
+ assert.ok(branch, r.stdout);
399
+ assert.equal(JSON.parse(git(f.origin, ["show", `${branch}:${TABLE}`])).fitted_through, "2026-10-05");
400
+ assert.equal(JSON.parse(git(f.origin, ["show", `main:${TABLE}`])).fitted_through, "2026-10-01", "main itself is untouched");
401
+ const [create] = calls(f, "pr create");
402
+ assert.equal(create[create.indexOf("--base") + 1], "main");
403
+ assert.equal(create[create.indexOf("--head") + 1], branch);
404
+ assert.equal(create[create.indexOf("--label") + 1], "patch", "labelled by the create itself, not by an edit that can fail after it");
405
+ assert.deepEqual(calls(f, "pr edit"), []);
406
+ assert.equal(git(f.repo, ["branch", "--list", "chore/*"]), "", "the checkout's repository keeps no local branch of the PR's");
407
+ assert.doesNotMatch(git(f.repo, ["config", "--local", "--list"]), /^branch\.chore/m, "and no upstream config for it");
408
+ assert.equal(worktrees(f.repo), 1);
409
+
410
+ const earlier = { number: 55, headRefName: "chore/router-fit-2026-10-01" };
411
+ const held = fixture({ rows: merged(FIT_EVERY_MERGED), gh: { openPrs: [earlier] } });
412
+ const h = held.run("close-out");
413
+ assert.equal(h.status, 0, h.stderr);
414
+ assert.match(h.stdout, new RegExp(`router fit: DUE=yes MERGED=50/50 — re-fit PR #${earlier.number} \\(${escape(earlier.headRefName)}\\) is still open$`, "m"));
415
+ assert.deepEqual(calls(held, "pr create"), []);
416
+ assert.equal(git(held.origin, ["branch", "--list", "chore/*"]), "");
417
+ });
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // usage
421
+
422
+ test("usage: exactly one phase, and only the declared flags, else exit 2", () => {
423
+ const f = fixture();
424
+ for (const extra of [[], ["phase-0", "close-out"], ["phase-0", "--bogus"]]) {
425
+ const r = spawnSync(process.execPath, [HOOK, ...extra, "--repo", f.repo], { encoding: "utf8", env: ENV });
426
+ assert.equal(r.status, 2, `${extra.join(" ")}: ${r.stderr}`);
427
+ }
428
+ // A flag's value that spells a phase is not the phase.
429
+ const value = spawnSync(process.execPath, [HOOK, "--repo", "phase-0", "--dry-run"], { encoding: "utf8", env: ENV, cwd: f.repo });
430
+ assert.equal(value.status, 2, value.stderr);
431
+ assert.match(value.stderr, /usage: hook\.mjs phase-0\|close-out/);
432
+ });