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