@feigi/fleet-ctl 3.22.9 → 3.23.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
CHANGED
package/scripts/cell-readout.mjs
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|