acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,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
|
+
}
|