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/audit.mjs ADDED
@@ -0,0 +1,530 @@
1
+ /**
2
+ * ── ⭐⭐ THE RUN LOG — EVIDENCE, NOT TELEMETRY ───────────────────────────────
3
+ *
4
+ * A regulated buyer cannot adopt an agent that edits their source and then
5
+ * forgets it did. Their question is never "is it good", it is "show me what it
6
+ * did on the fourteenth" — and today the only answer is a terminal scrollback
7
+ * that closed. `--json` gave a script one object per invocation, which is the
8
+ * same fact with the same lifetime: it exists until the pipe ends.
9
+ *
10
+ * ⭐ SO THIS FILE IS `--json` WITH A MEMORY, AND ALMOST NO NEW SHAPE. `toJson()`
11
+ * in `report.mjs` already decided what a run IS — ok, rounds, verification,
12
+ * changes, cost, refusals — and that shape is a stable contract we have already
13
+ * paid for. Re-deriving it here would give us two answers to one question and
14
+ * guarantee they drift. This module wraps it, corrects the one field it gets
15
+ * wrong (see MODEL below), and makes it durable.
16
+ *
17
+ * ── ⚠️ WHAT AN AUDIT LOG IS NOT ─────────────────────────────────────────────
18
+ * It is not a debug log and it must never become one. Every field here is a
19
+ * field someone may one day have to hand to a regulator, which means every
20
+ * field is a field that can leak. The pressure to add "and the model's reply,
21
+ * for debugging" will be constant; the answer is a debug log somewhere else.
22
+ *
23
+ * ── ⚠️⚠️ AND THE FAILURE MODE THAT MATTERS MOST IS SILENCE ──────────────────
24
+ * An audit log that quietly failed to write is worse than no audit log at all,
25
+ * because the buyer believes they have evidence and does not go looking for it
26
+ * until the day they need it. So nothing here throws — every function returns a
27
+ * result — and the caller is expected to WARN when a write fails rather than
28
+ * swallow it. A run must not die because logging did; a log must not fail
29
+ * quietly because the run lived.
30
+ */
31
+
32
+ import { appendFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { createHash } from 'node:crypto';
35
+
36
+ import { toJson } from './report.mjs';
37
+ import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
38
+ import { gpuSpend } from './budget.mjs';
39
+
40
+ /** Bump when a field changes meaning. Nobody reading this file in two years
41
+ * will know which writer produced a line, and guessing is how a compliance
42
+ * report ends up averaging two different definitions of `cost`. */
43
+ export const AUDIT_SCHEMA_VERSION = 1;
44
+
45
+ export const AUDIT_DIR = '.acuvo/audit';
46
+
47
+ /**
48
+ * ⚠️ THE TASK IS TRUNCATED, THE HASH IS OF THE WHOLE THING. Someone pasting a
49
+ * 40KB spec into `--task` must not silently get a 40KB line per run — but they
50
+ * also must not be told the record covers the whole task when it holds a page
51
+ * of it. The hash closes that gap: an operator holding the original can prove
52
+ * it is the one this run acted on, without the log ever storing it.
53
+ */
54
+ export const MAX_TASK_CHARS = 2_000;
55
+ /** One agent round can touch hundreds of files; the record stays a record. */
56
+ export const MAX_CHANGES = 200;
57
+ /** See `redact` — a bound on the input, not on the output. */
58
+ export const MAX_REDACT_CHARS = 20_000;
59
+
60
+ /**
61
+ * ⚠️ THESE BOUND A LAPTOP'S DISK. THEY ARE NOT A RETENTION POLICY, and must
62
+ * never be described as one to a buyer: a real retention obligation is measured
63
+ * in years and is satisfied by shipping these files OFF the box, not by a CLI
64
+ * deciding when a record stops mattering. Ninety daily files is what keeps a
65
+ * machine running this every day from accumulating forever; an operator with an
66
+ * obligation raises it and syncs the directory somewhere durable.
67
+ */
68
+ export const MAX_AUDIT_FILES = 90;
69
+ export const MAX_AUDIT_TOTAL_BYTES = 32 * 1024 * 1024;
70
+
71
+ /* ────────────────────────────────────────────────────────────────────────────
72
+ * SECRETS
73
+ * ──────────────────────────────────────────────────────────────────────────── */
74
+
75
+ /**
76
+ * ── ⭐⭐ THE REAL CONTROL IS OMISSION, NOT REDACTION ─────────────────────────
77
+ *
78
+ * The brief is "must never record secrets", and a regex is the weakest possible
79
+ * way to honour it. The strong control is the list of things this record does
80
+ * not have fields for, and it is deliberate every time:
81
+ *
82
+ * FILE CONTENTS — never. `describeChange` already reports paths, byte
83
+ * counts, line counts and the SHARE of a file replaced,
84
+ * with no text. That is enough to answer "what did it
85
+ * change" and it cannot leak a `.env` the agent read.
86
+ * COMMAND OUTPUT — never. Test output routinely prints connection strings
87
+ * and signed URLs on failure. We keep the exit code.
88
+ * THE MODEL'S PROSE — never. `toJson` already omits `outcome.note`, and it
89
+ * is the field most likely to quote a secret back.
90
+ * THE ENVIRONMENT — never. `scrubEnvironment` in `command.mjs` keeps it out
91
+ * of child processes; it has no business coming back in
92
+ * through the log.
93
+ *
94
+ * ⭐ What is LEFT is exactly what cannot be omitted without destroying the
95
+ * record's purpose: the task the human typed, the command that verified it, and
96
+ * the error strings from refusals. Those three are user-authored or echo
97
+ * user-authored text, so those three get scrubbed — and that is the whole job
98
+ * of the regex below.
99
+ *
100
+ * ── ⚠️ IT IS A DENYLIST, AND THAT IS A CONSCIOUS EXCEPTION ──────────────────
101
+ * Same exception `scrubEnvironment` makes, for the same reason, and it deserves
102
+ * the same honest statement of the guarantee: this catches conventionally
103
+ * shaped credentials. A bare 32-character password with no name in front of it
104
+ * survives, because the pattern that caught it would also redact every git SHA
105
+ * and half the file paths, and a log nobody can read is a log nobody keeps.
106
+ * Treat it as one layer. The layer that actually holds is the omission list.
107
+ *
108
+ * ⭐ AND IT ERRS TOWARDS OVER-REDACTION. `keychain: <six chars>` gets masked.
109
+ * That is the correct direction to be wrong in for a file whose entire promise
110
+ * is that it contains no secrets.
111
+ */
112
+ const REDACTIONS = [
113
+ // First, because it is the only multi-line one and the others would shred it.
114
+ [/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g, '[redacted:private-key]'],
115
+ [/\b(?:sk|rk)-(?:or-v1-)?[A-Za-z0-9_-]{16,}/g, '[redacted:api-key]'],
116
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[redacted:github-token]'],
117
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[redacted:github-token]'],
118
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted:aws-key-id]'],
119
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted:slack-token]'],
120
+ [/\bAIza[0-9A-Za-z_-]{35}/g, '[redacted:google-api-key]'],
121
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted:jwt]'],
122
+ [/\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{12,}/g, 'Bearer [redacted]'],
123
+ // `postgres://user:pass@host` — the credential nobody thinks of as a secret
124
+ // because it looks like a URL, and the one most likely to be in a task.
125
+ [/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/g, '$1[redacted]@'],
126
+ /**
127
+ * The name-then-value rule, last so the specific shapes above win.
128
+ *
129
+ * ⚠️ THE NAME CLASS EXCLUDES `.` ON PURPOSE. With it, `lib/auth.mjs: <text>`
130
+ * matched and the log started masking file references — an audit log that
131
+ * hides which file was touched has redacted the evidence instead of the
132
+ * secret.
133
+ */
134
+ [/\b([A-Za-z0-9_-]*(?:key|token|secret|password|passwd|credential|auth|dsn)[A-Za-z0-9_-]*)(["'\s]*[:=]\s*["']?)([^\s"',;]{6,})/gi,
135
+ (_m, name, sep) => `${name}${sep}[redacted]`],
136
+ ];
137
+
138
+ /**
139
+ * Mask conventionally shaped credentials in a string. Pure.
140
+ *
141
+ * ⚠️ THE INPUT IS CAPPED BEFORE THE REGEXES RUN. The private-key pattern is a
142
+ * lazy scan to a terminator that may not exist; handed a megabyte of BEGIN
143
+ * markers it degrades badly, and "the audit writer hung" is a denial of service
144
+ * with an innocent explanation. Nothing that reaches here is legitimately
145
+ * longer than this anyway — the task is truncated to 2,000 characters and
146
+ * refusal errors to 300.
147
+ */
148
+ export function redact(text) {
149
+ if (typeof text !== 'string' || text === '') return text;
150
+ let out = text.length > MAX_REDACT_CHARS ? text.slice(0, MAX_REDACT_CHARS) : text;
151
+ for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement);
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Redact every string anywhere in a structure. Pure.
157
+ *
158
+ * ⭐ WHY THIS IS RECURSIVE AND NOT A LIST OF FIELDS. A named list — "scrub
159
+ * task, command and refusals" — is a list someone forgets to extend the day
160
+ * they add a field, and the failure is invisible: the log keeps writing, the
161
+ * tests keep passing, and the new field carries the secret. Walking the whole
162
+ * object means a field added by a future `toJson` is scrubbed before anyone
163
+ * remembers this file exists.
164
+ *
165
+ * Keys are left alone: they are literals in our own source, never user text.
166
+ */
167
+ export function deepRedact(value) {
168
+ if (typeof value === 'string') return redact(value);
169
+ if (Array.isArray(value)) return value.map(deepRedact);
170
+ if (value && typeof value === 'object') {
171
+ /** @type {Record<string, any>} */
172
+ const out = {};
173
+ for (const [k, v] of Object.entries(value)) out[k] = deepRedact(v);
174
+ return out;
175
+ }
176
+ return value;
177
+ }
178
+
179
+ /* ────────────────────────────────────────────────────────────────────────────
180
+ * THE RECORD
181
+ * ──────────────────────────────────────────────────────────────────────────── */
182
+
183
+ /**
184
+ * ── ⚠️⚠️ THE MODEL THAT ANSWERED IS NOT THE MODEL THAT WAS ASKED FOR ────────
185
+ *
186
+ * `chain.mjs` exists because one provider is one outage, and its result says
187
+ * which candidate replied (`model`, `usedFallback`, `chainTried` —
188
+ * `chain.mjs:119-125`). ⚠️ `turn.mjs` throws all three away: the three
189
+ * `rounds.push` calls (lines 824, 834, 894) record note/usage/finishReason and
190
+ * no model, and the session returns `model: config.model` (line 1076) — the
191
+ * REQUESTED one. Grep `lib/` and `bin/` for `reply.model` and there are no hits.
192
+ *
193
+ * So on a day OpenRouter rate-limits, the record would swear the work was done
194
+ * by the model the user configured when a free fallback actually did it. For a
195
+ * buyer whose whole reason for reading this file is "which system processed our
196
+ * code", that is not a cosmetic inaccuracy — it is the log being wrong about
197
+ * the only thing they came to check.
198
+ *
199
+ * ⭐ SO IT REPORTS `null` UNTIL THE LOOP TELLS US. This is `describeChange`'s
200
+ * rule about a confidently wrong zero, applied where it costs something: a
201
+ * script can test for `null`, and it cannot know that a model name is a guess.
202
+ * The one-line fix in `turn.mjs` is in the handoff notes; this function reads
203
+ * it the moment it lands and needs no change when it does.
204
+ */
205
+ export function answeringModels(outcome) {
206
+ const seen = [];
207
+ for (const r of outcome?.rounds ?? []) {
208
+ if (typeof r?.model === 'string' && r.model && !seen.includes(r.model)) seen.push(r.model);
209
+ }
210
+ // The session-level field is the other place the fix could land.
211
+ if (seen.length === 0 && typeof outcome?.answeredModel === 'string' && outcome.answeredModel) {
212
+ seen.push(outcome.answeredModel);
213
+ }
214
+ return seen;
215
+ }
216
+
217
+ /** How many individual GPU calls a record itemises before it stops being a record. */
218
+ export const MAX_GPU_CALLS_LOGGED = 40;
219
+
220
+ /**
221
+ * ── ⭐⭐ WHAT THE RUN COST, INCLUDING THE PART NOBODY BILLED US FOR ──────────
222
+ *
223
+ * `toJson`'s `costUsd` is `outcome.usage.cost` — model tokens, aggregated over
224
+ * the rounds. Every Modal container the run started was absent from it, which
225
+ * is why `acuvo spend` and `--fleet-budget` (which reads the same field through
226
+ * `summariseSpend`) were both pricing a fraction of the bill.
227
+ *
228
+ * ⭐ THE RUN'S OWN BUDGET IS THE AUTHORITY WHEN THERE IS ONE. `outcome.budget`
229
+ * is what that run's governor actually claimed, and in `--parallel` / `--best-of`
230
+ * several sessions share one process and therefore one module-level ledger —
231
+ * reading the ledger there would charge every sibling for all of them. The
232
+ * process ledger is the fallback for a run with no budget object at all.
233
+ *
234
+ * @param {any} outcome
235
+ * @param {{usd: number, calls: any[]}} [ledger] injected for tests
236
+ */
237
+ export function runGpuSpend(outcome, ledger = gpuSpend()) {
238
+ const calls = (ledger?.calls ?? []).slice(0, MAX_GPU_CALLS_LOGGED);
239
+ const budget = outcome?.budget;
240
+ if (budget && typeof budget === 'object') {
241
+ const usd = Number.isFinite(budget.gpuUsd) ? budget.gpuUsd : 0;
242
+ return { usd, calls: usd > 0 ? calls : [], source: 'budget' };
243
+ }
244
+ return { usd: Number.isFinite(ledger?.usd) ? ledger.usd : 0, calls, source: 'ledger' };
245
+ }
246
+
247
+ /** SHA-256, hex. Of the ORIGINAL task — see MAX_TASK_CHARS. */
248
+ export function taskFingerprint(task) {
249
+ return createHash('sha256').update(String(task ?? ''), 'utf8').digest('hex');
250
+ }
251
+
252
+ /**
253
+ * Build the record. Pure — `now` and the id are injected, so a test asserts on
254
+ * an exact line instead of on a regex with a timestamp hole in it.
255
+ *
256
+ * @param {any} outcome the object `runSession` returned
257
+ * @param {{ changes?: any[], task?: string|null, now?: Date|string, id?: string|null }} [opts]
258
+ */
259
+ export function auditRecord(outcome, { changes = [], task = null, now = new Date(), id = null, gpu = null } = {}) {
260
+ const at = typeof now === 'string' ? now : now.toISOString();
261
+ const sha = taskFingerprint(task);
262
+
263
+ /**
264
+ * Redacted BEFORE truncation. The other order looks equivalent and is not:
265
+ * a key at character 2,050 would be cut off today and survive the first time
266
+ * someone raises the limit, which is the kind of regression that ships
267
+ * because the test still passes.
268
+ */
269
+ const scrubbed = redact(String(task ?? ''));
270
+ const trimmed = scrubbed.length > MAX_TASK_CHARS
271
+ ? `${scrubbed.slice(0, MAX_TASK_CHARS)}…[+${scrubbed.length - MAX_TASK_CHARS} chars]`
272
+ : scrubbed;
273
+
274
+ const kept = changes.slice(0, MAX_CHANGES);
275
+
276
+ /**
277
+ * ⭐ THE SHAPE IS `toJson`'s, DELIBERATELY AND ENTIRELY. Two documents
278
+ * describing one run must not disagree, and the surest way to make them agree
279
+ * is for one of them to be the other.
280
+ */
281
+ const base = toJson(outcome, { changes: kept, task: trimmed });
282
+
283
+ const answered = answeringModels(outcome);
284
+ const run = {
285
+ ...base,
286
+ /**
287
+ * Replaces `toJson`'s scalar `model`, which is the requested one. A record
288
+ * that offered both under one key would be read as one fact.
289
+ */
290
+ model: {
291
+ requested: base.model ?? null,
292
+ // The last one is the one that produced the final state.
293
+ answered: answered.length > 0 ? answered[answered.length - 1] : null,
294
+ chain: answered,
295
+ },
296
+ ...(changes.length > kept.length ? { changesOmitted: changes.length - kept.length } : {}),
297
+ };
298
+
299
+ /**
300
+ * ── ⭐⭐ ONE BOOK. `costUsd` IS WHAT THE RUN COST, NOT WHAT THE MODEL COST ──
301
+ *
302
+ * ⚠️ AND THE SCHEMA VERSION IS DELIBERATELY NOT BUMPED. `costUsd` has always
303
+ * meant "what this run cost"; it was simply incomplete, and correcting a
304
+ * value is not redefining a field. The new `cost` block beside it is purely
305
+ * additive, and it is ABSENT on a run that spent no GPU — so every existing
306
+ * line, and every line a no-GPU run writes tomorrow, is byte-for-byte what it
307
+ * was. A version bump would have changed all of them to announce a field they
308
+ * do not carry.
309
+ *
310
+ * ⚠️⚠️ AND THE ESTIMATE IS NAMED IN THE RECORD ITSELF. This file's own header
311
+ * insists that a null cost must never be summed as zero; the same argument
312
+ * forbids a price-table figure being read as a bill. A regulated buyer
313
+ * reading this line has to be able to tell which dollars came off an invoice
314
+ * and which came off our arithmetic, so `cost.estimatedUsd` is separate from
315
+ * `cost.modelUsd` and `cost.estimated` says so in one boolean.
316
+ */
317
+ const spend = gpu ?? runGpuSpend(outcome);
318
+ if (spend.usd > 0) {
319
+ const modelUsd = typeof base.costUsd === 'number' && Number.isFinite(base.costUsd) ? base.costUsd : null;
320
+ run.costUsd = (modelUsd ?? 0) + spend.usd;
321
+ run.cost = {
322
+ /** ⚠️ null means the provider never said — NOT zero. Same rule as costUsd. */
323
+ modelUsd,
324
+ estimatedUsd: spend.usd,
325
+ estimated: true,
326
+ basis: 'GPU seconds measured at the call site, priced from a published rate table — we are not billed per call',
327
+ calls: spend.calls.map((c) => ({
328
+ verb: c.verb,
329
+ kind: c.kind,
330
+ usd: c.usd,
331
+ seconds: c.seconds,
332
+ billedSeconds: c.billedSeconds,
333
+ basis: c.basis,
334
+ })),
335
+ };
336
+ }
337
+
338
+ return {
339
+ v: AUDIT_SCHEMA_VERSION,
340
+ // Deterministic: the instant plus the task's fingerprint. Two runs collide
341
+ // only if the same task starts in the same millisecond, and a random suffix
342
+ // would have cost this function its purity for that.
343
+ id: id ?? `${at}-${sha.slice(0, 8)}`,
344
+ at,
345
+ taskSha256: sha,
346
+ /**
347
+ * ⚠️ The wrapper above is exempt from the scrub because none of it comes
348
+ * from the user or the model — it is a version, a clock and two hashes.
349
+ * Everything that does is inside `run`, and all of it goes through the walk.
350
+ */
351
+ run: deepRedact(run),
352
+ };
353
+ }
354
+
355
+ /**
356
+ * One record, one line.
357
+ *
358
+ * ⚠️ NEVER TRUNCATE THE SERIALISED LINE TO FIT A BUDGET. A clipped JSON line is
359
+ * not a smaller record, it is an unparseable one — the run would be logged and
360
+ * simultaneously not exist, which is the worst of both. Every bound in this
361
+ * file is therefore on an INPUT (task chars, change count, refusal length), so
362
+ * the output is bounded by construction and always whole.
363
+ *
364
+ * ⭐ And this is why JSONL is the right format rather than a nicety:
365
+ * `JSON.stringify` escapes control characters, so a task containing newlines
366
+ * cannot break the one-record-per-line invariant the reader depends on.
367
+ */
368
+ export function serializeRecord(record) {
369
+ return `${JSON.stringify(record)}\n`;
370
+ }
371
+
372
+ /**
373
+ * Read a log back, tolerantly.
374
+ *
375
+ * ⭐ DAMAGED LINES ARE COUNTED, NOT SWALLOWED. A reader that silently skips
376
+ * what it cannot parse turns "three records were destroyed" into "there were
377
+ * three fewer runs" — and for an audit log that difference is the entire
378
+ * question. The count is itself evidence, so it comes back with the records.
379
+ */
380
+ export function parseAuditLog(text) {
381
+ const records = [];
382
+ let damaged = 0;
383
+ for (const line of String(text ?? '').split('\n')) {
384
+ if (line.trim() === '') continue;
385
+ try {
386
+ records.push(JSON.parse(line));
387
+ } catch {
388
+ damaged += 1;
389
+ }
390
+ }
391
+ return { records, damaged };
392
+ }
393
+
394
+ /* ────────────────────────────────────────────────────────────────────────────
395
+ * DISK
396
+ * ──────────────────────────────────────────────────────────────────────────── */
397
+
398
+ /**
399
+ * ⭐ ISO DATES SO THE NAMES SORT CHRONOLOGICALLY, and UTC so they do not
400
+ * reorder when the clock moves. Every prune decision below is a string sort;
401
+ * `10-8-2026.jsonl` would make it a lie twice a year.
402
+ */
403
+ export function dayFileName(at) {
404
+ const iso = typeof at === 'string' ? at : at.toISOString();
405
+ return `${iso.slice(0, 10)}.jsonl`;
406
+ }
407
+
408
+ /**
409
+ * Decide which files to delete. Pure — the whole reason it is separate.
410
+ *
411
+ * @param {{name: string, bytes: number}[]} files
412
+ * @returns {string[]} names to delete, oldest first
413
+ */
414
+ export function planPrune(files, { maxFiles = MAX_AUDIT_FILES, maxTotalBytes = MAX_AUDIT_TOTAL_BYTES } = {}) {
415
+ const sorted = [...files].sort((a, b) => a.name.localeCompare(b.name));
416
+ let total = sorted.reduce((n, f) => n + (f.bytes || 0), 0);
417
+ const doomed = [];
418
+ /**
419
+ * ⚠️ `> 1` NOT `> 0` — THE NEWEST FILE IS NEVER DELETED, even if it alone
420
+ * blows the byte budget. It is the file we are appending to right now, so
421
+ * pruning it would destroy the very run being recorded, and a single
422
+ * pathological day would take the log with it every time.
423
+ */
424
+ while (sorted.length > 1 && (sorted.length > maxFiles || total > maxTotalBytes)) {
425
+ const gone = sorted.shift();
426
+ total -= gone.bytes || 0;
427
+ doomed.push(gone.name);
428
+ }
429
+ return doomed;
430
+ }
431
+
432
+ /** What is on disk, in the shape `planPrune` wants. Never throws. */
433
+ function listAuditFiles(dir) {
434
+ if (!existsSync(dir)) return [];
435
+ let names;
436
+ try {
437
+ names = readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
438
+ } catch {
439
+ return [];
440
+ }
441
+ const out = [];
442
+ for (const name of names) {
443
+ try {
444
+ out.push({ name, bytes: statSync(join(dir, name)).size });
445
+ } catch {
446
+ // Vanished between the listing and the stat — a parallel run pruned it.
447
+ // Not our problem and not worth failing a write over.
448
+ }
449
+ }
450
+ return out;
451
+ }
452
+
453
+ /**
454
+ * Append one record, then keep the directory bounded.
455
+ *
456
+ * ── ⚠️ WHAT "CRASH-SAFE" HONESTLY MEANS HERE ────────────────────────────────
457
+ * Not that the last write is atomic — it is one `writeSync` loop and a kill
458
+ * between iterations leaves a partial line. The guarantee is the one that
459
+ * matters: **a killed process cannot damage a record that was already written.**
460
+ * Three things buy it, and all three are load-bearing:
461
+ *
462
+ * 1. `flag: 'a'` — O_APPEND. Every write goes to the end. Nothing ever seeks
463
+ * backwards, so there is no offset at which an earlier line lives that a
464
+ * later write could land on.
465
+ * 2. ONE `appendFileSync` CALL for line-plus-newline. Two calls double the
466
+ * window in which a crash separates a record from its terminator, for no
467
+ * benefit; the newline is part of the buffer, not a follow-up.
468
+ * 3. ⭐ PRUNING DELETES WHOLE FILES AND NEVER REWRITES ONE. This is the rule
469
+ * that keeps "append-only" and "bounded" from being in tension. The
470
+ * obvious way to cap a log — read it, drop the oldest lines, write it back
471
+ * — is a read-modify-write over live evidence, and a crash in the middle
472
+ * of it loses everything the file held. Rotating by day and unlinking old
473
+ * days means the bound never touches a byte that is still wanted.
474
+ *
475
+ * A torn tail therefore costs at most the record being written when the machine
476
+ * died, and `parseAuditLog` reports it as damaged rather than hiding it.
477
+ *
478
+ * ⚠️ RETURNS A RESULT, NEVER THROWS — see the file header. The caller warns.
479
+ */
480
+ export function appendAudit(root, record, { dir = AUDIT_DIR, maxFiles, maxTotalBytes } = {}) {
481
+ const target = join(root, dir);
482
+ let file;
483
+ try {
484
+ mkdirSync(target, { recursive: true });
485
+ /**
486
+ * ⚠️ THE MOMENT `.acuvo/` COMES INTO EXISTENCE IS THE MOMENT IT MUST STOP
487
+ * DIRTYING THE TREE — and this is one of only two places it happens in a
488
+ * normal run. Found by our own bench: the `git` task did the work correctly,
489
+ * committed exactly the right file, and failed on `left the tree dirty:
490
+ * ?? .acuvo/`.
491
+ *
492
+ * ⭐ HERE RATHER THAN AT STARTUP, deliberately. The first attempt called it
493
+ * once in `bin/acuvo.mjs` and created the directory on every invocation —
494
+ * including `--dry-run`, which promises to touch nothing. This path already
495
+ * respects every opt-out, so hanging the ignore off it inherits all of them.
496
+ */
497
+ ensureAcuvoDirIgnored(root);
498
+ file = join(target, dayFileName(record?.at ?? new Date()));
499
+ appendFileSync(file, serializeRecord(record), { encoding: 'utf8', flag: 'a' });
500
+ } catch (err) {
501
+ return { ok: false, error: `could not write the audit record: ${err?.message ?? err}` };
502
+ }
503
+
504
+ /**
505
+ * ⚠️ PRUNE FAILURES DO NOT FAIL THE WRITE. The record is already durable at
506
+ * this point; reporting `ok: false` because a stale file could not be
507
+ * unlinked would make the caller warn about a log that is, in fact, fine.
508
+ */
509
+ const pruned = [];
510
+ for (const name of planPrune(listAuditFiles(target), { maxFiles, maxTotalBytes })) {
511
+ try {
512
+ unlinkSync(join(target, name));
513
+ pruned.push(name);
514
+ } catch {
515
+ // Read-only directory, or another process got there first. Either way the
516
+ // bound is advisory and the evidence is intact, which is the priority.
517
+ }
518
+ }
519
+
520
+ return { ok: true, file, pruned };
521
+ }
522
+
523
+ /**
524
+ * The whole job, from an outcome to a line on disk. This is what the CLI calls.
525
+ *
526
+ * @param {{ root: string, outcome: any, changes?: any[], task?: string|null, now?: Date|string }} args
527
+ */
528
+ export function recordRun({ root, outcome, changes = [], task = null, now = new Date() }) {
529
+ return appendAudit(root, auditRecord(outcome, { changes, task, now }));
530
+ }