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,570 @@
1
+ /**
2
+ * ── ⭐⭐⭐ EDIT FAILURES ARE AN APPLICATION PROBLEM, NOT A COMPREHENSION ONE ──
3
+ *
4
+ * Measured across harnesses: removing flexible patch application caused a **9x
5
+ * increase in editing errors**. The model understood the change; the patch would
6
+ * not land.
7
+ *
8
+ * ⚠️ AND `diff -u` IS THE REASON. A unified diff header —
9
+ *
10
+ * @@ -14,7 +14,9 @@
11
+ *
12
+ * — carries four numbers, and **an LLM cannot compute any of them.** After its
13
+ * own earlier edits it has no line-accurate view of the file, so every hunk
14
+ * header is a guess that must be exactly right or the patch is rejected wholesale.
15
+ *
16
+ * ⭐ SO THE NUMBERS ARE DELETED. `@@` becomes a bare marker that may optionally
17
+ * carry the enclosing function or heading, and the location is found by SEARCHING
18
+ * for the context lines. That is Codex's `apply_patch` grammar, and it is the
19
+ * format the strongest agents converged on independently.
20
+ *
21
+ * ⭐ ADD / DELETE / UPDATE ARE ALL FIRST CLASS, so a six-file refactor is ONE
22
+ * atomic blob rather than six separate edits that can half-apply. Half-applied
23
+ * refactors are the failure that leaves a repo worse than before it started.
24
+ *
25
+ * ⚠️⚠️ FOUR MATCHING PASSES, DECREASING IN STRICTNESS, and the last one is not
26
+ * a nicety. Models emit typographic punctuation — U+2018/2019 for quotes,
27
+ * U+2013/2014 for dashes — into source code they were shown with ASCII. An
28
+ * exact-match-only patcher rejects a semantically perfect edit because the model
29
+ * smart-quoted a string. Normalising punctuation on BOTH sides is the fix.
30
+ */
31
+
32
+ export const BEGIN = '*** Begin Patch';
33
+ export const END = '*** End Patch';
34
+
35
+ /** ⚠️ A patch is bounded so a runaway generation cannot be applied. */
36
+ export const MAX_PATCH_BYTES = 400_000;
37
+ export const MAX_FILES_PER_PATCH = 40;
38
+
39
+ /**
40
+ * ── ⚠️ THE PUNCTUATION MAP, WHICH IS PASS FOUR ─────────────────────────────
41
+ * Every character here is one a model substitutes for an ASCII original when it
42
+ * is "tidying" text it is really quoting.
43
+ */
44
+ const PUNCT = new Map([
45
+ ['‘', "'"], ['’', "'"], ['‚', "'"], ['‛', "'"],
46
+ ['“', '"'], ['”', '"'], ['„', '"'], ['‟', '"'],
47
+ ['‐', '-'], ['‑', '-'], ['‒', '-'], ['–', '-'],
48
+ ['—', '-'], ['―', '-'], ['−', '-'],
49
+ [' ', ' '], [' ', ' '], [' ', ' '],
50
+ ['…', '...'],
51
+ ]);
52
+
53
+ export function normalisePunctuation(line) {
54
+ let out = '';
55
+ for (const ch of String(line ?? '')) out += PUNCT.get(ch) ?? ch;
56
+ return out;
57
+ }
58
+
59
+ /** The four passes, strictest first. Each returns a comparable form of a line. */
60
+ const PASSES = Object.freeze([
61
+ { name: 'exact', of: (l) => l },
62
+ { name: 'right-strip', of: (l) => l.replace(/\s+$/, '') },
63
+ { name: 'trim', of: (l) => l.trim() },
64
+ { name: 'punctuation', of: (l) => normalisePunctuation(l).trim() },
65
+ ]);
66
+
67
+ /**
68
+ * Parse the envelope into operations. Returns `{ ok, ops }` or `{ ok: false, error }`.
69
+ *
70
+ * ⚠️ EVERY REFUSAL NAMES THE LINE. A parser that says "invalid patch" costs the
71
+ * model a round of guessing, and this format is written BY the model — so the
72
+ * error message is part of the interface, not an afterthought.
73
+ */
74
+ export function parsePatch(text) {
75
+ const raw = String(text ?? '');
76
+ if (raw.length > MAX_PATCH_BYTES) {
77
+ return { ok: false, error: `patch is ${raw.length} bytes and the limit is ${MAX_PATCH_BYTES}` };
78
+ }
79
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
80
+ const begin = lines.findIndex((l) => l.trim() === BEGIN);
81
+ if (begin === -1) return { ok: false, error: `a patch must start with "${BEGIN}"` };
82
+ const end = lines.findIndex((l, i) => i > begin && l.trim() === END);
83
+ if (end === -1) return { ok: false, error: `a patch must finish with "${END}" — none was found` };
84
+
85
+ const ops = [];
86
+ let cur = null;
87
+ for (let i = begin + 1; i < end; i += 1) {
88
+ const line = lines[i];
89
+ const t = line.trim();
90
+ const add = /^\*\*\* Add File:\s*(.+)$/.exec(t);
91
+ const upd = /^\*\*\* Update File:\s*(.+)$/.exec(t);
92
+ const del = /^\*\*\* Delete File:\s*(.+)$/.exec(t);
93
+ const mov = /^\*\*\* Move to:\s*(.+)$/.exec(t);
94
+
95
+ if (add) { cur = { kind: 'add', path: add[1].trim(), lines: [] }; ops.push(cur); continue; }
96
+ if (upd) { cur = { kind: 'update', path: upd[1].trim(), hunks: [] }; ops.push(cur); continue; }
97
+ if (del) { cur = { kind: 'delete', path: del[1].trim() }; ops.push(cur); continue; }
98
+ if (mov) {
99
+ if (!cur || cur.kind !== 'update') return { ok: false, error: `line ${i + 1}: "Move to" must follow an "Update File"` };
100
+ cur.moveTo = mov[1].trim(); continue;
101
+ }
102
+ if (!cur) return { ok: false, error: `line ${i + 1}: content before any "*** Add/Update/Delete File:" header` };
103
+
104
+ if (cur.kind === 'add') {
105
+ if (line.startsWith('+')) cur.lines.push(line.slice(1));
106
+ else if (t === '') cur.lines.push('');
107
+ else return { ok: false, error: `line ${i + 1}: every line of an added file must start with "+"` };
108
+ continue;
109
+ }
110
+ if (cur.kind === 'update') {
111
+ if (t.startsWith('@@')) { cur.hunks.push({ marker: t.slice(2).trim(), lines: [] }); continue; }
112
+ if (cur.hunks.length === 0) cur.hunks.push({ marker: '', lines: [] });
113
+ const h = cur.hunks[cur.hunks.length - 1];
114
+ if (line.startsWith('+')) h.lines.push({ op: '+', text: line.slice(1) });
115
+ else if (line.startsWith('-')) h.lines.push({ op: '-', text: line.slice(1) });
116
+ else if (line.startsWith(' ')) h.lines.push({ op: ' ', text: line.slice(1) });
117
+ else if (line === '') h.lines.push({ op: ' ', text: '' });
118
+ else return { ok: false, error: `line ${i + 1}: a hunk line must begin with " ", "+" or "-" (got ${JSON.stringify(line.slice(0, 20))})` };
119
+ continue;
120
+ }
121
+ return { ok: false, error: `line ${i + 1}: content after a Delete File header` };
122
+ }
123
+
124
+ if (ops.length === 0) return { ok: false, error: 'the patch contains no file operations' };
125
+ if (ops.length > MAX_FILES_PER_PATCH) {
126
+ return { ok: false, error: `patch touches ${ops.length} files and the limit is ${MAX_FILES_PER_PATCH}` };
127
+ }
128
+ return { ok: true, ops };
129
+ }
130
+
131
+ /**
132
+ * Find where a hunk's context sits in the file, trying each pass in turn.
133
+ *
134
+ * ⚠️ RETURNS THE PASS THAT SUCCEEDED, because a match found only by the loosest
135
+ * pass is worth reporting: it means the model's copy of the file disagreed with
136
+ * the real one, and that is information the caller should be able to surface.
137
+ */
138
+ export function locateHunk(fileLines, hunk) {
139
+ const want = hunk.lines.filter((l) => l.op !== '+').map((l) => l.text);
140
+ if (want.length === 0) return { ok: false, error: 'hunk has no context or removal lines to locate' };
141
+
142
+ for (const pass of PASSES) {
143
+ const needle = want.map(pass.of);
144
+ const hay = fileLines.map(pass.of);
145
+ const hits = [];
146
+ for (let i = 0; i + needle.length <= hay.length; i += 1) {
147
+ let match = true;
148
+ for (let j = 0; j < needle.length; j += 1) {
149
+ if (hay[i + j] !== needle[j]) { match = false; break; }
150
+ }
151
+ if (match) hits.push(i);
152
+ if (hits.length > 1) break;
153
+ }
154
+ /**
155
+ * ⚠️⚠️ AMBIGUITY IS A REFUSAL, NOT A COIN FLIP. Two identical regions mean
156
+ * we do not know which the model meant, and picking one silently edits the
157
+ * wrong place — the single worst outcome available to a patcher, because it
158
+ * looks like success.
159
+ */
160
+ if (hits.length > 1) {
161
+ return { ok: false, error: `that context appears more than once — add a line or two around it so it is unique` };
162
+ }
163
+ if (hits.length === 1) return { ok: true, index: hits[0], pass: pass.name };
164
+ }
165
+ const first = want.find((l) => l.trim()) ?? want[0];
166
+ return { ok: false, error: `context not found in the file. The first line looked for was ${JSON.stringify(first.slice(0, 80))}` };
167
+ }
168
+
169
+ /** Apply one update hunk to an array of lines. */
170
+ function applyHunk(fileLines, hunk) {
171
+ const at = locateHunk(fileLines, hunk);
172
+ if (!at.ok) return at;
173
+ const out = fileLines.slice(0, at.index);
174
+ let cursor = at.index;
175
+ for (const l of hunk.lines) {
176
+ if (l.op === ' ') { out.push(fileLines[cursor]); cursor += 1; }
177
+ else if (l.op === '-') { cursor += 1; }
178
+ else out.push(l.text);
179
+ }
180
+ out.push(...fileLines.slice(cursor));
181
+ return { ok: true, lines: out, pass: at.pass };
182
+ }
183
+
184
+ /**
185
+ * Apply a parsed patch to a file map.
186
+ *
187
+ * ⚠️⚠️ ALL OR NOTHING. A patch that fails on file four must not leave files one
188
+ * to three changed — a half-applied refactor is worse than a rejected one,
189
+ * because the repo is now in a state neither the model nor the user expects.
190
+ * Every operation is computed against a COPY and committed only at the end.
191
+ *
192
+ * @param {Record<string,string>} files
193
+ * @param {string} patchText
194
+ */
195
+ export function applyPatch(files, patchText) {
196
+ const parsed = parsePatch(patchText);
197
+ if (!parsed.ok) return parsed;
198
+
199
+ const next = { ...files };
200
+ const changed = [];
201
+ const looseMatches = [];
202
+
203
+ for (const op of parsed.ops) {
204
+ if (op.kind === 'add') {
205
+ if (Object.prototype.hasOwnProperty.call(next, op.path)) {
206
+ return { ok: false, error: `${op.path} already exists — use "Update File" to change it` };
207
+ }
208
+ next[op.path] = op.lines.join('\n');
209
+ changed.push({ path: op.path, kind: 'added' });
210
+ continue;
211
+ }
212
+ if (op.kind === 'delete') {
213
+ if (!Object.prototype.hasOwnProperty.call(next, op.path)) {
214
+ return { ok: false, error: `cannot delete ${op.path} — it does not exist` };
215
+ }
216
+ delete next[op.path];
217
+ changed.push({ path: op.path, kind: 'deleted' });
218
+ continue;
219
+ }
220
+ // update
221
+ if (!Object.prototype.hasOwnProperty.call(next, op.path)) {
222
+ return { ok: false, error: `cannot update ${op.path} — it does not exist. Use "Add File" to create it` };
223
+ }
224
+ let lines = next[op.path].split('\n');
225
+ for (const hunk of op.hunks) {
226
+ const res = applyHunk(lines, hunk);
227
+ if (!res.ok) return { ok: false, error: `${op.path}: ${res.error}` };
228
+ lines = res.lines;
229
+ if (res.pass !== 'exact') looseMatches.push({ path: op.path, pass: res.pass });
230
+ }
231
+ const target = op.moveTo || op.path;
232
+ if (op.moveTo) {
233
+ delete next[op.path];
234
+ changed.push({ path: op.path, kind: 'moved', to: op.moveTo });
235
+ } else {
236
+ changed.push({ path: op.path, kind: 'updated' });
237
+ }
238
+ next[target] = lines.join('\n');
239
+ }
240
+
241
+ return { ok: true, files: next, changed, looseMatches };
242
+ }
243
+
244
+ /* ════════════════════════════════════════════════════════════════════════════
245
+ * THE CLI TOOL — the engine above, wired to a real workspace
246
+ * ════════════════════════════════════════════════════════════════════════════
247
+ *
248
+ * ── ⭐⭐⭐ WHY THIS IS THE TOP LEVER, MEASURED ON A REAL BUILD ──────────────
249
+ *
250
+ * One build: 28 model calls, 378,507 prompt tokens, ~4 cents.
251
+ *
252
+ * prompt cache 83.2%, and 100% steady-state on a stable prefix
253
+ * OUTPUT $0.045 of $0.080 — 56% of the spend, ~53,000 tokens
254
+ *
255
+ * The cache is at its ceiling and **a prompt cache cannot discount output at
256
+ * all**. Output is dominated by re-emitting whole files through `write_file`,
257
+ * and a patch is 10-50x smaller than the file it changes. It is also the
258
+ * accuracy fix: removing flexible patch application cost a measured 9x increase
259
+ * in editing errors (see this file's header).
260
+ *
261
+ * ── ⚠️⚠️ AND WHY THE GATE, NOT THE ENGINE, IS THE HARD PART ─────────────────
262
+ *
263
+ * A patch writes N files at once. Every write in this CLI goes through
264
+ * `executor.writeFile` / `deleteFile`, and that ONE door carries the file
265
+ * leases, the `.acuvo/` leash, the `node_modules`/`.git` refusals, `--dry-run`
266
+ * and the `--plan` read-only executor. So this module never touches `fs`: it
267
+ * READS through the executor, computes the whole changeset in memory, and
268
+ * writes through the same door `write_file` uses. A second door would be a
269
+ * safety regression larger than the cost win.
270
+ *
271
+ * ⭐ THE WORK IS SPLIT IN TWO — `planPatch` then `commitPatch` — because the
272
+ * approval gate needs the complete file list BEFORE anything lands. That is the
273
+ * same shape `write_files` uses: one question for the whole batch, carrying
274
+ * every path, so a person sees the scope rather than answering forty prompts.
275
+ */
276
+
277
+ /**
278
+ * ⚠️⚠️ A RENAME INSIDE A PATCH WOULD BE A SECOND MOVE PATH THAT SKIPS THE
279
+ * CREDENTIAL GUARD, so `*** Move to:` is refused here even though the engine
280
+ * supports it. `executor.moveFile` refuses `.env` → `notes/env.txt` because
281
+ * `git.mjs` checks committability BY PATH: a rename is exactly how a secret
282
+ * becomes committable under a name the agent chose. Implementing a move as a
283
+ * write plus a delete would route around that guard, and "two doors, one
284
+ * hardened" is the failure this package has already paid for — a hardened
285
+ * `editFile()` existed for weeks while the dispatcher called the unhardened one.
286
+ *
287
+ * ⭐ The engine keeps the capability because its other client is a Map, where
288
+ * none of that applies. The refusal belongs on the door the model reaches.
289
+ */
290
+ const MOVE_REFUSAL = 'this patch uses "*** Move to:", and apply_patch does not rename files. Use move_file for the '
291
+ + 'rename — it is the only verb that checks a move does not carry a credential path out of the namespace '
292
+ + 'git_commit refuses (.env -> notes/env.txt), and it handles large and binary files a patch cannot read. '
293
+ + 'Rename first, then send a patch against the new path.';
294
+
295
+ /**
296
+ * ⭐ THE SENTENCE THAT MAKES THE CAPABILITY REAL. A schema and a handler are two
297
+ * thirds of reachability; telling the model WHEN to reach for this is the third,
298
+ * and it is the one this repo forgets. Without it a coder model keeps emitting
299
+ * whole files, which is the 56%-of-spend this verb exists to cut.
300
+ *
301
+ * ⚠️ IT STATES THE REASON, NOT JUST THE RULE. A rule with no reason is the first
302
+ * thing a model drops when the task gets hard.
303
+ */
304
+ export function applyPatchToolSchemas() {
305
+ return [
306
+ {
307
+ type: 'function',
308
+ function: {
309
+ name: 'apply_patch',
310
+ description: [
311
+ 'Change one or more EXISTING files by sending only the lines that differ.',
312
+ 'PREFER THIS OVER write_file whenever the file already exists: write_file makes you re-emit the whole',
313
+ 'file, and emitting output tokens is 56% of what a run costs and is the one part a prompt cache can',
314
+ 'never discount. A patch is typically 10-50x smaller, and flexible patch application also measures 9x',
315
+ 'FEWER editing errors than rewriting a file from memory.',
316
+ 'FORMAT — no line numbers anywhere, the location is found by searching for your context lines:',
317
+ '*** Begin Patch / *** Update File: <path> / @@ / a few unchanged context lines prefixed with a space,',
318
+ 'lines to remove prefixed with "-", lines to add prefixed with "+" / *** End Patch.',
319
+ '"*** Add File: <path>" (every line prefixed "+") and "*** Delete File: <path>" may appear in the same',
320
+ 'patch. Give enough surrounding context that each hunk matches exactly one place in the file.',
321
+ 'ALL OR NOTHING: if any hunk or any file fails, nothing is written at all — so a refactor across',
322
+ `several files is one atomic call. At most ${MAX_FILES_PER_PATCH} files per patch.`,
323
+ 'Renaming is NOT supported here — use move_file, then patch the new path.',
324
+ ].join(' '),
325
+ parameters: {
326
+ type: 'object',
327
+ properties: {
328
+ patch: {
329
+ type: 'string',
330
+ description: 'The whole patch, from "*** Begin Patch" to "*** End Patch" inclusive.',
331
+ },
332
+ },
333
+ required: ['patch'],
334
+ },
335
+ },
336
+ },
337
+ ];
338
+ }
339
+
340
+ const byteLength = (s) => Buffer.byteLength(String(s ?? ''), 'utf8');
341
+
342
+ /**
343
+ * Work out the entire changeset WITHOUT touching disk.
344
+ *
345
+ * ⚠️ EVERY READ GOES THROUGH THE EXECUTOR, never through `fs` — the in-memory
346
+ * executor the browser builder uses has no filesystem, and a direct read here
347
+ * would make this silently wrong in the one place the registry exists to serve.
348
+ *
349
+ * @returns {{ok: true, writes: Array, deletes: Array, batch: Array, looseMatches: Array}
350
+ * | {ok: false, error: string}}
351
+ */
352
+ export function planPatch(executor, patchText) {
353
+ const parsed = parsePatch(patchText);
354
+ if (!parsed.ok) return parsed;
355
+ if (parsed.ops.some((op) => op.moveTo)) return { ok: false, error: MOVE_REFUSAL };
356
+
357
+ /**
358
+ * The file map the engine works on. An `add` is looked up too — a successful
359
+ * read is how `applyPatch` learns the path is taken and refuses with "already
360
+ * exists — use Update File to change it".
361
+ *
362
+ * ⚠️ AN UNREADABLE PATH IS NOT PROOF OF ABSENCE. A binary or over-large file
363
+ * reads `ok: false`, so an `Add File` over one lands as a plain overwrite —
364
+ * exactly as destructive as `write_file` on the same path and no more. The
365
+ * RESULT still tells the truth, because `created` comes from the executor's
366
+ * own existence check rather than from this map.
367
+ */
368
+ const before = {};
369
+ for (const op of parsed.ops) {
370
+ let read;
371
+ try { read = executor.readFile(op.path); } catch { read = null; }
372
+ const got = read && read.ok !== false && typeof read.content === 'string';
373
+ if (got) before[op.path] = read.content;
374
+ else if (op.kind !== 'add') {
375
+ return {
376
+ ok: false,
377
+ error: `cannot ${op.kind} ${op.path}: ${read?.error ?? 'it could not be read'}`,
378
+ };
379
+ }
380
+ }
381
+
382
+ const applied = applyPatch(before, patchText);
383
+ if (!applied.ok) return applied;
384
+
385
+ /**
386
+ * ── ⚠️ DERIVED FROM THE FINAL STATE, NOT FROM `changed[]` ──────────────────
387
+ *
388
+ * `changed[]` is a LOG of operations, and one path can appear in it more than
389
+ * once: a patch may carry two `Update File` headers for one file, or add a
390
+ * file and delete it again. Walking the log would write that path twice — the
391
+ * second write's `before` would be a value that no longer exists on disk, so
392
+ * a rollback would restore the wrong bytes — and for add-then-delete it would
393
+ * try to write `undefined`.
394
+ *
395
+ * ⭐ `applied.files` is the END STATE, which is the only thing disk should be
396
+ * asked to become. The log is used only for the ORDER, so the person reviewing
397
+ * and the model reading the result see the paths in the order they wrote them.
398
+ */
399
+ const touched = [];
400
+ for (const c of applied.changed) if (!touched.includes(c.path)) touched.push(c.path);
401
+
402
+ const writes = [];
403
+ const deletes = [];
404
+ for (const path of touched) {
405
+ const existed = Object.prototype.hasOwnProperty.call(before, path);
406
+ if (!Object.prototype.hasOwnProperty.call(applied.files, path)) {
407
+ deletes.push({ path, before: existed ? before[path] : null });
408
+ continue;
409
+ }
410
+ const after = applied.files[path];
411
+ /**
412
+ * ⚠️ A PATH THE PATCH TOUCHED AND LEFT BYTE-IDENTICAL IS NOT A WRITE. Writing
413
+ * it would put the file in the "N files changed" count, in the checkpoint
414
+ * journal and in `parallel.mjs`'s collision detection, for a change that does
415
+ * not exist — the same class of lie as `see_page` reporting "replaced
416
+ * index.html" for a photograph.
417
+ */
418
+ if (existed && before[path] === after) continue;
419
+ writes.push({ path, before: existed ? before[path] : null, after, exists: existed });
420
+ }
421
+
422
+ /**
423
+ * ⚠️ A PATCH THAT NETS TO NOTHING IS TOLD SO, NOT REPORTED AS A SUCCESS WITH
424
+ * ZERO FILES. Every hunk matched — so the file already says what the patch
425
+ * asks for — and that is a fact the model must have: silently "succeeding"
426
+ * here is how an agent loops sending the same patch, or worse, reports the
427
+ * work done. This package's standing rule is that a zero-effect success is
428
+ * the worst answer available.
429
+ */
430
+ if (writes.length === 0 && deletes.length === 0) {
431
+ return {
432
+ ok: false,
433
+ error: 'every hunk in this patch matched, and applying it changes nothing — the files already contain '
434
+ + 'exactly what it asks for. Nothing was written. Re-read the file before deciding what still needs doing.',
435
+ };
436
+ }
437
+
438
+ /**
439
+ * ⭐ THE BATCH THE APPROVER SEES, in `write_files`' shape — `{path, before,
440
+ * after, exists}`. `approvalDecision` derives "created / replaced / DELETED"
441
+ * from `after` being null, so a deletion has to arrive as a null rather than
442
+ * as an empty file, or the person is shown a blanking instead of a removal.
443
+ */
444
+ const batch = [
445
+ ...writes,
446
+ ...deletes.map((d) => ({ path: d.path, before: d.before, after: null, exists: true })),
447
+ ];
448
+
449
+ return { ok: true, writes, deletes, batch, looseMatches: applied.looseMatches ?? [] };
450
+ }
451
+
452
+ /**
453
+ * ── ⚠️⚠️ ALL OR NOTHING ALL THE WAY TO DISK ────────────────────────────────
454
+ *
455
+ * `planPatch` already guarantees it for APPLICATION failures — the engine
456
+ * computes against a copy, so a hunk that will not match refuses before a byte
457
+ * moves. This function closes the other half: a write that the EXECUTOR refuses
458
+ * halfway through the changeset.
459
+ *
460
+ * `write_files` deliberately reports partial success and does not roll back, and
461
+ * that is right THERE: it is N independent writes, and its header argues that a
462
+ * rollback would need to restore contents it never read. Neither is true here.
463
+ * A patch is ONE changeset — half a refactor on disk is a repo in a state
464
+ * neither the model nor the user expects — and `planPatch` is already holding
465
+ * every file's previous bytes, because it needed them to compute the new ones.
466
+ *
467
+ * ⭐ AND THE RESTORE CANNOT ITSELF BE REFUSED BY THE GUARD THAT STOPPED US. Any
468
+ * path already written passed the path rules and, when leases are on, is one
469
+ * this run now HOLDS — `auto-lease.mjs` is re-entrant and renews rather than
470
+ * re-acquiring. So the rollback goes back through the same door, and the only
471
+ * residual failure is real I/O, which is reported rather than swallowed.
472
+ *
473
+ * ⚠️ WHAT REMAINS OPEN, STATED HONESTLY: this reacts to the first refusal
474
+ * instead of preventing it. A true preflight needs the lease claimer, and that
475
+ * lives in a closure inside `createLocalExecutor` — exposing it is a one-line
476
+ * change to a file this lane does not own. The behaviour the user sees is the
477
+ * same; the difference is one wasted write-and-restore per conflict.
478
+ */
479
+ export function commitPatch(executor, plan) {
480
+ /** What we have done, newest last. Each entry knows how to undo itself. */
481
+ const undo = [];
482
+ const written = [];
483
+
484
+ const rollback = (why) => {
485
+ const restored = [];
486
+ const stuck = [];
487
+ for (const step of [...undo].reverse()) {
488
+ // A dry run put nothing on disk, so there is nothing to put back.
489
+ if (step.dryRun) continue;
490
+ const r = step.before === null
491
+ ? executor.deleteFile(step.path)
492
+ : executor.writeFile(step.path, step.before);
493
+ if (r?.ok) restored.push(step.path);
494
+ else stuck.push({ path: step.path, error: r?.error ?? 'the restore was refused' });
495
+ }
496
+ /**
497
+ * ⚠️ A FAILED RESTORE MUST NOT REPORT AS "NOTHING HAPPENED". Those paths ARE
498
+ * changed on disk, and the run summary counts `written[]` — so they go in it,
499
+ * flagged, and the error names them. Silence here would be the one outcome
500
+ * worse than the half-apply itself.
501
+ */
502
+ return {
503
+ ok: false,
504
+ written: stuck.map((s) => ({ path: s.path, bytes: 0, stale: true })),
505
+ error: stuck.length === 0
506
+ ? `${why}. Nothing was changed — the whole patch was rolled back, so the files are exactly as they were. `
507
+ + 'Fix that one path and send the patch again.'
508
+ : `${why}. The patch was rolled back, but ${stuck.length} file(s) could NOT be restored and are still `
509
+ + `modified on disk: ${stuck.map((s) => `${s.path} (${s.error})`).join('; ')}. Check them before continuing.`,
510
+ rolledBack: restored,
511
+ rollbackFailed: stuck,
512
+ };
513
+ };
514
+
515
+ for (const w of plan.writes) {
516
+ const r = executor.writeFile(w.path, w.after);
517
+ if (!r?.ok) return rollback(`${w.path}: ${r?.error ?? 'the write was refused'}`);
518
+ const path = r.path ?? w.path;
519
+ written.push({
520
+ path,
521
+ bytes: r.bytes ?? byteLength(w.after),
522
+ created: r.created === true,
523
+ dryRun: r.dryRun === true,
524
+ });
525
+ undo.push({ path, before: w.exists ? w.before : null, dryRun: r.dryRun === true });
526
+ }
527
+
528
+ /**
529
+ * ⭐ DELETES LAST. A patch that removes a module and rewrites its importers in
530
+ * one call should leave the importers correct before the file disappears —
531
+ * and if the delete is the operation that gets refused, the rollback has less
532
+ * to undo.
533
+ */
534
+ for (const d of plan.deletes) {
535
+ const r = executor.deleteFile(d.path);
536
+ if (!r?.ok) return rollback(`${d.path}: ${r?.error ?? 'the delete was refused'}`);
537
+ const path = r.path ?? d.path;
538
+ written.push({ path, bytes: r.bytes ?? 0, deleted: true, dryRun: r.dryRun === true });
539
+ undo.push({ path, before: d.before, dryRun: r.dryRun === true });
540
+ }
541
+
542
+ return { ok: true, written, looseMatches: plan.looseMatches ?? [] };
543
+ }
544
+
545
+ /**
546
+ * What the model reads back.
547
+ *
548
+ * ⚠️ THE LOOSE MATCHES ARE REPORTED, NOT HIDDEN. A hunk that only matched on the
549
+ * trim or punctuation pass means the model's copy of the file disagreed with the
550
+ * real one — that is a fact it should act on before sending the next patch, and
551
+ * `locateHunk` computes it precisely so somebody can say so.
552
+ */
553
+ export function formatApplyPatch(result) {
554
+ if (!result) return 'apply_patch returned nothing';
555
+ if (result.ok !== true) return `apply_patch: ${result.error}`;
556
+ const w = result.written ?? [];
557
+ const dry = w.some((f) => f.dryRun);
558
+ const lines = [
559
+ `${w.length} file${w.length === 1 ? '' : 's'} ${dry ? 'WOULD change (dry run — nothing was written)' : 'changed'}:`,
560
+ ];
561
+ for (const f of w.slice(0, MAX_FILES_PER_PATCH)) {
562
+ const verb = f.deleted ? 'deleted ' : (f.created ? 'created ' : 'patched ');
563
+ lines.push(` ${verb} ${f.path}${f.deleted ? '' : ` (${f.bytes} bytes)`}`);
564
+ }
565
+ for (const m of result.looseMatches ?? []) {
566
+ lines.push(` ⚠ ${m.path}: a hunk matched only after ${m.pass} normalisation — your copy of this file differs `
567
+ + 'from the one on disk. Re-read it before the next patch.');
568
+ }
569
+ return lines.join('\n');
570
+ }