acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/bin/acuvo.mjs ADDED
@@ -0,0 +1,3108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ACUVO CODE — the terminal client.
4
+ *
5
+ * `node acuvo-code/bin/acuvo.mjs "add a healthcheck route"` in any directory.
6
+ *
7
+ * ── WHAT THIS FILE IS AND IS NOT ────────────────────────────────────────────
8
+ * It is the CLIENT: argv in, exit code out, everything in between delegated.
9
+ * The capability (the tools), the executor (the filesystem), the transport (the
10
+ * model) and the report (the summary) each live in their own module, because
11
+ * the second client — the web console — already exists and the whole
12
+ * architecture is "one registry, two clients". A CLI that grew its own copy of
13
+ * any of those would be the fork this was built to avoid.
14
+ *
15
+ * ⚠️ EXIT CODES ARE PART OF THE CONTRACT. A coding agent gets piped, chained and
16
+ * put in a Makefile; `&&` has to mean something. 0 succeeded · 1 the model or
17
+ * the tools failed · 2 not configured · 64 bad usage (the sysexits convention).
18
+ *
19
+ * ⚠️ AND SINCE THE LOOP LANDED, 1 ALSO MEANS "THE CODE IT WROTE STILL DOES NOT
20
+ * PASS". That is the point of running anything: an agent that writes a failing
21
+ * test suite and exits 0 has told the shell it succeeded, and `acuvo … && git
22
+ * push` would believe it. See `sessionFailed`.
23
+ */
24
+
25
+ import { resolve, join } from 'node:path';
26
+ import { existsSync, statSync, readFileSync } from 'node:fs';
27
+ import { createInterface } from 'node:readline';
28
+ import { randomUUID } from 'node:crypto';
29
+
30
+ /**
31
+ * ⚠️ `planPhaseExecutor` IS THE SECOND LOCK ON `--plan`, and it comes from the
32
+ * module that PRINTS the promise (`USAGE`) so the sentence and its enforcement
33
+ * cannot drift apart — which is exactly what had happened. Measured 2026-08-20
34
+ * through the real `runSession` with this file's own plan-phase options: a
35
+ * `write_file` the model was never offered wrote a file to disk, an `edit_file`
36
+ * changed a source file and a `delete_file` removed one, all during a phase
37
+ * nobody had approved. See the block at the foot of lib/cli-args.mjs.
38
+ */
39
+ import { parseArgv, USAGE, planPhaseExecutor } from '../lib/cli-args.mjs';
40
+ import { runChat } from '../lib/chat.mjs';
41
+ // ⭐ The providers behind `/skills` and `/mcp`. Both modules already existed and
42
+ // already worked; nothing at the interactive prompt could reach either.
43
+ /**
44
+ * ⚠️⚠️ `discoverAllSkills`/`loadAnySkill`, NOT the project-only pair. Measured:
45
+ * `/skills` reported **0** while the model saw **24**, and `/skills
46
+ * nextjs-app-router` answered *"this project defines no skills … there is
47
+ * nothing to read until someone writes one"* — a sentence that is simply false
48
+ * about a CLI that ships 24 of them. Same product, two answers, and the wrong
49
+ * one was the answer a human reads.
50
+ */
51
+ import { discoverAllSkills, loadAnySkill } from '../lib/builtin-skills.mjs';
52
+ import { routingNote } from '../lib/warm-provider.mjs';
53
+ import { readMcpConfig } from '../lib/mcp.mjs';
54
+ import { readModelConfig, MISSING_KEY_MESSAGE } from '../lib/model.mjs';
55
+ import { executeRunCommand } from '../lib/command.mjs';
56
+ import { createLocalExecutor } from '../lib/workspace.mjs';
57
+ import { runSession, formatSummary, renderEvent, sessionFailed } from '../lib/turn.mjs';
58
+ import { runPool, detectConflicts, formatParallelSummary, shortLabel } from '../lib/parallel.mjs';
59
+ import { detectRepo, findToken, fetchIssue, branchNameFor, issueToTask, createBranch, nextSteps } from '../lib/github.mjs';
60
+ // ⚠️ `formatChanges` is deliberately NOT imported: rendering the change list is
61
+ // `formatSummary`'s job, and importing it here is how the second copy came back.
62
+ import { describeChanges, shortenRoot, toJson } from '../lib/report.mjs';
63
+ import { renderImage } from '../lib/terminal-graphics.mjs';
64
+ import { saveSession, listSessions, resumeMessages, loadSession } from '../lib/session.mjs';
65
+ import { recordRun, parseAuditLog } from '../lib/audit.mjs';
66
+ import { runBestOf, formatBestOf } from '../lib/best-of.mjs';
67
+ import { escalate, formatEscalation, outOfRoad } from '../lib/escalate.mjs';
68
+ import { homedir } from 'node:os';
69
+ import { loadEnvFiles as envLoad } from '../lib/env-file.mjs';
70
+ import {
71
+ loadPolicy, invocationDecision, roundBudget, costBudget, filterToolNames, mcpDecision,
72
+ USER_POLICY_FILE, USER_POLICY_ENV, WORKSPACE_POLICY_FILE,
73
+ } from '../lib/policy.mjs';
74
+ /**
75
+ * ⚠️ `bestOfAttemptBudget` LIVES IN lib/, NOT HERE, AND THAT IS NOT TIDINESS.
76
+ * Importing this file EXECUTES the CLI — it has a top-level main that then waits
77
+ * on stdin — so a test that imports a decision function declared here HANGS
78
+ * FOREVER rather than failing. Measured: the first version of this change put it
79
+ * in this file and the test never returned. Pure decisions belong where they can
80
+ * be tested; that is what lib/ is for.
81
+ */
82
+ import { createBudget, remainingForTurn, DEFAULT_BUDGET_USD, bestOfAttemptBudget } from '../lib/budget.mjs';
83
+ import { createFleetGate } from '../lib/fleet-budget.mjs';
84
+ import { FLEET_STOP_REASONS } from '../lib/budget.mjs';
85
+ import { refuteClaim, formatRefutation, refutationField } from '../lib/refute.mjs';
86
+ import { createAsker } from '../lib/prompt.mjs';
87
+ /**
88
+ * ── ⭐⭐ THE PLAN GATE, AND THE TWO VERDICTS THAT WERE COMPUTED AND NEVER SHOWN
89
+ *
90
+ * `lib/plan-coherence.mjs` is wired into `lib/turn.mjs`, and that wiring reaches
91
+ * the MODEL: the drift nudge is appended to the conversation and the
92
+ * reconciliation is put in the result object. Measured 2026-08-20, it reached
93
+ * nobody else — `renderEvent` has no case for the `plan-drift` event turn.mjs
94
+ * emits, and `formatReconciliation` is imported by turn.mjs on line 64 and
95
+ * called from nowhere. Two correct verdicts, invisible to the person paying.
96
+ *
97
+ * ⚠️ `toolNamesForRounds` IS IMPORTED HERE FOR ONE REASON ONLY: `--plan`'s
98
+ * read-only offer is an INTERSECTION with what this machine actually offers,
99
+ * never a fixed list. A hard-coded read list would offer `read_skill` in a
100
+ * project with no skills and the four LSP verbs on a machine with no language
101
+ * server — the dead buttons tools.mjs spends four hundred lines refusing.
102
+ */
103
+ import { toolNamesForRounds } from '../lib/tools.mjs';
104
+ import {
105
+ runPlanGate, planModeToolNames, planModeRounds, planPhaseTask,
106
+ driftBannerLine, formatReconciliation,
107
+ } from '../lib/plan-coherence.mjs';
108
+ import { summariseSpend, readAuditFiles, formatSpend, parseSince } from '../lib/spend.mjs';
109
+ import { PLANS, formatPlan, allowanceRemaining, usageByModel } from '../lib/plan.mjs';
110
+ import { labelForModelId } from '../lib/acuvo-models.mjs';
111
+ /**
112
+ * ⭐ CREATIVE ENGINE CHOICE. `listEngines` asks the gateway what this ACCOUNT
113
+ * may reach and what each engine costs — this package holds no prices, on
114
+ * purpose. `setRunEngine` records the engine the user named on the command
115
+ * line so a render verb can read it later.
116
+ */
117
+ import { listEngines, setRunEngine } from '../lib/creative-engines.mjs';
118
+
119
+ /**
120
+ * ⭐ Decided once, at the top, because it is a property of how this process was
121
+ * INVOKED and cannot change while it runs. `null` means stdin and stdout are
122
+ * not both terminals — a pipe, a CI job, a task runner — in which case there is
123
+ * nobody to ask and every consent gate must keep refusing exactly as it does
124
+ * today. `createAsker` only reads `isTTY`, so this costs nothing at load.
125
+ */
126
+ const asker = createAsker();
127
+
128
+ /**
129
+ * ── ⭐⭐ THE FIVE CAPABILITIES THAT WERE BUILT AND REACHED BY NOTHING ────────
130
+ *
131
+ * Each of the modules below shipped finished, documented and tested — and
132
+ * imported by no runtime path, which in this package is the same as not
133
+ * shipping at all. 7,397 lines (39% of the package) were once in that state.
134
+ *
135
+ * ⚠️ AN IMPORT IS NOT THE DELIVERABLE EITHER. The deliverable is a command a
136
+ * user can type: `--doctor`, `--replay`, `--design`, `--task-audio`, `--say`.
137
+ * Every one of them is in `--help` for the same reason — a capability only the
138
+ * changelog knows about is the identical orphan under a different name.
139
+ *
140
+ * ⚠️ COMPACTION IS DELIBERATELY ABSENT FROM THIS LIST. It is not a flag: it
141
+ * belongs inside the loop, applied automatically as the history approaches the
142
+ * budget, and it is wired in lib/turn.mjs. A user should not have to know the
143
+ * word "compaction" to stop paying for a transcript they cannot see.
144
+ */
145
+ import { runDoctor, formatDoctor } from '../lib/doctor.mjs';
146
+ /**
147
+ * ── ⭐ SHELL COMPLETION — built, tested, and reachable from nothing until now ──
148
+ * `lib/completion.mjs` is 509 lines that generate bash, zsh and fish scripts
149
+ * from the real flag list, so the completions cannot drift from the CLI. It had
150
+ * no entry point, which made it a capability nobody could use.
151
+ */
152
+ import { completionScript, SUPPORTED_SHELLS } from '../lib/completion.mjs';
153
+ import {
154
+ resolveConfig, explicitKeysFromArgv, applyConfigToOptions,
155
+ WORKSPACE_CONFIG_FILE, HOME_CONFIG_FILE, ACUVO_HOME_ENV,
156
+ } from '../lib/rcfile.mjs';
157
+ import { replaySession, formatTimeline, diffRuns, formatDiff } from '../lib/replay.mjs';
158
+ import { designPass, formatDesignPass } from '../lib/design-loop.mjs';
159
+ import {
160
+ extractVoiceFlags, taskFromAudio, confirmationLines, decideTranscript, speakSummary, VOICE_USAGE,
161
+ } from '../lib/voice-task.mjs';
162
+ import { createPainter, colourEnabled } from '../lib/colour.mjs';
163
+ /**
164
+ * ── ⭐⭐ FILE LEASES — THE SIXTH CAPABILITY THAT WAS BUILT AND REACHED BY
165
+ * NOTHING ─────────────────────────────────────────────────────────────
166
+ *
167
+ * `lib/lease.mjs` (868 lines) shipped finished and was imported by its own test
168
+ * and nothing else. The owner runs seven terminals against one checkout; without
169
+ * this, two of them writing the same file is silent data loss that shows up as
170
+ * "the agent undid my change".
171
+ *
172
+ * ⚠️ AND THE HONEST LIMIT, STATED HERE RATHER THAN IN A CHANGELOG: a coding
173
+ * agent does not know which files it will write until it writes them, so
174
+ * `--lease a.ts --lease b.ts` is a DECLARATION, not a guarantee. It protects
175
+ * exactly the paths named. The complete fix is one `acquire()` call inside the
176
+ * executor's write path (lib/workspace.mjs) — a different lane, and the module
177
+ * is shaped for it (single-path acquire is cheap and re-entrant).
178
+ */
179
+ import { acquireAll, renewAll, releaseAll, inspect, formatLeaseSummary, DEFAULT_TTL_MS } from '../lib/lease.mjs';
180
+ import { createPathClaimer } from '../lib/auto-lease.mjs';
181
+ import { boardAdd, boardList, boardClaim, boardDone, formatBoard } from '../lib/board.mjs';
182
+ import { loadRuns, pickRun, recheckClaim, formatRecheck, recheckAll, formatRecheckAll } from '../lib/verify-claim.mjs';
183
+ /**
184
+ * ── ⭐⭐ CHECKPOINT / REWIND — THE UNDO THIS TOOL DID NOT HAVE ───────────────
185
+ *
186
+ * Measured 2026-08-14: nothing in lib/ or bin/ restored a file. The agent could
187
+ * rewrite twelve files across five rounds and the only way back was git, and
188
+ * only if the tree happened to be clean beforehand. `openJournal` is handed to
189
+ * the executor so the previous bytes are copied at the two doors every mutation
190
+ * already goes through; `acuvo rewind` reads them back.
191
+ */
192
+ import {
193
+ openJournal, readJournal, groupRuns, planRewind, applyRewind, checkpointSize,
194
+ formatCheckpoints, formatRewind,
195
+ } from '../lib/checkpoint.mjs';
196
+ /**
197
+ * ── ⭐⭐ CTRL-C, AND THE HALF OF IT THAT LIVES HERE ─────────────────────────
198
+ *
199
+ * `lib/interrupt.mjs` shipped INERT — the policy and all five signal handlers
200
+ * were wired to consult it, and nothing ever registered a handler, so the first
201
+ * Ctrl-C still killed the run and lost its transcript. This import is the wire
202
+ * that was missing. See `armInterrupt` for what one press does and why the
203
+ * second one is not negotiable.
204
+ */
205
+ import { armInterrupt, wasAbortedByInterrupt, EXIT_INTERRUPTED } from '../lib/interrupt.mjs';
206
+ /**
207
+ * ── ⭐⭐ AND THE OTHER HALF: SAYING SOMETHING WITHOUT STOPPING ──────────────
208
+ * Ctrl-C is "stop". Steering is "no, do it this way instead" — the thing you
209
+ * actually want at round three of eight. See `lib/steer.mjs` for why it is a
210
+ * file and not a keystroke (short version: a keystroke works in exactly one of
211
+ * this tool's two input modes, and not the one that needs it).
212
+ */
213
+ import { takeSteer, planSteer, formatSteer, formatUnapplied, STEER_ABORT_REASON, STEER_FILE } from '../lib/steer.mjs';
214
+
215
+ const EXIT_OK = 0;
216
+ const EXIT_FAILED = 1;
217
+ const EXIT_UNCONFIGURED = 2;
218
+ const EXIT_USAGE = 64;
219
+ /**
220
+ * ⭐ "I CHOSE NOT TO RUN" IS NOT "I RAN AND FAILED", and under `--unattended`
221
+ * they need opposite reactions: one is a schedule behaving exactly as
222
+ * instructed, the other is something to look at. They had one exit code, so a
223
+ * cron log could not tell them apart — and the first one is far more common,
224
+ * which is how a person learns to ignore the alert that matters.
225
+ */
226
+ const EXIT_SKIPPED = 3;
227
+
228
+ function die(message, code) {
229
+ process.stderr.write(`${message}\n`);
230
+ process.exit(code);
231
+ }
232
+
233
+ /**
234
+ * ── ⭐⭐ SESSION LIFECYCLE + THE RUN LOG — THE FLAGS, AND WHY THEY ARE PARSED
235
+ * HERE RATHER THAN IN `cli-args.mjs` ───────────────────────────────────
236
+ *
237
+ * `parseArgv` refuses any `--flag` it does not know, which is the right default
238
+ * and is exactly why these are lifted out of argv BEFORE it runs. This file owns
239
+ * the operator surface (`--sessions`, `--resume`, `--continue`); the parser owns
240
+ * the model's budget. Folding them into `parseArgv` is the tidier long-term
241
+ * shape and is listed as the follow-up — it is not done here because that file
242
+ * is being edited concurrently and a lane that reaches into someone else's file
243
+ * destroys both pieces of work.
244
+ *
245
+ * ⚠️ THE STRIPPER IS TOTAL, NOT PERMISSIVE. Anything it does not recognise is
246
+ * passed through untouched so `parseArgv` still produces its own sentence for a
247
+ * typo — two parsers both guessing is how `--jsonn` ends up silently ignored.
248
+ */
249
+ const LIFECYCLE_USAGE = [
250
+ /**
251
+ * ── ⚠️⚠️⭐ THE ONLY ROUTE OFF BYOK WAS INVISIBLE ───────────────────────────
252
+ *
253
+ * Measured against the real `node bin/acuvo.mjs --help` output on 2026-08-19,
254
+ * on a clean tree: "login" 0, "logout" 0, "whoami" 0. All three flags WORK —
255
+ * `--whoami` printed "Using OPENROUTER_API_KEY from your environment (BYOK)
256
+ * … Run `acuvo --login` with an Acuvo key to use your credits instead."
257
+ *
258
+ * ⚠️ SO `--whoami` INSTRUCTED THE USER TO RUN A COMMAND `--help` DID NOT
259
+ * LIST, while the Environment section called OPENROUTER_API_KEY "required —
260
+ * the only one needed to write code". A stranger reading the front door end
261
+ * to end concluded BYOK is the only mode this tool has. The doctrine is the
262
+ * opposite, and an unreachable capability has not shipped.
263
+ *
264
+ * ⚠️ THEY LIVE HERE AND NOT IN `USAGE` FOR A MECHANICAL REASON. Every flag
265
+ * `extractLifecycleFlags` strips is invisible to `parseArgv`, and
266
+ * `test/cli-flags-parse.test.mjs` asserts that everything documented in
267
+ * `USAGE` survives `parseArgv`. I put this block in `USAGE` first and that
268
+ * test went red naming all three — correctly. `LIFECYCLE_USAGE` is where the
269
+ * pre-stripped flags are documented; that is the convention, not a workaround.
270
+ *
271
+ * ⭐ AND IT IS FIRST IN THIS ARRAY, above the session flags, because it is the
272
+ * first decision a new user makes: whose money this spends.
273
+ */
274
+ '',
275
+ 'Your account (an Acuvo key spends YOUR Acuvo credits — this is the way in):',
276
+ ' --login [key] Sign in. With no value it reads the key on stdin, which is the',
277
+ ' spelling to prefer: a credential typed as an argument lands in',
278
+ ' shell history, in `ps`, and in any terminal recording.',
279
+ ' acuvo --login < key.txt',
280
+ ' --whoami Which account this machine is using, and whose money it spends.',
281
+ ' Needs no key and spends nothing.',
282
+ ' --logout Forget the stored key. Falls back to OPENROUTER_API_KEY if one',
283
+ ' is set, which bills your provider account instead of your credits.',
284
+ '',
285
+ 'Session lifecycle (a run is saved when it ends, so you never re-pay for the gather):',
286
+ ' --sessions List the runs saved in this workspace, newest first, and exit.',
287
+ ' Needs no API key. With --json, one object: {"sessions":[…]}.',
288
+ ' --resume <id> Carry on from a saved run. The conversation is REBUILT, never',
289
+ ' replayed — no file is rewritten and no command is re-run.',
290
+ ' Add a new instruction to steer it: --resume <id> "now add tests".',
291
+ ' --continue Same, on the most recent resumable run.',
292
+ ' --strict Exit 1 when the run wrote nothing and ran nothing. Off by',
293
+ ' default, because a question can be answered correctly without',
294
+ ' touching anything. ON AUTOMATICALLY when CI is set.',
295
+ ' --no-session Do not save this run.',
296
+ ' --no-audit Do not append this run to the audit log.',
297
+ '',
298
+ 'Every run also appends one redacted JSON line to .acuvo/audit/<date>.jsonl — what was',
299
+ 'asked, what changed, what verified, what it cost. Never file contents, command output or',
300
+ 'model prose. --dry-run writes neither file, because a dry run touches nothing.',
301
+ '',
302
+ 'Look at what happened, and at what is working (none of these spend a completion):',
303
+ /**
304
+ * ⚠️ `completion <shell>` USED TO SIT ON THE LINE AFTER `--doctor`, i.e. IN
305
+ * THE MIDDLE OF --doctor's OWN DESCRIPTION. Rendered, a reader was told that
306
+ * `completion <shell>` prints "endpoints, which tools would be offered, git.
307
+ * Every dark or broken line names the exact variable that fixes it." — six
308
+ * continuation lines belonging to the entry above it. Pure array ordering; no
309
+ * sentence changed.
310
+ */
311
+ ' --doctor Say what is actually working here: key, model chain, media',
312
+ ' endpoints, which tools would be offered, git. Every dark or',
313
+ ' broken line names the exact variable that fixes it. Exits 0',
314
+ ' when nothing is broken. ⚠️ It VERIFIES over the network: your',
315
+ ' key is sent to openrouter.ai to check it authenticates, and',
316
+ ' each configured endpoint is pinged. Add --offline to skip all',
317
+ ' of it — nothing leaves the machine, and no key is sent.',
318
+ ` completion <shell> Print a completion script (${SUPPORTED_SHELLS.join(' · ')}) — append it to your shell profile`,
319
+ ' --replay <id> Step through a saved run: every round, call, result and refusal.',
320
+ ' Runs NOTHING and writes NOTHING. Add --json for the raw steps.',
321
+ ' --replay <id> --only <what>',
322
+ ' Narrow it: refusals | writes | runs | effects | reasoning.',
323
+ ' --replay <a> --diff <b>',
324
+ ' Compare two runs of the same task and name where they split.',
325
+ ' --design <file.html> Render the page, look at it, and print a verdict — plus the',
326
+ ' actual pixels if your terminal speaks kitty or iTerm2. Writes',
327
+ ' a screenshot into .acuvo/ and nothing else. Needs RENDER_AUDIT_URL.',
328
+ ].join('\n');
329
+
330
+ /**
331
+ * @param {readonly string[]} argv
332
+ * @returns {{ ok: true, flags: { sessions: boolean, resume: string | null, continueLatest: boolean, save: boolean, audit: boolean }, argv: string[] } | { ok: false, error: string }}
333
+ */
334
+ const RESUME_NEEDS_VALUE = '--resume needs the id of a saved run, e.g. --resume 20260811-0915-a1b2. Run `acuvo --sessions` to see the ids, or use --continue for the most recent.';
335
+
336
+ /**
337
+ * ⚠️ THE VALUED OPERATOR FLAGS SHARE ONE GUARD, and it is not tidiness. Each of
338
+ * these can eat the flag that follows it — `--replay --json` naming a session
339
+ * called "--json" is a confusing failure two steps later, and refusing here is
340
+ * one step. Writing the guard five times is how one of the five ends up without
341
+ * it, which is the shape of half the defects in this file's history.
342
+ */
343
+ const VALUED_LIFECYCLE_FLAGS = new Map([
344
+ ['--resume', { key: 'resume', need: RESUME_NEEDS_VALUE }],
345
+ ['--replay', { key: 'replay', need: '--replay needs the id of a saved run, e.g. --replay 20260811-023539-bg12. Run `acuvo --sessions` to see the ids.' }],
346
+ ['--diff', { key: 'diff', need: '--diff needs the id of a second saved run to compare against, e.g. --replay <a> --diff <b>. Run `acuvo --sessions` to see the ids.' }],
347
+ ['--only', { key: 'only', need: '--only needs one of: refusals, writes, runs, effects, reasoning.' }],
348
+ ['--design', { key: 'design', need: '--design needs the path to an HTML file in the workspace, e.g. --design index.html.' }],
349
+ ]);
350
+
351
+ function extractLifecycleFlags(argv) {
352
+ const flags = {
353
+ sessions: false, resume: null, continueLatest: false, save: true, audit: true,
354
+ doctor: false, replay: null, diff: null, only: null, design: null,
355
+ login: false, loginToken: null, logout: false, whoami: false,
356
+ };
357
+ const rest = [];
358
+ for (let i = 0; i < argv.length; i += 1) {
359
+ const arg = argv[i];
360
+ if (arg === '--sessions') { flags.sessions = true; continue; }
361
+ if (arg === '--doctor') { flags.doctor = true; continue; }
362
+ if (arg === '--logout') { flags.logout = true; continue; }
363
+ if (arg === '--whoami') { flags.whoami = true; continue; }
364
+ /**
365
+ * ⭐ `--login` TAKES ITS TOKEN OPTIONALLY. With a value it is convenient;
366
+ * with none it reads stdin, which is the spelling the docs should show —
367
+ * a live credential on the command line lands in shell history, in `ps`,
368
+ * and in any terminal recording. `gh auth login --with-token` reads stdin
369
+ * for exactly this reason.
370
+ */
371
+ if (arg === '--login') {
372
+ flags.login = true;
373
+ const next = argv[i + 1];
374
+ if (next !== undefined && !next.startsWith('--')) { flags.loginToken = next; i += 1; }
375
+ continue;
376
+ }
377
+ if (arg.startsWith('--login=')) { flags.login = true; flags.loginToken = arg.slice(8); continue; }
378
+ if (arg === '--continue') { flags.continueLatest = true; continue; }
379
+ if (arg === '--no-session') { flags.save = false; continue; }
380
+ if (arg === '--no-audit') { flags.audit = false; continue; }
381
+ const valued = VALUED_LIFECYCLE_FLAGS.get(arg);
382
+ if (valued) {
383
+ const value = argv[i + 1];
384
+ if (value === undefined || value.startsWith('--')) return { ok: false, error: valued.need };
385
+ flags[valued.key] = value;
386
+ i += 1;
387
+ continue;
388
+ }
389
+ // The `--flag=value` spelling, for every one of them rather than for
390
+ // `--resume` alone — an inconsistency here reads as a bug in the parser.
391
+ let matched = false;
392
+ for (const [name, spec] of VALUED_LIFECYCLE_FLAGS) {
393
+ if (!arg.startsWith(`${name}=`)) continue;
394
+ const value = arg.slice(name.length + 1);
395
+ if (value === '') return { ok: false, error: spec.need };
396
+ flags[spec.key] = value;
397
+ matched = true;
398
+ break;
399
+ }
400
+ if (matched) continue;
401
+ rest.push(arg);
402
+ }
403
+ return { ok: true, flags, argv: rest };
404
+ }
405
+
406
+ async function main() {
407
+ /**
408
+ * ── ⭐ `acuvo completion <shell>` ──────────────────────────────────────────
409
+ *
410
+ * ⚠️ FIRST, BEFORE ANY FLAG PARSING. `completion` is a SUBCOMMAND, not a flag,
411
+ * and `parseArgv` refuses anything it does not recognise — so checking later
412
+ * means the refusal fires before the feature does. It also needs no key, no
413
+ * model and no network: printing a completion script is a `cat` of generated
414
+ * text, and making someone authenticate to install tab-completion teaches
415
+ * them the tool is heavier than it is.
416
+ *
417
+ * ⚠️ STDOUT CARRIES THE SCRIPT AND NOTHING ELSE, because the documented
418
+ * install is `acuvo completion zsh >> ~/.zshrc`. A banner, a hint or a colour
419
+ * code on stdout lands inside the user's shell profile and breaks their next
420
+ * login. Errors go to stderr for exactly that reason.
421
+ */
422
+ const rawArgs = process.argv.slice(2);
423
+ if (rawArgs[0] === 'completion') {
424
+ const result = completionScript(rawArgs[1], { command: 'acuvo' });
425
+ if (!result.ok) {
426
+ process.stderr.write(`${result.error}
427
+ `);
428
+ return EXIT_USAGE;
429
+ }
430
+ process.stdout.write(`${result.script}
431
+ `);
432
+ return EXIT_OK;
433
+ }
434
+
435
+ const lifted = extractLifecycleFlags(rawArgs);
436
+ if (!lifted.ok) die(`${lifted.error}\n\n${USAGE}${LIFECYCLE_USAGE}\n`, EXIT_USAGE);
437
+ const life = lifted.flags;
438
+ /**
439
+ * ⚠️ LIFTED BEFORE `parseArgv`, WHICH REFUSES ANY `--flag` IT DOES NOT KNOW.
440
+ * That refusal is the right default and is precisely why the voice flags come
441
+ * out of argv first, exactly like the lifecycle ones above.
442
+ */
443
+ const voiced = extractVoiceFlags(lifted.argv);
444
+ if (!voiced.ok) die(`${voiced.error}\n\n${USAGE}${LIFECYCLE_USAGE}${VOICE_USAGE}\n`, EXIT_USAGE);
445
+ const voice = voiced.flags;
446
+ const parsed = parseArgv(voiced.argv);
447
+ if (!parsed.ok) die(`${parsed.error}\n\n${USAGE}`, EXIT_USAGE);
448
+ const opts = parsed.options;
449
+ /**
450
+ * ── ⭐ THE ENGINE THE USER NAMED, RECORDED ONCE FOR THE WHOLE RUN ──────────
451
+ *
452
+ * The parser only VALIDATED the id (it is pure, and a parser with a side
453
+ * effect on module state cannot be called twice in a test file without the
454
+ * second call inheriting the first one's choice). This is the one place that
455
+ * commits it, and it is a no-op when nobody passed `--engine`.
456
+ *
457
+ * ⚠️ IT IS PER MEDIUM. `--engine acuvo-image-ultra` changes what an image
458
+ * costs and cannot change what `speak` does — a flag whose blast radius is
459
+ * wider than its name is how somebody gets billed for a decision they think
460
+ * they scoped.
461
+ */
462
+ if (opts.engine) setRunEngine(opts.engine);
463
+ if (opts.help) {
464
+ // ⚠️ THE NEW FLAGS ARE DOCUMENTED WHERE PEOPLE LOOK. A capability that only
465
+ // the changelog knows about is the "built but unreachable" failure this
466
+ // whole exercise exists to end — `--help` is the front door.
467
+ process.stdout.write(`${USAGE}\n${LIFECYCLE_USAGE}\n${VOICE_USAGE}\n`);
468
+ return EXIT_OK;
469
+ }
470
+ /**
471
+ * ⚠️ BEFORE THE KEY CHECK, AND THAT ORDER IS THE WHOLE POINT. My first version
472
+ * sat below it, so `acuvo --version` demanded an OPENROUTER_API_KEY — the very
473
+ * first command anyone runs after installing, refusing to answer until they
474
+ * configure an account. `--version` and `--help` must work on a machine with
475
+ * nothing set up; they are how you check the install SUCCEEDED.
476
+ *
477
+ * The version is READ FROM package.json, never hardcoded: a string typed into
478
+ * source is wrong the first time someone bumps the manifest, and a CLI that
479
+ * misreports its own version makes every bug report start from a false premise.
480
+ */
481
+ if (opts.version) {
482
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
483
+ process.stdout.write(`acuvo-code ${pkg.version}\n`);
484
+ return EXIT_OK;
485
+ }
486
+
487
+ /**
488
+ * ── ⚠️⭐ REFUSE THE COMBINATIONS THAT CANNOT HONOUR `--json` ────────────────
489
+ *
490
+ * `--help` promises "One JSON object on stdout, nothing else". Measured: that
491
+ * was true on ONE of the four paths through this file. `--parallel` writes
492
+ * "running 2 tasks" and a summary table to stdout; interactive mode writes a
493
+ * whole conversation there. Both return BEFORE the `if (opts.json)` block near
494
+ * the bottom, so the flag was accepted, ignored, and `| jq` died on prose.
495
+ *
496
+ * ⚠️ THE HONEST ANSWER IS A REFUSAL, NOT A REROUTE. Pushing those lines to
497
+ * stderr would leave stdout holding nothing at all, because neither mode HAS a
498
+ * one-object answer: there is no single verdict for N parallel tasks, and a
499
+ * conversation has one per turn. Emitting an empty document, or an array the
500
+ * help text never promised, would be a second lie on top of the first.
501
+ *
502
+ * ⚠️ AND IT FIRES HERE — above the workspace resolve, above the `.env` load,
503
+ * above the key check, above the banner. Refusing later would print the very
504
+ * prose this exists to prevent. The message names the invocation that DOES
505
+ * work; "try again" would be worse than saying nothing, because nothing about
506
+ * retrying this command can change the answer.
507
+ */
508
+ /**
509
+ * ⚠️ TWO NEW EXEMPTIONS, AND BOTH ARE EXEMPT BECAUSE THEY DO HAVE ONE OBJECT.
510
+ * `--sessions --json` emits `{"sessions":[…],"unreadable":n}` — one document,
511
+ * nothing else on stdout. `--resume` (and `--continue`) run exactly one task
512
+ * and therefore reach the same one-object path a fresh run does; the only
513
+ * difference is where the first message came from. Refusing them would be the
514
+ * flag declining a shape it can honour perfectly.
515
+ */
516
+ /**
517
+ * ⚠️⚠️ FOUR MORE EXEMPTIONS, AND EVERY ONE OF THEM DOES HAVE ONE OBJECT.
518
+ * `--doctor` emits the report, `--replay` the timeline (or the diff), and
519
+ * `--design` the pass. `--task-audio` is the subtle one: the task comes from
520
+ * the AUDIO, so `opts.task` is empty at this point and without naming it here
521
+ * `acuvo --task-audio note.wav --json --yes` dies at "run one task per
522
+ * invocation" before it ever transcribes a byte — the flag refusing the exact
523
+ * shape it can honour, which is the defect this guard was written to end.
524
+ */
525
+ const resumeRequested = life.resume !== null || life.continueLatest;
526
+ /**
527
+ * ⚠️ `leases` BELONGS IN THIS LIST FOR THE SAME REASON `--doctor` DOES: it
528
+ * emits ONE object and nothing else, so refusing `--json` on it would be the
529
+ * flag declining a shape it can honour perfectly. Leaving it out is also what
530
+ * would make `acuvo leases --json` die at "run one task per invocation" —
531
+ * `opts.task` is empty for a command, which is exactly the trap `--task-audio`
532
+ * fell into.
533
+ */
534
+ const emitsOwnObject = life.sessions || life.doctor || life.login || life.logout || life.whoami
535
+ || life.replay !== null || life.design !== null
536
+ || opts.command !== null;
537
+ if (opts.json && !emitsOwnObject && (opts.parallel || (!opts.task && opts.issue === null && !resumeRequested && !voice.taskAudio))) {
538
+ die('--json emits one object for one task. --parallel and interactive mode print a running report instead, so run one task per invocation (acuvo --json "<task>"), or drop --json.', EXIT_USAGE);
539
+ }
540
+
541
+ /**
542
+ * ⚠️ `??` BELOW DOES NOT CATCH AN EMPTY STRING, and the gap points a
543
+ * FILE-WRITING agent at a directory nobody chose: `acuvo --dir "$PROJECT"`
544
+ * with PROJECT unset expands to `--dir ""`, which fell through to
545
+ * `process.cwd()` in silence. `--dir " "` already errored with "Not a
546
+ * directory", so the empty case was an inconsistency as well as a hazard —
547
+ * the more dangerous of the two spellings was the one that was accepted.
548
+ *
549
+ * ⚠️ ABOVE THE KEY CHECK ON PURPOSE. On an unconfigured machine the old order
550
+ * answered "no API key" — a true statement about the wrong problem.
551
+ */
552
+ if (opts.dir !== undefined && String(opts.dir).trim() === '') {
553
+ die('--dir was given an empty value. Pass a directory, or omit --dir to use the current directory.', EXIT_USAGE);
554
+ }
555
+
556
+ const root = resolve(opts.dir ?? process.cwd());
557
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
558
+ die(`Not a directory: ${root}`, EXIT_USAGE);
559
+ }
560
+
561
+ /**
562
+ * ⚠️⚠️ THE `.acuvo/` SELF-IGNORE IS DELIBERATELY *NOT* CALLED HERE, and it was
563
+ * on the first attempt. Running it at startup created the directory on every
564
+ * invocation — including `--dry-run`, whose `--help` promises it "touches
565
+ * nothing", and `--no-audit --no-session`, which promises to leave the
566
+ * workspace alone. The suite caught it immediately
567
+ * (`lifecycle-wiring.test.mjs`), and the test was right: creating a directory
568
+ * and a file IS touching something.
569
+ *
570
+ * ⭐ So the ignore belongs with whoever actually CREATES the directory —
571
+ * `appendAudit` and the session writer — because those already respect every
572
+ * opt-out. A convenience placed one layer too high broke a promise two flags
573
+ * had made.
574
+ */
575
+
576
+ /**
577
+ * ── ⚠️⭐ LOAD `.env` FROM THE WORKSPACE. THE MEDIA HALF WAS DARK WITHOUT IT ──
578
+ *
579
+ * Measured today: `mediaToolNames(process.env)` returned `[]` in an ordinary
580
+ * terminal, on a machine where every one of those services is configured and
581
+ * working. Nothing here read a `.env` file, so `see_page` — the capability
582
+ * this CLI is sold on — was never even OFFERED to the model unless you
583
+ * happened to know to `export` four variables by hand first.
584
+ *
585
+ * ⭐ It also removes the `--env-file` dance the README documents for the API
586
+ * key: the shape everyone already has (a `.env` next to the code) now works.
587
+ *
588
+ * ⚠️ A REAL ENVIRONMENT VARIABLE ALWAYS WINS. Node's loader does not overwrite
589
+ * what is already set, which is the behaviour you want: an explicit `export`
590
+ * in this shell must beat a stale file someone forgot about, or debugging
591
+ * becomes guesswork about which value is live.
592
+ *
593
+ * ⚠️ And it is best-effort by design. No `.env` is the normal case, and a
594
+ * malformed one must not stop a coding session that never needed it.
595
+ */
596
+ /**
597
+ * ⚠️⚠️ THIS LOOKED FOR `.env` AND THERE IS NO PLAIN `.env` ON THIS MACHINE —
598
+ * every file is `.env.local`, the name Next.js/Vite/CRA use for the one that
599
+ * holds secrets and is git-ignored. So the loader above never fired once and
600
+ * the media half it was written to rescue stayed dark. It now walks up for
601
+ * `.env.local` then `.env`, and lives in `lib/env-file.mjs` where a test can
602
+ * read the filename list — being inline here is why nobody caught it.
603
+ */
604
+ envLoad([root, process.cwd()]);
605
+
606
+ /**
607
+ * ── ⚠️⚠️ POLICY: 736 LINES OF ADMIN CONTROL THAT NOTHING EVER CALLED ───────
608
+ *
609
+ * `lib/policy.mjs` lets an organisation forbid verbs, cap rounds, cap dollars,
610
+ * force `--dry-run`, restrict models and ban MCP — and its design is the good
611
+ * kind: every merge takes the STRICTER value, so the merge is a meet on a
612
+ * lattice and a policy file the agent itself rewrites can only ever restrict
613
+ * it further. There is no value it can write that grants it anything.
614
+ *
615
+ * Measured 2026-08-12 by walking the import graph from both entry points:
616
+ * **it was reachable from nothing but its own test.** 736 lines, fully
617
+ * documented, fully tested, and every `--doctor` and every run behaved as if
618
+ * an admin had never been able to say no to anything. That is this package's
619
+ * signature failure — not writing bad code, writing good code and never
620
+ * connecting it — and the enterprise checklist item most likely to be asked
621
+ * about was the one sitting dark.
622
+ *
623
+ * ⚠️ TWO LAYERS, AND THE USER'S IS THE TRUSTED ONE. `~/.acuvo/policy.json` is
624
+ * the admin layer (outside the workspace, so the agent cannot reach it); the
625
+ * workspace file can only narrow it further.
626
+ */
627
+ const readIfPresent = (file) => {
628
+ try { return existsSync(file) ? readFileSync(file, 'utf8') : null; } catch { return null; }
629
+ };
630
+ /**
631
+ * ── ⭐⭐ THE CONFIG FILE — 825 built lines that nothing had ever called ─────
632
+ *
633
+ * Deliberately here, beside the POLICY load, because they are the same shape
634
+ * and the same trust argument: `~/.acuvo/config.json` is yours, the
635
+ * workspace's `.acuvo/config.json` came with a repo you cloned, and the second
636
+ * may only make things STRICTER. A cloned repo that could RAISE your budget or
637
+ * switch running back on would be a config file with a security hole in it.
638
+ * `rcfile.mjs` enforces that direction; this is only the door.
639
+ *
640
+ * ⚠️⚠️ A KEY THE USER TYPED IS NEVER OVERWRITTEN, and that is enforced HERE
641
+ * rather than trusted to the resolver. `resolveConfig` is told WHICH keys were
642
+ * explicit but never sees their VALUES, so its `values` still carry the file's
643
+ * number for a key the flag also set. Applying that blindly would let a config
644
+ * file silently beat a flag the person just typed — the one behaviour a config
645
+ * system must never have.
646
+ *
647
+ * ⚠️ AND A MALFORMED CONFIG STOPS THE RUN, matching the policy loader directly
648
+ * below: absent means "no config", but present-and-broken is a broken control,
649
+ * and quietly falling back is how someone discovers their settings never
650
+ * applied — from a surprise bill.
651
+ */
652
+ const homeConfigDir = process.env[ACUVO_HOME_ENV]?.trim() || join(homedir(), '.acuvo');
653
+ const configLoad = resolveConfig({
654
+ argv: voiced.argv,
655
+ env: process.env,
656
+ homeText: readIfPresent(join(homeConfigDir, HOME_CONFIG_FILE)),
657
+ workspaceText: readIfPresent(join(root, WORKSPACE_CONFIG_FILE)),
658
+ });
659
+ if (!configLoad.ok) die(`config: ${configLoad.error}`, EXIT_USAGE);
660
+ // ⭐ The precedence rule lives in rcfile.mjs so it is testable without running
661
+ // the whole CLI — inline here it was reachable only by end-to-end invocation,
662
+ // which is how a rule this important ends up unverified.
663
+ applyConfigToOptions(opts, configLoad.values, explicitKeysFromArgv(voiced.argv));
664
+
665
+ const adminPolicyFile = process.env[USER_POLICY_ENV]?.trim() || join(homedir(), USER_POLICY_FILE);
666
+ const policyLoad = loadPolicy({
667
+ adminText: readIfPresent(adminPolicyFile),
668
+ adminLabel: adminPolicyFile,
669
+ workspaceText: readIfPresent(join(root, WORKSPACE_POLICY_FILE)),
670
+ });
671
+ if (!policyLoad.ok) {
672
+ /**
673
+ * ⚠️ A MALFORMED POLICY STOPS THE RUN. `command.mjs` already makes this call
674
+ * for `.acuvo/commands.json`: absent means "no policy", but present-and-
675
+ * broken is a broken CONTROL, and quietly falling back to permissive is how
676
+ * an org discovers its restrictions never applied.
677
+ */
678
+ die(`policy: ${policyLoad.error}`, EXIT_USAGE);
679
+ }
680
+ const policy = policyLoad.policy;
681
+
682
+ const verdict = invocationDecision(policy, {
683
+ dryRun: opts.dryRun, model: opts.model ?? undefined, maxRounds: opts.maxRounds, allowRun: opts.allowRun,
684
+ });
685
+ if (!verdict.ok) {
686
+ // ⚠️ Before the key check and before any spend: a run policy forbids must
687
+ // cost nothing to discover.
688
+ die(`refused by policy:\n ${verdict.violations.join('\n ')}`, EXIT_USAGE);
689
+ }
690
+ for (const note of verdict.notes) process.stderr.write(` · ${note}\n`);
691
+
692
+ /**
693
+ * ⚠️ THE ROUND CEILING IS APPLIED, NOT JUST REPORTED. `invocationDecision`
694
+ * returns the cap as a NOTE; if nothing then lowers `maxRounds`, the note is
695
+ * an announcement of a limit that is not enforced.
696
+ */
697
+ const capped = roundBudget(policy, opts.maxRounds);
698
+ if (capped.capped) opts.maxRounds = capped.rounds;
699
+
700
+ /**
701
+ * ⚠️⚠️ AND SO IS THE COST CEILING — IT WAS ENFORCED BY NOTHING AT ALL.
702
+ * `costDecision` in policy.mjs is complete and had ZERO runtime callers.
703
+ * Measured: a workspace policy of `{"maxCostUsd": 0}` parsed fine, the
704
+ * decision function returned STOP when asked, and the run spent money over
705
+ * three rounds because nobody asked it. Exactly the disease the comment above
706
+ * describes for rounds, in the sibling control.
707
+ *
708
+ * ⭐ Folded into the ceiling the governor already reads, rather than added as
709
+ * a second check in the round loop — one mechanism cannot drift from itself,
710
+ * and a future call site cannot forget it.
711
+ */
712
+ const costCap = costBudget(policy, opts.budgetUsd);
713
+ if (costCap.capped) {
714
+ opts.budgetUsd = costCap.usd;
715
+ /**
716
+ * ⚠️ MARKED AS CHOSEN, NOT DEFAULT. `budgetExplicit` is what tells the
717
+ * governor a human picked this number — it changes the refusal wording and
718
+ * gates `--until-done`. An admin writing a policy file IS a human choosing,
719
+ * so a policy-set ceiling that still read as "the default" would announce
720
+ * itself as an accident.
721
+ */
722
+ opts.budgetExplicit = true;
723
+ opts.budgetSource = 'policy';
724
+ process.stderr.write(` · ${costCap.reason}\n`);
725
+ }
726
+
727
+ /**
728
+ * ── ⭐ `--sessions` — WHAT IS SAVED, AND ABOVE THE KEY CHECK ON PURPOSE ────
729
+ *
730
+ * Same reasoning as `--version`: reading a directory this tool wrote needs no
731
+ * account. A machine whose key expired is precisely the machine whose operator
732
+ * wants to know what the last run got through before it died, and answering
733
+ * "no API key" there is a true statement about the wrong problem.
734
+ *
735
+ * ⚠️ IT RESUMES NOTHING AND RUNS NOTHING. Listing is the one operation in this
736
+ * file that cannot spend money or write a byte, and it stays that way.
737
+ */
738
+ if (life.sessions) {
739
+ const listed = listSessions(root, { limit: 20 });
740
+ if (!listed.ok) die(listed.error, EXIT_FAILED);
741
+ if (opts.json) {
742
+ process.stdout.write(`${JSON.stringify({ sessions: listed.sessions, unreadable: listed.unreadable }, null, 2)}\n`);
743
+ return EXIT_OK;
744
+ }
745
+ if (listed.sessions.length === 0) {
746
+ // ⚠️ An empty state that only says "none" leaves the reader wondering
747
+ // whether the feature is off or simply unused. Say which.
748
+ process.stdout.write('\n no runs saved in this workspace yet — one is written each time a task finishes.\n\n');
749
+ return EXIT_OK;
750
+ }
751
+ process.stdout.write('\n');
752
+ for (const s of listed.sessions) process.stdout.write(` ${s.summary}\n`);
753
+ if (listed.unreadable > 0) {
754
+ process.stderr.write(` (${listed.unreadable} unreadable session file${listed.unreadable === 1 ? '' : 's'} skipped)\n`);
755
+ }
756
+ process.stdout.write('\n carry one on: acuvo --resume <id> ["what to do next"]\n\n');
757
+ return EXIT_OK;
758
+ }
759
+
760
+ /**
761
+ * ── ⭐⭐ `acuvo leases` — WHO IS HOLDING WHAT, AND SINCE WHEN ──────────────
762
+ *
763
+ * ⚠️ ABOVE THE KEY CHECK, like `--version`, `--sessions`, `--doctor` and
764
+ * `--replay`, and for the identical reason: reading a directory this tool
765
+ * wrote needs no account. The person typing this is usually the person whose
766
+ * SEVENTH terminal just refused to start, and answering "no API key" there is
767
+ * a true statement about the wrong problem.
768
+ *
769
+ * ⚠️ IT RUNS NOTHING, WRITES NOTHING AND RECLAIMS NOTHING. `inspect` reports a
770
+ * stale lease as `expired`/`reclaimable` and leaves it exactly where it is —
771
+ * a diagnostic that quietly breaks other people's locks would be the worst
772
+ * possible reading of "show me what is going on".
773
+ */
774
+ /**
775
+ * ── ⭐ `acuvo spend` — READING BACK WHAT EVERY RUN ALREADY WROTE DOWN ──────
776
+ *
777
+ * `parseAuditLog` shipped finished, exported and tested with ZERO runtime
778
+ * callers, so the tool recorded `costUsd` on every run and nobody could ask
779
+ * for it. For a product sold on telling you the price before it runs, being
780
+ * unable to answer "what have I spent" afterwards is the pitch with its last
781
+ * sentence removed.
782
+ *
783
+ * ⚠️ Reads only. No key, no completion, no network — same class as `--doctor
784
+ * --offline` and `leases`.
785
+ */
786
+ /**
787
+ * ── ⭐⭐ `acuvo engines` — "WHAT WILL THIS COST ME", ASKED BEFORE SPENDING ──
788
+ *
789
+ * Roman, 2026-08-16: *"as long as users have the choice to switch between
790
+ * premium and basic for video and image then we should be good"* — and a
791
+ * choice you cannot price is not a choice. This is the surface where a person
792
+ * finds out that an Ultra clip is 585 credits and the core one is 117, before
793
+ * either of them has run.
794
+ *
795
+ * ⚠️ ABOVE THE KEY CHECK, with `leases` and `spend`: it needs no OpenRouter
796
+ * key, because it asks the ACUVO GATEWAY about an ACUVO ACCOUNT. Refusing it
797
+ * for a missing model key would be a true statement about the wrong problem.
798
+ *
799
+ * ⚠️⚠️ AND IT PRINTS "PRICES UNAVAILABLE" RATHER THAN A NUMBER WHEN NOBODY
800
+ * ANSWERS — which today is everybody, because the gateway has no `/engines`
801
+ * route yet (measured 2026-08-16: `acuvo-gateway/lib/handler.mjs` proxies chat
802
+ * completions and routes nothing, and `console/app/api/cli/v1/` holds only
803
+ * `chat/`). Shipping the numbers inside the package to make this look finished
804
+ * is the one thing that must not happen: an npm package pins the price it was
805
+ * published with, and the customer can edit the file. Prices are account facts
806
+ * and they stay on the server.
807
+ */
808
+ if (opts.command === 'engines') {
809
+ const result = await listEngines({});
810
+ if (opts.json) {
811
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
812
+ `);
813
+ return EXIT_OK;
814
+ }
815
+ process.stdout.write(`
816
+ ${result.text}
817
+
818
+ `);
819
+ return EXIT_OK;
820
+ }
821
+
822
+ if (opts.command === 'spend') {
823
+ const since = parseSince(opts.since);
824
+ if (since && since.error) die(since.error, EXIT_USAGE);
825
+ const summary = summariseSpend(readAuditFiles(root), { since });
826
+ if (opts.json) {
827
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
828
+ return EXIT_OK;
829
+ }
830
+ process.stdout.write(`\n${formatSpend(summary, { since }).map((l) => ` ${l}`).join('\n')}\n\n`);
831
+
832
+ /**
833
+ * ── ⭐ WHAT THAT SPEND IS AGAINST ────────────────────────────────────
834
+ *
835
+ * A dollar figure alone cannot answer the question people actually ask,
836
+ * which is "how much have I got left". The plan is the denominator, and
837
+ * `lib/plan.mjs` holds it with prices measured from the endpoint each
838
+ * model is PINNED to — not from a model page, which is how pro looked
839
+ * 3.1x flash while we were being charged 11.2x.
840
+ *
841
+ * ⚠️ THE CACHE RATE IS PASSED IN, NOT ASSUMED. This plan clears an 80%
842
+ * margin only at or above 77% cache, so a margin quoted without the rate
843
+ * that produced it is a number somebody chose. It comes from this
844
+ * workspace's own audit log, computed above.
845
+ */
846
+ const observedCache = Number.isFinite(summary?.cacheHitRate) ? summary.cacheHitRate : 0.95;
847
+ /**
848
+ * ── ⭐ WHERE THIS WORKSPACE ACTUALLY STANDS AGAINST THE ALLOWANCE ────
849
+ *
850
+ * ⚠️ `allowanceRemaining` shipped and was called by nobody — an
851
+ * allowance nothing reads is a number on a pricing page. The usage is
852
+ * aggregated from this workspace's own audit log, per model that
853
+ * ANSWERED (not the one requested: a run that fell back spent tokens on
854
+ * whichever model actually served it).
855
+ *
856
+ * ⚠️ ENFORCEMENT AT RUN TIME NEEDS THE ACCOUNT. This is one workspace on
857
+ * one machine; the real limit is per TENANT and lives behind the
858
+ * gateway. What is honest to show today is where this workspace stands,
859
+ * and to say plainly that it is not the whole picture.
860
+ */
861
+ /**
862
+ * ⚠️ `readAuditFiles` returns `{name, text}` — RAW TEXT, not records. My
863
+ * first version assumed `.records` and silently produced 0.0M used,
864
+ * which is the worst possible wrong answer: an allowance reading zero
865
+ * looks healthy. Caught by running it against a workspace that had a
866
+ * real 93,743-token run in the log.
867
+ */
868
+ const auditRecords = readAuditFiles(root).flatMap((f) => parseAuditLog(f.text).records);
869
+ const { byModel, unknown } = usageByModel(auditRecords);
870
+ const left = allowanceRemaining(PLANS.starter, byModel);
871
+ const usageLines = Object.entries(left)
872
+ .filter(([, v]) => v.available)
873
+ .map(([id, v]) => ` ${labelForModelId(id).padEnd(12)} ${(v.used / 1e6).toFixed(1)}M of ${(v.granted / 1e6).toFixed(0)}M used${v.exhausted ? ' — EXHAUSTED' : ''}`);
874
+ if (unknown > 0) usageLines.push(` ⚠ ${unknown} run(s) recorded no model or token count, so this is a floor`);
875
+ usageLines.push(' (this workspace only — a plan limit is per account, and that lives behind the gateway)');
876
+
877
+ const planLines = [...usageLines, '', ...formatPlan(PLANS.starter, observedCache)].map((l) => ` ${l}`);
878
+ process.stdout.write(`${planLines.join('\n')}\n\n`);
879
+ return EXIT_OK;
880
+ }
881
+
882
+ /**
883
+ * ── ⭐⭐ `acuvo board` — THE LAST PIECE OF "SEVEN TERMINALS, SEVEN WORKERS" ──
884
+ *
885
+ * Everything else was already measured working: seven terminals run, leases
886
+ * stop them writing one file, the fleet ceiling caps the day, the plan ledger
887
+ * is per worker. What was missing is that nothing said what the WORK was, so
888
+ * seven terminals meant a person typing seven prompts and nothing stopping
889
+ * two of them being the same.
890
+ *
891
+ * ⚠️ READ-ONLY BY DEFAULT and above the key check, like `leases` and `spend`:
892
+ * looking at the board must work on a machine with no credentials at all.
893
+ */
894
+ /**
895
+ * ── ⭐⭐ `acuvo verify` — RE-CHECKING A PAST CLAIM FOR NOTHING ─────────────
896
+ *
897
+ * Every run already writes the exact command this process observed exiting 0.
898
+ * So a claim made yesterday can be tested today by RUNNING it again — no model
899
+ * call, no cost. Above the key check with the other read-only commands,
900
+ * because it needs no credentials at all: there is nothing to ask a model.
901
+ */
902
+ if (opts.command === 'verify') {
903
+ const loaded = loadRuns(root);
904
+ if (!loaded.ok) die(loaded.error, EXIT_FAILED);
905
+ const runner = (command, o) => executeRunCommand({
906
+ command,
907
+ executor: createLocalExecutor(root),
908
+ timeoutMs: o?.timeoutMs ?? opts.commandTimeoutMs,
909
+ shell: opts.shell,
910
+ });
911
+
912
+ /**
913
+ * ⭐ `--all` answers the question a fleet actually leaves behind. Seven
914
+ * terminals working a board overnight produce fifty claims, and nobody wants
915
+ * to read fifty receipts — they want to know which are still true.
916
+ */
917
+ if (opts.verifyAll) {
918
+ const all = await recheckAll(loaded.runs, { runner });
919
+ if (opts.json) process.stdout.write(`${JSON.stringify(all, null, 2)}
920
+ `);
921
+ else process.stdout.write(['', formatRecheckAll(all).split(String.fromCharCode(10)).map((l) => ` ${l}`).join(String.fromCharCode(10)), ''].join(String.fromCharCode(10)));
922
+ if (all.checked === 0) return EXIT_SKIPPED;
923
+ return all.ok ? EXIT_OK : EXIT_FAILED;
924
+ }
925
+
926
+ const picked = pickRun(loaded.runs, opts.verifyId);
927
+ if (!picked.ok) die(picked.error, EXIT_USAGE);
928
+
929
+ const outcome = await recheckClaim(picked.run, { runner });
930
+ if (opts.json) {
931
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}
932
+ `);
933
+ } else {
934
+ process.stdout.write(['', ` ${formatRecheck(outcome).split(String.fromCharCode(10)).join(String.fromCharCode(10) + ' ')}`, ''].join(String.fromCharCode(10)));
935
+ }
936
+ /**
937
+ * ⚠️ THREE OUTCOMES, THREE CODES. `holds` is 0. `broken` is 1 — that is the
938
+ * one a deploy gate cares about. "No checkable claim" is EXIT_SKIPPED, not
939
+ * 0: a run that executed nothing proved nothing, and reporting that as
940
+ * success is the quiet dishonesty every verdict here exists to prevent.
941
+ */
942
+ if (outcome.status === 'holds') return EXIT_OK;
943
+ if (outcome.status === 'unclaimed') return EXIT_SKIPPED;
944
+ return EXIT_FAILED;
945
+ }
946
+
947
+ if (opts.command === 'board') {
948
+ const [verb, ...rest] = opts.boardArgs ?? [];
949
+ if (!verb) {
950
+ const listed = boardList(root);
951
+ if (opts.json) {
952
+ process.stdout.write(`${JSON.stringify(listed, null, 2)}
953
+ `);
954
+ return listed.ok ? EXIT_OK : EXIT_FAILED;
955
+ }
956
+ process.stdout.write(`
957
+ ${formatBoard(listed)}
958
+
959
+ `);
960
+ return listed.ok ? EXIT_OK : EXIT_FAILED;
961
+ }
962
+ if (verb === 'add') {
963
+ const text = rest.join(' ').trim();
964
+ const added = boardAdd(root, text);
965
+ if (!added.ok) die(added.error, EXIT_USAGE);
966
+ process.stdout.write(` added ${added.id} — ${added.task}
967
+ `);
968
+ return EXIT_OK;
969
+ }
970
+ if (verb === 'done') {
971
+ const done = boardDone(root, rest[0]);
972
+ if (!done.ok) die(done.error, EXIT_USAGE);
973
+ process.stdout.write(` done ${done.id} — ${done.task}
974
+ `);
975
+ return EXIT_OK;
976
+ }
977
+ die(`unknown board command "${verb}". Try: acuvo board · acuvo board add "…" · acuvo board done <id>`, EXIT_USAGE);
978
+ }
979
+
980
+ /**
981
+ * ── ⭐⭐ `acuvo rewind` — THE UNDO, AND WHY IT SITS UP HERE ─────────────────
982
+ *
983
+ * Above the key check with `leases`, `spend`, `board` and `verify`: putting
984
+ * files back needs no credentials, no network and no completion. The moment
985
+ * you most want an undo is the moment something went wrong, and "configure an
986
+ * API key first" would be the worst possible answer to it.
987
+ *
988
+ * ⚠️ THE DEFAULT IS TO LIST, NOT TO ACT. A bare `acuvo rewind` restores
989
+ * nothing — it prints the checkpoints and the exact command to use. A verb
990
+ * that guesses which state you meant is a verb that overwrites the wrong one.
991
+ */
992
+ if (opts.command === 'rewind') {
993
+ const journal = readJournal(root);
994
+ if (!journal.ok) die(journal.error, EXIT_FAILED);
995
+ const runs = groupRuns(journal.entries);
996
+ const wanted = (opts.rewindArgs ?? [])[0] ?? null;
997
+
998
+ if (!wanted) {
999
+ // ⚠️ THE DISK COST IS PART OF THE ANSWER. Nothing prunes this store yet,
1000
+ // so a listing that never mentions its size is the one place a user would
1001
+ // have found out before it mattered.
1002
+ const size = checkpointSize(root);
1003
+ if (opts.json) {
1004
+ process.stdout.write(`${JSON.stringify({ checkpoints: runs, unreadable: journal.unreadable, size }, null, 2)}\n`);
1005
+ return EXIT_OK;
1006
+ }
1007
+ process.stdout.write(`\n${formatCheckpoints(runs, size).map((l) => ` ${l}`).join('\n')}\n\n`);
1008
+ /**
1009
+ * ⚠️ EXIT 3, NOT 0, WITH NOTHING TO SHOW. "There is no undo here" is not
1010
+ * success, and a script asking "can I roll this back" must be able to
1011
+ * tell it apart from "yes, here are four". Same reasoning as
1012
+ * `acuvo verify` returning EXIT_SKIPPED for an unclaimed run.
1013
+ */
1014
+ return runs.length === 0 ? EXIT_SKIPPED : EXIT_OK;
1015
+ }
1016
+
1017
+ const plan = planRewind(journal.entries, wanted);
1018
+ if (!plan.ok) die(plan.error, EXIT_USAGE);
1019
+ const result = applyRewind(root, plan, { dryRun: opts.dryRun, force: opts.force });
1020
+ /**
1021
+ * ⚠️⚠️ "I REFUSED EVERY FILE" IS NOT "I PUT THEM BACK", and a script must be
1022
+ * able to tell them apart: `acuvo rewind <id> && npm test` would otherwise
1023
+ * test the tree it was asked to undo. Three outcomes, three codes — the
1024
+ * same rule `acuvo verify` follows for a run with no checkable claim.
1025
+ * 0 something was restored · 3 nothing was, because it all conflicted ·
1026
+ * 1 something actually failed.
1027
+ */
1028
+ const touched = result.restored.length + result.removed.length;
1029
+ const code = !result.ok ? EXIT_FAILED : (touched === 0 && result.skipped.length > 0 ? EXIT_SKIPPED : EXIT_OK);
1030
+ if (opts.json) {
1031
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1032
+ return code;
1033
+ }
1034
+ process.stdout.write(`\n${formatRewind(result).map((l) => ` ${l}`).join('\n')}\n\n`);
1035
+ return code;
1036
+ }
1037
+
1038
+ if (opts.command === 'leases') {
1039
+ const view = inspect(root);
1040
+ if (opts.json) {
1041
+ process.stdout.write(`${JSON.stringify(view, null, 2)}\n`);
1042
+ return view.ok ? EXIT_OK : EXIT_FAILED;
1043
+ }
1044
+ process.stdout.write(`\n${formatLeaseSummary(view).map((l) => ` ${l}`).join('\n')}\n\n`);
1045
+ if (view.ok === false) return EXIT_FAILED;
1046
+ return EXIT_OK;
1047
+ }
1048
+
1049
+ /**
1050
+ * ── ⭐⭐ `--doctor` — ONE COMMAND THAT SAYS WHAT IS ACTUALLY WORKING ────────
1051
+ *
1052
+ * ⚠️ ABOVE THE KEY CHECK, for the same reason `--version` and `--sessions`
1053
+ * are. The doctor's whole job is to say WHY nothing is configured; demanding
1054
+ * configuration before it will answer would make it useless at exactly the
1055
+ * moment it is needed — the first command anyone runs after an install that
1056
+ * did not work.
1057
+ *
1058
+ * ⚠️ `opts.maxRounds` AND `opts.allowRun` ARE PASSED, NOT DROPPED. The tool
1059
+ * offer is a snapshot for the flags you actually gave; without them the
1060
+ * withheld-tool reasons would be right about the doctor's assumption and
1061
+ * wrong about your next run.
1062
+ */
1063
+ /**
1064
+ * ── ⭐⭐ `--login` / `--logout` / `--whoami` — THE STEP THAT WAS MISSING ───
1065
+ *
1066
+ * `writeAccount` has been exported, documented and reachable in code for
1067
+ * weeks while being called by NOTHING but its own tests. So `resolveCredential`
1068
+ * never found an account, fell through to `OPENROUTER_API_KEY`, and every user
1069
+ * was on BYOK — which `account.mjs` itself calls "never the plan" and which
1070
+ * makes the storefront an advertisement for somebody else.
1071
+ *
1072
+ * ⚠️ ABOVE THE CREDENTIAL CHECK, deliberately: the command that FIXES a
1073
+ * missing credential cannot be gated on having one. Same reason `--replay`
1074
+ * sits above it.
1075
+ */
1076
+ if (life.whoami) {
1077
+ const { describeAuth } = await import('../lib/login.mjs');
1078
+ const { resolveCredential } = await import('../lib/account.mjs');
1079
+ const d = describeAuth(resolveCredential());
1080
+ process.stdout.write(`${d.line}
1081
+ `);
1082
+ return d.ok ? EXIT_OK : EXIT_FAILED;
1083
+ }
1084
+
1085
+ if (life.logout) {
1086
+ const { clearAccount } = await import('../lib/account.mjs');
1087
+ const cleared = clearAccount();
1088
+ if (cleared.ok === false) die(cleared.error, EXIT_FAILED);
1089
+ /**
1090
+ * ⚠️ `.existed`, NOT the returned object. `clearAccount` returns
1091
+ * `{ ok, existed, path }`, so testing the object itself is always truthy and
1092
+ * would tell someone who was never logged in that their credential had just
1093
+ * been removed — a lie that sends them looking for a problem that is not there.
1094
+ */
1095
+ process.stdout.write(cleared.existed
1096
+ ? 'Logged out. The stored credential has been removed.\n'
1097
+ : 'You were not logged in — nothing to remove.\n');
1098
+ return EXIT_OK;
1099
+ }
1100
+
1101
+ if (life.login) {
1102
+ const { validateTokenShape, verifyToken, maskToken } = await import('../lib/login.mjs');
1103
+ const { writeAccount, DEFAULT_GATEWAY_URL } = await import('../lib/account.mjs');
1104
+
1105
+ /**
1106
+ * ⚠️ STDIN WHEN NO VALUE WAS GIVEN. A credential passed as an argument is
1107
+ * in shell history and in `ps` output the moment it is typed.
1108
+ */
1109
+ let raw = life.loginToken;
1110
+ if (raw === null) {
1111
+ if (process.stdin.isTTY) {
1112
+ die('paste the key on stdin: `acuvo --login < key.txt`, or pass it directly with `acuvo --login <key>`.', EXIT_USAGE);
1113
+ }
1114
+ const chunks = [];
1115
+ for await (const c of process.stdin) chunks.push(c);
1116
+ raw = Buffer.concat(chunks).toString('utf8');
1117
+ }
1118
+
1119
+ const shape = validateTokenShape(raw);
1120
+ if (!shape.ok) die(shape.reason, EXIT_USAGE);
1121
+
1122
+ const gateway = process.env.ACUVO_GATEWAY_URL || DEFAULT_GATEWAY_URL;
1123
+ process.stderr.write(`Checking that key against ${gateway} …
1124
+ `);
1125
+ const check = await verifyToken(shape.token, gateway);
1126
+ /**
1127
+ * ⚠️ VERIFY BEFORE WRITE. A saved-but-invalid token fails at the model call
1128
+ * on some later run, far from the mistake, with an error about chat
1129
+ * completions rather than about login.
1130
+ */
1131
+ if (!check.ok) die(check.reason, EXIT_FAILED);
1132
+
1133
+ const wrote = writeAccount({ token: shape.token, gatewayUrl: gateway });
1134
+ if (!wrote || wrote.ok === false) {
1135
+ die(`could not save the credential${wrote && wrote.error ? `: ${wrote.error}` : ''}`, EXIT_FAILED);
1136
+ }
1137
+ // Never echo the credential itself.
1138
+ process.stdout.write(`Logged in (${maskToken(shape.token)}). Your runs now bill Acuvo credits.\n`);
1139
+ /**
1140
+ * ⚠️ SAY SO IF THE FILE COULD NOT BE LOCKED DOWN. `writeAccount` reports
1141
+ * whether it managed to restrict permissions; on a filesystem that cannot
1142
+ * (a Windows share, some mounts) the credential is readable by other users
1143
+ * of the machine. Staying silent would be us deciding on the user's behalf
1144
+ * that it did not matter to them.
1145
+ */
1146
+ if (wrote.restricted === false) {
1147
+ process.stderr.write(`⚠️ ${wrote.note ?? `could not restrict permissions on ${wrote.path} — other users of this machine may be able to read it.`}\n`);
1148
+ }
1149
+ return EXIT_OK;
1150
+ }
1151
+
1152
+ if (life.doctor) {
1153
+ const report = await runDoctor({ root, allowRun: opts.allowRun, maxRounds: opts.maxRounds, skipNetwork: opts.offline === true });
1154
+ if (opts.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1155
+ else process.stdout.write(`${formatDoctor(report, { paint: createPainter(colourEnabled()) })}\n`);
1156
+ return report.ok ? EXIT_OK : EXIT_FAILED;
1157
+ }
1158
+
1159
+ /**
1160
+ * ── ⭐⭐ `--replay` / `--diff` — MAKE A RUN DEBUGGABLE ─────────────────────
1161
+ *
1162
+ * ⚠️ IT EXECUTES NOTHING AND WRITES NOTHING, and the document says so in a
1163
+ * field (`executed: false`) rather than only in prose. A "replay" that
1164
+ * re-ran the tool calls would be a command run twice by someone who typed it
1165
+ * once — the same invariant `--resume` protects, and for the same reason.
1166
+ *
1167
+ * ⚠️ Above the key check: reading a directory this tool wrote needs no
1168
+ * account, and a machine whose key expired is precisely the machine whose
1169
+ * operator wants to know what the last run got through before it died.
1170
+ */
1171
+ if (life.replay !== null) {
1172
+ const loaded = loadSession(root, life.replay);
1173
+ if (!loaded.ok) die(loaded.error, EXIT_USAGE);
1174
+ const paint = createPainter(colourEnabled());
1175
+ if (life.diff !== null) {
1176
+ const other = loadSession(root, life.diff);
1177
+ if (!other.ok) die(other.error, EXIT_USAGE);
1178
+ const d = diffRuns(loaded.session, other.session);
1179
+ if (!d.ok) die(d.error, EXIT_FAILED);
1180
+ process.stdout.write(opts.json ? `${JSON.stringify(d, null, 2)}\n` : formatDiff(d, { paint }));
1181
+ return EXIT_OK;
1182
+ }
1183
+ const replayed = replaySession(loaded.session);
1184
+ if (!replayed.ok) die(replayed.error, EXIT_FAILED);
1185
+ if (opts.json) process.stdout.write(`${JSON.stringify(replayed, null, 2)}\n`);
1186
+ else {
1187
+ /**
1188
+ * ⚠️ AN UNKNOWN `--only` THROWS OUT OF `filterSteps`, BY DESIGN — it
1189
+ * names the specs that exist rather than silently showing everything.
1190
+ * Caught here so a typo is a usage error with a sentence, not a stack
1191
+ * trace that reads like a bug in acuvo.
1192
+ */
1193
+ let text;
1194
+ try {
1195
+ text = formatTimeline(replayed, { paint, filter: life.only ?? undefined });
1196
+ } catch (e) {
1197
+ die(`${e?.message ?? e}`, EXIT_USAGE);
1198
+ }
1199
+ process.stdout.write(text);
1200
+ }
1201
+ return EXIT_OK;
1202
+ }
1203
+ if (life.diff !== null) {
1204
+ die('--diff compares two runs, so it needs both: acuvo --replay <a> --diff <b>.', EXIT_USAGE);
1205
+ }
1206
+ if (life.only !== null) {
1207
+ die('--only narrows a replay, so it needs one: acuvo --replay <id> --only refusals.', EXIT_USAGE);
1208
+ }
1209
+
1210
+ /**
1211
+ * ── ⭐⭐ `--design <file.html>` — RENDER IT, LOOK AT IT, SAY WHAT IS WRONG ──
1212
+ *
1213
+ * The design loop without the agent: one pass, no model call, no completion
1214
+ * spent. It is above the key check because it never talks to a model — this
1215
+ * is the browser and the judgement, not the writer.
1216
+ *
1217
+ * ⚠️ AN EXIT CODE THAT MEANS SOMETHING. 0 = the page was looked at and
1218
+ * nothing was found. 1 = either the look failed or the page has findings.
1219
+ * "Could not look" is never reported as "the page is fine": `trustworthy`
1220
+ * carries that distinction into the JSON, and the verdict carries it into the
1221
+ * prose.
1222
+ */
1223
+ if (life.design !== null) {
1224
+ const pass = await designPass(root, life.design, { dryRun: opts.dryRun });
1225
+ if (opts.json) {
1226
+ process.stdout.write(`${JSON.stringify(pass, null, 2)}\n`);
1227
+ return pass.ok && (pass.findings?.length ?? 0) === 0 ? EXIT_OK : EXIT_FAILED;
1228
+ }
1229
+ const lines = formatDesignPass(pass, { root });
1230
+ process.stdout.write(`${lines.join('\n')}\n`);
1231
+ if (!pass.ok && pass.error) process.stderr.write(` ${pass.error}\n`);
1232
+ return pass.ok && (pass.findings?.length ?? 0) === 0 ? EXIT_OK : EXIT_FAILED;
1233
+ }
1234
+
1235
+ /**
1236
+ * ── ⭐⭐ `--lease <path>` — CLAIM THE FILES BEFORE ANYTHING IS SPENT ───────
1237
+ *
1238
+ * ⚠️ ABOVE THE KEY CHECK, AND THAT IS NOT AN ACCIDENT. "Somebody else has
1239
+ * that file" is the answer the user needs FIRST — before a model is chosen,
1240
+ * before a banner is printed, and certainly before a completion is bought.
1241
+ * A run that discovers the contention after writing three files has already
1242
+ * done the damage the lease exists to prevent.
1243
+ *
1244
+ * ⚠️ BELOW THE READ-ONLY COMMANDS, equally deliberately: `--doctor`,
1245
+ * `--replay`, `--design` and `acuvo leases` write nothing to the workspace, so
1246
+ * taking a write lease for them would block a colleague for no reason.
1247
+ *
1248
+ * ⚠️ RELEASED ON `exit`, WHICH COVERS EVERY PATH OUT OF THIS PROCESS —
1249
+ * `die()`, the ordinary return, and a throw caught by the handler at the
1250
+ * bottom. Releasing only at the end of the happy path is how a crashed
1251
+ * terminal leaves a file locked and the person at the next desk concludes the
1252
+ * feature is broken.
1253
+ *
1254
+ * ⚠️ AND THE ALL-OR-NOTHING IS `acquireAll`'s, not ours: it takes every path
1255
+ * in a fixed global order or gives back the ones it took. A terminal holding
1256
+ * three of five files and waiting on the fourth is a stall nobody can
1257
+ * diagnose.
1258
+ */
1259
+ let held = { ok: true, leases: [], warnings: [] };
1260
+ /** Set by the heartbeat below. Non-null means another terminal took a file. */
1261
+ let leaseLost = null;
1262
+ if (opts.lease.length > 0) {
1263
+ const holder = opts.holder ?? `pid-${process.pid}`;
1264
+ held = acquireAll(root, { paths: opts.lease, holder, ttlMs: DEFAULT_TTL_MS });
1265
+ if (!held.ok) {
1266
+ const who = held.heldBy ? ` — held by ${held.heldBy}` : '';
1267
+ die(`${held.error}${who}\n\nRun \`acuvo leases\` to see who holds what.`, EXIT_FAILED);
1268
+ }
1269
+ for (const w of held.warnings) process.stderr.write(` ! ${w}\n`);
1270
+ process.on('exit', () => { try { releaseAll(held.leases); } catch { /* exiting anyway */ } });
1271
+ (opts.json ? process.stderr : process.stdout).write(
1272
+ ` · leased ${held.leases.length} path${held.leases.length === 1 ? '' : 's'} as ${holder}\n`,
1273
+ );
1274
+ }
1275
+
1276
+ // ⚠️ THE KEY IS CHECKED BEFORE THE WORKSPACE IS TOUCHED. Discovering the
1277
+ // configuration is missing AFTER walking a large tree is a slower way to
1278
+ // deliver the same message, and on a big repo it reads as a hang.
1279
+ const config = readModelConfig(process.env);
1280
+ if (!config.configured) die(MISSING_KEY_MESSAGE, EXIT_UNCONFIGURED);
1281
+ if (opts.model) config.model = opts.model;
1282
+
1283
+ /**
1284
+ * ── ⭐⭐ AUTOMATIC LEASING — WHAT MAKES `--lease` A GUARANTEE ──────────────
1285
+ *
1286
+ * The import comment above states the limit this closes: an agent does not
1287
+ * know which files it will write until it writes them, so a DECLARED lease
1288
+ * protects only what the user correctly predicted. This claims each path at
1289
+ * the moment it is written.
1290
+ *
1291
+ * ⚠️ ON BY DEFAULT, and that is a considered call rather than an oversight.
1292
+ * It refuses ONLY when another live terminal provably holds the exact path;
1293
+ * with one terminal open there is no conflict to find, so it is invisible.
1294
+ * The alternative — off unless asked — protects nobody, because the people
1295
+ * who most need it are the ones who did not think about it. `--no-auto-lease`
1296
+ * turns it off, and an infrastructure failure degrades to the old behaviour
1297
+ * rather than blocking work (see lib/auto-lease.mjs).
1298
+ */
1299
+ /**
1300
+ * ⚠️ A WINDOW WITH NO CEILING MEASURES NOTHING. `--budget-window 7d` on its
1301
+ * own reads like a spend limit and is not one — the kind of flag that makes
1302
+ * somebody believe they are protected. Refused rather than ignored.
1303
+ */
1304
+ if (opts.budgetWindow && opts.fleetBudgetUsd === null) {
1305
+ die('--budget-window sets the period --fleet-budget is measured over, so it needs one. Try: --fleet-budget 5.00 --budget-window 7d', EXIT_USAGE);
1306
+ }
1307
+
1308
+ const claimer = opts.autoLease
1309
+ ? createPathClaimer(root, { holder: opts.holder ?? `pid-${process.pid}` })
1310
+ : null;
1311
+ if (claimer) process.on('exit', () => { try { claimer.releaseAll(); } catch { /* exiting anyway */ } });
1312
+
1313
+ /**
1314
+ * ── ⭐⭐ THE CHECKPOINT JOURNAL FOR THIS RUN ────────────────────────────────
1315
+ *
1316
+ * ⚠️ `null` UNDER `--dry-run`, and that is not an optimisation. `--help`
1317
+ * promises a dry run "touches nothing"; a preview that created
1318
+ * `.acuvo/checkpoints/` and copied files into it would have broken that
1319
+ * promise to save an undo for a run that never happened. `writeFile` also
1320
+ * returns before recording in dry-run mode — belt and braces, because the two
1321
+ * halves of that promise live in two files.
1322
+ *
1323
+ * ⚠️ AND NOTHING IS CREATED UNTIL THE FIRST MUTATION. Opening it is free; a
1324
+ * run that answers a question leaves no directory behind.
1325
+ */
1326
+ const journal = (opts.checkpoint && !opts.dryRun)
1327
+ ? openJournal(root, { task: opts.task || null })
1328
+ : null;
1329
+
1330
+ const executor = createLocalExecutor(root, {
1331
+ dryRun: opts.dryRun,
1332
+ claimPath: claimer ? (p) => claimer.claim(p) : null,
1333
+ journal,
1334
+ /**
1335
+ * ⭐ WHO THIS TERMINAL IS — and the plan ledger keys on it. Measured with
1336
+ * two terminals in one checkout: terminal 2 could not plan at all (the
1337
+ * workspace already had terminal 1's plan), was invited by the refusal to
1338
+ * DESTROY it with `replace:true`, and had every round prefixed with a
1339
+ * banner describing terminal 1's task. `plan_step` from terminal 2 marked
1340
+ * "port auth" done — work it never did.
1341
+ *
1342
+ * ⚠️ `opts.holder` is null unless the user typed `--holder`, and that is the
1343
+ * whole compatibility story: a single terminal keeps `.acuvo/plan.json` and
1344
+ * keeps `--resume`, while the seven-terminal case is exactly the case where
1345
+ * a holder is already being named for the leases.
1346
+ */
1347
+ holder: opts.holder ?? null,
1348
+ });
1349
+
1350
+ /**
1351
+ * ⚠️ THE BANNER SAYS WHETHER IT CAN EXECUTE, BEFORE IT DOES. A tool that may
1352
+ * run commands on your machine has to say so on the line above the first one
1353
+ * it runs — not in a README, and not after the fact. `--dry-run` and
1354
+ * `--no-run` are the two ways to see the same line say it cannot.
1355
+ */
1356
+ // Interactive mode needs no task; the loop supplies each one.
1357
+ const canRun = opts.allowRun && !opts.dryRun && opts.maxRounds > 1;
1358
+ /**
1359
+ * ⚠️⭐ `--shell` SAYS ITSELF BACK, EVERY RUN, IN THE FIRST LINE ON SCREEN.
1360
+ * The default banner's "may run: node, npm test, …" is a promise; under
1361
+ * `--shell` that promise is void, and a banner still reciting the old list
1362
+ * would be actively misleading — the operator would read the safe sentence
1363
+ * while the unsafe thing happened. A mode that removes a guarantee has to be
1364
+ * impossible to have forgotten you enabled.
1365
+ */
1366
+ const mode = opts.dryRun
1367
+ ? 'DRY RUN (nothing written, nothing run)'
1368
+ : canRun
1369
+ ? (opts.shell
1370
+ ? `${opts.maxRounds} rounds · ⚠ SHELL MODE — may run ANY program, with your privileges`
1371
+ : `${opts.maxRounds} rounds · may run: node, npm test, npm run, npx vitest, tsc`)
1372
+ : `${opts.maxRounds === 1 ? 'single round' : `${opts.maxRounds} rounds`} · will NOT run anything`;
1373
+ /**
1374
+ * ⚠️ THE BANNER GOES TO STDERR UNDER `--json` TOO, and forgetting it is what
1375
+ * broke the first test of this flag: one friendly line at the top made the
1376
+ * whole document unparseable. "Everything human goes to stderr" has to mean
1377
+ * EVERYTHING — including the parts written before anyone thought about JSON.
1378
+ */
1379
+ /**
1380
+ * ⚠️ THE ROOT IS SHORTENED, NOT DROPPED. It printed as a 100+ character
1381
+ * absolute path and wrapped the one line whose whole job is to orient you
1382
+ * before anything happens — but WHICH directory this run will write to is
1383
+ * exactly the fact a banner exists to state, so it stays, shortened and with
1384
+ * any elision marked. See `shortenRoot`.
1385
+ */
1386
+ const banner = `acuvo · ${config.model} · ${shortenRoot(executor.root)}\n · ${mode}\n`;
1387
+ if (opts.json) process.stderr.write(banner);
1388
+ else process.stdout.write(banner);
1389
+
1390
+ /**
1391
+ * ── ⭐⭐ `--resume` / `--continue` — THE RECOVERY THE ROUND CAP NEEDS ───────
1392
+ *
1393
+ * `stoppedBecause: 'round-cap'` is the commonest way a real task ends, and
1394
+ * until now the only recovery was to retype the prompt and pay for the entire
1395
+ * gather a second time. Resume rebuilds the conversation from the saved record
1396
+ * and hands it to `runSession` as `priorMessages` — the exact path interactive
1397
+ * chat already uses, so nothing new happens inside the loop.
1398
+ *
1399
+ * ⚠️⚠️ IT RE-RUNS NOTHING, AND THAT IS THE INVARIANT TO PROTECT. The record
1400
+ * holds the OUTPUT of the commands the earlier process ran; `resumeMessages`
1401
+ * appends a note saying so in plain words. Nothing here replays a tool call,
1402
+ * and nothing here may ever start doing so — a resume that re-executes is a
1403
+ * command run twice by a user who typed it once.
1404
+ *
1405
+ * ⚠️ A RESUME WITH NO NEW INSTRUCTION IS A VALID REQUEST ("carry on"), so the
1406
+ * original task is reused when none is given. That is why `task` becomes a
1407
+ * local rather than staying `opts.task`.
1408
+ */
1409
+ /**
1410
+ * ── ⭐⭐ `--task-audio` — TALK TO YOUR TERMINAL, BUT CONFIRM FIRST ─────────
1411
+ *
1412
+ * ⚠️⚠️ IT NEVER ACTS ON WHAT IT HEARD WITHOUT SHOWING YOU. `taskFromAudio`
1413
+ * returns `needsConfirmation: true` unconditionally, and that is the right
1414
+ * unconditional: the dangerous case is not the transcript the service flagged
1415
+ * as uncertain, it is the one it got confidently wrong. Enter cancels. That
1416
+ * keystroke is the whole thing standing between a mis-heard word and a
1417
+ * file-writing agent.
1418
+ *
1419
+ * ⚠️ BELOW THE WORKSPACE `.env` LOAD, DELIBERATELY. Above it,
1420
+ * MODAL_TRANSCRIBE_URL from the workspace `.env` is invisible and the
1421
+ * capability reports itself absent on a machine where it works — the exact
1422
+ * bug that loader was added to fix.
1423
+ *
1424
+ * ⚠️ AND EVERY HUMAN LINE, INCLUDING THE PROMPT, GOES TO STDERR UNDER
1425
+ * `--json`. readline's `output` is stderr here for precisely that reason.
1426
+ */
1427
+ let voiceTask = null;
1428
+ if (voice.taskAudio) {
1429
+ const say = (t) => (opts.json ? process.stderr : process.stdout).write(t);
1430
+ const heard = await taskFromAudio(root, voice.taskAudio);
1431
+ if (!heard.ok) die(heard.error, EXIT_UNCONFIGURED);
1432
+ say(`${confirmationLines(heard).join('\n')}\n`);
1433
+ const tty = process.stdin.isTTY === true;
1434
+ let answer = null;
1435
+ if (tty && !voice.yes && !opts.json) {
1436
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
1437
+ answer = await new Promise((r) => rl.question(' > ', (l) => { rl.close(); r(l); }));
1438
+ }
1439
+ const decided = decideTranscript({ task: heard.task, answer, tty, json: opts.json, assumeYes: voice.yes });
1440
+ if (!decided.run) die(` ${decided.why}\n`, EXIT_USAGE);
1441
+ voiceTask = decided.task;
1442
+ }
1443
+
1444
+ let task = voiceTask ?? opts.task;
1445
+
1446
+ /**
1447
+ * ── ⭐⭐ `--claim` — SEVEN TERMINALS, ONE LIST, NOBODY DOING THE SAME JOB ────
1448
+ *
1449
+ * The instruction comes off the shared board instead of being typed. Seven
1450
+ * windows each running `acuvo --holder tN --claim` split one list of work
1451
+ * with nobody duplicating anyone — which is the whole "seven workers" idea,
1452
+ * and the last piece of it that did not exist.
1453
+ *
1454
+ * ⚠️ THE CLAIM IS A LEASE, so it is released on exit exactly like every other
1455
+ * lease — a worker that crashes returns its task to the board after the TTL
1456
+ * rather than parking it forever.
1457
+ *
1458
+ * ⚠️ AN EMPTY BOARD IS EXIT 0, NOT AN ERROR. Seven terminals finishing a list
1459
+ * means six of them find nothing left, and a fleet that reports six failures
1460
+ * every time it completes its work would train its owner to ignore the exit
1461
+ * code — which is the one signal this package asks people to gate on.
1462
+ */
1463
+ let claimed = null;
1464
+ if (opts.claim) {
1465
+ if (task) die('--claim takes the task from the board, so do not also type one. Use one or the other.', EXIT_USAGE);
1466
+ if (!opts.holder) die('--claim needs --holder, so the board can say which terminal is doing what. Try: acuvo --holder t1 --claim', EXIT_USAGE);
1467
+ claimed = boardClaim(root, { holder: opts.holder });
1468
+ if (!claimed.ok) {
1469
+ const out = claimed.empty ? process.stdout : process.stderr;
1470
+ out.write(` ${claimed.error}
1471
+ `);
1472
+ return claimed.empty ? EXIT_OK : EXIT_FAILED;
1473
+ }
1474
+ task = claimed.task;
1475
+ (opts.json ? process.stderr : process.stdout).write(` claimed ${claimed.id} as ${opts.holder} — ${claimed.task}
1476
+ `);
1477
+ process.on('exit', () => { try { if (claimed?.lease) releaseAll([claimed.lease]); } catch { /* exiting anyway */ } });
1478
+ }
1479
+
1480
+ let priorMessages = null;
1481
+ /**
1482
+ * ── ⭐⭐⭐ ONE STICKY KEY FOR THIS WHOLE CONVERSATION, ACROSS PROCESSES ────
1483
+ *
1484
+ * OpenRouter routes every request carrying the same `session_id` back to the
1485
+ * same upstream SERVER. That is the half of the prompt-cache story our own
1486
+ * prefix work could never reach: the prefix was already 99.9% byte-identical,
1487
+ * and the misses were the ROUTING — a cache lives on one machine and a
1488
+ * provider is a fleet.
1489
+ *
1490
+ * ⚠️ AND THE MEASURED FAILURE WAS BETWEEN PROCESSES, NOT WITHIN THEM: four
1491
+ * consecutive cold runs went 65 / 98 / 31 / 98, because each new process
1492
+ * rolled the dice again. So a RESUMED run must reuse the SAVED id — a fresh
1493
+ * key here would land on a fresh machine and throw away the warm cache that
1494
+ * the conversation being resumed had already paid to build.
1495
+ */
1496
+ let stickyKey = `acuvo-${randomUUID()}`;
1497
+ if (resumeRequested) {
1498
+ if (life.resume !== null && life.continueLatest) {
1499
+ die('--resume <id> and --continue both name a run to carry on, and they disagree. Pass one: --continue takes the most recent, --resume takes the id you name.', EXIT_USAGE);
1500
+ }
1501
+ if (opts.parallel) {
1502
+ die('--resume carries on ONE conversation; --parallel starts several fresh ones. Run the resume by itself.', EXIT_USAGE);
1503
+ }
1504
+ if (opts.issue !== null) {
1505
+ die('--issue starts a fresh branch and a fresh conversation, so there is nothing to resume. Drop one of --issue / --resume.', EXIT_USAGE);
1506
+ }
1507
+
1508
+ let id = life.resume;
1509
+ if (life.continueLatest) {
1510
+ const listed = listSessions(root, { limit: 50 });
1511
+ if (!listed.ok) die(listed.error, EXIT_FAILED);
1512
+ /**
1513
+ * ⚠️ THE MOST RECENT *RESUMABLE* ONE, NOT THE MOST RECENT ONE. A run that
1514
+ * died before round 1 is saved and listable but holds no conversation;
1515
+ * picking it would answer "carry on" with "there is nothing to carry",
1516
+ * naming a session the user never chose.
1517
+ */
1518
+ const latest = listed.sessions.find((s) => s.resumable);
1519
+ if (!latest) {
1520
+ die('nothing to continue — no run in this workspace saved a conversation. Run `acuvo --sessions` to see what is there, or start a fresh task.', EXIT_USAGE);
1521
+ }
1522
+ id = latest.id;
1523
+ }
1524
+
1525
+ const resumed = resumeMessages(root, id);
1526
+ if (!resumed.ok) die(resumed.error, EXIT_USAGE);
1527
+ priorMessages = resumed.messages;
1528
+ // ⭐ The saved id IS the conversation, so it is the routing key too. This
1529
+ // line is what makes stickiness survive closing the terminal.
1530
+ stickyKey = `acuvo-${resumed.id ?? id}`;
1531
+ if (!task) task = resumed.task;
1532
+ if (!task) {
1533
+ die(`run ${resumed.id} recorded no task text, so "carry on" has nothing to carry. Say what to do next: acuvo --resume ${resumed.id} "<the next step>"`, EXIT_USAGE);
1534
+ }
1535
+ // ⚠️ STDERR UNDER --json, like every other human line in this file.
1536
+ const warn = resumed.rootChanged ? ' ⚠️ it was recorded in a DIFFERENT workspace' : '';
1537
+ (opts.json ? process.stderr : process.stdout).write(
1538
+ ` · resuming ${resumed.id} — ${priorMessages.length} messages restored, nothing re-run${warn}\n`,
1539
+ );
1540
+
1541
+ /**
1542
+ * ── ⚠️⚠️ A RESUMED RUN USED TO GET A WHOLE FRESH BUDGET ──────────────────
1543
+ *
1544
+ * `createBudget` starts at `spentUsd = 0` every time, so
1545
+ * `acuvo --budget 0.50 …` followed by `acuvo --resume <id> --budget 0.50`
1546
+ * spent a DOLLAR while the person believed they had capped it at fifty
1547
+ * cents. `budget.mjs` flagged this against itself; nothing had closed it.
1548
+ *
1549
+ * ⭐ THE FIX IS SUBTRACTION, NOT A NEW PARAMETER. The limit is lowered by
1550
+ * what the earlier run already spent, so `budget.mjs` stays pure (data in,
1551
+ * data out, no disk) and `turn.mjs` is untouched. One task, one ceiling,
1552
+ * however many processes it takes.
1553
+ *
1554
+ * ⚠️ "$0.50" IS AMBIGUOUS ON A RESUME — is it fifty cents MORE, or fifty
1555
+ * cents TOTAL? Total is the reading that cannot silently overspend, so it
1556
+ * is the one taken, and it is SAID OUT LOUD rather than assumed. A person
1557
+ * who meant "more" can pass a bigger number; a person who meant "total" and
1558
+ * got "more" has no way to find out until the bill.
1559
+ */
1560
+ if (opts.budgetUsd) {
1561
+ /**
1562
+ * ⚠️ `loadSession` returns `{ ok, session }` — the cost lives at
1563
+ * `session.usage.cost`. My first draft read `.record.usage.cost` and
1564
+ * silently found `undefined`, which coerces to 0 and would have made this
1565
+ * whole guard a no-op that LOOKED like it worked. Verified against a real
1566
+ * record: `{"cost":0.000250116,"total_tokens":10218}`.
1567
+ */
1568
+ const prior = loadSession(root, id);
1569
+ const spent = Number(prior?.session?.usage?.cost ?? 0);
1570
+ if (Number.isFinite(spent) && spent > 0) {
1571
+ const left = opts.budgetUsd - spent;
1572
+ const money = (n) => (n < 0.01 ? `${(n * 100).toFixed(2)}c` : `$${n.toFixed(4)}`);
1573
+ if (left <= 0) {
1574
+ die(
1575
+ `that run already spent ${money(spent)}, which is at or over the ${money(opts.budgetUsd)} budget. `
1576
+ + `Raise it (--budget ${money(spent * 2)}) if you want it to carry on.`,
1577
+ EXIT_USAGE,
1578
+ );
1579
+ }
1580
+ opts.budgetUsd = left;
1581
+ (opts.json ? process.stderr : process.stdout).write(
1582
+ ` · budget ${money(opts.budgetUsd + spent)} total — ${money(spent)} already spent, ${money(left)} left for this run\n`,
1583
+ );
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ /**
1589
+ * One turn, shared by the one-shot path, `--issue` and the interactive loop.
1590
+ *
1591
+ * ⭐ THE PERSISTENCE HANGS OFF THIS ONE FUNCTION ON PURPOSE. Every path that
1592
+ * completes a turn goes through here, so "a run is saved and logged" is true
1593
+ * by construction rather than by remembering to repeat two calls at four
1594
+ * return sites — which is exactly how one of them would end up unlogged.
1595
+ */
1596
+ /**
1597
+ * ⚠️ `over` EXISTS SO THE ESCALATION LADDER CAN REUSE THIS FUNNEL RATHER THAN
1598
+ * GROW A SECOND ONE. Every durable record — the session, the audit log, the
1599
+ * spoken verdict — hangs off `oneTurn`, and the `--best-of` branch above
1600
+ * already proves what a parallel call site costs: it re-implements the
1601
+ * `runSession` arguments and is the one path that persists nothing. A rung of
1602
+ * the ladder needs a different workspace and a smaller budget, and nothing
1603
+ * else, so those are the only two things overridable.
1604
+ */
1605
+ /**
1606
+ * ── ⚠️⚠️ THE CEILING WAS PER TURN AND IS SOLD AS PER RUN ──────────────────
1607
+ *
1608
+ * `runChat` loops calling `oneTurn`, and `oneTurn` handed out
1609
+ * `opts.budgetUsd` FRESH EVERY TIME. A forty-turn conversation therefore
1610
+ * permitted forty times the number the user agreed to — $0.80 against a
1611
+ * stated $0.02 — while `--help` and the README both call it the run's ceiling.
1612
+ *
1613
+ * ⭐ The one-shot path was always right (one turn, nothing to accumulate) and
1614
+ * the RESUME path already subtracts prior spend. This is the same subtraction
1615
+ * for the turn loop, using the same pure helper, so the two cannot drift.
1616
+ */
1617
+ let sessionSpentUsd = 0;
1618
+ /**
1619
+ * ── ⭐ WHAT ACTUALLY SERVED, FOR `/model` ─────────────────────────────────
1620
+ *
1621
+ * `aggregateProviders` already computes this per turn and `formatSummary`
1622
+ * already prints it once, at the end. But routing is the question people ask
1623
+ * in the MIDDLE of a session — "why is this costing more than it did" — and
1624
+ * `/model` answered with the configured name only.
1625
+ *
1626
+ * ⚠️ THE EXPENSIVE CASE IS SILENT BY CONSTRUCTION. `pinFellBack` means a
1627
+ * later name in the pin served the round: a cold prefix cache billed at up to
1628
+ * 4.6x, measured, with no error anywhere. A user cannot ask about a number
1629
+ * they were never shown.
1630
+ */
1631
+ let lastProviders = null;
1632
+
1633
+ /**
1634
+ * ⚠️ SESSION-SCOPED, NOT TURN-SCOPED, because the EXIT CODE is a property of
1635
+ * the process and `verdictExit` runs long after the arming has been disposed.
1636
+ * Interactive mode never reads it (a conversation always exits 0 — see the
1637
+ * banner comment below), which is correct: there, Ctrl-C returns you to the
1638
+ * prompt and the session carries on.
1639
+ */
1640
+ let interruptedRun = false;
1641
+
1642
+ /**
1643
+ * ⚠️ SET BY THE ROUND-BOUNDARY HOOK INSIDE `oneTurn`, READ BY `steerable`
1644
+ * AFTER IT RETURNS. It is a variable rather than a return field because
1645
+ * `oneTurn`'s return value is the session outcome — a shape `--json`,
1646
+ * `formatSummary`, the audit log and `sessionFailed` all consume — and
1647
+ * smuggling a CLI-local flag into it would put a field in the machine
1648
+ * contract for the convenience of two lines of control flow.
1649
+ */
1650
+ let pendingSteer = null;
1651
+ /**
1652
+ * ⚠️⚠️ WHEN THIS TURN STARTED, SO A LEFTOVER STEER CANNOT HIJACK IT. Found by
1653
+ * running it: a steer written just after the LAST round boundary is never
1654
+ * picked up and the file survives the run — so the next `acuvo` in that
1655
+ * workspace would consume it at round one and apply an instruction about
1656
+ * yesterday's task to today's. See `takeSteer`'s `newerThan`.
1657
+ *
1658
+ * ⭐ THE TURN, NOT THE SEGMENT. A steer written during segment 1 that arrived
1659
+ * too late for it is still about this turn and must reach segment 2; keying
1660
+ * off the segment start would throw exactly that case away.
1661
+ */
1662
+ let turnStartedAt = 0;
1663
+
1664
+ const oneTurn = async (turnTask, priorTurnMessages, over = {}) => {
1665
+ /**
1666
+ * ⚠️ ONLY WHEN THIS IS A REAL TURN, NOT A LADDER RUNG. `over.budgetUsd` is a
1667
+ * slice `escalate.allocate()` already carved out of the session total, so
1668
+ * subtracting session spend from it would charge the same dollars twice and
1669
+ * starve rung three of a budget it was correctly allocated.
1670
+ */
1671
+ if (over.budgetUsd === undefined) {
1672
+ const room = remainingForTurn(opts.budgetUsd, sessionSpentUsd, { limitIsDefault: opts.budgetExplicit !== true, limitSource: opts.budgetSource ?? null });
1673
+ if (!room.ok) {
1674
+ const sentence = room.message;
1675
+ (opts.json ? process.stderr : process.stdout).write(`\n ⛔ ${sentence}\n`);
1676
+ // ⚠️ `error` AND `message`, because formatSummary prints `error` for a
1677
+ // failed run and printing "✖ undefined" is worse than the refusal.
1678
+ return { ok: false, stage: 'budget', stoppedBecause: 'limit-reached', error: sentence, message: sentence };
1679
+ }
1680
+ if (room.remainingUsd !== null) over = { ...over, budgetUsd: room.remainingUsd };
1681
+ }
1682
+
1683
+ /**
1684
+ * ── ⭐⭐ ARMED PER TURN, DISPOSED IN A `finally` ─────────────────────────
1685
+ *
1686
+ * ⚠️ PER TURN IS THE WHOLE POINT. `runChat` calls this function once per
1687
+ * turn for the life of a conversation; a handler left registered by turn 3
1688
+ * would swallow the Ctrl-C pressed during turn 9 — the user would press,
1689
+ * see the notice, and watch turn 9 keep going, because the signal that was
1690
+ * aborted belongs to a controller nobody is reading any more. Hence the
1691
+ * `finally` below, and hence the ownership guard in `onFirstInterrupt`.
1692
+ *
1693
+ * ⚠️ The notice goes to STDERR under `--json`, like every other human line
1694
+ * on this path: stdout carries exactly one object and one friendly sentence
1695
+ * there breaks `| jq` for everybody.
1696
+ */
1697
+ /**
1698
+ * ⚠️ ONE CONTROLLER, TWO REASONS TO ABORT. `runSession` takes a single
1699
+ * signal, so Ctrl-C and steering necessarily share it — and they are told
1700
+ * apart by `gate.wasInterrupted()`, NOT by the abort reason. That matters
1701
+ * for the exit code: a steered run must exit on its verdict, and only a
1702
+ * genuine keypress may produce 130.
1703
+ */
1704
+ const controller = new AbortController();
1705
+ const gate = armInterrupt({
1706
+ controller,
1707
+ notify: (notice) => {
1708
+ interruptedRun = true;
1709
+ (opts.json ? process.stderr : process.stdout).write(`\n ⏹ ${notice}\n`);
1710
+ },
1711
+ });
1712
+
1713
+ let result;
1714
+ try {
1715
+ result = await runSession({
1716
+ sessionId: stickyKey,
1717
+ task: turnTask,
1718
+ priorMessages: priorTurnMessages,
1719
+ executor: over.executor ?? executor,
1720
+ config,
1721
+ /**
1722
+ * ⭐ THE WIRE. `runSession` has taken a `signal` since it landed and
1723
+ * NOTHING supplied one — the built-but-unreached defect this package
1724
+ * ships most often. This is the supplier.
1725
+ */
1726
+ signal: gate.signal,
1727
+ maxTokens: opts.maxTokens,
1728
+ timeoutMs: opts.timeoutMs,
1729
+ /**
1730
+ * ⚠️⚠️ THE THIRD OVERRIDE, AND IT EXISTS TO STOP `--max-rounds` BECOMING
1731
+ * A LIE. A steered turn runs as several segments; if each one were handed
1732
+ * the full `opts.maxRounds`, `--max-rounds 8` plus three steers would
1733
+ * quietly mean 32 rounds. `steerable` passes what is LEFT. Every other
1734
+ * caller omits it and is byte-identical.
1735
+ */
1736
+ maxRounds: over.maxRounds ?? opts.maxRounds,
1737
+ /**
1738
+ * ── ⭐⭐ THE TWO OVERRIDES `--plan`'s PROPOSAL PHASE NEEDS ─────────────
1739
+ *
1740
+ * ⚠️ `allowRun` IS AN OVERRIDE AND NOT A REPLACEMENT — `??`, so a run
1741
+ * that names neither is byte-identical to yesterday's. The proposal phase
1742
+ * passes `false`, and it has to: `toolNames` below decides what the model
1743
+ * is OFFERED, while `allowRun` is also read by the DISPATCHER. A model can
1744
+ * call a tool it was never shown (this package's own two-lock rule), so a
1745
+ * read-only phase that only narrowed the offer would still execute a
1746
+ * `run_command` the model guessed at.
1747
+ *
1748
+ * ⚠️ `toolNames` IS OMITTED UNLESS ASKED FOR, not passed as null. Passing
1749
+ * null is the same as omitting it today, but `runSession` documents null
1750
+ * as "compute the offer from the round budget" and pinning that
1751
+ * equivalence here would make a future change to the default silently
1752
+ * bypass every caller.
1753
+ */
1754
+ allowRun: over.allowRun ?? (opts.allowRun && !opts.dryRun),
1755
+ ...(Array.isArray(over.toolNames) ? { toolNames: over.toolNames } : {}),
1756
+ shell: opts.shell,
1757
+ commandTimeoutMs: opts.commandTimeoutMs,
1758
+ /**
1759
+ * ── ⭐⭐ THE TWO OPTIONS THAT MOVE THE WALL FROM A COUNTER TO MONEY ────
1760
+ * `null` / `false` are what a caller who typed neither flag gets, and both
1761
+ * are no-ops inside the loop — see the option docs in `runSession`.
1762
+ */
1763
+ /**
1764
+ * ⚠️ THE RUNG'S SLICE WINS OVER THE WHOLE `--budget`, and that override is
1765
+ * the entire reason the ladder can escalate at all: handed the full
1766
+ * ceiling, rung one is entitled to spend it and there is nothing left to
1767
+ * try harder with. `escalate.allocate()` computes the slice.
1768
+ */
1769
+ budgetUsd: over.budgetUsd ?? opts.budgetUsd,
1770
+ /**
1771
+ * ⚠️ A ceiling the user never chose has to say so when it stops, and name
1772
+ * the flag that raises it. See DEFAULT_BUDGET_USD in budget.mjs.
1773
+ */
1774
+ budgetIsDefault: opts.budgetExplicit !== true,
1775
+ /**
1776
+ * ⭐ THE FLEET CEILING. `null` unless `--fleet-budget` was given, and
1777
+ * `createFleetGate` returns `null` for that case, so this is inert for
1778
+ * everyone who has not asked for it — no gate, no disk read.
1779
+ *
1780
+ * ⚠️ Built from `root`, not the process cwd: the ledger being summed has
1781
+ * to be the one every OTHER terminal on this workspace is appending to,
1782
+ * and `--dir` is exactly how a terminal ends up somewhere else.
1783
+ */
1784
+ fleetGate: createFleetGate(root, { fleetLimitUsd: opts.fleetBudgetUsd, since: opts.budgetWindow }),
1785
+ /**
1786
+ * ── ⚠️⚠️ THE CONSENT QUESTION NOBODY WAS EVER ASKED ────────────────────
1787
+ *
1788
+ * `turn.mjs` has taken `mcpAsk` and `mcpInteractive` since MCP consent
1789
+ * shipped, and until now NOTHING in this package supplied either — three
1790
+ * references in the whole codebase, all three inside `turn.mjs` itself.
1791
+ *
1792
+ * ⭐ It failed CLOSED, so nothing was ever silently approved. But
1793
+ * `checkMcpConsent` refuses whenever it cannot ask, so a committed
1794
+ * `.mcp.json` could not be approved from a terminal AT ALL: the only way
1795
+ * through was `ACUVO_TRUST_MCP=1`. Being able to drive every MCP server
1796
+ * is half the two-way story, and it was gated behind an environment
1797
+ * variable on a question the user was standing right there to answer.
1798
+ *
1799
+ * ⚠️ `createAsker` returns null when stdin/stdout are not both TTYs, and
1800
+ * that null is deliberate — see `lib/prompt.mjs`. A CI run keeps exactly
1801
+ * the behaviour it has today: refused, with "there is no terminal here".
1802
+ */
1803
+ mcpAsk: asker,
1804
+ mcpInteractive: asker !== null,
1805
+ /**
1806
+ * ⚠️ THE RUNG'S MODEL WINS, and `over.model` is undefined unless
1807
+ * `ACUVO_MODEL_TIERS` is configured — so a run without tiers is
1808
+ * byte-identical to one from before this existed. See `model-tier.mjs`
1809
+ * for why a feature that can multiply a bill has to default to inert.
1810
+ */
1811
+ ...(over.model && over.model !== config.model ? { config: { ...config, model: over.model } } : {}),
1812
+ untilDone: opts.untilDone,
1813
+ // ⭐ The admin layer reaches the loop. OPEN_POLICY when no file exists.
1814
+ policy,
1815
+ // ⚠️ STREAMED, NOT BUFFERED. A bounded loop that prints only at the end is
1816
+ // indistinguishable from a hang for however long it takes, and the whole
1817
+ // value of watching a fix land is watching it land.
1818
+ /**
1819
+ * ⚠️ HUMAN OUTPUT GOES TO STDERR UNDER `--json`. A script piping to `jq`
1820
+ * must receive ONE object and nothing else; interleaving progress lines
1821
+ * into stdout makes the flag useless while appearing to work.
1822
+ */
1823
+ onEvent: (event) => {
1824
+ /**
1825
+ * ── ⭐⭐ THE ROUND BOUNDARY IS WHERE A STEER IS PICKED UP ───────────
1826
+ *
1827
+ * ⚠️ ABOVE the `over.quiet` return, and gated on an EXPLICIT opt-in
1828
+ * rather than on `!quiet`. `--best-of` attempts and escalation-ladder
1829
+ * rungs are quiet, and they are also automated retries of a decision
1830
+ * the user already made — one steer file consumed by whichever of
1831
+ * three parallel attempts reached a boundary first is a race with no
1832
+ * right answer. `steerable` is the only caller that sets the flag.
1833
+ *
1834
+ * ⚠️ ONCE PER SEGMENT (`pendingSteer === null`). The abort takes effect
1835
+ * at the NEXT boundary, so this hook fires again before the loop
1836
+ * breaks; without the guard the second read would consume a steer the
1837
+ * user wrote for the continuation and apply it to a run that is already
1838
+ * stopping.
1839
+ *
1840
+ * ⭐ `controller.abort` rather than a mid-round injection: the loop
1841
+ * returns cleanly with its transcript, and `steerable` restarts it with
1842
+ * the instruction as a real user message. That is what makes the steer
1843
+ * arrive at a boundary by construction instead of by care.
1844
+ */
1845
+ if (event.type === 'round-start' && over.steerable === true && pendingSteer === null) {
1846
+ const steer = takeSteer(root, { newerThan: turnStartedAt });
1847
+ if (steer) {
1848
+ pendingSteer = steer;
1849
+ /**
1850
+ * ⚠️⚠️ A STALE STEER MUST NOT COST A ROUND, AND THE FIRST VERSION
1851
+ * OF THIS CHARGED ONE. Measured on a live run: a leftover file
1852
+ * aborted the loop at round 1, the task was never attempted, and
1853
+ * the summary said "No files changed" — the run was destroyed by a
1854
+ * sentence about a different task. It is still consumed and still
1855
+ * reported (`steerable` prints it at the end, with the words), it
1856
+ * just does not touch the run it does not belong to.
1857
+ */
1858
+ if (!steer.stale) controller.abort(STEER_ABORT_REASON);
1859
+ }
1860
+ }
1861
+ /**
1862
+ * ⚠️ SILENT WHEN THE CALLER ASKS. Three best-of attempts share one
1863
+ * terminal, and the `--best-of` branch below learned this first:
1864
+ * "three interleaved round-by-round streams are unreadable". The
1865
+ * escalation ladder reuses this funnel, so the same rule has to be
1866
+ * reachable from here or its top rung reprints that mess.
1867
+ */
1868
+ if (over.quiet) return;
1869
+ /**
1870
+ * ── ⭐ THE HEARTBEAT, DRIVEN OFF THE ROUND BOUNDARY ─────────────────
1871
+ *
1872
+ * A lease has a TTL, and a model round can take a minute. Without this,
1873
+ * a working terminal starts looking stale to the others and its files
1874
+ * become reclaimable while it is still writing them.
1875
+ *
1876
+ * ⚠️ IT STILL DOES NOT ABORT THE ROUND, AND THAT IS NOW A CHOICE
1877
+ * RATHER THAN A LIMIT. This comment used to say `runSession` takes no
1878
+ * abort signal; it does now, and `gate.signal` above is one. What has
1879
+ * not been decided is whether a lost lease SHOULD stop the run — it is
1880
+ * a different event from a keypress with a different exit code (1, the
1881
+ * verdict, not 130 — the distinction `wasAbortedByInterrupt` draws),
1882
+ * and changing it here would quietly repurpose a mechanism the user
1883
+ * asked for a different reason. So the callback still records the loss,
1884
+ * says so immediately, and fails the PROCESS — see `leaseLost` at the
1885
+ * exit.
1886
+ *
1887
+ * ⚠️ Only when leases are held. `renewAll([])` is harmless but running
1888
+ * it on every round of every run would put filesystem work in the hot
1889
+ * path of the 99% of runs that never asked for a lease.
1890
+ */
1891
+ if (event.type === 'round-start' && held.leases.length > 0 && !leaseLost) {
1892
+ const beat = renewAll(held.leases);
1893
+ if (!beat.ok) {
1894
+ leaseLost = beat.lost;
1895
+ process.stderr.write(
1896
+ ` ✖ lost a file lease mid-run: ${beat.lost.map((l) => `${l.path} (${l.reason})`).join(', ')}\n`
1897
+ + ' another terminal may be writing these files. This run will exit non-zero.\n',
1898
+ );
1899
+ }
1900
+ }
1901
+ /**
1902
+ * ── ⭐⭐ DRIFT, ON THE ROUND IT HAPPENED, TO THE PERSON PAYING ───────
1903
+ *
1904
+ * `turn.mjs` emits `{ type: 'plan-drift' }` the round a distinct drift
1905
+ * is detected and appends the nudge to the conversation. `renderEvent`
1906
+ * has no case for that type and returns `[]`, so until now the whole
1907
+ * chain ended at the model: it was told, and the user was not.
1908
+ *
1909
+ * ⚠️ IT IS NOT `event.text`. That string is written FOR A MODEL — 400-odd
1910
+ * characters offering both exits and naming the verbs — and printing it
1911
+ * at a terminal is a paragraph per drift that a person has to parse to
1912
+ * find the three filenames that matter. `driftBannerLine` rebuilds one
1913
+ * line from the same evidence object, which is why it takes the verdict
1914
+ * rather than the rendered nudge.
1915
+ *
1916
+ * ⚠️ ONCE PER DISTINCT DRIFT, because the event is: `turn.mjs`'s
1917
+ * `nudged` set already keys on `drift.evidence.key`. A per-round repeat
1918
+ * would be the plan-banner mistake — a true sentence that becomes noise.
1919
+ *
1920
+ * ⚠️ AND IT NEVER STOPS ANYTHING. Nothing here changes the exit code or
1921
+ * the run. The verdict leans towards "on plan" by construction (see
1922
+ * plan-coherence.mjs on why every threshold resolves that way), so the
1923
+ * honest action is to say it out loud and let the human decide.
1924
+ */
1925
+ if (event.type === 'plan-drift') {
1926
+ const line = driftBannerLine(event.verdict ?? event.drift ?? null);
1927
+ if (line) (opts.json ? process.stderr : process.stdout).write(` ${line}\n`);
1928
+ return;
1929
+ }
1930
+ const lines = renderEvent(event);
1931
+ if (lines.length === 0) return;
1932
+ const text = `${lines.join('\n')}\n`;
1933
+ if (opts.json) process.stderr.write(text);
1934
+ else process.stdout.write(text);
1935
+ },
1936
+ });
1937
+ } finally {
1938
+ /**
1939
+ * ⚠️⚠️ THE ONE LINE THIS FEATURE CANNOT SURVIVE WITHOUT. Unregistering
1940
+ * here is what makes the SECOND Ctrl-C fatal (with nobody listening,
1941
+ * `exitIsDeferred` returns false and `turn.mjs`'s handler exits 130) and
1942
+ * what makes turn 9's Ctrl-C reach turn 9. In a `finally` because a
1943
+ * thrown provider error must not leave the process holding a disarmed
1944
+ * Ctrl-C — that is the state where the key does nothing at all.
1945
+ */
1946
+ gate.dispose();
1947
+ }
1948
+ persistRun(turnTask, result);
1949
+ /**
1950
+ * ── ⭐ `--say` — NARRATE THE VERDICT ──────────────────────────────────────
1951
+ *
1952
+ * ONE site covers one-shot, `--issue`, `--resume`/`--continue` AND the
1953
+ * interactive loop, because every one of them goes through `oneTurn`. Four
1954
+ * call sites is how one of them ends up silent.
1955
+ *
1956
+ * ⚠️ IT CAN NEVER FAIL THE RUN. `speakSummary` returns a reason; it does not
1957
+ * throw and it does not touch the exit code. The work is already on disk and
1958
+ * the exit code is a verification verdict, not a narration one — the same
1959
+ * rule the two durable records above obey.
1960
+ *
1961
+ * ⚠️ AND WE WRITE A .wav, WE DO NOT PLAY IT. Playing it means spawning an OS
1962
+ * binary past the command allowlist; the hint line is the command for your
1963
+ * platform, and the choice stays the user's.
1964
+ */
1965
+ if (voice.say) {
1966
+ const said = await speakSummary(root, result, { task: turnTask, enabled: true, dryRun: opts.dryRun });
1967
+ const out = opts.json ? process.stderr : process.stdout;
1968
+ if (said.spoken) out.write(` · verdict spoken → ${said.path}\n ${said.hint}\n`);
1969
+ else if (!said.ok) out.write(` · the verdict was not spoken: ${said.reason}\n`);
1970
+ }
1971
+
1972
+ /**
1973
+ * ⚠️ CHARGE THE SESSION, NOT JUST THE TURN. Without this the subtraction
1974
+ * above always subtracts zero and the ceiling stays per-turn — the whole
1975
+ * defect. Counted on EVERY turn including a ladder rung, because the dollars
1976
+ * left the account either way; what `over.budgetUsd` changes is which
1977
+ * allowance the turn draws from, never whether it was spent.
1978
+ */
1979
+ // ⚠️ Only when the turn named one. A transport that reports no routing
1980
+ // must not erase what the previous turn honestly measured.
1981
+ if (result?.providers) lastProviders = result.providers;
1982
+ const turnCost = Number(result?.usage?.cost ?? 0);
1983
+ if (Number.isFinite(turnCost) && turnCost > 0) sessionSpentUsd += turnCost;
1984
+
1985
+ return result;
1986
+ };
1987
+
1988
+ /**
1989
+ * ── ⭐⭐ ONE TURN, POSSIBLY IN SEVERAL SEGMENTS ────────────────────────────
1990
+ *
1991
+ * `oneTurn` is unchanged for everyone: it runs the loop once, saves the
1992
+ * session, writes the audit line, charges the session budget and speaks the
1993
+ * verdict. This wraps it so a user can redirect it mid-flight — write a line
1994
+ * into `.acuvo/steer.txt` and the run stops at the next round boundary,
1995
+ * appends what you said as a real user message, and carries on from the same
1996
+ * transcript.
1997
+ *
1998
+ * ⚠️ THE LOOP IS OUTSIDE `oneTurn`, NOT INSIDE IT, AND THAT IS THE WHOLE
1999
+ * SAFETY ARGUMENT. Every segment therefore goes through the one funnel that
2000
+ * already gets money right — `sessionSpentUsd` is charged per segment and
2001
+ * `remainingForTurn` subtracts it before the next one, so the `--budget`
2002
+ * ceiling covers the WHOLE turn and not each piece of it. Merging segments
2003
+ * inside `oneTurn` would have meant re-implementing that arithmetic, which is
2004
+ * exactly how this file once handed out its ceiling forty times over.
2005
+ *
2006
+ * ⚠️ WHAT THE FINAL SUMMARY PRICES IS THE LAST SEGMENT, because that is the
2007
+ * run it describes. The dollars from the earlier ones are printed on the
2008
+ * steering line as they happen — see `formatSteer`. Nothing is hidden; it is
2009
+ * reported where it occurs rather than summed into a number that would then
2010
+ * disagree with the session record it came from.
2011
+ *
2012
+ * ⚠️ AND IT IS NOT USED BY `--best-of` OR THE ESCALATION LADDER. Those run
2013
+ * several attempts of a decision the user already made, quietly and in
2014
+ * parallel; one steer file consumed by whichever attempt reaches a boundary
2015
+ * first is a race with no right answer.
2016
+ */
2017
+ const steerable = async (turnTask, priorTurnMessages, over = {}) => {
2018
+ let task = turnTask;
2019
+ let prior = priorTurnMessages;
2020
+ let rounds = over.maxRounds ?? opts.maxRounds;
2021
+ let steersUsed = 0;
2022
+ // ⭐ Stamped ONCE per turn, before the first segment — see `turnStartedAt`.
2023
+ turnStartedAt = Date.now();
2024
+ const out = () => (opts.json ? process.stderr : process.stdout);
2025
+
2026
+ for (;;) {
2027
+ // ⚠️ Cleared per segment: a steer belongs to the segment that read it.
2028
+ pendingSteer = null;
2029
+ const result = await oneTurn(task, prior, { ...over, steerable: true, maxRounds: rounds });
2030
+ const taken = pendingSteer;
2031
+ const plan = planSteer({ steer: taken, outcome: result, maxRounds: rounds, steersUsed });
2032
+
2033
+ if (!plan.go) {
2034
+ /**
2035
+ * ⚠️ A STEER THAT WAS NOT APPLIED IS ANNOUNCED. It has already been
2036
+ * DELETED from disk by `takeSteer` — silently dropping it would mean
2037
+ * the user typed an instruction, watched it vanish, and got no hint
2038
+ * that the run never saw it.
2039
+ */
2040
+ if (plan.reason) out().write(`${formatUnapplied({ text: taken?.text, reason: plan.reason })}\n`);
2041
+ return result;
2042
+ }
2043
+
2044
+ steersUsed += 1;
2045
+ out().write(`${formatSteer({ ...taken, roundsLeft: plan.roundsLeft, spentUsd: sessionSpentUsd })}\n`);
2046
+ task = plan.task;
2047
+ prior = plan.priorMessages;
2048
+ rounds = plan.maxRounds;
2049
+ }
2050
+ };
2051
+
2052
+ /** What this run actually wrote. Shared, because both reports need it. */
2053
+ // ⚠️ flatMap, not map: `write_files` and a delegated build each name many
2054
+ // files in ONE record, and `.map` collapsed them into a single entry whose
2055
+ // `path` was undefined. This feeds the JSON report and the audit log.
2056
+ const changesOf = (result) => (result?.executed ?? []).filter((e) => e.mutated).flatMap(describeChanges);
2057
+
2058
+ /**
2059
+ * ── ⭐⭐ THE TWO DURABLE RECORDS, AND THE FOUR RULES THEY OBEY ──────────────
2060
+ *
2061
+ * A session (so the next run can carry on) and an audit line (so a buyer can
2062
+ * answer "show me what it did on the fourteenth" after the terminal closed —
2063
+ * ENTERPRISE.md:162 lists the absence of the second as an adoption blocker).
2064
+ *
2065
+ * 1. ⚠️⚠️ NOT ONE BYTE ON STDOUT. Under `--json` stdout carries exactly one
2066
+ * object; a friendly "saved as …" line there breaks `| jq` for everyone.
2067
+ * Both records are silent on success, on every path, json or not — which is
2068
+ * also what keeps a run with no new flags byte-identical to yesterday's.
2069
+ *
2070
+ * 2. ⚠️ A FAILED WRITE IS ANNOUNCED, NEVER SWALLOWED. `audit.mjs`'s header is
2071
+ * emphatic: an audit log that quietly failed is worse than none, because
2072
+ * the operator believes they have evidence and finds out on the day they
2073
+ * need it. So a failure prints one line — to STDERR.
2074
+ *
2075
+ * 3. ⚠️ AND IT CAN NEVER FAIL THE RUN. The work is already on disk and the
2076
+ * exit code is a verification verdict, not a bookkeeping one. Both calls
2077
+ * are wrapped: a read-only `.acuvo/`, a full disk or a locked file costs
2078
+ * the record, never the result.
2079
+ *
2080
+ * 4. ⚠️ `--dry-run` WRITES NEITHER. `--help` promises "touch nothing", and a
2081
+ * dry run that creates two files in the workspace has broken that promise
2082
+ * to save a record of a run that did not happen.
2083
+ */
2084
+ const persistRun = (turnTask, result) => {
2085
+ if (opts.dryRun) return;
2086
+ if (life.save) {
2087
+ try {
2088
+ const saved = saveSession(root, result, { task: turnTask });
2089
+ if (!saved.ok) process.stderr.write(` · the run was not saved: ${saved.error}\n`);
2090
+ } catch (e) {
2091
+ process.stderr.write(` · the run was not saved: ${e?.message ?? e}\n`);
2092
+ }
2093
+ }
2094
+ if (life.audit) {
2095
+ try {
2096
+ const logged = recordRun({ root, outcome: result, changes: changesOf(result), task: turnTask });
2097
+ if (!logged.ok) process.stderr.write(` · ${logged.error}\n`);
2098
+ } catch (e) {
2099
+ process.stderr.write(` · could not write the audit record: ${e?.message ?? e}\n`);
2100
+ }
2101
+ }
2102
+ announceCheckpoint();
2103
+ };
2104
+
2105
+ /**
2106
+ * ── ⭐⭐ THE ONE LINE THAT MAKES THE UNDO EXIST ─────────────────────────────
2107
+ *
2108
+ * ⚠️ A CAPABILITY NOBODY IS TOLD ABOUT IS THE "BUILT BUT UNREACHABLE" DEFECT
2109
+ * THIS PACKAGE KEEPS SHIPPING — six modules once sat finished and imported by
2110
+ * nothing. A journal written silently would be the same failure wearing a
2111
+ * disk: the person who needs it is the person who does not yet know it exists,
2112
+ * and they will be looking at this scrollback when they need it.
2113
+ *
2114
+ * ⚠️ STDERR UNDER `--json`, like the banner and the lease line. Under `--json`
2115
+ * stdout carries exactly one object, and one friendly sentence there breaks
2116
+ * `| jq` for everybody.
2117
+ *
2118
+ * ⚠️ AND IT ANNOUNCES CHANGES, NOT RUNS. Interactive mode calls `persistRun`
2119
+ * every turn; reprinting the same id after a turn that wrote nothing is noise
2120
+ * that teaches people to stop reading the line.
2121
+ */
2122
+ let announcedFiles = 0;
2123
+ let announcedErrors = 0;
2124
+ const announceCheckpoint = () => {
2125
+ if (!journal) return;
2126
+ const out = opts.json ? process.stderr : process.stdout;
2127
+ if (journal.files > announcedFiles) {
2128
+ announcedFiles = journal.files;
2129
+ out.write(` · checkpoint ${journal.runId} — ${announcedFiles} file${announcedFiles === 1 ? '' : 's'} can be put back: acuvo rewind ${journal.runId}\n`);
2130
+ }
2131
+ /**
2132
+ * ⚠️ A CHECKPOINT THAT FAILED TO RECORD IS WORSE THAN NONE — the operator
2133
+ * believes they can undo. Same rule `audit.mjs` states for a log that
2134
+ * quietly failed to write, and the reason `errors` exists at all.
2135
+ */
2136
+ for (const err of journal.errors.slice(announcedErrors)) out.write(` ! ${err}\n`);
2137
+ announcedErrors = journal.errors.length;
2138
+ };
2139
+
2140
+ /**
2141
+ * ── ⭐ ONE JSON SHAPE, BUILT ONCE, USED BY EVERY PATH THAT EMITS ONE ────────
2142
+ *
2143
+ * `--issue` and the one-shot path owe the caller the same document. Two object
2144
+ * literals would have drifted the first time either grew a field, and a
2145
+ * machine contract that varies by which flag you passed is not a contract.
2146
+ *
2147
+ * ⚠️⭐ `failed` EXISTS BECAUSE `ok` IS NOT THE PROCESS VERDICT — measured, and
2148
+ * this is the dangerous half of the defect. `toJson` sets `ok: outcome?.ok
2149
+ * !== false`, which means THE SESSION COMPLETED; a run whose `npm test` exits
2150
+ * 1 emits `ok: true` next to process exit code 1. To recover the verdict a
2151
+ * consumer had to reimplement `sessionFailed` — `j.ok && !(j.verification.ran
2152
+ * && !j.verification.passed)` — and nothing in the document told them so. So
2153
+ * `acuvo --json … | jq -e .ok && git push` pushed code whose suite fails.
2154
+ *
2155
+ * ⚠️ `ok` IS NOT REDEFINED. Scripts already read it and it answers a real
2156
+ * question ("did the agent finish, or did the provider die"). `failed` answers
2157
+ * the other one, and `exitCode` states the number this process is about to
2158
+ * exit with, so the document and the shell can never disagree — the callers
2159
+ * below return `doc.exitCode` rather than calling `sessionFailed` a second
2160
+ * time, which is what would let them drift.
2161
+ *
2162
+ * ⚠️ `dryRun` BECAUSE THE ONLY OTHER SIGNAL GOES TO STDERR. A dry run reports
2163
+ * its writes as `kind:"created"`, byte-identically to a real one; the 'DRY
2164
+ * RUN' banner that distinguishes them is deliberately sent to stderr under
2165
+ * `--json` — precisely where a machine consumer is told not to look. So
2166
+ * `… --dry-run --json | jq -r '.changes[].path' | xargs git add` believed
2167
+ * files existed that were never written.
2168
+ */
2169
+ /**
2170
+ * ⚠️⭐ `leaseLost` IS PART OF THE VERDICT, NOT A WARNING. A run whose file was
2171
+ * taken by another terminal mid-flight may have written over someone else's
2172
+ * work; `acuvo … && git push` must not believe that succeeded. Same reasoning
2173
+ * as `sessionFailed` itself — the exit code is the machine-readable version of
2174
+ * the verdict, and it has to agree with it.
2175
+ *
2176
+ * ⚠️ AND `budget` IS CARRIED INTO THE DOCUMENT because the one question a
2177
+ * script asks about an unattended run is what it cost. It is absent — not
2178
+ * zero — when no `--budget` was given, so the shape only grows for a caller
2179
+ * who asked for it.
2180
+ */
2181
+ /**
2182
+ * ── ⚠️ STRICT: A RUN THAT DID NOTHING IS NOT A SUCCESS ──────────────────────
2183
+ *
2184
+ * Measured from a real bench artifact: two rounds, nothing written, nothing
2185
+ * run, `exitCode: 0`. Every other clause of `sessionFailed` describes
2186
+ * something that happened, so none of them fire when nothing did.
2187
+ *
2188
+ * ⚠️ OPT-IN, because "what does this file do?" correctly writes nothing and a
2189
+ * check that fails correct work is worse than no check. ⭐ BUT ARMED
2190
+ * AUTOMATICALLY UNDER CI, because there the default is backwards: a build
2191
+ * step that reports success for doing nothing is the whole failure mode the
2192
+ * exit code exists to prevent, and nobody types a flag they have not read
2193
+ * about. `CI` is the one variable every runner sets.
2194
+ */
2195
+ /**
2196
+ * ⚠️ `Boolean(process.env.CI)` IS TRUE FOR THE STRING "false". Several CI
2197
+ * setups export `CI=false` deliberately, and Create React App made
2198
+ * `CI=false npm run build` a widely-copied incantation — so the naive check
2199
+ * would arm strict for people explicitly saying they are NOT in CI, and hand
2200
+ * them a false exit 1. That is the check-that-fails-correct-work failure,
2201
+ * inside the flag added to prevent its opposite.
2202
+ */
2203
+ const inCI = !['', '0', 'false', 'no', 'off'].includes(String(process.env.CI ?? '').trim().toLowerCase());
2204
+ const verdictOptions = { strict: opts.strict === true || inCI };
2205
+
2206
+ const jsonDoc = (result, { task = null, fields = null } = {}) => {
2207
+ /**
2208
+ * ⚠️ THE DOCUMENT AND THE SHELL MUST NEVER DISAGREE — that is the whole
2209
+ * reason `exitCode` is in here. So the interrupt has to be visible on BOTH:
2210
+ * `verdictExit` returns 130 and this said 1, which is exactly the drift the
2211
+ * field was added to prevent, and a `| jq .exitCode` consumer would have
2212
+ * been told a cancelled run was a failed one.
2213
+ */
2214
+ const stoppedByCtrlC = wasAbortedByInterrupt({ interrupted: interruptedRun, outcome: result });
2215
+ const failed = sessionFailed(result, verdictOptions) || leaseLost !== null || stoppedByCtrlC;
2216
+ return {
2217
+ ...toJson(result, { changes: changesOf(result), task }),
2218
+ failed,
2219
+ exitCode: stoppedByCtrlC ? EXIT_INTERRUPTED : (failed ? EXIT_FAILED : EXIT_OK),
2220
+ ...(stoppedByCtrlC ? { interrupted: true } : {}),
2221
+ dryRun: opts.dryRun === true,
2222
+ ...(result?.budget ? { budget: result.budget } : {}),
2223
+ ...(leaseLost ? { leaseLost } : {}),
2224
+ ...(fields ?? {}),
2225
+ };
2226
+ };
2227
+
2228
+ /**
2229
+ * The process verdict. One helper so the four return sites cannot drift —
2230
+ * which is exactly how one of them would end up ignoring a lost lease.
2231
+ */
2232
+ /**
2233
+ * ── ⭐⭐ THE SECOND OPINION, AND WHY IT RUNS ONLY ON A CLAIMED SUCCESS ──────
2234
+ *
2235
+ * Refuting a run that already failed buys nothing — the first verdict is
2236
+ * already the honest one, and a second paid run to agree with it is money for
2237
+ * a sentence nobody needed. The claim worth testing is `✔ VERIFIED`, because
2238
+ * that is the one somebody is about to act on.
2239
+ *
2240
+ * ⚠️ ONLY A CONCRETE REFUTATION FLIPS THE EXIT CODE. An opinion, an
2241
+ * uncertainty, or a refuter that crashed leaves the verdict exactly as it was:
2242
+ * failing correct work is the worse error, and an adversarial reviewer is
2243
+ * precisely the mechanism most likely to commit it.
2244
+ */
2245
+ const secondOpinion = async (outcome, alreadyFailed) => {
2246
+ if (!opts.refute || alreadyFailed) return null;
2247
+ /**
2248
+ * ⚠️ THE SECOND OPINION SPENDS WHAT IS LEFT OF THE NUMBER YOU TYPED, not a
2249
+ * fresh copy of it. A refuter with its own full ceiling would quietly turn
2250
+ * `--budget 0.02` into four cents — the exact "a limit that is really a
2251
+ * rate" defect fixed for schedules an hour ago, reintroduced by the feature
2252
+ * meant to increase trust.
2253
+ *
2254
+ * `null` (a `--budget none` run) passes through unbounded, as that run asked.
2255
+ */
2256
+ const spent = Number.isFinite(outcome?.usage?.cost) ? outcome.usage.cost : 0;
2257
+ const left = opts.budgetUsd === null ? null : Math.max(0, opts.budgetUsd - spent);
2258
+ if (left !== null && left <= 0) {
2259
+ (opts.json ? process.stderr : process.stdout).write(
2260
+ '\n · no second opinion: the run used its whole budget, and refuting costs a run. Raise --budget to check it.\n',
2261
+ );
2262
+ return null;
2263
+ }
2264
+ const r = await refuteClaim({
2265
+ task,
2266
+ claim: outcome?.note ?? outcome?.content ?? '',
2267
+ executor,
2268
+ config,
2269
+ budgetUsd: left,
2270
+ fleetGate: createFleetGate(root, { fleetLimitUsd: opts.fleetBudgetUsd, since: opts.budgetWindow }),
2271
+ commandTimeoutMs: opts.commandTimeoutMs,
2272
+ });
2273
+ (opts.json ? process.stderr : process.stdout).write(`${['', ` ${formatRefutation(r)}`, ''].join(String.fromCharCode(10))}`);
2274
+ return r;
2275
+ };
2276
+
2277
+ const verdictExit = (outcome) => {
2278
+ /**
2279
+ * ── ⭐⭐ AN INTERRUPT IS NOT A VERDICT ───────────────────────────────────
2280
+ *
2281
+ * ⚠️ Counted as `failed` for the BOARD's purposes — a run the user stopped
2282
+ * did not finish its task, so the claim goes back on the board rather than
2283
+ * being marked done — and reported as **130** rather than 1 to the shell,
2284
+ * because exit 1 here means "the code it wrote still does not pass" and a
2285
+ * script that cannot tell those apart retries the wrong one.
2286
+ *
2287
+ * ⚠️ `wasAbortedByInterrupt` needs BOTH halves: a press that lands during
2288
+ * the final round leaves a completed, verified run, and reporting 130 for
2289
+ * that would tell a caller to retry a job that succeeded.
2290
+ */
2291
+ const stoppedByCtrlC = wasAbortedByInterrupt({ interrupted: interruptedRun, outcome });
2292
+ const failed = sessionFailed(outcome, verdictOptions) || leaseLost !== null || stoppedByCtrlC;
2293
+
2294
+ /**
2295
+ * ── ⭐ "I CHOSE NOT TO RUN" IS NOT "I RAN AND FAILED" ────────────────────
2296
+ *
2297
+ * Under `--unattended` these need opposite reactions: a fleet ceiling
2298
+ * declining a run is the schedule behaving exactly as instructed, and a run
2299
+ * that started and broke is something to look at. They shared exit 1, and
2300
+ * the harmless one is far more common — which is precisely how somebody
2301
+ * learns to ignore the alert that mattered.
2302
+ *
2303
+ * ⚠️ ONLY WHEN IT DECLINED, not when it was cut off mid-way. A run that did
2304
+ * some work and then hit the ceiling has left the job half-finished, and
2305
+ * half-finished IS something to look at. `executed.length === 0` is the
2306
+ * difference between the two, and it is the whole distinction.
2307
+ *
2308
+ * ⚠️ The reason list is `FLEET_STOP_REASONS` from budget.mjs rather than two
2309
+ * retyped strings — the same anti-drift rule that put them there.
2310
+ */
2311
+ if (opts.unattended
2312
+ && FLEET_STOP_REASONS.includes(outcome?.stoppedBecause)
2313
+ && (outcome?.executed?.length ?? 0) === 0) {
2314
+ (opts.json ? process.stderr : process.stdout).write(
2315
+ ` declined: the fleet ceiling is spent, so nothing was started. Exit ${EXIT_SKIPPED} — this is the schedule working, not a failure.
2316
+ `,
2317
+ );
2318
+ return EXIT_SKIPPED;
2319
+ }
2320
+ /**
2321
+ * ── ⭐⭐ A CLAIMED TASK IS CLOSED BY THE VERDICT, NOT BY FINISHING ────────
2322
+ *
2323
+ * Found by RUNNING a real three-terminal fleet rather than by testing it:
2324
+ * t1, t2 and t3 each claimed a different task, each fixed its bug correctly,
2325
+ * all three exited 0 — and `acuvo board` still said **3 open, 0 done**. The
2326
+ * code was fixed and the board was lying about the state of the world,
2327
+ * which is the one thing a board must never do.
2328
+ *
2329
+ * ⚠️ MARKED DONE ON THE HONEST VERDICT, NOT ON EXIT 0. `sessionFailed` is
2330
+ * this package's whole argument about verification — a run that wrote
2331
+ * nothing, or ran nothing, or was cut off by the budget, is not a finished
2332
+ * task however cleanly the process ended. Closing on "the process returned"
2333
+ * would turn the board into a list of things that were ATTEMPTED, and a
2334
+ * fleet owner reading ✔ would have to re-check every one.
2335
+ *
2336
+ * ⭐ A failed attempt RELEASES instead, so the task returns to the board and
2337
+ * the next terminal — or the same one, later — can pick it up. That is the
2338
+ * behaviour that makes an overnight fleet safe to leave alone: work that did
2339
+ * not land is still on the list in the morning.
2340
+ */
2341
+ if (claimed?.ok) {
2342
+ if (!failed) {
2343
+ const done = boardDone(root, claimed.id, { lease: claimed.lease });
2344
+ if (done.ok) (opts.json ? process.stderr : process.stdout).write(` board: ${claimed.id} done
2345
+ `);
2346
+ } else {
2347
+ try { releaseAll([claimed.lease]); } catch { /* the TTL will clear it */ }
2348
+ (opts.json ? process.stderr : process.stdout).write(` board: ${claimed.id} left OPEN — this run did not verify, so the task goes back on the board
2349
+ `);
2350
+ }
2351
+ claimed = null; // the exit hook must not release a lease already handed back
2352
+ }
2353
+ if (stoppedByCtrlC) {
2354
+ (opts.json ? process.stderr : process.stdout).write(
2355
+ ` ⏹ stopped by Ctrl-C. The transcript, the cost and the changes were all saved — \`acuvo --resume\` carries on from here. Exit ${EXIT_INTERRUPTED}.\n`,
2356
+ );
2357
+ return EXIT_INTERRUPTED;
2358
+ }
2359
+ return failed ? EXIT_FAILED : EXIT_OK;
2360
+ };
2361
+
2362
+ /**
2363
+ * ── ⭐ NO TASK ⇒ INTERACTIVE SESSION ──────────────────────────────────────
2364
+ * `acuvo "do a thing"` behaves exactly as before. `acuvo` on its own opens a
2365
+ * conversation, because the second instruction should not cost what the first
2366
+ * did — measured, an unchanged prefix caches at 97.2% and the call is 4.3x
2367
+ * cheaper, so appending turns is nearly free while rebuilding them is not.
2368
+ *
2369
+ * ⚠️ INTERACTIVE MODE ALWAYS EXITS 0. The one-shot exit code is a verification
2370
+ * verdict a script can branch on; a conversation has many verdicts and the
2371
+ * last one is not the session's. Reporting the final turn as the process
2372
+ * result would make `acuvo` unusable in a shell that checks `$?`.
2373
+ */
2374
+ /**
2375
+ * ── ⭐ PARALLEL: SEVERAL TASKS, ONE WORKSPACE ─────────────────────────────
2376
+ * Bounded concurrency, and a conflict report if two tasks wrote the same file.
2377
+ *
2378
+ * ⚠️ EACH TASK GETS ITS OWN EXECUTOR but they share the DIRECTORY, so the
2379
+ * collision is real and the honest answer is to detect it rather than pretend
2380
+ * to merge. Two model-authored versions of one file cannot be reconciled
2381
+ * without a human, and the last writer silently winning is the failure this
2382
+ * whole path is designed around.
2383
+ *
2384
+ * ⚠️ Per-task output is SUPPRESSED. Four interleaved streams are unreadable —
2385
+ * you cannot tell which round belongs to which task — so the live view is one
2386
+ * line per task as it finishes, and the detail lands in the summary.
2387
+ */
2388
+ /**
2389
+ * ── ⭐⭐ `--issue 42` — READ IT, BRANCH, FIX IT ────────────────────────────
2390
+ * The whole job in one command. Everything after this point is the ordinary
2391
+ * loop; the only new thing is where the task came from.
2392
+ *
2393
+ * ⚠️ IT STOPS AT A LOCAL BRANCH. No push, no pull request — both are outward
2394
+ * -facing acts on the user's account that their colleagues can see, and an
2395
+ * agent that opens a PR because it believed it was finished embarrasses
2396
+ * someone in front of their team. It prints the exact commands instead.
2397
+ */
2398
+ if (opts.issue !== null) {
2399
+ /**
2400
+ * ⚠️ EVERY HUMAN LINE IN THIS BRANCH GOES TO STDERR UNDER `--json`. It used
2401
+ * to write four of them to stdout unconditionally, so
2402
+ * `acuvo --json --issue 42 | jq` died on "· reading acme/widgets#42" long
2403
+ * before any document appeared — and then the branch returned without
2404
+ * emitting one at all. Same rule as the banner and `onEvent`: everything
2405
+ * human goes to stderr, EVERYTHING. It is still printed, not suppressed:
2406
+ * a person watching a `--json` run in a terminal still wants to see which
2407
+ * issue was read and which branch was made.
2408
+ */
2409
+ const say = (text) => (opts.json ? process.stderr : process.stdout).write(text);
2410
+ const repo = detectRepo(root);
2411
+ if (!repo.ok) die(repo.error, EXIT_USAGE);
2412
+ const auth = findToken();
2413
+ if (!auth.ok) die(auth.error, EXIT_UNCONFIGURED);
2414
+
2415
+ say(` · reading ${repo.owner}/${repo.repo}#${opts.issue} (via ${auth.source})\n`);
2416
+ const issue = await fetchIssue({ owner: repo.owner, repo: repo.repo, number: opts.issue, token: auth.token });
2417
+ if (!issue.ok) die(issue.error, EXIT_FAILED);
2418
+ say(` · #${issue.number} ${issue.title}\n`);
2419
+
2420
+ const branch = branchNameFor(issue);
2421
+ const made = createBranch(root, branch);
2422
+ if (!made.ok) die(made.error, EXIT_FAILED);
2423
+ say(` · ${made.reused ? 'reusing' : 'created'} branch ${made.branch}\n\n`);
2424
+
2425
+ // Named, because the JSON must report the task that was actually sent —
2426
+ // `opts.task` is empty here, and reporting null would hide the framing the
2427
+ // issue body was wrapped in.
2428
+ const task = issueToTask(issue);
2429
+ const outcome = await steerable(task, null);
2430
+ say(`${formatSummary(outcome).join(String.fromCharCode(10))}\n`);
2431
+ say(`${nextSteps({ owner: repo.owner, repo: repo.repo, branch: made.branch, issue }).join(String.fromCharCode(10))}\n`);
2432
+ if (opts.json) {
2433
+ // ⭐ `issue` and `branch` are the two facts this path knows and the
2434
+ // one-shot path cannot: without them a script has to parse the branch
2435
+ // name out of the prose it was just told not to read.
2436
+ const doc = jsonDoc(outcome, { task, fields: { issue: opts.issue, branch: made.branch } });
2437
+ process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`);
2438
+ return doc.exitCode;
2439
+ }
2440
+ return verdictExit(outcome);
2441
+ }
2442
+
2443
+ if (opts.parallel) {
2444
+ process.stdout.write(`\n running ${opts.tasks.length} tasks, ${opts.concurrency} at a time\n\n`);
2445
+ const started = Date.now();
2446
+ const results = await runPool(
2447
+ opts.tasks,
2448
+ async (task, i) => {
2449
+ const outcome = await runSession({
2450
+ task,
2451
+ executor: createLocalExecutor(root, { dryRun: opts.dryRun }),
2452
+ config,
2453
+ maxTokens: opts.maxTokens,
2454
+ timeoutMs: opts.timeoutMs,
2455
+ maxRounds: opts.maxRounds,
2456
+ allowRun: opts.allowRun && !opts.dryRun,
2457
+ shell: opts.shell,
2458
+ commandTimeoutMs: opts.commandTimeoutMs,
2459
+ /**
2460
+ * ── ⚠️⚠️ THE PROMISE WAS MADE IN THE REFUSAL AND KEPT NOWHERE ─────
2461
+ *
2462
+ * `cli-args.mjs` REFUSES `--budget` with `--parallel`, and its stated
2463
+ * reason is that the default already applies: *"the default has no
2464
+ * surprise to prevent: it is a per-run blast radius by construction,
2465
+ * so N conversations getting N × $0.02 is what it means."*
2466
+ *
2467
+ * That was false. This call passed no `budgetUsd`, `runSession`
2468
+ * defaults it to null, and null is UNLIMITED — so the N × $0.02 the
2469
+ * refusal message promises was N × unbounded. Measured: a `--parallel`
2470
+ * run printed no budget line for either session, while a single run in
2471
+ * the same session printed one.
2472
+ *
2473
+ * ⭐ Now each conversation really does get the default ceiling, which
2474
+ * is what the refusal already told the user they were getting. The
2475
+ * message needed no change; the code had to catch up to it.
2476
+ */
2477
+ budgetUsd: DEFAULT_BUDGET_USD,
2478
+ onEvent: () => {},
2479
+ });
2480
+ /**
2481
+ * ⚠️ THE FAN-OUT PATH BUILDS ITS OWN SESSION rather than going through
2482
+ * `oneTurn` (different executor, silenced events), so the record has to
2483
+ * be taken here too. Leaving it out is precisely how "every invocation
2484
+ * persists" becomes "every invocation except the one that ran four" —
2485
+ * and the parallel path is the one whose scrollback is least readable,
2486
+ * so it is the one most in need of a durable record.
2487
+ */
2488
+ persistRun(task, outcome);
2489
+ const wrote = (outcome?.executed ?? []).filter((e) => e.mutated).length;
2490
+ process.stdout.write(` ${outcome?.ok === false ? '✖' : '✔'} ${shortLabel(task, i)} (${wrote} file${wrote === 1 ? '' : 's'})\n`);
2491
+ return outcome;
2492
+ },
2493
+ { concurrency: opts.concurrency },
2494
+ );
2495
+
2496
+ const analysis = detectConflicts(results);
2497
+ process.stdout.write(`${formatParallelSummary(results, analysis).join('\n')}\n`);
2498
+ process.stdout.write(` ${((Date.now() - started) / 1000).toFixed(0)}s\n\n`);
2499
+ /**
2500
+ * ⚠️ A CONFLICT IS A NON-ZERO EXIT. A script that fans out work must be able
2501
+ * to notice that one task's output was overwritten by another — reporting
2502
+ * success there would be the same silent-success failure the verifier had.
2503
+ */
2504
+ /**
2505
+ * ⚠️ `sessionFailed`, NOT `outcome.ok === false` — the parallel path had the
2506
+ * SAME hole as the single one (ENTERPRISE §3.5): a session killed by a
2507
+ * provider outage is never `ok:false`, so a fan-out where three of four
2508
+ * tasks died on a 429 reported success. One verdict function, used
2509
+ * everywhere, is the only way these cannot drift apart again.
2510
+ */
2511
+ return analysis.conflicts.length > 0 || results.some((r) => !r?.ok || sessionFailed(r.outcome))
2512
+ ? EXIT_FAILED
2513
+ : EXIT_OK;
2514
+ }
2515
+
2516
+ /**
2517
+ * ⚠️ `task`, NOT `opts.task` — A RESUME WITH NO NEW INSTRUCTION IS A TASK.
2518
+ * `acuvo --continue` supplies the previous run's task above, so testing
2519
+ * `opts.task` here would drop a resumed conversation into interactive mode and
2520
+ * throw away the messages that were just rebuilt. With no lifecycle flag,
2521
+ * `task === opts.task` and this branch is byte-identical to before.
2522
+ */
2523
+ if (!task) {
2524
+ await runChat({
2525
+ // ⭐ STEERABLE, not oneTurn: a conversation turn is exactly as long as a
2526
+ // one-shot run and just as worth redirecting. Ctrl-C returns you to the
2527
+ // prompt; a steer keeps the turn going with new instructions.
2528
+ runOne: steerable,
2529
+ render: (result, out) => out.write(formatSummary(result).join(String.fromCharCode(10)) + String.fromCharCode(10)),
2530
+ /**
2531
+ * ── ⭐ WHAT THE `/` COMMANDS REPORT ON ────────────────────────────────
2532
+ *
2533
+ * ⚠️ THIS OBJECT IS THE WHOLE FEATURE. `lib/slash.mjs` is pure and knows
2534
+ * nothing about disk or spend; without these providers every command
2535
+ * would honestly answer "not available in this session" and the surface
2536
+ * would be built-but-unreachable — the defect this repo has shipped four
2537
+ * times in one day, inside the commits fixing it.
2538
+ *
2539
+ * ⚠️ EVERY PROVIDER IS CALLED AT THE MOMENT THE COMMAND IS TYPED, never
2540
+ * captured up front. `/skills` after dropping a new file into
2541
+ * `.acuvo/skills/` must see it, and `/cost` read once at startup would
2542
+ * report $0.000000 for the whole session.
2543
+ */
2544
+ slashContext: {
2545
+ skills: () => (discoverAllSkills(root)?.skills ?? []).map((s) => ({ name: s.name, description: s.description })),
2546
+ loadSkill: (name) => loadAnySkill(root, name),
2547
+ mcp: () => {
2548
+ const cfg = readMcpConfig(root);
2549
+ // ⚠️ A BROKEN CONFIG IS REPORTED AS ITSELF. `{ servers: [] }` here
2550
+ // would say "you have no MCP servers" to someone whose mcp.json has a
2551
+ // syntax error — the wrong problem, and they would go looking for it
2552
+ // in the wrong file.
2553
+ if (!cfg?.ok) return { source: cfg?.error ? `a config error: ${cfg.error}` : null, servers: [] };
2554
+ return {
2555
+ /**
2556
+ * ⚠️ `cfg.file`, AND NULL WHEN THERE IS NO FILE — not a default
2557
+ * path string. `slash.mjs` reads a `source` on an empty list as
2558
+ * "there is a reason these are unusable", so defaulting it here
2559
+ * would tell every workspace WITHOUT an mcp.json that its
2560
+ * non-existent config was broken.
2561
+ */
2562
+ source: cfg.file ?? null,
2563
+ servers: (cfg.servers ?? []).map((s) => ({
2564
+ name: s.name,
2565
+ transport: s.transport,
2566
+ // ⚠️ NOT "connected". Servers are connected per RUN, and nothing
2567
+ // is held open between turns, so the only honest thing this can
2568
+ // report is that it is configured. Claiming a live connection we
2569
+ // have not made is exactly the lie `imagegen`'s honesty tests exist for.
2570
+ status: 'configured',
2571
+ })),
2572
+ };
2573
+ },
2574
+ cost: () => ({
2575
+ spentUsd: sessionSpentUsd,
2576
+ limitUsd: opts.budgetUsd,
2577
+ limitIsDefault: opts.budgetExplicit !== true,
2578
+ }),
2579
+ model: () => ({
2580
+ name: config.model,
2581
+ /**
2582
+ * ⚠️ THE ROUTE IS PART OF THE ANSWER. Two sessions on the same model
2583
+ * id can differ ~4.6x in cost depending on which upstream served
2584
+ * them, and nothing errors when the expensive one does. `null` until
2585
+ * a turn has actually run — `renderModel` omits an absent note, so an
2586
+ * unknown route says nothing rather than something reassuring.
2587
+ */
2588
+ note: routingNote(lastProviders),
2589
+ source: opts.model ? '--model' : 'the configured default',
2590
+ }),
2591
+ },
2592
+ });
2593
+ return EXIT_OK;
2594
+ }
2595
+
2596
+ /**
2597
+ * ── ⭐⭐ BEST-OF-N — THE CAPABILITY OUR PRICE BUYS ────────────────────────
2598
+ *
2599
+ * Do the task several times in isolated copies, keep the one that actually
2600
+ * PASSED. At ~$0.001 a run, three attempts cost a third of a cent; an agent
2601
+ * billing a hundred times that cannot offer this at all — not for lack of the
2602
+ * idea, but because the arithmetic forbids it.
2603
+ *
2604
+ * ⚠️ IT SITS AFTER THE RESUME BRANCH ON PURPOSE. Resuming rebuilds ONE
2605
+ * conversation; forking it into three divergent continuations and keeping one
2606
+ * would silently discard two histories the user believed they were carrying.
2607
+ */
2608
+ /**
2609
+ * ⚠️ `!opts.untilDone` — THE TWO FEATURES COLLIDED ON THE SAME FLAG. Both use
2610
+ * `--best-of n`, and this branch sits first, so `--until-done --budget 2
2611
+ * --best-of 4` would have run ONE round of parallel attempts and exited,
2612
+ * silently discarding the escalation the user asked for. Under `--until-done`
2613
+ * the flag means "how wide the ladder's top rung is" and the ladder owns it.
2614
+ */
2615
+ if (opts.bestOf >= 2 && !opts.untilDone) {
2616
+ if (resumeRequested) {
2617
+ die('--best-of starts several independent attempts; --resume carries one conversation forward. Pick one.', EXIT_USAGE);
2618
+ }
2619
+ const best = await runBestOf({
2620
+ root,
2621
+ attempts: opts.bestOf,
2622
+ /**
2623
+ * ⚠️ AN ADAPTER, NOT `runPool` DIRECTLY — and passing it directly is
2624
+ * exactly what failed first. `runPool(tasks, runOne, opts)` takes THREE
2625
+ * arguments and wraps each result as `{ok, index, task, outcome}`, so
2626
+ * handing it `(jobs, {concurrency})` bound the options object to `runOne`
2627
+ * and then double-wrapped every attempt. The symptom was quiet: the
2628
+ * best-of report printed perfectly and the winning file was never applied,
2629
+ * because `winner.outcome.executed` was `undefined` two levels down.
2630
+ *
2631
+ * ⭐ A shape mismatch between two of our own modules produced a plausible
2632
+ * report and no work — which is worse than a crash, and is why the
2633
+ * end-to-end test that caught it exists.
2634
+ */
2635
+ pool: async (jobs, { concurrency }) => {
2636
+ const results = await runPool(jobs, (job) => job(), { concurrency });
2637
+ return results.map((r) => (r.ok ? r.outcome : { error: r.error }));
2638
+ },
2639
+ concurrency: Math.min(2, opts.bestOf),
2640
+ failed: sessionFailed,
2641
+ runOne: async ({ root: attemptRoot, label }) => {
2642
+ /**
2643
+ * ⚠️ NOT `say()`. Both `say` helpers in this file are declared INSIDE
2644
+ * other branches, so neither is in scope here — using one would be a
2645
+ * ReferenceError at the moment the feature is first exercised, which is
2646
+ * precisely the `changes is not defined` bug that shipped this morning
2647
+ * on a path no test entered. Checked rather than assumed this time.
2648
+ */
2649
+ (opts.json ? process.stderr : process.stdout).write(` ${label} …\n`);
2650
+ return runSession({
2651
+ task,
2652
+ executor: createLocalExecutor(attemptRoot, { dryRun: opts.dryRun }),
2653
+ config,
2654
+ maxTokens: opts.maxTokens,
2655
+ timeoutMs: opts.timeoutMs,
2656
+ maxRounds: opts.maxRounds,
2657
+ allowRun: opts.allowRun && !opts.dryRun,
2658
+ shell: opts.shell,
2659
+ commandTimeoutMs: opts.commandTimeoutMs,
2660
+ /**
2661
+ * ── ⚠️⚠️ THE CEILING WAS MISSING ON THE MODE THAT SPENDS THE MOST ──
2662
+ *
2663
+ * `runSession` defaults `budgetUsd = null`, which means UNLIMITED. So
2664
+ * `--best-of N` ran N full sessions with no wall at all, bounded only
2665
+ * by the round cap — on the one mode whose entire purpose is to spend
2666
+ * several times over.
2667
+ *
2668
+ * ⚠️ AND WORSE THAN ABSENT: an explicit `--budget` was ACCEPTED
2669
+ * without complaint and silently discarded. Measured — `--best-of 2
2670
+ * --budget 0.005` ran both attempts and printed no budget line at
2671
+ * all. Taking a user's instruction about money and dropping it is a
2672
+ * different and worse failure than never offering the feature.
2673
+ *
2674
+ * ⭐ AN EXPLICIT BUDGET IS A TOTAL, NOT A PER-ATTEMPT ALLOWANCE.
2675
+ * Someone typing `--best-of 5 --budget 0.05` means "spend at most five
2676
+ * cents", not "spend up to twenty-five". Dividing is the reading that
2677
+ * cannot surprise them; the alternative multiplies their number by N
2678
+ * and would be indefensible on an invoice.
2679
+ */
2680
+ budgetUsd: bestOfAttemptBudget(opts),
2681
+ // ⚠️ Silent per attempt. Three interleaved round-by-round streams are
2682
+ // unreadable, and the report below is what the user acts on.
2683
+ onEvent: () => {},
2684
+ });
2685
+ },
2686
+ });
2687
+ process.stdout.write(`${formatBestOf(best)}\n`);
2688
+ if (!best.ok) return EXIT_FAILED;
2689
+ /**
2690
+ * ⚠️ THE EXIT CODE FOLLOWS THE WINNER, not the fact that a run happened. If
2691
+ * nothing verified, `acuvo --best-of 3 … && git push` must NOT push.
2692
+ */
2693
+ return best.winner ? verdictExit(best.winner) : EXIT_FAILED;
2694
+ }
2695
+
2696
+ /**
2697
+ * ── ⭐⭐ THE UNATTENDED RUN CLIMBS THE LADDER ──────────────────────────────
2698
+ *
2699
+ * `--until-done --budget X` is the only mode that runs for hours with nobody
2700
+ * watching, and until now it was also the only mode that could not use the
2701
+ * one capability nobody can copy us on. `runBestOf` had a single caller — the
2702
+ * `--best-of` branch above — which refuses to combine with `--resume` and
2703
+ * runs exactly once. So the mode that most needed "try harder" was the mode
2704
+ * structurally forbidden from it.
2705
+ *
2706
+ * ⚠️ GATED ON `--budget`, NOT ON `--until-done` ALONE. Escalation spends real
2707
+ * money on someone's behalf while they are asleep; doing that without a
2708
+ * ceiling is the single most dangerous thing in this package, which is why
2709
+ * `cli-args.mjs:483` already refuses `--until-done` without one. This branch
2710
+ * inherits that refusal rather than restating it.
2711
+ *
2712
+ * ⚠️ AND NOT WITH `--resume`. Same reason the `--best-of` branch refuses it:
2713
+ * the fresh rung deliberately DISCARDS the conversation, so carrying one
2714
+ * forward and then throwing it away would silently do the opposite of what
2715
+ * `--resume` promises.
2716
+ */
2717
+ if (opts.untilDone && opts.budgetUsd !== null && !resumeRequested) {
2718
+ const say = (line) => (opts.json ? process.stderr : process.stdout).write(line);
2719
+ const ladder = await escalate({
2720
+ root,
2721
+ task,
2722
+ budget: createBudget({ limitUsd: opts.budgetUsd, limitIsDefault: opts.budgetExplicit !== true, limitSource: opts.budgetSource ?? null, fleetGate: createFleetGate(root, { fleetLimitUsd: opts.fleetBudgetUsd, since: opts.budgetWindow }) }),
2723
+ // ⭐ Tier 0, and the only tier unless ACUVO_MODEL_TIERS is configured.
2724
+ baseModel: config.model,
2725
+ /**
2726
+ * ⭐ `--best-of n` DOUBLES AS THE LADDER'S TOP-RUNG WIDTH. One flag, one
2727
+ * meaning — "how many independent attempts" — rather than a second
2728
+ * `--attempts` that would differ from it by nothing.
2729
+ */
2730
+ attempts: opts.bestOf >= 2 ? opts.bestOf : undefined,
2731
+ maxTier: opts.maxTier,
2732
+ /**
2733
+ * ⚠️ THE EXIT-CODE VERDICT **PLUS** "WAS IT CUT OFF" — and the second half
2734
+ * is not optional. `sessionFailed` alone was the first wiring here and it
2735
+ * silently disabled the whole feature: it does not fail a run that
2736
+ * verified nothing, so a session that ran out of budget mid-task read as
2737
+ * a success and the ladder never climbed once. Measured, not reasoned —
2738
+ * see `outOfRoad`'s header for the run that caught it.
2739
+ */
2740
+ verified: (o) => !sessionFailed(o) && !outOfRoad(o),
2741
+ pool: async (jobs, { concurrency }) => {
2742
+ const results = await runPool(jobs, (job) => job(), { concurrency });
2743
+ return results.map((r) => (r.ok ? r.outcome : { error: r.error }));
2744
+ },
2745
+ onEvent: (ev) => {
2746
+ /**
2747
+ * ⚠️ ONLY THE CLIMB IS ANNOUNCED LIVE, and the omission is deliberate.
2748
+ * `escalate-up` is worth interrupting for — a long unattended run should
2749
+ * say out loud that it is now spending more. `escalate-skipped` fires
2750
+ * immediately before the ladder returns, so printing it here AND in the
2751
+ * report below is the same sentence twice, which `formatSummary` already
2752
+ * has a comment about: a repeat reads as a malfunction, not a report.
2753
+ */
2754
+ /**
2755
+ * ⚠️ A MODEL SWITCH IS SAID OUT LOUD, ALWAYS. A run that quietly moves to
2756
+ * a pricier model has changed what it costs without telling the person
2757
+ * paying, and "why was this bill different" must never be unanswerable.
2758
+ */
2759
+ if (ev.type === 'escalate-model') {
2760
+ say(`
2761
+ ↑ ${ev.note}
2762
+ `);
2763
+ } else if (ev.type === 'escalate-up') {
2764
+ say(`\n ↑ ${ev.from} did not verify — escalating to ${ev.to} (~${ev.projectedUsd.toFixed(4)} projected, ${ev.remainingUsd.toFixed(4)} left)\n`);
2765
+ }
2766
+ },
2767
+ runOne: async ({ root: dir, task: rungTask, tier, budgetUsd, model }) => {
2768
+ if (tier === 'best-of') {
2769
+ say(` · attempt running in ${shortenRoot(dir)}\n`);
2770
+ return oneTurn(rungTask, null, {
2771
+ executor: createLocalExecutor(dir, { dryRun: opts.dryRun }),
2772
+ budgetUsd,
2773
+ quiet: true,
2774
+ model,
2775
+ });
2776
+ }
2777
+ return oneTurn(rungTask, null, { budgetUsd, model });
2778
+ },
2779
+ });
2780
+
2781
+ const final = ladder.outcome;
2782
+ if (opts.json) {
2783
+ process.stdout.write(`${JSON.stringify(jsonDoc(final, { task, fields: { escalation: { stopped: ladder.stopped, tier: ladder.tier, rungs: ladder.rungs, skipped: ladder.skipped, spentUsd: ladder.spentUsd } } }), null, 2)}\n`);
2784
+ return final ? verdictExit(final) : EXIT_FAILED;
2785
+ }
2786
+
2787
+ for (const line of formatSummary(final ?? { ok: false, error: ladder.error ?? 'nothing ran' })) {
2788
+ process.stdout.write(`${line}\n`);
2789
+ }
2790
+
2791
+ /**
2792
+ * ── ⚠️⚠️ THE LADDER'S TOTAL IS PRINTED **LAST**, AND THAT IS A HONESTY FIX
2793
+ *
2794
+ * MEASURED, real run: the report was printed BEFORE the summary, so the
2795
+ * final line on screen was the last rung's own ledger — `$0.0012` — while
2796
+ * the run had actually spent `$0.0083`. Every number was individually true
2797
+ * and the one a human reads last understated the bill by 7x. `formatSummary`
2798
+ * describes ONE session and cannot know about the other four; the only
2799
+ * place that knows the total is here, so the total goes last.
2800
+ *
2801
+ * ⚠️ AND ONLY WHEN THE LADDER WAS ACTUALLY USED. A run that verified on the
2802
+ * first rung is byte-identical to yesterday's output — a new flag that
2803
+ * changes the look of every existing run is a regression dressed as a
2804
+ * feature.
2805
+ */
2806
+ const climbed = (ladder.rungs?.length ?? 0) > 1 || (ladder.skipped?.length ?? 0) > 0;
2807
+ if (climbed) process.stdout.write(`\n${formatEscalation(ladder)}\n`);
2808
+
2809
+ return final ? verdictExit(final) : EXIT_FAILED;
2810
+ }
2811
+
2812
+ /**
2813
+ * ── ⭐⭐⭐ `--plan` — SAY WHAT YOU INTEND, AND DO NOTHING UNTIL I AGREE ─────
2814
+ *
2815
+ * ⚠️⚠️ THE TWO FLAGS PEOPLE ALREADY REACH FOR ARE NOT THIS, and the whole
2816
+ * reason this gate had to be built is that both of them look like it.
2817
+ * `--dry-run` prints the writes it WOULD have made — after the model has
2818
+ * already decided what they are, which is the decision you wanted to see.
2819
+ * `--no-run` withholds the process spawners and leaves writing untouched.
2820
+ * Both are about the ACT; neither is about the INTENT, and neither has a
2821
+ * place to say no.
2822
+ *
2823
+ * ⭐ EVERY PART OF IT ALREADY EXISTED. `ORIENT_TOOLS` (plan-coherence.mjs) is
2824
+ * the read-only subset; `createAsker` (prompt.mjs) is the question, and it is
2825
+ * already the thing that decides whether `ask_user` is offered at all;
2826
+ * `toolNamesForRounds` (tools.mjs) already varies the offer by round budget.
2827
+ * Nothing joined them. This is the join, and `runPlanGate` holds the parts
2828
+ * that can be tested without a terminal.
2829
+ *
2830
+ * ⚠️ THE OFFER IS AN INTERSECTION, COMPUTED FROM THIS MACHINE. `root` and
2831
+ * `env` decide whether `read_skill` and the four LSP verbs exist here at all,
2832
+ * so the read-only list is `toolNamesForRounds(...) ∩ ORIENT_TOOLS` rather
2833
+ * than a constant — a constant would ship the dead buttons tools.mjs spends
2834
+ * four hundred lines refusing to ship.
2835
+ *
2836
+ * ⚠️ AND THE ROUND BUDGET IS THE PROPOSAL'S, NOT THE RUN'S. `planModeRounds`
2837
+ * clamps to 2..5: below two, `toolNamesForRounds` collapses to the write-only
2838
+ * single-shot list and the intersection is EMPTY (a model handed no tools and
2839
+ * asked to plan); above five the proposal starts eating the budget the user
2840
+ * typed for the work. The dollar ceiling is untouched — the proposal draws
2841
+ * from the same `--budget` through `oneTurn`'s existing subtraction, so
2842
+ * `--plan` cannot double what the user agreed to spend.
2843
+ *
2844
+ * ⚠️ IT SITS ON THE ORDINARY SINGLE-RUN PATH ONLY, deliberately. `--parallel`,
2845
+ * `--best-of`, `--issue` and the escalation ladder each run several attempts
2846
+ * of a decision already made; an approval prompt per attempt is an interview,
2847
+ * and one shared approval across attempts approves a plan three of them never
2848
+ * proposed. Those paths return above this line and are byte-identical.
2849
+ */
2850
+ if (opts.plan) {
2851
+ const out = () => (opts.json ? process.stderr : process.stdout);
2852
+ const planRounds = planModeRounds(opts.maxRounds);
2853
+ const readOnly = planModeToolNames(toolNamesForRounds(planRounds, {
2854
+ allowRun: false,
2855
+ root: executor.root,
2856
+ interactive: asker !== null,
2857
+ }));
2858
+ if (!readOnly.ok) die(` ${readOnly.error}\n`, EXIT_USAGE);
2859
+
2860
+ const gate = await runPlanGate({
2861
+ task,
2862
+ ask: asker,
2863
+ print: (text) => out().write(text),
2864
+ propose: () => {
2865
+ out().write(` · planning first — read-only, ${planRounds} round${planRounds === 1 ? '' : 's'}, `
2866
+ + `${readOnly.names.length} reading tools; writes are refused at the executor, not just withheld\n`);
2867
+ return oneTurn(planPhaseTask(task), null, {
2868
+ maxRounds: planRounds,
2869
+ allowRun: false,
2870
+ toolNames: readOnly.names,
2871
+ /**
2872
+ * ── ⚠️⚠️ THE THIRD OVERRIDE, AND IT IS THE ONE THAT MAKES THE
2873
+ * HEADLINE PROMISE TRUE ───────────────────────────────────
2874
+ *
2875
+ * `toolNames` narrows what the model is SHOWN and `allowRun: false`
2876
+ * stops the dispatcher spawning a process. Neither stops a WRITE:
2877
+ * `executeToolCall` is a switch on the tool name, `case 'write_file'`
2878
+ * calls `executor.writeFile` with nothing in between, and `allowRun`
2879
+ * is (correctly) not consulted because a write starts no process.
2880
+ *
2881
+ * ⚠️ MEASURED THROUGH THIS EXACT OPTION SET, 2026-08-20: a scripted
2882
+ * `write_file` for a tool absent from the 13-name offer returned
2883
+ * `ok:true, mutated:true` and left the file on disk; `edit_file`
2884
+ * rewrote a source file; `delete_file` removed one. Three mutations
2885
+ * during the phase whose whole promise is that there are none.
2886
+ *
2887
+ * ⭐ `over.executor` was ALREADY a supported override (`oneTurn` does
2888
+ * `over.executor ?? executor`) and nothing had ever used it. This is
2889
+ * the join, and it is structural rather than name-based: every write
2890
+ * verb in the dispatcher reaches disk through writeFile/deleteFile/
2891
+ * moveFile, including the ones a `delegate` helper would use, so a
2892
+ * tool added next year is covered without a list being updated.
2893
+ */
2894
+ executor: planPhaseExecutor(executor),
2895
+ });
2896
+ },
2897
+ });
2898
+
2899
+ if (!gate.proceed) {
2900
+ /**
2901
+ * ⚠️ EXIT 0 ON `declined`, AND NON-ZERO ON EVERYTHING ELSE. A person
2902
+ * reading a plan and saying no is the feature working; failing the process
2903
+ * for it would make `--plan` unusable in any script that checks a status.
2904
+ * A refusal for want of a terminal or want of a plan IS a failure — the
2905
+ * run was asked for and did not happen — and `--unattended` already
2906
+ * exists for callers that need to tell "chose not to" from "could not".
2907
+ */
2908
+ out().write(`\n ${gate.reason === 'declined' ? '✖ plan declined' : '✖ --plan could not run'} — ${gate.why}\n`);
2909
+ return gate.reason === 'declined' ? EXIT_OK : EXIT_USAGE;
2910
+ }
2911
+ out().write(` ✔ plan approved${gate.decision === 'amend' ? ' with an amendment' : ''} — starting work\n\n`);
2912
+ /**
2913
+ * ⭐ THE APPROVED PLAN BECOMES THE TASK, and it carries an instruction to
2914
+ * record itself with `plan_start` and mark it with `plan_step`. That is the
2915
+ * line that makes the approval BIND rather than merely happen: without a
2916
+ * ledger there is nothing for `detectDrift` to compare the run against and
2917
+ * nothing for the reconciliation block below to reconcile.
2918
+ */
2919
+ task = gate.task;
2920
+ }
2921
+
2922
+ const outcome = await steerable(task, priorMessages);
2923
+
2924
+ /**
2925
+ * ── ⚠️⚠️ THE SECOND OPINION USED TO BE SKIPPED IN THE MODE THAT NEEDS IT ───
2926
+ *
2927
+ * `secondOpinion` was called THIRTY LINES BELOW the `if (opts.json)` early
2928
+ * return, so `--json --refute` accepted the flag, charged nothing, ran no
2929
+ * refutation, and left NO field in the document to say it had been skipped.
2930
+ * Found independently by three dogfood agents, and confirmed by source read.
2931
+ *
2932
+ * ⚠️ AND IT IS THE EXACT COMBINATION CI USES — `--json` to parse, `--refute`
2933
+ * for the trust gate. So the one mode where nobody is watching the terminal
2934
+ * was the one that silently dropped the check. This file's own comment says
2935
+ * the refuter exists because "the claim worth testing is ✔ VERIFIED, because
2936
+ * that is the one somebody is about to act on"; under `--json` that claim was
2937
+ * never tested, and a script acted on it.
2938
+ *
2939
+ * ⭐ It moves ABOVE the return rather than being duplicated inside it: two
2940
+ * call sites for one decision is how the human and machine paths drift, which
2941
+ * is the defect this whole cluster is made of. `secondOpinion` already routes
2942
+ * its own prose to stderr when `opts.json` is set, so the one-object-on-stdout
2943
+ * contract holds without any change to it.
2944
+ */
2945
+ /**
2946
+ * ⚠️ A CANCELLED RUN IS NOT REFUTED, IT IS UNFINISHED — and refuting costs a
2947
+ * whole extra model run. Paying an adversarial reviewer to disprove work the
2948
+ * user just stopped mid-way is spending money to be told what the user
2949
+ * already knows. `alreadyFailed` is the existing gate for exactly this
2950
+ * ("don't buy a second opinion on a run we already call failed"), so the
2951
+ * interrupt joins it rather than growing a second skip condition.
2952
+ */
2953
+ const alreadyFailed = sessionFailed(outcome, verdictOptions) || leaseLost !== null
2954
+ || wasAbortedByInterrupt({ interrupted: interruptedRun, outcome });
2955
+ const opinion = await secondOpinion(outcome, alreadyFailed);
2956
+
2957
+ if (opts.json) {
2958
+ /**
2959
+ * ⚠️ ONE OBJECT ON STDOUT AND NOTHING ELSE. Every human line already went
2960
+ * to stderr (see onEvent), so `acuvo --json … | jq .verification.passed`
2961
+ * works with no flags and no grepping of prose that we keep improving.
2962
+ *
2963
+ * ⚠️ AND THE EXIT CODE IS READ BACK OUT OF THE DOCUMENT, not recomputed.
2964
+ * `doc.exitCode` is the same `sessionFailed` result the object reports, so
2965
+ * a run can never tell the shell one thing and `jq` another.
2966
+ */
2967
+ /**
2968
+ * ⭐ THREE DISTINGUISHABLE STATES, because collapsing any two of them would
2969
+ * let a script read a skipped check as a passed one — which is the whole
2970
+ * defect this fixes, moved one layer down:
2971
+ *
2972
+ * asked: false you never passed --refute
2973
+ * ran: false you did, but the run had already failed, so
2974
+ * refuting it would buy nothing (see secondOpinion)
2975
+ * ran: true, refuted:_ it ran, and this is what it found
2976
+ *
2977
+ * ⚠️ `refuted` is only meaningful when `ok` is true. A refuter that crashed
2978
+ * must not read as "could not refute it", so its own `ok` travels with it
2979
+ * rather than being flattened into a boolean.
2980
+ */
2981
+ // ⭐ The shape lives in lib/refute.mjs so it can be TESTED — importing
2982
+ // bin/acuvo.mjs executes the CLI, which then waits on stdin, so a decision
2983
+ // declared here is a decision no test can reach without hanging.
2984
+ const refutation = refutationField(opts.refute === true, opinion, alreadyFailed);
2985
+
2986
+ const doc = jsonDoc(outcome, { task, fields: { refutation } });
2987
+ process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`);
2988
+ /**
2989
+ * ⚠️ A REFUTED RUN MUST FAIL UNDER --json TOO. The human path returns
2990
+ * EXIT_FAILED when the second opinion refutes the claim; without this the
2991
+ * document could report `refutation.refuted: true` beside `exitCode: 0`,
2992
+ * and a CI gate reading the exit code would pass a run our own adversarial
2993
+ * check had just disproved. `doc.exitCode` is otherwise authoritative, so
2994
+ * this is the one place allowed to override it — and the document carries
2995
+ * the reason, so the two can still be reconciled by anyone reading both.
2996
+ */
2997
+ if (opinion?.ok && opinion.refuted) return EXIT_FAILED;
2998
+ return doc.exitCode;
2999
+ }
3000
+
3001
+ /**
3002
+ * ⚠️ THE CHANGE LIST IS **NOT** PRINTED AGAIN HERE, and that absence is the
3003
+ * fix. `formatSummary` already emits it under its "N files written:" header
3004
+ * (lib/turn.mjs) — this file printed a second, unlabelled copy below the cost
3005
+ * line, so every run that touched a file listed it twice.
3006
+ *
3007
+ * ⭐ WHEN A FACT APPEARS TWICE, DELETE THE COPY WITHOUT THE CONTEXT. The
3008
+ * summary's copy has a header explaining what the list is; this one was bare
3009
+ * paths after a price. Deleting the other one would have been "fixing" the
3010
+ * duplicate by keeping the worse half.
3011
+ */
3012
+ const lines = formatSummary(outcome);
3013
+ process.stdout.write(`${lines.join('\n')}\n`);
3014
+
3015
+ /**
3016
+ * ── ⭐⭐ WHAT THE MODEL'S OWN `done` WAS WORTH — PRINTED, AT LAST ───────────
3017
+ *
3018
+ * `runSession` has returned `reconciliation` since plan-coherence was wired
3019
+ * in, and `formatReconciliation` exists to print it. Measured 2026-08-20:
3020
+ * turn.mjs imported that formatter on line 64 and called it NOWHERE, so the
3021
+ * block existed only inside `--json`. The one number nobody has ever been
3022
+ * shown — how many steps marked done have any evidence behind them — was
3023
+ * computed on every planned run and thrown away on the human path.
3024
+ *
3025
+ * ⚠️ ONLY WHEN THERE WAS A PLAN FOR THIS TASK. `turn.mjs` omits the field
3026
+ * entirely otherwise (`planForTask` returns null for a plan left behind by a
3027
+ * different task), so this is silent on the overwhelming majority of runs
3028
+ * rather than printing "nothing to reconcile" at everybody.
3029
+ *
3030
+ * ⚠️ BELOW `formatSummary`, NOT ABOVE IT. The escalation ladder learned this
3031
+ * the expensive way twenty lines up: the last thing on screen is the thing a
3032
+ * person reads, and a step marked done with nothing behind it is worth more
3033
+ * of that position than the cost line.
3034
+ *
3035
+ * ⚠️ AND IT SAYS NOTHING ABOUT CORRECTNESS. `formatReconciliation`'s own last
3036
+ * line states that; it is left in rather than trimmed for width, because this
3037
+ * block appearing to be a verification verdict is exactly the `✔ VERIFIED`
3038
+ * over an untouched deliverable that produced plan-coherence.mjs.
3039
+ */
3040
+ if (outcome?.reconciliation?.ok) {
3041
+ process.stdout.write(`\n${formatReconciliation(outcome.reconciliation).join('\n')}\n`);
3042
+ }
3043
+
3044
+ /**
3045
+ * ── ⭐ SHOW THE PICTURE, DO NOT DESCRIBE IT ────────────────────────────────
3046
+ *
3047
+ * `see_page` already renders a real screenshot and then prints a FILE PATH —
3048
+ * a coding agent calling a painting over the radio. Terminals that speak the
3049
+ * kitty or iTerm2 protocol can simply be handed the pixels.
3050
+ *
3051
+ * ⚠️ SILENT AND OPTIONAL BY CONSTRUCTION. `renderImage` returns nothing at all
3052
+ * on a terminal we do not positively recognise, and nothing when stdout is not
3053
+ * a TTY, so `acuvo --json | jq` is untouched. The path line above stays either
3054
+ * way — the image is an addition to the report, never a replacement for it.
3055
+ */
3056
+ /**
3057
+ * ⚠️⚠️ THIS BINDING CRASHED THE CLI FOR ONE COMMIT, and the way it got past
3058
+ * me is the part worth keeping. Removing the duplicate change-list PRINT also
3059
+ * removed `const changes`, and I checked for other uses with
3060
+ * `awk '/\bchanges\b/'` — which matched nothing, so I concluded there were
3061
+ * none. **In POSIX awk `\b` is a BACKSPACE, not a word boundary.** The check
3062
+ * could not have matched anything, ever.
3063
+ *
3064
+ * ⭐ A CHECK THAT CANNOT FAIL IS WORSE THAN NO CHECK. It reads as evidence.
3065
+ * And 1,413 green tests said nothing, because this line runs only AFTER a
3066
+ * real completion — the crash surfaced on the first live run, on the report
3067
+ * path, after the work had already succeeded.
3068
+ */
3069
+ const changes = changesOf(outcome);
3070
+ for (const c of changes) {
3071
+ if (!/\.png$/i.test(c.path ?? '')) continue;
3072
+ const shot = renderImage(resolve(root, c.path));
3073
+ if (shot.text) process.stdout.write(shot.text);
3074
+ }
3075
+
3076
+ /**
3077
+ * ── ⭐⭐ THE SECOND OPINION, ON THE ONE PATH THAT MATTERS ──────────────────
3078
+ *
3079
+ * Wired here — the ordinary single-run exit — and deliberately not onto
3080
+ * `--parallel`, `--best-of` or the escalation ladder. Each of those already
3081
+ * spends several runs and has its own verdict machinery; bolting a refuter
3082
+ * onto all five call sites would multiply cost in exactly the modes that are
3083
+ * already expensive, for a claim that is already cross-checked.
3084
+ *
3085
+ * ⚠️ AND THE VERDICT ONLY MOVES ONE WAY. A concrete refutation turns a pass
3086
+ * into a failure; nothing here can turn a failure into a pass. An adversarial
3087
+ * reviewer that could clear a red run would be a way to launder a bad result,
3088
+ * which is the opposite of the reason it exists.
3089
+ */
3090
+ // ⚠️ `alreadyFailed` and `opinion` are computed ABOVE the --json return now,
3091
+ // so both paths act on the same single evaluation. Re-running the refuter
3092
+ // here would charge for a second adversarial pass and could disagree with the
3093
+ // document already printed.
3094
+ if (opinion?.ok && opinion.refuted) return EXIT_FAILED;
3095
+
3096
+ return verdictExit(outcome);
3097
+ }
3098
+
3099
+ main().then(
3100
+ (code) => process.exit(code),
3101
+ (err) => {
3102
+ // Nothing should reach here — every expected failure is a returned value.
3103
+ // A stack trace escaping to the user is therefore a BUG in this package,
3104
+ // and it says so rather than looking like the user's fault.
3105
+ process.stderr.write(`acuvo crashed — this is a bug in acuvo-code, not in your project:\n${err?.stack || err}\n`);
3106
+ process.exit(EXIT_FAILED);
3107
+ },
3108
+ );