bullswarm 0.28.5 → 0.28.7

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,55 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.28.7 — pools about to reset spend their leftover first
4
+
5
+ - routing: quota that expires sooner is worth more. A pool whose pacing window
6
+ resets within 24 hours (weekly) or 3 days (monthly) is now ranked on urgency
7
+ — its effective surplus divided by the fraction of the window still to run —
8
+ ahead of every pool whose window is not about to close, instead of on the
9
+ surplus alone. At 2026-09-11 12:26 HKT the medium lane went to
10
+ `claude-code:wati` (+22.9 points, 13h33m and 8.1% of its week left) over
11
+ `grok` (+13.8 points, 2h02m and 1.2% left, urgency ~1150 against wati's
12
+ ~283), and grok's points expired unspent two hours later; the owner had been
13
+ pinning grok by hand for such runs. Three states for an expiring pool:
14
+ `urgent` (surplus still to spend and a pacing forecast — the reading plus
15
+ in-flight work plus this candidate, each clipped at the reset — below 95%,
16
+ with 5 points of extra headroom demanded when no spend rate was measured),
17
+ which outranks incumbency and a configured effort assignment but never a
18
+ strict pin or the 5-hour rules; `draining` (forecast at/above 95%), ranked
19
+ behind every normal pool so a window about to be emptied is not fed one more
20
+ run; and `normal` (on or ahead of pace), ranked exactly as today. A pool with
21
+ no parsable `paceResetsAt`, a reset already past, or any other window is
22
+ never expiring soon and nothing about it changes. `bullswarm pools` ends an
23
+ expiring pool's line with `resets in 2h02m EXPIRING-SOON urgency=1150`, and
24
+ each routing candidate row carries `paceResetsInMinutes`, `expiringSoon`,
25
+ `urgency`, `forecastPacingPct` and `urgencyState`.
26
+
27
+ ## 0.28.6 — the 5-hour near-limit line reads the clock
28
+
29
+ - routing: the 5-hour near-limit line is now clock-relative. A pool is
30
+ deprioritized only when its 5h forecast is at/above 75% AND ahead of the
31
+ share of the 5h window that has already elapsed. On 2026-09-10 at 22:19Z a
32
+ high-tier integrator skipped `claude-code:wati` (81% used, 23 minutes to the
33
+ reset — 92.3% of the window elapsed, 88.1% projected) and
34
+ `claude-code:petsona` (75.3% projected, 85.7% elapsed) and went to the one
35
+ account already ahead of its weekly pace, while wati still held 34% of its
36
+ weekly quota unspent with 13% of the week left to spend it. Both pools now keep the lane:
37
+ 88.1% with 23 minutes left is a pool spending at the clock's pace, not a pool
38
+ about to hit a wall. Pools with no `resets_at`, an unparsable one, or a reset
39
+ already past keep the fixed 75% line, and the 90% burst gate is unchanged —
40
+ it ignores the clock. The routing reason and the `candidates[]` rows say
41
+ which case applied: `5h used 81% -> 88.1% projected, under the clock (92.3%
42
+ elapsed)`, `skipped near 5h limit (projected): claude-code:wati 88.1% (20.0%
43
+ elapsed)`, and a new `fiveHourElapsedPct` field. `bullswarm pools` shows the
44
+ same clock: `5h=81% (92% elapsed)`.
45
+ - routing: 5-hour spend is clipped at the reset. Quota spent after the window
46
+ rolls over lands in the next window, so the candidate's minutes and each
47
+ in-flight record's remaining minutes are charged to the 5h forecast only up
48
+ to `fiveHourResetsAt` — 10 minutes from a reset, a 40-minute task at 0.5
49
+ points per minute adds 5 points to the forecast, not 20. The weekly/monthly
50
+ pacing charge is deliberately not clipped: that spend counts against its
51
+ window whichever side of the 5h reset it lands on.
52
+
3
53
  ## 0.28.5 — narrow-terminal detail panes use the whole screen
4
54
 
5
55
  - tui: on a narrow terminal (under 100 columns, e.g. a phone over SSH) the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.28.5",
3
+ "version": "0.28.7",
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": {
package/src/cli.js CHANGED
@@ -3,7 +3,9 @@
3
3
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { homedir, tmpdir } from 'node:os';
6
- import { pickPool } from './lib/route.js';
6
+ import {
7
+ expiringSoonView, fiveHourElapsedPct, formatResetsIn, pickPool,
8
+ } from './lib/route.js';
7
9
  import { argvWithModel, watchOnce } from './lib/watch.js';
