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/handoff.mjs
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE HANDOFF — A HELPER THAT *IMPLEMENTS*, NOT ONLY ONE THAT READS ────
|
|
3
|
+
*
|
|
4
|
+
* `subagent.mjs` shipped delegation with a lock on it: twelve READ verbs, and
|
|
5
|
+
* `allowRun: false` as a second lock. Its own header states the reason, and the
|
|
6
|
+
* reason is TECHNICAL rather than moral:
|
|
7
|
+
*
|
|
8
|
+
* *"A subagent that writes can collide with the parent editing the same file,
|
|
9
|
+
* and whoever finishes second wins silently. `parallel.mjs` exists to catch
|
|
10
|
+
* exactly that between processes and is blind to it here, because these run
|
|
11
|
+
* INSIDE one session."*
|
|
12
|
+
*
|
|
13
|
+
* ⭐ THAT IS AN UNFINISHED CAPABILITY, NOT A SAFETY BOUNDARY — and the blocker
|
|
14
|
+
* it names is already solved twice in this package. `best-of.mjs` runs three
|
|
15
|
+
* attempts in `mkdtemp` copies of the workspace and applies the winner's own
|
|
16
|
+
* change list back (`best-of.mjs:162 applyAttempt`). `parallel.mjs` decides
|
|
17
|
+
* which files a record touched and refuses to pretend when two agents touched
|
|
18
|
+
* one. This file is those two ideas joined, so a helper can WRITE:
|
|
19
|
+
*
|
|
20
|
+
* 1. it works in an isolated COPY, so it cannot race the parent at all;
|
|
21
|
+
* 2. every file it changed is re-applied THROUGH THE PARENT'S EXECUTOR;
|
|
22
|
+
* 3. a file that changed underneath us between the copy and the apply is
|
|
23
|
+
* REFUSED BY NAME, never silently overwritten.
|
|
24
|
+
*
|
|
25
|
+
* ── ⭐⭐ WHY THROUGH THE EXECUTOR AND NOT `cpSync` ──────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* `best-of.mjs:179` applies with `cpSync`, which is right there — it runs from
|
|
28
|
+
* `bin/`, after the session, with no executor in scope. Copying here would have
|
|
29
|
+
* been three lines and would have walked around FOUR guarantees that this
|
|
30
|
+
* package sells:
|
|
31
|
+
*
|
|
32
|
+
* · **leases** — `createLocalExecutor(root, { claimPath })` claims each path
|
|
33
|
+
* (`workspace.mjs:625`). A raw copy claims nothing, so the fleet's
|
|
34
|
+
* file-level locking would be blind to every byte a helper wrote.
|
|
35
|
+
* · **`--dry-run`** — `writeFile` stops at `workspace.mjs:653` and reports
|
|
36
|
+
* what it WOULD do. A raw copy would make `--dry-run` a lie the moment the
|
|
37
|
+
* model delegated.
|
|
38
|
+
* · **the `.acuvo/` leash** — `agentWriteRefusal` (`workspace.mjs:610`) is on
|
|
39
|
+
* the executor method, deliberately and with a comment saying so.
|
|
40
|
+
* · **containment** — `resolveInWorkspace` refuses `..`, drive letters, UNC
|
|
41
|
+
* and symlink escapes.
|
|
42
|
+
*
|
|
43
|
+
* ⭐ So the apply path is `executor.writeFile` / `executor.deleteFile`: the
|
|
44
|
+
* same door the parent's own tools use, and therefore the same guards. The
|
|
45
|
+
* helper gets a capability; it does not get an exemption.
|
|
46
|
+
*
|
|
47
|
+
* ── ⚠️ THE COLLISION CHECK IS A CONTENT HASH, AND IT HAD TO BE ──────────────
|
|
48
|
+
*
|
|
49
|
+
* The cheap version compares byte LENGTH — `writeFile` already computes
|
|
50
|
+
* `previousBytes` (`workspace.mjs:645`), so it is free. It is also wrong for
|
|
51
|
+
* the most common edit there is: another terminal changing a line without
|
|
52
|
+
* changing the file's size. A check that cannot see the ordinary case is worse
|
|
53
|
+
* than no check, because it is quoted as if it saw it.
|
|
54
|
+
*
|
|
55
|
+
* ⭐ So the baseline is a SHA-256 per file, taken from the copy the instant it
|
|
56
|
+
* is made — which is byte-identical to the workspace at that instant. We have
|
|
57
|
+
* already paid to read every one of those bytes in order to copy them; reading
|
|
58
|
+
* them once more is the same order of magnitude and buys an exact answer.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { createHash } from 'node:crypto';
|
|
62
|
+
import {
|
|
63
|
+
cpSync, mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, symlinkSync,
|
|
64
|
+
} from 'node:fs';
|
|
65
|
+
import { tmpdir } from 'node:os';
|
|
66
|
+
import { join, relative, sep } from 'node:path';
|
|
67
|
+
|
|
68
|
+
import { COPY_SKIP_DIRS, MAX_COPY_BYTES, measureWorkspace } from './best-of.mjs';
|
|
69
|
+
import { changedPaths } from './changed-paths.mjs';
|
|
70
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* SHA-256 of one file, or `null` when it is not there.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ ABSENCE IS A VALUE, NOT AN ERROR. "the file does not exist" and "the file
|
|
76
|
+
* exists with these bytes" are both states the collision check compares, and
|
|
77
|
+
* conflating absence with failure would make a helper's freshly CREATED file
|
|
78
|
+
* look like a collision with something.
|
|
79
|
+
*/
|
|
80
|
+
export function fileHash(absolute, { read = readFileSync } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
return createHash('sha256').update(read(absolute)).digest('hex');
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Hash every file under `root`, keyed by the same '/'-separated relative path
|
|
90
|
+
* `resolveInWorkspace` returns — so the two halves of the comparison cannot
|
|
91
|
+
* disagree about how a path is spelled on Windows.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ THE SAME SKIP SET THE COPY USED. Hashing `node_modules` would cost more
|
|
94
|
+
* than the whole feature saves, and nothing under it can be written by a
|
|
95
|
+
* helper anyway: `resolveInWorkspace(..., 'write')` refuses it outright
|
|
96
|
+
* (`workspace.mjs:330`).
|
|
97
|
+
*/
|
|
98
|
+
export function hashTree(root, { skip = COPY_SKIP_DIRS } = {}) {
|
|
99
|
+
/** @type {Map<string, string>} */
|
|
100
|
+
const map = new Map();
|
|
101
|
+
const walk = (dir, prefix) => {
|
|
102
|
+
let entries;
|
|
103
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
if (e.isDirectory()) {
|
|
106
|
+
if (skip.has(e.name)) continue;
|
|
107
|
+
walk(join(dir, e.name), prefix ? `${prefix}/${e.name}` : e.name);
|
|
108
|
+
} else if (e.isFile()) {
|
|
109
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
110
|
+
const h = fileHash(join(dir, e.name));
|
|
111
|
+
if (h) map.set(rel, h);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
walk(root, '');
|
|
116
|
+
return map;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How many refusals to spell out before summarising the rest. */
|
|
120
|
+
const MAX_NAMED_REFUSALS = 6;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Re-apply everything a helper changed in its copy, into the real workspace.
|
|
124
|
+
*
|
|
125
|
+
* @param {{
|
|
126
|
+
* copyRoot: string,
|
|
127
|
+
* executor: any,
|
|
128
|
+
* outcome: any,
|
|
129
|
+
* baseline: Map<string, string>,
|
|
130
|
+
* }} args
|
|
131
|
+
* ── ⭐⭐ IT REPORTS `written[{path,bytes,previousBytes,created}]`, WHICH IS NOT
|
|
132
|
+
* A NEW SHAPE — IT IS `write_files`' SHAPE (`write-many.mjs:128`) ────────────
|
|
133
|
+
*
|
|
134
|
+
* My first version returned `applied: string[]`, and a REAL RUN showed why that
|
|
135
|
+
* was wrong. The end-of-run summary printed:
|
|
136
|
+
*
|
|
137
|
+
* 1 file written:
|
|
138
|
+
* replaced src/calc.test.mjs (0 bytes)
|
|
139
|
+
*
|
|
140
|
+
* for a file that was CREATED and was 510 bytes. `report.mjs:describeChange`
|
|
141
|
+
* reads `result.bytes` and `result.created` off a record, finds neither on a
|
|
142
|
+
* bare string list, and falls through to "replaced, 0 bytes" — confidently
|
|
143
|
+
* wrong in a place people trust. ⭐ `tools.mjs:989` records this exact class
|
|
144
|
+
* happening before ("a new tool whose result shape differs from write_file's
|
|
145
|
+
* breaks every downstream reader that assumed one shape"), and I did it again.
|
|
146
|
+
*
|
|
147
|
+
* ⭐ So a built handoff reports the SAME shape a bulk write already reports,
|
|
148
|
+
* and every reader that learned it once gets this for free — including
|
|
149
|
+
* `changed-paths.mjs`, which needs no delegate-specific arm at all.
|
|
150
|
+
*
|
|
151
|
+
* @returns {{ written: {path:string,bytes:number,previousBytes:number,created:boolean,deleted?:boolean}[],
|
|
152
|
+
* refused: {path:string, why:string}[], problems: string[] }}
|
|
153
|
+
*/
|
|
154
|
+
export function applyHandoff({ copyRoot, executor, outcome, baseline }) {
|
|
155
|
+
const written = [];
|
|
156
|
+
const refused = [];
|
|
157
|
+
const problems = [];
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
|
|
160
|
+
for (const record of outcome?.executed ?? []) {
|
|
161
|
+
for (const raw of changedPaths(record)) {
|
|
162
|
+
/**
|
|
163
|
+
* ⚠️ RESOLVED AGAINST THE COPY, WITH THE PACKAGE'S OWN PATH LAYER. The
|
|
164
|
+
* path is a string a model wrote; `best-of.mjs:171` hand-rolls four
|
|
165
|
+
* checks for this and misses UNC, control characters and symlink
|
|
166
|
+
* escapes. `resolveInWorkspace` is the audited version and it is right
|
|
167
|
+
* here.
|
|
168
|
+
*/
|
|
169
|
+
const src = resolveInWorkspace(copyRoot, raw, 'read');
|
|
170
|
+
if (!src.ok) { problems.push(`${raw}: ${src.reason}`); continue; }
|
|
171
|
+
const rel = src.relative;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* ⚠️ ONCE PER PATH, AND THE DISK IS THE TRUTH. A helper that wrote a
|
|
175
|
+
* file three times leaves three records; only the final state in the
|
|
176
|
+
* copy is what "apply" means, and that is what `existsSync` below reads.
|
|
177
|
+
*/
|
|
178
|
+
if (seen.has(rel)) continue;
|
|
179
|
+
seen.add(rel);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* ── ⚠️⚠️ THE COLLISION CHECK ────────────────────────────────────────
|
|
183
|
+
*
|
|
184
|
+
* `was` is the file's content at the instant the copy was taken; `now`
|
|
185
|
+
* is its content in the real workspace at the instant we are about to
|
|
186
|
+
* overwrite it. Equal means nobody touched it while the helper worked
|
|
187
|
+
* and applying is safe. Different means SOMEBODY ELSE — another
|
|
188
|
+
* terminal, the user's editor, a watcher — changed it, and overwriting
|
|
189
|
+
* would be the silent loss `subagent.mjs:24` refused to ship.
|
|
190
|
+
*
|
|
191
|
+
* ⭐ REFUSED BY NAME, AND THE HELPER'S VERSION IS STILL DESCRIBED in
|
|
192
|
+
* the summary the parent reads. Refusing silently would be its own
|
|
193
|
+
* version of the same failure.
|
|
194
|
+
*/
|
|
195
|
+
const was = baseline.get(rel) ?? null;
|
|
196
|
+
const now = fileHash(join(executor.root, ...rel.split('/')));
|
|
197
|
+
if (was !== now) {
|
|
198
|
+
refused.push({
|
|
199
|
+
path: rel,
|
|
200
|
+
why: was === null
|
|
201
|
+
? 'something else created this file while the helper was working'
|
|
202
|
+
: now === null
|
|
203
|
+
? 'something else deleted this file while the helper was working'
|
|
204
|
+
: 'this file changed in the workspace while the helper was working',
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!existsSync(src.absolute)) {
|
|
210
|
+
// The helper deleted it. Deleting it here is what "apply" means.
|
|
211
|
+
// ⭐ Through the executor, so the lease is claimed and `--dry-run` holds.
|
|
212
|
+
const d = executor.deleteFile(rel);
|
|
213
|
+
if (d?.ok) {
|
|
214
|
+
written.push({ path: rel, bytes: 0, previousBytes: d.bytes ?? 0, created: false, deleted: true });
|
|
215
|
+
} else {
|
|
216
|
+
problems.push(`${rel}: ${d?.error ?? 'delete failed'}`);
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let content;
|
|
222
|
+
try {
|
|
223
|
+
content = readFileSync(src.absolute, 'utf8');
|
|
224
|
+
} catch (err) {
|
|
225
|
+
problems.push(`${rel}: could not read it back from the helper's copy: ${String(err?.message ?? err)}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* ⚠️ THE SAME BINARY TEST `workspace.mjs:602` USES. A file read as UTF-8
|
|
230
|
+
* and written back is corrupted if it was not text, and it would be
|
|
231
|
+
* corrupted SILENTLY — the write succeeds and reports a byte count.
|
|
232
|
+
*/
|
|
233
|
+
if (content.includes('\u0000')) {
|
|
234
|
+
problems.push(`${rel}: looks binary — refusing to copy it back as text`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const w = executor.writeFile(rel, content);
|
|
239
|
+
if (w?.ok) {
|
|
240
|
+
/**
|
|
241
|
+
* ⭐ THE EXECUTOR ALREADY COMPUTED EVERY FIELD THE SUMMARY NEEDS —
|
|
242
|
+
* `created` and `previousBytes` are decided at `workspace.mjs:632-645`
|
|
243
|
+
* BEFORE the write, which is the only moment they are knowable. Passing
|
|
244
|
+
* them straight through is why the summary can say "created … 510
|
|
245
|
+
* bytes" instead of the "replaced … 0 bytes" it printed on the first
|
|
246
|
+
* real run.
|
|
247
|
+
*/
|
|
248
|
+
written.push({
|
|
249
|
+
path: w.path ?? rel,
|
|
250
|
+
bytes: w.bytes ?? 0,
|
|
251
|
+
previousBytes: w.previousBytes ?? 0,
|
|
252
|
+
created: w.created === true,
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
problems.push(`${rel}: ${w?.error ?? 'write failed'}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { written, refused, problems };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Copy the workspace, run something in the copy, apply what it changed.
|
|
265
|
+
*
|
|
266
|
+
* `run(copyRoot)` resolves to a SessionDone-shaped outcome. Injected so this
|
|
267
|
+
* module never imports the turn loop — it is a mechanism, not a second engine,
|
|
268
|
+
* exactly as `best-of.mjs` is.
|
|
269
|
+
*
|
|
270
|
+
* ⚠️ THE COPY IS ALWAYS REMOVED, including when `run` threw. Three abandoned
|
|
271
|
+
* workspace copies per invocation is how a temp directory becomes a disk
|
|
272
|
+
* problem nobody connects back to this feature — `best-of.mjs:285` learned that
|
|
273
|
+
* and it applies identically here.
|
|
274
|
+
*/
|
|
275
|
+
export async function runInIsolatedCopy({
|
|
276
|
+
root,
|
|
277
|
+
executor,
|
|
278
|
+
run,
|
|
279
|
+
maxBytes = MAX_COPY_BYTES,
|
|
280
|
+
makeTempDir = () => mkdtempSync(join(tmpdir(), 'acuvo-handoff-')),
|
|
281
|
+
copyDir = cpSync,
|
|
282
|
+
removeDir = rmSync,
|
|
283
|
+
hash = hashTree,
|
|
284
|
+
/**
|
|
285
|
+
* ── ⭐⭐ THE ONE THING THAT MADE "THE HELPER MAY VERIFY" IMPOSSIBLE ─────────
|
|
286
|
+
*
|
|
287
|
+
* `subagent.mjs` passed `allowRun: false` in BOTH modes, and its reason was
|
|
288
|
+
* this file's `COPY_SKIP_DIRS`: the copy has no `node_modules`, so `npm test`
|
|
289
|
+
* in there fails with "Cannot find module" — a verification that fails for a
|
|
290
|
+
* reason that has nothing to do with the code, which the parent would act on.
|
|
291
|
+
* That reasoning was CORRECT and it was an argument about the COPY, not about
|
|
292
|
+
* the helper. So the copy is what changes.
|
|
293
|
+
*
|
|
294
|
+
* ⭐ A LINK, NOT A COPY. Duplicating `node_modules` per delegation is exactly
|
|
295
|
+
* why `COPY_SKIP_DIRS` skips it (hundreds of megabytes, thousands of files),
|
|
296
|
+
* and this needs none of that: a symlink (a JUNCTION on Windows, which needs
|
|
297
|
+
* no elevation) makes the same directory reachable at the same relative path
|
|
298
|
+
* for the cost of one inode.
|
|
299
|
+
*
|
|
300
|
+
* ── ⚠️⚠️ THE DELETE, WHICH IS THE THING THAT COULD HAVE GONE VERY WRONG ────
|
|
301
|
+
*
|
|
302
|
+
* `finally` below runs `removeDir(copyRoot, { recursive: true })`, and a
|
|
303
|
+
* recursive delete that FOLLOWED the link would destroy the user's real
|
|
304
|
+
* `node_modules`. MEASURED on this machine (Windows, Node 22.17) before the
|
|
305
|
+
* code was written: a junction is removed as a link, `precious.txt` inside the
|
|
306
|
+
* real directory survived, and `lstatSync().isSymbolicLink()` is true. Node's
|
|
307
|
+
* `rmSync` unlinks rather than descends. That is not an assumption anyone
|
|
308
|
+
* should carry — it is the reason this comment cites a measurement.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ AND IT IS NOT A NEW CAPABILITY CLASS. The PARENT already runs `npm test`
|
|
311
|
+
* in the real workspace with the real `node_modules` on the default surface;
|
|
312
|
+
* a helper running the allowlisted set inside a copy that reaches the same
|
|
313
|
+
* directory is a subset of exposure that already exists. What it is NOT is a
|
|
314
|
+
* write path: `resolveInWorkspace(..., 'write')` refuses `node_modules`, the
|
|
315
|
+
* baseline hash skips it, and `npm install` is refused without
|
|
316
|
+
* `ACUVO_ALLOW_INSTALL`.
|
|
317
|
+
*
|
|
318
|
+
* ⚠️ OPT-IN AND FAIL-SOFT. Default false, so every existing caller copies
|
|
319
|
+
* exactly what it copied before; and if the link cannot be made the run
|
|
320
|
+
* continues WITHOUT it and reports `dependenciesLinked: false`, because a
|
|
321
|
+
* helper that cannot run `npm test` is worse than a helper, not worse than
|
|
322
|
+
* nothing.
|
|
323
|
+
*/
|
|
324
|
+
linkNodeModules = false,
|
|
325
|
+
linkDir = symlinkSync,
|
|
326
|
+
}) {
|
|
327
|
+
const size = measureWorkspace(root, { limit: maxBytes });
|
|
328
|
+
if (size.overLimit) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
error: `this workspace holds more than ${Math.round(maxBytes / 1024 / 1024)}MB of files that would have to be copied `
|
|
332
|
+
+ 'for a writing helper to work in isolation. Do this part yourself, or point --dir at the subdirectory the task actually touches.',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let copyRoot = null;
|
|
337
|
+
try {
|
|
338
|
+
copyRoot = makeTempDir();
|
|
339
|
+
copyDir(root, copyRoot, {
|
|
340
|
+
recursive: true,
|
|
341
|
+
// ⚠️ `dereference: false` — a symlink copied as its target silently
|
|
342
|
+
// doubles a workspace and breaks any code that checks `isSymbolicLink`.
|
|
343
|
+
dereference: false,
|
|
344
|
+
force: true,
|
|
345
|
+
filter: (src) => {
|
|
346
|
+
const rel = relative(root, src);
|
|
347
|
+
if (rel === '') return true;
|
|
348
|
+
return !rel.split(sep).some((part) => COPY_SKIP_DIRS.has(part));
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* ⚠️ AFTER THE COPY AND BEFORE THE BASELINE. After, so `cpSync`'s filter
|
|
354
|
+
* never sees it and cannot be asked to walk into the real dependency tree;
|
|
355
|
+
* before, so anything the helper does is bracketed by a hash that already
|
|
356
|
+
* knows the layout it worked against. `hashTree` skips `node_modules`
|
|
357
|
+
* itself, so the link contributes nothing to the collision check — which is
|
|
358
|
+
* right: a file in there is not a file the helper is allowed to change.
|
|
359
|
+
*/
|
|
360
|
+
let dependenciesLinked = false;
|
|
361
|
+
if (linkNodeModules) {
|
|
362
|
+
const from = join(root, 'node_modules');
|
|
363
|
+
if (existsSync(from)) {
|
|
364
|
+
try {
|
|
365
|
+
// 'junction' on Windows because a 'dir' symlink needs Developer Mode
|
|
366
|
+
// or elevation there, and a capability that only works for
|
|
367
|
+
// administrators is not a capability.
|
|
368
|
+
linkDir(from, join(copyRoot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
369
|
+
dependenciesLinked = true;
|
|
370
|
+
} catch {
|
|
371
|
+
// Degraded, not fatal — and SAID SO in the return value, because a
|
|
372
|
+
// helper that silently could not run its tests would report success
|
|
373
|
+
// it never had.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* ⭐ TAKEN FROM THE COPY, NOT THE ORIGINAL, AND THE DIFFERENCE MATTERS.
|
|
380
|
+
* The copy is frozen the moment `copyDir` returns; the original is not,
|
|
381
|
+
* and hashing it afterwards would race the very writes this check exists
|
|
382
|
+
* to catch — a file changed between the copy and the hash would be
|
|
383
|
+
* baselined in its NEW state and then overwritten as if nothing happened.
|
|
384
|
+
*/
|
|
385
|
+
const baseline = hash(copyRoot);
|
|
386
|
+
|
|
387
|
+
const outcome = await run(copyRoot);
|
|
388
|
+
const { written, refused, problems } = applyHandoff({ copyRoot, executor, outcome, baseline });
|
|
389
|
+
return { ok: true, outcome, written, refused, problems, copiedFiles: size.files, dependenciesLinked };
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return { ok: false, error: `the isolated workspace failed: ${err?.message ?? String(err)}` };
|
|
392
|
+
} finally {
|
|
393
|
+
if (copyRoot) { try { removeDir(copyRoot, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One sentence per outcome, for the parent to read.
|
|
399
|
+
*
|
|
400
|
+
* ⚠️ A REFUSAL IS THE HEADLINE, not a footnote. The parent is about to tell the
|
|
401
|
+
* user the work is done; a file that did NOT land is the one fact that changes
|
|
402
|
+
* what it should say next.
|
|
403
|
+
*/
|
|
404
|
+
export function describeHandoff({ written = [], refused = [], problems = [] } = {}) {
|
|
405
|
+
const lines = [];
|
|
406
|
+
lines.push(written.length === 0
|
|
407
|
+
? 'It changed nothing in the workspace.'
|
|
408
|
+
: `Applied to the workspace: ${written.map((w) => (w.deleted ? `${w.path} (deleted)` : w.path)).join(', ')}`);
|
|
409
|
+
if (refused.length > 0) {
|
|
410
|
+
lines.push(`⚠ ${refused.length} change${refused.length === 1 ? ' was' : 's were'} NOT applied because the file moved under us — `
|
|
411
|
+
+ refused.slice(0, MAX_NAMED_REFUSALS).map((r) => `${r.path} (${r.why})`).join('; ')
|
|
412
|
+
+ (refused.length > MAX_NAMED_REFUSALS ? `; +${refused.length - MAX_NAMED_REFUSALS} more` : '')
|
|
413
|
+
+ '. Re-read those files before trusting anything said about them.');
|
|
414
|
+
}
|
|
415
|
+
for (const p of problems.slice(0, MAX_NAMED_REFUSALS)) lines.push(`⚠ ${p}`);
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|