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
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ CHECKPOINT / REWIND — UNDO WHAT THE AGENT DID TO THE FILES ──────────
|
|
3
|
+
*
|
|
4
|
+
* MEASURED 2026-08-14, before this file existed: `grep -rn "checkpoint|rewind|
|
|
5
|
+
* undo|snapshot|restore" lib bin` returned nothing that restores a file. Every
|
|
6
|
+
* hit was something else — `lease-watch.mjs:46` snapshots foreign LEASES,
|
|
7
|
+
* `memory-workspace.mjs:84` snapshots an in-memory Map. So the agent could
|
|
8
|
+
* rewrite twelve files in five rounds and the only way back was git, and only
|
|
9
|
+
* if the tree had been clean when it started.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ THE INSIGHT THAT MAKES THIS CHEAP: every mutation already funnels through
|
|
12
|
+
* TWO functions. `lib/workspace.mjs` `writeFile()` and `deleteFile()` are the
|
|
13
|
+
* only doors — `write_files`, `edit_file` and the media verbs all call them
|
|
14
|
+
* (`lib/write-many.mjs:22-24` says so in as many words). `writeFile` already
|
|
15
|
+
* computes `existed` and `previousBytes` BEFORE writing, because the summary
|
|
16
|
+
* needs them. Reading the previous BYTES at that same point is the whole
|
|
17
|
+
* feature; everything below is bookkeeping around that one read.
|
|
18
|
+
*
|
|
19
|
+
* ── ⚠️⚠️ WHAT MAKES AN UNDO DANGEROUS, AND WHY THIS ONE REFUSES ─────────────
|
|
20
|
+
*
|
|
21
|
+
* A naive undo writes the old content back over whatever is there now. If the
|
|
22
|
+
* USER edited that file after the agent did — the normal case, because you read
|
|
23
|
+
* what it wrote and fixed a line — a blind restore destroys their edit while
|
|
24
|
+
* calling itself a safety feature. So every entry records the sha256 of what
|
|
25
|
+
* the agent LEFT, and `applyRewind` refuses any file whose current contents are
|
|
26
|
+
* not that. `--force` overrides, and says what it is overriding.
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ AND A CHECKPOINT THAT SILENTLY FAILED TO RECORD IS WORSE THAN NONE, for
|
|
29
|
+
* exactly the reason `audit.mjs` gives about a log that quietly failed to
|
|
30
|
+
* write: the operator believes they can undo, and finds out on the one day it
|
|
31
|
+
* matters. So a file too large to store is recorded as an entry that says so
|
|
32
|
+
* (`unrestorable`), `acuvo rewind` prints that count, and nothing here ever
|
|
33
|
+
* pretends a file is recoverable when its bytes were never kept.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ NOTHING HERE THROWS ON AN EXPECTED FAILURE. A write must not die because
|
|
36
|
+
* the journal could not be appended to — the work is the point, the record is
|
|
37
|
+
* the service. Failures accumulate in `journal.errors` so the caller can WARN,
|
|
38
|
+
* which is the same contract `audit.mjs` set.
|
|
39
|
+
*
|
|
40
|
+
* ── ⭐ WHY THE JOURNAL LIVES IN `.acuvo/` AND IS STILL SAFE ─────────────────
|
|
41
|
+
*
|
|
42
|
+
* The standing rule from the RCE we shipped was "the trust store must not live
|
|
43
|
+
* in the workspace". This does live there — and it is already out of the
|
|
44
|
+
* model's reach, because `agentWriteRefusal` (lib/workspace.mjs:428-433) hard-
|
|
45
|
+
* refuses EVERY agent write under `.acuvo/` with two or more segments, which
|
|
46
|
+
* `.acuvo/checkpoints/journal.jsonl` is. That refusal is load-bearing here for
|
|
47
|
+
* a new reason: an agent that can rewrite its own undo history can hide what it
|
|
48
|
+
* did. `test/checkpoint-agent-cannot-rewrite.test.mjs` pins it.
|
|
49
|
+
*
|
|
50
|
+
* ⭐ AND THE JOURNAL IS APPEND-ONLY. A rewind never edits or truncates it — it
|
|
51
|
+
* appends nothing either. Undoing a rewind is therefore just another rewind to
|
|
52
|
+
* an earlier point, and the record of what happened stays complete.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync,
|
|
57
|
+
} from 'node:fs';
|
|
58
|
+
import { dirname, join } from 'node:path';
|
|
59
|
+
import { createHash } from 'node:crypto';
|
|
60
|
+
|
|
61
|
+
import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
|
|
62
|
+
/**
|
|
63
|
+
* ⚠️⚠️ FOUND BY RUNNING THE REAL CLI, NOT BY A UNIT TEST (2026-08-14). The first
|
|
64
|
+
* end-to-end run announced "3 files can be put back" when the user had two:
|
|
65
|
+
* `evaluate` writes its snippet THROUGH the executor
|
|
66
|
+
* (lib/evaluate.mjs:202 `executor.writeFile(rel, source)`) and then removes it
|
|
67
|
+
* with raw `unlinkSync` (lib/evaluate.mjs:246), which this journal never sees.
|
|
68
|
+
* So the checkpoint held a create with no matching delete — an entry for a file
|
|
69
|
+
* that no longer exists, which would have made `acuvo rewind` print a confusing
|
|
70
|
+
* skip line about a scratch file the user never wrote.
|
|
71
|
+
*
|
|
72
|
+
* ⭐ IMPORTED, NEVER RE-TYPED. `evaluate.mjs` owns the shape of its own
|
|
73
|
+
* temporary name; a second copy of that regex here is how the filter ends up
|
|
74
|
+
* matching a name the tool stopped using.
|
|
75
|
+
*/
|
|
76
|
+
import { SNIPPET_NAME_PATTERN } from './evaluate.mjs';
|
|
77
|
+
|
|
78
|
+
/** Bump when a field changes meaning — a reader in a year cannot guess. */
|
|
79
|
+
export const CHECKPOINT_SCHEMA_VERSION = 1;
|
|
80
|
+
|
|
81
|
+
export const CHECKPOINT_DIR = '.acuvo/checkpoints';
|
|
82
|
+
export const JOURNAL_NAME = 'journal.jsonl';
|
|
83
|
+
export const BLOB_DIRNAME = 'blobs';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ⚠️ A CEILING ON WHAT IS COPIED, NOT ON WHAT MAY BE WRITTEN. `MAX_WRITE_BYTES`
|
|
87
|
+
* in workspace.mjs bounds the agent; this bounds our copy of the PREVIOUS
|
|
88
|
+
* contents, which is a different file and can be far larger (a checked-in
|
|
89
|
+
* dataset the agent rewrites). Above this the entry is still written, marked
|
|
90
|
+
* `unrestorable`, so the user is told rather than surprised.
|
|
91
|
+
*/
|
|
92
|
+
export const MAX_CHECKPOINT_BYTES = 4_000_000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Is this the agent's OWN scratch file rather than the user's work?
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ ONLY AT THE WORKSPACE ROOT AND ONLY THE EXACT GENERATED SHAPE, for the
|
|
98
|
+
* same reason `evaluate.mjs`'s own sweeper is that strict: `src/.acuvo-eval-
|
|
99
|
+
* notes.mjs` is somebody's file, and a checkpoint that quietly declines to
|
|
100
|
+
* record a file because its name looked temporary is a checkpoint that loses
|
|
101
|
+
* work.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} relPath
|
|
104
|
+
*/
|
|
105
|
+
export function isToolScratch(relPath) {
|
|
106
|
+
const p = String(relPath ?? '').replace(/\\/g, '/');
|
|
107
|
+
if (p.includes('/')) return false;
|
|
108
|
+
return SNIPPET_NAME_PATTERN.test(p);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {Buffer|string} data */
|
|
112
|
+
export function sha256(data) {
|
|
113
|
+
return createHash('sha256').update(data).digest('hex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ THE SAME SHAPE AS A SESSION ID (`lib/session.mjs:241`) ON PURPOSE, so an
|
|
118
|
+
* operator reading `acuvo --sessions` and `acuvo rewind` is not learning two
|
|
119
|
+
* id formats for the same run. It is deliberately NOT imported from there:
|
|
120
|
+
* this module must stay loadable without dragging in session serialisation,
|
|
121
|
+
* and the shape is asserted by a test rather than by a shared function.
|
|
122
|
+
*
|
|
123
|
+
* @param {Date} [now]
|
|
124
|
+
*/
|
|
125
|
+
export function newCheckpointId(now = new Date()) {
|
|
126
|
+
const iso = new Date(now).toISOString();
|
|
127
|
+
const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}`;
|
|
128
|
+
const salt = Math.random().toString(36).slice(2, 6);
|
|
129
|
+
return `${stamp}-${salt}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One entry, one line. JSONL because it is appended to under concurrency. */
|
|
133
|
+
export function serializeEntry(entry) {
|
|
134
|
+
return `${JSON.stringify(entry)}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* ⭐ PURE. Given the journal text, give back the entries worth trusting.
|
|
139
|
+
*
|
|
140
|
+
* ⚠️ A TORN OR HALF-WRITTEN LINE IS COUNTED, NOT THROWN ON. Seven terminals
|
|
141
|
+
* append to one file; a crash mid-append leaves a partial line, and losing the
|
|
142
|
+
* other six hundred entries over it would be the worst possible reaction.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} text
|
|
145
|
+
* @returns {{ entries: object[], unreadable: number }}
|
|
146
|
+
*/
|
|
147
|
+
export function parseJournal(text) {
|
|
148
|
+
const entries = [];
|
|
149
|
+
let unreadable = 0;
|
|
150
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
151
|
+
const trimmed = line.trim();
|
|
152
|
+
if (!trimmed) continue;
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(trimmed);
|
|
156
|
+
} catch {
|
|
157
|
+
unreadable += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// The three fields every later step indexes on. An entry missing one of
|
|
161
|
+
// them cannot be placed in the timeline, and guessing where it goes is how
|
|
162
|
+
// a rewind restores the wrong version.
|
|
163
|
+
if (!parsed || typeof parsed !== 'object'
|
|
164
|
+
|| typeof parsed.seq !== 'number' || !Number.isFinite(parsed.seq)
|
|
165
|
+
|| typeof parsed.path !== 'string' || parsed.path === ''
|
|
166
|
+
|| typeof parsed.runId !== 'string' || parsed.runId === '') {
|
|
167
|
+
unreadable += 1;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
entries.push(parsed);
|
|
171
|
+
}
|
|
172
|
+
return { entries, unreadable };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* ⭐ PURE. Fold the entries into one row per run, newest first — what
|
|
177
|
+
* `acuvo rewind` prints when you give it no id.
|
|
178
|
+
*
|
|
179
|
+
* @param {readonly object[]} entries
|
|
180
|
+
*/
|
|
181
|
+
export function groupRuns(entries) {
|
|
182
|
+
/** @type {Map<string, any>} */
|
|
183
|
+
const byRun = new Map();
|
|
184
|
+
for (const e of entries) {
|
|
185
|
+
let run = byRun.get(e.runId);
|
|
186
|
+
if (!run) {
|
|
187
|
+
run = {
|
|
188
|
+
runId: e.runId, at: e.at ?? null, lastAt: e.at ?? null, firstSeq: e.seq, lastSeq: e.seq,
|
|
189
|
+
paths: new Set(), writes: 0, deletes: 0, unrestorable: 0, task: null,
|
|
190
|
+
};
|
|
191
|
+
byRun.set(e.runId, run);
|
|
192
|
+
}
|
|
193
|
+
run.paths.add(e.path);
|
|
194
|
+
if (e.verb === 'delete') run.deletes += 1; else run.writes += 1;
|
|
195
|
+
if (e.unrestorable) run.unrestorable += 1;
|
|
196
|
+
if (e.seq < run.firstSeq) { run.firstSeq = e.seq; run.at = e.at ?? run.at; }
|
|
197
|
+
if (e.seq > run.lastSeq) { run.lastSeq = e.seq; run.lastAt = e.at ?? run.lastAt; }
|
|
198
|
+
if (!run.task && typeof e.task === 'string' && e.task) run.task = e.task;
|
|
199
|
+
}
|
|
200
|
+
return [...byRun.values()]
|
|
201
|
+
.map((r) => ({
|
|
202
|
+
runId: r.runId,
|
|
203
|
+
at: r.at,
|
|
204
|
+
lastAt: r.lastAt,
|
|
205
|
+
firstSeq: r.firstSeq,
|
|
206
|
+
lastSeq: r.lastSeq,
|
|
207
|
+
files: r.paths.size,
|
|
208
|
+
writes: r.writes,
|
|
209
|
+
deletes: r.deletes,
|
|
210
|
+
unrestorable: r.unrestorable,
|
|
211
|
+
task: r.task,
|
|
212
|
+
}))
|
|
213
|
+
.sort((a, b) => b.firstSeq - a.firstSeq);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* ── ⭐⭐ THE SEMANTICS, STATED ONCE: `rewind <id>` MEANS "PUT THE FILES BACK
|
|
218
|
+
* THE WAY THEY WERE BEFORE THAT RUN STARTED" ─────────────────────────
|
|
219
|
+
*
|
|
220
|
+
* So it covers that run AND everything after it, not that run alone. Undoing
|
|
221
|
+
* an older run while a newer one sits on top of it would restore a version no
|
|
222
|
+
* moment in time ever had — a "checkpoint" that produces a state that never
|
|
223
|
+
* existed is not a checkpoint. This is why the plan works on `seq >= start`.
|
|
224
|
+
*
|
|
225
|
+
* For each path in that window: the FIRST entry holds the state to go back to,
|
|
226
|
+
* and the LAST entry holds the state the agent left, which is what the current
|
|
227
|
+
* file on disk must still match for a restore to be safe.
|
|
228
|
+
*
|
|
229
|
+
* ⭐ PURE — no disk, no clock. The whole decision is testable from three
|
|
230
|
+
* literal entries, which is why the conflict rule below could be mutated and
|
|
231
|
+
* watched to go red.
|
|
232
|
+
*
|
|
233
|
+
* @param {readonly object[]} entries
|
|
234
|
+
* @param {string} runId
|
|
235
|
+
*/
|
|
236
|
+
export function planRewind(entries, runId) {
|
|
237
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
238
|
+
const start = sorted.find((e) => e.runId === runId);
|
|
239
|
+
if (!start) {
|
|
240
|
+
return { ok: false, error: `no checkpoint named "${runId}" — run \`acuvo rewind\` to see the ids that exist` };
|
|
241
|
+
}
|
|
242
|
+
const window = sorted.filter((e) => e.seq >= start.seq);
|
|
243
|
+
/** @type {Map<string, object>} */
|
|
244
|
+
const first = new Map();
|
|
245
|
+
/** @type {Map<string, object>} */
|
|
246
|
+
const last = new Map();
|
|
247
|
+
for (const e of window) {
|
|
248
|
+
if (!first.has(e.path)) first.set(e.path, e);
|
|
249
|
+
last.set(e.path, e);
|
|
250
|
+
}
|
|
251
|
+
const ops = [...first.entries()].map(([path, f]) => {
|
|
252
|
+
const l = last.get(path);
|
|
253
|
+
const wasThere = f.beforeExisted === true;
|
|
254
|
+
const stored = typeof f.beforeBlob === 'string' && f.beforeBlob !== '';
|
|
255
|
+
return {
|
|
256
|
+
path,
|
|
257
|
+
// "restore" puts bytes back; "delete" removes a file the agent CREATED,
|
|
258
|
+
// which is the case a git-based undo gets wrong (an untracked file
|
|
259
|
+
// survives `git checkout .` and looks like it was always yours).
|
|
260
|
+
action: wasThere ? 'restore' : 'delete',
|
|
261
|
+
blob: stored ? f.beforeBlob : null,
|
|
262
|
+
beforeBytes: typeof f.beforeBytes === 'number' ? f.beforeBytes : 0,
|
|
263
|
+
/** What the agent LEFT — the current file must still be this. */
|
|
264
|
+
expectSha: typeof l.afterSha === 'string' ? l.afterSha : null,
|
|
265
|
+
expectExists: l.afterExists !== false,
|
|
266
|
+
restorable: wasThere ? stored : true,
|
|
267
|
+
reason: wasThere && !stored
|
|
268
|
+
? (typeof f.unrestorable === 'string' && f.unrestorable
|
|
269
|
+
? f.unrestorable
|
|
270
|
+
: 'its previous contents were never stored')
|
|
271
|
+
: null,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
ops.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
275
|
+
return { ok: true, runId, fromSeq: start.seq, at: start.at ?? null, ops };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── THE EDGES: everything below this line touches a disk ────────────────────
|
|
279
|
+
|
|
280
|
+
function journalPaths(root, dir) {
|
|
281
|
+
const base = join(String(root ?? ''), dir);
|
|
282
|
+
return { base, journal: join(base, JOURNAL_NAME), blobs: join(base, BLOB_DIRNAME) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function errText(e) {
|
|
286
|
+
return e instanceof Error ? e.message : String(e);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* ⚠️ A JOURNAL PATH IS TRUSTED ONLY BECAUSE OF WHERE IT CAME FROM — it is the
|
|
291
|
+
* `relative` field `resolveInWorkspace` produced, already proven inside the
|
|
292
|
+
* workspace. This re-checks anyway, because a journal is a FILE and a file can
|
|
293
|
+
* be edited: a hand-written `"path": "../../.ssh/authorized_keys"` must not
|
|
294
|
+
* turn `acuvo rewind` into an arbitrary-write primitive.
|
|
295
|
+
*/
|
|
296
|
+
function safeJoin(root, relPath) {
|
|
297
|
+
const segments = String(relPath ?? '').replace(/\\/g, '/').split('/').filter(Boolean);
|
|
298
|
+
if (segments.length === 0) return null;
|
|
299
|
+
if (segments.some((s) => s === '.' || s === '..')) return null;
|
|
300
|
+
if (/^[a-zA-Z]:$/.test(segments[0]) || String(relPath).startsWith('/')) return null;
|
|
301
|
+
return join(root, ...segments);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Open the journal for one run. Cheap: nothing is created until the first
|
|
306
|
+
* mutation, so a run that writes nothing leaves no directory behind.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} root
|
|
309
|
+
* @param {{ runId?: string, now?: () => Date, dir?: string, maxBytes?: number, task?: string|null }} [opts]
|
|
310
|
+
*/
|
|
311
|
+
export function openJournal(root, {
|
|
312
|
+
runId = newCheckpointId(), now = () => new Date(), dir = CHECKPOINT_DIR,
|
|
313
|
+
maxBytes = MAX_CHECKPOINT_BYTES, task = null,
|
|
314
|
+
} = {}) {
|
|
315
|
+
const paths = journalPaths(root, dir);
|
|
316
|
+
/** @type {string[]} */
|
|
317
|
+
const errors = [];
|
|
318
|
+
let ready = false;
|
|
319
|
+
let seq = 0;
|
|
320
|
+
let recorded = 0;
|
|
321
|
+
let unrestorable = 0;
|
|
322
|
+
const touched = new Set();
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* ⚠️ THE SEQUENCE CONTINUES THE FILE, it does not restart at 0. `planRewind`
|
|
326
|
+
* compares seq across runs to build the "everything after this point" window;
|
|
327
|
+
* two runs both numbering from 1 would interleave into nonsense.
|
|
328
|
+
*/
|
|
329
|
+
const ensure = () => {
|
|
330
|
+
if (ready) return true;
|
|
331
|
+
try {
|
|
332
|
+
ensureAcuvoDirIgnored(root);
|
|
333
|
+
mkdirSync(paths.blobs, { recursive: true });
|
|
334
|
+
if (existsSync(paths.journal)) {
|
|
335
|
+
const { entries } = parseJournal(readFileSync(paths.journal, 'utf8'));
|
|
336
|
+
for (const e of entries) if (e.seq >= seq) seq = e.seq + 1;
|
|
337
|
+
}
|
|
338
|
+
ready = true;
|
|
339
|
+
return true;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
errors.push(`could not open the checkpoint journal: ${errText(e)}`);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
runId,
|
|
348
|
+
dir,
|
|
349
|
+
get recorded() { return recorded; },
|
|
350
|
+
get files() { return touched.size; },
|
|
351
|
+
get unrestorable() { return unrestorable; },
|
|
352
|
+
errors,
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Record the state of `path` BEFORE it is changed. Call it immediately
|
|
356
|
+
* before the mutation, never after — there is nothing to read afterwards.
|
|
357
|
+
*
|
|
358
|
+
* @param {{ verb: 'write'|'delete', path: string, absolute: string, after?: string|null }} m
|
|
359
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
360
|
+
*/
|
|
361
|
+
record({ verb, path, absolute, after = null }) {
|
|
362
|
+
/**
|
|
363
|
+
* ⚠️ BEFORE `ensure()`, so a run whose only mutation is an `evaluate`
|
|
364
|
+
* snippet still creates no checkpoint directory at all — the scratch file
|
|
365
|
+
* is not work anybody can want back.
|
|
366
|
+
*/
|
|
367
|
+
if (isToolScratch(path)) return { ok: true, skipped: 'the tool\'s own scratch file' };
|
|
368
|
+
if (!ensure()) return { ok: false, error: errors[errors.length - 1] };
|
|
369
|
+
const entry = {
|
|
370
|
+
v: CHECKPOINT_SCHEMA_VERSION,
|
|
371
|
+
seq,
|
|
372
|
+
runId,
|
|
373
|
+
at: new Date(now()).toISOString(),
|
|
374
|
+
verb,
|
|
375
|
+
path,
|
|
376
|
+
beforeExisted: false,
|
|
377
|
+
beforeBytes: 0,
|
|
378
|
+
beforeSha: null,
|
|
379
|
+
beforeBlob: null,
|
|
380
|
+
afterExists: verb !== 'delete',
|
|
381
|
+
afterBytes: 0,
|
|
382
|
+
afterSha: null,
|
|
383
|
+
unrestorable: null,
|
|
384
|
+
};
|
|
385
|
+
if (typeof task === 'string' && task) entry.task = task.slice(0, 120);
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
if (existsSync(absolute)) {
|
|
389
|
+
const stat = statSync(absolute);
|
|
390
|
+
entry.beforeExisted = true;
|
|
391
|
+
entry.beforeBytes = stat.size;
|
|
392
|
+
if (stat.size > maxBytes) {
|
|
393
|
+
// ⚠️ SAID OUT LOUD RATHER THAN SKIPPED. The entry still exists so
|
|
394
|
+
// `acuvo rewind` can print "1 file cannot be restored"; dropping it
|
|
395
|
+
// would make the undo look complete when it is not.
|
|
396
|
+
entry.unrestorable = `it was ${stat.size} bytes, over the ${maxBytes}-byte checkpoint limit`;
|
|
397
|
+
} else {
|
|
398
|
+
const raw = readFileSync(absolute);
|
|
399
|
+
const digest = sha256(raw);
|
|
400
|
+
entry.beforeSha = digest;
|
|
401
|
+
// ⭐ CONTENT-ADDRESSED, so rewriting one file ten rounds running
|
|
402
|
+
// costs one copy per distinct version rather than ten.
|
|
403
|
+
const blobFile = join(paths.blobs, digest);
|
|
404
|
+
if (!existsSync(blobFile)) writeFileSync(blobFile, raw);
|
|
405
|
+
entry.beforeBlob = digest;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
entry.unrestorable = `its previous contents could not be read: ${errText(e)}`;
|
|
410
|
+
errors.push(`checkpoint: ${path}: ${errText(e)}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (verb !== 'delete' && typeof after === 'string') {
|
|
414
|
+
const buf = Buffer.from(after, 'utf8');
|
|
415
|
+
entry.afterBytes = buf.byteLength;
|
|
416
|
+
entry.afterSha = sha256(buf);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
appendFileSync(paths.journal, serializeEntry(entry), 'utf8');
|
|
421
|
+
} catch (e) {
|
|
422
|
+
errors.push(`checkpoint: could not record ${path}: ${errText(e)}`);
|
|
423
|
+
return { ok: false, error: errText(e) };
|
|
424
|
+
}
|
|
425
|
+
seq += 1;
|
|
426
|
+
recorded += 1;
|
|
427
|
+
touched.add(path);
|
|
428
|
+
if (entry.unrestorable) unrestorable += 1;
|
|
429
|
+
return { ok: true };
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* @param {string} root
|
|
436
|
+
* @param {{ dir?: string }} [opts]
|
|
437
|
+
*/
|
|
438
|
+
export function readJournal(root, { dir = CHECKPOINT_DIR } = {}) {
|
|
439
|
+
const paths = journalPaths(root, dir);
|
|
440
|
+
if (!existsSync(paths.journal)) {
|
|
441
|
+
return { ok: true, entries: [], unreadable: 0, path: paths.journal, empty: true };
|
|
442
|
+
}
|
|
443
|
+
let text;
|
|
444
|
+
try {
|
|
445
|
+
text = readFileSync(paths.journal, 'utf8');
|
|
446
|
+
} catch (e) {
|
|
447
|
+
return { ok: false, entries: [], unreadable: 0, path: paths.journal, error: `could not read ${paths.journal}: ${errText(e)}` };
|
|
448
|
+
}
|
|
449
|
+
const { entries, unreadable } = parseJournal(text);
|
|
450
|
+
return { ok: true, entries, unreadable, path: paths.journal, empty: entries.length === 0 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Carry out a plan from `planRewind`.
|
|
455
|
+
*
|
|
456
|
+
* ⚠️ IT SKIPS RATHER THAN STOPS. A file you edited yourself blocks THAT file
|
|
457
|
+
* and nothing else — aborting the whole rewind over one conflict would leave
|
|
458
|
+
* the tree half-way between two states, which is the one outcome worse than
|
|
459
|
+
* either.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} root
|
|
462
|
+
* @param {{ ok: true, runId: string, ops: any[] }} plan
|
|
463
|
+
* @param {{ dryRun?: boolean, force?: boolean, dir?: string }} [opts]
|
|
464
|
+
*/
|
|
465
|
+
export function applyRewind(root, plan, { dryRun = false, force = false, dir = CHECKPOINT_DIR } = {}) {
|
|
466
|
+
const paths = journalPaths(root, dir);
|
|
467
|
+
const restored = [];
|
|
468
|
+
const removed = [];
|
|
469
|
+
const skipped = [];
|
|
470
|
+
const failed = [];
|
|
471
|
+
|
|
472
|
+
for (const op of plan.ops ?? []) {
|
|
473
|
+
const absolute = safeJoin(root, op.path);
|
|
474
|
+
if (!absolute) {
|
|
475
|
+
failed.push({ path: op.path, error: 'that path is not inside the workspace — the journal line is not one this tool wrote' });
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!op.restorable) {
|
|
479
|
+
skipped.push({ path: op.path, reason: op.reason ?? 'its previous contents were never stored' });
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// What is there NOW, against what the agent left.
|
|
484
|
+
let currentSha = null;
|
|
485
|
+
let currentExists = false;
|
|
486
|
+
try {
|
|
487
|
+
if (existsSync(absolute)) {
|
|
488
|
+
currentExists = true;
|
|
489
|
+
currentSha = sha256(readFileSync(absolute));
|
|
490
|
+
}
|
|
491
|
+
} catch (e) {
|
|
492
|
+
failed.push({ path: op.path, error: `could not inspect it: ${errText(e)}` });
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* ⚠️⚠️ THE RULE THIS WHOLE FEATURE STANDS ON. Restoring over a file the
|
|
498
|
+
* user changed after the run destroys their work while calling itself
|
|
499
|
+
* safety. Three ways it can be untrue, and each gets its own sentence,
|
|
500
|
+
* because "conflict" tells the reader nothing they can act on.
|
|
501
|
+
*/
|
|
502
|
+
let conflict = null;
|
|
503
|
+
if (op.expectExists && !currentExists) conflict = 'it is not there any more';
|
|
504
|
+
else if (!op.expectExists && currentExists) conflict = 'a file is there that the agent had deleted';
|
|
505
|
+
else if (op.expectExists && op.expectSha && currentSha !== op.expectSha) conflict = 'it changed after the agent wrote it';
|
|
506
|
+
if (conflict && !force) {
|
|
507
|
+
skipped.push({ path: op.path, reason: `${conflict} — rewinding would throw that away. Use --force if that is what you want.` });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (dryRun) {
|
|
512
|
+
if (op.action === 'restore') restored.push({ path: op.path, bytes: op.beforeBytes, forced: Boolean(conflict) });
|
|
513
|
+
else removed.push({ path: op.path, forced: Boolean(conflict) });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
if (op.action === 'restore') {
|
|
519
|
+
const blobFile = join(paths.blobs, String(op.blob));
|
|
520
|
+
const raw = readFileSync(blobFile);
|
|
521
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
522
|
+
writeFileSync(absolute, raw);
|
|
523
|
+
restored.push({ path: op.path, bytes: raw.byteLength, forced: Boolean(conflict) });
|
|
524
|
+
} else {
|
|
525
|
+
if (currentExists) unlinkSync(absolute);
|
|
526
|
+
removed.push({ path: op.path, forced: Boolean(conflict) });
|
|
527
|
+
}
|
|
528
|
+
} catch (e) {
|
|
529
|
+
failed.push({ path: op.path, error: errText(e) });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
ok: failed.length === 0,
|
|
535
|
+
runId: plan.runId,
|
|
536
|
+
dryRun,
|
|
537
|
+
forced: force,
|
|
538
|
+
restored,
|
|
539
|
+
removed,
|
|
540
|
+
skipped,
|
|
541
|
+
failed,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* ⚠️ NOT CALLED BY THE RUN PATH, and that is deliberate: deleting somebody's
|
|
547
|
+
* only way back while they are working is not a tidy-up. Exported so a future
|
|
548
|
+
* `acuvo rewind --prune` has one implementation to call, and so the disk cost
|
|
549
|
+
* is measurable rather than a guess.
|
|
550
|
+
*
|
|
551
|
+
* @param {string} root
|
|
552
|
+
*/
|
|
553
|
+
export function checkpointSize(root, { dir = CHECKPOINT_DIR } = {}) {
|
|
554
|
+
const paths = journalPaths(root, dir);
|
|
555
|
+
let bytes = 0;
|
|
556
|
+
let blobs = 0;
|
|
557
|
+
try {
|
|
558
|
+
for (const name of readdirSync(paths.blobs)) {
|
|
559
|
+
bytes += statSync(join(paths.blobs, name)).size;
|
|
560
|
+
blobs += 1;
|
|
561
|
+
}
|
|
562
|
+
} catch { /* no checkpoints yet — zero is the honest answer */ }
|
|
563
|
+
try {
|
|
564
|
+
bytes += statSync(paths.journal).size;
|
|
565
|
+
} catch { /* same */ }
|
|
566
|
+
return { blobs, bytes };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── RENDERING ───────────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
function whenText(at) {
|
|
572
|
+
if (typeof at !== 'string' || !at) return '';
|
|
573
|
+
return `${at.slice(0, 10)} ${at.slice(11, 19)}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* @param {readonly any[]} runs
|
|
578
|
+
* @param {{ blobs: number, bytes: number } | null} [size] what it costs on disk
|
|
579
|
+
*/
|
|
580
|
+
export function formatCheckpoints(runs, size = null) {
|
|
581
|
+
if (!runs || runs.length === 0) {
|
|
582
|
+
return [
|
|
583
|
+
'no checkpoints in this workspace yet.',
|
|
584
|
+
'Every run records the previous contents of each file it writes, so this fills up as soon as one does.',
|
|
585
|
+
];
|
|
586
|
+
}
|
|
587
|
+
const lines = [`${runs.length} checkpoint${runs.length === 1 ? '' : 's'}, newest first:`, ''];
|
|
588
|
+
for (const r of runs) {
|
|
589
|
+
const bits = [`${r.files} file${r.files === 1 ? '' : 's'}`];
|
|
590
|
+
if (r.deletes > 0) bits.push(`${r.deletes} deleted`);
|
|
591
|
+
if (r.unrestorable > 0) bits.push(`⚠ ${r.unrestorable} not restorable`);
|
|
592
|
+
lines.push(` ${r.runId} ${whenText(r.at)} ${bits.join(' · ')}`);
|
|
593
|
+
if (r.task) lines.push(` ${r.task}`);
|
|
594
|
+
}
|
|
595
|
+
lines.push('');
|
|
596
|
+
lines.push(' acuvo rewind <id> put the files back the way they were before that run');
|
|
597
|
+
lines.push(' acuvo rewind <id> --dry-run say what it would do, and touch nothing');
|
|
598
|
+
/**
|
|
599
|
+
* ⚠️⚠️ THE DISK COST IS PRINTED BECAUSE NOTHING PRUNES IT YET, and a store
|
|
600
|
+
* that grows forever in somebody's workspace without ever saying so is the
|
|
601
|
+
* quiet kind of defect. It is bounded by the content actually written (blobs
|
|
602
|
+
* are content-addressed) — but "bounded" is not "small", and the honest move
|
|
603
|
+
* is to show the number rather than to promise a cleanup that is not built.
|
|
604
|
+
*
|
|
605
|
+
* ⚠️ AND PRUNING IS NOT A ONE-LINE ADD-ON, which is why it is not here:
|
|
606
|
+
* `audit.mjs` prunes whole day FILES precisely because seven terminals append
|
|
607
|
+
* to one log, and rewriting a journal underneath a concurrent `appendFileSync`
|
|
608
|
+
* is how you lose the entries you were trying to keep.
|
|
609
|
+
*/
|
|
610
|
+
if (size && size.bytes > 0) {
|
|
611
|
+
const mb = size.bytes / 1_000_000;
|
|
612
|
+
const shown = mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(size.bytes / 1000)} KB`;
|
|
613
|
+
lines.push('');
|
|
614
|
+
lines.push(` ${shown} in .acuvo/checkpoints (${size.blobs} stored version${size.blobs === 1 ? '' : 's'}) — nothing prunes it yet; delete the directory to reclaim it.`);
|
|
615
|
+
}
|
|
616
|
+
return lines;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** @param {any} result */
|
|
620
|
+
export function formatRewind(result) {
|
|
621
|
+
const verb = result.dryRun ? 'would restore' : 'restored';
|
|
622
|
+
const removedVerb = result.dryRun ? 'would delete' : 'deleted';
|
|
623
|
+
const lines = [`rewind ${result.runId}${result.dryRun ? ' (dry run — nothing was touched)' : ''}`, ''];
|
|
624
|
+
for (const r of result.restored) lines.push(` ✔ ${verb} ${r.path}${r.forced ? ' (FORCED over your change)' : ''}`);
|
|
625
|
+
for (const r of result.removed) lines.push(` ✔ ${removedVerb} ${r.path} — the agent created it${r.forced ? ' (FORCED)' : ''}`);
|
|
626
|
+
for (const s of result.skipped) lines.push(` · skipped ${s.path}: ${s.reason}`);
|
|
627
|
+
for (const f of result.failed) lines.push(` ✗ ${f.path}: ${f.error}`);
|
|
628
|
+
if (result.restored.length === 0 && result.removed.length === 0) {
|
|
629
|
+
lines.push(' nothing was changed.');
|
|
630
|
+
}
|
|
631
|
+
lines.push('');
|
|
632
|
+
const counts = [`${result.restored.length} restored`, `${result.removed.length} deleted`];
|
|
633
|
+
if (result.skipped.length) counts.push(`${result.skipped.length} skipped`);
|
|
634
|
+
if (result.failed.length) counts.push(`${result.failed.length} failed`);
|
|
635
|
+
lines.push(` ${counts.join(' · ')}`);
|
|
636
|
+
return lines;
|
|
637
|
+
}
|