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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/escalate.mjs
ADDED
|
@@ -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
|
+
}
|