acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/spend.mjs ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * ── ⭐⭐ WHAT DID THIS COST ME — THE QUESTION WE ALREADY ANSWERED AND NEVER READ
3
+ *
4
+ * Every run appends one redacted JSON line to `.acuvo/audit/<date>.jsonl`,
5
+ * including `costUsd`. `parseAuditLog` was written, exported and tested, and had
6
+ * **zero runtime callers**. So the tool wrote the answer down every single time
7
+ * and nobody could ask for it.
8
+ *
9
+ * That is the payer's first question — "what have I spent" — and for a product
10
+ * whose whole pitch is that it tells you the price before it runs and stops at
11
+ * the number you gave it, being unable to answer it afterwards is not a missing
12
+ * report. It is the pitch with its last sentence removed.
13
+ *
14
+ * ── ⚠️⚠️ THE ONE RULE THIS FILE EXISTS TO ENFORCE ───────────────────────────
15
+ *
16
+ * `costUsd` IS NULL ON A REAL RECORD. A run that died on a 401 never billed
17
+ * anything and honestly does not know what it cost; a run whose provider omitted
18
+ * usage is the same. Summing null as zero would produce a total that is
19
+ * confidently too low, in the one report a user checks precisely because they do
20
+ * not trust their own memory of it.
21
+ *
22
+ * ⭐ So unknown is COUNTED AND REPORTED SEPARATELY, never folded in. A total of
23
+ * "$0.0412 across 31 runs, 4 of which do not know what they cost" is useful and
24
+ * true. "$0.0412 across 35 runs" is neither.
25
+ *
26
+ * ⚠️ AND THE WINDOW IS BOUNDED BY PRUNING, NOT BY THE QUESTION. `audit.mjs`
27
+ * keeps at most MAX_AUDIT_FILES days and deletes whole files, so "all time" means
28
+ * "as far back as the log still goes". The report says which day it can actually
29
+ * see from, because a total that silently starts mid-history is the same lie as
30
+ * one that counts null as zero.
31
+ */
32
+
33
+ import { readdirSync, readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+
36
+ import { AUDIT_DIR, parseAuditLog } from './audit.mjs';
37
+
38
+ /** `7d`, `24h`, `30`, or an ISO date. Returns a Date, or null for "everything". */
39
+ export function parseSince(raw, now = new Date()) {
40
+ const s = String(raw ?? '').trim().toLowerCase();
41
+ if (!s || s === 'all') return null;
42
+
43
+ const rel = /^(\d+)\s*([dhw])?$/.exec(s);
44
+ if (rel) {
45
+ const n = Number(rel[1]);
46
+ if (!Number.isFinite(n) || n <= 0) return { error: `"${raw}" is not a period — try 7d, 24h, or a date like 2026-08-01` };
47
+ const ms = { d: 86_400_000, h: 3_600_000, w: 604_800_000 }[rel[2] ?? 'd'];
48
+ return new Date(now.getTime() - n * ms);
49
+ }
50
+
51
+ const at = new Date(s);
52
+ if (Number.isNaN(at.getTime())) {
53
+ return { error: `"${raw}" is not a period — try 7d, 24h, or a date like 2026-08-01` };
54
+ }
55
+ return at;
56
+ }
57
+
58
+ /**
59
+ * Aggregate every audit record in a workspace.
60
+ *
61
+ * ⚠️ Pure over injected text, so the whole report is testable without touching a
62
+ * filesystem — the same seam that makes the governor testable without spending.
63
+ *
64
+ * @param {Array<{name: string, text: string}>} files
65
+ * @param {{ since?: Date|null }} [options]
66
+ */
67
+ export function summariseSpend(files, { since = null } = {}) {
68
+ let totalUsd = 0;
69
+ let counted = 0;
70
+ let unknown = 0;
71
+ let damaged = 0;
72
+ let failed = 0;
73
+ /**
74
+ * ── ⭐⭐ HOW MUCH OF THAT TOTAL IS ARITHMETIC RATHER THAN A BILL ────────────
75
+ *
76
+ * GPU containers are not billed back per call, so a run that rendered an
77
+ * image carries a price-table figure inside its `costUsd`. This report's
78
+ * whole reason for existing is that a number a user might act on must not
79
+ * hide where it came from — the same argument that made `unknown` its own
80
+ * counter instead of a zero. So the estimated share is tracked separately and
81
+ * printed on its own line.
82
+ */
83
+ let estimatedUsd = 0;
84
+ let estimatedRuns = 0;
85
+ const byDay = new Map();
86
+ const byModel = new Map();
87
+ let earliest = null;
88
+ let latest = null;
89
+
90
+ for (const file of files ?? []) {
91
+ const parsed = parseAuditLog(file?.text ?? '');
92
+ damaged += parsed.damaged;
93
+
94
+ for (const rec of parsed.records) {
95
+ const at = rec?.at ? new Date(rec.at) : null;
96
+ if (at && Number.isNaN(at.getTime())) continue;
97
+ if (since && at && at < since) continue;
98
+
99
+ if (at) {
100
+ if (!earliest || at < earliest) earliest = at;
101
+ if (!latest || at > latest) latest = at;
102
+ }
103
+
104
+ const run = rec?.run ?? {};
105
+ if (run.ok === false) failed += 1;
106
+
107
+ const day = String(rec?.at ?? '').slice(0, 10) || 'unknown';
108
+ const bucket = byDay.get(day) ?? { usd: 0, runs: 0, unknown: 0 };
109
+ bucket.runs += 1;
110
+
111
+ /**
112
+ * ⚠️ `typeof === 'number'` — NOT a truthiness check. A genuine $0.00 run
113
+ * (cached, or refused before any call) is a KNOWN zero and belongs in the
114
+ * counted set; `if (run.costUsd)` would quietly reclassify it as unknown
115
+ * and make the "we don't know" number look worse than it is.
116
+ */
117
+ /**
118
+ * ⚠️ READ FROM THE RECORD, NOT RE-DERIVED. `audit.mjs` writes
119
+ * `run.cost.estimatedUsd` only when there was GPU spend, so an older log
120
+ * line — or a run that never touched a container — contributes nothing
121
+ * here and this whole report is unchanged for it, character for character.
122
+ */
123
+ const est = run?.cost?.estimatedUsd;
124
+ if (typeof est === 'number' && Number.isFinite(est) && est > 0) {
125
+ estimatedUsd += est;
126
+ estimatedRuns += 1;
127
+ }
128
+
129
+ if (typeof run.costUsd === 'number' && Number.isFinite(run.costUsd)) {
130
+ totalUsd += run.costUsd;
131
+ counted += 1;
132
+ bucket.usd += run.costUsd;
133
+
134
+ const model = run?.model?.answered ?? run?.model?.requested ?? 'unknown';
135
+ const m = byModel.get(model) ?? { usd: 0, runs: 0 };
136
+ m.usd += run.costUsd;
137
+ m.runs += 1;
138
+ byModel.set(model, m);
139
+ } else {
140
+ unknown += 1;
141
+ bucket.unknown += 1;
142
+ }
143
+
144
+ byDay.set(day, bucket);
145
+ }
146
+ }
147
+
148
+ return {
149
+ ok: true,
150
+ totalUsd,
151
+ runs: counted + unknown,
152
+ counted,
153
+ /** ⚠️ Runs whose cost is genuinely unknown. NEVER added to totalUsd as zero. */
154
+ unknown,
155
+ /** The share of `totalUsd` that came from a price table rather than a bill. */
156
+ estimatedUsd,
157
+ estimatedRuns,
158
+ failed,
159
+ damaged,
160
+ earliest: earliest ? earliest.toISOString() : null,
161
+ latest: latest ? latest.toISOString() : null,
162
+ byDay: [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, v]) => ({ day, ...v })),
163
+ byModel: [...byModel.entries()].sort((a, b) => b[1].usd - a[1].usd).map(([model, v]) => ({ model, ...v })),
164
+ };
165
+ }
166
+
167
+ /** Read the audit directory. Returns `[]` when there is none — that is not an error. */
168
+ export function readAuditFiles(root, { dir = AUDIT_DIR } = {}) {
169
+ const target = join(root, dir);
170
+ let names;
171
+ try {
172
+ names = readdirSync(target).filter((n) => n.endsWith('.jsonl')).sort();
173
+ } catch {
174
+ return [];
175
+ }
176
+ const files = [];
177
+ for (const name of names) {
178
+ try {
179
+ files.push({ name, text: readFileSync(join(target, name), 'utf8') });
180
+ } catch { /* a file that vanished mid-read is not worth failing the report for */ }
181
+ }
182
+ return files;
183
+ }
184
+
185
+ const money = (n) => (n < 0.01 ? `${(n * 100).toFixed(3)}c` : `$${n.toFixed(4)}`);
186
+
187
+ /** The human report. Lines without the leading indent, like every other view. */
188
+ export function formatSpend(summary, { since = null } = {}) {
189
+ if (!summary || summary.runs === 0) {
190
+ return [
191
+ 'No runs recorded here yet.',
192
+ 'Every run appends one line to .acuvo/audit/<date>.jsonl — unless it was given --no-audit.',
193
+ ];
194
+ }
195
+
196
+ const lines = [];
197
+ const window = since ? `since ${since.toISOString().slice(0, 10)}` : 'all recorded runs';
198
+
199
+ /**
200
+ * ⚠️ WHEN NOTHING IS KNOWN, DO NOT LEAD WITH A TOTAL. "0.000c across 0 runs"
201
+ * next to "1 run did not record a cost" is accurate and reads as "nothing
202
+ * happened" — which is the impression this whole report exists to prevent.
203
+ * Say what is actually true: runs occurred and none of them recorded a price.
204
+ */
205
+ if (summary.counted === 0 && summary.unknown > 0) {
206
+ lines.push(`${summary.unknown} run${summary.unknown === 1 ? '' : 's'} recorded, none of which reported a cost · ${window}`);
207
+ } else {
208
+ lines.push(`${money(summary.totalUsd)} across ${summary.counted} run${summary.counted === 1 ? '' : 's'} · ${window}`);
209
+ }
210
+
211
+ /**
212
+ * ⚠️ THE UNKNOWNS GET THEIR OWN LINE, ALWAYS, when there are any. Burying them
213
+ * in a footnote is how a total that is too low gets believed.
214
+ */
215
+ if (summary.unknown > 0) {
216
+ lines.push(`⚠️ ${summary.unknown} run${summary.unknown === 1 ? '' : 's'} did not record a cost — not counted above, and not zero.`);
217
+ }
218
+ /**
219
+ * ⚠️ ITS OWN LINE, ALWAYS, when there is any — for the same reason the
220
+ * unknowns get one. A total that is part measurement and part arithmetic and
221
+ * says so is useful; the same total presented as a bill is a worse lie than
222
+ * the incomplete number it replaced, because it is confident.
223
+ */
224
+ if (summary.estimatedUsd > 0) {
225
+ lines.push(`⚠️ ${money(summary.estimatedUsd)} of that is ESTIMATED GPU time across ${summary.estimatedRuns} run${summary.estimatedRuns === 1 ? '' : 's'} — priced from a published rate table, not billed per call.`);
226
+ }
227
+ if (summary.failed > 0) lines.push(`${summary.failed} of those runs failed.`);
228
+ if (summary.damaged > 0) lines.push(`⚠️ ${summary.damaged} damaged log line${summary.damaged === 1 ? '' : 's'} skipped.`);
229
+
230
+ if (summary.earliest) {
231
+ lines.push(`Log reaches back to ${summary.earliest.slice(0, 10)} — older days are pruned, so this is not necessarily your whole history.`);
232
+ }
233
+
234
+ if (summary.byDay.length > 1) {
235
+ lines.push('');
236
+ for (const d of summary.byDay.slice(-14)) {
237
+ const flag = d.unknown > 0 ? ` (${d.unknown} unknown)` : '';
238
+ lines.push(` ${d.day} ${money(d.usd).padStart(9)} ${String(d.runs).padStart(3)} run${d.runs === 1 ? ' ' : 's'}${flag}`);
239
+ }
240
+ }
241
+
242
+ if (summary.byModel.length > 0) {
243
+ lines.push('');
244
+ for (const m of summary.byModel.slice(0, 5)) {
245
+ lines.push(` ${money(m.usd).padStart(9)} ${m.model}`);
246
+ }
247
+ }
248
+
249
+ return lines;
250
+ }
package/lib/steer.mjs ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * ── ⭐⭐ SAYING SOMETHING WHILE IT WORKS ─────────────────────────────────────
3
+ *
4
+ * `--help` already documents steering BETWEEN turns —
5
+ * `acuvo --resume <id> "now add tests"`. The gap is the during-run version: an
6
+ * eight-round build takes minutes, and the moment you most want to say "stop
7
+ * writing tests, just fix the import" is round three, not after round eight.
8
+ * Today the only lever is Ctrl-C, which throws away the round in flight.
9
+ *
10
+ * ── ⚠️⚠️ WHY A FILE AND NOT THE TERMINAL. THIS WAS THE REAL DECISION ────────
11
+ *
12
+ * A TTY mechanism works in exactly ONE of the two modes this tool runs in, and
13
+ * it is the wrong one:
14
+ *
15
+ * · **Interactive** (`acuvo` with no task) — stdin is owned by `readline`.
16
+ * Anything typed mid-run is already buffered by the interface, and stealing
17
+ * it back means reaching into readline's internals or racing it.
18
+ * · **One-shot** (`acuvo "task"`) — the common case, the LONG case, and the
19
+ * one that most needs steering — has no readline at all, and its stdin is
20
+ * usually a pipe or nothing. There is no keystroke to read.
21
+ *
22
+ * ⭐ A FILE WORKS IN BOTH, and in several places a keyboard cannot reach: an
23
+ * editor in another pane, `echo "now add tests" > .acuvo/steer.txt` from a
24
+ * second terminal, a CI supervisor, another agent in the fleet. It is testable
25
+ * without a terminal, it survives the run reading it, and it is inspectable
26
+ * afterwards. It is unglamorous and it is right.
27
+ *
28
+ * ── ⚠️ THE FIVE RULES, EACH ONE A FAILURE THAT WOULD OTHERWISE BE SHIPPED ───
29
+ *
30
+ * 1. **CONSUMED, NOT WATCHED.** The file is deleted the instant it is read.
31
+ * Left in place it would be re-applied at every round boundary forever —
32
+ * an instruction given once becoming one the user cannot stop giving.
33
+ * 2. **ANNOUNCED, ALWAYS.** `turn.mjs` states the rule for the memory file:
34
+ * *"the agent is being steered by a file the user may have forgotten they
35
+ * wrote — silently obeying it is how 'why did it do that?' becomes
36
+ * unanswerable."* A steer is exactly that file, so it is printed to the
37
+ * terminal AND labelled inside the conversation.
38
+ * 3. **A USER MESSAGE AT A ROUND BOUNDARY, NEVER MID-ROUND.** A round owns a
39
+ * model call, its tool results and its ledger entry; injecting into the
40
+ * middle of one would record spend against a conversation that no longer
41
+ * matches what was sent.
42
+ * 4. **THE ROUNDS ARE NOT REFILLED.** The continuation gets what was LEFT of
43
+ * `--max-rounds`, never a fresh allowance. Otherwise `--max-rounds 8` plus
44
+ * three steers quietly means 32 rounds and the flag is a lie.
45
+ * 5. **BOUNDED.** `MAX_STEERS` caps how many times one turn can be redirected,
46
+ * because a script that rewrites the file after every read is an infinite
47
+ * loop that spends money.
48
+ *
49
+ * Zero dependencies. The only I/O is one `readFileSync` + one `unlinkSync` of a
50
+ * path inside `.acuvo/`, and neither may ever throw into a run.
51
+ */
52
+
53
+ import { readFileSync, unlinkSync, existsSync, statSync } from 'node:fs';
54
+ import { join } from 'node:path';
55
+ import { ACUVO_DIR } from './acuvo-dir.mjs';
56
+
57
+ /**
58
+ * Where the user writes.
59
+ *
60
+ * ⭐⭐ INSIDE `.acuvo/` FOR TWO REASONS, AND THE SECOND ONE IS THE IMPORTANT
61
+ * ONE. First, that directory ignores itself in git (`acuvo-dir.mjs`), so the
62
+ * steer never dirties the tree — the exact defect that made our own bench fail
63
+ * its `git` task. Second, and load-bearing: `workspace.mjs`'s
64
+ * `AGENT_CONFIG_DIR` and `policy.mjs`'s `isPolicyProtectedPath` both HARD-REFUSE
65
+ * agent writes anywhere under `.acuvo/`. **So the agent cannot write its own
66
+ * steer file.** A steering channel the model could write to is a model that can
67
+ * hand itself new instructions mid-run and report them as the user's — put it
68
+ * anywhere else in the workspace and that is exactly what it becomes.
69
+ */
70
+ export const STEER_FILE = join(ACUVO_DIR, 'steer.txt');
71
+
72
+ /**
73
+ * ⚠️ HOW MANY TIMES ONE TURN MAY BE REDIRECTED. Three is a judgement, not a
74
+ * measurement: enough for "no, the other file" → "now add a test" → "run it",
75
+ * few enough that a loop which keeps rewriting the file stops on its own. The
76
+ * cap is REPORTED when it bites, because a steer that was silently ignored is
77
+ * the worst outcome of the three.
78
+ */
79
+ export const MAX_STEERS = 3;
80
+
81
+ /**
82
+ * ⚠️ A CAP, BECAUSE THE FILE IS WRITTEN BY A HUMAN IN A HURRY. A stray `cat
83
+ * bigfile > steer.txt` would otherwise put a megabyte into the prompt — which
84
+ * fails as a context-length 400 several rounds later, nowhere near the cause.
85
+ * 4,000 characters is longer than any instruction anyone types on purpose.
86
+ */
87
+ export const MAX_STEER_CHARS = 4000;
88
+
89
+ /** What the abort reason says, so the summary names the cause honestly. */
90
+ export const STEER_ABORT_REASON = 'you steered the run mid-flight';
91
+
92
+ /** Absolute path of the steer file for a workspace. */
93
+ export function steerPath(root) {
94
+ return join(String(root ?? ''), STEER_FILE);
95
+ }
96
+
97
+ /**
98
+ * Read the pending steer and REMOVE it, so it applies exactly once.
99
+ *
100
+ * ⚠️ IT NEVER THROWS. This runs on the round-boundary hot path of every run.
101
+ * A read-only `.acuvo/`, a file deleted between the `existsSync` and the read,
102
+ * or a locked handle on Windows must cost the steer, never the run — the same
103
+ * rule the audit log and the session save already obey.
104
+ *
105
+ * ⚠️ AND THE DELETE HAPPENS EVEN IF THE CONTENT IS UNUSABLE. An empty or
106
+ * over-long file that stayed on disk would be re-read at every single round
107
+ * boundary for the rest of the run.
108
+ *
109
+ * @param {string} root workspace root
110
+ * @param {{ read?: Function, remove?: Function, exists?: Function }} [io] test seam
111
+ * @returns {{ text: string, truncated: boolean } | null} null when there is nothing to say
112
+ */
113
+ export function takeSteer(root, io = {}) {
114
+ const exists = io.exists ?? existsSync;
115
+ const read = io.read ?? ((p) => readFileSync(p, 'utf8'));
116
+ const remove = io.remove ?? unlinkSync;
117
+ const modified = io.modified ?? ((p) => statSync(p).mtimeMs);
118
+ /**
119
+ * ── ⚠️⚠️ THE HIJACK THIS CLOSES, FOUND BY RUNNING IT NOT BY THINKING ──────
120
+ *
121
+ * Measured on a live run: a steer written 200ms AFTER the last round boundary
122
+ * is never picked up, and the file simply survives the run. The next `acuvo`
123
+ * in that workspace would then consume it at ROUND ONE and apply *"actually
124
+ * make it a haiku"* — written about yesterday's task — to whatever is being
125
+ * asked today. It is announced, so it is not silent, but "why did it do
126
+ * that?" would take a while to answer.
127
+ *
128
+ * ⭐ A steer must be NEWER than the turn it steers. Anything older predates
129
+ * the run and therefore cannot be about it, so it is consumed once and
130
+ * reported as stale (the caller hands the words back — `formatUnapplied`)
131
+ * rather than obeyed or silently deleted.
132
+ *
133
+ * ⚠️ `newerThan = 0` keeps the old behaviour for any caller that does not
134
+ * supply a clock, which is every test that does not care about this.
135
+ */
136
+ const newerThan = Number(io.newerThan ?? 0);
137
+ const path = steerPath(root);
138
+
139
+ let raw = null;
140
+ let stale = false;
141
+ try {
142
+ if (!exists(path)) return null;
143
+ if (newerThan > 0) {
144
+ // ⚠️ A stat that throws must NOT make a live steer look stale — the
145
+ // conservative failure here is to apply it, because the user did write it.
146
+ try { stale = Number(modified(path)) < newerThan; } catch { stale = false; }
147
+ }
148
+ raw = String(read(path));
149
+ } catch {
150
+ return null;
151
+ } finally {
152
+ // Consume unconditionally — see the header rule 1.
153
+ try { if (raw !== null || exists(path)) remove(path); } catch { /* nothing to do */ }
154
+ }
155
+
156
+ const trimmed = raw.trim();
157
+ /**
158
+ * ⚠️ AN EMPTY FILE IS NOT A STEER. `touch .acuvo/steer.txt`, an editor that
159
+ * saved before anything was typed, or a `>` that truncated — none of those
160
+ * are an instruction, and treating them as one would cost a round and inject
161
+ * a blank user message the model has to invent a meaning for.
162
+ */
163
+ if (!trimmed) return null;
164
+
165
+ const truncated = trimmed.length > MAX_STEER_CHARS;
166
+ return { text: truncated ? trimmed.slice(0, MAX_STEER_CHARS) : trimmed, truncated, stale };
167
+ }
168
+
169
+ /**
170
+ * ── ⭐⭐ THE SENTENCE THE MODEL ACTUALLY RECEIVES ───────────────────────────
171
+ *
172
+ * ⚠️ IT IS LABELLED AS COMING FROM THE USER, AND `turn.mjs` IS WHY. That file
173
+ * injects its own automated user messages prefixed
174
+ * `[runner — automatic, not from the user]` precisely so the model can tell an
175
+ * instruction from a nudge. An unlabelled steer would be indistinguishable from
176
+ * the runner's own automation and would be weighted like one.
177
+ *
178
+ * ⚠️ AND IT ASKS FOR AN ACKNOWLEDGEMENT. The steer arrives after a round the
179
+ * user watched go the wrong way; a model that silently changes course leaves
180
+ * them unable to tell whether the file was read at all.
181
+ */
182
+ export function steerTask(text) {
183
+ return `[steering — this came from YOU, the user, written to ${STEER_FILE} while the run was working. `
184
+ + 'It is not from the runner and it is not automation.] '
185
+ + `${String(text ?? '').trim()}\n\n`
186
+ + 'Say in one short line what you are changing because of this, then do it. '
187
+ + 'What you have already done stays done — carry on from here rather than starting over.';
188
+ }
189
+
190
+ /**
191
+ * The line the TERMINAL shows. Rule 2: never silent.
192
+ *
193
+ * @param {{ text: string, truncated?: boolean, roundsLeft: number, spentUsd?: number|null }} p
194
+ */
195
+ export function formatSteer({ text, truncated = false, roundsLeft, spentUsd = null }) {
196
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
197
+ const shown = one.length > 90 ? `${one.slice(0, 87)}…` : one;
198
+ /**
199
+ * ⚠️ THE COST SO FAR IS ON THIS LINE ON PURPOSE. The final summary prices the
200
+ * CONTINUATION, because that is the run it describes; without this line the
201
+ * dollars spent before the steer would appear nowhere a person reads, and a
202
+ * number that quietly omits part of the bill is the one thing this package
203
+ * refuses to print.
204
+ */
205
+ const money = typeof spentUsd === 'number' && spentUsd > 0 ? ` · $${spentUsd.toFixed(6)} spent so far` : '';
206
+ return ` ⤳ steering: "${shown}"${truncated ? ' (truncated)' : ''} — applied now, ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left${money}`;
207
+ }
208
+
209
+ /**
210
+ * ── ⚠️⚠️ THE WORDS ARE HANDED BACK, BECAUSE THE FILE IS ALREADY GONE ────────
211
+ *
212
+ * `takeSteer` deletes on read — it has to, or one instruction repeats at every
213
+ * boundary forever. The consequence is that when a steer CANNOT be applied
214
+ * (the run had already finished, the rounds ran out, the cap was hit) the only
215
+ * remaining copy of what the user typed is in this process's memory. Printing
216
+ * the reason alone would mean they watched their sentence be consumed and
217
+ * discarded, and had to retype it from memory.
218
+ *
219
+ * ⭐ So the text comes back, on its own line, in a shape that can be copied
220
+ * straight into the suggested command. This is the same rule the refusal
221
+ * messages obey elsewhere in the package: a refusal that does not say what to
222
+ * do next is half a refusal.
223
+ */
224
+ export function formatUnapplied({ text, reason }) {
225
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
226
+ return [
227
+ ` ⤳ steer NOT applied: ${reason}`,
228
+ ` what you wrote is not lost — it was: ${JSON.stringify(one)}`,
229
+ ].join('\n');
230
+ }
231
+
232
+ /**
233
+ * ── ⭐ SHOULD THIS TURN CONTINUE, AND WITH WHAT? ────────────────────────────
234
+ *
235
+ * The whole decision, pure, so it is testable without a model, a terminal or a
236
+ * filesystem — and so the CLI can be three lines that cannot get it wrong.
237
+ *
238
+ * ⚠️ IT REFUSES UNLESS THE RUN REALLY STOPPED FOR THE STEER. `stoppedBecause`
239
+ * has to be `'aborted'`: if the loop ended on `verified`, `no-tool-calls` or a
240
+ * budget stop in the same instant the steer was picked up, the work is over and
241
+ * starting a fresh segment would be buying rounds nobody asked for. The steer is
242
+ * reported as unapplied rather than silently dropped — see rule 2.
243
+ *
244
+ * @param {{ steer: {text:string,truncated?:boolean}|null, outcome: any, maxRounds: number, steersUsed: number, maxSteers?: number }} p
245
+ * @returns {{ go: false, reason: string|null } | { go: true, task: string, priorMessages: any[], maxRounds: number, roundsLeft: number }}
246
+ */
247
+ export function planSteer({ steer, outcome, maxRounds, steersUsed, maxSteers = MAX_STEERS }) {
248
+ if (!steer || !steer.text) return { go: false, reason: null };
249
+ if (steer.stale === true) {
250
+ return { go: false, reason: 'it was written BEFORE this run started, so it is not about this task — a leftover from an earlier run. It has been cleared.' };
251
+ }
252
+ if (outcome?.stoppedBecause !== 'aborted') {
253
+ return { go: false, reason: `the run had already finished (${outcome?.stoppedBecause ?? 'unknown'}) — nothing was steered. Re-run with it as the task, or \`acuvo --continue "…"\`.` };
254
+ }
255
+ if (!Array.isArray(outcome?.messages) || outcome.messages.length === 0) {
256
+ /**
257
+ * ⚠️ WITHOUT THE TRANSCRIPT A CONTINUATION IS A NEW RUN WEARING ITS NAME —
258
+ * it would re-gather the workspace, lose the 96%-cached prefix, and lose
259
+ * every fact the first segment learned. Refusing is the honest answer.
260
+ */
261
+ return { go: false, reason: 'the run returned no transcript, so there is nothing to carry on from' };
262
+ }
263
+ if (steersUsed >= maxSteers) {
264
+ return { go: false, reason: `already steered ${steersUsed} time${steersUsed === 1 ? '' : 's'} this turn (the cap is ${maxSteers}) — this one was not applied` };
265
+ }
266
+
267
+ const used = Number(outcome?.roundsUsed ?? 0);
268
+ const left = Math.max(0, Number(maxRounds ?? 0) - (Number.isFinite(used) ? used : 0));
269
+ if (left < 1) {
270
+ return { go: false, reason: `no rounds left (--max-rounds ${maxRounds} is spent) — this steer was not applied. Raise --max-rounds, or \`acuvo --continue\` with it.` };
271
+ }
272
+
273
+ return {
274
+ go: true,
275
+ task: steerTask(steer.text),
276
+ priorMessages: outcome.messages,
277
+ maxRounds: left,
278
+ roundsLeft: left,
279
+ };
280
+ }