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
@@ -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
+ }