@feigi/fleet-ctl 3.98.3 → 3.99.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.98.3",
3
+ "version": "3.99.0",
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": {
@@ -14,6 +14,8 @@
14
14
  // `docs/metrics/ticket-features.tsv`, deduped on `session` + `agent`, and
15
15
  // deletes the pending file. A crash between the write and the delete leaves
16
16
  // rows the next drain finds already there, so a second run changes nothing.
17
+ // A corpus written before a column the pending file carries — one the row
18
+ // grammar names — gains that column, blank on every row it already holds.
17
19
  // A pending file that is empty, or blank lines only, is removed with its own
18
20
  // line: an append created it and never wrote.
19
21
  // The drain takes no lock by design: it assumes only the run's controller
@@ -59,6 +61,17 @@ const say = (line) => process.stdout.write(`${NAME}: ${line}\n`);
59
61
  const headerOf = (text) => String(text).split("\n").find((l) => l.trim() && !l.startsWith("#")).split("\t");
60
62
  const pullKey = (r) => `${r.session}\0${r.agent}`;
61
63
 
64
+ // `text`, a features TSV, with `columns` appended: named on its header line, blank on each row.
65
+ function widen(text, columns) {
66
+ let header = true;
67
+ return text.split("\n").map((l) => {
68
+ if (!l.trim() || l.startsWith("#")) return l;
69
+ const added = header ? columns : columns.map(() => "");
70
+ header = false;
71
+ return [l, ...added].join("\t");
72
+ }).join("\n");
73
+ }
74
+
62
75
  function drain(repo) {
63
76
  const pending = join(repo, PENDING);
64
77
  if (!existsSync(pending)) return say(`drain: nothing pending in ${PENDING}`);
@@ -76,19 +89,22 @@ function drain(repo) {
76
89
  const target = join(repo, FEATURES);
77
90
  let text = existsSync(target) ? readFileSync(target, "utf8") : `${FEATURE_COLUMNS.join("\t")}\n`;
78
91
  const seen = new Set(parseFeatures(text).map(pullKey));
79
- const header = headerOf(text);
80
- const unknown = headerOf(pendingText).filter((c) => !header.includes(c));
92
+ const had = headerOf(text);
93
+ const newer = headerOf(pendingText).filter((c) => !had.includes(c));
94
+ const unknown = newer.filter((c) => !FEATURE_COLUMNS.includes(c));
81
95
  if (unknown.length) throw new Error(`${PENDING} carries ${unknown.join(", ")}, which ${FEATURES} has no column for`);
96
+ if (newer.length) text = widen(text, newer);
97
+ const header = [...had, ...newer];
82
98
  const added = [];
83
99
  for (const r of incoming) {
84
100
  if (seen.has(pullKey(r))) continue;
85
101
  seen.add(pullKey(r));
86
102
  added.push(header.map((c) => r[c] ?? "").join("\t"));
87
103
  }
88
- if (added.length) {
104
+ if (added.length || newer.length) {
89
105
  if (!text.endsWith("\n")) text += "\n";
90
106
  mkdirSync(dirname(target), { recursive: true });
91
- writeFileSync(`${target}.tmp`, `${text}${added.join("\n")}\n`);
107
+ writeFileSync(`${target}.tmp`, `${text}${added.map((l) => `${l}\n`).join("")}`);
92
108
  renameSync(`${target}.tmp`, target);
93
109
  }
94
110
  unlinkSync(pending);
@@ -1,7 +1,8 @@
1
1
  // The phase-0 receipt, read. `.fleet/phase0.json` is written by phase0.mjs
2
2
  // after every step (its header gives the shape); this module is its readers'
3
3
  // half: the tick's dispatch gate, the session directory tier-check.mjs
4
- // and ticket-router.mjs take when their own flag is omitted, and the In-flight
4
+ // and ticket-router.mjs take when their own flag is omitted, the Objective
5
+ // ticket-router.mjs takes likewise, with its default, and the In-flight
5
6
  // bound the drivers hand the tick, with its default. One reading of the file,
6
7
  // so the gate and the session readers cannot disagree about what a receipt is.
7
8
  //
@@ -145,6 +146,25 @@ export function sessionFromReceipt(opts) {
145
146
  throw new Error(`no session given and none in the phase-0 receipt ${path} (${why}) — give one, or run ${phase0Command(null)} first`);
146
147
  }
147
148
 
149
+ // What a run asks the router to minimise, and the one it asks when nobody
150
+ // names one. phase0.mjs records the run's in its receipt; a receipt written
151
+ // before it recorded one is read as the default when phase 0 resumes it.
152
+ export const OBJECTIVES = Object.freeze(["cost", "speed"]);
153
+ export const OBJECTIVE_DEFAULT = "cost";
154
+
155
+ /**
156
+ * The Objective the receipt records, for a reader whose own flag was omitted.
157
+ * Throws an Error naming the receipt when it has none to give.
158
+ */
159
+ export function objectiveFromReceipt(opts) {
160
+ const { path, read } = readRunReceipt(opts);
161
+ if (read.state === "ok" && OBJECTIVES.includes(read.receipt.objective)) return read.receipt.objective;
162
+ const why = read.state === "missing" ? "no receipt"
163
+ : read.state === "unreadable" ? `unreadable: ${read.why}`
164
+ : "it records no objective";
165
+ throw new Error(`no objective given and none in the phase-0 receipt ${path} (${why}) — give one (${OBJECTIVES.join(" or ")}), or run ${phase0Command(null)} first`);
166
+ }
167
+
148
168
  // The In-flight bound a run takes when nobody gives one: the most tickets
149
169
  // between Claim and merge at once. Its one definition — phase0.mjs and the
150
170
  // tick's own flag default both read it from here.
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // Phase 0 as one command: the run's start sequence, in order, with a receipt.
3
3
  //
4
- // phase0.mjs [--session <dir>] [--in-flight n] [--plugin-checkout <dir>]
5
- // [--spend-dir <dir>]
4
+ // phase0.mjs [--session <dir>] [--in-flight n] [--objective cost|speed]
5
+ // [--plugin-checkout <dir>] [--spend-dir <dir>]
6
6
  // [--workflow <name> | --declare-no-ci]
7
7
  //
8
8
  // The session directory is `--session`, else `PI_ARTIFACTS_DIR`, which omp
@@ -74,7 +74,7 @@
74
74
  // THE RECEIPT is `.fleet/phase0.json` (resolved through fleet-dir.mjs, so a
75
75
  // worktree and the main checkout name one file):
76
76
  //
77
- // { run, session, scratch, inFlight,
77
+ // { run, session, scratch, inFlight, objective,
78
78
  // steps: { <name>: { status, at, output } } }
79
79
  //
80
80
  // `status` is ok, pending, failed, drift or unanswered; `at` an ISO time;
@@ -85,6 +85,12 @@
85
85
  // run. `session` is the session directory, from `--session` or
86
86
  // `PI_ARTIFACTS_DIR`. `scratch` is what `scratch-root.mjs` prints. `inFlight`
87
87
  // is the In-flight bound this run used — `--in-flight`, or its default.
88
+ // `objective` is the Objective the router routes this run's Pulls by:
89
+ // `--objective`, or its default (OBJECTIVE_DEFAULT in phase0-receipt.mjs).
90
+ // It is fixed for the run: a resumed receipt keeps the one it records — the
91
+ // default when it records none — and a resume whose `--objective` names the
92
+ // other one exits 2 naming both, leaving the receipt as it was. A resumed
93
+ // receipt recording a value that is not an Objective exits 3 naming it.
88
94
  //
89
95
  // RESUMABLE. A re-run reads the receipt. It continues it — skipping every `ok`
90
96
  // step but `alarm` and re-running the rest — only when its `run` equals the
@@ -148,7 +154,7 @@ import { gitEnv } from "./git-env.mjs";
148
154
  import { isCLI } from "./is-cli.mjs";
149
155
  import { RECEIPT, RECEIPT_HELD, archiveStamp, receiptArchiveName, rowNums } from "./ledger-grammar.mjs";
150
156
  import { ancestry, controllerOf, processSource, ProcUnreadable } from "./proc.mjs";
151
- import { IN_FLIGHT_DEFAULT } from "./phase0-receipt.mjs";
157
+ import { IN_FLIGHT_DEFAULT, OBJECTIVES, OBJECTIVE_DEFAULT } from "./phase0-receipt.mjs";
152
158
 
153
159
  const NAME = "phase0";
154
160
  const SCRIPTS = dirname(fileURLToPath(import.meta.url));
@@ -525,7 +531,7 @@ function main() {
525
531
  const die = makeDie(NAME);
526
532
  const F = defineFlags(die, {
527
533
  flags: {
528
- session: "value", "in-flight": "value", "plugin-checkout": "value",
534
+ session: "value", "in-flight": "value", objective: "value", "plugin-checkout": "value",
529
535
  "spend-dir": "value", workflow: "value", "declare-no-ci": "bool",
530
536
  },
531
537
  });
@@ -563,6 +569,8 @@ function main() {
563
569
  return n;
564
570
  };
565
571
  const inFlight = positive("in-flight") ?? IN_FLIGHT_DEFAULT;
572
+ const named = F.arg("objective");
573
+ if (named !== null && !OBJECTIVES.includes(named)) die(`--objective needs ${OBJECTIVES.join(" or ")}, got ${named}`);
566
574
  if (F.has("declare-no-ci") && !absent("workflow")) die("--workflow and --declare-no-ci are alternatives, not a pair");
567
575
  const ci = F.has("declare-no-ci") ? "CI: declared absent (--declare-no-ci)" : absent("workflow") ? null : `CI: workflow ${F.arg("workflow")} (--workflow)`;
568
576
  const pathOf = (name) => (absent(name) ? null : resolve(F.arg(name)));
@@ -617,6 +625,19 @@ function main() {
617
625
  // the `at` it names and the session is the same.
618
626
  const rotated = previous !== null && (previous.run !== null || previous.steps.rotate?.status === "ok");
619
627
  const resumed = rotated && previous.run === (record?.at ?? null) && previous.session === session;
628
+ // The Objective is fixed for the run: a resumed receipt's stands, and one
629
+ // written before receipts recorded it resumes as the default. A receipt
630
+ // recording anything else is refused, never read as an Objective.
631
+ if (resumed && previous.objective !== undefined && !OBJECTIVES.includes(previous.objective)) {
632
+ process.stderr.write(`${NAME}: this run's receipt ${receiptPath} records the objective ${JSON.stringify(previous.objective)}, which is not ${OBJECTIVES.join(" or ")} — set it to the objective this run began with and re-run\n`);
633
+ process.exitCode = 3;
634
+ return;
635
+ }
636
+ const kept = resumed ? previous.objective ?? OBJECTIVE_DEFAULT : null;
637
+ if (kept !== null && named !== null && named !== kept) {
638
+ die(`--objective ${named}: this run's receipt ${receiptPath} records the objective ${kept}, fixed for the run — run phase 0 again without --objective, or with --objective ${kept}`);
639
+ }
640
+ const objective = kept ?? named ?? OBJECTIVE_DEFAULT;
620
641
  if (rotated && !resumed) {
621
642
  if (existsSync(held)) {
622
643
  process.stderr.write(`${NAME}: cannot hold the previous run's receipt for the rotation: ${held} already holds another\n`);
@@ -626,7 +647,7 @@ function main() {
626
647
  renameSync(receiptPath, held);
627
648
  }
628
649
 
629
- const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, steps: {} };
650
+ const receipt = { run: resumed ? previous.run : null, session, scratch, inFlight, objective, steps: {} };
630
651
  for (const name of STEP_NAMES) {
631
652
  // `alarm` checks the omp running this invocation, so its `ok` is never carried.
632
653
  const was = resumed && name !== "alarm" ? previous.steps[name] : undefined;
@@ -102,7 +102,7 @@ export function trips(cell, base) {
102
102
  export function computeReport({ members, tiers, features, prs, routerTable = null, pricing = null, windowStart = WINDOW_START }) {
103
103
  const units = attribute({ features, members, tiers, prs, windowStart });
104
104
  const tallies = new Map(tally(units).map((t) => [t.cell, t]));
105
- const bootUnits = (g) => g.pulls.map(bootUnit);
105
+ const bootUnits = (g) => g.pulls.map((u) => bootUnit(u));
106
106
  const meanUsd = (t) => (t.merged ? t.spend / t.merged : null);
107
107
  const base = tallies.get(BASELINE_CELL) ?? null;
108
108
  // One generation's figures. The interval is the newest generation's against
@@ -143,8 +143,9 @@ export function computeReport({ members, tiers, features, prs, routerTable = nul
143
143
  }
144
144
  }
145
145
  const verdict = baseline.n < MIN_N ? "none" : tripped.length ? "tripped" : "ok";
146
- // Retire: every non-default stage-1 cell tripped before any stratum adopted one.
147
- const adopted = Object.values(routerTable?.rows ?? {}).some((c) => c !== BASELINE_CELL);
146
+ // Retire: every non-default stage-1 cell tripped before any row of either
147
+ // objective's set adopted one.
148
+ const adopted = Object.values(routerTable?.rows ?? {}).some((set) => Object.values(set).some((c) => c !== BASELINE_CELL));
148
149
  const retire = !adopted && STAGE_1.filter((c) => c !== BASELINE_CELL).every((c) => tripped.includes(c));
149
150
 
150
151
  return {
@@ -78,12 +78,14 @@ import { parseMemberName } from "./member-record.mjs";
78
78
  import { isImplementer } from "./member-outcomes.mjs";
79
79
  import { DATE, rulingFor, rulingsByTicket } from "./tier-outcomes.mjs";
80
80
 
81
- // The ticket-features row, one per Pull, in this order.
81
+ // The ticket-features row, one per Pull, in this order. `objective` came last
82
+ // and is optional on read: a file whose header lacks it reads it blank.
82
83
  export const FEATURE_COLUMNS = Object.freeze([
83
84
  "run_date", "session", "agent", "ticket", "policy_cell", "chosen_cell", "exploration_draw",
84
85
  "sizing_src", "sizing_pre", "router_usd", "brief_chars", "criteria", "comments", "age_days",
85
- "paths", "test_paths", "xrefs", "kind",
86
+ "paths", "test_paths", "xrefs", "kind", "objective",
86
87
  ]);
88
+ const OPTIONAL_COLUMNS = new Set(["objective"]);
87
89
 
88
90
  // The quality floor's margin, in points of fail rate.
89
91
  export const TRIP_POINTS = 15;
@@ -108,10 +110,12 @@ function mulberry32(seed) {
108
110
  };
109
111
  }
110
112
 
111
- // The bootstrap 95% range of (cell − base) $ per merged PR, its ends rounded
112
- // to cents. Each unit is one counted Pull: `{ usd, merged }`, merged 0 or 1.
113
- // $ per merged PR is Σusd / Σmerged. null when either side has no unit, or
114
- // when under half the resamples have a merged PR on both sides.
113
+ // The bootstrap 95% range of (cell − base) per merged PR, its ends rounded to
114
+ // two places. Each unit is one counted Pull, `bootUnit`'s shape:
115
+ // `{ value, merged }`, `value` the measure `bootUnit` was asked for and
116
+ // merged 0 or 1. The figure per merged PR is Σvalue / Σmerged. null when
117
+ // either side has no unit, or when under half the resamples have a merged PR
118
+ // on both sides.
115
119
  export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed = 1 } = {}) {
116
120
  if (!cellUnits.length || !baseUnits.length) return null;
117
121
  const rand = mulberry32(seed);
@@ -119,7 +123,7 @@ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed
119
123
  let u = 0, m = 0;
120
124
  for (let i = 0; i < units.length; i++) {
121
125
  const x = units[Math.floor(rand() * units.length)];
122
- u += x.usd; m += x.merged;
126
+ u += x.value; m += x.merged;
123
127
  }
124
128
  return m ? u / m : null;
125
129
  };
@@ -133,8 +137,11 @@ export function bootstrapDiffCI(cellUnits, baseUnits, { iterations = 2000, seed
133
137
  return [round2(diffs[Math.floor(0.025 * (diffs.length - 1))]), round2(diffs[Math.ceil(0.975 * (diffs.length - 1))])];
134
138
  }
135
139
 
136
- // A counted Pull as a bootstrap unit.
137
- export const bootUnit = (u) => ({ usd: u.spend, merged: u.merged ? 1 : 0 });
140
+ // What a bootstrap unit measures: `usd`, its spend, or `minutes`, its work time.
141
+ const METRIC = Object.freeze({ usd: (u) => u.spend, minutes: (u) => u.workS / 60 });
142
+
143
+ // A counted Pull as a bootstrap unit, its `value` the Pull's `metric`.
144
+ export const bootUnit = (u, metric = "usd") => ({ value: METRIC[metric](u), merged: u.merged ? 1 : 0 });
138
145
 
139
146
  // ticket-features.tsv: one bare header line naming the columns, then rows.
140
147
  // Read by header name; a row whose width differs from the header's is refused.
@@ -142,7 +149,7 @@ export function parseFeatures(text) {
142
149
  const lines = String(text ?? "").split("\n").filter((l) => l.trim() && !l.startsWith("#"));
143
150
  if (lines.length === 0) throw new Error("ticket-features.tsv has no header line");
144
151
  const header = lines[0].split("\t");
145
- const missing = FEATURE_COLUMNS.filter((c) => !header.includes(c));
152
+ const missing = FEATURE_COLUMNS.filter((c) => !header.includes(c) && !OPTIONAL_COLUMNS.has(c));
146
153
  if (missing.length) throw new Error(`ticket-features.tsv header lacks ${missing.join(", ")}`);
147
154
  return lines.slice(1).map((l, i) => {
148
155
  const cells = l.split("\t");
@@ -150,6 +157,7 @@ export function parseFeatures(text) {
150
157
  throw new Error(`ticket-features.tsv row ${i + 1}: ${cells.length} fields, expected ${header.length} — ${l.slice(0, 60)}`);
151
158
  }
152
159
  const row = Object.fromEntries(header.map((c, j) => [c, cells[j]]));
160
+ for (const c of OPTIONAL_COLUMNS) row[c] ??= "";
153
161
  if (!isDigits(row.ticket)) throw new Error(`ticket-features.tsv row ${i + 1}: ticket ${JSON.stringify(row.ticket)} is not a number`);
154
162
  if (!CELL.test(row.chosen_cell)) throw new Error(`ticket-features.tsv row ${i + 1}: chosen_cell ${JSON.stringify(row.chosen_cell)} is not a cell`);
155
163
  return row;
package/scripts/pull.mjs CHANGED
@@ -104,7 +104,8 @@
104
104
  // `behind-issue:#M`, or `paths <p>…`, which scans again and
105
105
  // asks again. An exclusion is a ledger row.
106
106
  // 6 claim `claim-ticket.sh <N> impl-<N> implementer --apply`.
107
- // 7 route `ticket-router.mjs route …`; a router that refuses releases
107
+ // 7 route `ticket-router.mjs route …`, which routes by the Objective
108
+ // the phase-0 receipt records; a router that refuses releases
108
109
  // the claim, and a release that fails is exit 2 with the claim
109
110
  // still standing. `--chain-head` is given when a ledger row reads
110
111
  // `excluded · behind-issue:#<N>`: another open ticket
@@ -957,7 +958,7 @@ class Pull {
957
958
  "--impl-row", String(k), ...(head ? ["--chain-head"] : []), "--issue", this.p.issue, "--guard", this.p.guard,
958
959
  ];
959
960
  const r = this.script("route", "ticket-router.mjs", args);
960
- const m = r.status === 0 ? /^POLICY=(\S+) CELL=(\S+) DRAW=(\S+) STRATUM=(\S+) REASON=(\S+)$/m.exec(r.stdout) : null;
961
+ const m = r.status === 0 ? /^POLICY=(\S+) CELL=(\S+) DRAW=(\S+) STRATUM=(\S+) OBJECTIVE=(\S+) REASON=(\S+)$/m.exec(r.stdout) : null;
961
962
  if (m === null) {
962
963
  const said = r.status === 0 ? `printed no routing line: ${r.stdout}` : withText(`refused (${r.how})`, r);
963
964
  const rel = this.script("release", "release-ticket.sh", [String(st.ticket), st.member, "implementer", "--apply"]);
@@ -969,7 +970,7 @@ class Pull {
969
970
  st.outcome = { exit: 2, reason: `router ${said}`, at: now() };
970
971
  return this.stop(`ticket-router.mjs ${said}\nits claim is released`);
971
972
  }
972
- st.route = { line: m[0], cell: m[2], draw: m[3], reason: m[5], implRow: k, chainHead: head };
973
+ st.route = { line: m[0], cell: m[2], draw: m[3], reason: m[6], implRow: k, chainHead: head };
973
974
  st.stage = "row";
974
975
  return null;
975
976
  }
@@ -10,7 +10,12 @@
10
10
  ],
11
11
  "burn_in": true,
12
12
  "rows": {
13
- "*": "slow-high"
13
+ "cost": {
14
+ "*": "slow-high"
15
+ },
16
+ "speed": {
17
+ "*": "slow-high"
18
+ }
14
19
  },
15
20
  "classifier": {
16
21
  "b": null,
@@ -18,11 +23,21 @@
18
23
  },
19
24
  "estimates": {},
20
25
  "reasons": {
21
- "*": {
22
- "cell": "slow-high",
23
- "reason": "unjudged",
24
- "cells": {},
25
- "pairs": []
26
+ "cost": {
27
+ "*": {
28
+ "cell": "slow-high",
29
+ "reason": "unjudged",
30
+ "cells": {},
31
+ "pairs": []
32
+ }
33
+ },
34
+ "speed": {
35
+ "*": {
36
+ "cell": "slow-high",
37
+ "reason": "unjudged",
38
+ "cells": {},
39
+ "pairs": []
40
+ }
26
41
  }
27
42
  },
28
43
  "guard": {
@@ -3,26 +3,28 @@
3
3
  // for dispatch and for the offline fit, because the stratum rule is the one
4
4
  // function that must never read differently between the two.
5
5
  //
6
- // route [--session <dir|id>] --ticket <N> --arm A|B --impl-row <k>
7
- // --issue <issue.json> --guard <cost-guard.json>
6
+ // route [--session <dir|id>] [--objective cost|speed] --ticket <N> --arm A|B
7
+ // --impl-row <k> --issue <issue.json> --guard <cost-guard.json>
8
8
  // [--sizing <sizing.json>] [--table <router-table.json>]
9
9
  // [--chain-head] [--pending <tsv>]
10
10
  // Prints one line, `POLICY=<cell> CELL=<cell> DRAW=<k/K|->
11
- // STRATUM=<light|heavy|unknown> REASON=<reason>`, and appends the Pull's
11
+ // STRATUM=<light|heavy|unknown> OBJECTIVE=<cost|speed> REASON=<reason>`,
12
+ // routing by the objective's row set, and appends the Pull's
12
13
  // ticket-features row to the pending TSV (by default
13
14
  // `ticket-features.pending.tsv` beside `--guard`, in the run's `.fleet/`). Exit 0 on
14
15
  // every data degradation — a missing or unreadable issue, sizing or guard
15
16
  // file degrades the line, never the exit. Exit 2 only on a usage error or
16
17
  // an unreadable/invalid table: the caller does not dispatch then. With no
17
18
  // `--session`, the session is the one the phase-0 receipt
18
- // `.fleet/phase0.json` records; a receipt that records none is exit 2.
19
+ // `.fleet/phase0.json` records, and with no `--objective` the objective
20
+ // is; a receipt that records none is exit 2.
19
21
  // fit --table <json> --features <tsv> --members <tsv> --verdicts <tsv>
20
22
  // --guard <cost-guard.json> --commit <rev> [--due]
21
23
  // Re-fits the table from the three TSVs as committed at `--commit` (read
22
24
  // with `git show`, never from the working tree) and the guard, and
23
25
  // rewrites `--table`, recording the commit's sha as `fitted_commit`, then
24
- // prints the fit and one line per row: its cell and the reason, then each
25
- // candidate's verdict and range. The TSV flags name paths inside the
26
+ // prints the fit and one line per row of each objective's set: its cell
27
+ // and the reason, then each candidate's verdict and range. The TSV flags name paths inside the
26
28
  // repository that holds them. With
27
29
  // `--due`, writes nothing and prints `DUE=yes|no MERGED=<n>/<N>`: whether
28
30
  // N merged PRs have landed since `fitted_commit`, counting the features
@@ -47,7 +49,7 @@ import { isCLI } from "./is-cli.mjs";
47
49
  import { gitEnv } from "./git-env.mjs";
48
50
  import { makeDie, defineFlags } from "./arg.mjs";
49
51
  import { CELL, POLICY_CELL, drawCell, parseMember } from "./ledger-grammar.mjs";
50
- import { sessionFromReceipt } from "./phase0-receipt.mjs";
52
+ import { OBJECTIVES, objectiveFromReceipt, sessionFromReceipt } from "./phase0-receipt.mjs";
51
53
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
52
54
  import {
53
55
  AttributionError, FEATURE_COLUMNS, TRIP_POINTS, attribute, bootUnit, bootstrapDiffCI, generations, parseFeatures, tally, underFloor,
@@ -181,15 +183,29 @@ export function ruleStratum({ brief_chars, criteria }) {
181
183
  /** A parsed table, or an Error saying why it is not one. */
182
184
  export function validateTable(t) {
183
185
  const bad = (why) => new Error(`router table: ${why}`);
184
- if (t === null || typeof t !== "object" || Array.isArray(t)) return bad("not a JSON object");
186
+ const isObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
187
+ if (!isObject(t)) return bad("not a JSON object");
185
188
  if (!Array.isArray(t.cells) || t.cells.length === 0) return bad("`cells` is not a non-empty array");
186
189
  for (const c of t.cells) if (typeof c !== "string" || !CELL.test(c)) return bad(`\`cells\` holds ${JSON.stringify(c)}, not a cell token`);
187
190
  if (new Set(t.cells).size !== t.cells.length) return bad("`cells` repeats a cell");
188
- if (t.rows === null || typeof t.rows !== "object" || Array.isArray(t.rows)) return bad("`rows` is not an object");
189
- if (!Object.hasOwn(t.rows, "*")) return bad("`rows` has no `*` row");
190
- for (const [k, v] of Object.entries(t.rows)) {
191
- if (k !== "*" && !STRATA.includes(k)) return bad(`\`rows\` key ${JSON.stringify(k)} is not \`*\` or a stratum`);
192
- if (typeof v !== "string" || !CELL.test(v)) return bad(`\`rows.${k}\` is ${JSON.stringify(v)}, not a cell token`);
191
+ // `rows` and `reasons` are each keyed by objective, then by `*` or a stratum.
192
+ for (const field of ["rows", "reasons"]) {
193
+ if (!isObject(t[field])) return bad(`\`${field}\` is not an object`);
194
+ const stray = Object.keys(t[field]).find((o) => !OBJECTIVES.includes(o));
195
+ if (stray !== undefined) return bad(`\`${field}\` key ${JSON.stringify(stray)} is not an objective (${OBJECTIVES.join(", ")})`);
196
+ for (const o of OBJECTIVES) {
197
+ const set = t[field][o];
198
+ if (!isObject(set)) return bad(`\`${field}.${o}\` is not an object`);
199
+ for (const k of Object.keys(set)) {
200
+ if (k !== "*" && !STRATA.includes(k)) return bad(`\`${field}.${o}\` key ${JSON.stringify(k)} is not \`*\` or a stratum`);
201
+ }
202
+ }
203
+ }
204
+ for (const o of OBJECTIVES) {
205
+ if (!Object.hasOwn(t.rows[o], "*")) return bad(`\`rows.${o}\` has no \`*\` row`);
206
+ for (const [k, v] of Object.entries(t.rows[o])) {
207
+ if (typeof v !== "string" || !CELL.test(v)) return bad(`\`rows.${o}.${k}\` is ${JSON.stringify(v)}, not a cell token`);
208
+ }
193
209
  }
194
210
  if (typeof t.burn_in !== "boolean") return bad("`burn_in` is not a boolean");
195
211
  if (!Number.isInteger(t.stage) || t.stage < 1) return bad("`stage` is not a positive integer");
@@ -204,9 +220,13 @@ export function validateTable(t) {
204
220
  return t;
205
221
  }
206
222
 
207
- /** Arm B runs only once a classifier has passed its gate and some row is not the default. */
208
- export function bArmLive(table) {
209
- return table.classifier.b !== null && Object.values(table.rows).some((c) => c !== POLICY_CELL);
223
+ /**
224
+ * Arm B runs only once a classifier has passed its gate and some row of the
225
+ * run's own objective's set is not the default: it decides a paid classifier
226
+ * call for one Pull, which the other set's rows never route.
227
+ */
228
+ export function bArmLive(table, objective) {
229
+ return table.classifier.b !== null && Object.values(table.rows[objective]).some((c) => c !== POLICY_CELL);
210
230
  }
211
231
 
212
232
  const rowFor = (rows, stratum) => rows[stratum] ?? rows["*"];
@@ -253,10 +273,14 @@ export function sessionDate(session) {
253
273
  }
254
274
 
255
275
  /**
256
- * One Pull's routing. Pure: every input is already read.
276
+ * One Pull's routing, by `objective`'s row set. Pure: every input is already
277
+ * read. The exploration draw ignores the objective: it leaves out the cell
278
+ * the `cost` set routes the Pull to — by the sizing when arm B is live for
279
+ * the `cost` set, whichever set routes the Pull — so a Pull draws the same
280
+ * cell under both.
257
281
  * @returns {{ line: string, row: Record<string,string> }}
258
282
  */
259
- export function route({ session, ticket, arm, implRow, chainHead = false, issue, sizing = null, guard, table, now = Date.now() }) {
283
+ export function route({ session, ticket, arm, implRow, chainHead = false, issue, sizing = null, guard, table, objective, now = Date.now() }) {
260
284
  let reason = null;
261
285
  const degrade = (r) => { reason ??= r; };
262
286
 
@@ -270,9 +294,10 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
270
294
  let sizingSrc = "rule";
271
295
  let sizingPre = "";
272
296
  let routerUsd = "";
273
- const b = arm === "B" && bArmLive(table);
297
+ const sized = arm === "B" ? readSizing(sizing, table.classifier) : null;
298
+ const b = arm === "B" && bArmLive(table, objective);
274
299
  if (b) {
275
- const s = readSizing(sizing, table.classifier);
300
+ const s = sized;
276
301
  if (s === null) {
277
302
  degrade("no-sizing");
278
303
  stratum = "unknown";
@@ -282,8 +307,11 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
282
307
  }
283
308
  }
284
309
 
285
- let policy = rowFor(table.rows, ruleS);
286
- let base = b ? rowFor(table.rows, stratum) : policy;
310
+ const rows = table.rows[objective];
311
+ let policy = rowFor(rows, ruleS);
312
+ let base = b ? rowFor(rows, stratum) : policy;
313
+ const costB = arm === "B" && bArmLive(table, "cost");
314
+ let drawBase = rowFor(table.rows.cost, costB ? sized?.stratum ?? "unknown" : ruleS);
287
315
  let cell;
288
316
  let draw = "-";
289
317
  if (guard === null) {
@@ -298,10 +326,11 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
298
326
  if (tripped.has(policy)) policy = POLICY_CELL;
299
327
  if (tripped.has(base)) base = POLICY_CELL;
300
328
  }
329
+ if (tripped.has(drawBase)) drawBase = POLICY_CELL;
301
330
  cell = base;
302
331
  const exploring = table.burn_in || (implRow % EXPLORE_EVERY === 0 && !chainHead);
303
332
  if (exploring) {
304
- const policyCell = table.burn_in ? null : base;
333
+ const policyCell = table.burn_in ? null : drawBase;
305
334
  if (table.cells.some((c) => c !== policyCell && tripped.has(c))) degrade("guard-tripped");
306
335
  const d = drawCell({ session, ticket, policyCell, cells: table.cells.filter((c) => !tripped.has(c)) });
307
336
  if (d.K > 0) {
@@ -315,9 +344,9 @@ export function route({ session, ticket, arm, implRow, chainHead = false, issue,
315
344
  const row = {
316
345
  run_date: sessionDate(session), session, agent: `impl-${ticket}`, ticket: String(ticket),
317
346
  policy_cell: policy, chosen_cell: cell, exploration_draw: draw === "-" ? "" : draw,
318
- sizing_src: sizingSrc, sizing_pre: sizingPre, router_usd: routerUsd, ...m,
347
+ sizing_src: sizingSrc, sizing_pre: sizingPre, router_usd: routerUsd, ...m, objective,
319
348
  };
320
- return { line: `POLICY=${policy} CELL=${cell} DRAW=${draw} STRATUM=${stratum} REASON=${reason}`, row };
349
+ return { line: `POLICY=${policy} CELL=${cell} DRAW=${draw} STRATUM=${stratum} OBJECTIVE=${objective} REASON=${reason}`, row };
321
350
  }
322
351
 
323
352
  export function formatFeatureRow(row) {
@@ -393,95 +422,140 @@ function estimate(units, order) {
393
422
  return { estimates, pulls };
394
423
  }
395
424
 
425
+ // What each objective's judge reads and says: the figure per merged PR it
426
+ // minimises (`estimate()`'s name and its bootstrap metric), its verdict for a
427
+ // candidate past the margin and for one with no reliable difference, the key
428
+ // a pair names its better side by, and its reasons for a winner every other
429
+ // qualifier was reliably worse than and for one ahead on the figure.
430
+ const JUDGE = Object.freeze({
431
+ cost: {
432
+ figure: "usd_per_merged", metric: "usd", over: "too-dear", unreliable: "not-reliably-cheaper",
433
+ better: "cheaper", reliably: "reliably-cheaper", ahead: "lower-usd",
434
+ },
435
+ speed: {
436
+ figure: "minutes_per_merged", metric: "minutes", over: "too-slow", unreliable: "not-reliably-faster",
437
+ better: "faster", reliably: "reliably-faster", ahead: "fewer-minutes",
438
+ },
439
+ });
440
+
441
+ // A row's record with no cell: a fresh object each time, since judge() fills
442
+ // in `cells` and `pairs`.
443
+ const unjudged = () => ({ cell: null, reason: "unjudged", cells: {}, pairs: [] });
444
+
396
445
  /**
397
- * One row's cell — the `*` row's or a stratum's — and why. The quality floor
398
- * is held twice: pooled by the guard, whose trip takes a cell out of every row
399
- * and out of the exploration draw, and per row by the fit, which refuses a
400
- * cell under the floor that row alone. Every figure read is the cell's newest
401
- * generation's, the default cell's included. `est` and `pulls` are the row's
402
- * `estimate()` figures and counted Pulls.
446
+ * One row's cell under one objective — the `*` row's or a stratum's — and
447
+ * why. The quality floor is held twice: pooled by the guard, whose trip takes
448
+ * a cell out of every row and out of the exploration draw, and per row by the
449
+ * fit, which refuses a cell under the floor that row alone. The floor is
450
+ * measured against the default cell under both objectives. Every figure read
451
+ * is the cell's newest generation's, the default cell's included. `est` and
452
+ * `pulls` are the row's `estimate()` figures and counted Pulls; `baseCell` is
453
+ * the cell the row holds unless a candidate qualifies: the default cell under
454
+ * `cost`, the `cost` row's cell under `speed`. The figure is $ per merged PR
455
+ * under `cost` and minutes of work time per merged PR under `speed`.
403
456
  *
404
- * A candidate is a cell of `cells` other than the default cell. Its `verdict`,
405
- * the first that applies: `tripped` by the guard; `too-few`, under MIN_N
406
- * merged PRs in the row; `under-floor` against the default cell there;
407
- * `too-dear`, above ADOPT_RATIO of the default cell's $ per merged PR;
408
- * `not-reliably-cheaper`, its `range` does not lie wholly below zero (its
409
- * upper end, as `bootstrapDiffCI` returns it, is not below zero) or there is
410
- * none; else it `qualifies`. A candidate past `too-few` records whether it is
411
- * `under_floor`, the floor's `bar` (the default cell's fail rate plus
412
- * TRIP_POINTS, rounded as a fail rate is, for reading only: the floor compares
413
- * counts) and its `range`, the bootstrap 95% range of (candidate − default
414
- * cell) $ per merged PR over those Pulls; the others record null for each.
457
+ * A candidate is a cell of `cells` other than the base. Its `verdict`, the
458
+ * first that applies: `tripped` by the guard; `too-few`, under MIN_N merged
459
+ * PRs in the row; `under-floor` against the default cell there; `too-dear`
460
+ * (`cost`) or `too-slow` (`speed`), above ADOPT_RATIO of the base's figure;
461
+ * `not-reliably-cheaper` or `not-reliably-faster`, its `range` does not lie
462
+ * wholly below zero (its upper end, as `bootstrapDiffCI` returns it, is not
463
+ * below zero) or there is none; else it `qualifies`. A candidate past
464
+ * `too-few` records whether it is `under_floor`, the floor's `bar` (the
465
+ * default cell's fail rate plus TRIP_POINTS, rounded as a fail rate is, for
466
+ * reading only: the floor compares counts) and its `range`, the bootstrap 95%
467
+ * range of (candidate − base) figure over those Pulls; the others record null
468
+ * for each.
415
469
  *
416
- * Several qualifiers: each pair records the range of (the cheaper by $ per
417
- * merged PR, else by name − the other) in `pairs`, and a qualifier another
418
- * is reliably cheaper than is dropped. Among the rest the lower fail rate,
419
- * compared in counts, wins, then the lower $ per merged PR, then the name.
470
+ * Several qualifiers: each pair records the range of (the better by the
471
+ * figure, else by name − the other) in `pairs`, naming the better side
472
+ * `cheaper` or `faster`, and a qualifier another is reliably better than is
473
+ * dropped. Among the rest the lower fail rate, compared in counts, wins, then
474
+ * the lower figure, then the name.
420
475
  *
421
- * `reason`, why the row holds `cell`: `unjudged` (the default cell, or every
422
- * untripped candidate, is under MIN_N merged PRs; `cell` is null and a
423
- * stratum follows `*`), `no-qualifier` (the default cell), or, for the
424
- * winner, `sole-qualifier`, `reliably-cheaper` (every other qualifier was
476
+ * `reason`, why the row holds `cell`: `unjudged` (the default cell or the
477
+ * base, or every untripped candidate, is under MIN_N merged PRs; `cell` is
478
+ * null), `no-qualifier` (the base), or, for the winner, `sole-qualifier`,
479
+ * `reliably-cheaper` or `reliably-faster` (every other qualifier was
425
480
  * dropped), or what put it ahead of the next of the rest: `lower-fail-rate`,
426
- * `lower-usd` or `name`.
481
+ * `lower-usd` or `fewer-minutes`, or `name`.
427
482
  */
428
- function judgeRow(est = {}, pulls, cells, tripped) {
429
- const record = { cell: null, reason: "unjudged", cells: {}, pairs: [] };
430
- const base = est[POLICY_CELL];
431
- if (!base || base.merged < MIN_N) return record;
483
+ function judge(objective, est = {}, pulls, cells, tripped, baseCell) {
484
+ const { figure, metric, over, unreliable, better, reliably, ahead } = JUDGE[objective];
485
+ const record = unjudged();
486
+ const floor = est[POLICY_CELL];
487
+ const base = est[baseCell];
488
+ if (!floor || floor.merged < MIN_N || !base || base.merged < MIN_N) return record;
432
489
  // JSON writes −0 as 0, so a recorded end is 0 either way; `--check` compares the two.
433
- const range = (a, b) => bootstrapDiffCI(pulls[a].map(bootUnit), pulls[b].map(bootUnit))?.map((x) => x + 0) ?? null;
490
+ const units = (c) => pulls[c].map((u) => bootUnit(u, metric));
491
+ const range = (a, b) => bootstrapDiffCI(units(a), units(b))?.map((x) => x + 0) ?? null;
434
492
  const reliablyBelow = (r) => r !== null && r[1] < 0;
435
- const bar = round4(base.failed / base.claims + TRIP_POINTS / 100);
493
+ const bar = round4(floor.failed / floor.claims + TRIP_POINTS / 100);
436
494
  const qualifiers = [];
437
495
  let judged = false;
438
496
  for (const c of cells) {
439
- if (c === POLICY_CELL) continue;
497
+ if (c === baseCell) continue;
440
498
  const e = est[c];
441
499
  if (tripped.has(c) || !e || e.merged < MIN_N) {
442
500
  record.cells[c] = { verdict: tripped.has(c) ? "tripped" : "too-few", under_floor: null, bar: null, range: null };
443
501
  continue;
444
502
  }
445
503
  judged = true;
446
- const under = underFloor(e, base);
447
- const r = range(c, POLICY_CELL);
504
+ const under = underFloor(e, floor);
505
+ const r = range(c, baseCell);
448
506
  const verdict = under ? "under-floor"
449
- : e.usd_per_merged > ADOPT_RATIO * base.usd_per_merged ? "too-dear"
450
- : reliablyBelow(r) ? "qualifies" : "not-reliably-cheaper";
507
+ : e[figure] > ADOPT_RATIO * base[figure] ? over
508
+ : reliablyBelow(r) ? "qualifies" : unreliable;
451
509
  record.cells[c] = { verdict, under_floor: under, bar, range: r };
452
510
  if (verdict === "qualifies") qualifiers.push(c);
453
511
  }
454
512
  if (!judged) return record;
455
- if (qualifiers.length === 0) return { ...record, cell: POLICY_CELL, reason: "no-qualifier" };
513
+ if (qualifiers.length === 0) return { ...record, cell: baseCell, reason: "no-qualifier" };
456
514
 
457
- const usd = (c) => est[c].usd_per_merged;
515
+ const of = (c) => est[c][figure];
458
516
  const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
459
517
  const failCmp = (a, b) => est[a].failed * est[b].claims - est[b].failed * est[a].claims;
460
518
  const named = [...qualifiers].sort(byName);
461
519
  const dropped = new Set();
462
520
  for (let i = 0; i < named.length; i++) {
463
521
  for (let j = i + 1; j < named.length; j++) {
464
- const [cheaper, other] = usd(named[j]) < usd(named[i]) ? [named[j], named[i]] : [named[i], named[j]];
465
- const r = range(cheaper, other);
466
- record.pairs.push({ cheaper, other, range: r });
522
+ const [win, other] = of(named[j]) < of(named[i]) ? [named[j], named[i]] : [named[i], named[j]];
523
+ const r = range(win, other);
524
+ record.pairs.push({ [better]: win, other, range: r });
467
525
  if (reliablyBelow(r)) dropped.add(other);
468
526
  }
469
527
  }
470
- // The cheapest qualifier, by $ then name, is never dropped, so the rest is never empty.
471
- const [win, next] = named.filter((c) => !dropped.has(c)).sort((a, b) => failCmp(a, b) || usd(a) - usd(b) || byName(a, b));
528
+ // The best qualifier, by the figure then name, is never dropped, so the rest is never empty.
529
+ const [win, next] = named.filter((c) => !dropped.has(c)).sort((a, b) => failCmp(a, b) || of(a) - of(b) || byName(a, b));
472
530
  const reason = qualifiers.length === 1 ? "sole-qualifier"
473
- : next === undefined ? "reliably-cheaper"
531
+ : next === undefined ? reliably
474
532
  : failCmp(win, next) !== 0 ? "lower-fail-rate"
475
- : usd(win) !== usd(next) ? "lower-usd" : "name";
533
+ : of(win) !== of(next) ? ahead : "name";
476
534
  return { ...record, cell: win, reason };
477
535
  }
478
536
 
537
+ /** A `cost` row: `judge()` from the default cell, by $ per merged PR. */
538
+ function judgeRow(est, pulls, cells, tripped) {
539
+ return judge("cost", est, pulls, cells, tripped, POLICY_CELL);
540
+ }
541
+
542
+ /** A `speed` row: `judge()` from the `cost` row's cell, by minutes of work time per merged PR. */
543
+ function judgeSpeedRow(est, pulls, cells, tripped, costCell) {
544
+ return judge("speed", est, pulls, cells, tripped, costCell);
545
+ }
546
+
479
547
  /**
480
548
  * The re-fit table. `guard` is `{ tripped, n, window_start }` (readGuard's
481
549
  * shape); `commit` is the sha of the commit the inputs were read from, or null
482
- * for the empty corpus before any fit. `reasons` holds `judgeRow()`'s record
483
- * for `*` and for each stratum with Pulls; a stratum whose record has no cell
484
- * gets no row and follows `*`, and an unjudged `*` is the default cell.
550
+ * for the empty corpus before any fit. `rows` and `reasons` hold one set per
551
+ * objective. `reasons.cost` holds `judgeRow()`'s record for `*` and for each
552
+ * stratum with Pulls; a stratum whose record has no cell gets no `cost` row
553
+ * and follows `*`, and an unjudged `*` is the default cell. `reasons.speed`
554
+ * holds `judgeSpeedRow()`'s record for each row with a `cost` row, from that
555
+ * row's cell, and an `unjudged` record with no cell for each stratum with no
556
+ * `cost` row: a row with a `cost` row holds a `speed` row, the base when the
557
+ * record has no cell, and a stratum with no `cost` row has no `speed` row
558
+ * and follows `*`.
485
559
  */
486
560
  export function fitTable({ prior, features, members, verdicts, guard, commit = null }) {
487
561
  const window = guard.window_start ?? prior.window_start ?? null;
@@ -492,15 +566,26 @@ export function fitTable({ prior, features, members, verdicts, guard, commit = n
492
566
  let { stage, cells } = prior;
493
567
  cells = [...cells];
494
568
  const star = judgeRow(estimates["*"], pulls["*"], cells, tripped);
495
- const rows = { "*": star.cell ?? POLICY_CELL };
496
- const reasons = { "*": { ...star, cell: rows["*"] } };
569
+ const rows = { cost: { "*": star.cell ?? POLICY_CELL }, speed: {} };
570
+ const reasons = { cost: { "*": { ...star, cell: rows.cost["*"] } }, speed: {} };
497
571
  for (const s of STRATA) {
498
572
  if (!estimates[s]) continue;
499
- reasons[s] = judgeRow(estimates[s], pulls[s], cells, tripped);
500
- if (reasons[s].cell !== null) rows[s] = reasons[s].cell;
573
+ reasons.cost[s] = judgeRow(estimates[s], pulls[s], cells, tripped);
574
+ if (reasons.cost[s].cell !== null) rows.cost[s] = reasons.cost[s].cell;
575
+ }
576
+ for (const s of Object.keys(reasons.cost)) {
577
+ const costCell = rows.cost[s];
578
+ if (costCell === undefined) {
579
+ reasons.speed[s] = unjudged();
580
+ continue;
581
+ }
582
+ const record = judgeSpeedRow(estimates[s], pulls[s], cells, tripped, costCell);
583
+ rows.speed[s] = record.cell ?? costCell;
584
+ reasons.speed[s] = { ...record, cell: rows.speed[s] };
501
585
  }
502
586
  const atN = (c) => (guard.n[c] ?? 0) >= MIN_N;
503
- if (stage === 1 && cells.every(atN) && (rows["*"] !== POLICY_CELL || cells.some((c) => tripped.has(c)))) {
587
+ const adopted = Object.values(rows).some((set) => set["*"] !== POLICY_CELL);
588
+ if (stage === 1 && cells.every(atN) && (adopted || cells.some((c) => tripped.has(c)))) {
504
589
  stage = 2;
505
590
  for (const { cell, survives } of STAGE2) {
506
591
  if (!cells.includes(cell) && cells.includes(survives) && !tripped.has(survives)) cells.push(cell);
@@ -546,14 +631,15 @@ function cellCounts(estimates) {
546
631
  .join(", ") || "none";
547
632
  }
548
633
 
549
- // One line per row the fit judged or skipped: its cell (`-` for a stratum
550
- // that follows `*`) and its reason, then each candidate's verdict and range.
634
+ // One line per row the fit judged or skipped, per objective: its cell (`-`
635
+ // for a stratum that follows `*`) and its reason, then each candidate's
636
+ // verdict and range.
551
637
  function rowLines(reasons) {
552
638
  const range = (r) => (r === null ? "" : ` [${r.map((x) => x.toFixed(2)).join(", ")}]`);
553
- return Object.entries(reasons).map(([s, r]) => {
639
+ return Object.entries(reasons).flatMap(([o, set]) => Object.entries(set).map(([s, r]) => {
554
640
  const cells = Object.entries(r.cells).map(([c, x]) => `${c} ${x.verdict}${range(x.range)}`).join(", ");
555
- return `${NAME}: row ${s} ${r.cell ?? "-"} ${r.reason}${cells ? ` (${cells})` : ""}\n`;
556
- }).join("");
641
+ return `${NAME}: row ${o} ${s} ${r.cell ?? "-"} ${r.reason}${cells ? ` (${cells})` : ""}\n`;
642
+ })).join("");
557
643
  }
558
644
 
559
645
  // ---------------------------------------------------------------------------
@@ -598,7 +684,7 @@ function main() {
598
684
  const die = makeDie(NAME);
599
685
  const F = defineFlags(die, {
600
686
  flags: {
601
- session: "value", ticket: "value", arm: "value", "impl-row": "value", issue: "value",
687
+ session: "value", objective: "value", ticket: "value", arm: "value", "impl-row": "value", issue: "value",
602
688
  sizing: "value", guard: "value", table: "value", pending: "value", "chain-head": "bool",
603
689
  features: "value", members: "value", verdicts: "value", commit: "value", check: "bool", due: "bool",
604
690
  },
@@ -612,7 +698,7 @@ function main() {
612
698
  die("usage: ticket-router.mjs route … | fit … | --check … — exactly one mode");
613
699
  }
614
700
  const allowed = {
615
- route: ["session", "ticket", "arm", "impl-row", "issue", "sizing", "guard", "table", "pending", "chain-head"],
701
+ route: ["session", "objective", "ticket", "arm", "impl-row", "issue", "sizing", "guard", "table", "pending", "chain-head"],
616
702
  fit: ["table", "features", "members", "verdicts", "guard", "commit", "due"],
617
703
  check: ["check", "table", "features", "members", "verdicts"],
618
704
  }[mode];
@@ -643,9 +729,16 @@ function main() {
643
729
  try { sessionArg = sessionFromReceipt(); } catch (e) { die(e.message); }
644
730
  }
645
731
  const session = basename(sessionArg.replace(/\/+$/, ""));
732
+ // `--objective` omitted is the one the receipt records, likewise.
733
+ let objective = F.arg("objective");
734
+ if (objective === null) {
735
+ try { objective = objectiveFromReceipt(); } catch (e) { die(e.message); }
736
+ } else if (!OBJECTIVES.includes(objective)) {
737
+ die(`--objective ${objective} is not ${OBJECTIVES.join(" or ")}`);
738
+ }
646
739
  const { line, row } = route({
647
740
  session, ticket: Number(ticket), arm, implRow: Number(implRowArg), chainHead: F.has("chain-head"),
648
- issue: readJson(issuePath), sizing: readJson(F.arg("sizing")), guard: readGuard(readJson(guardPath)), table,
741
+ issue: readJson(issuePath), sizing: readJson(F.arg("sizing")), guard: readGuard(readJson(guardPath)), table, objective,
649
742
  });
650
743
  const pending = F.arg("pending") ?? join(dirname(guardPath), "ticket-features.pending.tsv");
651
744
  // The header decision and the append are not atomic and take no lock. That is
@@ -27,12 +27,12 @@
27
27
  // definition never declared, and every row the definition ran would
28
28
  // be inadmissible. The one exception is an implementer definition
29
29
  // whose cell the router never selects (not in the router table's
30
- // `cells`, in none of its `rows` values, and not the policy cell): a
31
- // cell can be defined before the router may pick it, so its mismatch
32
- // is a notice. The router table is `router-table.json` beside this
33
- // script, or the file `--table <path>` names; one that cannot be read,
34
- // or that lacks a `cells` array or a `rows` object of cell names,
35
- // refuses. A target the catalog does not list cannot be checked, so it
30
+ // `cells`, in no row of either objective's `rows` set, and not the
31
+ // policy cell): a cell can be defined before the router may pick it,
32
+ // so its mismatch is a notice. The router table is `router-table.json`
33
+ // beside this script, or the file `--table <path>` names; one that
34
+ // cannot be read, or that lacks a `cells` array or a `rows` object of
35
+ // row sets of cell names, refuses. A target the catalog does not list cannot be checked, so it
36
36
  // fails too;
37
37
  // (e) `modelRoles.slow`/`modelRoles.task` resolving to the same model is
38
38
  // flagged as a notice — legal, but every `slow-*` cell then measures
@@ -139,17 +139,20 @@ export function implementerCell(file) {
139
139
  }
140
140
 
141
141
  // The cells the router can select from a parsed router table: the pool it
142
- // explores (`cells`), the cell each stratum's row names (`rows` values) and the
143
- // policy cell, which every Pull falls back to whatever the table says. An
144
- // Error saying why when the table lacks a `cells` array of names or a `rows`
145
- // object of names — a table that cannot be read must refuse, never quietly
146
- // make every cell non-routable.
142
+ // explores (`cells`), the cell each row of each objective's set names (`rows`
143
+ // values) and the policy cell, which every Pull falls back to whatever the
144
+ // table says. An Error saying why when the table lacks a `cells` array of
145
+ // names or a `rows` object of row sets of names — a table that cannot be read
146
+ // must refuse, never quietly make every cell non-routable.
147
147
  export function routableCells(table) {
148
148
  const bad = (why) => new Error(`router table: ${why}`);
149
149
  if (!isJsonObject(table)) return bad("not a JSON object");
150
150
  if (!Array.isArray(table.cells) || !table.cells.every((c) => typeof c === "string")) return bad("`cells` is not an array of cell names");
151
- if (!isJsonObject(table.rows) || !Object.values(table.rows).every((c) => typeof c === "string")) return bad("`rows` is not an object of cell names");
152
- return new Set([POLICY_CELL, ...table.cells, ...Object.values(table.rows)]);
151
+ const sets = isJsonObject(table.rows) ? Object.values(table.rows) : null;
152
+ if (sets === null || !sets.every((set) => isJsonObject(set) && Object.values(set).every((c) => typeof c === "string"))) {
153
+ return bad("`rows` is not an object of row sets of cell names");
154
+ }
155
+ return new Set([POLICY_CELL, ...table.cells, ...sets.flatMap((set) => Object.values(set))]);
153
156
  }
154
157
 
155
158
  // Tolerates a role value written without its provider prefix — omp's
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: run-team
3
3
  description: Run an agent fleet — implementers, reviewers, one merge bot — over the ready-for-agent queue. Invoke-only; the fleet writes to a live repo and must never start unasked.
4
- argument-hint: "[in-flight]"
4
+ argument-hint: "[in-flight] [cost|speed]"
5
5
  disable-model-invocation: true
6
6
  ---
7
7
 
8
8
  Run `next-ticket`, `review-and-fix`, `run-merge-bot` as one fleet. You are the
9
9
  **controller**, in the main thread, never a member.
10
10
 
11
- `$ARGUMENTS` = `[in-flight]`, optional, one positive integer, default 5: the
12
- In-flight bound, the most tickets between Claim and merge at once.
11
+ `$ARGUMENTS` = `[in-flight] [cost|speed]`, both optional: the In-flight bound,
12
+ a positive integer, default 5, and the Objective, default `cost`.
13
13
  Merge bot is at most one, not configurable.
14
14
 
15
15
  The war story behind a rule ending `See references/<file>` lives in that file; load
@@ -40,10 +40,10 @@ Nothing in this phase, or in any later one, asks the maintainer which tickets to
40
40
  0. **Once per run, before anything else.** Run phase 0 from a Python eval
41
41
  cell in this main checkout, without `--session`:
42
42
  `subprocess.run([os.path.expanduser("~/.fleet/bin/fleet-run"),
43
- "phase0.mjs", …], stderr=subprocess.STDOUT)`. Add `--in-flight <n>` when
44
- `$ARGUMENTS` gave one, `--plugin-checkout <dir>` when this workspace tracks
45
- no `plugin/…` of its own, `--spend-dir <dir>` to name the directory the
46
- cockpit's spend panel reads, and `--workflow <name>` or `--declare-no-ci`
43
+ "phase0.mjs", …], stderr=subprocess.STDOUT)`. Add `--in-flight <n>` and
44
+ `--objective <o>` as `$ARGUMENTS` gave them, `--plugin-checkout <dir>` when
45
+ this workspace tracks no `plugin/…` of its own, `--spend-dir <dir>` for the
46
+ spend panel, and `--workflow <name>` or `--declare-no-ci`
47
47
  when the repository has no workflow named CI. Its steps and receipt: See
48
48
  references/phase-0.md. Dispatch nothing before it exits 0. Exit 4 is a
49
49
  step that needs you: act on each `NEED` line below, then run it again. Once
@@ -14,6 +14,13 @@ it — `~/.omp/agent/sessions/<encoded-cwd>`, which holds every session of the
14
14
  checkout — names no session: phase 0 exits 2 naming the path and where it
15
15
  came from, and writes no receipt.
16
16
 
17
+ `--objective cost|speed` is the run's Objective, which phase 0 records in its
18
+ receipt, `cost` when it is not given; the router routes every Pull of the run
19
+ by it. It is fixed for the run: a resumed receipt keeps the one it records —
20
+ `cost` when it records none — and a resume whose `--objective` names the
21
+ other exits 2 naming both, leaving the receipt as it was. A resumed receipt
22
+ recording anything other than `cost` or `speed` exits 3 naming the value.
23
+
17
24
  Exit 3 is a step that failed: its line carries the refusal, so fix the cause
18
25
  and run it again.
19
26
 
@@ -249,7 +249,7 @@ terminating in exactly one dispatch or an empty shortlist:
249
249
  7. **Route, row, then dispatch** — `~/.fleet/bin/fleet-run ticket-router.mjs route
250
250
  --session <session> --ticket <N> --arm <A|B> --impl-row <k>[ --chain-head] --issue
251
251
  <scratch>/impl-<N>/issue.json --guard .fleet/cost-guard.json` prints
252
- `POLICY=… CELL=… DRAW=… STRATUM=… REASON=…` (the router's arguments paragraph below the list
252
+ `POLICY=… CELL=… DRAW=… STRATUM=… OBJECTIVE=… REASON=…` (the router's arguments paragraph below the list
253
253
  says what each argument is); then `ledger.mjs row <N> "impl-<N>[ · tier=<CELL>][ · route=<REASON>]"`,
254
254
  `tier=<CELL>` exactly when `DRAW` is not `-`; then the dispatch at once: claim
255
255
  what you are about to dispatch; dispatch what you have just claimed. Router
@@ -263,7 +263,9 @@ row 5k and every row after it carry no `tier=`), that row's number;
263
263
  `--chain-head` marks a ticket another open ticket sequences after, which is
264
264
  what rolls it. `--arm` is `A` for an even ticket number and `B` for an odd
265
265
  one; a B Pull routes exactly as an A one until the table opens arm B.
266
- `--session` is this run's own session directory. The printed line is the
266
+ `--session` is this run's own session directory. The router routes by the
267
+ row set of the run's Objective, `OBJECTIVE`, which it reads off the phase-0
268
+ receipt. The printed line is the
267
269
  whole routing decision — never re-derive `CELL` from the table, the guard or
268
270
  this paragraph. A missing or unreadable `.fleet/cost-guard.json` routes the
269
271
  default cell only (`REASON=guard-missing`), burn-in included, and `REASON`
@@ -429,7 +431,8 @@ controller's probe could not look.
429
431
  rows in `.fleet/ledger.md` at Pull time; the Pull that creates row 5, 10, 15 …
430
432
  is an Exploration Pull, and the router draws its cell: `k = 1 +
431
433
  (sha256("<session>\t<ticket>")[0:8] mod K)` over `router-table.json`'s
432
- `cells` minus the policy cell, in token order. While the table's `burn_in` is
434
+ `cells` minus the policy cell, in token order. Under either Objective that
435
+ policy cell is the `cost` row set's, so the draw is the same under both. While the table's `burn_in` is
433
436
  true every Pull draws, over every cell, the policy cell included. An
434
437
  Exploration Pull records `tier=<cell>` in the row, and a replacement inherits the row's tier —
435
438
  written at the Pull's row step, ahead of `ledger.mjs dispatch`, which reads it