claude-usage-limits 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "usage-limits",
3
3
  "displayName": "Usage Limits",
4
- "version": "1.8.0",
4
+ "version": "1.9.0",
5
5
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
6
6
  "author": {
7
7
  "name": "Ridelink",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usage-limits",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Reports how much of your Codex usage limit is left as turns of work rather than a percentage, prices a job before you start it, and counts the other agents sharing the same budget.",
5
5
  "author": {
6
6
  "name": "Ridelink",
package/README.md CHANGED
@@ -430,6 +430,44 @@ times one that answers from context. The upper end is the honest one for a long
430
430
  run, since turns get dearer as the context grows.
431
431
 
432
432
 
433
+ ## Which effort and model should this run at
434
+
435
+ ```
436
+ node skills/usage-limits/scripts/usage.js --recommend # against the headroom
437
+ node skills/usage-limits/scripts/usage.js --recommend 15 # against a 15 turn job
438
+ ```
439
+
440
+ ```
441
+ Recommendation for 15 turns
442
+
443
+ Posture tight - a 15 turn job fits, but only just (5-hour window, 12% left, resets in 1h 40m)
444
+ Effort xhigh -> medium; one notch covers the mechanical stretches; keep judgement calls at full effort
445
+ this session: /effort medium (only the user can run it)
446
+ new sessions: node scripts/lowpower.js on --effort medium
447
+ Model keep opus for the judgement; the saving is in where the mechanical bulk runs
448
+ dispatch self-contained mechanical work to a subagent on sonnet at low effort, and keep the judgement here
449
+ ```
450
+
451
+ It weighs the binding window, the measured cost of a turn, and how much of the
452
+ output is actually reasoning, then names a posture - roomy, tight, critical, or
453
+ reset-first - and the exact commands. When there is room it says to keep
454
+ everything as it is, out loud, because turning effort down when the budget is
455
+ not tight buys nothing and costs quality. When reasoning is only a sliver of
456
+ the output it says so too, and leaves effort alone: the reasoning share is the
457
+ ceiling on what lowering effort can save. `--json` returns the decision as an
458
+ object.
459
+
460
+ The three levers it recommends across belong to different hands. The running
461
+ session's effort and model are the user's alone (`/effort`, `/model`, applied
462
+ immediately); new sessions belong to `lowpower.js`, which writes `settings.json`
463
+ for the next launch; and delegated work belongs to the agent itself, which can
464
+ dispatch a subagent on any model at any effort, mid-session, with no one asked.
465
+ No script or hook can change the model or effort of a session already running -
466
+ `settings.json` is read at launch and hook output has no model field - which is
467
+ why the recommendation separates "this session" from "new sessions" instead of
468
+ pretending one command covers both.
469
+
470
+
433
471
  ## Plans
434
472
 
435
473
  It reads which plan you are on and adjusts what it tells you, because the
@@ -545,6 +583,10 @@ overwrite the saved originals.
545
583
  The file change applies to new sessions. For a session already running,
546
584
  `/effort low` does the same thing immediately.
547
585
 
586
+ `--effort max` is refused: `settings.json` does not accept `max`, so saving it
587
+ would store a value the next session silently ignores. `max` lives in
588
+ `/effort` and `CLAUDE_CODE_EFFORT_LEVEL` only.
589
+
548
590
  That covers the setting. The larger saving is behavioural, and the skill file
549
591
  spells it out: batch tool calls, read line ranges instead of whole files, skip
550
592
  subagents when the context already exists, stop retrying a fix that is not
package/bin/cli.js CHANGED
@@ -11,6 +11,7 @@ const HELP = `claude-usage-limits - how much agent usage is left, and whether th
11
11
  claude-usage-limits --json the same numbers, machine readable
12
12
  claude-usage-limits --status one short line, for a status line
13
13
  claude-usage-limits --forecast 15 what a 15 turn job would cost
14
+ claude-usage-limits --recommend [15] which effort and model this budget calls for
14
15
  claude-usage-limits --sessions what recent sessions cost
15
16
  claude-usage-limits --session last one session in full, or give its id
16
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-limits",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Puts your remaining Claude Code usage limit into Claude's context before every prompt, so it opens with what fits in the budget instead of starting work that gets cut off. Reports headroom as turns rather than percentages, prices a job before you start it, and detects your plan tier.",
5
5
  "keywords": [
6
6
  "claude",
@@ -272,6 +272,45 @@ to `low` is the largest per-turn saving available without changing model or
272
272
  scope. The file change applies to new sessions; for the session already
273
273
  running, `/effort low` takes effect immediately.
274
274
 
275
+ ### Choosing the level
276
+
277
+ Do not guess which effort or model the budget calls for; the report can say:
278
+
279
+ ```
280
+ node scripts/usage.js --recommend # against the headroom in general
281
+ node scripts/usage.js --recommend 15 # against a 15 turn job
282
+ ```
283
+
284
+ It weighs the binding window, the measured cost of a turn, and how much of
285
+ the output is actually reasoning, then names the posture (roomy, tight,
286
+ critical, or reset-first) and the exact commands. When there is room it says
287
+ to keep everything as it is, out loud, so cheapness never becomes a habit.
288
+ Add `--json` for the decision as fields.
289
+
290
+ The three levers it recommends across belong to different hands, and keeping
291
+ that straight is the whole trick:
292
+
293
+ | Lever | Whose hand | When it acts |
294
+ | --- | --- | --- |
295
+ | `/effort`, `/model` | the user's only | this session, immediately |
296
+ | `lowpower.js on` (writes settings.json) | yours, right now | new sessions, at launch |
297
+ | subagent model and effort | yours, freely | that dispatch, immediately |
298
+
299
+ The running session's own model and effort cannot be changed by any script or
300
+ hook: settings.json is read at launch and hook output has no model field. So
301
+ when the recommendation says `/effort low`, put that in front of the user as
302
+ one short line and keep working; do not wait on it. What can be done without
303
+ asking anyone is the other two rows: write the next session's settings with
304
+ `lowpower.js`, and send self-contained mechanical bulk to a subagent on a
305
+ cheaper model at low effort, which is a change of model that needs nobody's
306
+ permission. The cold start still costs (see `references/tactics.md` on
307
+ subagents), so delegate work that is big and mechanical, not quick questions.
308
+
309
+ One catch to know about: `settings.json` does not accept `max`, so a saved
310
+ effort level tops out at `xhigh`. `max` only survives through `/effort` or the
311
+ `CLAUDE_CODE_EFFORT_LEVEL` environment variable, and `lowpower.js` refuses to
312
+ write it rather than save a value the next session would silently ignore.
313
+
275
314
  Do not take that on trust: the report measures it. Under the model table it
276
315
  says how much of the output was reasoning and what that cost, for example
277
316
 
@@ -394,5 +433,6 @@ a turn, which is the thing it is trying to save.
394
433
  | `scripts/codex.js` | The Codex reader: the meter and the pace out of `~/.codex/sessions`, plus the live `--refresh` call. |
395
434
  | `scripts/install-codex-hook.js` | `status`, `on`, `off`. Installs the Codex-side instruction, which Claude Code does not need. |
396
435
  | `scripts/lowpower.js` | `status`, `on`, `off`. Restores what it replaced. Claude Code only. |
436
+ | `scripts/recommend.js` | The chooser behind `usage.js --recommend`: posture, then the effort and model commands for each lever. Not meant to be called by hand. |
397
437
  | `references/tactics.md` | Every lever that lowers cost, and why it works. |
398
438
  | `references/how-it-works.md` | Where the numbers come from and where they are soft. |
@@ -56,6 +56,13 @@ Switch at a task boundary rather than mid-task, because a model switch
56
56
  invalidates the prompt cache and the rebuild can cost more than the saving on
57
57
  a short remaining task.
58
58
 
59
+ The switch needs no boundary at all when the work is delegated: a subagent
60
+ can be dispatched on a cheaper model at low effort mid-session, and that is
61
+ the one model change the agent can make without anyone's hand on `/model`.
62
+ It pays the cold start from lever 7, so it is for self-contained bulky work,
63
+ not quick questions. `node scripts/usage.js --recommend` prices whether the
64
+ budget calls for it.
65
+
59
66
  ### 3. Read less, and read it once
60
67
 
61
68
  - Line ranges instead of whole files. Grep with a result limit instead of
@@ -502,7 +502,9 @@ function briefText(parts) {
502
502
  'save at clean boundaries as you go, and keep a short running note of ' +
503
503
  'what is done, what is next, and which files are mid-change, so that ' +
504
504
  'stopping at any moment loses nothing. Say in one line what may not ' +
505
- 'land before the reset, then keep working.'
505
+ 'land before the reset, then keep working. If part of what remains is ' +
506
+ 'mechanical, node scripts/usage.js --recommend (from the skill directory) ' +
507
+ 'names the effort and model it should run at.'
506
508
  : 'Open your reply with one short line stating this and confirming the ' +
507
509
  'request fits, then get on with the work. Keep it to a single line. ' +
508
510
  'There is room, so use it: work at full quality, take on the whole ' +
@@ -81,6 +81,15 @@ function planApply(settings, options, existingState) {
81
81
  if (EFFORT_LEVELS.indexOf(effort) === -1) {
82
82
  throw new Error('unknown effort "' + effort + '", expected one of ' + EFFORT_LEVELS.join(', '));
83
83
  }
84
+ // Claude Code refuses 'max' in settings.json; it only survives through
85
+ // /effort or CLAUDE_CODE_EFFORT_LEVEL. Writing it here would save a value
86
+ // the next session silently ignores, which is worse than an error.
87
+ if (effort === 'max') {
88
+ throw new Error(
89
+ "settings.json does not accept 'max'. Use --effort xhigh here, and /effort max " +
90
+ 'or CLAUDE_CODE_EFFORT_LEVEL=max for the sessions that need it.'
91
+ );
92
+ }
84
93
  wanted.effortLevel = effort;
85
94
  if (options.model) wanted.model = options.model;
86
95
 
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // Turns the budget figures into a choice of effort and model, and says where
4
+ // each half of that choice can actually be applied. There are three levers and
5
+ // they belong to different hands:
6
+ //
7
+ // - The running session's effort and model are the user's: only /effort and
8
+ // /model change them, and they change them immediately.
9
+ // - New sessions are the script's: lowpower.js writes effortLevel and model
10
+ // into settings.json, which Claude Code reads at launch.
11
+ // - Delegated work is Claude's alone: a subagent can be dispatched on any
12
+ // model at any effort, mid-session, with no one asked.
13
+ //
14
+ // Nothing here touches disk. decide() is a pure function over the same report
15
+ // data usage.js already gathers, so the reasoning can be tested without a
16
+ // transcript in sight.
17
+
18
+ // One notch down, not a cliff. Dropping xhigh to low on work that still has
19
+ // judgement in it costs more in rework than it saves; the ladder loses height
20
+ // a step at a time and 'critical' is the only posture that goes straight to
21
+ // the floor.
22
+ const NEXT_LOWER = { max: 'high', xhigh: 'medium', high: 'medium', medium: 'low', low: 'low' };
23
+
24
+ // Below this share of output, reasoning is not where the money is going, and
25
+ // turning effort down would trade quality for a saving that is not there.
26
+ const REASONING_FLOOR = 0.1;
27
+
28
+ // Turns-left walls used when no job size is given. Ten turns is barely a
29
+ // feature; twenty-five is room for one, carefully.
30
+ const CRITICAL_TURNS = 10;
31
+ const TIGHT_TURNS = 25;
32
+
33
+ const HOUR = 60 * 60 * 1000;
34
+
35
+ // Where the mechanical bulk should go when it is delegated. One tier down
36
+ // from whatever is doing the judgement; haiku is already the floor.
37
+ function delegateModel(model) {
38
+ const name = String(model || '').toLowerCase();
39
+ if (name.indexOf('haiku') !== -1) return 'haiku';
40
+ if (name.indexOf('sonnet') !== -1) return 'haiku';
41
+ return 'sonnet';
42
+ }
43
+
44
+ // The effort actually in force. settings.json says 'default' when nothing is
45
+ // set, and the measured dominant effort of recent turns is better evidence
46
+ // than a guess; xhigh is what Claude Code defaults to when neither knows.
47
+ function currentEffort(settings, recentEffort) {
48
+ const set = settings && settings.effortLevel;
49
+ if (set && set !== 'default') return set;
50
+ if (recentEffort) return recentEffort;
51
+ return 'xhigh';
52
+ }
53
+
54
+ function decide(inputs) {
55
+ const binding = inputs.binding;
56
+ const rates = inputs.rates;
57
+ const settings = inputs.settings || {};
58
+ const effortNow = currentEffort(settings, inputs.recentEffort);
59
+ const modelNow = (settings.model && settings.model !== 'default' && settings.model) || 'default';
60
+
61
+ const base = {
62
+ posture: 'unknown',
63
+ reason: null,
64
+ turnsLeft: null,
65
+ effort: { current: effortNow, target: effortNow, changes: false, why: null },
66
+ model: { current: modelNow, delegate: null, nextSession: null, why: null },
67
+ apply: { now: null, next: null, delegate: null },
68
+ notes: [],
69
+ };
70
+
71
+ if (!binding || binding.stale) {
72
+ base.reason = 'no fresh reading of the binding window';
73
+ return base;
74
+ }
75
+ if (!rates || !Number.isFinite(rates.median) || rates.median <= 0) {
76
+ base.reason = 'no measured turn cost to price the budget in turns';
77
+ return base;
78
+ }
79
+ if (!Number.isFinite(binding.usdPerPercent) || binding.usdPerPercent <= 0) {
80
+ base.reason = 'the binding window has no calibrated price per point yet';
81
+ return base;
82
+ }
83
+
84
+ const percentLeft = Number.isFinite(binding.percentLeft) ? binding.percentLeft : 0;
85
+ const turnsLeft = Math.floor((percentLeft * binding.usdPerPercent) / rates.median);
86
+ base.turnsLeft = turnsLeft;
87
+
88
+ // When the clock wins the race, the limit is not the constraint and there
89
+ // is nothing to buy by economising: whatever is left at the reset is lost.
90
+ const pace = inputs.recentTurnsPerHour;
91
+ if (
92
+ Number.isFinite(binding.msToReset) &&
93
+ binding.msToReset > 0 &&
94
+ Number.isFinite(pace) &&
95
+ pace > 0 &&
96
+ (binding.msToReset / HOUR) * pace < turnsLeft * 0.8
97
+ ) {
98
+ base.posture = 'reset-first';
99
+ base.reason = 'the window resets before this pace can spend it';
100
+ base.effort.why = 'the budget is not the constraint';
101
+ base.model.why = 'the budget is not the constraint';
102
+ return base;
103
+ }
104
+
105
+ // With a job size, the forecast arithmetic decides. Without one, the raw
106
+ // turns of headroom do. Both use the expensive end of the measured spread,
107
+ // because that is the honest number for a long run.
108
+ if (Number.isFinite(inputs.turns) && inputs.turns > 0) {
109
+ const percentHigh = (inputs.turns * rates.high) / binding.usdPerPercent;
110
+ if (percentHigh > percentLeft) {
111
+ base.posture = 'critical';
112
+ base.reason = 'a ' + inputs.turns + ' turn job does not fit in what is left';
113
+ } else if (percentHigh > percentLeft * 0.75) {
114
+ base.posture = 'tight';
115
+ base.reason = 'a ' + inputs.turns + ' turn job fits, but only just';
116
+ } else {
117
+ base.posture = 'roomy';
118
+ base.reason = 'a ' + inputs.turns + ' turn job fits with room to spare';
119
+ }
120
+ } else if (percentLeft <= 0 || turnsLeft <= 0) {
121
+ base.posture = 'critical';
122
+ base.reason = 'the binding window is spent';
123
+ } else if (turnsLeft <= CRITICAL_TURNS) {
124
+ base.posture = 'critical';
125
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
126
+ } else if (turnsLeft <= TIGHT_TURNS) {
127
+ base.posture = 'tight';
128
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
129
+ } else {
130
+ base.posture = 'roomy';
131
+ base.reason = 'about ' + turnsLeft + ' turns of headroom';
132
+ }
133
+
134
+ if (base.posture === 'roomy') {
135
+ base.effort.why = 'cheapness is not a virtue when the budget is not tight';
136
+ base.model.why = 'cheapness is not a virtue when the budget is not tight';
137
+ return base;
138
+ }
139
+
140
+ // Effort is the biggest per-turn lever, but only when reasoning is actually
141
+ // where the money goes. The reasoning share is the ceiling on the saving,
142
+ // so a small share means the honest advice is to leave effort alone.
143
+ const share = inputs.reasoningShare;
144
+ if (Number.isFinite(share) && share < REASONING_FLOOR) {
145
+ base.effort.why =
146
+ 'reasoning is only ' + Math.round(share * 100) +
147
+ '% of output, so effort is not where the money is going';
148
+ } else {
149
+ const target = base.posture === 'critical' ? 'low' : NEXT_LOWER[effortNow] || 'medium';
150
+ if (target !== effortNow) {
151
+ base.effort.target = target;
152
+ base.effort.changes = true;
153
+ base.effort.why =
154
+ base.posture === 'critical'
155
+ ? 'reasoning is billed as output, and low is the largest saving that changes nothing else'
156
+ : 'one notch covers the mechanical stretches; keep judgement calls at full effort';
157
+ } else {
158
+ base.effort.why = 'already at the floor for this posture';
159
+ }
160
+ }
161
+
162
+ // The main model is only worth flipping when things are critical, and even
163
+ // then it lands in settings.json for the next session: switching the running
164
+ // session's model mid-task invalidates the prompt cache, so the change
165
+ // belongs at a session boundary.
166
+ base.model.delegate = delegateModel(modelNow);
167
+ base.model.why =
168
+ 'keep ' + (modelNow === 'default' ? 'the current model' : modelNow) +
169
+ ' for the judgement; the saving is in where the mechanical bulk runs';
170
+ if (base.posture === 'critical' && base.model.delegate !== 'haiku') {
171
+ base.model.nextSession = 'sonnet';
172
+ }
173
+
174
+ // The commands, spelled out, because the point of a recommendation is that
175
+ // it can be acted on without working anything out.
176
+ if (base.effort.changes) {
177
+ base.apply.now = '/effort ' + base.effort.target;
178
+ }
179
+ if (inputs.codex) {
180
+ base.notes.push(
181
+ 'Under Codex, settings.json is not in play: change model or effort through ' +
182
+ "Codex's own controls."
183
+ );
184
+ } else if (base.effort.changes || base.model.nextSession) {
185
+ const settingsEffort = base.effort.changes ? base.effort.target : effortNow;
186
+ base.apply.next =
187
+ 'node scripts/lowpower.js on --effort ' +
188
+ (settingsEffort === 'max' ? 'xhigh' : settingsEffort) +
189
+ (base.model.nextSession ? ' --model ' + base.model.nextSession : '');
190
+ }
191
+ base.apply.delegate =
192
+ 'dispatch self-contained mechanical work to a subagent on ' +
193
+ base.model.delegate +
194
+ ' at low effort, and keep the judgement here';
195
+
196
+ if (Number.isFinite(inputs.sessions) && inputs.sessions > 1) {
197
+ base.notes.push(
198
+ inputs.sessions + ' sessions are spending this budget at once, so the headroom ' +
199
+ 'drains faster than these figures alone suggest.'
200
+ );
201
+ }
202
+ if (effortNow === 'max') {
203
+ base.notes.push(
204
+ "settings.json does not accept 'max', so a saved level can only go up to " +
205
+ 'xhigh; max survives only through /effort or CLAUDE_CODE_EFFORT_LEVEL.'
206
+ );
207
+ }
208
+
209
+ return base;
210
+ }
211
+
212
+ // The report data usage.js gathers, reduced to what decide() reads.
213
+ function fromReport(data, turns) {
214
+ return {
215
+ binding: (data && data.binding) || null,
216
+ rates: (data && data.rates) || null,
217
+ settings: (data && data.settings) || {},
218
+ recentEffort: data && data.recent ? data.recent.effort : null,
219
+ recentTurnsPerHour: data && data.recent ? data.recent.turns : null,
220
+ reasoningShare: data && data.reasoning ? data.reasoning.shareOfOutput : null,
221
+ sessions: data && data.sessions ? data.sessions.length : 1,
222
+ codex: Boolean(data && data.money === false),
223
+ turns: Number.isFinite(turns) ? turns : null,
224
+ };
225
+ }
226
+
227
+ // Local and small on purpose: requiring usage.js back for its formatters
228
+ // would make the two modules a cycle.
229
+ function fmtDuration(ms) {
230
+ if (!Number.isFinite(ms) || ms <= 0) return 'now';
231
+ const minutes = Math.round(ms / 60000);
232
+ if (minutes < 60) return minutes + 'm';
233
+ const hours = Math.floor(minutes / 60);
234
+ if (hours < 48) return hours + 'h ' + (minutes % 60) + 'm';
235
+ return Math.floor(hours / 24) + 'd ' + (hours % 24) + 'h';
236
+ }
237
+
238
+ function renderRecommend(data, turns) {
239
+ const decision = decide(fromReport(data, turns));
240
+ const lines = [];
241
+ lines.push('Recommendation' + (Number.isFinite(turns) && turns > 0 ? ' for ' + turns + ' turns' : ''));
242
+ lines.push('');
243
+
244
+ if (decision.posture === 'unknown') {
245
+ lines.push(' Nothing to recommend yet: ' + decision.reason + '.');
246
+ lines.push(' Run /usage once, do a little work, then ask again.');
247
+ return lines.join('\n');
248
+ }
249
+
250
+ const binding = data.binding;
251
+ const where =
252
+ binding.label +
253
+ ' window, ' +
254
+ (Number.isFinite(binding.percentLeft) ? Math.max(0, Math.round(binding.percentLeft)) : '?') +
255
+ '% left' +
256
+ (Number.isFinite(binding.msToReset) ? ', resets in ' + fmtDuration(binding.msToReset) : '');
257
+ lines.push(' Posture ' + decision.posture + ' - ' + decision.reason + ' (' + where + ')');
258
+
259
+ if (decision.posture === 'roomy' || decision.posture === 'reset-first') {
260
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
261
+ lines.push(' Model keep ' + decision.model.current + '; do not economise');
262
+ return lines.join('\n');
263
+ }
264
+
265
+ if (decision.effort.changes) {
266
+ lines.push(' Effort ' + decision.effort.current + ' -> ' + decision.effort.target + '; ' + decision.effort.why);
267
+ lines.push(' this session: ' + decision.apply.now + ' (only the user can run it)');
268
+ } else {
269
+ lines.push(' Effort keep ' + decision.effort.current + '; ' + decision.effort.why);
270
+ }
271
+ if (decision.apply.next) {
272
+ lines.push(' new sessions: ' + decision.apply.next);
273
+ }
274
+ lines.push(' Model ' + decision.model.why);
275
+ lines.push(' ' + decision.apply.delegate);
276
+ if (decision.model.nextSession) {
277
+ lines.push(' new sessions: main model to ' + decision.model.nextSession + ' until the window resets');
278
+ }
279
+ for (const note of decision.notes) {
280
+ lines.push(' Note ' + note);
281
+ }
282
+ return lines.join('\n');
283
+ }
284
+
285
+ module.exports = {
286
+ decide,
287
+ fromReport,
288
+ renderRecommend,
289
+ delegateModel,
290
+ currentEffort,
291
+ NEXT_LOWER,
292
+ REASONING_FLOOR,
293
+ CRITICAL_TURNS,
294
+ TIGHT_TURNS,
295
+ };
@@ -812,13 +812,24 @@ function stampPlan(all, planId) {
812
812
  return stamped;
813
813
  }
814
814
 
815
- // A sample is better when it rests on more turns. Percentages read in whole
816
- // numbers, so a bigger percentage also divides more precisely.
815
+ // A sample is better when it rests on more of the meter. Percentages read in
816
+ // whole numbers, so a reading at 1% prices a point against a bracket that is
817
+ // mostly rounding, while one at 60% divides by a number that means something.
818
+ // Turn count only breaks the tie: it says how much local spend sat behind the
819
+ // reading, not how precise the denominator was, and preferring it outright is
820
+ // how a 44-turn baseline read at 1% once beat every honest sample after it.
817
821
  function betterCalibration(current, candidate) {
818
822
  if (!candidate || !Number.isFinite(candidate.usdPerPercent) || candidate.usdPerPercent <= 0) {
819
823
  return current || null;
820
824
  }
821
825
  if (!current || !Number.isFinite(current.turns)) return candidate;
826
+ if (
827
+ Number.isFinite(candidate.percent) &&
828
+ Number.isFinite(current.percent) &&
829
+ candidate.percent !== current.percent
830
+ ) {
831
+ return candidate.percent > current.percent ? candidate : current;
832
+ }
822
833
  return candidate.turns > current.turns ? candidate : current;
823
834
  }
824
835
 
@@ -933,16 +944,23 @@ function buildWindow(spec, snapshot, events, now, options) {
933
944
  // holding. The learned price covers it: what a point costs is a property
934
945
  // of the plan, not of this reading.
935
946
  const selfPriced =
936
- rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
947
+ rawPercent >= MIN_BASELINE_PERCENT && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
937
948
  ? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
938
949
  : null;
939
950
  // A metered window has nothing to learn: its price per point is stated.
940
951
  if (selfPriced && !extra.metered) window.calibration = selfPriced;
941
952
 
942
953
  const known = extra.knownCalibration;
954
+ // A remembered price read off a near-empty meter is the same rounding
955
+ // bracket in disguise, so it is no more usable than measuring one now.
943
956
  const usable =
944
- known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
945
- // Trust the better-sampled of the two, whichever that is; a stated price
957
+ known &&
958
+ Number.isFinite(known.usdPerPercent) &&
959
+ known.usdPerPercent > 0 &&
960
+ !(Number.isFinite(known.percent) && known.percent < MIN_BASELINE_PERCENT)
961
+ ? known
962
+ : null;
963
+ // Trust the better-measured of the two, whichever that is; a stated price
946
964
  // beats both.
947
965
  const stated =
948
966
  extra.metered && Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
@@ -950,9 +968,7 @@ function buildWindow(spec, snapshot, events, now, options) {
950
968
  : null;
951
969
  const chosen =
952
970
  stated ||
953
- (selfPriced && usable
954
- ? (usable.turns > selfPriced.turns ? usable : selfPriced)
955
- : selfPriced || usable);
971
+ (selfPriced && usable ? betterCalibration(usable, selfPriced) : selfPriced || usable);
956
972
 
957
973
  if (after.cost > 0 && chosen) {
958
974
  const pricePerPoint = chosen.usdPerPercent;
@@ -988,7 +1004,12 @@ function buildWindow(spec, snapshot, events, now, options) {
988
1004
  // so when the two disagree that badly it is the local history that is
989
1005
  // incomplete, not the meter.
990
1006
  const thin = spent.turns < MIN_BASELINE_TURNS && percent >= UNEXPLAINED_PERCENT;
991
- const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
1007
+ // The same rounding bracket again, in the optimistic direction this time: a
1008
+ // window reading 1% divided a full hour of spend by one and priced the
1009
+ // remaining 99 points at thousands of dollars. Below the floor the learned
1010
+ // price takes over through the fallback chain.
1011
+ const measured =
1012
+ percent !== null && percent >= MIN_BASELINE_PERCENT && spent.cost > 0 && !thin;
992
1013
  const derived = measured ? spent.cost / percent : null;
993
1014
  const known = extra.knownCalibration;
994
1015
  const metered =
@@ -1004,11 +1025,14 @@ function buildWindow(spec, snapshot, events, now, options) {
1004
1025
  : null;
1005
1026
  window.metered = metered;
1006
1027
 
1028
+ // The API reports whole numbers, so a low reading is a wide bracket. A fact
1029
+ // about the reading, not the pricing, so it is set whether or not a price
1030
+ // per point could be found.
1031
+ window.coarse = percent !== null && percent < MIN_BASELINE_PERCENT;
1032
+
1007
1033
  if (percent !== null && priced !== null) {
1008
1034
  window.usdPerPercent = priced;
1009
1035
  window.remainingUSD = window.usdPerPercent * window.percentLeft;
1010
- // The API reports whole numbers, so a low reading is a wide bracket.
1011
- window.coarse = percent < 5;
1012
1036
 
1013
1037
  const perTurn = typicalTurnCost(recentEvents, inWindow, events, MIN_PACE_SAMPLE);
1014
1038
  window.percentPerTurn = perTurn === null ? null : perTurn / window.usdPerPercent;
@@ -1313,6 +1337,15 @@ const SATURATION_LIMIT = 105;
1313
1337
  // Fewer turns than this before the snapshot and a point cannot be priced.
1314
1338
  const MIN_BASELINE_TURNS = 5;
1315
1339
 
1340
+ // A reading below this cannot price a point either. The API reports whole
1341
+ // numbers, so at 1% the denominator is mostly rounding: the true figure is
1342
+ // anywhere in a bracket as wide as the reading itself, and a point priced
1343
+ // against it converts later spend into several times the points it really
1344
+ // moved. A snapshot taken just after a reset is the common case - one sat at
1345
+ // 1% while the local spend divided by it asserted 97% of a window that was
1346
+ // truly at 35.
1347
+ const MIN_BASELINE_PERCENT = 5;
1348
+
1316
1349
  // Past this much of a window, a handful of local turns is not what spent it,
1317
1350
  // so their total is not a fair price for a point.
1318
1351
  const UNEXPLAINED_PERCENT = 20;
@@ -1328,6 +1361,10 @@ function reconstructWindow(spec, snapshot, events, now) {
1328
1361
  const past = totals(events.filter((e) => e.at >= pastStart && e.at <= resetsAt));
1329
1362
  if (past.cost <= 0) return null;
1330
1363
 
1364
+ // The same rounding bracket that poisons the live correction poisons a
1365
+ // rebuild: a closed window that read 1% prices a point off almost nothing.
1366
+ if (snapshot.utilization < MIN_BASELINE_PERCENT) return null;
1367
+
1331
1368
  const usdPerPercent = past.cost / snapshot.utilization;
1332
1369
 
1333
1370
  // The window running now began when the old one reset, not five hours ago.
@@ -2261,6 +2298,23 @@ async function main(argv) {
2261
2298
  }
2262
2299
  return 0;
2263
2300
  }
2301
+ const recommendAt = argv.indexOf('--recommend');
2302
+ if (recommendAt !== -1) {
2303
+ // The turn count is optional: with one the verdict is about that job,
2304
+ // without one it is about the headroom in general.
2305
+ const next = argv[recommendAt + 1];
2306
+ const turns = next && next.indexOf('--') !== 0 ? Number(next) : null;
2307
+ const recommend = require('./recommend.js');
2308
+ if (wantsJson) {
2309
+ process.stdout.write(
2310
+ JSON.stringify(recommend.decide(recommend.fromReport(data, turns)), null, 2) + '\n'
2311
+ );
2312
+ } else {
2313
+ process.stdout.write(recommend.renderRecommend(data, turns) + '\n');
2314
+ }
2315
+ return 0;
2316
+ }
2317
+
2264
2318
  if (wantsJson) {
2265
2319
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
2266
2320
  } else {
@@ -2295,6 +2349,7 @@ module.exports = {
2295
2349
  reconstructWindow,
2296
2350
  SATURATION_LIMIT,
2297
2351
  MIN_BASELINE_TURNS,
2352
+ MIN_BASELINE_PERCENT,
2298
2353
  buildWindows,
2299
2354
  limitWindows,
2300
2355
  lastRejections,