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
@@ -0,0 +1,702 @@
1
+ /**
2
+ * ── ⭐⭐ THE BUDGET AS A STRATEGY, NOT A CEILING ────────────────────────────
3
+ *
4
+ * `lib/budget.mjs` answers one question: *may I afford another round?* That is
5
+ * a brake. It has never once decided to spend MORE on a task because the task
6
+ * was hard — and spending more on the hard ones is the only advantage we
7
+ * actually have.
8
+ *
9
+ * Measured, this repo, 2026-08-11: **$0.000738 per task.** Three attempts cost
10
+ * less than one attempt on Claude or GPT pricing. So the loop every competitor
11
+ * is forced to run is *"one attempt, be smart"*, and the loop we can afford is
12
+ * *"escalate until it passes, and stop at the dollar."* That is not a margin
13
+ * trick. On a pass/fail task it is a capability, and it gets HARDER to copy the
14
+ * more expensive the competitor's model is.
15
+ *
16
+ * ⚠️ IT WAS UNREACHABLE FROM THE ONLY MODE THAT NEEDED IT. `runBestOf` had
17
+ * exactly one caller — `bin/acuvo.mjs:1092`, a top-level `--best-of` mode that
18
+ * refuses to combine with `--resume`. So the unattended runner, the one thing
19
+ * that runs for hours with nobody watching, could not use it. Built, tested,
20
+ * green, and locked out of its own use case: the failure mode this package has
21
+ * hit often enough to have a name for it.
22
+ *
23
+ * ── THE LADDER ─────────────────────────────────────────────────────────────
24
+ *
25
+ * 1. `solo` one session. What every run does today. Cheapest.
26
+ * 2. `fresh` the SAME task, a NEW context, carrying the FAILURE and not
27
+ * the transcript. The industry calls this the Ralph loop; the
28
+ * reason it works is that the context which produced the loop
29
+ * is the context least able to escape it. `stuck.mjs` nudges
30
+ * INSIDE that context (`turn.mjs:2142`) — this is the rung
31
+ * above the nudge, and it is what the nudge escalates to.
32
+ * 3. `best-of` N isolated copies of the workspace, N independent attempts,
33
+ * keep the one that VERIFIES. `best-of.mjs` already does all of
34
+ * this and picks by what ran, not by what was claimed.
35
+ *
36
+ * ── ⚠️⚠️ FOUR HONESTY RULES, AND EACH ONE IS A TEST ────────────────────────
37
+ *
38
+ * 1. **NEVER START A RUNG THE BUDGET CANNOT FINISH.** Half a best-of is worse
39
+ * than none: it spends real money and applies nothing, because the winner is
40
+ * chosen by comparison and there is nothing to compare. So a rung is entered
41
+ * only when the remaining budget covers its PROJECTION.
42
+ *
43
+ * 2. **THE PROJECTION IS MEASURED, NOT A CONSTANT.** `best-of N` costs about N
44
+ * times what the last attempt cost, because it is literally N of them. A
45
+ * flat estimate is a lie that overspends by 3x on the one code path whose
46
+ * entire job is not to.
47
+ *
48
+ * 3. **A SKIPPED RUNG IS REPORTED, NEVER ABSENT.** "It failed" and "it failed
49
+ * and I could not afford to try harder" are different facts, and only one of
50
+ * them means *give me another dollar*. A ladder that silently stops short
51
+ * reads as a capability failure when it was a funding decision.
52
+ *
53
+ * 4. **A CRASH IS NOT A RUNG.** A transport error, a killed process, a 429 —
54
+ * those are not evidence the approach was wrong, so they must not consume an
55
+ * escalation. Escalating on a network blip pays triple for a bad connection.
56
+ * Only a completed-and-unverified attempt moves the ladder.
57
+ */
58
+
59
+ import { runBestOf } from './best-of.mjs';
60
+ import { formatUsd, BUDGET_REASONS } from './budget.mjs';
61
+ import { planRung, describeSwitch } from './model-tier.mjs';
62
+
63
+ /**
64
+ * The rungs, cheapest first. Order is load-bearing: `nextTier` walks this
65
+ * array, and `maxTier` is an index into it.
66
+ */
67
+ export const TIERS = Object.freeze(['solo', 'fresh', 'best-of']);
68
+
69
+ /** Default attempts for the `best-of` rung. `best-of.mjs` clamps to 2..5. */
70
+ export const DEFAULT_ATTEMPTS = 3;
71
+
72
+ /**
73
+ * ⚠️ A FLOOR UNDER THE PROJECTION, because the first attempt can legitimately
74
+ * cost ~$0 (a cache hit, a refusal, an immediate answer) and a projection of
75
+ * zero would wave a rung through that the budget cannot actually pay for.
76
+ * Seeded from the same measured order of magnitude as `budget.mjs`.
77
+ */
78
+ export const MIN_PROJECTION_USD = 0.0005;
79
+
80
+ /**
81
+ * How much more a rung costs than the attempt before it.
82
+ *
83
+ * `fresh` is one more session, so ~1x — plus a little, because a fresh context
84
+ * re-reads the files the old one already had cached, and a cache miss is the
85
+ * expensive direction (measured 3.05x in `turn-cache-and-prefix.test.mjs`).
86
+ *
87
+ * `best-of` is N sessions, so it is `attempts` x — computed, never guessed.
88
+ */
89
+ export const FRESH_MULTIPLIER = 1.4;
90
+
91
+ /**
92
+ * What the next rung will cost, in dollars, based on what the last one actually
93
+ * cost. Exported because rule 2 is only true if this is testable on its own.
94
+ *
95
+ * @param {string} tier
96
+ * @param {{ lastAttemptUsd?: number, attempts?: number }} opts
97
+ * @returns {number}
98
+ */
99
+ /**
100
+ * ── ⚠️⚠️ THE PROJECTION KNEW NOTHING ABOUT WHICH MODEL IT WAS PROJECTING ────
101
+ *
102
+ * `projectTierCost` scaled by TIER EFFORT alone — 1x solo, 1.4x fresh, 3x
103
+ * best-of — and the ladder switches MODEL between rungs. So rung 1 measured
104
+ * flash at $0.0015, rung 2 switched to pro, and the projection said $0.0021
105
+ * when pro really costs ~$0.0165. **Wrong by ~8x, in exactly the case the model
106
+ * switch exists for.** The escalation therefore projected with the cheap
107
+ * model's price and then ran the expensive one: it entered a rung it could not
108
+ * afford, spent one round, and stopped with nothing to show.
109
+ *
110
+ * MEASURED on our own 13-task bench, 2026-08-15, same tasks both runs:
111
+ * flash 12/13 $0.0146
112
+ * pro 5/13 $0.1639 ← 5 of its 13 runs died on the budget after 1 round
113
+ *
114
+ * ── ⚠️ WHY THESE ARE MEASURED RATIOS AND NOT LIST PRICES ────────────────────
115
+ *
116
+ * OpenRouter list makes pro look 3.1x flash ((0.435+0.870)/(0.140+0.280)). The
117
+ * REAL ratio on our workload is 11.2x, because we PIN StreamLake for flash —
118
+ * measured at $0.080/M against a $0.140 headline — while pro was served at
119
+ * list. A table of list prices would understate the gap by ~3x, which is the
120
+ * same class of error as having no table at all, wearing a citation.
121
+ *
122
+ * ⚠️ AND THE CACHE-READ COLUMN IS A TRAP. Pro's cached input is $0.004/M
123
+ * against flash's $0.028/M — SEVEN TIMES CHEAPER — and it does not help,
124
+ * because at high cache rates the input is already nearly free for both and the
125
+ * bill is dominated by OUTPUT, which is 3.1x dearer and is NEVER cached.
126
+ * Measured per-task at 98% cache: pro still cost 9.4x flash. Never quote one
127
+ * price column; compute the blended ratio.
128
+ *
129
+ * ⭐ FLASH IS THE UNIT. Every entry is "cost per unit of work, relative to
130
+ * flash", so a new model needs one bench run to earn a number here.
131
+ */
132
+ export const MODEL_COST_INDEX = Object.freeze({
133
+ 'deepseek/deepseek-v4-flash-0731': 1,
134
+ // 13-task bench: $0.1639 vs $0.0146.
135
+ 'deepseek/deepseek-v4-pro-0813': 11.2,
136
+ // The reviewer A/B, same claim, same workspace: $0.000490 vs flash $0.000909.
137
+ 'qwen/qwen3.7-flash': 0.54,
138
+ // Same A/B: $0.004348 vs $0.000909 — and it produced no parseable verdict.
139
+ 'z-ai/glm-4.6': 4.8,
140
+ });
141
+
142
+ /**
143
+ * ⚠️ AN UNKNOWN MODEL IS ASSUMED DEARER, NOT EQUAL. Assuming parity is what the
144
+ * old code did implicitly, and it is the assumption that let a rung enter a
145
+ * budget it could not afford. Guessing high costs a skipped rung and a printed
146
+ * reason; guessing low costs a wasted round and produces nothing.
147
+ */
148
+ export const UNKNOWN_MODEL_MULTIPLIER = 4;
149
+
150
+ /**
151
+ * How much dearer `toModel` is than `fromModel`, per unit of work.
152
+ *
153
+ * @returns {{ ratio: number, known: boolean }}
154
+ */
155
+ export function modelCostRatio(fromModel, toModel) {
156
+ const from = MODEL_COST_INDEX[String(fromModel ?? '')];
157
+ const to = MODEL_COST_INDEX[String(toModel ?? '')];
158
+ // Same model, or no switch at all — the old behaviour, exactly.
159
+ if (!toModel || fromModel === toModel) return { ratio: 1, known: true };
160
+ if (Number.isFinite(from) && Number.isFinite(to) && from > 0) return { ratio: to / from, known: true };
161
+ return { ratio: UNKNOWN_MODEL_MULTIPLIER, known: false };
162
+ }
163
+
164
+ /**
165
+ * @param {string} tier
166
+ * @param {{ lastAttemptUsd?: number, attempts?: number, fromModel?: string|null, toModel?: string|null }} opts
167
+ */
168
+ export function projectTierCost(tier, { lastAttemptUsd = 0, attempts = DEFAULT_ATTEMPTS, fromModel = null, toModel = null } = {}) {
169
+ const measured = Number.isFinite(lastAttemptUsd) && lastAttemptUsd > 0
170
+ ? lastAttemptUsd
171
+ : MIN_PROJECTION_USD;
172
+ /**
173
+ * ⭐ THE MODEL TERM. Applied to the MEASURED base rather than replacing it,
174
+ * so a real observation still leads and the ratio only corrects it for the
175
+ * switch that is about to happen.
176
+ */
177
+ const base = measured * modelCostRatio(fromModel, toModel).ratio;
178
+
179
+ switch (tier) {
180
+ case 'solo':
181
+ return base;
182
+ case 'fresh':
183
+ return base * FRESH_MULTIPLIER;
184
+ case 'best-of': {
185
+ const n = Math.max(2, Math.min(5, Number(attempts) || DEFAULT_ATTEMPTS));
186
+ return base * n;
187
+ }
188
+ default:
189
+ throw new RangeError(`escalate: unknown tier ${JSON.stringify(tier)} — expected one of ${TIERS.join(', ')}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * ── ⚠️⚠️ THE BUDGET MUST BE ALLOCATED, OR THE LADDER IS DECORATIVE ─────────
195
+ *
196
+ * `runSession` already accepts `budgetUsd` and enforces it per session. Hand
197
+ * every rung the SAME `--budget 2.00` and the first one is entitled to spend
198
+ * all of it — so the ladder would sit there, correct and green and permanently
199
+ * on rung one, having been engineered out of its own job by a default.
200
+ *
201
+ * ⭐ So each rung gets a SLICE, weighted by what that rung costs relative to
202
+ * the others. Weights are the same multipliers `projectTierCost` uses, so the
203
+ * allocation and the projection can never drift apart.
204
+ *
205
+ * ⚠️ THE CHEAP RUNG GETS A SMALL SHARE, AND THAT IS THE POINT. A solo attempt
206
+ * that cannot finish in ~18% of the budget is exactly the attempt whose
207
+ * remaining 82% is better spent on a fresh context and three parallel tries.
208
+ * The budget is a ceiling, not a target: when rung one verifies, the other 82%
209
+ * is never spent at all.
210
+ *
211
+ * @param {number} totalUsd
212
+ * @param {{ maxTier?: string, attempts?: number }} opts
213
+ * @returns {Record<string, number>} per-rung ceiling in dollars
214
+ */
215
+ export function allocate(totalUsd, { maxTier = 'best-of', attempts = DEFAULT_ATTEMPTS } = {}) {
216
+ const ceiling = TIERS.indexOf(maxTier);
217
+ if (ceiling === -1) throw new RangeError(`escalate: unknown maxTier ${JSON.stringify(maxTier)}`);
218
+ if (!Number.isFinite(totalUsd) || totalUsd <= 0) return {};
219
+
220
+ const rungs = TIERS.slice(0, ceiling + 1);
221
+ const weights = rungs.map((t) => projectTierCost(t, { lastAttemptUsd: 1, attempts }));
222
+ const sum = weights.reduce((a, b) => a + b, 0);
223
+
224
+ /** @type {Record<string, number>} */
225
+ const out = {};
226
+ rungs.forEach((t, i) => { out[t] = (totalUsd * weights[i]) / sum; });
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * The rung above `current`, or null at the top.
232
+ * @param {string} current
233
+ * @param {{ maxTier?: string }} opts
234
+ */
235
+ export function nextTier(current, { maxTier = 'best-of' } = {}) {
236
+ const i = TIERS.indexOf(current);
237
+ if (i === -1) throw new RangeError(`escalate: unknown tier ${JSON.stringify(current)}`);
238
+ const ceiling = TIERS.indexOf(maxTier);
239
+ if (ceiling === -1) throw new RangeError(`escalate: unknown maxTier ${JSON.stringify(maxTier)}`);
240
+ if (i >= ceiling) return null;
241
+ return TIERS[i + 1];
242
+ }
243
+
244
+ /**
245
+ * ⚠️ THE FAILURE IS CARRIED, THE TRANSCRIPT IS NOT — that is the entire point
246
+ * of the `fresh` rung. Handing the new context the old conversation recreates
247
+ * the state that got stuck; handing it the OUTCOME gives it the one thing the
248
+ * old context learned without any of what trapped it.
249
+ *
250
+ * Kept short on purpose: this text is a prompt prefix, it is paid for on every
251
+ * round of the new session, and a long one buys nothing.
252
+ *
253
+ * @param {string} task
254
+ * @param {object|null} prior
255
+ * @returns {string}
256
+ */
257
+ export function freshBriefing(task, prior) {
258
+ const lines = [task];
259
+ if (!prior) return lines.join('\n');
260
+
261
+ const notes = [];
262
+ if (prior.reason) notes.push(`it stopped because: ${prior.reason}`);
263
+ if (prior.failure) notes.push(`the check that did not pass: ${prior.failure}`);
264
+
265
+ if (notes.length === 0) return lines.join('\n');
266
+
267
+ lines.push('');
268
+ lines.push(
269
+ '[runner — automatic, not from the user] An earlier attempt at this exact task did not '
270
+ + `verify. You are starting over with a clean context and the workspace as that attempt left it. ${notes.join('; ')}. `
271
+ + 'Do not assume the earlier approach was right — check the current state of the files yourself before changing anything.',
272
+ );
273
+ return lines.join('\n');
274
+ }
275
+
276
+ /**
277
+ * ── ⚠️⚠️ RULE 5: SOME FAILURES WILL FAIL IDENTICALLY ON EVERY RUNG ─────────
278
+ *
279
+ * FOUND WHILE DESIGNING THE $0 REACH TEST, before it could cost anything. Point
280
+ * the CLI at an invalid key and rung one fails; rungs two and three then fail
281
+ * the same way, for the same reason, at triple the wall-clock — and the report
282
+ * says "every rung tried and none verified — this one is genuinely hard", which
283
+ * is a lie about a typo in an environment variable.
284
+ *
285
+ * ⭐ THE TEST: would a fresh context change the answer? An unusable key, an
286
+ * exhausted balance, a model id that does not exist and a workspace we cannot
287
+ * write to are all NO. They are conditions of the machine, not of the task, and
288
+ * the honest move is to stop and name the condition.
289
+ *
290
+ * ⚠️ DELIBERATELY NARROW. Anything not on this list escalates, because the
291
+ * expensive direction here is a false stop: refusing to try harder on a task
292
+ * that a second attempt would have solved is the exact capability this module
293
+ * exists to add.
294
+ */
295
+ export const BLOCKING_PATTERNS = Object.freeze([
296
+ [/\b401\b|invalid api key|no api key|OPENROUTER_API_KEY/i, 'the API key is missing or not accepted'],
297
+ [/\b402\b|balance is exhausted|cannot pay/i, 'the account cannot pay for the call'],
298
+ [/\b404\b.*model|does not serve that model/i, 'the configured model does not exist'],
299
+ [/EACCES|EROFS|read-only file system/i, 'the workspace cannot be written to'],
300
+ ]);
301
+
302
+ /**
303
+ * If this outcome failed for a reason the next rung would hit identically,
304
+ * return why. Otherwise null.
305
+ * @param {object|null} outcome
306
+ * @returns {string|null}
307
+ */
308
+ export function blockedBy(outcome) {
309
+ const text = String(outcome?.error ?? '');
310
+ if (text === '') return null;
311
+ for (const [pattern, why] of BLOCKING_PATTERNS) {
312
+ if (pattern.test(text)) return why;
313
+ }
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * ── ⚠️⚠️ "THE PROCESS FAILED" AND "TRY HARDER" ARE DIFFERENT QUESTIONS ─────
319
+ *
320
+ * FOUND BY RUNNING IT, 2026-08-12, for $0.0016 — after the unit tests were
321
+ * green. The first wiring used `!sessionFailed(outcome)` as the definition of
322
+ * success, reasoning that one verdict is safer than two. It is not, because
323
+ * `sessionFailed` is deliberately LENIENT: `turn.mjs:3202` only fails a run
324
+ * whose verification actually RAN and lost, so a session that wrote two files,
325
+ * ran out of money and verified nothing returns `false` — not failed. Correct
326
+ * for an exit code. Fatal for a ladder, which read it as "it worked" and never
327
+ * climbed. The one run engineered to force an escalation produced none.
328
+ *
329
+ * ⭐ THE MISSING IDEA: a run can end three ways, not two — verified, wrong, or
330
+ * OUT OF ROAD. Out of road is budget, rounds, or a stuck loop, and it is the
331
+ * exact condition escalation exists for: nothing is known to be wrong, the run
332
+ * simply never got to find out.
333
+ *
334
+ * ⚠️ AND THE OPPOSITE ERROR IS THE MORE EXPENSIVE ONE. A model that finishes on
335
+ * its own terms with no command to run — "write me a README" — has done correct
336
+ * work, and a strict `stoppedBecause === 'verified'` test would escalate it
337
+ * three times and charge for two attempts nobody needed. That is the
338
+ * check-that-fails-correct-work failure this repo has paid for four times. So
339
+ * this list is the three ways a run gets CUT OFF, and nothing else.
340
+ *
341
+ * ⚠️⚠️ AND THE VALUES ARE THE REAL ONES, WHICH COST A SECOND $0.0018 RUN TO
342
+ * LEARN. The first version of this list was `['budget','rounds','stuck',
343
+ * 'max-rounds']` — typed from memory, and THREE of the four do not exist.
344
+ * `turn.mjs` sets exactly `round-cap`, `model-error`, `no-tool-calls`, `stuck`,
345
+ * `verified`, and routes a budget stop through `budget.mjs`'s own reasons
346
+ * (`too-small` · `would-exceed` · `limit-reached`). So the ladder read
347
+ * `stoppedBecause: 'would-exceed'`, found it in no list, and concluded the run
348
+ * had finished happily — the same feature disabled twice, by two different
349
+ * invented constants.
350
+ *
351
+ * ⭐ THE FIX IS STRUCTURAL, NOT A LONGER LIST: the money half is imported from
352
+ * `BUDGET_REASONS`, so it cannot drift, and `escalate.test.mjs` asserts every
353
+ * remaining value against the ones `turn.mjs` actually assigns. A constant that
354
+ * names another module's strings has to be checked against that module, or it
355
+ * is a guess with a comment on it.
356
+ */
357
+ export const OUT_OF_ROAD = Object.freeze([
358
+ // Cut off by the loop's own wall.
359
+ 'round-cap',
360
+ // Cut off by `stuck.mjs` under --until-done: circling, and the nudge failed.
361
+ 'stuck',
362
+ /**
363
+ * ⭐ Cut off MID-SENTENCE by the output token limit — the most literal member
364
+ * of this list. `turn.mjs` sets it when a reply came back
365
+ * `finishReason: 'length'` and the bounded continuations were all used up.
366
+ *
367
+ * ⚠️ ADDED 2026-08-16 WITH THE STOP REASON ITSELF, and that pairing is the
368
+ * whole lesson of the comment above. Until today a truncated round was
369
+ * reported as `no-tool-calls`, which is deliberately NOT in this list because
370
+ * "the model finished and had no command to run" is correct work. So a run
371
+ * that was chopped off half way through a thought was indistinguishable from
372
+ * one that finished cleanly, and the ladder declined to escalate it — the same
373
+ * feature disabled a THIRD time, by a stop reason that told the truth about
374
+ * the wrong thing. Measured on Terminal-Bench `write-compressor`: stopped at
375
+ * round 2 of 16, $0.0025 of $0.05 spent, nothing written, scored 0.
376
+ */
377
+ 'truncated',
378
+ // Cut off by money. These three are `BUDGET_REASONS` minus the two that never
379
+ // stop anything ('ok', 'no-budget-set') — the same split `turn.mjs` makes.
380
+ ...BUDGET_REASONS.filter((r) => r !== 'ok' && r !== 'no-budget-set'),
381
+ ]);
382
+
383
+ /**
384
+ * True when the run was cut off rather than finished — the case where trying
385
+ * again with more room is the correct response.
386
+ * @param {object|null} outcome
387
+ */
388
+ export function outOfRoad(outcome) {
389
+ return OUT_OF_ROAD.includes(String(outcome?.stoppedBecause ?? ''));
390
+ }
391
+
392
+ /** The dollars an outcome reports having spent. Absent is 0, never invented. */
393
+ function costOf(outcome) {
394
+ const c = outcome?.usage?.cost;
395
+ return Number.isFinite(c) && c > 0 ? c : 0;
396
+ }
397
+
398
+ /**
399
+ * ⚠️ "DID IT WORK" IS THE CALLER'S QUESTION, NOT OURS. The default is
400
+ * deliberately strict — a session that stopped for any reason other than a
401
+ * verified check is NOT verified — because the expensive mistake in this
402
+ * package has always been a proxy for "done" that stopped being one.
403
+ */
404
+ export function defaultVerified(outcome) {
405
+ if (!outcome || outcome.ok !== true) return false;
406
+ if (outcome.acceptance && outcome.acceptance.passed !== true) return false;
407
+ return outcome.stoppedBecause === 'verified';
408
+ }
409
+
410
+ /**
411
+ * Run the ladder.
412
+ *
413
+ * Every collaborator is injected so the whole thing is testable for $0.00 —
414
+ * the rule this repo learned the hard way, that a test which only passes on a
415
+ * configured machine is a test that gets deleted the first week it fires.
416
+ *
417
+ * @param {object} opts
418
+ * @param {string} opts.root workspace root
419
+ * @param {string} opts.task the task, verbatim as the user typed it
420
+ * @param {object} opts.budget a `createBudget()` instance
421
+ * @param {(ctx: {root: string, task: string, tier: string, label: string, attempt: number}) => Promise<object>} opts.runOne
422
+ * @param {(outcome: object) => boolean} [opts.verified]
423
+ * @param {number} [opts.attempts] attempts for the best-of rung
424
+ * @param {string} [opts.maxTier]
425
+ * @param {(ev: object) => void} [opts.onEvent]
426
+ * @param {Function} [opts.bestOf] injectable `runBestOf`
427
+ * @param {Function} [opts.pool] injectable concurrency pool
428
+ */
429
+ export async function escalate({
430
+ root,
431
+ task,
432
+ budget,
433
+ runOne,
434
+ verified = defaultVerified,
435
+ attempts = DEFAULT_ATTEMPTS,
436
+ maxTier = 'best-of',
437
+ onEvent = () => {},
438
+ bestOf = runBestOf,
439
+ pool,
440
+ /** The model the run is already using — tier 0, and the only one unless tiers are configured. */
441
+ baseModel = null,
442
+ env = process.env,
443
+ }) {
444
+ if (typeof runOne !== 'function') {
445
+ throw new TypeError('escalate: runOne must be a function — it is the only thing that does any work');
446
+ }
447
+ if (TIERS.indexOf(maxTier) === -1) {
448
+ throw new RangeError(`escalate: unknown maxTier ${JSON.stringify(maxTier)} — expected one of ${TIERS.join(', ')}`);
449
+ }
450
+
451
+ /**
452
+ * ⚠️ READ OFF THE BUDGET ITSELF, never passed in separately — two numbers
453
+ * meaning "the limit" is how they end up disagreeing. `null` (no `--budget`)
454
+ * yields an empty allocation and every rung runs uncapped, which is exactly
455
+ * what a user who set no ceiling asked for.
456
+ */
457
+ const limitUsd = budget?.canContinue?.()?.limitUsd ?? null;
458
+ const share = limitUsd === null ? {} : allocate(limitUsd, { maxTier, attempts });
459
+
460
+ /** @type {Array<{tier: string, verified: boolean, costUsd: number, why: string}>} */
461
+ const rungs = [];
462
+ /** @type {Array<{tier: string, why: string, projectedUsd: number, remainingUsd: number}>} */
463
+ const skipped = [];
464
+
465
+ let tier = 'solo';
466
+ let last = null;
467
+ let lastAttemptUsd = 0;
468
+ let spentUsd = 0;
469
+ let lastModel = null;
470
+
471
+ for (;;) {
472
+ const label = `${tier} attempt ${rungs.length + 1}`;
473
+
474
+ /**
475
+ * ⭐ THE RUNG MAY ALSO GET A STRONGER MODEL. Off unless `ACUVO_MODEL_TIERS`
476
+ * is set, in which case every tier is the same model and nothing changes —
477
+ * see `model-tier.mjs` for why the default has to be inert.
478
+ */
479
+ const plan = planRung(TIERS.indexOf(tier), { baseModel, env, previousModel: lastModel });
480
+ const rungModel = plan.model;
481
+ if (plan.changed) {
482
+ const line = describeSwitch(lastModel, rungModel);
483
+ if (line) onEvent({ type: 'escalate-model', from: lastModel, to: rungModel, note: line });
484
+ /**
485
+ * ⚠️⚠️ A CHANGED MODEL INVALIDATES THE COST PROJECTION. `projectTierCost`
486
+ * prices the next rung from what the LAST one measurably cost, which is
487
+ * only sound while the model is constant. Rather than guess at a price
488
+ * table this package does not have, the measured basis is DISCARDED so
489
+ * the projection falls back to its floor and the budget check below stays
490
+ * conservative instead of confidently wrong.
491
+ */
492
+ lastAttemptUsd = 0;
493
+ }
494
+ lastModel = rungModel;
495
+
496
+ onEvent({ type: 'escalate-start', tier, attempt: rungs.length + 1, label, model: rungModel });
497
+
498
+ let outcome = null;
499
+ let crashed = null;
500
+
501
+ try {
502
+ if (tier === 'best-of') {
503
+ const result = await bestOf({
504
+ root,
505
+ attempts,
506
+ pool,
507
+ onEvent,
508
+ failed: (o) => !verified(o),
509
+ runOne: ({ root: dir, label: attemptLabel }) => runOne({
510
+ root: dir,
511
+ task,
512
+ tier,
513
+ label: attemptLabel,
514
+ attempt: rungs.length + 1,
515
+ /**
516
+ * ⚠️ THE RUNG'S SHARE DIVIDED BY N, because this rung runs N of
517
+ * them. Handing each parallel attempt the whole rung budget is how
518
+ * `--budget 2.00` quietly becomes $6.
519
+ */
520
+ budgetUsd: share[tier] === undefined ? undefined : share[tier] / Math.max(1, attempts),
521
+ model: rungModel,
522
+ }),
523
+ });
524
+ /**
525
+ * ⚠️ `runBestOf` REPORTS ITS OWN REFUSAL AS `ok:false` — the workspace
526
+ * being too large to copy three times is not a failed attempt, it is a
527
+ * rung that could not be entered. Treating it as a failure would let
528
+ * the ladder claim it tried something it never started.
529
+ */
530
+ if (result?.ok !== true) {
531
+ skipped.push({
532
+ tier, why: String(result?.error ?? 'best-of could not start'), projectedUsd: 0, remainingUsd: 0,
533
+ });
534
+ onEvent({ type: 'escalate-skipped', tier, why: String(result?.error ?? 'best-of could not start') });
535
+ break;
536
+ }
537
+ outcome = result.winner;
538
+ spentUsd += result.totalCost ?? 0;
539
+ lastAttemptUsd = (result.totalCost ?? 0) / Math.max(1, result.attempts ?? 1);
540
+ onEvent({ type: 'escalate-best-of', tier, ...result });
541
+ } else {
542
+ const briefed = tier === 'fresh' ? freshBriefing(task, last) : task;
543
+ outcome = await runOne({
544
+ root, task: briefed, tier, label, attempt: rungs.length + 1, budgetUsd: share[tier],
545
+ model: rungModel,
546
+ });
547
+ const c = costOf(outcome);
548
+ spentUsd += c;
549
+ lastAttemptUsd = c;
550
+ }
551
+ } catch (e) {
552
+ crashed = e?.message ?? String(e);
553
+ }
554
+
555
+ /**
556
+ * ── ⚠️ RULE 4: A CRASH IS NOT A RUNG ────────────────────────────────────
557
+ *
558
+ * A thrown error is the transport, the process, or us — never evidence that
559
+ * the APPROACH was wrong. Consuming an escalation for it means a flaky
560
+ * connection buys the expensive rung, which is the precise opposite of
561
+ * spending more on hard problems. It ends the ladder honestly instead.
562
+ */
563
+ if (crashed !== null) {
564
+ onEvent({ type: 'escalate-crashed', tier, error: crashed });
565
+ return {
566
+ ok: false,
567
+ stopped: 'crashed',
568
+ error: crashed,
569
+ tier,
570
+ outcome: last,
571
+ rungs,
572
+ skipped,
573
+ spentUsd,
574
+ };
575
+ }
576
+
577
+ const passed = verified(outcome);
578
+ const costThisRung = tier === 'best-of' ? spentUsd - rungs.reduce((s, r) => s + r.costUsd, 0) : lastAttemptUsd;
579
+ rungs.push({
580
+ tier,
581
+ verified: passed,
582
+ costUsd: costThisRung,
583
+ why: passed ? 'verified' : (outcome?.stoppedBecause ?? outcome?.error ?? 'did not verify'),
584
+ });
585
+ last = {
586
+ reason: outcome?.stoppedBecause ?? null,
587
+ failure: outcome?.acceptance?.failing?.[0]?.command ?? null,
588
+ outcome,
589
+ };
590
+
591
+ if (passed) {
592
+ onEvent({ type: 'escalate-verified', tier, attempts: rungs.length, spentUsd });
593
+ return { ok: true, stopped: 'verified', tier, outcome, rungs, skipped, spentUsd };
594
+ }
595
+
596
+ /**
597
+ * ⚠️ RULE 5, CHECKED BEFORE THE BUDGET CHECK ON PURPOSE. A blocked run has
598
+ * usually spent almost nothing, so the budget would happily wave the next
599
+ * rung through — and the two remaining attempts would fail identically. The
600
+ * cheaper stop has to win.
601
+ */
602
+ const blocked = blockedBy(outcome);
603
+ if (blocked !== null) {
604
+ onEvent({ type: 'escalate-blocked', tier, why: blocked });
605
+ return { ok: false, stopped: 'blocked', why: blocked, tier, outcome, rungs, skipped, spentUsd };
606
+ }
607
+
608
+ const up = nextTier(tier, { maxTier });
609
+ if (up === null) {
610
+ onEvent({ type: 'escalate-exhausted', tier, spentUsd });
611
+ return { ok: false, stopped: 'exhausted', tier, outcome, rungs, skipped, spentUsd };
612
+ }
613
+
614
+ /**
615
+ * ── ⚠️ RULES 1 + 2 + 3, THE WHOLE POINT OF THE MODULE ───────────────────
616
+ *
617
+ * The projection is derived from what the last rung MEASURABLY cost, and
618
+ * the rung is entered only if the remaining budget covers it. When it does
619
+ * not, that is recorded as a SKIP with the two numbers that explain it —
620
+ * so the caller can tell "this is hard" from "this needed another dollar".
621
+ */
622
+ const verdict = budget?.canContinue?.() ?? { ok: true, remainingUsd: Infinity };
623
+ const remainingUsd = Number.isFinite(verdict.remainingUsd) ? verdict.remainingUsd : Infinity;
624
+ /**
625
+ * ⭐ THE MODEL THE NEXT RUNG WOULD ACTUALLY USE. Resolved BEFORE the
626
+ * affordability gate, because the whole defect was projecting the next
627
+ * rung at the price of the model the LAST one happened to run on.
628
+ * `planRung` is the same function the rung itself calls, so the
629
+ * projection and the run cannot disagree about which model that is.
630
+ */
631
+ const upModel = planRung(TIERS.indexOf(up), { baseModel, env, previousModel: lastModel }).model;
632
+ const priced = modelCostRatio(lastModel, upModel);
633
+ const projectedUsd = projectTierCost(up, { lastAttemptUsd, attempts, fromModel: lastModel, toModel: upModel });
634
+
635
+ if (projectedUsd > remainingUsd) {
636
+ const why = `the ${up} rung projects at ~${formatUsd(projectedUsd)} and only ${formatUsd(remainingUsd)} of the budget is left`;
637
+ skipped.push({ tier: up, why, projectedUsd, remainingUsd });
638
+ onEvent({ type: 'escalate-skipped', tier: up, why, projectedUsd, remainingUsd });
639
+ return { ok: false, stopped: 'budget', tier, outcome, rungs, skipped, spentUsd };
640
+ }
641
+
642
+ onEvent({ type: 'escalate-up', from: tier, to: up, projectedUsd, remainingUsd });
643
+ tier = up;
644
+ }
645
+
646
+ return { ok: false, stopped: 'exhausted', tier, outcome: last?.outcome ?? null, rungs, skipped, spentUsd };
647
+ }
648
+
649
+ /**
650
+ * Render the ladder for a human. Rule 3 lives here too: a skipped rung must be
651
+ * as visible as an attempted one, or the report quietly becomes a lie of
652
+ * omission at exactly the moment the operator is deciding whether to pay more.
653
+ */
654
+ export function formatEscalation(result) {
655
+ if (!result) return '';
656
+ const lines = [];
657
+
658
+ for (const r of result.rungs ?? []) {
659
+ const mark = r.verified ? '✔' : '✖';
660
+ lines.push(` ${mark} ${r.tier.padEnd(8)} ${formatUsd(r.costUsd).padStart(9)} ${r.why}`);
661
+ }
662
+ for (const s of result.skipped ?? []) {
663
+ lines.push(` · ${s.tier.padEnd(8)} ${'skipped'.padStart(9)} ${s.why}`);
664
+ }
665
+
666
+ /**
667
+ * ⚠️ THE HEADER SAYS THESE ARE SLICES. Each rung ran under its own share of
668
+ * `--budget`, so a reader comparing a rung's figure to the ceiling they typed
669
+ * would otherwise conclude the run barely spent anything.
670
+ */
671
+ if (lines.length > 0) lines.unshift(' the ladder (each rung ran on its own slice of --budget):');
672
+
673
+ const total = formatUsd(result.spentUsd ?? 0);
674
+ switch (result.stopped) {
675
+ case 'verified':
676
+ lines.push(` → verified at the ${result.tier} rung for ${total}.`);
677
+ break;
678
+ case 'budget':
679
+ /**
680
+ * ⚠️ THIS SENTENCE IS THE PRODUCT. It is the only place a run says "I did
681
+ * not fail, I ran out of money, and here is what the next dollar buys".
682
+ */
683
+ lines.push(` → stopped on budget, not on capability: ${total} spent and the next rung was unaffordable.`);
684
+ break;
685
+ case 'exhausted':
686
+ lines.push(` → every rung tried and none verified — ${total} spent. This one is genuinely hard.`);
687
+ break;
688
+ case 'crashed':
689
+ lines.push(` → the ladder stopped on an error, which is not a verdict on the work: ${result.error}`);
690
+ break;
691
+ case 'blocked':
692
+ /**
693
+ * ⚠️ NAMES THE MACHINE, NOT THE TASK. Trying twice more would have cost
694
+ * three times the wall-clock to reprint this same sentence.
695
+ */
696
+ lines.push(` → did not escalate: ${result.why}. Every rung would fail the same way, so trying harder would only cost more.`);
697
+ break;
698
+ default:
699
+ break;
700
+ }
701
+ return lines.join('\n');
702
+ }