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,1255 @@
1
+ /**
2
+ * ARGUMENT PARSING — pure, so the CLI's contract is testable without spawning a
3
+ * process or spending a completion.
4
+ *
5
+ * Deliberately hand-rolled rather than `node:util.parseArgs`: the flag surface
6
+ * is five entries and the errors need to be sentences, not `Unknown option`.
7
+ */
8
+
9
+ import { DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS, DEFAULT_MODEL } from './model.mjs';
10
+ import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS, ALLOWED_BINARIES, PRESET_NAMES } from './command.mjs';
11
+ /**
12
+ * ⭐ THE PARSER FOR `--budget` LIVES IN `budget.mjs`, NOT HERE. The flag and the
13
+ * governor that enforces it must read the same string the same way; two parsers
14
+ * for one idea is how `25c` becomes $25 in one of them.
15
+ */
16
+ import { parseBudgetUsd, formatUsd, DEFAULT_BUDGET_USD } from './budget.mjs';
17
+ import { parseSince } from './spend.mjs';
18
+ import { resolveModelName } from './acuvo-models.mjs';
19
+ import { TIERS as ESCALATION_TIERS } from './escalate.mjs';
20
+ /**
21
+ * ⭐ ENGINE IDS ONLY — no prices reach the parser, because no prices reach this
22
+ * PACKAGE. `--engine` is validated against the id list; what an engine costs is
23
+ * an account fact the gateway answers. See creative-engines.mjs.
24
+ */
25
+ import { CREATIVE_ENGINES, engineById } from './creative-engines.mjs';
26
+
27
+ /**
28
+ * ── ⭐⭐ THE CEILING: RAISED 8 → 16 (2026-08-11), AND WHY IT COULD NOT MOVE
29
+ * BEFORE TODAY ────────────────────────────────────────────────────────
30
+ *
31
+ * ⚠️ THE OLD NOTE HERE SAID "LEFT AT 8 DELIBERATELY: nothing measured argues
32
+ * against it", and it was right at the time — because the binding constraint
33
+ * was never the round COUNT, it was the transcript. Every `read_file` result
34
+ * stayed in the conversation forever, so round N re-sent everything rounds
35
+ * 1..N-1 had read. Raising the ceiling raised the burst risk, not the horizon.
36
+ *
37
+ * ⭐ COMPACTION (lib/compact.mjs, wired into lib/turn.mjs's round loop today)
38
+ * removes that coupling: the history is now held under a 24,000-token budget
39
+ * before each call, so a 12-round session sends roughly what a 4-round one
40
+ * does. The ceiling can move because the thing it was protecting against no
41
+ * longer grows without bound.
42
+ *
43
+ * ── THE MEASUREMENT, NOT A GUESS ────────────────────────────────────────────
44
+ * Driven live on deepseek-v4-flash today, in a workspace with a real file tree:
45
+ *
46
+ * · the loop STOPS ITSELF. A verify-and-tidy task given --max-rounds 5 used
47
+ * 3 and stopped ("no-tool-calls", verified, $0.000921). A second given 2
48
+ * used 2. Nothing in the shipped bench has ever consumed its budget.
49
+ * · the bench's own worst cases — git, refactor, crossfile, feature — are
50
+ * budgeted at 7. Doubling the observed worst case is the standard headroom
51
+ * rule, and 7 × 2 ≈ 16.
52
+ * · an over-budget transcript measured here compacted 61,000 → 21,000
53
+ * estimated tokens. That is the headroom the extra eight rounds spend.
54
+ *
55
+ * ── ⚠️⚠️ RAISED 16 → 64 ON 2026-08-16, AND THE OLD ARGUMENT IS KEPT ─────────
56
+ *
57
+ * It read: *"16, NOT 64, AND NOT UNBOUNDED. A ceiling is a blast radius, not a
58
+ * target … 16 rounds of a $0.0003 round is under a cent — a number a person can
59
+ * lose to a typo without caring. The policy layer's MAX_ROUNDS_CEILING = 64
60
+ * remains the separate, opt-in bound for a config file that states a number on
61
+ * purpose."*
62
+ *
63
+ * ⭐ EVERY WORD OF THAT IS STILL TRUE ABOUT MONEY, and money is no longer the
64
+ * binding constraint — `DEFAULT_BUDGET_USD` has been ON by default since
65
+ * 2026-08-12, and `turn.mjs` checks it **before** each round and refuses the one
66
+ * it cannot afford. So a run given 64 rounds and a $0.02 ceiling still stops at
67
+ * $0.02. Raising this cannot increase what anything spends; the two governors
68
+ * are independent and the dollar one binds first, every time.
69
+ *
70
+ * ⚠️ WHAT 16 ACTUALLY COST US, measured. Terminal-Bench 2.1
71
+ * `torch-tensor-parallelism`, 2026-08-16: the agent wrote `parallel_linear.py`
72
+ * AND a test, ran verification, got **exit 1** — and stopped at round 11 of 16
73
+ * with budget remaining. A failing test in hand and no room to iterate is the
74
+ * exact shape of work this ceiling was silently truncating. Long-horizon tasks
75
+ * average far more turns than 16; the blast-radius reasoning was calibrated on
76
+ * a 13-task bench whose worst case was 7.
77
+ *
78
+ * ⚠️ THE DEFAULT STILL DOES NOT MOVE. A user who asks for nothing must not
79
+ * suddenly spend more; raising the ceiling costs exactly zero until someone
80
+ * types a bigger number. `DEFAULT_MAX_ROUNDS` is untouched.
81
+ *
82
+ * ⚠️ AND `UNTIL_DONE_MAX_ROUNDS = 200` IS STILL THE SEPARATE, HIGHER BOUND for
83
+ * a run that has priced the job — see below. This change is only about the
84
+ * number a human is allowed to type by hand.
85
+ */
86
+ export const MAX_ROUNDS_LIMIT = 64;
87
+
88
+ /**
89
+ * ── ⭐ THE CLI'S ROUND BUDGET: RAISED 3 → 5 (2026-08-10) ─────────────────────
90
+ *
91
+ * ⚠️ FIRST, THE DIVERGENCE, NAMED RATHER THAN HIDDEN: `turn.mjs` also exports a
92
+ * `DEFAULT_MAX_ROUNDS = 3`, which this module used to import. That constant is
93
+ * now only the fallback for a LIBRARY caller that omits `maxRounds`; the CLI
94
+ * always passes `opts.maxRounds` (bin/acuvo.mjs:117 and :207), so this value is
95
+ * what anyone typing `acuvo` actually gets. Two numbers for one idea is real
96
+ * debt — the fix is to move the constant here (or have turn.mjs import it) and
97
+ * delete the other, which needs an edit to turn.mjs.
98
+ *
99
+ * ── WHY 3 WAS NOT "CONSERVATIVE", IT WAS BROKEN ─────────────────────────────
100
+ * Measured live on deepseek-v4-flash, a plain fix-and-verify task
101
+ * ("run the failing test, work out why, fix the code, re-run, write NOTES.md"):
102
+ *
103
+ * round 1 $ npm test → diagnose the failure
104
+ * round 2 write the fix + NOTES.md
105
+ * round 3 $ npm test → passes
106
+ *
107
+ * That is the COMMONEST SHAPE IN THIS TOOL and it consumes the entire default
108
+ * budget with zero slack. It did not fail — it had no room to.
109
+ *
110
+ * ⚠️ AND THE REAL DAMAGE IS A FEATURE THAT CANNOT RUN. turn.mjs:1028 grants one
111
+ * extra round after a command passes ("committing your work, cleaning up a
112
+ * scratch file, another step that was asked for") — but ONLY when
113
+ * `round < maxRounds`. Since the pass reliably lands ON round 3, the guard fires
114
+ * every time and the grace round is STRUCTURALLY UNREACHABLE at the default.
115
+ * Observed verbatim: "✔ a command passed — stopping here rather than spending
116
+ * another round." The tidy-up round is dead code for every default user.
117
+ * At maxRounds 4 the same task instead printed "one more round to finish
118
+ * anything else that was asked" and used it to write the file it owed.
119
+ *
120
+ * Corroborating, from this package's own bench, where the author had to
121
+ * override the budget per task: create 3 · edit 3 · refuse 3 · search 4 ·
122
+ * fix 4 · multifile 4 · git 7 · refactor 7 · crossfile 7 · feature 7.
123
+ * SIX OF NINE needed more than the default; the median need is 4. bench/tasks.mjs:173
124
+ * already wrote the conclusion down — "DEFAULT_MAX_ROUNDS is 3, which cannot fit
125
+ * any task that ends in cleanup and a commit."
126
+ *
127
+ * ── SO: 3 (the measured floor) + 1 (the grace round) + 1 (one failed fix) = 5 ─
128
+ * The +1 for a failed fix is not padding: the bench budgets 4 for `fix` and
129
+ * `search` assuming the FIRST fix works, so a single wrong guess needs a fifth.
130
+ * 5 is the median-plus-recovery, not the maximum — the four 7-round tasks all
131
+ * end in "tidy up AND commit", a shape the user has explicitly asked for and can
132
+ * pay for with --max-rounds.
133
+ *
134
+ * ── 💸 COST IMPACT, FROM AN A/B ON THE IDENTICAL TASK ────────────────────────
135
+ * Same prompt, same fresh workspace, only the budget differs:
136
+ *
137
+ * --max-rounds 3 → 3 rounds · 11,746 tok · $0.000377972 (stopped: verified)
138
+ * --max-rounds 4 → 4 rounds · 16,258 tok · $0.000509272 (used the grace round)
139
+ *
140
+ * so the marginal round costs $0.000131 — +34.7% on a task that costs under a
141
+ * twentieth of a cent. At 1,000 tasks/month that is $0.38 → $0.51.
142
+ *
143
+ * ⚠️ THEN THE SAME TASK WAS RUN ON THE SHIPPED DEFAULT AND COST MORE THAN THAT
144
+ * ARITHMETIC PREDICTED — the honest number, not the flattering one:
145
+ *
146
+ * default 5 → 5 rounds · $0.000784 (stopped: no-tool-calls, verified true)
147
+ *
148
+ * i.e. +$0.000406 / +107% against the old default's $0.000378, because the model
149
+ * took the grace round AND a fifth round to re-verify, rather than the four the
150
+ * A/B extrapolation assumed. Late rounds are dearer than early ones (the whole
151
+ * conversation is re-sent as prompt), so per-round averages understate the tail.
152
+ * Doubling the price of a task that costs $0.0008 is the right trade for a tool
153
+ * that otherwise stops one step short of finishing — but it IS a doubling, and
154
+ * anyone revisiting this should argue with that number, not the +34.7% one.
155
+ *
156
+ * ⭐ AND THE CAP IS NOT A SPEND COMMITMENT, WHICH IS THE WHOLE REASON THIS IS
157
+ * SAFE. The loop stops on its own — `verified`, or `no-tool-calls`. Measured: a
158
+ * six-instruction task (fix · add a test · re-run · delete a scratch file ·
159
+ * write a CHANGELOG) given --max-rounds 8 used FOUR and stopped, for $0.000717.
160
+ * Raising the ceiling buys headroom for the tasks that need it and costs nothing
161
+ * on the tasks that don't. That is why this moves and MAX_ROUNDS_LIMIT does not.
162
+ */
163
+ /**
164
+ * ── ⭐⭐⭐ RAISED 5 → 24 ON 2026-08-19, AND THE ARGUMENT IS ABOVE ────────────
165
+ *
166
+ * Roman: *"it's not capable and it shouldn't take this long, there's no
167
+ * reason."* He was right, and this number was a large part of why. Two live
168
+ * runs the same day both produced correct, verified work and both stopped
169
+ * because they ran out of ROOM, not because they were finished — one at 5 of 5,
170
+ * one at 4 of 5. A harness that can only ever attempt three-file tasks looks
171
+ * incapable while being nothing of the kind.
172
+ *
173
+ * ⭐ AND RAISING IT IS ALMOST FREE, which is the measurement directly above:
174
+ * the loop stops on its own at `verified` or `no-tool-calls`, so a task that
175
+ * needs four rounds still takes four. A ceiling is headroom for the work that
176
+ * needs it and costs nothing on the work that does not.
177
+ *
178
+ * ⚠️ THE MONEY IS THE REAL WALL, NOT THIS. `--budget` governs spend and
179
+ * `DEFAULT_BUDGET_USD` moved with it; this number exists so a bug in the
180
+ * governor cannot produce a `while (true)`. 24 is deliberately far below
181
+ * `MAX_ROUNDS_LIMIT` (64) so there is still a ceiling above the default.
182
+ */
183
+ export const DEFAULT_MAX_ROUNDS = 24;
184
+
185
+ /**
186
+ * ── ⭐⭐ THE BACKSTOP `--until-done` RUNS AGAINST, AND WHY IT IS NOT INFINITY ─
187
+ *
188
+ * ⚠️ THE ROUND COUNTER IS AN ARBITRARY STOP AND ALWAYS WAS. It stops a run that
189
+ * is one round from finishing and it lets a run that is going nowhere spend its
190
+ * whole allowance; the thing a user actually has an opinion about is MONEY.
191
+ * `--budget` is the real wall, `lib/budget.mjs` is the governor, and this number
192
+ * exists only so that a bug in the governor cannot produce a `while (true)`.
193
+ *
194
+ * ⚠️ IT IS UNREACHABLE WITHOUT A CEILING. `--until-done` REFUSES to run without
195
+ * `--budget` (see `parseArgv`), so nothing can select this backstop without
196
+ * first stating what it is willing to spend. 200 rounds of a measured $0.0008
197
+ * round is about $0.16 — well below any budget anyone would type, which is the
198
+ * point: the money runs out first, every time, and this only ever catches the
199
+ * case where the governor itself is broken.
200
+ *
201
+ * ⭐ AND `MAX_ROUNDS_LIMIT` REMAINS SEPARATE AND LOWER. A number a human types by
202
+ * hand is a blast radius (64 since 2026-08-16 — see the note there for why that
203
+ * is still free). A number the machine reaches only after the human has priced
204
+ * the job is a different decision, and stays higher.
205
+ */
206
+ export const UNTIL_DONE_MAX_ROUNDS = 200;
207
+
208
+ export const USAGE = [
209
+ 'acuvo — a coding agent that writes code, RUNS it, and fixes what broke.',
210
+ '',
211
+ 'Usage:',
212
+ /**
213
+ * ⚠️ THE COMMAND A USER ACTUALLY TYPES. This line used to read
214
+ * `node acuvo-code/bin/acuvo.mjs "…"` — a DEV invocation from inside a clone,
215
+ * which nobody who installed the package has ever typed. Help text that
216
+ * teaches the wrong incantation makes the tool look broken to the one person
217
+ * following it exactly.
218
+ */
219
+ ' acuvo "<what you want built or changed>"',
220
+ /**
221
+ * ⭐ THE BARE INVOCATION WAS UNDOCUMENTED, AND IT IS THE FIRST THING A PERSON
222
+ * TYPES. `acuvo` with no task opens an interactive conversation (bin/acuvo.mjs
223
+ * `if (!task) await runChat(...)`), where six `/` commands live — and item 14
224
+ * built them, `/help` generates itself from `SLASH_COMMANDS`, and NOTHING a
225
+ * new user reads said the prompt existed at all. Measured 2026-08-19 against
226
+ * the real `--help` output: "chat" 0, "slash" 0, "/help" 0.
227
+ */
228
+ ' acuvo With no task: an interactive prompt. Type `/help` there',
229
+ ' for the commands it understands (/skills, /mcp, /cost,',
230
+ ' /model, /clear). Ctrl-C returns you to the prompt.',
231
+ '',
232
+ ' (from a clone, without installing: node bin/acuvo.mjs "<task>")',
233
+ '',
234
+ 'Options:',
235
+ ` --dir <path> Workspace root (default: the current directory).`,
236
+ ` --model <id> OpenRouter model id (default: $OPENROUTER_CODEGEN_MODEL, else ${DEFAULT_MODEL}).`,
237
+ ` --max-rounds <n> Write→run→fix rounds, 1-${MAX_ROUNDS_LIMIT} (default: ${DEFAULT_MAX_ROUNDS}). 1 = one completion, nothing executed.`,
238
+ /**
239
+ * ── ⭐⭐ THE TWO FLAGS THAT MOVE THE STOP CONDITION FROM A COUNTER TO MONEY ─
240
+ * Documented here and not only in the changelog, because a capability nobody
241
+ * can find is the same orphan as a module nobody imports.
242
+ */
243
+ ' --budget <usd> Stop when the NEXT round would cross this much spend.',
244
+ ' --budget 0.50 · --budget 25c · --budget $2',
245
+ ' Refuses to start at all if it cannot afford one round,',
246
+ ' so it never spends money to discover it had none.',
247
+ ' ⭐ A $0.02 ceiling is ALREADY ON. A measured task costs',
248
+ ' $0.0008–$0.003, so it never fires on ordinary work — it',
249
+ ' is there so a runaway costs two cents to find. Raise it',
250
+ ' with --budget, or remove it with --budget none.',
251
+ ' --fleet-budget <usd> The ceiling across EVERY terminal working this workspace',
252
+ ' today, not just this one. --budget caps a run; seven',
253
+ ' terminals multiply that by seven, and this is the number',
254
+ ' that stays true when you open all seven. Summed from the',
255
+ ' audit log they all already write to, so there is no second',
256
+ ' ledger to drift. Off unless you ask for it.',
257
+ ' --until-done Keep going while the criterion you declared is unmet,',
258
+ ' the budget allows, and the loop is not going in circles.',
259
+ ' REQUIRES --budget. There is no unbounded mode.',
260
+ ' ESCALATES rather than just retrying: one attempt, then a',
261
+ ' fresh context carrying the failure, then several parallel',
262
+ ' attempts keeping whichever verifies. Each rung runs on its',
263
+ ' own slice of --budget, and a rung the remaining budget',
264
+ ' cannot cover is skipped and reported, never half-started.',
265
+ ' --budget-window <p> Measure --fleet-budget over this period instead of today.',
266
+ ' 7d · 24h · 2026-08-01. A schedule that fires hourly gets a',
267
+ ' FRESH per-run ceiling every time, so the number you chose is',
268
+ ' a rate, not a total — this makes it a total again.',
269
+ ' verify [id] [--all] Re-check a past claim by RUNNING it again — no model call,',
270
+ ' no cost. --all checks every recorded claim at once,',
271
+ ' deduplicated by command: twelve runs that claimed `npm test`',
272
+ ' are twelve claims and one thing to run.',
273
+ ' rewind [id] UNDO WHAT THE AGENT DID TO YOUR FILES. Bare, it lists every',
274
+ ' checkpoint in this workspace; with an id it puts the files back',
275
+ ' the way they were before that run started. No model call, no cost.',
276
+ ' ⚠️ It REFUSES any file you changed yourself after the run — that',
277
+ ' edit is yours and a rewind that threw it away would be the',
278
+ ' accident, not the fix. --force overrides, and says it did.',
279
+ ' Add --dry-run to see exactly what it would touch.',
280
+ ' --no-checkpoint Do not record the previous contents of the files this run writes,',
281
+ ' so `acuvo rewind` will have nothing to put back. On by default:',
282
+ ' it costs one read of a file about to be overwritten anyway.',
283
+ ' --force Only with `acuvo rewind`. Restore even the files you edited after',
284
+ ' the run — every forced path is printed as FORCED.',
285
+ ' --refute Get a SECOND OPINION. When the run claims success, an',
286
+ ' independent agent with a fresh context — no sight of how the',
287
+ ' first one thought — tries to REFUTE the claim: runs the tests,',
288
+ ' checks the callers, looks for a requirement nothing addressed.',
289
+ ' It cannot write. A concrete refutation fails the run; an opinion',
290
+ ' changes nothing, because failing correct work is the worse error.',
291
+ ' Costs a second run — about what the first one cost.',
292
+ ' --unattended Nobody is watching. Declining on budget exits 3 instead of 1,',
293
+ ' so a cron log can tell "it chose not to run" from "it failed".',
294
+ ' --claim Take the next open task off the shared board and run it,',
295
+ ' instead of typing a prompt. Needs --holder. Seven terminals',
296
+ ' each running `acuvo --holder tN --claim` split one list of',
297
+ ' work with nobody doing the same task twice.',
298
+ ' See `acuvo board` and `acuvo board add "…"`.',
299
+ ' --no-auto-lease Stop claiming each file as it is written. Every write and',
300
+ ' delete normally takes a short lease on that exact path, so a',
301
+ ' second terminal writing the same file is REFUSED rather than',
302
+ ' silently overwriting your work. Only a proven conflict refuses;',
303
+ ' a lease system that cannot run degrades to the old behaviour.',
304
+ ' --lease <path> Claim a file before starting, so several terminals can',
305
+ ' share one checkout. Repeatable. Released on exit.',
306
+ ' --holder <name> Who to record as holding those leases (default: the pid).',
307
+ ' --no-run Never execute anything. The model can still read and write files.',
308
+ // ⚠️ THE HELP LINE STATES THE RULE, NOT THE MENU. Somebody skimming this must
309
+ // learn that the dear engine is opt-in; a bare list of ids would read as a
310
+ // menu of equals and the expensive one is not an equal.
311
+ ' --engine <id> Which Acuvo creative engine this run may use, e.g. acuvo-image-ultra.',
312
+ ' The CORE engine is the default and an Ultra one runs ONLY when you',
313
+ ' name it here. `acuvo engines` lists them with what each one costs.',
314
+ ' --max-tier <tier> How hard --until-done may try: solo | fresh | best-of (default best-of).',
315
+ ' solo turns escalation off entirely; fresh allows one retry with a clean',
316
+ ' context; best-of allows the parallel attempts as well.',
317
+ ' --best-of <n> Do the task n times (2-5) in isolated copies and keep the one that',
318
+ ' actually passes. Costs n times as much — which at ~$0.001 a run is',
319
+ ' a third of a cent for three tries.',
320
+ ' --shell Let it run ANY program, with pipes and redirection, at your privileges.',
321
+ ' Off by default: without this it can only run node, npm, npx and tsc.',
322
+ ` --command-timeout <s> Kill a command after this long (default: ${DEFAULT_COMMAND_TIMEOUT_MS / 1000}).`,
323
+ // ⚠️ INTERPOLATED, NOT TYPED OUT. This line said "default: 8000" while the
324
+ // constant said something else the moment DEFAULT_MAX_TOKENS moved — help text
325
+ // that lies about the tool's own budget is how someone concludes a truncated
326
+ // reply is a model defect rather than a flag they can raise.
327
+ ` --max-tokens <n> Ceiling on each reply (default: ${DEFAULT_MAX_TOKENS}).`,
328
+ ' --timeout <seconds> Give up on the model after this long (default: 180).',
329
+ ' --issue <n> Read a GitHub issue, branch, fix it, run the tests.',
330
+ ' Stops at a local branch — never pushes, never opens a PR.',
331
+ ' --parallel Run several quoted tasks at once:',
332
+ ' acuvo --parallel "add tests" "write the README"',
333
+ ' Names any file written by more than one task, and',
334
+ ' exits 1 if there was a collision.',
335
+ ' --concurrency <n> How many at a time, 1-4 (default 2).',
336
+ ' --json One JSON object on stdout, nothing else. Human output',
337
+ ' goes to stderr, so `acuvo --json ... | jq` just works.',
338
+ ' --dry-run Print what WOULD be written, touch nothing, run nothing.',
339
+ /**
340
+ * ⚠️ THE HELP LINE HAS TO SAY HOW THIS DIFFERS FROM THE TWO FLAGS PEOPLE
341
+ * ALREADY REACH FOR. `--dry-run` and `--no-run` are about the ACT; `--plan`
342
+ * is about the INTENT, and someone skimming a list of three will otherwise
343
+ * assume the one they know already covers it.
344
+ *
345
+ * ⚠️⚠️ "TWO LOCKS" IS IN THE TEXT BECAUSE ONE OF THEM WAS MISSING (fixed
346
+ * 2026-08-20). This line used to promise "offered READING TOOLS ONLY — it
347
+ * cannot write", and only the first clause was enforced: narrowing the offer
348
+ * does not stop a model emitting a name it was never shown, and
349
+ * `executeToolCall` executed `write_file` when it arrived. Measured — the
350
+ * proposal phase wrote a file, edited a source file and deleted a file. The
351
+ * fix is `planPhaseExecutor` at the foot of this file, and the help now says
352
+ * which lock does what so the promise can be checked rather than believed.
353
+ */
354
+ ' --plan Say what it intends to do FIRST, and do nothing until you approve.',
355
+ ' TWO LOCKS: the planning phase is OFFERED reading tools only, and the',
356
+ ' executor REFUSES every write, delete and move underneath it — so a',
357
+ ' tool it was never shown still cannot change anything.',
358
+ ' You then get the plan, and',
359
+ ' [y/N]: Enter means no. Type a correction instead of y to approve',
360
+ ' with an amendment. Needs a terminal (it refuses in CI, before',
361
+ ' spending anything). Unlike --dry-run, the approved run is real.',
362
+ ' -h, --help This.',
363
+ ' --version Print the version and exit. Needs no key, spends nothing.',
364
+ '',
365
+ 'Commands (these read; they never spend a completion and need no API key):',
366
+ ' acuvo leases Who holds which file in this workspace, and since when.',
367
+ /**
368
+ * ── ⚠️ TWO DEFECTS ON THESE FIVE LINES, AND BOTH SHIPPED ──────────────────
369
+ *
370
+ * 1. The separator was the literal bytes EF BF BD — U+FFFD REPLACEMENT
371
+ * CHARACTER — committed into SOURCE, not a terminal rendering artifact.
372
+ * `sed -n '332p' lib/cli-args.mjs | od -c` showed it, while the same `·`
373
+ * rendered correctly elsewhere in the same output.
374
+ * 2. The two "Reads .acuvo/audit/" lines hung off `acuvo engines`, describing
375
+ * a cost ledger that belongs to `acuvo spend`. Rendered, a reader was told
376
+ * the ENGINE LIST omits runs with no recorded cost — which is not a thing
377
+ * engines does. Pure ordering; the sentences are unchanged.
378
+ */
379
+ ' acuvo spend What runs in this workspace have cost. --since 7d · --json.',
380
+ ' Reads .acuvo/audit/ · runs that never recorded a cost are shown',
381
+ ' separately and are NEVER counted as zero.',
382
+ ' acuvo engines Which creative engines this account can reach, and the credit',
383
+ ' cost of each — asked BEFORE you spend any of them.',
384
+ '',
385
+ /**
386
+ * ── ⚠️⭐ EVERY VARIABLE, INCLUDING THE SHARED SECRET ───────────────────────
387
+ *
388
+ * This section used to list two names. The media half of this tool reads five
389
+ * more, and their absence from here is exactly what made four capabilities
390
+ * look BROKEN rather than UNCONFIGURED — `see_page`, `speak`, `transcribe`
391
+ * and `make_document` are simply never offered to the model when their URL is
392
+ * unset, and nothing anywhere told the reader which variable to set.
393
+ *
394
+ * ⚠️ MODAL_VIDEO_SECRET IS THE ONE THAT COST THE MOST. It is not a URL, so it
395
+ * never appeared in an error about a missing endpoint; a correctly-set URL
396
+ * without it returns an authorisation failure that reads like a broken
397
+ * service. It is one shared secret for all four Modal endpoints.
398
+ *
399
+ * ⭐ AND `--doctor` IS NAMED HERE ON PURPOSE: reading a list is guessing;
400
+ * `acuvo --doctor` says which of these are actually working on THIS machine,
401
+ * needs no key, and spends nothing.
402
+ */
403
+ 'Environment (or put them in a .env beside your project — acuvo loads it):',
404
+ /**
405
+ * ── ⚠️⚠️⭐ THIS LINE SAID BYOK WAS THE ONLY WAY IN ─────────────────────────
406
+ *
407
+ * It read: "OPENROUTER_API_KEY required — the only one needed to write
408
+ * code." Measured 2026-08-19, `--help` mentioned "login" 0 times, "whoami" 0,
409
+ * "logout" 0 — so a stranger who read the front door end to end concluded
410
+ * BYOK is the only mode this tool has, while `--whoami` was simultaneously
411
+ * telling them to run `acuvo --login`, a command `--help` did not list.
412
+ *
413
+ * ⭐ The doctrine is the opposite (memory `project_acuvo_cli_byok_never_and_licence`,
414
+ * Roman, twice): an Acuvo account is the product, BYOK is the fallback. The
415
+ * entry now names the flag that gets you off it, in the one place a person
416
+ * who is about to export a provider key is actually looking.
417
+ */
418
+ ' OPENROUTER_API_KEY the BYOK FALLBACK — used only when you have not run',
419
+ ' `acuvo --login`. It bills YOUR provider account, not',
420
+ ' your Acuvo credits. One of the two is needed to write',
421
+ ' code; `acuvo --whoami` says which one is in force, and',
422
+ ' `acuvo --login` switches to your Acuvo credits.',
423
+ ' OPENROUTER_CODEGEN_MODEL optional — override the default model.',
424
+ '',
425
+ /**
426
+ * ── ⚠️⚠️ THESE TWO WERE INVISIBLE, AND INVISIBLE IS THE SAME AS ABSENT ────
427
+ *
428
+ * Measured 2026-08-16: `grep -c -i skill lib/cli-args.mjs` → **0**. The whole
429
+ * feature works — `.acuvo/skills/*.md`, frontmatter, discovery, a `load_skill`
430
+ * tool the model can call — and nothing in `--help`, no flag, no mention
431
+ * anywhere a stranger would look. The same was true of MCP config.
432
+ *
433
+ * ⭐ THE POINT IS NOT DOCUMENTATION, IT IS REACH. An extensibility feature
434
+ * nobody can find is extensibility for US and nobody else, which is precisely
435
+ * the verdict an audit returned on this package. Two lines close it.
436
+ */
437
+ ' Extending it — both are real, and were previously undocumented:',
438
+ ' .acuvo/skills/*.md A markdown file per skill (name + description in',
439
+ ' frontmatter). Discovered automatically; the model',
440
+ ' is shown the list and loads one on demand.',
441
+ ' .acuvo/mcp.json MCP servers to connect. `acuvo --doctor` says which',
442
+ ' are reachable on this machine. Ships knowing about',
443
+ ' playwright, browser, docs, filesystem and firecrawl.',
444
+ '',
445
+ /**
446
+ * ⭐ And the thing a user does before they read any of this: they drag a file
447
+ * onto the window. Documented beside the features rather than in a changelog,
448
+ * for the same reason as above.
449
+ */
450
+ ' Drop a file on the terminal and it is read: images, PDFs, Word, Excel, CSV.',
451
+ ' Your terminal pastes the path; acuvo notices it is a real file and looks at it.',
452
+ '',
453
+ ' The media half. Each is dark, not broken, when unset:',
454
+ ' RENDER_AUDIT_URL see_page — render an HTML file and look at it.',
455
+ ' MODAL_TTS_URL speak / --say — turn text into a .wav.',
456
+ ' MODAL_TRANSCRIBE_URL transcribe / --task-audio — turn audio into text.',
457
+ ' MODAL_PRESS_URL make_document — render HTML to PDF.',
458
+ ' MODAL_DOC_READ_URL read_document — read a PDF/Word/Excel/scan you are given.',
459
+ ' MODAL_TABLE_READ_URL read_table — rows and columns out of a picture of a table.',
460
+ ' MODAL_SELECT_URL edit_image — name a thing in an image to replace it.',
461
+ ' MODAL_FLUX_URL edit_image / expand_image — the inpaint + outpaint studio.',
462
+ ' MODAL_VIDEO_SECRET the shared secret those endpoints expect. A URL',
463
+ ' without it fails authorisation and reads like a broken',
464
+ ' service, which is why it is listed here and not implied.',
465
+ ' PERCHANCE_IMAGE_URL generate_image — optional; a default is built in.',
466
+ '',
467
+ ' Run `acuvo --doctor` to see which of these are live on this machine.',
468
+ '',
469
+ `What it may execute out of the box: ${ALLOWED_BINARIES.join(', ')} — and only as \`node <file>\`,`,
470
+ '`npm test`, `npm run <script>`, `npx vitest run`, `tsc`. No shell, no pipes, no other program,',
471
+ 'nothing outside the workspace. Exit code 1 if the last command it ran still fails.',
472
+ '',
473
+ /**
474
+ * ⚠️⚠️ THIS PARAGRAPH IS THE FIX FOR THE LARGEST GAP IN THE PRODUCT, and the
475
+ * gap was never capability. Six vetted presets — python, go, rust, ruby, make,
476
+ * node-bin — ship in `lib/command.mjs` with per-language argument grammars and
477
+ * their own test file. `grep -c preset` returned ZERO in this file and zero in
478
+ * the system prompt, so neither the person reading the tour nor the model
479
+ * driving the loop was ever told they exist. A Python user concluded, from our
480
+ * own help text, that the run-and-fix loop could not touch their repo.
481
+ */
482
+ `Other ecosystems, OFF by default and one line away: ${PRESET_NAMES.join(', ')}.`,
483
+ ' Enable per project: .acuvo/commands.json → {"presets":["python"]}',
484
+ ' Or per shell: ACUVO_ALLOW_COMMANDS=python',
485
+ 'Then `pytest -q`, `go test ./...`, `cargo test`, `rspec`, `make test` run like `npm test` does.',
486
+ 'Each preset is a vetted argument grammar, not a shell — the boundary does not move, the menu does.',
487
+ ].join('\n');
488
+
489
+ const FLAGS_WITH_VALUES = new Set([
490
+ '--dir', '--model', '--max-tokens', '--timeout', '--max-rounds', '--command-timeout',
491
+ '--budget', '--fleet-budget', '--budget-window', '--lease', '--holder', '--since',
492
+ '--engine',
493
+ ]);
494
+
495
+ /**
496
+ * ⭐ A BARE `acuvo leases` IS A COMMAND; `acuvo leases are broken` IS A TASK.
497
+ * The distinction is deliberately as narrow as it can be: the word must be the
498
+ * FIRST argument and the ONLY positional. Anything looser would swallow a real
499
+ * instruction, and a coding agent that silently answers a different question
500
+ * than the one asked is the worst failure available to an argument parser.
501
+ */
502
+ /**
503
+ * ⭐ `verify` takes an OPTIONAL id, so it is handled beside `board` rather than
504
+ * here — the bare-word rule below is "exactly one positional", and
505
+ * `acuvo verify 2026-08-13T...` is two.
506
+ */
507
+ const COMMANDS = new Set(['leases', 'spend', 'engines']);
508
+
509
+ /**
510
+ * ⚠️ The union is DECLARED so the discriminant survives into TypeScript — see
511
+ * the contracts note in `workspace.mjs` for why inference is not enough.
512
+ *
513
+ * @typedef {{ task: string, tasks: string[], parallel: boolean, issue: number | null, json: boolean, concurrency: number, dir: string | null, model: string | null, maxTokens: number, timeoutMs: number, maxRounds: number, allowRun: boolean, shell: boolean, commandTimeoutMs: number, dryRun: boolean, strict: boolean, offline: boolean, since: string | null, help: boolean, version: boolean, budgetUsd: number | null, budgetExplicit: boolean, untilDone: boolean, maxTier: string, lease: string[], holder: string | null, command: string | null }} CliOptions
514
+ * @param {readonly string[]} argv
515
+ * @returns {{ ok: true, options: CliOptions } | { ok: false, error: string }}
516
+ */
517
+ export function parseArgv(argv) {
518
+ const out = {
519
+ task: '',
520
+ tasks: [],
521
+ parallel: false,
522
+ issue: null,
523
+ json: false,
524
+ concurrency: 2,
525
+ dir: null,
526
+ model: null,
527
+ maxTokens: DEFAULT_MAX_TOKENS,
528
+ timeoutMs: DEFAULT_TIMEOUT_MS,
529
+ maxRounds: DEFAULT_MAX_ROUNDS,
530
+ allowRun: true,
531
+ shell: false,
532
+ bestOf: 0,
533
+ maxTier: 'best-of',
534
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
535
+ dryRun: false,
536
+ /**
537
+ * ⭐⭐ `--plan`: THE READ-ONLY-UNTIL-APPROVED GATE THIS CLI DID NOT HAVE.
538
+ *
539
+ * ⚠️ `--dry-run` AND `--no-run` ARE NOT THIS, and the confusion is worth
540
+ * naming here because both were offered as "the safe mode" for months.
541
+ * `--dry-run` prints the writes it would have made — after the model has
542
+ * already decided what they are. `--no-run` withholds the process spawners
543
+ * and leaves writing untouched. Neither one ever shows you the INTENT
544
+ * before the work, and neither one has a place to say no.
545
+ *
546
+ * ⚠️ OFF BY DEFAULT, like every flag that changes the shape of a run. It
547
+ * also costs an extra model call, so defaulting it on would raise the price
548
+ * of every run for a gate most scripted callers cannot even answer.
549
+ */
550
+ plan: false,
551
+ /**
552
+ * ⚠️ OFF BY DEFAULT, ON IN CI. Under `--strict` a run that wrote nothing and
553
+ * ran nothing exits 1 instead of 0. It is opt-in because "what does this
554
+ * file do?" legitimately produces neither, and a check that fails correct
555
+ * work is worse than no check — but in a build script the opposite is true,
556
+ * so `bin/acuvo.mjs` arms it automatically when CI is set.
557
+ */
558
+ strict: false,
559
+ help: false,
560
+ version: false,
561
+ /**
562
+ * ⭐ THE THREE NEW FIELDS, ALL INERT BY DEFAULT. `null` and `[]` and `false`
563
+ * are what a caller who passed nothing gets, and every consumer treats those
564
+ * as "the behaviour you had yesterday" — which is the only way a flag can be
565
+ * added to a tool people already script against.
566
+ */
567
+ /**
568
+ * ── ⭐⭐ ON BY DEFAULT AS OF 2026-08-12, AND THE NOTE ABOVE NO LONGER
569
+ * APPLIES TO THIS ONE ────────────────────────────────────────────
570
+ * It was `null`, which made the one behaviour no competitor offers — a hard
571
+ * cap enforced BEFORE the round rather than an alert after it — reachable
572
+ * only by typing a flag nobody knew existed. See `DEFAULT_BUDGET_USD` in
573
+ * budget.mjs for why $0.02, and why a ceiling is a blast radius rather than
574
+ * a target. `--budget none` restores the unbounded behaviour exactly.
575
+ */
576
+ budgetUsd: DEFAULT_BUDGET_USD,
577
+ /**
578
+ * ⚠️ WHETHER THE USER CHOSE THE NUMBER. Two things depend on it and both
579
+ * would be wrong without it: the stop message (a limit nobody set has to
580
+ * admit where it came from and how to raise it), and `--until-done`, which
581
+ * must keep DEMANDING an explicit ceiling — silently inheriting $0.02 would
582
+ * make the unbounded mode stop almost at once and look broken.
583
+ */
584
+ budgetExplicit: false,
585
+ /**
586
+ * ⚠️ null AND OPT-IN, unlike `budgetUsd`. This ceiling spans every terminal
587
+ * working this workspace today, so a default would change the meaning of a
588
+ * plain `acuvo "…"` for someone running one terminal who never asked for a
589
+ * fleet. `--budget` earned its default by being a per-run blast radius;
590
+ * this one has to be chosen.
591
+ */
592
+ fleetBudgetUsd: null,
593
+ /**
594
+ * ⚠️ ON by default — see bin/acuvo.mjs. It refuses only a PROVEN conflict
595
+ * with another live terminal, so a single-terminal run never meets it, and
596
+ * the people who most need collision protection are exactly the ones who
597
+ * would never have thought to switch it on.
598
+ */
599
+ autoLease: true,
600
+ /**
601
+ * ⭐ The window the fleet ceiling is measured over. `null` = today, which is
602
+ * right for a person and wrong for a schedule — see lib/fleet-budget.mjs.
603
+ */
604
+ budgetWindow: null,
605
+ /**
606
+ * ⭐ `--unattended`: nobody is watching. Declining on budget then exits 3
607
+ * rather than 1, so a cron log can tell "it chose not to run" from "it ran
608
+ * and failed" — two facts that need opposite reactions and had one code.
609
+ */
610
+ unattended: false,
611
+ /**
612
+ * ⭐ `--refute`: after the run claims success, an INDEPENDENT agent with a
613
+ * fresh context is asked to break the claim. Off by default because it is a
614
+ * second paid run; on a $0.001 task that is a rounding error, which is
615
+ * precisely why nobody priced at frontier rates can offer it as a default.
616
+ */
617
+ refute: false,
618
+ /** The run id `acuvo verify <id>` names, or null for the most recent claim. */
619
+ verifyId: null,
620
+ /** `acuvo verify --all` — re-check every recorded claim, deduplicated by command. */
621
+ verifyAll: false,
622
+ /** Positional words after `acuvo board` — e.g. ['add', 'make the suite pass']. */
623
+ boardArgs: [],
624
+ /** Positional words after `acuvo rewind` — the checkpoint id, or nothing to list. */
625
+ rewindArgs: [],
626
+ /**
627
+ * ⭐ CHECKPOINTS ARE ON BY DEFAULT, and that is the same argument
628
+ * `--no-auto-lease` makes: the people who most need an undo are the ones
629
+ * who did not think to ask for one. It costs one read of a file we are
630
+ * about to overwrite anyway, and it creates nothing until a run writes.
631
+ */
632
+ checkpoint: true,
633
+ /**
634
+ * ⚠️ ONLY MEANINGFUL TO `acuvo rewind`. It overrides the single guard that
635
+ * keeps a rewind from destroying your own edits, so it is never implied by
636
+ * anything else and never on by default.
637
+ */
638
+ force: false,
639
+ /**
640
+ * ⭐ `--claim` takes the next open task off the shared board and runs it, so
641
+ * seven terminals can be given one instruction each without a person typing
642
+ * seven prompts. Requires `--holder`, because a claim nobody can attribute
643
+ * tells the other six terminals nothing.
644
+ */
645
+ claim: false,
646
+ /** ?? Only meaningful to `acuvo spend`. null = every record the log still holds. */
647
+ since: null,
648
+ untilDone: false,
649
+ lease: [],
650
+ holder: null,
651
+ command: null,
652
+ /** The creative engine the USER named for this run, per medium. null = the core engine. */
653
+ engine: null,
654
+ };
655
+ const prompts = [];
656
+ // Whether the user typed --max-rounds themselves. `--until-done` raises the
657
+ // backstop only when they did not: an explicit number is an instruction.
658
+ let sawMaxRounds = false;
659
+
660
+ for (let i = 0; i < argv.length; i += 1) {
661
+ const arg = argv[i];
662
+ if (arg === '-h' || arg === '--help') { out.help = true; continue; }
663
+ /**
664
+ * ⚠️ `--version` IS NOT A NICETY once this is installable. It is the first
665
+ * thing anyone types when reporting a bug, and the first thing you ask them
666
+ * for. A CLI on someone else's machine with no way to state its own version
667
+ * makes every bug report start with a guess.
668
+ */
669
+ if (arg === '-v' || arg === '--version') { out.version = true; continue; }
670
+ if (arg === '--parallel') { out.parallel = true; continue; }
671
+ if (arg === '--json') { out.json = true; continue; }
672
+ if (arg === '--issue') {
673
+ const raw = String(argv[++i] ?? '').replace(/^#/, '');
674
+ const n = Number(raw);
675
+ // ⚠️ Refuse a non-number rather than coercing: `--issue main` silently
676
+ // becoming issue 0 would fetch nothing and blame GitHub for it.
677
+ if (!Number.isInteger(n) || n < 1) return { ok: false, error: '--issue needs an issue number, e.g. --issue 42' };
678
+ out.issue = n;
679
+ continue;
680
+ }
681
+ if (arg === '--concurrency') {
682
+ const n = Number(argv[++i]);
683
+ if (!Number.isFinite(n) || n < 1 || n > 4) return { ok: false, error: '--concurrency must be 1-4' };
684
+ out.concurrency = Math.floor(n);
685
+ continue;
686
+ }
687
+ if (arg === '--dry-run') { out.dryRun = true; continue; }
688
+ /**
689
+ * ⚠️ IN THE BOOLEAN BLOCK, NOT THE VALUED ONE. `--no-auto-lease` and
690
+ * `--no-checkpoint` both shipped documented and unparseable by being written
691
+ * into the branch that is only entered for names in `FLAGS_WITH_VALUES`;
692
+ * `test/cli-flags-parse.test.mjs` exists because of it and drives every
693
+ * documented boolean through this parser.
694
+ */
695
+ if (arg === '--plan') { out.plan = true; continue; }
696
+ if (arg === '--strict') { out.strict = true; continue; }
697
+ if (arg === '--offline') { out.offline = true; continue; }
698
+ if (arg === '--no-run') { out.allowRun = false; continue; }
699
+ /**
700
+ * ⚠️⚠️ BOOLEAN FLAGS BELONG HERE, AND BOTH OF THESE WERE PUT IN THE WRONG
701
+ * BLOCK FIRST. They were written into the branch that handles flags TAKING
702
+ * A VALUE, which is only entered for names in `FLAGS_WITH_VALUES` — so
703
+ * `--no-auto-lease` shipped documented in the README and the help text and
704
+ * answered `Unknown option --no-auto-lease`.
705
+ *
706
+ * ⭐ Nothing caught it. The docs guard asserts that every flag the parser
707
+ * mentions has a README row, and both did — it greps the SOURCE for the
708
+ * flag string and never asks the parser to parse one. A flag can be
709
+ * written, documented, and completely unreachable while a test that exists
710
+ * precisely to prevent that reports green. `test/cli-flags-parse.test.mjs`
711
+ * now drives every documented boolean flag through `parseArgv`.
712
+ */
713
+ if (arg === '--no-auto-lease') { out.autoLease = false; continue; }
714
+ // ⚠️ BOOLEANS, SO THEY BELONG IN THIS BLOCK — the comment above records
715
+ // that two flags shipped documented and unparseable by being written into
716
+ // the valued-flag branch instead. `test/cli-flags-parse.test.mjs` drives
717
+ // every documented boolean through the parser for that reason.
718
+ if (arg === '--no-checkpoint') { out.checkpoint = false; continue; }
719
+ if (arg === '--force') { out.force = true; continue; }
720
+ if (arg === '--claim') { out.claim = true; continue; }
721
+ if (arg === '--unattended') { out.unattended = true; continue; }
722
+ if (arg === '--refute') { out.refute = true; continue; }
723
+ if (arg === '--all') { out.verifyAll = true; continue; }
724
+ /**
725
+ * ⚠️⭐ THE ONE FLAG THAT REMOVES A SAFETY PROPERTY RATHER THAN ADDING ONE.
726
+ * Everything else here narrows what the agent may do; this widens it to
727
+ * every program on the machine. It is spelled out in --help, said back in
728
+ * the banner, and recorded on every command it runs — because the operator
729
+ * has to be able to tell, afterwards, which runs had it on.
730
+ */
731
+ if (arg === '--shell') { out.shell = true; continue; }
732
+ /**
733
+ * ⚠️ VALIDATED AGAINST `TIERS`, NOT A TYPED-OUT LIST. The ladder owns those
734
+ * names; restating them here is how a flag ends up accepting a tier the
735
+ * escalator has never heard of — the same defect that shipped this feature
736
+ * disabled twice today.
737
+ */
738
+ if (arg === '--max-tier') {
739
+ const raw = argv[++i];
740
+ if (!ESCALATION_TIERS.includes(raw)) {
741
+ return { ok: false, error: `--max-tier takes one of ${ESCALATION_TIERS.join(', ')} (got ${JSON.stringify(raw ?? null)}). It caps how hard --until-done is allowed to try.` };
742
+ }
743
+ out.maxTier = raw;
744
+ continue;
745
+ }
746
+ if (arg === '--best-of') {
747
+ const raw = argv[++i];
748
+ const n = Number(raw);
749
+ if (!Number.isInteger(n) || n < 2 || n > 5) {
750
+ return { ok: false, error: '--best-of takes a whole number from 2 to 5 (got ' + JSON.stringify(raw ?? null) + '). Below 2 it is just a run; above 5 you are paying for attempts that rarely change the answer.' };
751
+ }
752
+ out.bestOf = n;
753
+ continue;
754
+ }
755
+ if (arg === '--until-done') { out.untilDone = true; continue; }
756
+ if (FLAGS_WITH_VALUES.has(arg)) {
757
+ const value = argv[i + 1];
758
+ if (value === undefined || value.startsWith('--')) {
759
+ return { ok: false, error: `${arg} needs a value.` };
760
+ }
761
+ i += 1;
762
+ if (arg === '--dir') out.dir = value;
763
+ if (arg === '--model') {
764
+ /**
765
+ * ⭐ OUR NAMES, RESOLVED HERE. `--model acuvo-pro` is the shape a
766
+ * user should be typing; a raw vendor id still passes through so no
767
+ * existing script breaks. Resolved at PARSE time so a typo costs a
768
+ * message rather than a round trip to a provider that answers "no
769
+ * endpoints found" — and so an internal model (the reviewer) is
770
+ * refused with the reason instead of silently becoming the builder.
771
+ */
772
+ const picked = resolveModelName(value);
773
+ if (!picked.ok) return { ok: false, error: picked.error };
774
+ out.model = picked.id;
775
+ }
776
+ /**
777
+ * ── ⭐ `--engine` — THE CREATIVE ENGINE FOR THIS RUN, TYPED BY A HUMAN ──
778
+ *
779
+ * Roman, 2026-08-16: *"as long as users have the choice to switch between
780
+ * premium and basic for video and image then we should be good"* — with
781
+ * the constraint that a higher payer *"might not always want"* the dearer
782
+ * one. So the choice is a thing you SAY, once, and it applies to the
783
+ * medium it names; it never applies to a medium it does not.
784
+ *
785
+ * ⭐ VALIDATED AT PARSE TIME, exactly like `--model` two lines up and for
786
+ * the same reason: a typo costs a message instead of a round trip and a
787
+ * confused refusal from a render that had already started.
788
+ *
789
+ * ⚠️ THIS IS THE ONLY SANCTIONED WAY TO REACH AN ULTRA ENGINE FROM THE
790
+ * COMMAND LINE, and that is the point rather than a limitation. A flag a
791
+ * person typed is consent; software choosing the expensive engine is not.
792
+ */
793
+ /**
794
+ * ⚠️ AND IT ONLY *VALIDATES* HERE — `setRunEngine` is called by
795
+ * `bin/acuvo.mjs`, not by the parser. A parser with a side effect on
796
+ * module state is one that cannot be called twice in a test file without
797
+ * the second call inheriting the first one's choice, which is how a test
798
+ * passes for the wrong reason.
799
+ */
800
+ if (arg === '--engine') {
801
+ const picked = engineById(String(value).trim().toLowerCase());
802
+ if (!picked) {
803
+ return {
804
+ ok: false,
805
+ error: `--engine takes an Acuvo engine id (got ${JSON.stringify(value)}). `
806
+ + `Choices: ${CREATIVE_ENGINES.map((e) => e.id).join(', ')}. Run \`acuvo engines\` to see what each one costs.`,
807
+ };
808
+ }
809
+ out.engine = picked.id;
810
+ }
811
+ if (arg === '--max-tokens') {
812
+ const n = Number(value);
813
+ // ⚠️ A non-numeric --max-tokens must not become NaN and travel to the
814
+ // API as `"max_tokens": null`, which reads as "no ceiling" — the one
815
+ // parse failure here that costs money rather than producing an error.
816
+ if (!Number.isInteger(n) || n < 256 || n > 64_000) {
817
+ return { ok: false, error: `--max-tokens must be a whole number between 256 and 64000 (got ${JSON.stringify(value)}).` };
818
+ }
819
+ out.maxTokens = n;
820
+ }
821
+ if (arg === '--timeout') {
822
+ const n = Number(value);
823
+ if (!Number.isFinite(n) || n < 5 || n > 900) {
824
+ return { ok: false, error: `--timeout must be between 5 and 900 seconds (got ${JSON.stringify(value)}).` };
825
+ }
826
+ out.timeoutMs = Math.round(n * 1000);
827
+ }
828
+ /**
829
+ * ⚠️ THE VALUE IS PARSED BY `budget.mjs`, AND ITS REFUSAL IS RETURNED
830
+ * VERBATIM. Re-wording it here would give the same mistake two different
831
+ * explanations depending on which layer noticed it.
832
+ */
833
+ if (arg === '--since') { out.since = value; continue; }
834
+ if (arg === '--budget') {
835
+ /**
836
+ * ⭐ `--budget none` IS THE WAY BACK OUT, and it exists because the
837
+ * ceiling is now on by default. A default you cannot turn off is not a
838
+ * default, it is a policy — and the stop message promises this escape
839
+ * hatch by name, so it has to be real.
840
+ */
841
+ if (/^(none|off|unlimited)$/i.test(String(value ?? '').trim())) {
842
+ out.budgetUsd = null;
843
+ out.budgetExplicit = true;
844
+ } else {
845
+ const parsed = parseBudgetUsd(value);
846
+ if (!parsed.ok) return { ok: false, error: parsed.message };
847
+ out.budgetUsd = parsed.usd;
848
+ out.budgetExplicit = true;
849
+ }
850
+ }
851
+ /**
852
+ * ── ⭐ `--fleet-budget` — THE CEILING ACROSS EVERY TERMINAL, NOT THIS ONE ──
853
+ *
854
+ * `--budget` caps a run. Seven terminals on one repository is the thing
855
+ * this tool exists to make possible, and seven runs multiply that cap by
856
+ * seven — so the number a person chose quietly becomes a different number
857
+ * the moment they do what the product tells them to do.
858
+ *
859
+ * This is the spend cap for this WORKSPACE, for today, summed across
860
+ * every terminal from the audit log they all already write to.
861
+ *
862
+ * ⚠️ OPT-IN, unlike `--budget`. A fleet is something you assemble
863
+ * deliberately, and a default here would fire for people running one
864
+ * terminal who never asked — the same trap that made the per-run default
865
+ * silently disable `--parallel` before `budgetExplicit` existed.
866
+ *
867
+ * ⚠️ `none` is accepted for symmetry with `--budget`, and means the same
868
+ * thing: no fleet ceiling. Parsing is `budget.mjs`'s, so `25c` and `$2`
869
+ * work here exactly as they do there and a bad value gets ONE wording.
870
+ */
871
+ if (arg === '--budget-window') {
872
+ /**
873
+ * ⚠️ PARSED BY `spend.mjs`, whose `parseSince` already understands
874
+ * `7d`, `24h` and a date — one reading of a period, so `--since` and
875
+ * this cannot disagree about what `7d` means.
876
+ */
877
+ const parsed = parseSince(value);
878
+ if (parsed?.error) return { ok: false, error: parsed.error };
879
+ out.budgetWindow = parsed instanceof Date ? parsed : (parsed?.since ?? null);
880
+ if (!out.budgetWindow) return { ok: false, error: `--budget-window did not understand ${JSON.stringify(value)}. Try 7d, 24h, or a date like 2026-08-01.` };
881
+ continue;
882
+ }
883
+ if (arg === '--fleet-budget') {
884
+ if (/^(none|off|unlimited)$/i.test(String(value ?? '').trim())) {
885
+ out.fleetBudgetUsd = null;
886
+ } else {
887
+ const parsed = parseBudgetUsd(value);
888
+ if (!parsed.ok) return { ok: false, error: parsed.message };
889
+ out.fleetBudgetUsd = parsed.usd;
890
+ }
891
+ continue;
892
+ }
893
+ /**
894
+ * ⭐ REPEATABLE, NOT LAST-WINS. `--lease a.ts --lease b.ts` means both
895
+ * files; a last-wins flag would silently take one lease and leave the
896
+ * other file unprotected, which is worse than not having the feature —
897
+ * the user would believe they were covered.
898
+ */
899
+ if (arg === '--lease') {
900
+ const path = value.trim();
901
+ if (!path) return { ok: false, error: '--lease needs a path inside the workspace, e.g. --lease src/app.ts' };
902
+ out.lease.push(path);
903
+ }
904
+ if (arg === '--holder') {
905
+ const name = value.trim();
906
+ if (!name) return { ok: false, error: '--holder needs a name, e.g. --holder terminal-3' };
907
+ out.holder = name;
908
+ }
909
+ if (arg === '--max-rounds') {
910
+ sawMaxRounds = true;
911
+ const n = Number(value);
912
+ // ⚠️ Same reasoning as --max-tokens, and with sharper teeth: a round IS
913
+ // a paid completion, so a NaN reaching the loop as a comparison bound
914
+ // would make `round <= NaN` false and silently produce a zero-round run,
915
+ // or — with the comparison written the other way — an unbounded one.
916
+ if (!Number.isInteger(n) || n < 1 || n > MAX_ROUNDS_LIMIT) {
917
+ return { ok: false, error: `--max-rounds must be a whole number between 1 and ${MAX_ROUNDS_LIMIT} (got ${JSON.stringify(value)}). Each round is a paid completion.` };
918
+ }
919
+ out.maxRounds = n;
920
+ }
921
+ if (arg === '--command-timeout') {
922
+ const n = Number(value);
923
+ const maxSeconds = MAX_COMMAND_TIMEOUT_MS / 1000;
924
+ if (!Number.isFinite(n) || n < 1 || n > maxSeconds) {
925
+ return { ok: false, error: `--command-timeout must be between 1 and ${maxSeconds} seconds (got ${JSON.stringify(value)}).` };
926
+ }
927
+ out.commandTimeoutMs = Math.round(n * 1000);
928
+ }
929
+ continue;
930
+ }
931
+ if (arg.startsWith('--')) return { ok: false, error: `Unknown option ${arg}. Run with --help.` };
932
+ prompts.push(arg);
933
+ }
934
+
935
+ // Joined rather than "first wins": an unquoted prompt arrives as many argv
936
+ // entries, and silently using only the first word is the worst possible
937
+ // reading of what the user meant.
938
+ /**
939
+ * ── ⭐ `--parallel` KEEPS THE PROMPTS SEPARATE ────────────────────────────
940
+ * Without it, several quoted arguments are ONE task (an unquoted prompt
941
+ * arrives as many argv entries, and taking only the first word is the worst
942
+ * possible reading). With it, each quoted argument is its own task.
943
+ *
944
+ * ⚠️ SEQUENTIAL REMAINS THE DEFAULT AND MUST. Two agents in one workspace can
945
+ * overwrite each other, so the safe behaviour has to be what happens when you
946
+ * do not think about it. Parallelism is a thing you ask for.
947
+ */
948
+ if (out.parallel) {
949
+ out.tasks = prompts.map((p) => p.trim()).filter(Boolean);
950
+ if (out.tasks.length < 2) {
951
+ return { ok: false, error: '--parallel needs at least two quoted tasks, e.g. acuvo --parallel "add tests" "write the README"' };
952
+ }
953
+ }
954
+ /**
955
+ * ⭐ THE COMMAND WORD, CLAIMED BEFORE THE PROMPT IS ASSEMBLED. Exactly one
956
+ * positional, and it must be the one the user typed first — see COMMANDS.
957
+ */
958
+ if (prompts.length === 1 && COMMANDS.has(prompts[0]) && argv[0] === prompts[0]) {
959
+ out.command = prompts[0];
960
+ prompts.length = 0;
961
+ }
962
+ /**
963
+ * ── ⭐ `board` IS THE ONE COMMAND THAT TAKES ARGUMENTS ──────────────────────
964
+ *
965
+ * `leases` and `spend` are bare words, so the rule above ("exactly one
966
+ * positional, and it must be first") fits them exactly. `acuvo board add
967
+ * "make the suite pass"` is three positionals, so it needs its own clause
968
+ * rather than a loosening of that one — widening the shared rule to allow
969
+ * trailing words would let `acuvo leases are broken` become a command instead
970
+ * of the instruction it obviously is.
971
+ *
972
+ * ⚠️ STILL ANCHORED ON `argv[0]`. "board" is an ordinary English word and
973
+ * `acuvo "the board is rendering wrong"` must remain a task.
974
+ */
975
+ /**
976
+ * ⚠️ A RUN ID LOOKS LIKE ONE, OR THIS IS A TASK. `acuvo verify` and
977
+ * `acuvo verify 2026-08-13T12:34:25.408Z-479f8318` are the command;
978
+ * `acuvo verify the invoice bug is fixed` is an instruction, and reading its
979
+ * second word as a run id would answer a question nobody asked — the exact
980
+ * trap the bare-word rule above exists to avoid.
981
+ *
982
+ * Ids are ISO-dated, so "starts with a four-digit year" separates them from
983
+ * English without a list of words to maintain.
984
+ */
985
+ const looksLikeRunId = (w) => typeof w === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(w);
986
+ if (argv[0] === 'verify' && prompts[0] === 'verify'
987
+ && (prompts.length === 1 || (prompts.length === 2 && looksLikeRunId(prompts[1])))) {
988
+ out.command = 'verify';
989
+ out.verifyId = prompts[1] ?? null;
990
+ prompts.length = 0;
991
+ }
992
+ if (argv[0] === 'board' && prompts[0] === 'board') {
993
+ out.command = 'board';
994
+ out.boardArgs = prompts.slice(1);
995
+ prompts.length = 0;
996
+ }
997
+ /**
998
+ * ── ⭐ `acuvo rewind [id]` — THE FIRST POSITIONAL, *NOT* `argv[0]` ──────────
999
+ *
1000
+ * ⚠️⚠️ MEASURED 2026-08-14, AND IT COST REAL MONEY. Written first with the
1001
+ * `argv[0] === 'rewind'` anchor the other commands use, `acuvo --dir <ws>
1002
+ * rewind` did not dispatch at all: argv[0] was `--dir`, so the word fell
1003
+ * through as a TASK and a paid agent session spent $0.0030 reading the
1004
+ * workspace and doing nothing. Twice. A command that silently becomes a paid
1005
+ * run when you put a flag before it is worse than one that refuses.
1006
+ *
1007
+ * ⭐ `prompts[0]` IS ALREADY THE SAFE TEST. Anything consumed as a flag's
1008
+ * VALUE never reaches `prompts` — `--model rewind` is a model id and is gone
1009
+ * by here — so the first positional being exactly `rewind` means the user
1010
+ * typed it as a word of their own.
1011
+ *
1012
+ * ⚠️ THE ENGLISH-WORD RISK IS STILL HANDLED, just differently: an actual
1013
+ * instruction is quoted, so `acuvo "rewind the migration to the previous
1014
+ * schema"` is ONE positional that is not equal to `rewind` and stays a task.
1015
+ * What is claimed here is the bare word, which as a task is not an
1016
+ * instruction anybody means — that is exactly what the two wasted runs above
1017
+ * proved.
1018
+ *
1019
+ * ⚠️ AND AT MOST ONE ARGUMENT. `acuvo rewind the last thing you did` reads its
1020
+ * second word as a checkpoint id and is refused by name, which costs nothing;
1021
+ * treating it as a task would have restored nothing and charged for it.
1022
+ *
1023
+ * ⚠️ `board`, `verify`, `leases` and `spend` STILL USE THE `argv[0]` ANCHOR
1024
+ * and so `acuvo --dir <path> board` is still a paid task run. That is a real
1025
+ * defect in a file another lane is editing today; it is reported rather than
1026
+ * fixed here.
1027
+ */
1028
+ if (prompts[0] === 'rewind' && prompts.length <= 2) {
1029
+ out.command = 'rewind';
1030
+ out.rewindArgs = prompts.slice(1);
1031
+ prompts.length = 0;
1032
+ }
1033
+
1034
+ /**
1035
+ * ── ⚠️⚠️ THE ONE REFUSAL THIS PACKAGE IS NOT ALLOWED TO SOFTEN ────────────
1036
+ *
1037
+ * An unbounded loop against a paid endpoint, running unattended, on someone
1038
+ * else's money, is the single most dangerous thing here. `--until-done` is
1039
+ * exactly that loop, so it may not exist without a number the user typed
1040
+ * saying what it is allowed to cost.
1041
+ *
1042
+ * ⚠️ AND IT IS REFUSED, NOT DEFAULTED. Picking a default ceiling for someone
1043
+ * would be this tool deciding how much of their money it may spend while they
1044
+ * are asleep. The error names the flag and shows the whole invocation, because
1045
+ * a refusal that does not say what to type instead is just an obstacle.
1046
+ */
1047
+ if (out.untilDone) {
1048
+ /**
1049
+ * ⚠️ EXPLICIT, NOT MERELY PRESENT. Since 2026-08-12 `budgetUsd` carries a
1050
+ * $0.02 default, so `=== null` would no longer catch anything and this
1051
+ * refusal would quietly die — handing the unbounded mode a ceiling that
1052
+ * stops it almost immediately, which reads as a broken feature rather than
1053
+ * a missing flag. The question was always "did a human choose a number for
1054
+ * this run", and now it has to be asked that way.
1055
+ */
1056
+ if (!out.budgetExplicit) {
1057
+ return {
1058
+ ok: false,
1059
+ error: '--until-done keeps working until the job is done, so it MUST be given a spending ceiling.\n'
1060
+ + 'Add --budget: acuvo --until-done --budget 0.50 "<task>"\n'
1061
+ + 'A typical task costs well under a cent, so 0.50 is a large allowance. An unbounded\n'
1062
+ + 'loop against a paid API is the one thing this CLI will not do.',
1063
+ };
1064
+ }
1065
+ // The counter stops being the wall; money becomes it. An explicit
1066
+ // --max-rounds is still honoured, because the user said a number.
1067
+ if (!sawMaxRounds) out.maxRounds = UNTIL_DONE_MAX_ROUNDS;
1068
+ }
1069
+
1070
+ /**
1071
+ * ⚠️ A BUDGET IS PER CONVERSATION, AND `--parallel` RUNS SEVERAL. Accepting
1072
+ * the pair would silently multiply the ceiling by the number of tasks — the
1073
+ * user types 0.50, three tasks run, $1.50 is spent, and every layer involved
1074
+ * was individually honest. Dividing it instead would be worse: each share
1075
+ * could fall under the one-round floor and every task would refuse to start
1076
+ * for a reason nobody typed. So this is refused, and the message states the
1077
+ * real total so the number is never a surprise.
1078
+ */
1079
+ /**
1080
+ * ⚠️⚠️ `budgetExplicit`, NOT `budgetUsd !== null` — AND THE SUITE CAUGHT ME.
1081
+ *
1082
+ * This refusal is right when a human typed a number: they said "$0.20" and
1083
+ * `--parallel 2` would quietly permit $0.40, so surprising them is worse than
1084
+ * refusing. It became WRONG the moment a default ceiling existed, because it
1085
+ * then fired for everyone — `--parallel` was refused outright for a budget
1086
+ * nobody had asked for. A feature silently disabled by someone else's default
1087
+ * is the exact "don't break what works" failure this package keeps paying for.
1088
+ *
1089
+ * ⭐ And the default has no surprise to prevent: it is a per-run blast radius
1090
+ * by construction, so N conversations getting N × $0.02 is what it means
1091
+ * rather than a number anyone was misled about.
1092
+ */
1093
+ if (out.budgetExplicit && out.budgetUsd !== null && out.parallel) {
1094
+ const total = formatUsd(out.budgetUsd * Math.max(1, out.tasks.length));
1095
+ return {
1096
+ ok: false,
1097
+ error: `--budget is a ceiling for ONE conversation and --parallel starts ${out.tasks.length}, so together they would allow ${total}, not ${formatUsd(out.budgetUsd)}.\n`
1098
+ + 'Run the tasks one at a time with their own budgets, or drop --budget.',
1099
+ };
1100
+ }
1101
+
1102
+ out.task = prompts.join(' ').trim();
1103
+ /**
1104
+ * ── ⭐ NO PROMPT IS NOW A VALID INVOCATION ────────────────────────────────
1105
+ * It used to be a usage error. `acuvo` with no argument opens an INTERACTIVE
1106
+ * session — the shape every coding agent people already know uses, and the one
1107
+ * that makes the prompt cache pay: an unchanged prefix caches at 97.2%, so the
1108
+ * second instruction in a conversation costs a fraction of the first.
1109
+ *
1110
+ * ⚠️ A bare `acuvo` must NOT be treated as an empty task and sent to the
1111
+ * model. An empty prompt is a paid round-trip that can only produce a
1112
+ * confused reply, which is why `task` stays empty here and the entry point
1113
+ * branches on it rather than defaulting it to something.
1114
+ */
1115
+ return { ok: true, options: out };
1116
+ }
1117
+
1118
+ /* ══════════════════════════════════════════════════════════════════════════
1119
+ * ── ⚠️⚠️ THE SECOND LOCK ON `--plan`, AND THE ONE THAT WAS MISSING ─────────
1120
+ *
1121
+ * `--plan`'s headline promise is printed thirty lines above, in `USAGE`:
1122
+ * *read-only until you approve*. When the gate shipped it had exactly ONE
1123
+ * lock — `planModeToolNames` narrows the OFFER to `ORIENT_TOOLS`, so the model
1124
+ * is never SHOWN `write_file`.
1125
+ *
1126
+ * ⚠️ THAT IS A BELT WITH NO BRACES, AND THIS PACKAGE ALREADY SAID SO — in
1127
+ * `tools.mjs`, at `run_program`'s own dispatcher guard:
1128
+ *
1129
+ * "a model can emit a call for a tool it was never shown (a stale
1130
+ * conversation, a resumed session, a provider that echoes an old tool
1131
+ * list), and the flag has to hold at the point the process would actually
1132
+ * start."
1133
+ *
1134
+ * The RUN half of `--plan` obeyed that rule: the proposal phase passes
1135
+ * `allowRun: false`, and `executeToolCall` refuses the nine process-starting
1136
+ * verbs at the dispatcher whatever the offer said. The WRITE half had nothing.
1137
+ * `executeToolCall` is a `switch` on the tool NAME, and `case 'write_file'`
1138
+ * calls `executor.writeFile` with no gate between them — `allowRun` is not
1139
+ * consulted, correctly, because a write starts no process.
1140
+ *
1141
+ * ── ⭐ MEASURED, NOT ARGUED (2026-08-20) ────────────────────────────────────
1142
+ *
1143
+ * Driven through the REAL `runSession` with the exact options `bin/acuvo.mjs`
1144
+ * passes to the proposal phase — `toolNames` = the 13-name read-only
1145
+ * intersection, `allowRun: false`, `maxRounds: 5` — against a real
1146
+ * `createLocalExecutor` on a real temp directory, with a scripted model that
1147
+ * emitted three names it had never been offered:
1148
+ *
1149
+ * offer size: 13 | write_file offered? false
1150
+ * write_file → ok=true mutated=true pwned.txt on disk: "the proposal phase wrote this"
1151
+ * edit_file → ok=true mutated=true app.js: "const a = 1;" → "const a = 2;"
1152
+ * delete_file → ok=true mutated=true doomed.js gone
1153
+ *
1154
+ * Three files changed during the phase whose entire promise is that nothing
1155
+ * changes. The headline safety property was refuted with one fixture.
1156
+ *
1157
+ * ── ⭐⭐ WHY THE FIX IS THE EXECUTOR AND NOT A LIST OF TOOL NAMES ───────────
1158
+ *
1159
+ * A deny-list keyed on tool NAMES goes stale the day a tool is added — this
1160
+ * repo has paid for that five times over (`turn.mjs`: "a guard keyed on the
1161
+ * NAME OF A TOOL goes stale every time a tool is added — this is the fifth").
1162
+ * The executor is the opposite: every mutating verb in the dispatcher, present
1163
+ * or future, reaches disk through exactly three methods —
1164
+ *
1165
+ * write_file, write_files, edit_file → executor.writeFile
1166
+ * delete_file → executor.deleteFile
1167
+ * move_file → executor.moveFile
1168
+ *
1169
+ * — and `case 'delegate'` hands this same object to `runSubagent`, so a helper
1170
+ * spawned during a proposal inherits the refusal without a second rule being
1171
+ * written anywhere. Close those three and the phase is read-only by
1172
+ * construction rather than by the model's cooperation.
1173
+ *
1174
+ * ── ⚠️ WHY IT LIVES IN THE ARGUMENT PARSER ─────────────────────────────────
1175
+ *
1176
+ * Because this is the file that MAKES the promise. `USAGE` is where a user
1177
+ * reads "it cannot write while it is proposing", and a promise whose
1178
+ * enforcement lives three modules away is a promise that drifts — which is
1179
+ * precisely what happened: the sentence shipped and the lock did not. It
1180
+ * imports nothing and touches no disk, so this module stays what its header
1181
+ * says it is: pure, and testable without spawning a process.
1182
+ * ══════════════════════════════════════════════════════════════════════════ */
1183
+
1184
+ /**
1185
+ * ⚠️ ONE SENTENCE, USED BY ALL THREE METHODS. The model reads this as a tool
1186
+ * result and decides what to do next; three different wordings for one rule
1187
+ * teaches it that some writes might work if it rephrases, which is the exact
1188
+ * "talk your way past the gate" behaviour the phase exists to prevent.
1189
+ *
1190
+ * ⭐ AND IT NAMES THE WAY THROUGH, which is a standing rule here: a refusal
1191
+ * that only says no makes the model spend the rest of the phase retrying. This
1192
+ * one says what unblocks it — a human approving the plan — so the correct next
1193
+ * move is to finish writing the plan.
1194
+ */
1195
+ export const PLAN_PHASE_REFUSAL =
1196
+ 'this is PLAN MODE — nothing may be written, deleted or moved until a human has read your plan and approved it. '
1197
+ + 'Do not retry: describe this change as a numbered step in the plan instead, naming the file, and it will be '
1198
+ + 'carried out for real once the plan is approved.';
1199
+
1200
+ /** The shape every refused method returns — the dispatcher's ordinary failure. */
1201
+ const refusePlanPhaseWrite = () => ({ ok: false, error: PLAN_PHASE_REFUSAL });
1202
+
1203
+ /**
1204
+ * Wrap an executor so the `--plan` proposal phase cannot change anything.
1205
+ *
1206
+ * Reads (`readFile`, `listDir`), the workspace `root` and the lease `holder`
1207
+ * pass straight through — the phase exists in order to LOOK, and a guard that
1208
+ * broke reading would make `--plan` useless rather than safe.
1209
+ *
1210
+ * @param {{ root: string, dryRun?: boolean, holder?: string|null, readFile: Function, listDir: Function, writeFile?: Function, deleteFile?: Function, moveFile?: Function }} executor
1211
+ * @returns {object} a NEW executor; the one passed in is untouched
1212
+ */
1213
+ export function planPhaseExecutor(executor) {
1214
+ if (!executor || typeof executor !== 'object') {
1215
+ throw new TypeError('planPhaseExecutor needs the executor the run was built with');
1216
+ }
1217
+ return {
1218
+ /**
1219
+ * ⚠️ SPREAD FIRST, OVERRIDE AFTER. An executor is not a fixed interface —
1220
+ * the browser builder implements the same verbs over a Map and may carry
1221
+ * fields this file has never heard of. Listing the survivors by hand would
1222
+ * silently drop them; spreading and then closing the three doors that
1223
+ * matter keeps the wrapper correct for an executor written next year.
1224
+ *
1225
+ * ⚠️ AND IT IS A NEW OBJECT, NEVER A MUTATION. The APPROVED run uses the
1226
+ * original executor and must be able to write the moment the human says
1227
+ * yes. Monkey-patching the live executor and restoring it afterwards would
1228
+ * put the whole guarantee on a `finally` block surviving an exception.
1229
+ */
1230
+ ...executor,
1231
+ /**
1232
+ * ⚠️ `dryRun` IS NOT DECORATION. Six verbs (`speak`, `transcribe`,
1233
+ * `make_document`, `see_page`, `edit_image`, `expand_image`) are handed
1234
+ * `executor.root` and `executor.dryRun` and write through `fs` directly
1235
+ * rather than through `executor.writeFile`, so the three overrides below
1236
+ * cannot see them. They all honour `dryRun`, so this is what stops a
1237
+ * name-guessed `speak` leaving a .wav in a workspace nobody has approved a
1238
+ * change to. It also makes `loadPlanQuietly` (turn.mjs) skip reading
1239
+ * `.acuvo/plan.json`, which is what the proposal phase wants anyway: there
1240
+ * is by construction no plan for this task yet, so the only one it could
1241
+ * load is somebody else's.
1242
+ *
1243
+ * ⚠️⚠️ ONE VERB IS STILL OUTSIDE THIS FUNCTION'S REACH, and it is named
1244
+ * here rather than left for someone to find: `generate_image` resolves
1245
+ * against `executor.root` and calls `writeFileSync` without consulting
1246
+ * `dryRun` at all (`lib/imagegen.mjs`). It is not offered during a
1247
+ * proposal and `allowRun` does not cover it (it starts no process), so
1248
+ * closing it needs a guard inside `executeToolCall` itself — `tools.mjs`.
1249
+ */
1250
+ dryRun: true,
1251
+ writeFile: refusePlanPhaseWrite,
1252
+ deleteFile: refusePlanPhaseWrite,
1253
+ moveFile: refusePlanPhaseWrite,
1254
+ };
1255
+ }