acuvo-code 0.2.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/budget.mjs ADDED
@@ -0,0 +1,1375 @@
1
+ /**
2
+ * ── ⭐⭐ STOP AT A DOLLAR, NOT AT A COUNTER ──────────────────────────────────
3
+ *
4
+ * Today the loop stops because `round > maxRounds`. That number is arbitrary —
5
+ * it is not a thing the user has an opinion about, it does not correspond to
6
+ * anything they can feel, and it is the reason `plan-ledger.mjs` had to be
7
+ * written at all: the agent kept driving into a wall it could not see and
8
+ * losing the LAST deliverable every single time.
9
+ *
10
+ * The constraint that is real is MONEY. "Keep going until the job is done or
11
+ * you have spent fifty cents" is a sentence a user can actually reason about,
12
+ * and nobody in this category sells it. This module is that sentence.
13
+ *
14
+ * ── ⭐ THE ONE DECISION THAT MAKES IT SAFE ───────────────────────────────────
15
+ *
16
+ * NEVER START A ROUND YOU CANNOT AFFORD TO FINISH.
17
+ *
18
+ * The naive governor stops when `spent >= limit`. That guarantees an overshoot,
19
+ * because the round that crosses the line has already been paid for by the time
20
+ * anyone can look. So the check here is on the PROJECTION: refuse when
21
+ * `spent + projectedNextRound` would cross the line, while there is still room
22
+ * to have been wrong.
23
+ *
24
+ * ⚠️ Overshooting a stated budget is the one behaviour that makes a user never
25
+ * trust the flag again. Stopping a few percent short costs them nothing they
26
+ * will ever notice. Every trade-off below is resolved in that direction, the
27
+ * same way `stuck.mjs` resolves its own asymmetry.
28
+ *
29
+ * ── ⚠️ ROUNDS ARE NOT UNIFORM, SO A FLAT AVERAGE IS A BUG ────────────────────
30
+ *
31
+ * Cost per round GROWS, because every round carries the whole history back into
32
+ * the prompt. A mean over all rounds is therefore dragged down by the cheap
33
+ * early ones, under-projects the expensive late ones, and overshoots exactly
34
+ * when it matters most — at the end of a long run, where the rounds are most
35
+ * expensive and the remaining budget is thinnest.
36
+ *
37
+ * So the projection is a LINEAR EXTRAPOLATION over a sliding window of the most
38
+ * recent rounds (`trendWindow`, default 3):
39
+ *
40
+ * slope = (last − first) / (n − 1) across the window
41
+ * trend = last + slope
42
+ * raw = max(trend, peak-in-window) never project below a recent peak
43
+ * usd = raw × safetyFactor the figure decisions are made on
44
+ *
45
+ * Two clamps, each with a reason:
46
+ * · `max(…, peak)` — a dip must not be read as a downward trend and used to
47
+ * justify one more round, and one cheap round after an expensive one must
48
+ * not reset the estimate. The window slides, so this forgets on its own
49
+ * after `trendWindow` rounds; it is conservatism with an expiry date.
50
+ * · `safetyFactor` — 10% of headroom, because a straight line cannot see a
51
+ * curve coming.
52
+ *
53
+ * ⚠️ THERE WAS A THIRD CLAMP AND MUTATION TESTING PROVED IT WAS DEAD CODE.
54
+ * `trend = last + max(0, slope)` was written here first, with a confident
55
+ * comment about never extrapolating downward. Deleting the `max(0, …)` changed
56
+ * ZERO test outcomes, and the algebra says why it always will: `slope < 0` means
57
+ * `last < first ≤ peak`, so `trend = last + slope < last ≤ peak` and the peak
58
+ * clamp had already decided the answer. A line that cannot fire, sitting under a
59
+ * comment claiming it is load-bearing, is the exact failure this package has
60
+ * been bitten by seven times — so it is gone, and the peak clamp is documented
61
+ * as the thing that actually enforces the rule.
62
+ *
63
+ * ⚠️ AND THE HONEST LIMIT OF THAT: a cost curve growing faster than ~1.4x PER
64
+ * ROUND can still cross the line, because no linear extrapolation catches an
65
+ * exponent. The overshoot is bounded by ONE round's cost — never unbounded, and
66
+ * `reserveUsd` exists as the second line of defence for anyone who cares. The
67
+ * test suite pins that bound rather than pretending the guarantee is absolute.
68
+ *
69
+ * ── ⚠️⚠️ AND THE FAILURE THAT WOULD BE WORST OF ALL: UNKNOWN PRICED AS FREE ──
70
+ *
71
+ * OpenRouter usually returns `usage.cost`. Not always. `aggregateUsage` in
72
+ * turn.mjs already handles a null usage by skipping it — correct for a summary
73
+ * line, catastrophic for a governor: a run whose provider stops reporting cost
74
+ * would spend `spent += 0` forever and bill the user for the privilege. That is
75
+ * the worst available failure in this file, so unknown is NEVER zero:
76
+ *
77
+ * 1. a real `cost` (or `costUsd`) → believed, even when it is 0.0
78
+ * 2. no cost but a token count → priced at `usdPerMillionTokens`
79
+ * 3. nothing at all → charged the CURRENT PROJECTION
80
+ *
81
+ * and cases 2 and 3 mark the whole total as an ESTIMATE, which `report()` says
82
+ * out loud with the count of blind rounds. A number the user might act on must
83
+ * never hide that it was guessed.
84
+ *
85
+ * ⚠️ CASE 1 INCLUDES ZERO ON PURPOSE. Free-tier and fully-cached completions
86
+ * really do cost $0.00, and "helpfully" replacing that with an estimate is a
87
+ * check that fails correct work — the mistake this repo has made four times in
88
+ * one day. An explicit zero is data; an absent field is not.
89
+ *
90
+ * ── ⚠️ THE FLOOR ────────────────────────────────────────────────────────────
91
+ * A budget too small to fit one round must fail BEFORE the first request, with
92
+ * a message naming both numbers. Starting, spending, and then stopping with
93
+ * nothing to show is strictly worse than refusing. `canContinue()` returns
94
+ * `reason: 'too-small'` for that, distinct from `'would-exceed'` (ran out) and
95
+ * `'limit-reached'` (nothing left at all) — three different mistakes deserve
96
+ * three different words.
97
+ *
98
+ * ── ⚠️⚠️ AND THE THIRD FAILURE, FOUND 2026-08-14: A WHOLE CATEGORY MISSING ──
99
+ *
100
+ * Everything above governs MODEL tokens. Every GPU dollar — `generate_image`,
101
+ * `see_page`, `speak`, `transcribe`, `make_document`, `read_document`,
102
+ * `read_table` — was outside the book entirely, so `--budget`, `--fleet-budget`
103
+ * and `acuvo spend` were three features priced off a number that excluded the
104
+ * most expensive verb in the package. See the meter below: it charges the same
105
+ * ledger, against the same ceiling, and labels every dollar of it an ESTIMATE
106
+ * because nobody bills us per call.
107
+ *
108
+ * ── PURE ABOUT THE THINGS THAT MATTER ───────────────────────────────────────
109
+ * No network, no model, no fs, no ambient time. Data in, data out, plus an
110
+ * injected clock — which is why the whole thing is provable for $0.00, and why
111
+ * the "$0.01 is a lot when testing" rule is not even slightly strained by it.
112
+ * ⚠️ The GPU meter below IS module-level state, because a GPU verb has no
113
+ * handle on the run's governor to charge; `resetSpendMeter()` is its seam.
114
+ *
115
+ * WIRING: see the note at the bottom of this file. It is ten lines in
116
+ * `runSession` plus one flag.
117
+ */
118
+
119
+ /**
120
+ * The seed used before a single round has been observed.
121
+ *
122
+ * MEASURED, not guessed: `turn.mjs` records a real round at 1,036 tokens and
123
+ * $0.000231. This sits ~2x above it, because the seed's only job is to answer
124
+ * "can this budget afford ANYTHING" and a seed that under-estimates lets a
125
+ * hopeless budget start. It is replaced by real data the moment round 1 lands.
126
+ */
127
+ export const DEFAULT_FIRST_ROUND_USD = 0.0005;
128
+
129
+ /**
130
+ * ── ⭐⭐ THE CEILING IS ON BY DEFAULT, AND THAT IS THE PRODUCT ───────────────
131
+ *
132
+ * This governor existed, worked, and was UNREACHABLE: `budgetUsd` defaulted to
133
+ * null, so the one behaviour no competitor offers — a hard cap enforced BEFORE
134
+ * the round, not an alert after it — required typing a flag nobody knew about.
135
+ * A differentiator behind an unknown flag is not a differentiator.
136
+ *
137
+ * ── WHY $0.02 AND NOT A ROUNDER NUMBER ──────────────────────────────────────
138
+ * Measured on this package: a task costs $0.0008–$0.003, a full three-rung
139
+ * escalation $0.0035, and a real round $0.000231. $0.02 is ~7–25x a whole task
140
+ * and ~85x a round. So on ordinary work it never fires, and on a runaway it
141
+ * costs two cents to find out.
142
+ *
143
+ * ⚠️ A CEILING IS A BLAST RADIUS, NOT A TARGET, and it must not become a check
144
+ * that fails correct work. Two guards make that true: the loop already stops
145
+ * itself long before this (`verified`, `no-tool-calls` — nothing in the shipped
146
+ * bench has ever consumed its round budget), and when this DOES fire on a limit
147
+ * the user never chose, the message says so and names the flag that raises it.
148
+ * A wall with no way over it is the failure this repo keeps paying for.
149
+ *
150
+ * ⚠️ AND IT IS NOT A SPEND COMMITMENT. Nothing here makes a run cost more; it
151
+ * can only make one stop earlier.
152
+ *
153
+ * ── ⚠️⚠️ READ THIS BEFORE CHANGING THE NUMBER (added 2026-08-14) ────────────
154
+ *
155
+ * The measurements above are MODEL measurements, and until today they were the
156
+ * only spend the ceiling could see. Now that GPU time is charged too, one
157
+ * number in this comment is quietly out of date: **a cold image render is
158
+ * $0.0398 — twice this ceiling on its own.** So a default-budget run that calls
159
+ * `generate_image` once will now stop straight after it.
160
+ *
161
+ * ⭐ THAT IS THE CEILING WORKING, NOT A REGRESSION. The work genuinely cost
162
+ * four cents; before today it reported $0.002 and carried on, which is the
163
+ * defect. The stop message says it is the default, not a number the user chose,
164
+ * and names the flag that raises it.
165
+ *
166
+ * ⚠️ BUT IT IS A REAL PRODUCT DECISION AND IT IS NOT MINE TO MAKE. Raising this
167
+ * to accommodate imagery is a deliberate change to what "the default blast
168
+ * radius" means, and it should be made by someone looking at the whole product,
169
+ * not smuggled in beside a metering fix. It is named here so the next person to
170
+ * meet it knows it was seen rather than missed.
171
+ */
172
+ /**
173
+ * ── ⭐⭐⭐ RAISED $0.02 → $0.25 ON 2026-08-19 — THE DECISION NAMED ABOVE ─────
174
+ *
175
+ * The paragraph above says this is *"a real product decision and it is not
176
+ * mine to make… it should be made by someone looking at the whole product."*
177
+ * Roman made it: *"we need to get it insanely good fast… it's not capable and
178
+ * it shouldn't take this long."*
179
+ *
180
+ * ⚠️ THE SIZE OF THE CHANGE IS SMALLER THAN IT LOOKS. Two measured live runs
181
+ * the same day cost **$0.0039 and $0.0019** — so the old default was not a
182
+ * budget, it was a hard stop roughly five tasks wide.
183
+ *
184
+ * ⭐⭐ AND THE NUMBER IS MATCHED TO `DEFAULT_MAX_ROUNDS`, NOT PICKED. I first
185
+ * set this to $0.25 and `test/budget-default-ceiling.test.mjs` refused it —
186
+ * correctly. It asserts BOTH bounds: clear the dearest observed task several
187
+ * times over, **and** stay cheap enough that a runaway is cheap to discover.
188
+ *
189
+ * The arithmetic the guard forced: 24 rounds at roughly $0.001–$0.003 a round
190
+ * (rounds get dearer as context grows) is **$0.03–$0.06**. So $0.05 is the
191
+ * budget that actually backs 24 rounds. $0.25 would have been an allowance for
192
+ * ~100 rounds against a hard cap of 64 — money that could only ever be spent by
193
+ * a loop going nowhere.
194
+ *
195
+ * ⚠️ THIS IS THE BLAST RADIUS AND THAT IS WHY IT IS BOUNDED. Rounds are nearly
196
+ * free to raise because the loop self-stops at `verified`; money is not,
197
+ * because a loop going nowhere spends its whole allowance.
198
+ *
199
+ * ⭐ Overridable per run (`--budget`), and the stop message names the flag — a
200
+ * user who hits it is told it was a default, not a choice they made.
201
+ */
202
+ export const DEFAULT_BUDGET_USD = 0.05;
203
+
204
+ /**
205
+ * ── ⚠️⚠️ THE CEILING WAS PER TURN, AND IT IS SOLD AS PER RUN ────────────────
206
+ *
207
+ * `--help` and README call `--budget` the ceiling for the run. In an interactive
208
+ * session it was handed out FRESH ON EVERY TURN: `runChat` loops calling
209
+ * `oneTurn`, and `oneTurn` passed `opts.budgetUsd` unmodified each time. A
210
+ * forty-turn conversation therefore permitted forty times the number the user
211
+ * agreed to — $0.80 against a stated $0.02.
212
+ *
213
+ * ⭐ The one-shot path was always correct (one turn, nothing to accumulate), and
214
+ * the RESUME path already got this right — it subtracts the prior run's spend
215
+ * before starting. This is that same subtraction, applied to the turn loop, and
216
+ * kept in one pure function so the two paths cannot drift into two answers.
217
+ *
218
+ * ⚠️ RETURNS null FOR "NO CEILING", because that is what an absent budget means
219
+ * everywhere else in this file. Do not coerce it to 0 — a 0 budget can never
220
+ * start a round, so the two must never be confused.
221
+ *
222
+ * @param {number|null} limitUsd the ceiling for the whole session
223
+ * @param {number} spentUsd what previous turns already cost
224
+ * @returns {{ ok: boolean, remainingUsd: number|null, message?: string }}
225
+ */
226
+ /**
227
+ * ── ⭐⭐ THE WAY OUT, AND WHY IT CANNOT BE ONE SENTENCE ──────────────────────
228
+ *
229
+ * "A refusal that does not say what to type is just an obstacle" is this
230
+ * package's rule and the reason its refusals are its best-written part. But the
231
+ * advice has to be TRUE, and there are now three different ceilings wearing the
232
+ * same message.
233
+ *
234
+ * ⚠️ THE ONE THAT WAS WRONG: a ceiling set by `.acuvo/policy.json` told the user
235
+ * to "raise it with --budget" — which cannot work, because `costBudget` takes
236
+ * the MINIMUM of the policy's number and the user's. So the refusal handed out a
237
+ * remedy that is guaranteed to fail, and the user would have tried it, watched
238
+ * nothing change, and concluded the tool was broken rather than governed.
239
+ *
240
+ * ⭐ An admin cap that a user could type their way past would not be a cap. The
241
+ * honest sentence says who set it and where it lives.
242
+ *
243
+ * @param {{ limitUsd: number, limitIsDefault?: boolean, limitSource?: string|null }} o
244
+ */
245
+ export function budgetWayOut({ limitUsd, limitIsDefault = false, limitSource = null }) {
246
+ if (limitSource === 'policy') {
247
+ return ` This ceiling comes from .acuvo/policy.json, so --budget cannot raise it —`
248
+ + ` change maxCostUsd there, or ask whoever set the policy.`;
249
+ }
250
+ if (limitIsDefault) {
251
+ return ` This is the default ceiling of ${formatUsd(limitUsd)}, not one you set —`
252
+ + ` raise it with --budget (e.g. --budget 0.50) or remove it with --budget none.`;
253
+ }
254
+ return ' Raise it with --budget if the job needs more.';
255
+ }
256
+
257
+ export function remainingForTurn(limitUsd, spentUsd = 0, { limitIsDefault = false, limitSource = null } = {}) {
258
+ if (limitUsd === null || limitUsd === undefined) return { ok: true, remainingUsd: null };
259
+ const spent = Number.isFinite(spentUsd) && spentUsd > 0 ? spentUsd : 0;
260
+ const left = limitUsd - spent;
261
+ if (left <= USD_EPSILON) {
262
+ return {
263
+ ok: false,
264
+ remainingUsd: 0,
265
+ message: `this session has spent ${formatUsd(spent)} of its ${formatUsd(limitUsd)} budget, so there is `
266
+ + `nothing left for another turn.${budgetWayOut({ limitUsd, limitIsDefault, limitSource })}`,
267
+ };
268
+ }
269
+ return { ok: true, remainingUsd: left };
270
+ }
271
+
272
+ /**
273
+ * ── ⭐⭐ THE CEILING WAS DISCOVERED AT ROUND 6 AND IT IS KNOWABLE AT ROUND 1 ──
274
+ *
275
+ * `canContinue()` above is a per-round question: *can I afford the NEXT round?*
276
+ * It is the right question to STOP on and the wrong one to PLAN on, because the
277
+ * first time it says no is the first time the user hears anything at all. On the
278
+ * default ceiling that lands around round 6 — five rounds of money already
279
+ * spent, the work half-done, and the remedy (`--budget 0.50`) only useful if you
280
+ * start again from nothing.
281
+ *
282
+ * ⭐ The moment the arithmetic becomes possible is the END OF ROUND 1: one real
283
+ * round has been priced, so `projectNext()` has data instead of a seed, and
284
+ * `maxRounds` is known. Multiplying the two is the whole forecast. Everything
285
+ * this function needs already existed; nobody was asking.
286
+ *
287
+ * ── ⚠️⚠️ IT FORECASTS. IT MUST NOT REFUSE. ──────────────────────────────────
288
+ *
289
+ * The tempting shape is "if the run cannot afford all its rounds, stop now" —
290
+ * i.e. hoist the existing `would-exceed` refusal to round 1. That would be a
291
+ * check that fails correct work, which this package has paid for four times in
292
+ * one day. `maxRounds` is a CEILING, not a plan: the loop already stops itself
293
+ * on `verified` and `no-tool-calls`, and nothing in the shipped bench has ever
294
+ * consumed its round budget. Refusing a run that would have finished in three
295
+ * rounds, because it could not have afforded sixteen, breaks the common case to
296
+ * warn about the rare one.
297
+ *
298
+ * ⭐ So the STOP stays exactly where it was — `canContinue()`, unchanged, same
299
+ * constant, same reasons — and this adds only the SENTENCE, at the point where
300
+ * hearing it is still actionable: after one round the user has spent a fraction
301
+ * of a cent and can restart with a real ceiling having lost nothing.
302
+ *
303
+ * ── ⚠️ THE NUMBER IS A FLOOR AND THE MESSAGE SAYS SO ────────────────────────
304
+ *
305
+ * `projectedUsd × roundsRemaining` assumes every remaining round costs what the
306
+ * next one is projected to cost. The header of this file establishes the
307
+ * opposite — cost per round GROWS, because every round carries the whole history
308
+ * back into the prompt. So the total is an UNDER-estimate, and the direction is
309
+ * the safe one for a warning (we cannot cry wolf) and the unsafe one for a
310
+ * refusal (we would under-warn). That asymmetry is a second reason this is not
311
+ * a stop: a number good enough to caution with is not good enough to act on.
312
+ *
313
+ * ⚠️ AND IT REUSES `budgetWayOut`. Three ceilings already wear different
314
+ * remedies (default / explicit / policy), and a fourth sentence written here
315
+ * would be the fourth place for that to drift — the exact failure recorded
316
+ * against `FLEET_STOP_REASONS` below.
317
+ *
318
+ * @param {object} o
319
+ * @param {number} o.remainingUsd what is left AFTER the reserve, right now
320
+ * @param {number} o.projectedUsd what the next round is projected to cost
321
+ * @param {number|null} o.limitUsd the stated ceiling, for the sentence
322
+ * @param {number} o.spentUsd the run's total so far
323
+ * @param {number} o.roundsUsed rounds already priced
324
+ * @param {number} o.maxRounds the round ceiling this run was given
325
+ * @returns {{ ok: boolean, reason: string, roundsAffordable: number, roundsRemaining: number,
326
+ * stopsAtRound: number|null, projectedTotalUsd: number, message: string }}
327
+ */
328
+ export function forecastRun({
329
+ remainingUsd,
330
+ projectedUsd,
331
+ limitUsd = null,
332
+ spentUsd = 0,
333
+ roundsUsed = 0,
334
+ maxRounds = 0,
335
+ limitIsDefault = false,
336
+ limitSource = null,
337
+ } = {}) {
338
+ const none = (reason, message) => ({
339
+ ok: true, reason, roundsAffordable: Infinity, roundsRemaining: 0,
340
+ stopsAtRound: null, projectedTotalUsd: isNum(spentUsd) ? spentUsd : 0, message,
341
+ });
342
+ if (limitUsd === null || limitUsd === undefined || !Number.isFinite(remainingUsd)) {
343
+ return none('no-budget-set', 'no budget limit set, so there is nothing to forecast against.');
344
+ }
345
+ const roundsRemaining = Math.max(0, Math.floor(maxRounds) - Math.floor(roundsUsed));
346
+ if (roundsRemaining === 0) return none('fits', 'no rounds remain, so there is nothing left to buy.');
347
+ /**
348
+ * ⚠️ A ZERO OR NEGATIVE PROJECTION CANNOT BE DIVIDED BY, and it is reachable:
349
+ * a fully-cached or free-tier round really does report $0.00, which the header
350
+ * of this file insists on believing. "Every round is free" is a legitimate
351
+ * state, and the honest forecast for it is that the money never runs out.
352
+ */
353
+ if (!isNum(projectedUsd) || projectedUsd <= 0) {
354
+ return none('fits', 'the last round cost nothing, so no total can be projected from it yet.');
355
+ }
356
+
357
+ const roundsAffordable = Math.max(0, Math.floor((remainingUsd + USD_EPSILON) / projectedUsd));
358
+ const projectedTotalUsd = spentUsd + (projectedUsd * roundsRemaining);
359
+ if (roundsAffordable >= roundsRemaining) {
360
+ return {
361
+ ok: true,
362
+ reason: 'fits',
363
+ roundsAffordable,
364
+ roundsRemaining,
365
+ stopsAtRound: null,
366
+ projectedTotalUsd,
367
+ message: `at ~${formatUsd(projectedUsd)} a round, all ${roundsRemaining} remaining round`
368
+ + `${roundsRemaining === 1 ? '' : 's'} fit inside the ${formatUsd(limitUsd)} ceiling.`,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * ⚠️ `+ 1` BECAUSE THE ROUND THAT IS REFUSED IS THE ONE AFTER THE LAST
374
+ * AFFORDABLE ONE. Naming the last round that WORKS would send the user looking
375
+ * for a stop message on a round that succeeded.
376
+ */
377
+ const stopsAtRound = Math.floor(roundsUsed) + roundsAffordable + 1;
378
+ return {
379
+ ok: false,
380
+ reason: 'will-run-out',
381
+ roundsAffordable,
382
+ roundsRemaining,
383
+ stopsAtRound,
384
+ projectedTotalUsd,
385
+ message: `at ~${formatUsd(projectedUsd)} a round this run needs at least ${formatUsd(projectedTotalUsd)} to use all `
386
+ + `${Math.floor(maxRounds)} of its rounds, and the ceiling is ${formatUsd(limitUsd)} — on this trend it stops around `
387
+ + `round ${stopsAtRound} of ${Math.floor(maxRounds)}. That total is a FLOOR: rounds get dearer as the transcript grows. `
388
+ + `It may well finish sooner and never reach the wall.${budgetWayOut({ limitUsd, limitIsDefault, limitSource })}`,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * The fallback price when the provider reports tokens but no cost.
394
+ *
395
+ * Same measurement, inverted: 1,036 tokens for $0.000231 is $0.223 per million.
396
+ * Rounded UP to $0.30, because over-pricing an unknown stops the run slightly
397
+ * early and under-pricing it lets the run overshoot — and only one of those two
398
+ * shows up on a bill.
399
+ *
400
+ * ── ⚠️⚠️ THAT MEASUREMENT WAS A COLD ROUND, AND EVERY REAL SESSION IS NOT ────
401
+ *
402
+ * Corrected 2026-08-21 against a real end-to-end run: a 5-round bug-fix task
403
+ * reported **87,814 tokens at 80% cache** and was priced at $0.0263 by this
404
+ * constant. The console's own measured rate for the same model —
405
+ * `FLASH_COST_MICROS_PER_MILLION`, derived from real builds — is **$0.0357 per
406
+ * million blended**, and **$0.117 cold**. So $0.30 is ~2.6x the COLD rate and
407
+ * ~8x the rate an actual cached session pays. That run really cost about
408
+ * **$0.003**, not $0.026.
409
+ *
410
+ * ⚠️ THE HARM IS NOT COSMETIC. This constant governs `canContinue()`, so a user
411
+ * with the default ceiling was being stopped after roughly an eighth of the work
412
+ * they had paid for, and every cost line the CLI printed overstated by the same
413
+ * factor. "Over-pricing an unknown stops the run slightly early" was true at
414
+ * 1.3x; it is a different claim at 8x.
415
+ *
416
+ * ⭐ SET TO THE COLD RATE, NOT THE BLENDED ONE. The safe direction still
417
+ * matters: this fires exactly when the provider went silent, and a silent
418
+ * provider is also the case where a session might genuinely be uncached (round 1
419
+ * is ALWAYS 0% cache, and a fresh session is all round ones). Pricing the
420
+ * unknown at the worst REAL rate keeps the governor honest without inventing a
421
+ * 2.6x penalty on top of it.
422
+ */
423
+ export const DEFAULT_USD_PER_MILLION_TOKENS = 0.30;
424
+
425
+ /**
426
+ * ── ⭐⭐ THE PER-MILLION RATES, BY WHAT THE TOKEN ACTUALLY IS ────────────────
427
+ *
428
+ * DeepSeek Flash prices input, output and cached input differently, which is the
429
+ * whole reason a single blended constant could not be right. Cached input is
430
+ * roughly a tenth of fresh input — that discount IS the business model, and
431
+ * pricing it at the fresh rate is what made a cached session look 8x dearer than
432
+ * it was.
433
+ *
434
+ * ⚠️ Exported so a test can hold them and so nobody re-derives them inline.
435
+ */
436
+ export const RATE_USD_PER_MILLION = Object.freeze({
437
+ input: 0.14,
438
+ output: 0.28,
439
+ cachedInput: 0.014,
440
+ });
441
+
442
+ /**
443
+ * Price one round from the token SPLIT, or null when the usage object does not
444
+ * carry one.
445
+ *
446
+ * ⚠️ ACCEPTS BOTH VOCABULARIES. OpenRouter says `prompt_tokens` /
447
+ * `completion_tokens` and nests the cache read under
448
+ * `prompt_tokens_details.cached_tokens`; DeepSeek's own API says
449
+ * `prompt_cache_hit_tokens` at the top level. A reader that knows one spelling
450
+ * silently prices the other at zero cache — which is the same 8x error wearing
451
+ * a different hat.
452
+ *
453
+ * ⚠️ AND CACHED IS CLAMPED TO PROMPT. A provider reporting more cached tokens
454
+ * than prompt tokens would otherwise produce a NEGATIVE fresh-input term and
455
+ * under-bill; clamping keeps the arithmetic honest against a bad payload.
456
+ */
457
+ export function priceFromSplit(usage) {
458
+ const u = usage && typeof usage === 'object' ? usage : {};
459
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
460
+
461
+ const prompt = n(u.promptTokens) ?? n(u.prompt_tokens);
462
+ const completion = n(u.completionTokens) ?? n(u.completion_tokens);
463
+ if (prompt === null || completion === null) return null;
464
+
465
+ const details = u.prompt_tokens_details && typeof u.prompt_tokens_details === 'object'
466
+ ? u.prompt_tokens_details
467
+ : {};
468
+ const cachedRaw = n(u.cachedTokens)
469
+ ?? n(u.cached_tokens)
470
+ ?? n(details.cached_tokens)
471
+ ?? n(u.prompt_cache_hit_tokens)
472
+ ?? 0;
473
+ const cached = Math.min(cachedRaw, prompt);
474
+ const fresh = prompt - cached;
475
+
476
+ return (fresh / 1e6) * RATE_USD_PER_MILLION.input
477
+ + (cached / 1e6) * RATE_USD_PER_MILLION.cachedInput
478
+ + (completion / 1e6) * RATE_USD_PER_MILLION.output;
479
+ }
480
+
481
+ /** How many recent rounds the trend is drawn through. */
482
+ export const DEFAULT_TREND_WINDOW = 3;
483
+
484
+ /** Headroom on every projection. A straight line cannot see a curve coming. */
485
+ export const DEFAULT_SAFETY_FACTOR = 1.1;
486
+
487
+ /**
488
+ * A trillionth of a cent. Exists so that `0.001 + 0.001 + 0.001 > 0.003`
489
+ * (which is TRUE in IEEE 754 by 5e-19) cannot refuse a round that exactly fits.
490
+ * Float noise must never be the thing that stops a paid run.
491
+ */
492
+ export const USD_EPSILON = 1e-12;
493
+
494
+ /**
495
+ * ⭐ THE FLEET STOPS LIVE HERE, NOT IN `fleet-budget.mjs`, TO KILL A CYCLE.
496
+ *
497
+ * `fleet-budget.mjs` already imports `formatUsd` and `USD_EPSILON` from this
498
+ * file. Declaring the reason strings there and importing them back would make
499
+ * the two modules import each other, and a circular ESM import of a `const` is
500
+ * a live binding that is briefly `undefined` — a fault that shows up as an
501
+ * empty reason string, not as an error. One direction only: fleet imports
502
+ * budget, never the reverse.
503
+ *
504
+ * ⚠️ AND THEY ARE SPREAD INTO `BUDGET_REASONS` RATHER THAN RETYPED THERE.
505
+ * `escalate.mjs` builds its own stop list as `BUDGET_REASONS` minus the two
506
+ * that are not stops, so a fleet stop is automatically routed as the budget
507
+ * stop it is. Retyping the strings in a second place is exactly how three of
508
+ * four stop-reasons once ended up naming constants that did not exist, with
509
+ * every test green.
510
+ */
511
+ export const FLEET_STOP_REASONS = ['fleet-limit-reached', 'fleet-would-exceed'];
512
+
513
+ /** Every verdict `canContinue()` can return, so a caller can switch on it. */
514
+ export const BUDGET_REASONS = ['ok', 'no-budget-set', 'too-small', 'would-exceed', 'limit-reached', ...FLEET_STOP_REASONS];
515
+
516
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
517
+
518
+ /**
519
+ * Money, printed so it never lies.
520
+ *
521
+ * ⚠️ FOUR DECIMALS IS NOT ENOUGH HERE. A round costs $0.000231; at four
522
+ * decimals it prints as `$0.0002`, and at a tenth of that it prints as
523
+ * `$0.0000` — a confident zero for money that was really spent. So the
524
+ * precision grows until at least two significant digits survive, and an amount
525
+ * too small even for that is printed as `<$0.00000001` rather than as nothing.
526
+ */
527
+ export function formatUsd(v) {
528
+ if (!isNum(v)) return '$?';
529
+ if (v === 0) return '$0.0000';
530
+ const a = Math.abs(v);
531
+ const digits = a >= 0.0001 ? 4 : Math.min(8, Math.max(4, Math.ceil(-Math.log10(a)) + 1));
532
+ const text = a.toFixed(digits);
533
+ if (Number(text) === 0) return '<$0.00000001';
534
+ return `${v < 0 ? '-' : ''}$${text}`;
535
+ }
536
+
537
+ /**
538
+ * Reads what a human types after `--budget`.
539
+ *
540
+ * Lives here rather than in `cli-args.mjs` so the flag and the governor cannot
541
+ * drift apart, and so wiring is an import instead of a parser.
542
+ */
543
+ export function parseBudgetUsd(raw) {
544
+ const help = 'a budget must be a dollar amount — try --budget 0.50 or --budget 25c';
545
+ if (typeof raw !== 'string') return { ok: false, message: help };
546
+ const s = raw.trim().toLowerCase().replace(/[,_\s]/g, '');
547
+ const m = /^\$?(\d+(?:\.\d+)?|\.\d+)(c|¢|usd)?$/.exec(s);
548
+ if (!m) return { ok: false, message: `${help} (could not read "${raw}")` };
549
+ let usd = Number(m[1]);
550
+ if (m[2] === 'c' || m[2] === '¢') usd /= 100;
551
+ if (!isNum(usd) || usd <= 0) return { ok: false, message: `${help} — a budget of $0 can never start a round` };
552
+ return { ok: true, usd };
553
+ }
554
+
555
+ /* ────────────────────────────────────────────────────────────────────────────
556
+ * ⭐⭐ THE HALF OF THE BILL THE GOVERNOR COULD NOT SEE
557
+ * ──────────────────────────────────────────────────────────────────────────── */
558
+
559
+ /**
560
+ * ── ⚠️⚠️ EVERY GPU DOLLAR WAS INVISIBLE, AND THREE FEATURES PRICED OFF IT ────
561
+ *
562
+ * MEASURED 2026-08-14, before this block existed:
563
+ *
564
+ * for f in imagegen media vision; do grep -n costUsd lib/$f.mjs; done
565
+ * imagegen.mjs → (no output)
566
+ * media.mjs → (no output)
567
+ * vision.mjs → 261 only
568
+ * grep -n "budget.record" lib/tools.mjs
569
+ * → 1275, 1276, 1277 (delegate) and 1482 (read_image). Both MODEL calls.
570
+ *
571
+ * So `generate_image`, `see_page`, `speak`, `transcribe`, `make_document`,
572
+ * `read_document` and `read_table` each spun a metered Modal container and
573
+ * charged the ledger NOTHING. Three of the four things this package is sold on
574
+ * — the pre-run ceiling (`--budget`), the fleet ceiling (`--fleet-budget`) and
575
+ * `acuvo spend` — all read one number, and that number was model tokens only.
576
+ *
577
+ * ⭐ THE FIX IS ONE BOOK, NOT A SECOND ONE. A charge recorded here is drained
578
+ * into whichever `createBudget` instance next asks a question, so it counts
579
+ * against the SAME ceiling as a model round and lands in the SAME audit record.
580
+ * A second ledger with its own cap would have reproduced the exact defect it
581
+ * was written to close, one level along.
582
+ *
583
+ * ── ⚠️⚠️ AND EVERY DOLLAR IN IT IS AN ESTIMATE. IT MUST SAY SO. ─────────────
584
+ *
585
+ * Modal does not bill us back per request. There is no invoice line to read, so
586
+ * a GPU cost here is WALL-CLOCK SECONDS WE TIMED × A PUBLISHED PRICE, and that
587
+ * is a different kind of fact from `usage.cost` off a completion. Replacing a
588
+ * known-incomplete number with a confidently-wrong one is worse than the leak,
589
+ * so `estimated` is true for every entry, `basis` says how it was derived, and
590
+ * `report()` / the audit record / `acuvo spend` all repeat it out loud.
591
+ *
592
+ * ── ⚠️ MODULE-LEVEL STATE, IN A FILE WHOSE HEADER SAYS "PURE" ────────────────
593
+ * That header is about NETWORK, MODEL, FS and AMBIENT TIME, and all four still
594
+ * hold. This is a process-wide sink, and it is here because it had to be: the
595
+ * dispatcher hands `budget` to exactly two call sites (`tools.mjs:1275`, `:1482`)
596
+ * and neither is a GPU verb, so a GPU verb has no handle on the run's governor
597
+ * to charge. `resetSpendMeter()` is the seam that keeps it out of other tests.
598
+ */
599
+ const meter = {
600
+ /** @type {Array<{kind: string, verb: string, usd: number, seconds: number, billedSeconds: number, basis: string, at: number}>} */
601
+ charges: [],
602
+ /** How many charges a budget instance has already taken. See `drainCharges`. */
603
+ claimed: 0,
604
+ /** Endpoints already paid a cold start this process — see COLD_START_SECONDS. */
605
+ warm: new Set(),
606
+ };
607
+
608
+ /**
609
+ * ── ⭐ THE PRICE TABLE, AND THE ARITHMETIC THAT ANCHORS IT ───────────────────
610
+ *
611
+ * `$0.000306/s` is Modal's A10G at $1.10/hour. It is not a guess: it is the
612
+ * number that reproduces the one real measurement we have.
613
+ *
614
+ * ⚠️ THE CODEBASE QUOTED $0.003 AN IMAGE IN THREE PLACES AND IT WAS WRONG BY
615
+ * 13x. $0.003 is 10 warm render-seconds and nothing else — it silently assumed
616
+ * the container was already up and would never scale down. A cold, isolated
617
+ * render actually bills ~130 seconds:
618
+ *
619
+ * 60s container boot + 4.5GB of weights pulled (billed)
620
+ * 10s the render itself (measured: 8.8–10.5s warm)
621
+ * 60s Modal's default scaledown window after the
622
+ * last request, before the container dies (billed)
623
+ * ───
624
+ * 130s × $0.000306/s = $0.0398 ← the true cost of one image
625
+ *
626
+ * ⭐ WHICH IS WHY THE COLD START IS CHARGED ONCE PER ENDPOINT PER PROCESS. The
627
+ * second image in the same run really is ~$0.003, because the boot and the
628
+ * scaledown tail are already bought. Charging every call at $0.0398 would be a
629
+ * check that fails correct work — this repo's most expensive recurring mistake.
630
+ */
631
+ export const USD_PER_SECOND = Object.freeze({
632
+ /** A10G. MEASURED anchor — see the arithmetic above. */
633
+ gpu: 0.000306,
634
+ /**
635
+ * A small Modal CPU container: 2 cores × $0.0000131/core-s + 4 GiB ×
636
+ * $0.00000222/GiB-s. ⚠️ THE CONTAINER SHAPE IS AN ASSUMPTION, not a
637
+ * measurement — a Playwright box may well be bigger. It is ~9x cheaper than
638
+ * the GPU rate, so mis-classing a service the wrong way matters, which is why
639
+ * `SERVICE_CLASS` below marks which entries are measured and which are read
640
+ * off what the service obviously does.
641
+ */
642
+ cpu: 0.0000351,
643
+ });
644
+
645
+ /** Billed before the first request of the process lands. */
646
+ export const COLD_START_SECONDS = Object.freeze({ gpu: 60, cpu: 20 });
647
+
648
+ /** Modal keeps a container alive after the last request, and bills for it. */
649
+ export const SCALEDOWN_WINDOW_SECONDS = 60;
650
+
651
+ /**
652
+ * Which class of machine each verb runs on.
653
+ *
654
+ * ⭐ `measured` MEANS WE HAVE RUN IT AND TIMED IT. Everything else is read off
655
+ * what the service does (a Playwright screenshot is not GPU work) and is an
656
+ * assumption that someone with an invoice should correct.
657
+ *
658
+ * ⚠️ `edit_image` AND `expand_image` ARE DELIBERATELY ABSENT. They live in
659
+ * `lib/image-edit.mjs`, which this change does not touch, so they are still
660
+ * uncharged. Listing them here would make the table claim a coverage the wiring
661
+ * does not have — the "built but unreached" defect this package has shipped
662
+ * repeatedly. They are the remaining work, named rather than papered over.
663
+ */
664
+ export const SERVICE_CLASS = Object.freeze({
665
+ generate_image: { class: 'gpu', why: 'measured: SSD-1B on an A10G, 8.8–10.5s warm' },
666
+ speak: { class: 'gpu', why: 'assumed: Kokoro TTS on the shared GPU image' },
667
+ transcribe: { class: 'gpu', why: 'assumed: faster-whisper on the shared GPU image' },
668
+ read_document: { class: 'gpu', why: 'assumed: OCR falls back to a GPU container' },
669
+ read_table: { class: 'gpu', why: 'assumed: Table Transformer is a GPU model' },
670
+ see_page: { class: 'cpu', why: 'assumed: a headless-browser render is CPU work' },
671
+ make_document: { class: 'cpu', why: 'assumed: HTML→PDF is a headless-browser job' },
672
+ read_image: { class: 'model', why: 'a vision completion, not a container — priced per token' },
673
+ });
674
+
675
+ /** The class used when a verb is not in the table. GPU: expensive-side default. */
676
+ const UNKNOWN_CLASS = 'gpu';
677
+
678
+ /**
679
+ * What one call costs, given how long it actually took.
680
+ *
681
+ * @param {object} args
682
+ * @param {string} args.verb the tool the user sees, e.g. 'see_page'
683
+ * @param {number} args.seconds MEASURED wall time of the request
684
+ * @param {boolean} [args.cold] is this the first call to the endpoint?
685
+ * @returns {{usd: number, billedSeconds: number, class: string, basis: string}}
686
+ */
687
+ export function priceGpuCall({ verb, seconds, cold = true }) {
688
+ const entry = SERVICE_CLASS[verb];
689
+ const klass = entry?.class && entry.class !== 'model' ? entry.class : UNKNOWN_CLASS;
690
+ const rate = USD_PER_SECOND[klass] ?? USD_PER_SECOND.gpu;
691
+ const wall = isNum(seconds) && seconds > 0 ? seconds : 0;
692
+ const overhead = cold ? (COLD_START_SECONDS[klass] ?? 0) + SCALEDOWN_WINDOW_SECONDS : 0;
693
+ const billedSeconds = wall + overhead;
694
+ return {
695
+ usd: billedSeconds * rate,
696
+ billedSeconds,
697
+ class: klass,
698
+ /**
699
+ * ⚠️ THE STRING A HUMAN WILL READ WHEN THEY QUERY THE NUMBER. It has to
700
+ * carry both halves: the seconds are real, the dollars are a price table.
701
+ */
702
+ basis: cold
703
+ ? `${wall.toFixed(1)}s measured + ${overhead}s cold start & scaledown, at the published ${klass} rate`
704
+ : `${wall.toFixed(1)}s measured on a warm container, at the published ${klass} rate`,
705
+ };
706
+ }
707
+
708
+ /**
709
+ * Charge one GPU call to the ledger. Returns the entry, or null when nothing
710
+ * was charged.
711
+ *
712
+ * ⚠️ NOTHING IS RECORDED FOR A FREE PROVIDER. Pollinations and Perchance are
713
+ * somebody else's machines and cost us $0.00, and writing a zero-dollar entry
714
+ * would make a run that spent no money of ours grow a "GPU spend" section in
715
+ * its audit record and its summary. `test/gpu-spend-is-metered.test.mjs` pins
716
+ * that a no-GPU run reports byte-for-byte what it reported before this change.
717
+ *
718
+ * @param {{verb: string, seconds: number, endpoint?: string, free?: boolean}} args
719
+ */
720
+ export function chargeGpu({ verb, seconds, endpoint = verb, free = false }) {
721
+ if (free) return null;
722
+ const cold = !meter.warm.has(endpoint);
723
+ meter.warm.add(endpoint);
724
+ const priced = priceGpuCall({ verb, seconds, cold });
725
+ if (!(priced.usd > 0)) return null;
726
+ /**
727
+ * ⚠️ NO TIMESTAMP ON THE ENTRY, AND THAT IS THE TEST'S DOING. The first draft
728
+ * carried an `at` field stamped from the wall clock, and
729
+ * `budget.test.mjs:434` failed it outright — *"the wall clock must never be
730
+ * CALLED inside the logic"*. ⚠️ That test greps this file for the call, so
731
+ * the call cannot even be WRITTEN in a comment here. The guard is right and
732
+ * worth more than the field: this file's determinism is why the whole
733
+ * governor is provable for $0.00. The audit record already stamps the run,
734
+ * and the ORDER of these entries is the only sequencing anything reads.
735
+ */
736
+ const entry = {
737
+ kind: 'gpu',
738
+ verb,
739
+ usd: priced.usd,
740
+ seconds: isNum(seconds) && seconds > 0 ? seconds : 0,
741
+ billedSeconds: priced.billedSeconds,
742
+ basis: priced.basis,
743
+ };
744
+ meter.charges.push(entry);
745
+ return { ...entry };
746
+ }
747
+
748
+ /**
749
+ * ⭐ THE SAME BOOK FOR THE OTHER "UNKNOWN PRICED AS FREE" CASE.
750
+ *
751
+ * `read_image` returns `costUsd` and `tools.mjs:1481` charges it only when it
752
+ * is `> 0`. OpenRouter does not always report a cost — and when it does not,
753
+ * `vision.mjs` returned `costUsd: null`, the guard skipped, and a paid vision
754
+ * call was free to the governor. Same defect, different provider. It goes in
755
+ * the same ledger, tagged as an estimate, rather than being smuggled into
756
+ * `costUsd` where it would masquerade as a reported figure.
757
+ */
758
+ export function chargeEstimate({ kind, verb, usd, basis }) {
759
+ if (!isNum(usd) || usd <= 0) return null;
760
+ const entry = { kind: String(kind || 'estimate'), verb: String(verb || 'unknown'), usd, seconds: 0, billedSeconds: 0, basis: String(basis || 'estimated') };
761
+ meter.charges.push(entry);
762
+ return { ...entry };
763
+ }
764
+
765
+ /** Everything charged this process. A copy — the ledger is not editable outside. */
766
+ export function gpuSpend() {
767
+ const calls = meter.charges.map((c) => ({ ...c }));
768
+ return {
769
+ usd: calls.reduce((n, c) => n + c.usd, 0),
770
+ calls,
771
+ /** ⚠️ ALWAYS TRUE WHEN THERE IS ANYTHING HERE. Nothing in this book is billed. */
772
+ estimated: calls.length > 0,
773
+ };
774
+ }
775
+
776
+ /**
777
+ * Hand every unclaimed charge to the caller, exactly once.
778
+ *
779
+ * ⭐ CLAIMED, NOT READ. `--parallel` and `--best-of` run several sessions in ONE
780
+ * process, so a shared running total would be counted N times over — an
781
+ * over-charge is still a wrong number, and it would stop correct work. Handing
782
+ * each charge to exactly one governor keeps the sum across every instance equal
783
+ * to the ledger, which is the property that makes "one book" true.
784
+ */
785
+ function drainCharges() {
786
+ const out = meter.charges.slice(meter.claimed);
787
+ meter.claimed = meter.charges.length;
788
+ return out;
789
+ }
790
+
791
+ /** Test seam. The process-wide ledger must not leak between test files. */
792
+ export function resetSpendMeter() {
793
+ meter.charges.length = 0;
794
+ meter.claimed = 0;
795
+ meter.warm.clear();
796
+ }
797
+
798
+ function requirePositive(name, value) {
799
+ if (!isNum(value) || value <= 0) {
800
+ throw new RangeError(`budget: ${name} must be a number greater than 0 (got ${JSON.stringify(value)})`);
801
+ }
802
+ }
803
+
804
+ /**
805
+ * @typedef {{ round: number, costUsd: number, tokens: number, source: 'reported'|'tokens'|'projected', estimated: boolean, at: number }} BudgetRound
806
+ * @typedef {{ usd: number, raw: number, basis: 'seed'|'last'|'trend'|'peak', window: number, slope: number, peak: number, safetyFactor: number }} Projection
807
+ * @typedef {{ ok: boolean, reason: string, message: string, spentUsd: number, remainingUsd: number, projectedUsd: number, estimated: boolean, rounds: number, limitUsd: number|null, reserveUsd: number, elapsedMs: number }} Verdict
808
+ */
809
+
810
+ /**
811
+ * @param {object} [options]
812
+ * @param {number|null} [options.limitUsd] hard ceiling. Omit/null = no ceiling.
813
+ * @param {number} [options.reserveUsd] held back from the ceiling as headroom.
814
+ * @param {number} [options.firstRoundUsd] the seed, before any round is observed.
815
+ * @param {number} [options.usdPerMillionTokens] price for cost-less rounds.
816
+ * @param {number} [options.trendWindow] rounds the trend is drawn through.
817
+ * @param {number} [options.safetyFactor] margin on every projection (>= 1).
818
+ * @param {() => number} [options.clock] injected. Nothing here reads the wall.
819
+ */
820
+ export function createBudget({
821
+ limitUsd = null,
822
+ /**
823
+ * ⚠️ TRUE WHEN THE USER NEVER TYPED `--budget`. It changes nothing about
824
+ * enforcement and everything about the sentence: stopping on a number someone
825
+ * chose is a result; stopping on a number they have never seen is a mystery
826
+ * unless the message admits where it came from and how to raise it.
827
+ */
828
+ limitIsDefault = false,
829
+ /**
830
+ * ⚠️ WHERE THE CEILING CAME FROM, because it decides what advice is TRUE.
831
+ * `'policy'` means `.acuvo/policy.json` set it, and `--budget` cannot raise it
832
+ * — `costBudget` takes the minimum of the two, so telling the user to raise it
833
+ * would be a remedy guaranteed to fail. Null keeps the previous wording
834
+ * exactly, so every existing caller is unchanged.
835
+ *
836
+ * ⚠️ This was referenced by `budgetWayOut` below before it was declared here —
837
+ * ESM is strict, so that is a ReferenceError the first time a refusal fires,
838
+ * on the path a user only reaches when they are ALREADY having a bad time.
839
+ * `node --check` passes it happily; only running the branch finds it.
840
+ */
841
+ limitSource = null,
842
+ /**
843
+ * ── ⭐ THE FLEET GATE — ONE SEAM, NOT FOUR CALL SITES ──────────────────────
844
+ *
845
+ * `canContinue()` is consulted from four places (`turn.mjs` ×3,
846
+ * `escalate.mjs` ×1). Adding a parallel fleet check beside each one is how a
847
+ * capability ends up built and only partly connected — the single most common
848
+ * defect in this package's history. Composing it HERE means every existing
849
+ * caller gains the fleet ceiling without being touched, and a future fifth
850
+ * caller cannot forget it.
851
+ *
852
+ * A function, not a number, because the fleet's spend changes underneath a
853
+ * long run: six other terminals finish work while this one is thinking, and a
854
+ * value captured at construction would be stale by the second round.
855
+ *
856
+ * ⚠️ Called with the projection for the round being considered, and this
857
+ * run's own spend so far — which the shared ledger cannot know, because this
858
+ * run does not write its audit record until it ends.
859
+ *
860
+ * @type {null | ((args: {projectedUsd: number, thisRunUsd: number}) => {ok: boolean, reason: string, message: string})}
861
+ */
862
+ fleetGate = null,
863
+ reserveUsd = 0,
864
+ firstRoundUsd = DEFAULT_FIRST_ROUND_USD,
865
+ usdPerMillionTokens = DEFAULT_USD_PER_MILLION_TOKENS,
866
+ trendWindow = DEFAULT_TREND_WINDOW,
867
+ safetyFactor = DEFAULT_SAFETY_FACTOR,
868
+ clock = Date.now,
869
+ } = {}) {
870
+ /**
871
+ * ⚠️ MALFORMED INPUT THROWS, AND IT THROWS HERE. `--budget banana` is a typo
872
+ * in a command that is about to spend money; the only safe moment to notice
873
+ * is before the first request. Contrast with 'too-small' below, which is a
874
+ * legitimate budget that simply cannot buy anything — that is a verdict, not
875
+ * a crash, because the user did nothing wrong except be optimistic.
876
+ */
877
+ const unlimited = limitUsd === null || limitUsd === undefined;
878
+ if (!unlimited) requirePositive('limitUsd', limitUsd);
879
+ if (!isNum(reserveUsd) || reserveUsd < 0) {
880
+ throw new RangeError(`budget: reserveUsd must be a number >= 0 (got ${JSON.stringify(reserveUsd)})`);
881
+ }
882
+ if (!unlimited && reserveUsd >= limitUsd) {
883
+ throw new RangeError(`budget: reserveUsd (${reserveUsd}) must be less than limitUsd (${limitUsd}) — otherwise nothing can ever run`);
884
+ }
885
+ requirePositive('firstRoundUsd', firstRoundUsd);
886
+ requirePositive('usdPerMillionTokens', usdPerMillionTokens);
887
+ if (!isNum(trendWindow) || !Number.isInteger(trendWindow) || trendWindow < 1) {
888
+ throw new RangeError(`budget: trendWindow must be an integer >= 1 (got ${JSON.stringify(trendWindow)})`);
889
+ }
890
+ if (!isNum(safetyFactor) || safetyFactor < 1) {
891
+ throw new RangeError(`budget: safetyFactor must be a number >= 1 (got ${JSON.stringify(safetyFactor)})`);
892
+ }
893
+ if (typeof clock !== 'function') {
894
+ throw new RangeError('budget: clock must be a function returning milliseconds');
895
+ }
896
+
897
+ const effectiveLimit = unlimited ? Infinity : limitUsd - reserveUsd;
898
+ const startedAt = clock();
899
+
900
+ /** @type {BudgetRound[]} */
901
+ const rounds = [];
902
+ let spentUsd = 0;
903
+ let totalTokens = 0;
904
+ let estimatedRounds = 0;
905
+
906
+ /**
907
+ * ── ⭐⭐ GPU DOLLARS COUNT AGAINST THE CEILING, AND NOT AGAINST THE TREND ───
908
+ *
909
+ * They are held in their own accumulator rather than pushed into `rounds`,
910
+ * and that separation is load-bearing in both directions:
911
+ *
912
+ * · `spentUsd + gpuUsd` is what every ceiling comparison uses, so one image
913
+ * render is a real dollar against `--budget` for the first time.
914
+ * · `projectNext()` still reads ONLY `rounds`, so the projection keeps
915
+ * meaning "what the next MODEL round will cost". Feeding a $0.0398 render
916
+ * into a three-round trend would project the next round at four cents and
917
+ * stop a run that could comfortably afford to finish — a guard that fails
918
+ * correct work, which is worse than the leak it closes.
919
+ */
920
+ let gpuUsd = 0;
921
+ let gpuCalls = 0;
922
+
923
+ /**
924
+ * ⚠️ CALLED AT THE TOP OF EVERY PUBLIC METHOD, INCLUDING THE READ-ONLY ONES.
925
+ * A GPU verb called on the last round of a session would otherwise never be
926
+ * claimed by anyone — `canContinue()` is not asked again — and would vanish
927
+ * from the summary and the audit record. `report()`, `stats()` and `toJSON()`
928
+ * all run at the end of a run, so draining there is what makes the last call
929
+ * of a session countable.
930
+ */
931
+ function syncGpu() {
932
+ for (const c of drainCharges()) {
933
+ gpuUsd += c.usd;
934
+ gpuCalls += 1;
935
+ }
936
+ }
937
+
938
+ /** The one number: model rounds plus estimated GPU time. */
939
+ const totalSpent = () => spentUsd + gpuUsd;
940
+
941
+ /**
942
+ * ⭐ THE HONESTY CLAUSE, AND IT IS EMPTY WHEN THERE IS NOTHING TO SAY.
943
+ *
944
+ * ⚠️ RETURNING `''` ON A ZERO-GPU RUN IS A REQUIREMENT, NOT A TIDINESS
945
+ * CHOICE. Every stop message, summary line and audit record on a run that
946
+ * never touched a GPU has to come out byte-for-byte as it did before this
947
+ * change, or "we added GPU metering" silently becomes "we changed the output
948
+ * of every run in the product". `test/gpu-spend-is-metered.test.mjs` pins it.
949
+ */
950
+ function gpuClause() {
951
+ if (!(gpuUsd > 0)) return '';
952
+ return ` (${formatUsd(gpuUsd)} of that is ESTIMATED GPU time across ${gpuCalls} call${gpuCalls === 1 ? '' : 's'}, priced from a table rather than billed)`;
953
+ }
954
+
955
+ /** @returns {Projection} */
956
+ function projectNext() {
957
+ if (rounds.length === 0) {
958
+ return { usd: firstRoundUsd * safetyFactor, raw: firstRoundUsd, basis: 'seed', window: 0, slope: 0, peak: 0, safetyFactor };
959
+ }
960
+ const window = rounds.slice(-trendWindow);
961
+ const costs = window.map((r) => r.costUsd);
962
+ const first = costs[0];
963
+ const last = costs[costs.length - 1];
964
+ const peak = Math.max(...costs);
965
+ const slope = costs.length >= 2 ? (last - first) / (costs.length - 1) : 0;
966
+ // No `Math.max(0, slope)` here — see the header. A negative slope always
967
+ // implies `peak > trend`, so the peak clamp on the next line is the only
968
+ // thing that has ever enforced "never project downward".
969
+ const trend = last + slope;
970
+ const raw = Math.max(trend, peak);
971
+ const basis = costs.length < 2 ? 'last' : (raw > trend ? 'peak' : 'trend');
972
+ return { usd: raw * safetyFactor, raw, basis, window: costs.length, slope, peak, safetyFactor };
973
+ }
974
+
975
+ /**
976
+ * Accepts either this module's own shape (`{ costUsd, tokens }`) or the
977
+ * OpenRouter usage object turn.mjs already has (`{ cost, total_tokens }`),
978
+ * so the wiring is `budget.record(reply.usage)` and nothing else.
979
+ */
980
+ function record(entry) {
981
+ syncGpu();
982
+ const e = entry && typeof entry === 'object' ? entry : {};
983
+ const rawCost = isNum(e.costUsd) ? e.costUsd : e.cost;
984
+ const rawTokens = isNum(e.tokens) ? e.tokens : e.total_tokens;
985
+ const tokens = isNum(rawTokens) && rawTokens >= 0 ? rawTokens : 0;
986
+
987
+ let costUsd;
988
+ /** @type {'reported'|'tokens'|'projected'} */
989
+ let source;
990
+ if (isNum(rawCost) && rawCost >= 0) {
991
+ costUsd = rawCost;
992
+ source = 'reported';
993
+ } else if (tokens > 0) {
994
+ /**
995
+ * ── ⭐⭐⭐ PRICE THE MIX, NOT A BLEND ─────────────────────────────────
996
+ *
997
+ * A flat `$/M` cannot be right for two rounds with different shapes, and
998
+ * that is exactly why two honest measurements of this model appeared to
999
+ * disagree by 2x. Reconciled 2026-08-21 against real rates
1000
+ * (input $0.14/M · output $0.28/M · cached input ~10% of input):
1001
+ *
1002
+ * 1,036 tok, cold, output-heavy -> $0.231/M (this file measured 0.223 ✓)
1003
+ * a build round, prompt-heavy -> $0.154/M
1004
+ * the same round at 80% cache -> $0.063/M (the console's blend ✓)
1005
+ *
1006
+ * ⭐ SAME RATES, DIFFERENT MIXES. Neither measurement was wrong; the
1007
+ * constant was, because one number cannot describe both. So when the
1008
+ * usage object tells us the split — and DeepSeek always does, even when
1009
+ * it reports no cost — price it properly and stop guessing.
1010
+ *
1011
+ * ⚠️ FALLS BACK, NEVER THROWS. A usage object without the split is priced
1012
+ * exactly as before, so this can only ever be more accurate than the
1013
+ * constant, never less.
1014
+ */
1015
+ const priced = priceFromSplit(e);
1016
+ if (priced !== null) {
1017
+ costUsd = priced;
1018
+ source = 'tokens';
1019
+ } else {
1020
+ costUsd = (tokens / 1e6) * usdPerMillionTokens;
1021
+ source = 'tokens';
1022
+ }
1023
+ } else {
1024
+ // Nothing to go on. Charge what we were about to bet this round would
1025
+ // cost — the one option that is neither free nor invented.
1026
+ costUsd = projectNext().raw;
1027
+ source = 'projected';
1028
+ }
1029
+
1030
+ const rec = {
1031
+ round: rounds.length + 1,
1032
+ costUsd,
1033
+ tokens,
1034
+ source,
1035
+ estimated: source !== 'reported',
1036
+ at: clock(),
1037
+ };
1038
+ rounds.push(rec);
1039
+ spentUsd += costUsd;
1040
+ totalTokens += tokens;
1041
+ if (rec.estimated) estimatedRounds += 1;
1042
+ return { ...rec };
1043
+ }
1044
+
1045
+ /** @returns {Verdict} */
1046
+ function canContinue() {
1047
+ syncGpu();
1048
+ const projection = projectNext();
1049
+ const elapsedMs = clock() - startedAt;
1050
+ const base = {
1051
+ /**
1052
+ * ⚠️ THIS IS NOW THE TOTAL, NOT THE MODEL TOTAL. Every caller that reads
1053
+ * `spentUsd` — `escalate.mjs`, the fleet gate below, the stop messages —
1054
+ * is asking "how much has this run cost", and answering with the model
1055
+ * half was the whole defect. The split is available under `modelUsd` /
1056
+ * `gpuUsd` for anyone who needs it.
1057
+ */
1058
+ spentUsd: totalSpent(),
1059
+ modelUsd: spentUsd,
1060
+ gpuUsd,
1061
+ gpuCalls,
1062
+ projectedUsd: projection.usd,
1063
+ /**
1064
+ * ⭐ GPU SPEND MAKES THE TOTAL AN ESTIMATE, FULL STOP. There is no
1065
+ * invoice behind it, so a run that rendered an image can never report an
1066
+ * exact figure, however well the provider reported its tokens.
1067
+ */
1068
+ estimated: estimatedRounds > 0 || gpuCalls > 0,
1069
+ rounds: rounds.length,
1070
+ limitUsd: unlimited ? null : limitUsd,
1071
+ reserveUsd,
1072
+ elapsedMs,
1073
+ };
1074
+
1075
+ /**
1076
+ * ── ⭐ THE FLEET IS ASKED FIRST, AND IT CAN ONLY EVER REFUSE ─────────────
1077
+ *
1078
+ * Two properties, both deliberate:
1079
+ *
1080
+ * 1. **It runs BEFORE the `unlimited` branch.** A terminal started with
1081
+ * `--budget none` has no per-run ceiling at all, which is exactly the
1082
+ * worker that can drain a fleet ceiling on its own. Checking after the
1083
+ * unlimited early-return would have left the one case that most needs
1084
+ * the fleet gate as the one case it never sees.
1085
+ *
1086
+ * 2. **It never turns a refusal into an allow.** The only outcome consulted
1087
+ * here is `ok === false`; a happy fleet falls through to the per-run
1088
+ * logic completely unchanged. So every existing behaviour is preserved
1089
+ * exactly, and the new layer is a no-op for anyone who has not asked for
1090
+ * it — which is the bar for adding anything to a path that spends money.
1091
+ *
1092
+ * ⚠️ Precedence is fleet-first ON PURPOSE. When both ceilings are spent,
1093
+ * "raise --budget" is the wrong advice: the per-run ceiling is not what is
1094
+ * stopping you and lifting it changes nothing. The binding constraint has
1095
+ * to be the one that gets named.
1096
+ *
1097
+ * ⚠️ `remainingUsd` becomes the FLEET's remaining here, because
1098
+ * `escalate.mjs` sizes its next rung from that number. Handing it the
1099
+ * per-run figure while the fleet is empty would have it buy a rung the
1100
+ * fleet cannot pay for.
1101
+ */
1102
+ if (fleetGate) {
1103
+ // ⭐ The fleet is told the TOTAL. A workspace ceiling that priced only
1104
+ // model tokens was the same hole one level up — seven terminals each
1105
+ // rendering images could not cross a fleet cap they were not charged to.
1106
+ const fleet = fleetGate({ projectedUsd: projection.usd, thisRunUsd: totalSpent() });
1107
+ if (fleet && fleet.ok === false) {
1108
+ return {
1109
+ ok: false,
1110
+ reason: fleet.reason,
1111
+ message: fleet.message,
1112
+ remainingUsd: Math.max(0, fleet.fleetRemainingUsd ?? 0),
1113
+ fleetSpentUsd: fleet.fleetSpentUsd,
1114
+ ...base,
1115
+ };
1116
+ }
1117
+ }
1118
+
1119
+ if (unlimited) {
1120
+ return {
1121
+ ok: true,
1122
+ reason: 'no-budget-set',
1123
+ message: `no budget limit set — ${formatUsd(totalSpent())} spent so far`,
1124
+ remainingUsd: Infinity,
1125
+ ...base,
1126
+ };
1127
+ }
1128
+
1129
+ const remainingUsd = effectiveLimit - totalSpent();
1130
+
1131
+ /**
1132
+ * ⭐ A REFUSAL THAT DOES NOT SAY WHAT TO TYPE IS JUST AN OBSTACLE. That rule
1133
+ * is why this package's refusals are its best-written part, and it applies
1134
+ * hardest here — because the ceiling is now ON BY DEFAULT, so the first
1135
+ * person to meet it will not have chosen the number.
1136
+ */
1137
+ const wayOut = budgetWayOut({ limitUsd, limitIsDefault, limitSource });
1138
+
1139
+ if (remainingUsd <= USD_EPSILON) {
1140
+ return {
1141
+ ok: false,
1142
+ reason: 'limit-reached',
1143
+ message: `budget spent: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} after ${rounds.length} round${rounds.length === 1 ? '' : 's'}${gpuClause()}.${wayOut}`,
1144
+ remainingUsd: Math.max(0, remainingUsd),
1145
+ ...base,
1146
+ };
1147
+ }
1148
+
1149
+ if (totalSpent() + projection.usd > effectiveLimit + USD_EPSILON) {
1150
+ /**
1151
+ * ⚠️ `rounds.length === 0` STILL MEANS "NOTHING WAS STARTED", and a GPU
1152
+ * charge with no rounds behind it cannot happen — a tool call only exists
1153
+ * inside a round. Left as it was rather than switched to a total-spend
1154
+ * test, because 'too-small' is the preflight verdict and preflight runs
1155
+ * before anything has been charged at all.
1156
+ */
1157
+ const tooSmall = rounds.length === 0;
1158
+ return {
1159
+ ok: false,
1160
+ reason: tooSmall ? 'too-small' : 'would-exceed',
1161
+ message: tooSmall
1162
+ ? `budget of ${formatUsd(limitUsd)} cannot cover even one round (projected ~${formatUsd(projection.usd)}). Nothing was started — raise it or drop the flag.`
1163
+ : `stopping on budget after ${rounds.length} round${rounds.length === 1 ? '' : 's'}: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} spent${gpuClause()} and the next round is projected at ~${formatUsd(projection.usd)}, which would cross the line.${wayOut}`,
1164
+ remainingUsd,
1165
+ ...base,
1166
+ };
1167
+ }
1168
+
1169
+ return {
1170
+ ok: true,
1171
+ reason: 'ok',
1172
+ message: `${formatUsd(remainingUsd)} of budget left, next round projected at ~${formatUsd(projection.usd)}`,
1173
+ remainingUsd,
1174
+ ...base,
1175
+ };
1176
+ }
1177
+
1178
+ /**
1179
+ * ⭐ THE FORECAST, ASKED WITH THE STATE THIS CLOSURE ALREADY HOLDS.
1180
+ *
1181
+ * ⚠️ IT DRAINS THE GPU METER FIRST, like every other public method here. A run
1182
+ * that rendered an image in round 1 has spent four cents; forecasting from the
1183
+ * model half alone would produce a confident "this fits" about a run that is
1184
+ * already twice over its ceiling — the same "unknown priced as free" failure
1185
+ * this file exists to refuse, one level along.
1186
+ *
1187
+ * ⚠️ AND IT IS A READ. Nothing here records, stops, or changes a verdict; the
1188
+ * only thing that stops a run is still `canContinue()`.
1189
+ */
1190
+ function forecast(maxRounds) {
1191
+ syncGpu();
1192
+ return forecastRun({
1193
+ remainingUsd: unlimited ? Infinity : effectiveLimit - totalSpent(),
1194
+ projectedUsd: projectNext().usd,
1195
+ limitUsd: unlimited ? null : limitUsd,
1196
+ spentUsd: totalSpent(),
1197
+ roundsUsed: rounds.length,
1198
+ maxRounds: Number.isFinite(maxRounds) ? maxRounds : 0,
1199
+ limitIsDefault,
1200
+ limitSource,
1201
+ });
1202
+ }
1203
+
1204
+ function stats() {
1205
+ syncGpu();
1206
+ return {
1207
+ rounds: rounds.length,
1208
+ spentUsd: totalSpent(),
1209
+ modelUsd: spentUsd,
1210
+ gpuUsd,
1211
+ gpuCalls,
1212
+ totalTokens,
1213
+ estimated: estimatedRounds > 0 || gpuCalls > 0,
1214
+ estimatedRounds,
1215
+ limitUsd: unlimited ? null : limitUsd,
1216
+ reserveUsd,
1217
+ remainingUsd: unlimited ? Infinity : effectiveLimit - totalSpent(),
1218
+ projectedUsd: projectNext().usd,
1219
+ startedAt,
1220
+ elapsedMs: clock() - startedAt,
1221
+ };
1222
+ }
1223
+
1224
+ /**
1225
+ * One line, and it must be true. `~` marks the projection; the estimate
1226
+ * clause appears only when a round really did come back without a price.
1227
+ */
1228
+ function report() {
1229
+ syncGpu();
1230
+ const n = rounds.length;
1231
+ const roundWord = `${n} round${n === 1 ? '' : 's'}`;
1232
+ /**
1233
+ * ⭐ THE GPU CLAUSE COMES FIRST BECAUSE IT IS THE BIGGER SURPRISE. A run
1234
+ * that spent $0.0002 on tokens and $0.0398 on one image render is 99% GPU,
1235
+ * and a summary that mentions only rounds is describing the 1%.
1236
+ */
1237
+ const gpu = gpuUsd > 0
1238
+ ? ` · ⚠ ${formatUsd(gpuUsd)} of that is GPU time on ${gpuCalls} call${gpuCalls === 1 ? '' : 's'}, ESTIMATED from a price table (we are not billed per call)`
1239
+ : '';
1240
+ const tail = estimatedRounds > 0
1241
+ ? ` · ⚠ ${estimatedRounds} of ${n} rounds reported no cost, so the total is an estimate`
1242
+ : '';
1243
+ if (unlimited) {
1244
+ return `budget: ${formatUsd(totalSpent())} spent · ${roundWord} · no limit set${gpu}${tail}`;
1245
+ }
1246
+ const projected = projectNext().usd;
1247
+ const remaining = Math.max(0, effectiveLimit - totalSpent());
1248
+ return `budget: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} spent · ${roundWord} · next ~${formatUsd(projected)} · ${formatUsd(remaining)} left${gpu}${tail}`;
1249
+ }
1250
+
1251
+ function toJSON() {
1252
+ const s = stats();
1253
+ return {
1254
+ limitUsd: s.limitUsd,
1255
+ reserveUsd: s.reserveUsd,
1256
+ spentUsd: s.spentUsd,
1257
+ remainingUsd: s.remainingUsd,
1258
+ projectedUsd: s.projectedUsd,
1259
+ totalTokens: s.totalTokens,
1260
+ rounds: s.rounds,
1261
+ /**
1262
+ * ⚠️ CONDITIONAL, AND IN THE MIDDLE OF THE OBJECT ON PURPOSE — the two
1263
+ * GPU keys sit next to the number they explain, and are absent entirely
1264
+ * when there is no GPU spend, so `--json` on an ordinary run is unchanged
1265
+ * byte for byte. A consumer's schema does not move because we added a
1266
+ * capability it never used.
1267
+ */
1268
+ ...(s.gpuUsd > 0 ? { gpuUsd: s.gpuUsd, gpuCalls: s.gpuCalls } : {}),
1269
+ estimated: s.estimated,
1270
+ estimatedRounds: s.estimatedRounds,
1271
+ elapsedMs: s.elapsedMs,
1272
+ };
1273
+ }
1274
+
1275
+ /** A copy. The ledger is not editable from outside. */
1276
+ const history = () => rounds.map((r) => ({ ...r }));
1277
+
1278
+ /**
1279
+ * ⭐ `fleetGate` is exposed so a DELEGATED helper can inherit it. Without
1280
+ * that, seven terminals could each delegate their way around the
1281
+ * workspace-wide ceiling — the parent obeys the fleet cap and the child it
1282
+ * spawns never hears about it.
1283
+ */
1284
+ return { record, projectNext, canContinue, forecast, report, stats, toJSON, history, fleetGate };
1285
+ }
1286
+
1287
+ /**
1288
+ * ── ⭐ HOW TO WIRE THIS (the whole point of the module) ──────────────────────
1289
+ *
1290
+ * 1. THE FLAG — in `lib/cli-args.mjs`, beside `--max-rounds`:
1291
+ *
1292
+ * import { parseBudgetUsd } from './budget.mjs';
1293
+ * // ...
1294
+ * case '--budget': {
1295
+ * const parsed = parseBudgetUsd(argv[++i]);
1296
+ * if (!parsed.ok) return { ok: false, error: parsed.message };
1297
+ * out.budgetUsd = parsed.usd;
1298
+ * break;
1299
+ * }
1300
+ *
1301
+ * 2. THE GOVERNOR — in `lib/turn.mjs`, at the top:
1302
+ *
1303
+ * import { createBudget } from './budget.mjs';
1304
+ *
1305
+ * beside `const rounds = []` in `runSession` (add `budgetUsd` to the options
1306
+ * destructure, defaulting to `null`):
1307
+ *
1308
+ * const budget = createBudget({ limitUsd: budgetUsd });
1309
+ * const preflight = budget.canContinue();
1310
+ * if (!preflight.ok) return { ok: false, stage: 'budget', stoppedBecause: preflight.reason, message: preflight.message };
1311
+ *
1312
+ * at the top of the `for (let round = ...)` body:
1313
+ *
1314
+ * const affordable = budget.canContinue();
1315
+ * if (!affordable.ok) { stoppedBecause = affordable.reason; onEvent({ type: 'budget-stop', ...affordable }); break; }
1316
+ *
1317
+ * and immediately after each existing `rounds.push({ round, ... })`:
1318
+ *
1319
+ * budget.record(reply.usage);
1320
+ *
1321
+ * 3. THE LINE — wherever the summary is printed, add `budget.report()`.
1322
+ *
1323
+ * ⚠️ THE PREFLIGHT CALL IS NOT OPTIONAL. Without it a budget of $0.000001 opens
1324
+ * a connection, spends a round, and then discovers it could never have afforded
1325
+ * one — the exact "start and stop having spent money for nothing" this module
1326
+ * was written to prevent. It is two lines.
1327
+ *
1328
+ * ⚠️ AND `budget.record(reply.usage)` MUST RUN ON EVERY ROUND, INCLUDING THE
1329
+ * FAILED ONES. `runSession` pushes `{ round, error, usage: null }` on a
1330
+ * transport failure; skipping those makes a provider that errors after billing
1331
+ * look free, which is precisely the "unknown priced as zero" trap. Passing the
1332
+ * null is correct and intended — the governor charges it the projection.
1333
+ *
1334
+ * ⭐ `maxRounds` DOES NOT HAVE TO DIE FOR THIS TO SHIP. The two coexist: leave
1335
+ * the counter as a very high backstop and let money be the real wall. Removing
1336
+ * the counter in the same change would make an unaffordable-budget bug and a
1337
+ * runaway-loop bug indistinguishable, and `stuck.mjs` is the module that earns
1338
+ * the right to remove it, not this one.
1339
+ */
1340
+
1341
+ /**
1342
+ * ── ⭐ WHAT ONE `--best-of` ATTEMPT MAY SPEND ───────────────────────────────
1343
+ *
1344
+ * Pure, and separately named so the reasoning is reviewable rather than buried
1345
+ * in a call site: this decides whether a user's stated ceiling is honoured or
1346
+ * multiplied.
1347
+ *
1348
+ * ⚠️ IT EXISTS BECAUSE BOTH FAN-OUT PATHS HAD NO CEILING AT ALL. `runSession`
1349
+ * defaults `budgetUsd = null`, and null is UNLIMITED — so `--best-of N` ran N
1350
+ * full sessions with no wall, and an explicit `--budget` was accepted without
1351
+ * complaint and silently discarded. Taking a user's instruction about money and
1352
+ * dropping it is a different and worse failure than never offering the feature.
1353
+ *
1354
+ * ⭐ AN EXPLICIT `--budget` IS A TOTAL. `--best-of 5 --budget 0.05` means "spend
1355
+ * at most five cents", never "spend up to twenty-five" — so it is divided across
1356
+ * the attempts. That is the only reading that cannot surprise someone reading an
1357
+ * invoice, and the alternative is indefensible.
1358
+ *
1359
+ * ⚠️ THE DEFAULT IS NOT DIVIDED. `DEFAULT_BUDGET_USD` is a per-run blast radius
1360
+ * that nobody chose, and splitting an unchosen number into fifths would starve
1361
+ * each attempt at four tenths of a cent — turning a safety net into a feature
1362
+ * that silently stops working at N > 2. A ceiling the user DID choose is a
1363
+ * promise; one they did not is a guard rail, and they behave differently on
1364
+ * purpose.
1365
+ *
1366
+ * @param {{ bestOf?: number, budgetUsd?: number | null, budgetExplicit?: boolean }} opts
1367
+ * @returns {number} always finite and > 0 — null would mean unlimited
1368
+ */
1369
+ export function bestOfAttemptBudget(opts) {
1370
+ const n = Math.max(1, Number(opts?.bestOf) || 1);
1371
+ if (opts?.budgetExplicit === true && Number.isFinite(opts?.budgetUsd) && opts.budgetUsd > 0) {
1372
+ return opts.budgetUsd / n;
1373
+ }
1374
+ return DEFAULT_BUDGET_USD;
1375
+ }