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