bullswarm 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.28.1 — summary bytes on the wire, monthly pacing
4
+
5
+ - `workflow runs result <id> --summary` was budgeted by `fitResultSummary`
6
+ against compact `JSON.stringify(summary)` (`RESULT_SUMMARY_BYTE_BUDGET =
7
+ 4096` in `src/workflow/v2-outcome.js`) but `jsonOut` printed
8
+ `JSON.stringify(obj, null, 2)`, so the bytes on the wire exceeded the
9
+ budget. `--summary` now prints compact single-line JSON
10
+ (`JSON.stringify(obj)` in `src/workflow/runs-cli.js`); `--json` without
11
+ `--summary` still pretty-prints the full envelope, and `--summary --json`
12
+ stays identical to `--summary`. Measured on
13
+ `tests/fixtures/real-result-ze5xz2.json` through the summariser / CLI:
14
+ compact `--summary` is 3,786 bytes
15
+ (`tests/workflow-result-summary.test.js` prints `result-summary size:
16
+ full=57141 summary=3786`; same figure as
17
+ `Buffer.byteLength(JSON.stringify(summarizeV2Result(fixture)))`); the
18
+ full envelope as `--json` prints it is 60,709 bytes
19
+ (`JSON.stringify(envelope, null, 2)` plus the trailing newline
20
+ `console.log` adds — `tests/workflow-result-summary.test.js` prints
21
+ `result-summary cli: prettyFull=60709`).
22
+
23
+ - Routing paced every pool by `windows.seven_day ?? windows.monthly`
24
+ (`paceSnapshot` in `src/meters/framework.js`), so command-code — whose real
25
+ budget is a monthly credit allocation — was paced by its weekly rate-limit
26
+ window. Live meter, captured 2026-09-09T10:45:28Z and evaluated at
27
+ 2026-09-09T11:09:44.982Z: weekly `used 73.1% elapsed 91.8% surplus +18.7`
28
+ against monthly `used 79.4% elapsed 75.3% surplus -4.1`, with 14.43 of 70
29
+ credits left for the 7.66 days to the 2026-09-17T03:06:55Z reset. Routing
30
+ therefore called it "the most-behind capable pool" and kept sending it work
31
+ while its monthly budget was already 4.1 points overspent. Pacing is now per
32
+ pool: `pacingWindowFor({connector, subscription})` resolves
33
+ `state.strategy.subscriptions[pool].quotaWindow`, then
34
+ `connector.subscription.quotaWindow`, normalised to `weekly` | `monthly` |
35
+ `null` (any other label — including a pre-0.28.1 free-text one — is ignored
36
+ for pacing and keeps the old weekly-first order). `paceSnapshot(snapshot,
37
+ nowMs, {pacingWindow})` takes `monthly` → `windows.monthly ??
38
+ windows.seven_day`, `weekly`/`null` → `windows.seven_day ??
39
+ windows.monthly`, and returns `pacingWindow` naming the window actually
40
+ used. `src/lib/config.js` `buildPools` resolves the choice once per pool
41
+ (where the connector and the state both are) and re-paces `usedPct`,
42
+ `elapsedPct`, `pace` and `paceResetsAt` off `reading.windows`, so cache,
43
+ stale and live readings are paced identically; the pool view carries
44
+ `pacingWindow`. The 5h gate is untouched: `command-code` still gates on 5h
45
+ `25.2%`. Connectors already declared this — `command-code` and the kaihk
46
+ pools `monthly`, `claude-code`/`codex`/`grok` `weekly`; nothing read it for
47
+ pacing before.
48
+ - The spend model follows the pacing window. `WINDOW_KEYS` (framework.js)
49
+ gains `monthly: {snapshot: 'monthly', history: 'monthly', windowMs: null}`,
50
+ and `rateForWindow` (`src/lib/spend.js`) derives the bootstrap window start
51
+ with `meta.windowMs ?? monthlyWindowMs(resetsAtMs)` — the calendar month
52
+ ending at the provider's `resets_at` (M2), never an assumed 30 days.
53
+ `attachSpend` now writes `pool.spend.monthly` and `pool.projectedMonthlyPct`
54
+ next to the fiveHour/weekly fields, plus `pool.spend.pacing = {window,
55
+ ratePerMinute, source, samples}` and `pool.projectedPacingPct` for the
56
+ window that paces the pool (default `weekly`, so a pool that declares
57
+ nothing keeps its old numbers). `inflightLoad` (`src/lib/route.js`) charges
58
+ the in-flight penalty from `spend.pacing?.ratePerMinute ??
59
+ spend.weekly?.ratePerMinute` with that rate's own source label, so the
60
+ surplus and the penalty are measured in the same window; candidate rows gain
61
+ `pacingWindow` and `projectedPacingPct` beside the unchanged
62
+ `projectedWeeklyPct`.
63
+ - Operator control and display. `bullswarm strategy set-subscription <pool>
64
+ --quota-window <weekly|monthly>` now selects the window that paces routing
65
+ (help text in `src/help.js`) and validates it: anything else exits 2 with
66
+ `--quota-window must be weekly or monthly (or unknown to clear)`, and
67
+ `unknown` clears the override back to the connector's declaration. Labels
68
+ already stored are ignored for pacing, never rejected on read. `bullswarm
69
+ pools` names the window in the meter column — `cmd-fixture cost=5
70
+ lanes=chore monthly used 79.4% elapsed 75.3% [cache] surplus=-4.1
71
+ inflight=0 5h=25.2% ready` — and `pools --json` entries carry
72
+ `pacingWindow`. `strategy refresh`/`show` print the window on each
73
+ subscription line (`command-code: GOAT · ... · monthly 79.4% used · surplus
74
+ -4.1`) and carry `pacingWindow` next to the free-text `quotaWindow` label in
75
+ `--json`; `strategy inventory --json` carries it per provider.
76
+ - Tests: 738 -> 750, 0 failures. The new behaviour is covered in
77
+ `tests/meters.test.js` (the live command-code snapshot as a fixture, the
78
+ helper's precedence, `buildPools` pacing), `tests/spend.test.js` (monthly
79
+ bootstrap window start, `spend.pacing`/`projectedPacingPct`),
80
+ `tests/route.test.js` (the penalty on the pacing window; weekly-only pools
81
+ unchanged), `tests/strategy-cli.test.js` (`--quota-window` validation) and
82
+ `tests/assignments.test.js` (the `pools` meter column and `--json`
83
+ `pacingWindow`).
84
+
3
85
  ## 0.28.0 — context diet