8
10
  import {
9
11
  isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
@@ -113,17 +115,29 @@ async function cmdPools(opts) {
113
115
  const projectedPct = p.projectedFiveHourPct == null
114
116
  ? null
115
117
  : Math.round(p.projectedFiveHourPct * 10) / 10;
118
+ // R10: the same reading means different things at different points in the
119
+ // window, so show where the window stands when the provider reported it.
120
+ const elapsed = fiveHourElapsedPct(p);
121
+ const clock = elapsed == null ? '' : ` (${Math.round(elapsed)}% elapsed)`;
116
122
  const fiveHour = readingPct == null
117
- ? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%`)
118
- : ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}`;
123
+ ? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%${clock}`)
124
+ : ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}${clock}`;
119
125
  const nearLimit = p.nearFiveHourLimit === true ? ' NEAR-5H-LIMIT' : '';
126
+ // R11: a pacing window about to reset is quota about to be lost, so say
127
+ // when it closes and how urgent what is left has become. Pools whose
128
+ // window is not closing soon print nothing extra.
129
+ const expiring = expiringSoonView(p, { now });
130
+ const expiringNote = expiring.expiringSoon
131
+ ? ` resets in ${formatResetsIn(expiring.minutesToReset)} EXPIRING-SOON`
132
+ + ` urgency=${Math.round(expiring.urgency)}`
133
+ : '';
120
134
  const status = !p.enabled
121
135
  ? 'disabled'
122
136
  : p.quarantine
123
137
  ? `QUARANTINED until ${new Date(p.quarantine.until).toLocaleTimeString()} (${p.quarantine.reason})`
124
138
  : `ready${burst}${nearLimit}`;
125
139
  console.log(
126
- `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}`,
140
+ `${p.name.padEnd(14)} cost=${p.costRank} lanes=${p.lanes.join('/')} ${meter} surplus=${p.pace ?? '-'} inflight=${p.inflight?.count ?? 0}${fiveHour} ${status}${expiringNote}`,
127
141
  );
128
142
  }
129
143
  return 0;
package/src/help.js CHANGED
@@ -324,11 +324,17 @@ const poolsText = rich({
324
324
  + 'pace surplus, in-flight assignment count, projected 5-hour utilization, and '
325
325
  + 'quarantine/burst-gate status. The 5-hour column reads `5h=<reading>%` alone when '
326
326
  + 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
327
- + 'expected to push the window further; routing decides on the right-hand number.',
327
+ + 'expected to push the window further; routing decides on the right-hand number. '
328
+ + 'A trailing `(<n>% elapsed)` is how much of that 5-hour window has already run: '
329
+ + 'routing only deprioritizes a near-limit pool whose usage is ahead of its clock. '
330
+ + 'A pool whose weekly window resets within 24 hours, or whose monthly window resets '
331
+ + 'within 3 days, ends its line with `resets in <Nd Nh|Nh Nm|Nm> EXPIRING-SOON '
332
+ + 'urgency=<n>`: quota about to be lost, scored as surplus divided by the fraction '
333
+ + 'of the window still to run, which is what routing ranks it on.',
328
334
  args: [],
329
335
  options: [
330
336
  { 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, monthly, pacing} rates with their source and sample count, pacingWindow, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
337
+ { 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, paceResetsAt, and projectedFiveHourPct / projectedWeeklyPct / projectedMonthlyPct / projectedPacingPct', default: 'human-readable aligned table' },
332
338
  ],
333
339
  safety: [
334
340
  'calls each connector\'s live usage meter (network request per metered pool) to compute used/elapsed percentages',
package/src/lib/route.js CHANGED
@@ -15,10 +15,12 @@
15
15
  // R6. A pool at 100% used is exhausted; quarantined pools are ineligible
16
16
  // until their quarantine expires (the re-probe path).
17
17
  // R7. 5h headroom outranks pace: a pool at/above FIVE_HOUR_NEAR_LIMIT_PCT of
18
- // its 5h window is chosen only when no eligible pool below the threshold
19
- // exists for the lane. Like quarantine and burst gates, this outranks an
20
- // explicit assignment and incumbency a near-limit pool that is picked
21
- // anyway spends the run's next attempt on a quota failure.
18
+ // its 5h window AND above that window's elapsed share (R10 the
19
+ // threshold is clock-relative, not a fixed line) is chosen only when no
20
+ // eligible pool below the threshold exists for the lane. Like quarantine
21
+ // and burst gates, this outranks an explicit assignment and incumbency —
22
+ // a near-limit pool that is picked anyway spends the run's next attempt
23
+ // on a quota failure.
22
24
  // R8. Route on the FORECAST, not on the reading. A reading is already old at
23
25
  // the moment it is read: work dispatched seconds ago has spent quota the
24
26
  // meter has not seen, and the assignment being routed will spend more.
@@ -34,8 +36,63 @@
34
36
  // R9. Load beats incumbency: an incumbent carrying more in-flight agents
35
37
  // than a challenger keeps neither its margin nor its cost guard; the
36
38
  // quieter pool wins as soon as its effective surplus is higher.
39
+ // R10. The near-limit line is clock-relative. R7's tier is for a pool that
40
+ // will hit its 5h wall mid-run, and that danger is time-shaped: 88%
41
+ // projected with 23 minutes left in the window is a pool spending at its
42
+ // own pace that is about to be handed a fresh window; 77% with four
43
+ // hours left is a pool heading for the wall. So a pool is deprioritized
44
+ // only when its forecast is at/above FIVE_HOUR_NEAR_LIMIT_PCT *and*
45
+ // above the percentage of the 5h window already elapsed. (Observed
46
+ // 2026-09-10T22:19Z: claude-code:wati, 81% used with 23 minutes left —
47
+ // 92.3% of its window elapsed — was tiered down for a forecast of 88.1%,
48
+ // so a high-tier integrator went to the one account already ahead of its
49
+ // weekly pace while wati's quota, 34% of the week unspent with 13% of
50
+ // the week left, expired unused.) Spend that lands after the reset
51
+ // belongs to the NEXT window: the candidate's minutes and each in-flight
52
+ // record's remaining minutes are clipped at resets_at before they are
53
+ // charged to the 5h forecast — the weekly/monthly pacing penalty is
54
+ // never clipped, that spend does count against its window. No
55
+ // resets_at, an unparsable one, or a reset already in the past means no
56
+ // clock: R7 keeps its fixed line and nothing is clipped, because a pool
57
+ // is never treated differently for a number nobody produced (R8).
58
+ // R11. Quota that expires sooner is worth more ("expiring soon"). A pace
59
+ // surplus is a difference in points and says nothing about how long the
60
+ // pool has left to spend it. (Observed 2026-09-11T04:26Z: grok held
61
+ // +13.8 weekly points with 2h02m left in its week — 1.2% of the window —
62
+ // while claude-code:wati held +22.9 with 13h33m left (8.1%). R2 sent the
63
+ // run to wati on 22.9 > 13.8, and grok's 15 points expired two hours
64
+ // later; the owner had been pinning grok by hand.) So a pool whose
65
+ // PACING window resets within a fixed lead time — EXPIRING_SOON_MS: 24
66
+ // hours weekly, 3 days monthly, the owner's chosen values, about a
67
+ // seventh of a week and a tenth of a month — is ranked on
68
+ // urgency = effective surplus / the fraction of its window still to run
69
+ // (floored at MIN_WINDOW_LEFT_FRACTION so a reset seconds away cannot
70
+ // divide by zero) instead of on the surplus alone. Three states:
71
+ // urgent — surplus still to spend and a pacing forecast (the
72
+ // reading, plus in-flight work and this candidate, each
73
+ // clipped at the pacing reset exactly as R10 clips the 5h
74
+ // window) below PACING_FORECAST_BLOCK_PCT; with no measured
75
+ // rate the reading must also sit 5 points under that line,
76
+ // because an unmeasured pool's forecast is only its
77
+ // reading. Ranks ahead of every pool not expiring soon.
78
+ // draining — forecast at/above the line: ranked after every normal
79
+ // pool and chosen only when nothing else is eligible, so a
80
+ // pool about to be emptied is not fed one more run that
81
+ // would push it over the wall.
82
+ // normal — expiring soon but on or ahead of pace: ranked with
83
+ // everyone else on effective surplus, exactly as today.
84
+ // Urgency outranks incumbency (R3/R4/R9) and a configured effort
85
+ // assignment (preferredPool) by the same mechanism R7's tier uses —
86
+ // selection happens among the urgent pools while one exists — so an
87
+ // urgent challenger needs neither the 10-point margin nor the cost
88
+ // guard. It never overrides a strict pin (workflow strictPool filters
89
+ // the pool list before pickPool ever sees it), and never the 5h rules:
90
+ // a pool tiered down by R7/R10 or gated by R8 is not rescued by
91
+ // urgency. No pacing window, no parsable paceResetsAt, a reset already
92
+ // in the past, or any window other than weekly/monthly means there is
93
+ // no lead time to measure and nothing about the pool changes (R8).
37
94
 
38
- import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT } from '../meters/framework.js';
95
+ import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT, WINDOW_MS } from '../meters/framework.js';
39
96
  // One strict numeric coercion for the whole codebase (src/lib/num.js): a
40
97
  // missing measurement stays null instead of becoming a confident zero.
41
98
  import { finiteOrNull as num } from './num.js';
@@ -54,6 +111,40 @@ export const INCUMBENCY_MARGIN = 10; // surplus points a challenger must beat
54
111
  */
55
112
  export const DEFAULT_INFLIGHT_PENALTY_PCT = 3;
56
113
 
114
+ /**
115
+ * R11 lead times: how close a pacing window's reset has to be before the pool
116
+ * counts as "expiring soon". The owner's chosen values — roughly a seventh of
117
+ * a week and a tenth of a month — long enough that a run dispatched now can
118
+ * still use the quota, short enough that the pool really is about to lose it.
119
+ * Any window that is not one of these keys is never expiring soon.
120
+ */
121
+ export const EXPIRING_SOON_MS = {
122
+ weekly: 24 * 3600_000,
123
+ monthly: 72 * 3600_000,
124
+ };
125
+
126
+ /**
127
+ * Pacing-window forecast at/above which an expiring-soon pool is `draining`
128
+ * rather than `urgent`: its window is about to close AND about to be emptied,
129
+ * so one more run spends the run's next attempt on a quota failure.
130
+ */
131
+ export const PACING_FORECAST_BLOCK_PCT = 95;
132
+
133
+ /**
134
+ * Smallest window-left fraction urgency will divide by (0.5% of the window).
135
+ * A reset thirty seconds away is 0.005% of a week: without a floor the score
136
+ * would be Infinity-shaped and one pool would swallow every lane.
137
+ */
138
+ export const MIN_WINDOW_LEFT_FRACTION = 0.005;
139
+
140
+ /**
141
+ * Points of headroom an UNMEASURED expiring-soon pool needs below
142
+ * PACING_FORECAST_BLOCK_PCT to be called urgent. With no spend rate its
143
+ * forecast is only its reading plus a flat penalty, so the last few points
144
+ * before the line are exactly where that estimate is least trustworthy.
145
+ */
146
+ export const UNMEASURED_URGENT_HEADROOM_PCT = 5;
147
+
57
148
  export function elapsedPct(meter, now = Date.now()) {
58
149
  if (!meter || meter.type === 'none') return 0;
59
150
  const start = meter.windowStart ?? 0;
@@ -100,36 +191,115 @@ function tenth(value) {
100
191
  return Math.round(Number(value) * 10) / 10;
101
192
  }
102
193
 
194
+ /** The 5h window, in minutes — 300. */
195
+ export const FIVE_HOUR_WINDOW_MINUTES = WINDOW_MS['5h'] / 60_000;
196
+
197
+ /**
198
+ * Minutes left before this pool's 5h window resets, from the provider's
199
+ * `fiveHourResetsAt` (src/lib/config.js, straight off the meter reading).
200
+ *
201
+ * null when there is no reading, when it cannot be parsed, or when the reset
202
+ * is already at/behind `now` — an outrun deadline is unknown, not a
203
+ * zero-length window (R8/R10).
204
+ */
205
+ export function minutesUntilFiveHourReset(pool, now = Date.now()) {
206
+ const resetsAtMs = Date.parse(pool?.fiveHourResetsAt ?? '');
207
+ if (!Number.isFinite(resetsAtMs)) return null;
208
+ const minutes = (resetsAtMs - now) / 60_000;
209
+ return minutes > 0 ? minutes : null;
210
+ }
211
+
212
+ /**
213
+ * How much of the 5h window has already elapsed, 0–100, or null when the
214
+ * reset time is unknown (R10). elapsed = 100 × (300 − minutes left) / 300.
215
+ */
216
+ export function fiveHourElapsedPct(pool, now = Date.now()) {
217
+ const left = minutesUntilFiveHourReset(pool, now);
218
+ if (left == null) return null;
219
+ const elapsed = (100 * (FIVE_HOUR_WINDOW_MINUTES - left)) / FIVE_HOUR_WINDOW_MINUTES;
220
+ return Math.max(0, Math.min(100, elapsed));
221
+ }
222
+
223
+ /**
224
+ * In-flight minutes that fall PAST the 5h reset, summed over the records the
225
+ * producer already charged to this window (R10). Only these minutes are
226
+ * credited back from the projection — the rest of the record still spends
227
+ * inside the window being forecast.
228
+ */
229
+ function inflightOverflowMinutes(pool, minutesToReset) {
230
+ const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
231
+ let overflow = 0;
232
+ for (const record of records) {
233
+ const m = num(record?.remainingMinutes);
234
+ if (m == null) continue;
235
+ overflow += Math.max(0, Math.max(0, m) - minutesToReset);
236
+ }
237
+ return overflow;
238
+ }
239
+
103
240
  /**
104
241
  * 5h forecast for one pool and the assignment being routed (R8 rule a):
105
242
  *
106
243
  * forecast = (projectedFiveHourPct ?? fiveHourUsedPct)
107
- * + fiveHour.ratePerMinute × candidateMinutes
244
+ * ratePerMinute × in-flight minutes past the reset
245
+ * + ratePerMinute × min(candidateMinutes, minutes to the reset)
108
246
  *
109
247
  * with the candidate term added only when both of its numbers exist; anything
110
248
  * else falls back to the best available reading, and a pool with no reading
111
249
  * at all forecasts null (unknown — never gated, never deprioritized).
112
250
  *
251
+ * R10 clipping: quota spent after `fiveHourResetsAt` lands in the NEXT 5h
252
+ * window and cannot overflow this one, so the candidate's minutes are clipped
253
+ * at the reset, and the in-flight minutes the producer charged past the reset
254
+ * are credited back out of its projection (never below the pool's own
255
+ * reading). This clip is the 5h forecast's alone — the pacing-window charge in
256
+ * inflightLoad() is deliberately left unclipped, because that spend does count
257
+ * against the weekly/monthly window whichever side of the 5h reset it lands
258
+ * on. With no parsable reset time nothing is clipped at all.
259
+ *
113
260
  * `forecasted` records whether the number is more than the raw reading. Only a
114
261
  * real projection input (a producer-supplied projectedFiveHourPct, or a rate ×
115
262
  * candidateMinutes term) turns a reading into a forecast; a bare reading keeps
116
263
  * exactly its old meaning so nothing changes for callers that attach no model.
117
264
  *
265
+ * `nearLimit` is the raw R7 test (forecast >= FIVE_HOUR_NEAR_LIMIT_PCT);
266
+ * `underClock` is R10's exemption from it — near the limit, but no further
267
+ * into the window's quota than into the window's time.
268
+ *
118
269
  * @param {object} pool
119
270
  * @param {number|null} [candidateMinutes] expected minutes of this assignment
271
+ * @param {number} [now]
120
272
  * @returns {{raw: number|null, projected: number|null,
121
273
  * ratePerMinute: number|null, candidateAdd: number|null,
122
- * forecast: number|null, forecasted: boolean}}
274
+ * forecast: number|null, forecasted: boolean,
275
+ * minutesToReset: number|null, elapsedPct: number|null,
276
+ * inflightCreditPct: number, nearLimit: boolean,
277
+ * underClock: boolean}}
123
278
  */
124
- export function fiveHourForecast(pool, candidateMinutes = null) {
279
+ export function fiveHourForecast(pool, candidateMinutes = null, now = Date.now()) {
125
280
  const raw = num(pool?.fiveHourUsedPct);
126
281
  const projected = num(pool?.projectedFiveHourPct);
127
282
  const ratePerMinute = num(pool?.spend?.fiveHour?.ratePerMinute);
128
283
  const minutes = num(candidateMinutes);
129
- const base = projected ?? raw;
284
+ const minutesToReset = minutesUntilFiveHourReset(pool, now);
285
+ const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
130
286
  const candidateAdd =
131
- ratePerMinute != null && minutes != null ? ratePerMinute * minutes : null;
287
+ ratePerMinute != null && minutes != null ? ratePerMinute * clip(minutes) : null;
288
+ // Credit back only what the producer charged past the reset; a zero credit
289
+ // leaves the projection byte-for-byte what it was before R10.
290
+ const overflowMinutes =
291
+ minutesToReset == null || ratePerMinute == null
292
+ ? 0
293
+ : inflightOverflowMinutes(pool, minutesToReset);
294
+ const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
295
+ const base =
296
+ projected == null ? raw
297
+ : inflightCreditPct > 0
298
+ ? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
299
+ : projected;
132
300
  const forecast = base == null ? null : base + (candidateAdd ?? 0);
301
+ const elapsedPct = fiveHourElapsedPct(pool, now);
302
+ const nearLimit = forecast != null && forecast >= FIVE_HOUR_NEAR_LIMIT_PCT;
133
303
  return {
134
304
  raw,
135
305
  projected,
@@ -137,6 +307,13 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
137
307
  candidateAdd,
138
308
  forecast,
139
309
  forecasted: projected != null || candidateAdd != null,
310
+ minutesToReset,
311
+ elapsedPct,
312
+ inflightCreditPct,
313
+ nearLimit,
314
+ // R10: at/above the line but no further through its quota than through its
315
+ // window — the reset arrives before the wall does.
316
+ underClock: nearLimit && elapsedPct != null && forecast <= elapsedPct,
140
317
  };
141
318
  }
142
319
 
@@ -167,6 +344,12 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
167
344
  * elapsed worker-minutes (src/lib/assignments.js attachInflight), which says
168
345
  * nothing about the quota still to be spent.
169
346
  *
347
+ * These minutes are NOT clipped at the 5h reset (R10). This charge is the
348
+ * pacing window's — weekly or monthly — and an agent still running an hour
349
+ * after the 5h window rolls over goes on spending the same weekly quota. Only
350
+ * the 5h forecast in fiveHourForecast() clips at `fiveHourResetsAt`, and it
351
+ * computes that separately from this number.
352
+ *
170
353
  * estimateSource: `none` (nothing to charge), `penalty` (the flat floor set the
171
354
  * charge), or the source label of the rate that was used (`history` /
172
355
  * `bootstrap`, from `spend.pacing` or `spend.weekly`) when the measured
@@ -184,9 +367,7 @@ export function inflightLoad(pool, opts = {}) {
184
367
  const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
185
368
  // The pacing window's rate, or the weekly one when that window has no
186
369
  // 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;
370
+ const paced = pacingRateBlock(pool);
190
371
  const rate = num(paced?.ratePerMinute);
191
372
  const minutes = num(candidateMinutes);
192
373
  const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
@@ -229,6 +410,201 @@ export function inflightLoad(pool, opts = {}) {
229
410
  return { count, penalty, ratePerMinute: rate, estimateSource };
230
411
  }
231
412
 
413
+ /**
414
+ * The spend block whose rate paces this pool: `spend.pacing` when it carries a
415
+ * measured rate, else `spend.weekly`. Charging a weekly rate against a monthly
416
+ * surplus would compare points from two different windows, so this is the only
417
+ * fallback — and it is the one inflightLoad() has always used, shared here so
418
+ * the pacing forecast (R11) charges the same rate the ranking charges.
419
+ */
420
+ function pacingRateBlock(pool) {
421
+ return num(pool?.spend?.pacing?.ratePerMinute) != null
422
+ ? pool.spend.pacing
423
+ : pool?.spend?.weekly ?? null;
424
+ }
425
+
426
+ /**
427
+ * Minutes until this pool's PACING window (weekly or monthly) resets, from
428
+ * `pool.paceResetsAt` (src/lib/config.js, straight off the meter reading).
429
+ *
430
+ * null when there is no reading, when it cannot be parsed, or when the reset
431
+ * is already at/behind `now` — an outrun deadline is unknown, not a
432
+ * zero-length window (R8/R11). The 5h twin is minutesUntilFiveHourReset().
433
+ */
434
+ export function minutesUntilPacingReset(pool, now = Date.now()) {
435
+ const resetsAtMs = Date.parse(pool?.paceResetsAt ?? '');
436
+ if (!Number.isFinite(resetsAtMs)) return null;
437
+ const minutes = (resetsAtMs - now) / 60_000;
438
+ return minutes > 0 ? minutes : null;
439
+ }
440
+
441
+ /** `5d22h`, `2h02m`, `45m` — how long a window has left, for humans (R11). */
442
+ export function formatResetsIn(minutes) {
443
+ const total = Math.max(0, Math.round(num(minutes) ?? 0));
444
+ const days = Math.floor(total / 1440);
445
+ const hours = Math.floor((total % 1440) / 60);
446
+ const mins = total % 60;
447
+ if (days > 0) return `${days}d${hours}h`;
448
+ if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`;
449
+ return `${mins}m`;
450
+ }
451
+
452
+ /**
453
+ * In-flight minutes that fall INSIDE the pacing window still to run — the
454
+ * complement of inflightOverflowMinutes(). Everything after the reset is the
455
+ * next window's problem (R11, mirroring R10).
456
+ */
457
+ function inflightMinutesWithin(pool, minutesToReset) {
458
+ const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
459
+ let inside = 0;
460
+ for (const record of records) {
461
+ const m = num(record?.remainingMinutes);
462
+ if (m == null) continue;
463
+ const kept = Math.max(0, m);
464
+ inside += minutesToReset == null ? kept : Math.min(kept, minutesToReset);
465
+ }
466
+ return inside;
467
+ }
468
+
469
+ /**
470
+ * What this pool's PACING window (weekly or monthly) will read once the work
471
+ * it is already carrying and the assignment being routed have landed (R11):
472
+ *
473
+ * forecast = (projectedPacingPct ?? usedPct)
474
+ * − ratePerMinute × in-flight minutes past the pacing reset
475
+ * + ratePerMinute × min(candidateMinutes, minutes to the reset)
476
+ *
477
+ * Spend that lands after the reset belongs to the NEXT window, so both terms
478
+ * are clipped at `paceResetsAt` exactly as fiveHourForecast() clips at the 5h
479
+ * one: the producer's projection (src/lib/spend.js) charges every remaining
480
+ * in-flight minute to this window, and the minutes past the reset are credited
481
+ * back out of it — never below the pool's own reading. When the producer
482
+ * attached no projection the same in-flight minutes are added to the reading
483
+ * instead, which is the identical number by another route.
484
+ *
485
+ * With no measured rate there is nothing to multiply by: the forecast is the
486
+ * reading plus the flat per-agent penalty inflightLoad() already charges, and
487
+ * a pool with no reading at all forecasts null (unknown — R8).
488
+ *
489
+ * @returns {{raw: number|null, projected: number|null,
490
+ * ratePerMinute: number|null, candidateAdd: number|null,
491
+ * inflightCreditPct: number, minutesToReset: number|null,
492
+ * forecast: number|null}}
493
+ */
494
+ export function pacingForecast(pool, candidateMinutes = null, now = Date.now(), opts = {}) {
495
+ const { inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT } = opts;
496
+ const raw = num(pool?.usedPct);
497
+ const projected = num(pool?.projectedPacingPct);
498
+ const ratePerMinute = num(pacingRateBlock(pool)?.ratePerMinute);
499
+ const minutes = num(candidateMinutes);
500
+ const minutesToReset = minutesUntilPacingReset(pool, now);
501
+ const base = projected ?? raw;
502
+ const empty = {
503
+ raw, projected, ratePerMinute, candidateAdd: null, inflightCreditPct: 0, minutesToReset,
504
+ };
505
+ if (base == null) return { ...empty, forecast: null };
506
+ if (ratePerMinute == null) {
507
+ const count = Math.max(0, num(pool?.inflight?.count) ?? 0);
508
+ const penaltyPct = num(inflightPenaltyPct) ?? DEFAULT_INFLIGHT_PENALTY_PCT;
509
+ return { ...empty, forecast: base + count * penaltyPct };
510
+ }
511
+ const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
512
+ const candidateAdd = minutes == null ? 0 : ratePerMinute * clip(Math.max(0, minutes));
513
+ const overflowMinutes =
514
+ minutesToReset == null ? 0 : inflightOverflowMinutes(pool, minutesToReset);
515
+ const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
516
+ const carried =
517
+ projected == null
518
+ ? raw + ratePerMinute * inflightMinutesWithin(pool, minutesToReset)
519
+ : inflightCreditPct > 0
520
+ ? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
521
+ : projected;
522
+ return {
523
+ ...empty,
524
+ candidateAdd,
525
+ inflightCreditPct,
526
+ forecast: carried + candidateAdd,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * R11 view of one pool: is its pacing window about to close, how urgent is the
532
+ * quota it still holds, and what will that window read once in-flight work and
533
+ * this candidate land.
534
+ *
535
+ * `effective` is the ranking's own `pace − load.penalty`; pickPool passes the
536
+ * number it already computed, and any other caller (bullswarm pools) lets this
537
+ * recompute it from the pool.
538
+ *
539
+ * `windowLeftFraction` is (100 − elapsedPct) / 100, floored at
540
+ * MIN_WINDOW_LEFT_FRACTION. A pool whose reading carries no elapsedPct has no
541
+ * measured window position, so the fraction is 1 and urgency is just the
542
+ * surplus — never inflated for a number nobody produced (R8).
543
+ *
544
+ * @returns {{expiringSoon: boolean, window: string|null,
545
+ * minutesToReset: number|null, windowLeftFraction: number|null,
546
+ * effective: number|null, urgency: number|null,
547
+ * forecast: number|null, ratePerMinute: number|null,
548
+ * state: 'urgent'|'normal'|'draining'|null}}
549
+ */
550
+ export function expiringSoonView(pool, opts = {}) {
551
+ const {
552
+ now = Date.now(),
553
+ candidateMinutes = null,
554
+ inflightPenaltyPct = DEFAULT_INFLIGHT_PENALTY_PCT,
555
+ effective = null,
556
+ } = opts;
557
+ const window = pool?.pacingWindow ?? null;
558
+ const leadMs = EXPIRING_SOON_MS[window] ?? null;
559
+ const minutesToReset = minutesUntilPacingReset(pool, now);
560
+ const notSoon = {
561
+ expiringSoon: false,
562
+ window,
563
+ minutesToReset,
564
+ windowLeftFraction: null,
565
+ effective: null,
566
+ urgency: null,
567
+ forecast: null,
568
+ ratePerMinute: null,
569
+ state: null,
570
+ };
571
+ if (leadMs == null || minutesToReset == null || minutesToReset * 60_000 > leadMs) {
572
+ return notSoon;
573
+ }
574
+
575
+ const eff =
576
+ num(effective)
577
+ ?? paceScore(pool, now) - inflightLoad(pool, { candidateMinutes, inflightPenaltyPct }).penalty;
578
+ const elapsed = num(pool?.elapsedPct);
579
+ const windowLeftFraction = Math.max(
580
+ MIN_WINDOW_LEFT_FRACTION,
581
+ elapsed == null ? 1 : (100 - elapsed) / 100,
582
+ );
583
+ const pacing = pacingForecast(pool, candidateMinutes, now, { inflightPenaltyPct });
584
+ const forecast = pacing.forecast;
585
+ const used = num(pool?.usedPct);
586
+ // An unmeasured pool's forecast is its reading: demand real headroom before
587
+ // handing it the lane ahead of everyone else.
588
+ const trusted =
589
+ pacing.ratePerMinute != null ||
590
+ (used != null && used <= PACING_FORECAST_BLOCK_PCT - UNMEASURED_URGENT_HEADROOM_PCT);
591
+ const state =
592
+ forecast != null && forecast >= PACING_FORECAST_BLOCK_PCT ? 'draining'
593
+ : eff > 0 && forecast != null && trusted ? 'urgent'
594
+ : 'normal';
595
+ return {
596
+ expiringSoon: true,
597
+ window,
598
+ minutesToReset,
599
+ windowLeftFraction,
600
+ effective: eff,
601
+ urgency: eff / windowLeftFraction,
602
+ forecast,
603
+ ratePerMinute: pacing.ratePerMinute,
604
+ state,
605
+ };
606
+ }
607
+
232
608
  export function isExhausted(pool) {
233
609
  // Flat shape (buildPools) first, legacy meter shape second. A stale
234
610
  // meterSource reading must not permanently exclude a pool: if the reading
@@ -307,31 +683,50 @@ export function pickPool(lane, pools, opts = {}) {
307
683
  const eligible = laneCapable.filter((p) => p.modelPolicy?.eligible !== false);
308
684
 
309
685
  const scored = eligible.map((p) => {
310
- const forecast = fiveHourForecast(p, candidateMins);
686
+ const forecast = fiveHourForecast(p, candidateMins, now);
311
687
  const load = inflightLoad(p, { candidateMinutes: candidateMins, inflightPenaltyPct });
312
688
  const pace = paceScore(p, now);
689
+ // R8c: pace minus the quota this pool's in-flight work and this
690
+ // assignment are expected to spend. Equals pace when nothing is in
691
+ // flight and no rate applies.
692
+ const effective = pace - load.penalty;
693
+ // R11: the same surplus, divided by how much of the pacing window is left
694
+ // to spend it in. All-null for a pool whose window is not about to close.
695
+ const expiring = expiringSoonView(p, {
696
+ now, candidateMinutes: candidateMins, inflightPenaltyPct, effective,
697
+ });
313
698
  return {
314
699
  pool: p,
315
700
  pace,
316
- // R8c: pace minus the quota this pool's in-flight work and this
317
- // assignment are expected to spend. Equals pace when nothing is in
318
- // flight and no rate applies.
319
- effective: pace - load.penalty,
701
+ effective,
320
702
  load,
321
703
  forecast,
322
- // R8b: R7's tier, applied to the forecast instead of the reading.
323
- tier: forecast.forecast != null && forecast.forecast >= FIVE_HOUR_NEAR_LIMIT_PCT ? 1 : 0,
704
+ expiring,
705
+ // urgent first, draining last, everything else in the middle the tier
706
+ // R11 adds under R7's 5h tier and above the pace comparison.
707
+ urgencyRank: expiring.state === 'urgent' ? 0 : expiring.state === 'draining' ? 2 : 1,
708
+ // R8b: R7's tier, applied to the forecast instead of the reading — and
709
+ // R10: only for a pool further through its 5h quota than through its 5h
710
+ // window. A pool at 88% with 23 minutes left keeps its tier 0.
711
+ tier: forecast.nearLimit && !forecast.underClock ? 1 : 0,
324
712
  // A pool is gated only by a FORECAST at/above the burst line — a bare
325
713
  // reading keeps its current meaning (dispatch owns that gate), so pools
326
714
  // without a spend model behave exactly as before.
327
715
  gated: forecast.forecasted && forecast.forecast != null && forecast.forecast >= BURST_BLOCK_PCT,
328
716
  };
329
717
  });
330
- // R8 before R7 before R2: forecast-gated pools last, then 5h headroom, then
331
- // most-behind-after-load within the tier. The candidate list is reported in
332
- // this exact preference order.
718
+ // R8 before R7 before R11 before R2: forecast-gated pools last, then 5h
719
+ // headroom, then urgent < normal < draining, then the group's own score —
720
+ // urgency among the urgent, most-behind-after-load everywhere else. The
721
+ // candidate list is reported in this exact preference order.
333
722
  scored.sort(
334
- (a, b) => (a.gated ? 1 : 0) - (b.gated ? 1 : 0) || a.tier - b.tier || b.effective - a.effective,
723
+ (a, b) =>
724
+ (a.gated ? 1 : 0) - (b.gated ? 1 : 0) ||
725
+ a.tier - b.tier ||
726
+ a.urgencyRank - b.urgencyRank ||
727
+ (a.urgencyRank === 0
728
+ ? b.expiring.urgency - a.expiring.urgency
729
+ : b.effective - a.effective),
335
730
  );
336
731
 
337
732
  const candidates = scored.map((e) => ({
@@ -345,6 +740,9 @@ export function pickPool(lane, pools, opts = {}) {
345
740
  fiveHourUsedPct: e.forecast.raw,
346
741
  projectedFiveHourPct: e.forecast.projected,
347
742
  forecastFiveHourPct: e.forecast.forecast == null ? null : tenth(e.forecast.forecast),
743
+ // R10: how far into the 5h window this reading sits; null when the
744
+ // provider reported no reset time, in which case the fixed line applies.
745
+ fiveHourElapsedPct: e.forecast.elapsedPct == null ? null : tenth(e.forecast.elapsedPct),
348
746
  projectedWeeklyPct: num(e.pool.projectedWeeklyPct),
349
747
  // The window this pool is paced by, and the projection in it. Equal to
350
748
  // the weekly pair for every pool that declares no monthly quota window.
@@ -354,6 +752,16 @@ export function pickPool(lane, pools, opts = {}) {
354
752
  estimateSource: e.load.estimateSource,
355
753
  nearFiveHourLimit: e.tier === 1,
356
754
  forecastGated: e.gated,
755
+ // R11: when the pacing window resets, whether that is close enough to
756
+ // count, and the urgency/forecast that decided the pool's standing. Every
757
+ // field but the first is null for a pool whose window is not about to
758
+ // close — nothing changes for a number nobody produced (R8).
759
+ paceResetsInMinutes:
760
+ e.expiring.minutesToReset == null ? null : tenth(e.expiring.minutesToReset),
761
+ expiringSoon: e.expiring.expiringSoon,
762
+ urgency: e.expiring.urgency == null ? null : tenth(e.expiring.urgency),
763
+ forecastPacingPct: e.expiring.forecast == null ? null : tenth(e.expiring.forecast),
764
+ urgencyState: e.expiring.state,
357
765
  }));
358
766
  const gatedNames = scored.filter((e) => e.gated).map((e) => e.pool.name);
359
767
  const forecastReport = { candidateMinutes: candidateMins, gated: gatedNames };
@@ -393,6 +801,7 @@ export function pickPool(lane, pools, opts = {}) {
393
801
 
394
802
  let winnerEntry;
395
803
  let skippedNearLimit = [];
804
+ let skippedDraining = [];
396
805
  if (allGated) {
397
806
  winnerEntry = [...scored].sort(
398
807
  (a, b) =>
@@ -402,9 +811,21 @@ export function pickPool(lane, pools, opts = {}) {
402
811
  } else {
403
812
  // R7: selection happens only among pools with 5h headroom while any exists.
404
813
  const withHeadroom = open.filter((e) => e.tier === 0);
405
- const selectable = withHeadroom.length ? withHeadroom : open;
814
+ const headroomSet = withHeadroom.length ? withHeadroom : open;
406
815
  skippedNearLimit = withHeadroom.length ? open.filter((e) => e.tier === 1) : [];
407
816
 
817
+ // R11, by the same mechanism and one rung below it: while any pool's
818
+ // quota is about to expire with room to spend it, that pool is the only
819
+ // selectable one — which is what puts urgency ahead of incumbency and of
820
+ // a configured effort assignment, both of which are resolved inside
821
+ // `selectable` below. A draining pool is the mirror image: out of
822
+ // selection until nothing else is left.
823
+ const urgentSet = headroomSet.filter((e) => e.urgencyRank === 0);
824
+ const notDraining = headroomSet.filter((e) => e.urgencyRank !== 2);
825
+ const selectable =
826
+ urgentSet.length ? urgentSet : notDraining.length ? notDraining : headroomSet;
827
+ skippedDraining = notDraining.length ? headroomSet.filter((e) => e.urgencyRank === 2) : [];
828
+
408
829
  const preferredEntry = preferredPool
409
830
  ? selectable.find((entry) => entry.pool.name === preferredPool)
410
831
  : null;
@@ -459,6 +880,7 @@ export function pickPool(lane, pools, opts = {}) {
459
880
  preferred: !allGated && Boolean(preferredPool) && winnerEntry.pool.name === preferredPool,
460
881
  effortTier: opts.effortTier,
461
882
  skippedNearLimit,
883
+ skippedDraining,
462
884
  gated: allGated ? [] : gatedEntries,
463
885
  gatedFallback: allGated,
464
886
  yieldedBusier,
@@ -535,6 +957,7 @@ function routingReason(
535
957
  preferred,
536
958
  effortTier,
537
959
  skippedNearLimit = [],
960
+ skippedDraining = [],
538
961
  gated = [],
539
962
  gatedFallback = false,
540
963
  yieldedBusier = [],
@@ -554,10 +977,21 @@ function routingReason(
554
977
  base = `configured ${effortTier ?? 'effort'} assignment (${
555
978
  [winnerEntry.pool.name, note, inflight].filter(Boolean).join(', ')
556
979
  })`;
980
+ } else if (winnerEntry.urgencyRank === 0) {
981
+ // R11: this pool did not win on the size of its surplus but on how little
982
+ // time is left to spend it, so the reason names the clock, the fraction of
983
+ // the window still to run, and the forecast that kept it out of draining.
984
+ base = urgencyClause(winnerEntry, [note, inflight].filter(Boolean).join(', '));
557
985
  } else {
558
- base = `most-behind capable pool${
559
- note ? (winnerEntry.tier === 0 ? ' with 5h headroom' : ' near its 5h limit') : ''
560
- } (${detail})`;
986
+ // Three states, not two (R10): headroom, near the limit and tiered down,
987
+ // or near the limit but under the window's clock where the note itself
988
+ // carries the explanation, so the label stays out of its way.
989
+ const standing =
990
+ !note ? ''
991
+ : winnerEntry.tier === 1 ? ' near its 5h limit'
992
+ : winnerEntry.forecast.underClock ? ''
993
+ : ' with 5h headroom';
994
+ base = `most-behind capable pool${standing} (${detail})`;
561
995
  }
562
996
  const clauses = [base];
563
997
  if (skippedNearLimit.length) {
@@ -573,6 +1007,15 @@ function routingReason(
573
1007
  `forecast-gated at/above ${BURST_BLOCK_PCT}%: ${gated.map((e) => poolPctLabel(e)).join(', ')}`,
574
1008
  );
575
1009
  }
1010
+ if (skippedDraining.length) {
1011
+ // R11: a pool whose window is about to close was passed over anyway,
1012
+ // because the run would spend what little it has left through the wall.
1013
+ clauses.push(
1014
+ `expiring but draining (forecast >= ${PACING_FORECAST_BLOCK_PCT}%): ${skippedDraining
1015
+ .map((e) => `${e.pool.name} ${pacingPctText(e)}`)
1016
+ .join(', ')}`,
1017
+ );
1018
+ }
576
1019
  if (yieldedBusier.length) {
577
1020
  clauses.push(
578
1021
  `preferred over busier: ${yieldedBusier
@@ -583,21 +1026,67 @@ function routingReason(
583
1026
  return clauses.join(' · ');
584
1027
  }
585
1028
 
586
- /** `<pool> <pct>%` using the forecast when one exists, else the raw reading. */
1029
+ /**
1030
+ * R11's reason for an urgent winner:
1031
+ * `expiring soon: grok resets in 2h02m, surplus 13.8 over 1.2% of the week
1032
+ * left → urgency 1140, forecast 91.0%`. Urgency reads as a whole number: at
1033
+ * this scale a tenth of a point is noise, and the candidate row carries the
1034
+ * rounded value for anything that needs it.
1035
+ */
1036
+ function urgencyClause(entry, detail) {
1037
+ const { minutesToReset, windowLeftFraction, urgency, window } = entry.expiring;
1038
+ const word = window === 'monthly' ? 'month' : 'week';
1039
+ const left = tenth(windowLeftFraction * 100);
1040
+ const tail = detail ? ` (${detail})` : '';
1041
+ return (
1042
+ `expiring soon: ${entry.pool.name} resets in ${formatResetsIn(minutesToReset)}, `
1043
+ + `surplus ${tenth(entry.effective)} over ${left}% of the ${word} left `
1044
+ + `→ urgency ${Math.round(urgency)}, forecast ${pacingPctText(entry)}${tail}`
1045
+ );
1046
+ }
1047
+
1048
+ /** `91.0%` — an expiring-soon pool's pacing forecast, or `?%` with no reading. */
1049
+ function pacingPctText(entry) {
1050
+ const pct = entry.expiring.forecast;
1051
+ return pct == null ? '?%' : `${Number(pct).toFixed(1)}%`;
1052
+ }
1053
+
1054
+ /**
1055
+ * `<pool> <pct>%` using the forecast when one exists, else the raw reading —
1056
+ * and, for a pool at/above the near-limit line, where its 5h window stands
1057
+ * (R10): `claude-code:wati 88.1% (92.3% elapsed)`. The clock is what decided
1058
+ * the tier, so the number that decided it is named.
1059
+ */
587
1060
  function poolPctLabel(entry) {
588
1061
  const pct = entry.forecast.forecasted ? entry.forecast.forecast : entry.forecast.raw;
589
- return `${entry.pool.name}${pct == null ? '' : ` ${tenth(pct)}%`}`;
1062
+ const clock = entry.forecast.nearLimit ? elapsedText(entry.forecast.elapsedPct) : null;
1063
+ return `${entry.pool.name}${pct == null ? '' : ` ${tenth(pct)}%`}${clock ? ` (${clock})` : ''}`;
1064
+ }
1065
+
1066
+ /** `92.3% elapsed`, or null when the provider reported no 5h reset time. */
1067
+ function elapsedText(elapsedPct) {
1068
+ return elapsedPct == null ? null : `${Number(elapsedPct).toFixed(1)}% elapsed`;
590
1069
  }
591
1070
 
592
- /** `5h used 30%` or, when a forecast adds to it, `5h used 30% -> 41% projected`. */
1071
+ /**
1072
+ * `5h used 30%` or, when a forecast adds to it, `5h used 30% -> 41% projected`.
1073
+ *
1074
+ * A forecast at/above the near-limit line also carries its window's clock —
1075
+ * `, under the clock (92.3% elapsed)` when R10 exempts it from the tier,
1076
+ * `, 20.0% elapsed` when the clock is what put it there.
1077
+ */
593
1078
  function fiveHourNote(entry) {
594
- const { raw, forecast, forecasted } = entry.forecast;
1079
+ const { raw, forecast, forecasted, nearLimit, underClock, elapsedPct } = entry.forecast;
1080
+ const clock = nearLimit ? elapsedText(elapsedPct) : null;
1081
+ const suffix = clock ? `, ${underClock ? `under the clock (${clock})` : clock}` : '';
595
1082
  if (raw == null) {
596
- return forecasted && forecast != null ? `5h projected ${tenth(forecast)}%` : null;
1083
+ return forecasted && forecast != null ? `5h projected ${tenth(forecast)}%${suffix}` : null;
597
1084
  }
598
1085
  const reading = `5h used ${tenth(raw)}%`;
599
- if (!forecasted || forecast == null || tenth(forecast) === tenth(raw)) return reading;
600
- return `${reading} -> ${tenth(forecast)}% projected`;
1086
+ if (!forecasted || forecast == null || tenth(forecast) === tenth(raw)) {
1087
+ return `${reading}${suffix}`;
1088
+ }
1089
+ return `${reading} -> ${tenth(forecast)}% projected${suffix}`;
601
1090
  }
602
1091
 
603
1092
  /** `2 in flight`, or null when the caller tracks no in-flight work here. */