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,1044 @@
1
+ /**
2
+ * ── ⭐⭐ SHOW THE CHANGE BEFORE IT LANDS, NOT AFTER ──────────────────────────
3
+ *
4
+ * This agent writes a file and then tells you what it wrote. Every terminal
5
+ * agent people compare us to shows the diff and asks first. That ordering is
6
+ * the single biggest felt difference on a first run, and it is not a cosmetic
7
+ * one: "I rewrote 200 lines of a 210-line file" reported afterwards is a
8
+ * post-mortem, and reported beforehand it is a veto.
9
+ *
10
+ * `report.mjs` already saw half of this and said so out loud — *"the CLI tells
11
+ * you WHICH files it touched and never WHAT it did to them"* — and deliberately
12
+ * stopped short of a unified diff because a report that runs off the screen is
13
+ * one nobody reads. That reasoning is right for a REPORT and wrong for a
14
+ * PROMPT: a person about to answer y/n will read forty lines, and the cap is a
15
+ * rendering problem, not a reason to withhold the diff.
16
+ *
17
+ * ── ⚠️⚠️ THIS IS NOT A SECURITY BOUNDARY, AND BUILDING IT AS ONE BREAKS IT ───
18
+ *
19
+ * The temptation is to make approval fail closed everywhere, the way
20
+ * `mcp-consent.mjs` does. It is the wrong instinct here, and the difference is
21
+ * worth stating precisely because getting it backwards is what kills the
22
+ * feature:
23
+ *
24
+ * `checkMcpConsent` guards a **foreign binary being executed**. There is no
25
+ * other control standing between a cloned repo and code running as you, so
26
+ * "nobody could be asked" has to mean "no".
27
+ *
28
+ * A write is already guarded four times over before it reaches here:
29
+ * `resolveInWorkspace` confines it to the root, `WRITE_FORBIDDEN_ROOTS`
30
+ * refuses `.git` and friends, `MAX_WRITE_BYTES` bounds it, and
31
+ * `policy.requireDryRun` is the org control that says "this machine may not
32
+ * be written to at all". Approval is a **review affordance on top of those**,
33
+ * not a fifth lock.
34
+ *
35
+ * ⚠️ SO WHAT HAPPENS WITH NO TERMINAL IS THE WHOLE DESIGN QUESTION. In CI, in
36
+ * `--parallel`, in the fleet, in a pipe, nobody can answer. `prompt.mjs` already
37
+ * pays for that lesson in its own header: a prompt with no terminal must fail
38
+ * with a message and NEVER block, because a hang is the least legible failure
39
+ * there is. Blocking is off the table. That leaves two options and both are
40
+ * wrong in isolation:
41
+ *
42
+ * · **Refuse the write.** Then the day this merges, every unattended run
43
+ * stops working. The workaround people reach for is `ACUVO_APPROVE=never`
44
+ * in their shell profile — which switches review off on their laptop too.
45
+ * ⭐ A gate that breaks unattended runs gets globally disabled, and then it
46
+ * protects nobody. That is strictly worse than not shipping it.
47
+ * · **Write silently.** Then an operator who explicitly asked for a review
48
+ * gate gets no gate and no warning, which is the fail-open-on-a-typo shape
49
+ * `policy.mjs` refuses by name.
50
+ *
51
+ * ⭐ THE SPLIT IS ON WHETHER ANYBODY ASKED. Default (`auto`) with no terminal:
52
+ * the write proceeds, is marked `reviewed: false`, and the reason says so, so
53
+ * the run summary can count "4 files written without review". Explicit
54
+ * (`--approve` / `ACUVO_APPROVE=always`) with no terminal: that is a stated
55
+ * intent we cannot honour, so it refuses and names the way out — exactly the
56
+ * `checkMcpConsent` shape, applied only where somebody actually stated the
57
+ * intent.
58
+ *
59
+ * ── ⚠️⚠️ AND ASKING ABOUT EVERY WRITE IS THE SAME AS ASKING ABOUT NONE ──────
60
+ *
61
+ * `mcp-consent.mjs` says it twice in its own comments: *a nag people learn to
62
+ * click through is worse than no prompt.* A 40-file build that asks 40 times
63
+ * trains the exact reflex the feature exists to prevent, and the 41st question
64
+ * — the one that mattered — gets the same reflex `y`.
65
+ *
66
+ * ⭐ THE GRANULARITY IS NOT "PER FILE" AND NOT "PER RUN". IT IS **PER
67
+ * IRREVERSIBLE ACT**. Creating a file destroys nothing; you can delete it
68
+ * afterwards and be exactly where you started. Overwriting or deleting content
69
+ * that existed BEFORE this run destroys the only copy — that content exists
70
+ * nowhere else on the machine. So:
71
+ *
72
+ * create a new file → never asked (nothing is lost)
73
+ * rewrite a file THIS RUN created → never asked (the agent revising
74
+ * its own draft)
75
+ * write identical bytes → never asked (a no-op is not a
76
+ * decision)
77
+ * scratch / build output → never asked (see the caveat below)
78
+ * overwrite pre-existing content → ASK
79
+ * delete a pre-existing file → ASK, highest risk
80
+ * a credential path, or `.acuvo/` → ASK, whatever else is true
81
+ *
82
+ * On a realistic build that is a handful of questions, not forty, and every one
83
+ * of them is a question a person would actually want.
84
+ *
85
+ * ⚠️ THE SCRATCH LIST IS A HEURISTIC AND IS LABELLED AS ONE. `dist/` being
86
+ * regenerable is a convention, not a fact we can verify; someone's `dist/` is
87
+ * hand-maintained. It is overridable for that reason, and it only ever
88
+ * downgrades an overwrite to "not asked" — it never upgrades anything, so the
89
+ * worst case is a question that should have been asked, in a directory whose
90
+ * whole purpose is to be rebuilt.
91
+ *
92
+ * ⭐ AND `[a]ll remaining` IS A DELIBERATE ANSWER, NOT A CONVENIENCE. People
93
+ * disengage from prompts; that is a fact about people, not a bug to design out.
94
+ * The choice is whether disengagement is EXPLICIT and recorded once, or
95
+ * simulated by forty reflex keystrokes. Offering "stop asking me" makes it the
96
+ * former, and the run summary can then say "you approved the rest at file 3".
97
+ *
98
+ * ── ⚠️ THE DIFF ITSELF HAS TO BE HONEST ABOUT INVISIBLE CHANGES ─────────────
99
+ *
100
+ * A preview that renders a change as two identical-looking lines is worse than
101
+ * no preview, because it invites a yes on a false reading. Three real cases,
102
+ * all handled here rather than left to the eye:
103
+ *
104
+ * · **CRLF ↔ LF.** Diffing lines with their terminators attached marks every
105
+ * line in the file changed and prints hundreds of `-foo` / `+foo` pairs.
106
+ * So lines are compared WITHOUT terminators and the ending change is
107
+ * reported in words instead: "line endings changed CRLF → LF".
108
+ * · **A trailing newline appearing or vanishing.** Zero visible line changes.
109
+ * Forced into a real `-`/`+` pair carrying git's
110
+ * `` marker, which is the whole point of that
111
+ * marker existing.
112
+ * · **Trailing whitespace.** `-a` and `+a` where one has a space. Annotated
113
+ * on the line, because nobody has ever spotted that unaided.
114
+ *
115
+ * Pure throughout. No filesystem, no spawn, no network, no clock — the caller
116
+ * supplies the before and after text, and every branch below is reachable from
117
+ * a test with none of those things.
118
+ */
119
+
120
+ /**
121
+ * ⭐ IMPORTED, NEVER RE-IMPLEMENTED — `secret-paths.mjs` exists precisely
122
+ * because two copies of "is this a credential?" drifted apart and which of a
123
+ * user's secrets were protected depended on which verb the model picked.
124
+ *
125
+ * ⚠️ AND IT IS THE ONLY IMPORT, WHICH IS A MEASURED CONSTRAINT RATHER THAN A
126
+ * PREFERENCE. `isPolicyProtectedPath` in `policy.mjs` answers the `.acuvo/`
127
+ * question and would be the natural second import — but `policy.mjs` imports
128
+ * `tools.mjs` → `command.mjs` → `workspace.mjs`, and `workspace.mjs` is where
129
+ * this module gets called from. That is a cycle, and `secret-paths.mjs`'s own
130
+ * header records what a cycle costs here: Node runs it happily, every test
131
+ * passes, and `scripts/bundle.mjs` cannot topologically order it, so the break
132
+ * lands at ship time. The `.acuvo/` predicate below is therefore a deliberate
133
+ * two-line duplicate, and it is injectable so the lead can pass the real one in
134
+ * from a layer that already has both.
135
+ */
136
+ import { refusedCommitPath } from './secret-paths.mjs';
137
+
138
+ // ── caps ───────────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Bigger than this and we do not attempt a diff.
142
+ *
143
+ * ⭐ Matched to `workspace.mjs`'s `MAX_READ_BYTES` (200_000) on purpose rather
144
+ * than to `MAX_WRITE_BYTES` (400_000): a file this agent cannot READ as text is
145
+ * one it cannot meaningfully show you either, so the two limits agreeing means a
146
+ * user never meets a file that is diffable but not readable, or the reverse.
147
+ */
148
+ export const MAX_DIFF_BYTES = 200_000;
149
+
150
+ /** A file taller than this is a data dump, not something a person reviews. */
151
+ export const MAX_DIFF_LINES = 20_000;
152
+
153
+ /**
154
+ * ⚠️ ONE LONG LINE IS THE MINIFIED-BUNDLE CASE, and it defeats a line diff
155
+ * completely: `dist/app.js` is a single 300KB line, so the "diff" is one `-`
156
+ * and one `+` containing the entire file. Refusing is honest; printing that is
157
+ * not a preview.
158
+ */
159
+ export const MAX_DIFF_LINE_CHARS = 2_000;
160
+
161
+ /**
162
+ * Myers is O((N+M)·D). D is small for real edits (the common prefix and suffix
163
+ * are trimmed first), but two unrelated 5,000-line files have D ≈ 10,000 and
164
+ * would take a noticeable second to compare for no benefit. Past this we say
165
+ * "this file was replaced wholesale" — which is both true and the thing the
166
+ * reviewer needed to know anyway.
167
+ */
168
+ export const MAX_EDIT_DISTANCE = 3_000;
169
+
170
+ /** Unified-diff context lines either side of a change. Three is the universal default. */
171
+ export const DEFAULT_CONTEXT = 3;
172
+
173
+ /** How many diff lines a prompt prints before it truncates and says so. */
174
+ export const DEFAULT_PREVIEW_LINES = 60;
175
+
176
+ /** The way out of the truncation, named in the truncation message itself. */
177
+ export const DIFF_LINES_ENV = 'ACUVO_DIFF_LINES';
178
+
179
+ /** The env var that chooses the review mode, and the modes it accepts. */
180
+ export const APPROVE_ENV = 'ACUVO_APPROVE';
181
+ export const APPROVE_MODES = Object.freeze(['auto', 'always', 'never']);
182
+
183
+ /**
184
+ * Directories whose contents are regenerated rather than authored. See the
185
+ * header: a heuristic, only ever downgrades, and overridable.
186
+ */
187
+ export const SCRATCH_PREFIXES = Object.freeze([
188
+ 'dist/', 'build/', 'out/', '.next/', '.turbo/', '.cache/',
189
+ 'coverage/', 'tmp/', '.tmp/', 'node_modules/',
190
+ ]);
191
+
192
+ /** Risk, ordered. `auto` asks at `medium` and above. */
193
+ export const RISK_ORDER = Object.freeze({ none: 0, low: 1, medium: 2, high: 3 });
194
+
195
+ /**
196
+ * Removing this fraction or more of a pre-existing file is the "it quietly
197
+ * deleted something" case `report.mjs` was written to surface. It does not
198
+ * change WHETHER we ask — an overwrite is asked about regardless — it changes
199
+ * the risk label and the wording, so the dangerous one does not read like the
200
+ * routine one.
201
+ */
202
+ export const REWRITE_FRACTION = 0.5;
203
+
204
+ // ── line splitting ─────────────────────────────────────────────────────────
205
+
206
+ /**
207
+ * Split into lines, keeping the terminators SEPARATELY.
208
+ *
209
+ * ⚠️ NOT `text.split('\n')`. That leaves a `\r` glued to the end of every line
210
+ * of a CRLF file, which then compares unequal to the same line in an LF file and
211
+ * marks the whole file changed. The terminators are kept because they are real
212
+ * (a CRLF→LF flip is a genuine change) but they are compared separately, so the
213
+ * change is reported once in words instead of once per line.
214
+ *
215
+ * A lone `\r` is a terminator too — old Mac line endings still turn up in
216
+ * fixtures, and treating one as ordinary text would make a whole file into one
217
+ * enormous line and then trip `MAX_DIFF_LINE_CHARS`.
218
+ */
219
+ export function splitLines(text) {
220
+ const s = String(text ?? '');
221
+ const lines = [];
222
+ const eols = [];
223
+ let start = 0;
224
+ for (let i = 0; i < s.length; i += 1) {
225
+ const c = s[i];
226
+ if (c === '\n') {
227
+ lines.push(s.slice(start, i)); eols.push('\n'); start = i + 1;
228
+ } else if (c === '\r') {
229
+ if (s[i + 1] === '\n') { lines.push(s.slice(start, i)); eols.push('\r\n'); i += 1; } else { lines.push(s.slice(start, i)); eols.push('\r'); }
230
+ start = i + 1;
231
+ }
232
+ }
233
+ // Text after the last terminator is a line with no terminator — the case git
234
+ // marks with ``.
235
+ if (start < s.length) {
236
+ lines.push(s.slice(start));
237
+ eols.push('');
238
+ return { lines, eols, finalNewline: false };
239
+ }
240
+ return { lines, eols, finalNewline: true };
241
+ }
242
+
243
+ /** 'lf' | 'crlf' | 'cr' | 'mixed' | 'none' */
244
+ export function eolStyle(eols) {
245
+ let lf = 0; let crlf = 0; let cr = 0;
246
+ for (const e of eols) {
247
+ if (e === '\n') lf += 1;
248
+ else if (e === '\r\n') crlf += 1;
249
+ else if (e === '\r') cr += 1;
250
+ }
251
+ const kinds = [lf > 0 ? 'lf' : null, crlf > 0 ? 'crlf' : null, cr > 0 ? 'cr' : null].filter(Boolean);
252
+ if (kinds.length === 0) return 'none';
253
+ if (kinds.length > 1) return 'mixed';
254
+ return kinds[0];
255
+ }
256
+
257
+ // ── refusing to render ─────────────────────────────────────────────────────
258
+
259
+ /**
260
+ * Can this pair be shown at all?
261
+ *
262
+ * ⭐ RETURNS THE REASON, AND THE REASON NAMES THE WAY OUT. "cannot render" on
263
+ * its own is an obstacle; a reviewer who is told the file is binary and that
264
+ * they can inspect it with `git diff` after the run has been given a decision.
265
+ *
266
+ * ⚠️ A REFUSAL TO *RENDER* IS NOT A REFUSAL TO *ASK*. The policy below still
267
+ * runs, and the prompt still appears — it just carries sizes and a reason
268
+ * instead of hunks. Silently skipping the question because we could not draw a
269
+ * picture would mean the largest and least legible writes are the ones that
270
+ * land unreviewed, which is precisely backwards.
271
+ *
272
+ * @returns {string|null}
273
+ */
274
+ export function refuseToRender(before, after, { maxBytes = MAX_DIFF_BYTES, maxLines = MAX_DIFF_LINES, maxLineChars = MAX_DIFF_LINE_CHARS } = {}) {
275
+ for (const [label, text] of [['the previous contents', before], ['the new contents', after]]) {
276
+ if (text === null || text === undefined) continue;
277
+ const s = String(text);
278
+ if (s.includes('\u0000')) {
279
+ return `${label} contain a NUL byte, so this is binary and a line diff of it would be noise. `
280
+ + 'Inspect it with your own tools after the run (`git diff -- <path>` shows it as "Binary files differ").';
281
+ }
282
+ const bytes = Buffer.byteLength(s, 'utf8');
283
+ if (bytes > maxBytes) {
284
+ return `${label} are ${bytes} bytes, over the ${maxBytes}-byte preview limit. `
285
+ + 'The change is still described by its counts below; review the file itself if the counts look wrong.';
286
+ }
287
+ const { lines } = splitLines(s);
288
+ if (lines.length > maxLines) {
289
+ return `${label} are ${lines.length} lines, over the ${maxLines}-line preview limit. `
290
+ + 'The change is still described by its counts below; review the file itself if the counts look wrong.';
291
+ }
292
+ const longest = lines.reduce((n, l) => Math.max(n, l.length), 0);
293
+ if (longest > maxLineChars) {
294
+ return `${label} contain a ${longest}-character line, over the ${maxLineChars}-character limit — `
295
+ + 'this is minified or generated, and a line diff of it shows the whole file as one changed line. '
296
+ + 'Review the source it was generated from instead.';
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+
302
+ // ── the diff engine ────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Myers' greedy shortest-edit-script, returning ops or `null` when the two
306
+ * files are too different to be worth aligning.
307
+ *
308
+ * ⚠️ `null` IS A REAL ANSWER AND MUST NOT BE TREATED AS "NO CHANGES". The
309
+ * caller turns it into a wholesale replace, which is honest; a bug that read it
310
+ * as "identical" would be a preview showing an empty diff for a total rewrite —
311
+ * the single worst thing this module could do.
312
+ */
313
+ function shortestEdit(a, b, maxEdits) {
314
+ const n = a.length;
315
+ const m = b.length;
316
+ const max = Math.min(maxEdits, n + m);
317
+ const offset = max + 1;
318
+ const v = new Int32Array(2 * max + 3);
319
+ const trace = [];
320
+
321
+ for (let d = 0; d <= max; d += 1) {
322
+ // ⚠️ Recorded BEFORE this round runs. The backtrack re-derives round d's
323
+ // move from the state that preceded it, so storing the post-state here
324
+ // would make every step off by one and silently mislabel insertions.
325
+ trace.push(v.slice());
326
+ for (let k = -d; k <= d; k += 2) {
327
+ let x;
328
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1];
329
+ else x = v[offset + k - 1] + 1;
330
+ let y = x - k;
331
+ while (x < n && y < m && a[x] === b[y]) { x += 1; y += 1; }
332
+ v[offset + k] = x;
333
+ if (x >= n && y >= m) return backtrack(trace, offset, n, m);
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ function backtrack(trace, offset, n, m) {
340
+ const ops = [];
341
+ let x = n;
342
+ let y = m;
343
+ for (let d = trace.length - 1; d >= 0; d -= 1) {
344
+ const v = trace[d];
345
+ const k = x - y;
346
+ let prevK;
347
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) prevK = k + 1;
348
+ else prevK = k - 1;
349
+ const prevX = v[offset + prevK];
350
+ const prevY = prevX - prevK;
351
+ while (x > prevX && y > prevY) {
352
+ ops.push({ sign: '=', oldIndex: x - 1, newIndex: y - 1 });
353
+ x -= 1; y -= 1;
354
+ }
355
+ if (d > 0) {
356
+ if (x === prevX) ops.push({ sign: '+', oldIndex: -1, newIndex: y - 1 });
357
+ else ops.push({ sign: '-', oldIndex: x - 1, newIndex: -1 });
358
+ }
359
+ x = prevX; y = prevY;
360
+ }
361
+ ops.reverse();
362
+ return ops;
363
+ }
364
+
365
+ /**
366
+ * Trim the shared head and tail before running Myers.
367
+ *
368
+ * Not an optimisation for its own sake: a one-line change in a 3,000-line file
369
+ * has D = 2 after trimming and D = 2 before it only by luck of the algorithm's
370
+ * shape. Trimming makes the `MAX_EDIT_DISTANCE` ceiling apply to how DIFFERENT
371
+ * the files are rather than to how BIG they are, which is the property that
372
+ * keeps ordinary edits to large files inside the fast path.
373
+ */
374
+ function diffLines(a, b, maxEdits) {
375
+ let head = 0;
376
+ while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
377
+ let tail = 0;
378
+ while (tail < a.length - head && tail < b.length - head
379
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1;
380
+
381
+ const midA = a.slice(head, a.length - tail);
382
+ const midB = b.slice(head, b.length - tail);
383
+ const mid = shortestEdit(midA, midB, maxEdits);
384
+ if (mid === null) return null;
385
+
386
+ const ops = [];
387
+ for (let i = 0; i < head; i += 1) ops.push({ sign: '=', oldIndex: i, newIndex: i });
388
+ for (const op of mid) {
389
+ ops.push({
390
+ sign: op.sign,
391
+ oldIndex: op.oldIndex >= 0 ? op.oldIndex + head : -1,
392
+ newIndex: op.newIndex >= 0 ? op.newIndex + head : -1,
393
+ });
394
+ }
395
+ for (let i = 0; i < tail; i += 1) {
396
+ ops.push({ sign: '=', oldIndex: a.length - tail + i, newIndex: b.length - tail + i });
397
+ }
398
+ return ops;
399
+ }
400
+
401
+ /** Every old line removed, then every new line added. The honest fallback. */
402
+ function wholesale(a, b) {
403
+ const ops = [];
404
+ for (let i = 0; i < a.length; i += 1) ops.push({ sign: '-', oldIndex: i, newIndex: -1 });
405
+ for (let i = 0; i < b.length; i += 1) ops.push({ sign: '+', oldIndex: -1, newIndex: i });
406
+ return ops;
407
+ }
408
+
409
+ function groupHunks(ops, context) {
410
+ const changedAt = [];
411
+ for (let i = 0; i < ops.length; i += 1) if (ops[i].sign !== '=') changedAt.push(i);
412
+ if (changedAt.length === 0) return [];
413
+
414
+ const groups = [];
415
+ let start = changedAt[0];
416
+ let end = changedAt[0];
417
+ for (let i = 1; i < changedAt.length; i += 1) {
418
+ // Two changes closer than 2×context share a hunk, otherwise their context
419
+ // blocks would overlap and print the same lines twice.
420
+ if (changedAt[i] - end <= context * 2 + 1) end = changedAt[i];
421
+ else { groups.push([start, end]); start = changedAt[i]; end = changedAt[i]; }
422
+ }
423
+ groups.push([start, end]);
424
+
425
+ // Running counts of how many old/new lines precede each op, so a hunk header
426
+ // can be produced for a pure insertion (which has no old line to read a
427
+ // number off).
428
+ const oldBefore = new Array(ops.length);
429
+ const newBefore = new Array(ops.length);
430
+ let o = 0; let nw = 0;
431
+ for (let i = 0; i < ops.length; i += 1) {
432
+ oldBefore[i] = o; newBefore[i] = nw;
433
+ if (ops[i].sign !== '+') o += 1;
434
+ if (ops[i].sign !== '-') nw += 1;
435
+ }
436
+
437
+ return groups.map(([g0, g1]) => {
438
+ const from = Math.max(0, g0 - context);
439
+ const to = Math.min(ops.length - 1, g1 + context);
440
+ const slice = ops.slice(from, to + 1);
441
+ const oldLines = slice.filter((op) => op.sign !== '+').length;
442
+ const newLines = slice.filter((op) => op.sign !== '-').length;
443
+ return {
444
+ oldStart: oldLines > 0 ? oldBefore[from] + 1 : oldBefore[from],
445
+ oldLines,
446
+ newStart: newLines > 0 ? newBefore[from] + 1 : newBefore[from],
447
+ newLines,
448
+ ops: slice,
449
+ };
450
+ });
451
+ }
452
+
453
+ function rangeText(start, count) {
454
+ // git omits the count when it is 1; matching it means our output pastes into
455
+ // `git apply` and into every diff viewer people already have.
456
+ return count === 1 ? String(start) : `${start},${count}`;
457
+ }
458
+
459
+ /**
460
+ * The unified diff for one file. PURE.
461
+ *
462
+ * `before === null` (or undefined) means the file did not exist — a creation.
463
+ * `after === null` means it is being deleted. An EMPTY STRING is a real,
464
+ * existing, empty file and is not the same thing as either; conflating them
465
+ * would report "created" for a write that blanked a file, which is the exact
466
+ * wording `tools.mjs` records as having frightened a reader once already.
467
+ *
468
+ * @param {string|null|undefined} before
469
+ * @param {string|null|undefined} after
470
+ * @param {string} path
471
+ * @returns {{ok: true, ...} | {ok: false, ...}}
472
+ */
473
+ export function diffUnified(before, after, path, {
474
+ context = DEFAULT_CONTEXT,
475
+ maxBytes = MAX_DIFF_BYTES,
476
+ maxLines = MAX_DIFF_LINES,
477
+ maxLineChars = MAX_DIFF_LINE_CHARS,
478
+ maxEdits = MAX_EDIT_DISTANCE,
479
+ } = {}) {
480
+ const rel = String(path ?? '');
481
+ const existedBefore = before !== null && before !== undefined;
482
+ const existsAfter = after !== null && after !== undefined;
483
+
484
+ const kind = !existedBefore && existsAfter ? 'created'
485
+ : existedBefore && !existsAfter ? 'deleted'
486
+ : !existedBefore && !existsAfter ? 'absent'
487
+ : String(before) === String(after) ? 'unchanged' : 'replaced';
488
+
489
+ const base = {
490
+ path: rel,
491
+ kind,
492
+ added: 0,
493
+ removed: 0,
494
+ beforeBytes: existedBefore ? Buffer.byteLength(String(before), 'utf8') : 0,
495
+ afterBytes: existsAfter ? Buffer.byteLength(String(after), 'utf8') : 0,
496
+ };
497
+
498
+ if (kind === 'absent') {
499
+ return { ok: false, ...base, reason: 'there is nothing to compare: the file neither existed before nor exists after' };
500
+ }
501
+
502
+ const refusal = refuseToRender(before, after, { maxBytes, maxLines, maxLineChars });
503
+ if (refusal) {
504
+ /**
505
+ * ⚠️ `ok: false` HERE MEANS "NOT SHOWABLE", NOT "NOT HAPPENING". The counts
506
+ * we can still compute are returned alongside, because a reviewer staring at
507
+ * a refusal with no numbers has strictly less than they started with.
508
+ */
509
+ return { ok: false, ...base, reason: refusal, renderable: false };
510
+ }
511
+
512
+ const a = splitLines(existedBefore ? String(before) : '');
513
+ const b = splitLines(existsAfter ? String(after) : '');
514
+
515
+ const eol = {
516
+ before: existedBefore ? eolStyle(a.eols) : 'none',
517
+ after: existsAfter ? eolStyle(b.eols) : 'none',
518
+ changed: false,
519
+ };
520
+ eol.changed = kind === 'replaced' && eol.before !== 'none' && eol.after !== 'none' && eol.before !== eol.after;
521
+
522
+ if (kind === 'unchanged') {
523
+ return {
524
+ ok: true, ...base, eol, hunks: [], text: '', truncatedByDistance: false,
525
+ noNewlineBefore: !a.finalNewline, noNewlineAfter: !b.finalNewline, invisible: false,
526
+ };
527
+ }
528
+
529
+ let ops = diffLines(a.lines, b.lines, maxEdits);
530
+ const truncatedByDistance = ops === null;
531
+ if (truncatedByDistance) ops = wholesale(a.lines, b.lines);
532
+
533
+ /**
534
+ * ⚠️⚠️ A TRAILING NEWLINE THAT APPEARS OR VANISHES CHANGES ZERO LINES.
535
+ * `"a\n"` → `"a"` is a real, byte-level change that every line-based diff
536
+ * reports as nothing at all. Forcing the last line into a `-`/`+` pair is
537
+ * what git does, and it is the only way the ``
538
+ * marker below ever gets somewhere to attach.
539
+ */
540
+ if (a.finalNewline !== b.finalNewline && existedBefore && existsAfter) {
541
+ const last = ops[ops.length - 1];
542
+ if (last && last.sign === '=') {
543
+ ops = ops.slice(0, -1);
544
+ ops.push({ sign: '-', oldIndex: last.oldIndex, newIndex: -1 });
545
+ ops.push({ sign: '+', oldIndex: -1, newIndex: last.newIndex });
546
+ }
547
+ }
548
+
549
+ const added = ops.filter((op) => op.sign === '+').length;
550
+ const removed = ops.filter((op) => op.sign === '-').length;
551
+ const hunks = groupHunks(ops, context).map(({ ops: hunkOps, ...h }) => ({
552
+ ...h,
553
+ lines: hunkOps.map((op) => ({
554
+ /**
555
+ * ⚠️ `=` IS AN INTERNAL TOKEN AND A UNIFIED DIFF USES A SPACE. Emitting
556
+ * `=a` for a context line produced something that LOOKS like a diff in a
557
+ * terminal and is rejected by `git apply`, by every review tool, and by
558
+ * every editor's diff viewer. Caught by a test asserting `^ b$`; nothing
559
+ * about the rendered output looked wrong to the eye.
560
+ */
561
+ sign: op.sign === '=' ? ' ' : op.sign,
562
+ text: op.sign === '+' ? b.lines[op.newIndex] : a.lines[op.oldIndex],
563
+ oldIndex: op.oldIndex,
564
+ newIndex: op.newIndex,
565
+ })),
566
+ }));
567
+
568
+ const result = {
569
+ ok: true,
570
+ ...base,
571
+ added,
572
+ removed,
573
+ eol,
574
+ hunks,
575
+ truncatedByDistance,
576
+ noNewlineBefore: existedBefore && !a.finalNewline,
577
+ noNewlineAfter: existsAfter && !b.finalNewline,
578
+ oldLineCount: a.lines.length,
579
+ newLineCount: b.lines.length,
580
+ /**
581
+ * ⭐ THE FLAG THAT STOPS THE PREVIEW LYING. The bytes differ and the lines
582
+ * do not, which today only happens for a pure line-ending flip. Without
583
+ * this the renderer prints a header, no hunks, and reads as "nothing
584
+ * changed" over a write that touches every byte in the file.
585
+ */
586
+ invisible: added === 0 && removed === 0 && kind === 'replaced',
587
+ };
588
+ result.text = renderUnified(result);
589
+ return result;
590
+ }
591
+
592
+ /** `--- a/x` / `+++ b/x` / `@@` … — the pasteable form. */
593
+ export function renderUnified(diff) {
594
+ if (!diff?.ok) return '';
595
+ const lines = [];
596
+ lines.push(`--- ${diff.kind === 'created' ? '/dev/null' : `a/${diff.path}`}`);
597
+ lines.push(`+++ ${diff.kind === 'deleted' ? '/dev/null' : `b/${diff.path}`}`);
598
+ for (const h of diff.hunks) {
599
+ lines.push(`@@ -${rangeText(h.oldStart, h.oldLines)} +${rangeText(h.newStart, h.newLines)} @@`);
600
+ for (const l of h.lines) {
601
+ lines.push(`${l.sign}${l.text}`);
602
+ if (l.sign !== '+' && diff.noNewlineBefore && l.oldIndex === diff.oldLineCount - 1) {
603
+ lines.push('\');
604
+ } else if (l.sign === '+' && diff.noNewlineAfter && l.newIndex === diff.newLineCount - 1) {
605
+ lines.push('\');
606
+ }
607
+ }
608
+ }
609
+ return lines.join('\n');
610
+ }
611
+
612
+ // ── the policy ─────────────────────────────────────────────────────────────
613
+
614
+ /** `.acuvo/` — the directory holding `policy.json` and `mcp.json`. See the
615
+ * import note for why this is a deliberate duplicate rather than an import. */
616
+ export function isProtectedConfigPath(relPath) {
617
+ if (typeof relPath !== 'string') return false;
618
+ return relPath === '.acuvo' || relPath.startsWith('.acuvo/');
619
+ }
620
+
621
+ export function isScratchPath(relPath, prefixes = SCRATCH_PREFIXES) {
622
+ const p = String(relPath ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
623
+ return prefixes.some((prefix) => p === prefix.replace(/\/$/, '') || p.startsWith(prefix));
624
+ }
625
+
626
+ /**
627
+ * Read the review mode.
628
+ *
629
+ * ⚠️ AN UNKNOWN VALUE IS AN ERROR, NOT A FALLBACK TO THE DEFAULT. `policy.mjs`
630
+ * names this exact failure: `ACUVO_APPROVE=nver` is a person asking for a gate
631
+ * and silently getting none, and the symptom is *everything works fine*.
632
+ */
633
+ export function approvalMode({ env = process.env, flag = null } = {}) {
634
+ if (flag !== null && flag !== undefined) {
635
+ if (!APPROVE_MODES.includes(flag)) {
636
+ return { ok: false, error: `review mode "${flag}" is not one of ${APPROVE_MODES.join(', ')}` };
637
+ }
638
+ return { ok: true, mode: flag, from: 'flag' };
639
+ }
640
+ const raw = String(env?.[APPROVE_ENV] ?? '').trim().toLowerCase();
641
+ if (!raw) return { ok: true, mode: 'auto', from: 'default' };
642
+ if (!APPROVE_MODES.includes(raw)) {
643
+ return {
644
+ ok: false,
645
+ error: `${APPROVE_ENV}="${raw}" is not a review mode. Use one of ${APPROVE_MODES.join(', ')} — `
646
+ + '"auto" asks only about writes that destroy something, "always" asks about every write, "never" asks about none.',
647
+ };
648
+ }
649
+ return { ok: true, mode: raw, from: APPROVE_ENV };
650
+ }
651
+
652
+ /**
653
+ * ⭐⭐ THE POLICY. Is a human's approval REQUIRED before this write lands, and
654
+ * can it actually be obtained here?
655
+ *
656
+ * PURE. Everything it needs about the outside world — is there a terminal, did
657
+ * this run create this file, what did the user already say — arrives as an
658
+ * argument.
659
+ *
660
+ * @param {{path: string, before?: string|null, after?: string|null, exists?: boolean}} write
661
+ * @returns {{required: boolean, satisfiable: boolean, risk: string, reason: string, blocked: boolean, reviewed: boolean, scope: string}}
662
+ */
663
+ export function approvalDecision(write, {
664
+ mode = 'auto',
665
+ interactive = false,
666
+ createdThisRun = null,
667
+ sessionApproved = false,
668
+ scratchPrefixes = SCRATCH_PREFIXES,
669
+ isSecretPath = refusedCommitPath,
670
+ isProtectedPath = isProtectedConfigPath,
671
+ rewriteFraction = REWRITE_FRACTION,
672
+ } = {}) {
673
+ const path = String(write?.path ?? '');
674
+ const before = write?.before;
675
+ const after = write?.after;
676
+ const existedBefore = write?.exists ?? (before !== null && before !== undefined);
677
+ const existsAfter = after !== null && after !== undefined;
678
+
679
+ const madeByThisRun = typeof createdThisRun === 'function'
680
+ ? Boolean(createdThisRun(path))
681
+ : Boolean(createdThisRun && typeof createdThisRun.has === 'function' && createdThisRun.has(path));
682
+
683
+ const risk = writeRisk({
684
+ path, before, after, existedBefore, existsAfter, madeByThisRun,
685
+ scratchPrefixes, isSecretPath, isProtectedPath, rewriteFraction,
686
+ });
687
+
688
+ const settled = (over) => ({
689
+ required: false, satisfiable: true, blocked: false, reviewed: false, scope: 'write', risk: risk.level, ...over,
690
+ });
691
+
692
+ if (mode === 'never') {
693
+ return settled({ reason: `review is switched off (${APPROVE_ENV}=never / --yes), so ${path} was written without asking` });
694
+ }
695
+ if (risk.level === 'none') {
696
+ return settled({ reason: risk.why });
697
+ }
698
+ if (sessionApproved) {
699
+ return settled({ reason: `you answered "all remaining" earlier in this run, so ${path} was not queried again` });
700
+ }
701
+
702
+ const needed = mode === 'always' || RISK_ORDER[risk.level] >= RISK_ORDER.medium;
703
+ if (!needed) {
704
+ return settled({ reason: risk.why });
705
+ }
706
+
707
+ if (!interactive) {
708
+ if (mode === 'always') {
709
+ /**
710
+ * ⚠️ THE ONE FAIL-CLOSED BRANCH, AND THE ONLY ONE THAT EARNS IT. Somebody
711
+ * stated an intent — "ask me about every write" — that cannot be honoured
712
+ * here. Honouring it silently is impossible and ignoring it silently is
713
+ * the fail-open shape `policy.mjs` refuses by name. So it stops, and it
714
+ * names three ways forward rather than one.
715
+ */
716
+ return {
717
+ required: true,
718
+ satisfiable: false,
719
+ blocked: true,
720
+ reviewed: false,
721
+ scope: 'write',
722
+ risk: risk.level,
723
+ reason: `${path}: you asked for every write to be approved, but stdin and stdout are not both terminals, so there is nobody here to ask `
724
+ + '— and waiting for an answer that cannot arrive would hang this run instead of failing it.\n'
725
+ + `Run it from a terminal to review the change, pass --yes (${APPROVE_ENV}=never) to write without review, `
726
+ + `or ${APPROVE_ENV}=auto to review only the writes that destroy something.`,
727
+ };
728
+ }
729
+ /**
730
+ * ⭐ THE DEFAULT FAILS OPEN, DELIBERATELY, AND SAYS SO. See the header: a
731
+ * review gate that breaks CI gets globally disabled and then protects
732
+ * nobody. `reviewed: false` is what lets the run summary count these
733
+ * honestly instead of implying they were seen.
734
+ */
735
+ return {
736
+ required: false,
737
+ satisfiable: false,
738
+ blocked: false,
739
+ reviewed: false,
740
+ scope: 'write',
741
+ risk: risk.level,
742
+ reason: `${path}: ${risk.why}, but there is no terminal here to ask, so it was written WITHOUT review. `
743
+ + `Run interactively to review it, or set ${APPROVE_ENV}=always to make an unattended run refuse rather than write.`,
744
+ };
745
+ }
746
+
747
+ return {
748
+ required: true,
749
+ satisfiable: true,
750
+ blocked: false,
751
+ reviewed: false,
752
+ scope: 'write',
753
+ risk: risk.level,
754
+ reason: risk.why,
755
+ };
756
+ }
757
+
758
+ /**
759
+ * How much of this write cannot be undone?
760
+ *
761
+ * ⭐ THE AXIS IS DESTRUCTION, NOT MODIFICATION. See the header. Everything here
762
+ * is a statement about what stops existing if the write proceeds.
763
+ */
764
+ export function writeRisk({
765
+ path, before, after, existedBefore, existsAfter, madeByThisRun,
766
+ scratchPrefixes = SCRATCH_PREFIXES,
767
+ isSecretPath = refusedCommitPath,
768
+ isProtectedPath = isProtectedConfigPath,
769
+ rewriteFraction = REWRITE_FRACTION,
770
+ }) {
771
+ /**
772
+ * ⚠️ THE PATH CHECKS COME FIRST AND ARE NOT DOWNGRADEABLE. `.acuvo/mcp.json`
773
+ * names a binary spawned on the NEXT run (`mcp-consent.mjs` exists entirely
774
+ * because of that file), and a credential path is the one write whose damage
775
+ * survives being reverted. Neither becomes safe by being small, new, or
776
+ * inside `dist/`.
777
+ */
778
+ if (typeof isSecretPath === 'function' && isSecretPath(path)) {
779
+ return { level: 'high', why: `${path} looks like a credential file, and this write ${existsAfter ? 'changes' : 'deletes'} it` };
780
+ }
781
+ if (typeof isProtectedPath === 'function' && isProtectedPath(path)) {
782
+ return { level: 'high', why: `${path} is agent configuration — it decides what the NEXT run is allowed to do and which binaries it may spawn` };
783
+ }
784
+
785
+ if (existedBefore && !existsAfter) {
786
+ return madeByThisRun
787
+ ? { level: 'low', why: `${path} is being deleted, and this run created it — nothing that predates the run is lost` }
788
+ : { level: 'high', why: `${path} is being DELETED, and its contents existed before this run started` };
789
+ }
790
+
791
+ if (!existedBefore) {
792
+ return { level: 'low', why: `${path} is a new file — nothing is overwritten, and deleting it puts you back exactly where you were` };
793
+ }
794
+
795
+ /**
796
+ * ⚠️ "IT EXISTS BUT WE COULD NOT READ IT" IS THE RISKIEST CASE, NOT A MISSING
797
+ * ONE. A caller passes `exists: true` with no `before` when the file was too
798
+ * big, binary, or unreadable. Falling through would compare the string
799
+ * "undefined" against the new contents and produce a confidently wrong
800
+ * "1 of its 1 pre-existing lines" — a number that reads like a measurement.
801
+ */
802
+ if (before === undefined || before === null) {
803
+ return { level: 'high', why: `${path} exists but its current contents could not be read, so what this write overwrites cannot be shown to you` };
804
+ }
805
+
806
+ if (String(before) === String(after)) {
807
+ // ⭐ A no-op write is not a decision, and asking about one teaches the
808
+ // reflex that makes the real questions worthless.
809
+ return { level: 'none', why: `${path} is written with identical contents — nothing changes` };
810
+ }
811
+
812
+ if (madeByThisRun) {
813
+ return { level: 'low', why: `${path} was created by this run, so this is the agent revising its own draft` };
814
+ }
815
+
816
+ if (isScratchPath(path, scratchPrefixes)) {
817
+ return { level: 'low', why: `${path} is under a build/scratch directory whose contents are regenerated rather than authored` };
818
+ }
819
+
820
+ const oldLines = splitLines(String(before)).lines.length;
821
+ const newLines = splitLines(String(after)).lines.length;
822
+ if (oldLines > 0) {
823
+ const survives = commonLineCount(String(before), String(after));
824
+ const lost = (oldLines - survives) / oldLines;
825
+ if (lost >= rewriteFraction) {
826
+ return {
827
+ level: 'high',
828
+ why: `${path} is being rewritten: ${oldLines - survives} of its ${oldLines} pre-existing lines do not appear in the new contents`
829
+ + `${newLines < oldLines ? `, and the file shrinks to ${newLines} lines` : ''}`,
830
+ };
831
+ }
832
+ }
833
+ return { level: 'medium', why: `${path} already existed before this run, and this write replaces its contents` };
834
+ }
835
+
836
+ /**
837
+ * How many of the old lines survive into the new text.
838
+ *
839
+ * ⚠️ DELIBERATELY A MULTISET COUNT, NOT THE LCS. It is used only to LABEL risk,
840
+ * and it is O(n) rather than O(n·d) — running the full diff here would double
841
+ * the work on every write for a heuristic, and `approvalDecision` is called for
842
+ * writes that are never even shown.
843
+ */
844
+ function commonLineCount(before, after) {
845
+ const counts = new Map();
846
+ for (const l of splitLines(after).lines) counts.set(l, (counts.get(l) ?? 0) + 1);
847
+ let survives = 0;
848
+ for (const l of splitLines(before).lines) {
849
+ const n = counts.get(l) ?? 0;
850
+ if (n > 0) { counts.set(l, n - 1); survives += 1; }
851
+ }
852
+ return survives;
853
+ }
854
+
855
+ /**
856
+ * Plan a whole batch in one pass — the granularity answer, made concrete.
857
+ *
858
+ * ⭐ The point is the SPLIT, not the loop: `auto` is the list a person is
859
+ * actually asked about, and it is the short one. A caller that prints
860
+ * `summariseApprovals` before the first question tells the user up front "6
861
+ * files, 2 need you" — which is what stops the third question feeling like an
862
+ * ambush and getting a reflex `y`.
863
+ */
864
+ export function planApprovals(writes, options = {}) {
865
+ const decisions = [];
866
+ let sessionApproved = Boolean(options.sessionApproved);
867
+ for (const w of writes ?? []) {
868
+ const d = approvalDecision(w, { ...options, sessionApproved });
869
+ decisions.push({ path: String(w?.path ?? ''), ...d });
870
+ // ⚠️ Not mutated by this function: `sessionApproved` only ever arrives from
871
+ // a real answer. Planning must not invent one.
872
+ }
873
+ return {
874
+ decisions,
875
+ ask: decisions.filter((d) => d.required && d.satisfiable),
876
+ blocked: decisions.filter((d) => d.blocked),
877
+ unreviewed: decisions.filter((d) => !d.required && !d.satisfiable),
878
+ auto: decisions.filter((d) => !d.required && d.satisfiable),
879
+ };
880
+ }
881
+
882
+ /** One line the caller can print before the first question. */
883
+ export function summariseApprovals(plan) {
884
+ const total = plan?.decisions?.length ?? 0;
885
+ const parts = [`${total} file${total === 1 ? '' : 's'} to write`];
886
+ if (plan.ask.length) parts.push(`${plan.ask.length} need${plan.ask.length === 1 ? 's' : ''} your approval`);
887
+ if (plan.blocked.length) parts.push(`${plan.blocked.length} cannot be approved here`);
888
+ if (plan.unreviewed.length) parts.push(`${plan.unreviewed.length} will land without review`);
889
+ if (plan.auto.length) parts.push(`${plan.auto.length} need no review`);
890
+ return parts.join(' · ');
891
+ }
892
+
893
+ // ── the answer ─────────────────────────────────────────────────────────────
894
+
895
+ /**
896
+ * Read what the human typed.
897
+ *
898
+ * ⚠️⚠️ EVERYTHING THAT IS NOT A YES IS NOT A YES. `null` (the stream ended
899
+ * mid-question — `askOnce` returns exactly that) is an ABORT, empty is a
900
+ * refusal, and an unrecognised word is a refusal that says which letters work.
901
+ * A parser that fell through to "approve" on anything would turn a dying pipe
902
+ * into consent, which is the failure `prompt.mjs`'s header spends a paragraph
903
+ * on.
904
+ */
905
+ export function interpretAnswer(raw) {
906
+ if (raw === null || raw === undefined) {
907
+ return { decision: 'abort', reason: 'the input ended before an answer arrived, and silence is not consent — nothing was written' };
908
+ }
909
+ const a = String(raw).trim().toLowerCase();
910
+ if (a === 'y' || a === 'yes') return { decision: 'approve', reason: 'approved at the prompt' };
911
+ if (a === 'a' || a === 'all') return { decision: 'approve-all', reason: 'approved, and the rest of this run will not be queried' };
912
+ if (a === 'n' || a === 'no' || a === '') return { decision: 'reject', reason: 'declined at the prompt — the file was left as it was' };
913
+ if (a === 'q' || a === 'quit') return { decision: 'abort', reason: 'stopped at the prompt — this write and everything after it was abandoned' };
914
+ return {
915
+ decision: 'reject',
916
+ reason: `"${String(raw).trim()}" is not one of y / n / a / q, and an answer that is not a yes is treated as a no. `
917
+ + 'The file was left as it was; answer y to apply it.',
918
+ };
919
+ }
920
+
921
+ // ── the terminal view ──────────────────────────────────────────────────────
922
+
923
+ const NO_PAINT = { dim: (s) => s, bold: (s) => s, gold: (s) => s, green: (s) => s, red: (s) => s, cyan: (s) => s };
924
+
925
+ /** Honour the cap override, and refuse a nonsense one rather than silently ignoring it. */
926
+ export function previewLineCap({ env = process.env, fallback = DEFAULT_PREVIEW_LINES } = {}) {
927
+ const raw = String(env?.[DIFF_LINES_ENV] ?? '').trim();
928
+ if (!raw) return fallback;
929
+ const n = Number(raw);
930
+ if (!Number.isInteger(n) || n < 1) return fallback;
931
+ return n;
932
+ }
933
+
934
+ /**
935
+ * The hunks, capped, with the cap STATED.
936
+ *
937
+ * ⚠️ "…and more" IS NOT A STATED CAP. A reader who is told 214 lines were
938
+ * hidden but not why, or how to see them, has been told the preview is
939
+ * incomplete and given no way to complete it — which is the same as being told
940
+ * nothing, with extra anxiety.
941
+ */
942
+ export function renderDiff(diff, { cap = DEFAULT_PREVIEW_LINES, paint = NO_PAINT, indent = ' ' } = {}) {
943
+ if (!diff?.ok) return [];
944
+ const out = [];
945
+ const all = [];
946
+ for (const h of diff.hunks) {
947
+ all.push({ sign: '@', text: `@@ -${rangeText(h.oldStart, h.oldLines)} +${rangeText(h.newStart, h.newLines)} @@` });
948
+ for (let i = 0; i < h.lines.length; i += 1) {
949
+ const l = h.lines[i];
950
+ let note = '';
951
+ /**
952
+ * ⭐ THE ANNOTATION THAT MAKES AN INVISIBLE EDIT VISIBLE. `-const a = 1;`
953
+ * over `+const a = 1; ` are the same seven pixels wide in every terminal
954
+ * ever made. Nobody catches this by looking, so the renderer says it.
955
+ */
956
+ const prev = h.lines[i - 1];
957
+ if (l.sign === '+' && prev?.sign === '-' && prev.text !== l.text && prev.text.trimEnd() === l.text.trimEnd()) {
958
+ note = paint.dim(' ⟵ trailing whitespace only');
959
+ }
960
+ all.push({ sign: l.sign, text: `${l.sign}${l.text}${note}` });
961
+ if (l.sign !== '+' && diff.noNewlineBefore && l.oldIndex === diff.oldLineCount - 1) {
962
+ all.push({ sign: '\\', text: '\' });
963
+ } else if (l.sign === '+' && diff.noNewlineAfter && l.newIndex === diff.newLineCount - 1) {
964
+ all.push({ sign: '\\', text: '\' });
965
+ }
966
+ }
967
+ }
968
+
969
+ const shown = all.slice(0, cap);
970
+ for (const l of shown) {
971
+ const colour = l.sign === '+' ? paint.green : l.sign === '-' ? paint.red : paint.dim;
972
+ out.push(indent + colour(l.text));
973
+ }
974
+ if (all.length > shown.length) {
975
+ out.push(indent + paint.dim(
976
+ `… ${all.length - shown.length} more diff line${all.length - shown.length === 1 ? '' : 's'} hidden `
977
+ + `(showing ${shown.length} of ${all.length}; the cap is ${cap}). `
978
+ + `Set ${DIFF_LINES_ENV}=${all.length} to see all of it.`,
979
+ ));
980
+ }
981
+ return out;
982
+ }
983
+
984
+ /**
985
+ * The whole terminal view: what is changing, by how much, a capped look at it,
986
+ * and the exact question.
987
+ *
988
+ * Returns the body and the prompt SEPARATELY, because the caller writes the
989
+ * body to stderr and hands the prompt to `askOnce` — a single blob would print
990
+ * the question twice or lose the cursor position.
991
+ */
992
+ export function renderApproval(diff, decision, { cap = DEFAULT_PREVIEW_LINES, paint = NO_PAINT, path = null } = {}) {
993
+ const rel = path ?? diff?.path ?? decision?.path ?? '';
994
+ const lines = [];
995
+
996
+ const kind = diff?.kind ?? 'replaced';
997
+ const headline = kind === 'created' ? 'creates' : kind === 'deleted' ? 'DELETES' : 'changes';
998
+ lines.push(` ${paint.gold('✎')} ${paint.bold(rel)} — this ${headline} the file`);
999
+
1000
+ if (diff?.ok) {
1001
+ lines.push(` ${paint.green(`+${diff.added}`)} ${paint.red(`−${diff.removed}`)}`
1002
+ + (kind === 'replaced' ? paint.dim(` (${diff.oldLineCount} lines → ${diff.newLineCount})`) : ''));
1003
+ if (diff.eol?.changed) {
1004
+ // ⭐ Reported in words rather than as N identical-looking ± pairs. See the header.
1005
+ lines.push(` ${paint.dim(`line endings change ${diff.eol.before.toUpperCase()} → ${diff.eol.after.toUpperCase()} across the whole file`)}`);
1006
+ }
1007
+ if (diff.invisible) {
1008
+ lines.push(` ${paint.dim('no line content changes — the bytes differ only in the line endings above')}`);
1009
+ }
1010
+ if (diff.noNewlineBefore !== diff.noNewlineAfter) {
1011
+ lines.push(` ${paint.dim(diff.noNewlineAfter ? 'the trailing newline is REMOVED' : 'a trailing newline is added')}`);
1012
+ }
1013
+ if (diff.truncatedByDistance) {
1014
+ lines.push(` ${paint.dim(`the two versions share too little to align (over ${MAX_EDIT_DISTANCE} edits), so this is shown as a wholesale replacement`)}`);
1015
+ }
1016
+ lines.push(...renderDiff(diff, { cap, paint }));
1017
+ } else {
1018
+ lines.push(` ${paint.dim(`${diff?.beforeBytes ?? 0} bytes → ${diff?.afterBytes ?? 0} bytes`)}`);
1019
+ lines.push(` ${paint.dim(`the change cannot be shown: ${diff?.reason ?? 'unknown'}`)}`);
1020
+ }
1021
+
1022
+ if (decision?.risk === 'high') lines.push(` ${paint.red('⚠')} ${decision.reason}`);
1023
+ else if (decision?.reason) lines.push(` ${paint.dim(decision.reason)}`);
1024
+
1025
+ return {
1026
+ body: lines.join('\n'),
1027
+ prompt: approvalPrompt(rel, kind),
1028
+ lines,
1029
+ };
1030
+ }
1031
+
1032
+ /**
1033
+ * The exact question.
1034
+ *
1035
+ * ⚠️ `[y]es` IS LISTED FIRST AND IS STILL NOT THE DEFAULT — `interpretAnswer`
1036
+ * maps an empty answer to a refusal. The order reflects what a reviewer usually
1037
+ * wants; the default reflects what is safe when they hit return by accident.
1038
+ * `mcp-consent.mjs` uses `[y/N]` for the same reason, and this prompt has four
1039
+ * answers rather than two so the letters are spelled out.
1040
+ */
1041
+ export function approvalPrompt(path, kind = 'replaced') {
1042
+ const verb = kind === 'deleted' ? 'Delete' : kind === 'created' ? 'Create' : 'Apply this change to';
1043
+ return ` ${verb} ${path}? [y]es / [n]o / [a]ll remaining / [q]uit `;
1044
+ }