4
86
 
5
87
  - `workflow runs result <id> --summary` prints a compact status-loop
@@ -77,9 +159,10 @@
77
159
  39,288; compact `JSON.stringify` of the parsed envelope is 57,141.
78
160
  `summarizeV2Result` of that fixture is 3,786 bytes —
79
161
  `tests/workflow-result-summary.test.js` prints `result-summary size:
80
- full=57141 summary=3786`. The 0.28.0 goal recorded the integrator's
81
- inputs as 60,790 bytes; `wc -c` of the seven dependency out-files under
82
- `.diet-inputs/` sums to 46,022 (out-surface 18,659, out-routing-cleanup
162
+ full=57141 summary=3786` (numbers re-measured in 0.28.1). The 0.28.0
163
+ goal recorded the integrator's inputs as 60,790 bytes; `wc -c` of the
164
+ seven dependency out-files under `.diet-inputs/` sums to 46,022
165
+ (out-surface 18,659, out-routing-cleanup
83
166
  10,202, out-state-bugs 7,052, out-docs 6,831, out-dead-kernel 1,377,
84
167
  out-verify-gate 974, out-dead-code 927) and the integrator task file is
85
168
  14,768 (`wc -c .diet-inputs/task-integrate-attempt-1.md`), which
package/README.md CHANGED
@@ -51,7 +51,12 @@ detaches safely.
51
51
  passing verification.
52
52
  2. **Pace by meter.** The scheduling resource is the subscription window:
53
53
  elapsed% minus used%, most-behind pool wins. Pace may only promote a
54
- *cheaper* pool. Lanes are work-nature, never hard-coded to pools. The
54
+ *cheaper* pool. Lanes are work-nature, never hard-coded to pools. Which
55
+ window paces one pool is the subscription window that pool's connector
56
+ declares (`quotaWindow`: weekly for claude-code, codex and grok; monthly
57
+ for command-code and the kaihk pools), overridable per pool with
58
+ `bullswarm strategy set-subscription <pool> --quota-window <weekly|monthly>`
59
+ — `bullswarm pools` names it in the meter column. The
55
60
  5-hour window never paces — it gates: a pool at or above 75% of it is
56
61
  chosen only when no eligible pool below that line exists, and one at or
57
62
  above 90% is not dispatched at all.
@@ -656,6 +661,10 @@ These are UTF-8 byte counts, never tokens.
656
661
  requirement as `{ id, status, mandatory, evidenceCount, why }`, each action as
657
662
  `{ id, kind, lane, effort, status, pool, model, reasoning, wallSec, outFile,
658
663
  bytes }`, `concerns: { count, first }`, `usage`, and `next: { full, runDir, outputs }` — every output name is a basename inside `next.runDir`.
664
+ `--summary` is single-line JSON (`JSON.stringify`), so the bytes on the wire
665
+ match the 4,096-byte fitter budget. As printed by the CLI on
666
+ `tests/fixtures/real-result-ze5xz2.json`, the compact summary is 3,786 bytes
667
+ and the pretty full envelope (`--json` alone) is 60,709 bytes.
659
668
  The full `bullswarm.workflow.result.v2` envelope is unchanged and remains the
660
669
  default. Read it (`--json` alone) on a failed or partial run, or before judging
661
670
  evidence. A terminal `workflow watch` prints the same compact command as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -249,8 +249,17 @@ bullswarm workflow runs show <id> --json # routing reason + candidates
249
249
  - `bullswarm pools` carries `inflight=<n>` next to each pool's `5h=<n>%`
250
250
  reading; `--json` adds the full `inflight` block (`count`, elapsed
251
251
  `minutes`, `remainingMinutes`, `unknownExpected`, `records[]`) and each
252
- pool's `spend.fiveHour` / `spend.weekly` rates with
253
- `projectedFiveHourPct` / `projectedWeeklyPct`.
252
+ pool's `spend.fiveHour` / `spend.weekly` / `spend.monthly` / `spend.pacing`
253
+ rates with `projectedFiveHourPct` / `projectedWeeklyPct` /
254
+ `projectedMonthlyPct` / `projectedPacingPct`.
255
+ - The meter column names the window the pool is paced by, e.g.
256
+ `command-code cost=1 lanes=analyze/build/chore monthly used 79.4% elapsed
257
+ 75.3% [cache] surplus=-4.1 ...`. That window is the connector's declared
258
+ `quotaWindow` (monthly for command-code and the kaihk pools, weekly for
259
+ claude-code, codex and grok), overridable with `bullswarm strategy
260
+ set-subscription <pool> --quota-window <weekly|monthly>`; `pools --json`
261
+ carries it as `pacingWindow`, and `used%`/`elapsed%`/`surplus` are that
262
+ window's. The 5h reading still only gates.
254
263
  - `bullswarm strategy rungs --json` answers "what would this pool actually run
255
264
  on this tier, and what did it cost last time" in one row per pool and effort
256
265
  tier: the effective model and its source, the effective reasoning level and
package/src/cli.js CHANGED
@@ -96,9 +96,14 @@ async function cmdPools(opts) {
96
96
  }
97
97
  for (const p of pools) {
98
98
  const src = p.meterSource;
99
+ // Name the window the numbers came from: `used`/`elapsed` mean different
100
+ // things for a weekly-paced and a monthly-paced pool, and the surplus
101
+ // routing compares is this window's. Only a real window reading is
102
+ // labeled — a declared meter has a number but no window.
103
+ const window = p.pacingWindow && p.elapsedPct != null ? `${p.pacingWindow} ` : '';
99
104
  const meter = src === 'none'
100
105
  ? 'unmetered'
101
- : `used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}]`;
106
+ : `${window}used ${p.usedPct ?? '?'}% elapsed ${p.elapsedPct ?? '?'}% [${src}]`;
102
107
  const burst = p.burstGate ? ' BURST-GATED' : '';
103
108
  // 5h is a gate, never a pace (doctrine M3): show the reading and whether
104
109
  // routing now deprioritizes this pool for it. When in-flight work makes
package/src/help.js CHANGED
@@ -328,7 +328,7 @@ const poolsText = rich({
328
328
  args: [],
329
329
  options: [
330
330
  { flag: '--force', desc: 'bypass the meter cache and re-read live usage for every pool', default: 'off (cached meter readings reused within their TTL)' },
331
- { flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly} rates with their source and sample count, and projectedFiveHourPct / projectedWeeklyPct', default: 'human-readable aligned table' },
331
+ { flag: '--json', desc: 'machine-readable pool array, each entry carrying inflight {count, minutes, remainingMinutes, unknownExpected, records[]}, spend {fiveHour, weekly, monthly, pacing} rates with their source and sample count, pacingWindow, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
332
332
  ],
333
333
  safety: [
334
334
  'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
@@ -660,15 +660,16 @@ const strategyIncludeModelText = rich({
660
660
  });
661
661
 
662
662
  const strategySetSubscriptionText = rich({
663
- usage: 'bullswarm strategy set-subscription <pool> [--plan <name>] [--monthly-usd <n|unknown>] [--included-usd <n|unknown>] [--quota-window <name>]',
663
+ usage: 'bullswarm strategy set-subscription <pool> [--plan <name>] [--monthly-usd <n|unknown>] [--included-usd <n|unknown>] [--quota-window <weekly|monthly|unknown>]',
664
664
  purpose: "Record known subscription pricing for a pool so refresh's value-multiple math "
665
- + '(included value vs. monthly cost) is accurate.',
665
+ + '(included value vs. monthly cost) is accurate, and choose the quota window '
666
+ + 'routing paces this pool by.',
666
667
  args: [{ name: '<pool>', desc: 'connector/pool name to record economics for' }],
667
668
  options: [
668
669
  { flag: '--plan <name>', desc: 'plan label to record', default: 'unchanged' },
669
670
  { flag: '--monthly-usd <n|unknown>', desc: 'monthly subscription price', default: 'unchanged' },
670
671
  { flag: '--included-usd <n|unknown>', desc: 'estimated included usage value', default: 'unchanged' },
671
- { flag: '--quota-window <name>', desc: 'label for the quota reset window', default: 'unchanged' },
672
+ { flag: '--quota-window <weekly|monthly>', desc: 'the subscription window that PACES routing for this pool (used% vs elapsed% of it); unknown clears it back to the connector default', default: 'unchanged' },
672
673
  ],
673
674
  safety: ['writes state.strategy.subscriptions[pool] and invalidates the cached report'],
674
675
  examples: [{ cmd: 'bullswarm strategy set-subscription claude --plan max --monthly-usd 200 --included-usd 1000' }],
package/src/lib/config.js CHANGED
@@ -8,6 +8,13 @@
8
8
  // Pace source (doctrine M2): the pacing object carries elapsed% computed
9
9
  // from the provider's resets_at. Declared meters fall back to the local
10
10
  // elapsed estimate and are visibly labeled.
11
+ //
12
+ // Pacing window (doctrine M3): WHICH window paces a pool is the pool's own
13
+ // subscription window — `state.strategy.subscriptions[pool].quotaWindow`,
14
+ // else `connector.subscription.quotaWindow` — resolved here, where both the
15
+ // state and the connector are in hand, so a cache, stale or live reading is
16
+ // paced identically. `p.pacingWindow` names the window the numbers on the
17
+ // pool view actually came from.
11
18
 
12
19
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
13
20
  import { join } from 'node:path';
@@ -15,7 +22,7 @@ import { loadState } from './state.js';
15
22
  import { paceScore, isQuarantined } from './route.js';
16
23
  // One strict numeric coercion for the whole codebase (src/lib/num.js).
17
24
  import { finiteOrNull } from './num.js';
18
- import { FIVE_HOUR_NEAR_LIMIT_PCT } from '../meters/framework.js';
25
+ import { FIVE_HOUR_NEAR_LIMIT_PCT, pacingWindowFor, pickPacingWindow } from '../meters/framework.js';
19
26
  import { expandClaudeAccountConnectors } from './claude-accounts.js';
20
27
  import { expandOpenCodeKaihkConnectors } from './opencode-kaihk.js';
21
28
 
@@ -79,6 +86,13 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
79
86
  usedPct: null,
80
87
  elapsedPct: null,
81
88
  pace: null,
89
+ // The subscription window that paces this pool, before any reading:
90
+ // the operator's setting, else the connector's declaration, else null
91
+ // (default order). Replaced below by the window a reading really used.
92
+ pacingWindow: pacingWindowFor({
93
+ connector: conn,
94
+ subscription: state.strategy?.subscriptions?.[name] ?? null,
95
+ }),
82
96
  burstGate: false,
83
97
  // 5h window (doctrine M3): gates routing, never paces it.
84
98
  fiveHourUsedPct: null,
@@ -112,13 +126,15 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
112
126
  p.fiveHourResetsAt = fiveHour.resetsAt;
113
127
  p.nearFiveHourLimit = fiveHour.nearLimit;
114
128
  }
115
- if (reading?.pacing) {
129
+ const paced = pacedReading(reading, p.pacingWindow);
130
+ if (paced) {
116
131
  // Provider-truth path (M1/M2)
117
132
  p.meterSource = reading.source; // live | cache | stale
118
- p.usedPct = reading.pacing.usedPct;
119
- p.elapsedPct = reading.pacing.elapsedPct;
120
- p.pace = reading.pacing.surplus; // surplus = elapsed − used
121
- p.paceResetsAt = reading.pacing.resetsAt;
133
+ p.usedPct = paced.pacing.usedPct;
134
+ p.elapsedPct = paced.pacing.elapsedPct;
135
+ p.pace = paced.pacing.surplus; // surplus = elapsed − used
136
+ p.paceResetsAt = paced.pacing.resetsAt;
137
+ p.pacingWindow = paced.window;
122
138
  p.burstGate = reading.burstGate === true;
123
139
  p.meterSnapshot = reading.snapshot ?? null;
124
140
  } else {
@@ -139,6 +155,28 @@ export function buildPools(bullswarmDir, now = Date.now(), readings = {}) {
139
155
  return { state, connectors, pools };
140
156
  }
141
157
 
158
+ /**
159
+ * The window of a reading that paces this pool, and its name.
160
+ *
161
+ * `reading.windows` carries every window the provider reported, so the choice
162
+ * is made here rather than re-deriving it: 'monthly' takes monthly and falls
163
+ * back to weekly, anything else keeps the historical weekly-first order.
164
+ * A reading assembled without `windows` (a hand-built one, or an older code
165
+ * path) still carries `pacing`, which is used as-is — its window name is
166
+ * whatever the producer labeled, else the pool's declaration.
167
+ *
168
+ * @returns {{pacing: object, window: 'weekly'|'monthly'|null}|null}
169
+ */
170
+ function pacedReading(reading, pacingWindow) {
171
+ if (!reading) return null;
172
+ if (reading.windows) {
173
+ const chosen = pickPacingWindow(reading.windows, pacingWindow);
174
+ if (chosen.pacing) return chosen;
175
+ }
176
+ if (!reading.pacing) return null;
177
+ return { pacing: reading.pacing, window: reading.pacingWindow ?? pacingWindow ?? null };
178
+ }
179
+
142
180
  /**
143
181
  * 5h window fields from a meter reading. Prefers the flat fields paceSnapshot
144
182
  * produces and falls back to the raw snapshot, so a reading assembled by an
@@ -74,8 +74,9 @@ export function attachForecast(pools, bullswarmDir, opts = {}) {
74
74
  }
75
75
 
76
76
  /**
77
- * The flat surplus points charged per in-flight agent when no weekly spend
78
- * rate is known, from core state when an operator configured one.
77
+ * The flat surplus points charged per in-flight agent when no spend rate is
78
+ * known for the pool's pacing window, from core state when an operator
79
+ * configured one.
79
80
  * `config.inflightPenaltyPct: 0` disables the tie-breaker entirely; anything
80
81
  * unusable falls back to route.js's documented default.
81
82
  */
package/src/lib/route.js CHANGED
@@ -45,8 +45,9 @@ export const LANES = ['analyze', 'build', 'chore'];
45
45
  export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
46
46
 
47
47
  /**
48
- * Surplus points charged per in-flight agent when no weekly spend rate is
49
- * known for the pool. It is a tie-breaker, not a measurement: three points is
48
+ * Surplus points charged per in-flight agent when no spend rate is known for
49
+ * the pool's pacing window (or, failing that, its weekly one). It is a
50
+ * tie-breaker, not a measurement: three points is
50
51
  * under a third of INCUMBENCY_MARGIN, so it separates pools of similar pace
51
52
  * without ever overturning a real quota difference. Callers override it with
52
53
  * opts.inflightPenaltyPct.
@@ -140,30 +141,37 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
140
141
  }
141
142
 
142
143
  /**
143
- * Weekly cost of the work a pool is already carrying plus the work being
144
- * routed to it (R8 rule c). The result is subtracted from the pace surplus so
145
- * that, between pools of similar pace, the quieter one wins.
144
+ * Pacing-window cost of the work a pool is already carrying plus the work
145
+ * being routed to it (R8 rule c). The result is subtracted from the pace
146
+ * surplus so that, between pools of similar pace, the quieter one wins.
147
+ *
148
+ * The rate is read from `spend.pacing` — the rate for the window this pool is
149
+ * actually paced by — and falls back to `spend.weekly` when no pacing rate is
150
+ * known (a pool paced monthly with no monthly rate, or a producer that
151
+ * attached only the weekly one). Charging a weekly rate against a monthly
152
+ * surplus would compare points from two different windows.
146
153
  *
147
154
  * Two bases, and the larger one is charged:
148
- * 1. a known weekly rate: rate × each in-flight record's remainingMinutes,
155
+ * 1. a known rate: rate × each in-flight record's remainingMinutes,
149
156
  * plus rate × candidateMinutes — real projected percentage points (an
150
157
  * in-flight agent whose remaining minutes nobody recorded is charged
151
158
  * inflightPenaltyPct instead);
152
159
  * 2. the floor: inflightPenaltyPct per in-flight agent — a documented flat
153
160
  * default, labeled `penalty` so no reader mistakes it for a measurement.
154
- * The floor exists because at real weekly rates (about 0.05 points per
155
- * worker-minute) a six-minute agent projects to under a point, which cannot
156
- * spread a burst across a pace gap of a few points; the measured projection
157
- * only ever raises the charge above the floor.
161
+ * The floor exists because at real subscription-window rates (about 0.05
162
+ * points per worker-minute) a six-minute agent projects to under a point,
163
+ * which cannot spread a burst across a pace gap of a few points; the measured
164
+ * projection only ever raises the charge above the floor.
158
165
  *
159
166
  * Only `inflight.records[].remainingMinutes` is read: `inflight.minutes` is
160
167
  * elapsed worker-minutes (src/lib/assignments.js attachInflight), which says
161
168
  * nothing about the quota still to be spent.
162
169
  *
163
170
  * estimateSource: `none` (nothing to charge), `penalty` (the flat floor set the
164
- * charge), or the producer's own `spend.weekly.source` label (`history` /
165
- * `bootstrap`) when the measured projection exceeded the floor; null when a
166
- * rate was used but the producer labeled no provenance for it.
171
+ * charge), or the source label of the rate that was used (`history` /
172
+ * `bootstrap`, from `spend.pacing` or `spend.weekly`) when the measured
173
+ * projection exceeded the floor; null when a rate was used but the producer
174
+ * labeled no provenance for it.
167
175
  *
168
176
  * @returns {{count: number, penalty: number, ratePerMinute: number|null,
169
177
  * estimateSource: string|null}}
@@ -174,13 +182,16 @@ export function inflightLoad(pool, opts = {}) {
174
182
  inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT,
175
183
  } = opts;
176
184
  const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
177
- const rate = num(pool?.spend?.weekly?.ratePerMinute);
185
+ // The pacing window's rate, or the weekly one when that window has no
186
+ // measured rate — the surplus and the penalty stay on the same window.
187
+ const paced = num(pool?.spend?.pacing?.ratePerMinute) != null
188
+ ? pool.spend.pacing
189
+ : pool?.spend?.weekly ?? null;
190
+ const rate = num(paced?.ratePerMinute);
178
191
  const minutes = num(candidateMinutes);
179
192
  const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
180
193
  const sourceLabel =
181
- typeof pool?.spend?.weekly?.source === 'string' && pool.spend.weekly.source
182
- ? pool.spend.weekly.source
183
- : null;
194
+ typeof paced?.source === 'string' && paced.source ? paced.source : null;
184
195
 
185
196
  if (rate == null) {
186
197
  return {
@@ -238,8 +249,10 @@ export function isExhausted(pool) {
238
249
  * attached by the caller when it tracks them:
239
250
  * inflight {count, minutes, records:[{remainingMinutes}]},
240
251
  * spend {fiveHour:{ratePerMinute, source},
241
- * weekly:{ratePerMinute, source}}, projectedFiveHourPct,
242
- * projectedWeeklyPct.
252
+ * weekly:{...}, monthly:{...},
253
+ * pacing:{window, ratePerMinute, source}},
254
+ * pacingWindow, projectedFiveHourPct,
255
+ * projectedWeeklyPct, projectedPacingPct.
243
256
  * @param {object} [opts] { callerEligible=true, callerName='claude', now,
244
257
  * requiredCapabilities, preferredPool, effortTier,
245
258
  * callerSession, candidateMinutes=null (expected minutes
@@ -333,6 +346,10 @@ export function pickPool(lane, pools, opts = {}) {
333
346
  projectedFiveHourPct: e.forecast.projected,
334
347
  forecastFiveHourPct: e.forecast.forecast == null ? null : tenth(e.forecast.forecast),
335
348
  projectedWeeklyPct: num(e.pool.projectedWeeklyPct),
349
+ // The window this pool is paced by, and the projection in it. Equal to
350
+ // the weekly pair for every pool that declares no monthly quota window.
351
+ pacingWindow: e.pool.pacingWindow ?? null,
352
+ projectedPacingPct: num(e.pool.projectedPacingPct),
336
353
  ratePerMinute: e.forecast.ratePerMinute,
337
354
  estimateSource: e.load.estimateSource,
338
355
  nearFiveHourLimit: e.tier === 1,
package/src/lib/spend.js CHANGED
@@ -15,6 +15,8 @@
15
15
 
16
16
  import {
17
17
  WINDOW_KEYS,
18
+ monthlyWindowMs,
19
+ normalizePacingWindow,
18
20
  projectedUtilization,
19
21
  } from '../meters/framework.js';
20
22
  import { readMeterHistory } from '../meters/registry.js';
@@ -311,9 +313,13 @@ function rateForWindow(meta, { history, snapshot, workerMinutesBetween, nowMs })
311
313
  // 2. Bootstrap: the whole window so far — current utilization over the
312
314
  // worker-minutes dispatched since the window opened (M2: the start comes
313
315
  // from the provider's resets_at, never from a locally assumed start).
316
+ // A window with no constant length (monthly) takes its length from that
317
+ // same resets_at — the calendar month ending on it — so the start is
318
+ // still the provider's, never a 30-day assumption.
314
319
  const resetsAtMs = num(Date.parse(latest?.resetsAt ?? ''));
315
- if (latest && resetsAtMs != null && latest.usedPct > 0 && minutesBetween) {
316
- const minutes = num(minutesBetween(resetsAtMs - meta.windowMs, nowMs));
320
+ const windowMs = meta.windowMs ?? (resetsAtMs != null ? monthlyWindowMs(resetsAtMs) : null);
321
+ if (latest && resetsAtMs != null && num(windowMs) != null && latest.usedPct > 0 && minutesBetween) {
322
+ const minutes = num(minutesBetween(resetsAtMs - windowMs, nowMs));
317
323
  if (minutes != null && minutes >= MIN_RATE_MINUTES) {
318
324
  return {
319
325
  ratePerMinute: round(latest.usedPct / minutes, 6),
@@ -336,7 +342,7 @@ function rateForWindow(meta, { history, snapshot, workerMinutesBetween, nowMs })
336
342
  * @param {{history?: Array<object>,
337
343
  * workerMinutesBetween?: (fromMs: number, toMs: number) => number,
338
344
  * nowMs?: number, snapshot?: object|null}} [opts]
339
- * @returns {{fiveHour: object, weekly: object}} each
345
+ * @returns {{fiveHour: object, weekly: object, monthly: object}} each
340
346
  * `{ratePerMinute, source: 'history'|'bootstrap'|null, samples, windowUsedPct}`
341
347
  */
342
348
  export function spendRateFor(pool, opts = {}) {
@@ -374,10 +380,10 @@ function historyResolver({ history = null, readHistory = null, historyFor = null
374
380
  /**
375
381
  * Current utilization for one window of a pool view.
376
382
  *
377
- * The weekly branch reads the rate's own window utilization only: no producer
378
- * has ever written `pool.weeklyUsedPct` — buildPools writes `usedPct` (the
379
- * pacing window) and `fiveHourUsedPct` — so reading it first only made the
380
- * fall-through look conditional when it never was.
383
+ * The weekly and monthly branches read the rate's own window utilization
384
+ * only: no producer has ever written `pool.weeklyUsedPct` — buildPools writes
385
+ * `usedPct` (the pacing window) and `fiveHourUsedPct` — so reading it first
386
+ * only made the fall-through look conditional when it never was.
381
387
  */
382
388
  function currentUsedPct(pool, key, rate) {
383
389
  if (key === 'fiveHour') return num(pool?.fiveHourUsedPct) ?? rate.windowUsedPct ?? null;
@@ -387,8 +393,13 @@ function currentUsedPct(pool, key, rate) {
387
393
  /**
388
394
  * Attach the spend model to pool views in place:
389
395
  *
390
- * pool.spend.fiveHour / pool.spend.weekly = {ratePerMinute, source, samples}
391
- * pool.projectedFiveHourPct / pool.projectedWeeklyPct
396
+ * pool.spend.fiveHour / .weekly / .monthly = {ratePerMinute, source, samples}
397
+ * pool.spend.pacing = {window, ratePerMinute, source, samples} — the same
398
+ * numbers for the window that PACES this pool (`pool.pacingWindow`,
399
+ * default weekly), so the surplus routing compares and the load it
400
+ * charges are measured in the same window.
401
+ * pool.projectedFiveHourPct / pool.projectedWeeklyPct /
402
+ * pool.projectedMonthlyPct / pool.projectedPacingPct
392
403
  * = current utilization + rate × the remaining minutes of the pool's
393
404
  * in-flight work (this candidate is NOT included — src/lib/route.js
394
405
  * adds the assignment being routed on top).
@@ -428,20 +439,29 @@ export function attachSpend(pools, opts = {}) {
428
439
  remainingMinutes += remainingMinutesOf(record, nowMs) ?? 0;
429
440
  }
430
441
 
442
+ // The window this pool is paced by decides which rate routing charges.
443
+ // Default weekly: that is what every pool was paced by before 0.28.1, so
444
+ // a pool that declares nothing keeps exactly its old numbers.
445
+ const pacingWindow = normalizePacingWindow(pool?.pacingWindow) ?? 'weekly';
446
+ const rateOf = (key) => ({
447
+ ratePerMinute: rates[key].ratePerMinute,
448
+ source: rates[key].source,
449
+ samples: rates[key].samples,
450
+ });
451
+
431
452
  pool.spend = {
432
- fiveHour: {
433
- ratePerMinute: rates.fiveHour.ratePerMinute,
434
- source: rates.fiveHour.source,
435
- samples: rates.fiveHour.samples,
436
- },
437
- weekly: {
438
- ratePerMinute: rates.weekly.ratePerMinute,
439
- source: rates.weekly.source,
440
- samples: rates.weekly.samples,
441
- },
453
+ fiveHour: rateOf('fiveHour'),
454
+ weekly: rateOf('weekly'),
455
+ monthly: rateOf('monthly'),
456
+ pacing: { window: pacingWindow, ...rateOf(pacingWindow) },
442
457
  };
443
458
 
444
- for (const [key, field] of [['fiveHour', 'projectedFiveHourPct'], ['weekly', 'projectedWeeklyPct']]) {
459
+ for (const [key, field] of [
460
+ ['fiveHour', 'projectedFiveHourPct'],
461
+ ['weekly', 'projectedWeeklyPct'],
462
+ ['monthly', 'projectedMonthlyPct'],
463
+ [pacingWindow, 'projectedPacingPct'],
464
+ ]) {
445
465
  const projection = projectedUtilization({
446
466
  usedPct: currentUsedPct(pool, key, rates[key]),
447
467
  ratePerMinute: rates[key].ratePerMinute,
@@ -7,6 +7,7 @@ import { modelProfile } from './usage.js';
7
7
  import { openRouterMetadata } from './openrouter-models.js';
8
8
  import { isReasoningLevel, REASONING_LEVELS, resolveReasoningLevel } from './reasoning.js';
9
9
  import { attemptWindow } from './spend.js';
10
+ import { pacingWindowFor } from '../meters/framework.js';
10
11
  // The canonical lane/effort tables. Imported, never restated: see
11
12
  // TIER_CONTEXTS below for the tier -> lane derivation they feed.
12
13
  import { DEFAULT_EFFORT_BY_LANE, KIND_DEFAULTS } from '../workflow/action-validator.js';
@@ -550,7 +551,12 @@ function subscriptionView(pool, state) {
550
551
  includedValueUsd,
551
552
  valueMultiple: monthlyPriceUsd > 0 && includedValueUsd != null
552
553
  ? Math.round((includedValueUsd / monthlyPriceUsd) * 100) / 100 : null,
554
+ // `quotaWindow` is the label as declared (it may name several windows,
555
+ // e.g. "weekly+monthly+5h"); `pacingWindow` is the one window routing
556
+ // actually paces this pool by — see src/meters/framework.js.
553
557
  quotaWindow: declared.quotaWindow ?? pool.connector?.meter?.window ?? null,
558
+ pacingWindow: pool.pacingWindow
559
+ ?? pacingWindowFor({ connector, subscription: state.strategy?.subscriptions?.[pool.name] }),
554
560
  quota: monthlyQuota,
555
561
  meterSource: pool.meterSource ?? 'none',
556
562
  usedPct: pool.usedPct ?? null,
@@ -9,7 +9,11 @@
9
9
  // M3. Weekly/monthly windows pace routing; 5h windows are gates only
10
10
  // (they never pace): >= BURST_BLOCK_PCT blocks dispatch outright and
11
11
  // >= FIVE_HOUR_NEAR_LIMIT_PCT deprioritizes the pool while any pool
12
- // with 5h headroom is eligible.
12
+ // with 5h headroom is eligible. WHICH of weekly/monthly paces one pool
13
+ // is the pool's own subscription window (`quotaWindow`), not a global
14
+ // preference: command-code buys a monthly credit allocation and only
15
+ // rate-limits weekly, so pacing it by its weekly window sends work to a
16
+ // pool whose real budget is already overspent.
13
17
  // M4. Readers fail closed: an unreadable response is an error, not a
14
18
  // zero. A stale cached reading is shown with its age.
15
19
  // M5. Auth tokens are read from each CLI's native store; refresh
@@ -41,21 +45,69 @@ export function windowPace({ usedPct, resetsAtMs, windowMs, nowMs = Date.now() }
41
45
  };
42
46
  }
43
47
 
48
+ /** The two windows that may pace a pool. 5h is never one of them (M3). */
49
+ export const PACING_WINDOWS = ['weekly', 'monthly'];
50
+
51
+ /**
52
+ * A quota-window label as pacing understands it, or null.
53
+ *
54
+ * Labels are free text on disk — `strategy set-subscription --quota-window`
55
+ * has always written whatever it was handed, and connectors describe meters
56
+ * with strings like "weekly+monthly+5h". Anything that is not exactly one
57
+ * pacing window is null: unknown, so pacing keeps its default order rather
58
+ * than guessing which window an operator meant.
59
+ */
60
+ export function normalizePacingWindow(value) {
61
+ if (typeof value !== 'string') return null;
62
+ const name = value.trim().toLowerCase();
63
+ return PACING_WINDOWS.includes(name) ? name : null;
64
+ }
65
+
66
+ /**
67
+ * The window a pool's quota actually lives in: the operator's stored
68
+ * subscription first (`state.strategy.subscriptions[pool].quotaWindow`), then
69
+ * the connector's declaration (`connector.subscription.quotaWindow`).
70
+ *
71
+ * Precedence is by VALUE, not by validity: a stored label the operator set
72
+ * wins over the connector's even when it is unrecognised, and an unrecognised
73
+ * label resolves to null (today's default order) rather than silently falling
74
+ * through to a window the operator did not choose. `strategy set-subscription`
75
+ * now rejects labels that are neither, so only pre-0.28.1 state can hold one.
76
+ *
77
+ * @param {{connector?: object|null, subscription?: object|null}} [pool]
78
+ * @returns {'weekly'|'monthly'|null}
79
+ */
80
+ export function pacingWindowFor({ connector = null, subscription = null } = {}) {
81
+ const declared = subscription?.quotaWindow ?? connector?.subscription?.quotaWindow ?? null;
82
+ return normalizePacingWindow(declared);
83
+ }
84
+
44
85
  /**
45
86
  * Pace a snapshot per doctrine M3:
46
- * - pacing window = weekly ?? monthly ?? none (never 5h)
87
+ * - pacing window = the pool's own subscription window when it declares one
88
+ * ('monthly' → monthly ?? weekly, 'weekly' → weekly ?? monthly), else the
89
+ * default order weekly ?? monthly ?? none. Never 5h.
47
90
  * - burst gate = 5h utilization >= BURST_BLOCK_PCT blocks dispatch
48
91
  * - near limit = 5h utilization >= FIVE_HOUR_NEAR_LIMIT_PCT: still
49
92
  * dispatchable, but routing prefers any pool with 5h headroom
93
+ *
94
+ * `pacingWindow` on the result names the window the numbers actually came
95
+ * from ('weekly' | 'monthly' | null) — which is the requested one only when
96
+ * the provider reported it.
97
+ *
98
+ * @param {object|null} snapshot
99
+ * @param {number} [nowMs]
100
+ * @param {{pacingWindow?: string|null}} [opts]
50
101
  */
51
102
  export const BURST_BLOCK_PCT = 90;
52
103
  /** 5h utilization at/above which routing treats a pool as near its limit. */
53
104
  export const FIVE_HOUR_NEAR_LIMIT_PCT = 75;
54
105
 
55
- export function paceSnapshot(snapshot, nowMs = Date.now()) {
106
+ export function paceSnapshot(snapshot, nowMs = Date.now(), opts = {}) {
56
107
  if (!snapshot) {
57
108
  return {
58
109
  pacing: null,
110
+ pacingWindow: null,
59
111
  burstGate: false,
60
112
  windows: {},
61
113
  fiveHourUsedPct: null,
@@ -81,7 +133,7 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
81
133
  });
82
134
  }
83
135
 
84
- const pacing = windows.seven_day ?? windows.monthly ?? null;
136
+ const chosen = pickPacingWindow(windows, opts.pacingWindow);
85
137
  const fiveHourUsed = snapshot.five_hour?.utilization;
86
138
  const fiveHourUsedPct = Number.isFinite(fiveHourUsed) ? fiveHourUsed : null;
87
139
  const burstGate = fiveHourUsedPct != null && fiveHourUsedPct >= BURST_BLOCK_PCT;
@@ -92,7 +144,8 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
92
144
  : NaN;
93
145
 
94
146
  return {
95
- pacing,
147
+ pacing: chosen.pacing,
148
+ pacingWindow: chosen.window,
96
149
  burstGate,
97
150
  windows,
98
151
  fiveHourUsedPct,
@@ -104,15 +157,38 @@ export function paceSnapshot(snapshot, nowMs = Date.now()) {
104
157
  };
105
158
  }
106
159
 
160
+ /**
161
+ * The paced window out of a `windows` map, honouring the pool's declared
162
+ * window and falling back to the other one when the provider did not report
163
+ * the declared one (a reading with only a weekly window still paces).
164
+ *
165
+ * @param {{seven_day?: object|null, monthly?: object|null}} windows
166
+ * @param {string|null} [requested]
167
+ * @returns {{pacing: object|null, window: 'weekly'|'monthly'|null}}
168
+ */
169
+ export function pickPacingWindow(windows = {}, requested = null) {
170
+ const order = normalizePacingWindow(requested) === 'monthly'
171
+ ? [['monthly', 'monthly'], ['seven_day', 'weekly']]
172
+ : [['seven_day', 'weekly'], ['monthly', 'monthly']];
173
+ for (const [key, name] of order) {
174
+ const pacing = windows?.[key] ?? null;
175
+ if (pacing) return { pacing, window: name };
176
+ }
177
+ return { pacing: null, window: null };
178
+ }
179
+
107
180
  /**
108
181
  * Window names the spend model works in, mapped to where each one lives.
109
182
  * - `snapshot`: the key a provider reading uses (`seven_day` for weekly)
110
183
  * - `history`: the key a history line uses (`weekly`)
111
- * - `windowMs`: the window length, for deriving its start from resets_at (M2)
184
+ * - `windowMs`: the window length, for deriving its start from resets_at
185
+ * (M2) — null for the monthly window, whose length is the calendar month
186
+ * ending at the provider's resets_at (monthlyWindowMs), not a constant.
112
187
  */
113
188
  export const WINDOW_KEYS = {
114
189
  fiveHour: { snapshot: 'five_hour', history: 'five_hour', windowMs: WINDOW_MS['5h'] },
115
190
  weekly: { snapshot: 'seven_day', history: 'weekly', windowMs: WINDOW_MS.weekly },
191
+ monthly: { snapshot: 'monthly', history: 'monthly', windowMs: null },
116
192
  };
117
193
 
118
194
  /**
@@ -1,6 +1,7 @@
1
1
  import { loadState, updateState } from './lib/state.js';
2
2
  import { loadConnectors, buildPools, buildPoolsLive } from './lib/config.js';
3
3
  import { getAllMeterReadings } from './meters/registry.js';
4
+ import { PACING_WINDOWS } from './meters/framework.js';
4
5
  import {
5
6
  discoverAllModels, buildStrategy, normalizeExcludedModels, resolveDispatchModel,
6
7
  selectedModelsForTier, setModelTierSelection, STRATEGY_TIERS,
@@ -60,6 +61,25 @@ function numberOrNull(value, label) {
60
61
  return n;
61
62
  }
62
63
 
64
+ /**
65
+ * `--quota-window` is no longer a free-text label: it selects the window that
66
+ * PACES this pool (src/meters/framework.js pacingWindowFor), so a value
67
+ * pacing cannot act on is refused instead of being stored and ignored.
68
+ * `unknown`/`null` clears it, exactly like the price flags, which is the way
69
+ * back for a pre-0.28.1 label that is neither window.
70
+ */
71
+ function quotaWindowValue(value) {
72
+ if (value === undefined) return undefined;
73
+ if (value === 'unknown' || value === 'null') return null;
74
+ const name = typeof value === 'string' ? value.trim().toLowerCase() : '';
75
+ if (!PACING_WINDOWS.includes(name)) {
76
+ throw new Error(
77
+ `--quota-window must be ${PACING_WINDOWS.join(' or ')} (or unknown to clear)`,
78
+ );
79
+ }
80
+ return name;
81
+ }
82
+
63
83
  function refreshHoursValue(value) {
64
84
  const hours = Number(value ?? 24);
65
85
  if (!Number.isFinite(hours) || hours <= 0) throw new Error('refresh-hours must be a positive number');
@@ -72,7 +92,10 @@ function render(report, reasoning = null) {
72
92
  const value = sub.monthlyPriceUsd == null || sub.includedValueUsd == null
73
93
  ? 'value unknown'
74
94
  : `$${sub.monthlyPriceUsd}/mo → ~$${sub.includedValueUsd} included (${sub.valueMultiple}×)`;
75
- lines.push(` ${sub.pool}: ${sub.plan ?? 'plan unknown'} · ${value} · ${sub.usedPct ?? '?'}% used · surplus ${sub.surplus ?? '?'}`);
95
+ // Which window paces this pool is the difference between "behind" and
96
+ // "overspent" for the same reading, so the line names it.
97
+ const paced = sub.pacingWindow ? `${sub.pacingWindow} ` : '';
98
+ lines.push(` ${sub.pool}: ${sub.plan ?? 'plan unknown'} · ${value} · ${paced}${sub.usedPct ?? '?'}% used · surplus ${sub.surplus ?? '?'}`);
76
99
  }
77
100
  lines.push('', 'tier suggestions:');
78
101
  for (const [tier, suggestion] of Object.entries(report.suggestions)) {
@@ -378,6 +401,9 @@ export function strategyInventory({ pools, state, report, evidence = null }) {
378
401
  enabled: pool.enabled !== false,
379
402
  usedPct: pool.usedPct ?? null,
380
403
  surplus: pool.pace ?? null,
404
+ // The window `usedPct` and `surplus` are measured in, and that routing
405
+ // paces this pool by (weekly | monthly | null = weekly-first default).
406
+ pacingWindow: pool.pacingWindow ?? null,
381
407
  // How many agents this pool is running right now, across every
382
408
  // Bullswarm process — the same count `bullswarm pools` reports.
383
409
  inflight: pool.inflight?.count ?? 0,
@@ -487,6 +513,9 @@ export async function loadStrategyInventory(bullswarmDir, {
487
513
  pool.usedPct = live.usedPct;
488
514
  pool.pace = live.surplus;
489
515
  pool.meterSource = live.meterSource;
516
+ // The report's numbers came from a window; carry its name with them so
517
+ // the inventory view does not label them with a different one.
518
+ pool.pacingWindow = live.pacingWindow ?? pool.pacingWindow ?? null;
490
519
  }
491
520
  // The control center previews real routing, so it reads the same live
492
521
  // in-flight ledger and spend rates every dispatch path does.
@@ -878,6 +907,7 @@ export async function cmdStrategy(args, {
878
907
  // Flag validation first, so a bad number fails before any lock is taken.
879
908
  const monthlyPriceUsd = numberOrNull(opts['monthly-usd'], 'monthly-usd');
880
909
  const includedValueUsd = numberOrNull(opts['included-usd'], 'included-usd');
910
+ const quotaWindow = quotaWindowValue(opts['quota-window']);
881
911
  const state = updateState(bullswarmDir, (fresh) => {
882
912
  fresh.strategy ??= {};
883
913
  fresh.strategy.subscriptions ??= {};
@@ -887,7 +917,7 @@ export async function cmdStrategy(args, {
887
917
  ...(opts.plan !== undefined ? { plan: opts.plan } : {}),
888
918
  ...(monthlyPriceUsd !== undefined ? { monthlyPriceUsd } : {}),
889
919
  ...(includedValueUsd !== undefined ? { includedValueUsd } : {}),
890
- ...(opts['quota-window'] !== undefined ? { quotaWindow: opts['quota-window'] } : {}),
920
+ ...(quotaWindow !== undefined ? { quotaWindow } : {}),
891
921
  };
892
922
  delete fresh.strategy.lastReport;
893
923
  });
@@ -973,7 +1003,7 @@ export async function cmdStrategy(args, {
973
1003
  throw new Error(strategyUsage());
974
1004
  } catch (err) {
975
1005
  console.error(`✗ ${err.message}`);
976
- const usage = /^(usage:|missing |assignment needs |--apply changes|(?:strategy )?(?:apply|auto off|configure|set-provider|set-model|reset-tier|set-reasoning|reset-reasoning) changes|--tiers? must be|--level must be|--reasoning must be|reasoning(?:\.|\s)|refresh-hours must be|.* must be a non-negative number|unknown phase|unknown command|unknown pool|unknown tier|unknown model)/i.test(err.message);
1006
+ const usage = /^(usage:|missing |assignment needs |--apply changes|(?:strategy )?(?:apply|auto off|configure|set-provider|set-model|reset-tier|set-reasoning|reset-reasoning) changes|--tiers? must be|--level must be|--reasoning must be|--quota-window must be|reasoning(?:\.|\s)|refresh-hours must be|.* must be a non-negative number|unknown phase|unknown command|unknown pool|unknown tier|unknown model)/i.test(err.message);
977
1007
  return usage ? 2 : 1;
978
1008
  }
979
1009
  }
@@ -27,7 +27,11 @@ import { deserializeV2ResultEnvelope, summarizeV2Result } from './v2-outcome.js'
27
27
  import { helpText, usageLine } from '../help.js';
28
28
  import { flagName, unknownFlagExit } from '../lib/cli-flags.js';
29
29
 
30
- function jsonOut(obj, opts) { if (opts.json || opts.summary) console.log(JSON.stringify(obj, null, 2)); }
30
+ function jsonOut(obj, opts) {
31
+ if (!(opts.json || opts.summary)) return;
32
+ // Summary is budgeted against compact JSON.stringify; --json alone stays pretty.
33
+ console.log(opts.summary ? JSON.stringify(obj) : JSON.stringify(obj, null, 2));
34
+ }
31
35
  function err(msg, code = 1) { console.error(msg); return code; }
32
36
 
33
37
  // Every command that would drive a legacy run answers with the same sentence
@@ -225,7 +225,7 @@ export async function dispatchV2Action({
225
225
  };
226
226
  const coreDecisionLog = () => safeCoreState()?.decisionLog ?? [];
227
227
  // Operator-configurable, read once: the flat surplus cost of an in-flight
228
- // agent on a pool whose weekly spend rate nobody has measured yet.
228
+ // agent on a pool whose pacing-window spend rate nobody has measured yet.
229
229
  const inflightPenaltyPct = inflightPenaltyFrom(safeCoreState());
230
230
  // One expectation for the whole action: lane and effort do not change
231
231
  // between attempts, and the spend model is memoized per process anyway. The