@warlock.js/ai-workspace 5.39.2 → 5.40.1

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 (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/cjs/index.cjs +691 -114
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/contracts/index.d.mts +2 -2
  6. package/esm/contracts/tool-io.type.d.mts +32 -1
  7. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  8. package/esm/contracts/workspace-ops.contract.d.mts +10 -1
  9. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  10. package/esm/contracts/workspace.contract.d.mts +7 -1
  11. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  12. package/esm/errors.d.mts +17 -1
  13. package/esm/errors.d.mts.map +1 -1
  14. package/esm/errors.mjs +1 -0
  15. package/esm/errors.mjs.map +1 -1
  16. package/esm/index.d.mts +6 -2
  17. package/esm/index.mjs +5 -1
  18. package/esm/ops.d.mts.map +1 -1
  19. package/esm/ops.mjs +16 -0
  20. package/esm/ops.mjs.map +1 -1
  21. package/esm/patch/apply-hunks.d.mts +14 -0
  22. package/esm/patch/apply-hunks.d.mts.map +1 -0
  23. package/esm/patch/apply-hunks.mjs +89 -0
  24. package/esm/patch/apply-hunks.mjs.map +1 -0
  25. package/esm/patch/apply-patch-generic.d.mts +14 -0
  26. package/esm/patch/apply-patch-generic.d.mts.map +1 -0
  27. package/esm/patch/apply-patch-generic.mjs +68 -0
  28. package/esm/patch/apply-patch-generic.mjs.map +1 -0
  29. package/esm/patch/execute-patch.mjs +151 -0
  30. package/esm/patch/execute-patch.mjs.map +1 -0
  31. package/esm/patch/parse-patch.d.mts +45 -0
  32. package/esm/patch/parse-patch.d.mts.map +1 -0
  33. package/esm/patch/parse-patch.mjs +201 -0
  34. package/esm/patch/parse-patch.mjs.map +1 -0
  35. package/esm/tools/apply-patch.d.mts +27 -0
  36. package/esm/tools/apply-patch.d.mts.map +1 -0
  37. package/esm/tools/apply-patch.mjs +61 -0
  38. package/esm/tools/apply-patch.mjs.map +1 -0
  39. package/esm/tools/edit-file.mjs +1 -1
  40. package/esm/tools/glob.mjs +1 -1
  41. package/esm/tools/grep.mjs +1 -1
  42. package/esm/tools/json-schemas.mjs +10 -1
  43. package/esm/tools/json-schemas.mjs.map +1 -1
  44. package/esm/tools/list-dir.mjs +1 -1
  45. package/esm/tools/multi-edit.mjs +1 -1
  46. package/esm/tools/read-file.mjs +1 -1
  47. package/esm/tools/run-shell.mjs +1 -1
  48. package/esm/tools/run-tests.mjs +1 -1
  49. package/esm/tools/shell-kill.mjs +1 -1
  50. package/esm/tools/shell-output.mjs +1 -1
  51. package/esm/tools/todo.mjs +1 -1
  52. package/esm/tools/write-file.mjs +1 -1
  53. package/esm/workspace.d.mts.map +1 -1
  54. package/esm/workspace.mjs +9 -0
  55. package/esm/workspace.mjs.map +1 -1
  56. package/llms-full.txt +7 -6
  57. package/llms.txt +2 -2
  58. package/package.json +3 -3
  59. package/skills/build-loop-agent/SKILL.md +1 -1
  60. package/skills/index.md +1 -1
  61. package/skills/use-a-workspace/SKILL.md +5 -4
package/cjs/index.cjs CHANGED
@@ -89,6 +89,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
89
89
  this.expectedHash = options.expectedHash;
90
90
  this.actualHash = options.actualHash;
91
91
  this.editIndex = options.editIndex;
92
+ this.hunkIndex = options.hunkIndex;
92
93
  }
93
94
  };
94
95
  /**
@@ -1900,6 +1901,493 @@ function buildCommandEnv(policy) {
1900
1901
  return env;
1901
1902
  }
1902
1903
 
1904
+ //#endregion
1905
+ //#region ../../packages/ai-workspace/src/patch/apply-hunks.ts
1906
+ /** The text of a line without a trailing carriage return. */
1907
+ const bare = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
1908
+ /** Same, and ignoring trailing blanks (a second, looser matching pass). */
1909
+ const loose = (line) => bare(line).trimEnd();
1910
+ /** Split file content into lines (carriage returns kept) and note the final newline. */
1911
+ function split(content) {
1912
+ if (content === "") return {
1913
+ lines: [],
1914
+ trailingNewline: false
1915
+ };
1916
+ const parts = content.split("\n");
1917
+ const trailingNewline = parts[parts.length - 1] === "";
1918
+ if (trailingNewline) parts.pop();
1919
+ return {
1920
+ lines: parts,
1921
+ trailingNewline
1922
+ };
1923
+ }
1924
+ /** Whether most of the file's lines end in CRLF. */
1925
+ function usesCrlf(lines) {
1926
+ const crlf = lines.filter((line) => line.endsWith("\r")).length;
1927
+ return crlf > 0 && crlf * 2 >= lines.length;
1928
+ }
1929
+ /** First index at or after `from` (searching outward from `expected`) where `old` matches. */
1930
+ function findMatch(lines, old, expected, from, normalize) {
1931
+ if (old.length === 0) return Math.min(Math.max(expected, from), lines.length);
1932
+ const last = lines.length - old.length;
1933
+ const wanted = old.map(normalize);
1934
+ const matches = (start) => start >= from && start <= last && wanted.every((line, offset) => normalize(lines[start + offset]) === line);
1935
+ for (let distance = 0; distance <= lines.length; distance++) {
1936
+ if (matches(expected + distance)) return expected + distance;
1937
+ if (distance > 0 && matches(expected - distance)) return expected - distance;
1938
+ }
1939
+ return -1;
1940
+ }
1941
+ /**
1942
+ * Apply a file's hunks to its content, in memory. Hunks are located by their
1943
+ * context (not only by line number, so a few lines of drift are fine), must
1944
+ * not overlap, and keep the file's line endings: untouched lines are
1945
+ * byte-for-byte as they were and added lines use the file's CRLF/LF style.
1946
+ *
1947
+ * Throws a `WorkspaceEditError` (`hunk-failed`) naming the first hunk whose
1948
+ * context is not in the file; nothing is returned in that case.
1949
+ */
1950
+ function applyHunks(content, hunks, displayPath) {
1951
+ const { lines, trailingNewline: initialTrailing } = split(content);
1952
+ const wasEmpty = lines.length === 0;
1953
+ const eol = usesCrlf(lines) ? "\r" : "";
1954
+ let trailingNewline = initialTrailing;
1955
+ let cursor = 0;
1956
+ let delta = 0;
1957
+ hunks.forEach((hunk, hunkIndex) => {
1958
+ const old = hunk.lines.filter((line) => line.op !== "+").map((line) => line.text);
1959
+ const expected = (old.length === 0 ? hunk.oldStart : hunk.oldStart - 1) + delta;
1960
+ let start = findMatch(lines, old, expected, cursor, bare);
1961
+ if (start === -1) start = findMatch(lines, old, expected, cursor, loose);
1962
+ if (start === -1) {
1963
+ const probe = old.find((line) => line.trim() !== "") ?? old[0] ?? "";
1964
+ throw new WorkspaceEditError(`Hunk ${hunkIndex + 1} of ${displayPath} does not apply: its context was not found in the file${probe === "" ? "" : ` (looked for a line like ${JSON.stringify(probe.slice(0, 80))})`}. Re-read the file and regenerate the patch.`, {
1965
+ type: "hunk-failed",
1966
+ path: displayPath,
1967
+ hunkIndex
1968
+ });
1969
+ }
1970
+ const replacement = [];
1971
+ let consumed = start;
1972
+ for (const line of hunk.lines) if (line.op === " ") {
1973
+ replacement.push(lines[consumed]);
1974
+ consumed++;
1975
+ } else if (line.op === "-") consumed++;
1976
+ else replacement.push(line.text + eol);
1977
+ lines.splice(start, old.length, ...replacement);
1978
+ cursor = start + replacement.length;
1979
+ delta += replacement.length - old.length;
1980
+ if (cursor >= lines.length) {
1981
+ if (hunk.newNoNewline) trailingNewline = false;
1982
+ else if (hunk.oldNoNewline || wasEmpty) trailingNewline = true;
1983
+ }
1984
+ });
1985
+ if (lines.length === 0) return "";
1986
+ return lines.join("\n") + (trailingNewline ? "\n" : "");
1987
+ }
1988
+
1989
+ //#endregion
1990
+ //#region ../../packages/ai-workspace/src/patch/parse-patch.ts
1991
+ /** Longest patch text accepted, in characters. */
1992
+ const MAX_PATCH_CHARS = 2097152;
1993
+ /** Most files one patch may touch. */
1994
+ const MAX_PATCH_FILES = 200;
1995
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
1996
+ function invalid(message, path = "") {
1997
+ return new WorkspaceEditError(message, {
1998
+ type: "patch-invalid",
1999
+ path
2000
+ });
2001
+ }
2002
+ /** Unquote a git-quoted path (`"a/with\ttab"`) and strip a trailing timestamp. */
2003
+ function cleanPath(raw) {
2004
+ let value = raw.trim();
2005
+ if (value.startsWith("\"")) {
2006
+ const end = value.lastIndexOf("\"");
2007
+ return (end > 0 ? value.slice(1, end) : value.slice(1)).replace(/\\(["\\tn])/g, (_match, char) => char === "t" ? " " : char === "n" ? "\n" : char);
2008
+ }
2009
+ const tab = value.indexOf(" ");
2010
+ if (tab !== -1) value = value.slice(0, tab);
2011
+ return value.trimEnd();
2012
+ }
2013
+ /** `a/x` → `x`; `/dev/null` → `null`. */
2014
+ function stripPrefix(raw, git) {
2015
+ const value = cleanPath(raw);
2016
+ if (value === "/dev/null") return null;
2017
+ return git && /^[ab]\//.test(value) ? value.slice(2) : value;
2018
+ }
2019
+ /**
2020
+ * Parse a unified diff (plain or `git diff` style) into per-file patches:
2021
+ * modifications, creates (`--- /dev/null`), deletes (`+++ /dev/null`) and
2022
+ * renames (`rename from` / `rename to`). A binary patch, a copy, or text that
2023
+ * is not a diff throws a `WorkspaceEditError` (`patch-invalid` /
2024
+ * `binary-file`) naming the problem.
2025
+ *
2026
+ * A CRLF-wrapped patch is accepted: one trailing `\r` is dropped from every
2027
+ * line.
2028
+ */
2029
+ function parsePatch(text) {
2030
+ if (text.length > 2097152) throw invalid(`The patch is larger than ${MAX_PATCH_CHARS} characters; split it up.`);
2031
+ const lines = text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
2032
+ const files = [];
2033
+ let current;
2034
+ const finish = () => {
2035
+ if (current === void 0) return;
2036
+ const state = current;
2037
+ current = void 0;
2038
+ let dashOld = state.oldPath;
2039
+ let dashNew = state.newPath;
2040
+ if (!state.git) {
2041
+ const oldOk = dashOld === void 0 || dashOld === null || dashOld.startsWith("a/");
2042
+ const newOk = dashNew === void 0 || dashNew === null || dashNew.startsWith("b/");
2043
+ if (oldOk && newOk) {
2044
+ dashOld = typeof dashOld === "string" ? dashOld.slice(2) : dashOld;
2045
+ dashNew = typeof dashNew === "string" ? dashNew.slice(2) : dashNew;
2046
+ }
2047
+ }
2048
+ let oldPath = state.renameFrom ?? dashOld;
2049
+ let newPath = state.renameTo ?? dashNew;
2050
+ if (state.isNew) oldPath = null;
2051
+ if (state.isDeleted) newPath = null;
2052
+ if (oldPath === void 0 && newPath === void 0) throw invalid("A file section of the patch names no file.");
2053
+ if (oldPath === void 0) oldPath = newPath ?? null;
2054
+ if (newPath === void 0) newPath = oldPath;
2055
+ const kind = oldPath === null ? "create" : newPath === null ? "delete" : oldPath !== newPath ? "rename" : "modify";
2056
+ if (state.hunks.length === 0 && kind === "modify") return;
2057
+ files.push({
2058
+ kind,
2059
+ oldPath,
2060
+ newPath,
2061
+ hunks: state.hunks
2062
+ });
2063
+ };
2064
+ const open = (git) => {
2065
+ finish();
2066
+ current = {
2067
+ git,
2068
+ oldPath: void 0,
2069
+ newPath: void 0,
2070
+ isNew: false,
2071
+ isDeleted: false,
2072
+ hunks: []
2073
+ };
2074
+ return current;
2075
+ };
2076
+ for (let index = 0; index < lines.length; index++) {
2077
+ const line = lines[index];
2078
+ if (line.startsWith("diff --git ")) {
2079
+ const section = open(true);
2080
+ const rest = line.slice(11);
2081
+ const split = rest.indexOf(" b/");
2082
+ if (rest.startsWith("a/") && split !== -1) {
2083
+ section.oldPath = rest.slice(2, split);
2084
+ section.newPath = rest.slice(split + 3);
2085
+ }
2086
+ continue;
2087
+ }
2088
+ if (line.startsWith("--- ") && lines[index + 1]?.startsWith("+++ ") === true && (current === void 0 || !current.git && current.hunks.length > 0)) open(false);
2089
+ else if (current === void 0) {
2090
+ if (line.startsWith("*** Begin Patch")) throw invalid("This is the \"*** Begin Patch\" format, not a unified diff. Send a unified diff (`--- a/file` / `+++ b/file` / `@@` hunks).");
2091
+ continue;
2092
+ }
2093
+ const section = current;
2094
+ if (line.startsWith("GIT binary patch") || /^Binary files .* differ$/.test(line)) {
2095
+ const shown = section.newPath ?? section.oldPath ?? "a file";
2096
+ throw new WorkspaceEditError(`The patch changes a binary file (${shown}); binary files cannot be patched.`, {
2097
+ type: "binary-file",
2098
+ path: shown ?? ""
2099
+ });
2100
+ }
2101
+ if (line.startsWith("copy from ") || line.startsWith("copy to ")) throw invalid("Copies are not supported in a patch; create the new file instead.");
2102
+ if (line.startsWith("rename from ")) {
2103
+ section.renameFrom = cleanPath(line.slice(12));
2104
+ continue;
2105
+ }
2106
+ if (line.startsWith("rename to ")) {
2107
+ section.renameTo = cleanPath(line.slice(10));
2108
+ continue;
2109
+ }
2110
+ if (line.startsWith("new file mode")) {
2111
+ section.isNew = true;
2112
+ continue;
2113
+ }
2114
+ if (line.startsWith("deleted file mode")) {
2115
+ section.isDeleted = true;
2116
+ continue;
2117
+ }
2118
+ if (line.startsWith("--- ") && section.hunks.length === 0) {
2119
+ section.oldPath = stripPrefix(line.slice(4), section.git);
2120
+ if (section.oldPath === null) section.isNew = true;
2121
+ continue;
2122
+ }
2123
+ if (line.startsWith("+++ ") && section.hunks.length === 0) {
2124
+ section.newPath = stripPrefix(line.slice(4), section.git);
2125
+ if (section.newPath === null) section.isDeleted = true;
2126
+ continue;
2127
+ }
2128
+ const header = HUNK_HEADER.exec(line);
2129
+ if (header !== null) {
2130
+ const oldStart = Number(header[1]);
2131
+ const oldCount = header[2] === void 0 ? 1 : Number(header[2]);
2132
+ let newCount = header[4] === void 0 ? 1 : Number(header[4]);
2133
+ let remainingOld = oldCount;
2134
+ const hunk = {
2135
+ oldStart,
2136
+ oldCount,
2137
+ lines: [],
2138
+ oldNoNewline: false,
2139
+ newNoNewline: false
2140
+ };
2141
+ while (remainingOld > 0 || newCount > 0 || lines[index + 1]?.startsWith("\\")) {
2142
+ const body = lines[index + 1];
2143
+ if (body === void 0) break;
2144
+ if (body.startsWith("\\")) {
2145
+ const last = hunk.lines[hunk.lines.length - 1];
2146
+ if (last?.op !== "+") hunk.oldNoNewline = true;
2147
+ if (last?.op !== "-") hunk.newNoNewline = true;
2148
+ index++;
2149
+ continue;
2150
+ }
2151
+ if (remainingOld <= 0 && newCount <= 0) break;
2152
+ const op = body.charAt(0);
2153
+ if (op === "+" && newCount > 0) {
2154
+ hunk.lines.push({
2155
+ op: "+",
2156
+ text: body.slice(1)
2157
+ });
2158
+ newCount--;
2159
+ } else if (op === "-" && remainingOld > 0) {
2160
+ hunk.lines.push({
2161
+ op: "-",
2162
+ text: body.slice(1)
2163
+ });
2164
+ remainingOld--;
2165
+ } else if ((op === " " || body === "") && remainingOld > 0 && newCount > 0) {
2166
+ hunk.lines.push({
2167
+ op: " ",
2168
+ text: body.slice(1)
2169
+ });
2170
+ remainingOld--;
2171
+ newCount--;
2172
+ } else break;
2173
+ index++;
2174
+ }
2175
+ if (remainingOld > 0 || newCount > 0) throw invalid(`Hunk ${section.hunks.length + 1} of ${section.newPath ?? section.oldPath ?? "the patch"} is truncated or its line counts in the @@ header are wrong.`, section.newPath ?? section.oldPath ?? "");
2176
+ section.hunks.push(hunk);
2177
+ continue;
2178
+ }
2179
+ }
2180
+ finish();
2181
+ if (files.length === 0) throw invalid("The patch contains no file changes. Send a unified diff with ---/+++ headers and @@ hunks.");
2182
+ if (files.length > 200) throw invalid(`The patch touches more than ${200} files; split it up.`);
2183
+ return files;
2184
+ }
2185
+
2186
+ //#endregion
2187
+ //#region ../../packages/ai-workspace/src/patch/execute-patch.ts
2188
+ /** Largest file, in UTF-8 bytes, `applyPatch` will read and rewrite. */
2189
+ const MAX_PATCH_FILE_BYTES = 10485760;
2190
+ async function executePatch(input, io) {
2191
+ {
2192
+ const filePatches = parsePatch(input.patch);
2193
+ const targets = await Promise.all(filePatches.map(async (filePatch) => ({
2194
+ filePatch,
2195
+ from: filePatch.oldPath === null ? null : await io.resolve(filePatch.oldPath),
2196
+ to: filePatch.newPath === null ? null : await io.resolve(filePatch.newPath)
2197
+ })));
2198
+ const lockPaths = [...new Set(targets.flatMap((target) => [target.from?.absolutePath, target.to?.absolutePath].filter((value) => value !== void 0)))].sort();
2199
+ const run = async () => {
2200
+ const staged = /* @__PURE__ */ new Map();
2201
+ const original = /* @__PURE__ */ new Map();
2202
+ const results = [];
2203
+ const load = async (absolutePath, shown) => {
2204
+ if (staged.has(absolutePath)) return staged.get(absolutePath) ?? null;
2205
+ if (!await io.exists(absolutePath)) {
2206
+ original.set(absolutePath, null);
2207
+ return null;
2208
+ }
2209
+ const stat = io.stat ? await io.stat(absolutePath) : void 0;
2210
+ if (stat !== void 0 && stat.type !== "file") throw new WorkspaceEditError(`"${shown}" is not a regular file; it cannot be patched.`, {
2211
+ type: "file-missing",
2212
+ path: shown
2213
+ });
2214
+ if (stat !== void 0 && stat.size > MAX_PATCH_FILE_BYTES) throw new WorkspaceEditError(`"${shown}" is larger than ${MAX_PATCH_FILE_BYTES} bytes; it cannot be patched.`, {
2215
+ type: "patch-invalid",
2216
+ path: shown
2217
+ });
2218
+ const content = await io.read(absolutePath);
2219
+ if (content.includes("\0")) throw new WorkspaceEditError(`"${shown}" is a binary file; binary files cannot be patched.`, {
2220
+ type: "binary-file",
2221
+ path: shown
2222
+ });
2223
+ original.set(absolutePath, content);
2224
+ return content;
2225
+ };
2226
+ for (const { filePatch, from, to } of targets) {
2227
+ const shownFrom = from?.relativePath || filePatch.oldPath || "";
2228
+ const shownTo = to?.relativePath || filePatch.newPath || "";
2229
+ if (filePatch.kind === "create") {
2230
+ const absolutePath = to.absolutePath;
2231
+ if (await load(absolutePath, shownTo) !== null) throw new WorkspaceEditError(`"${shownTo}" already exists; the patch cannot create it.`, {
2232
+ type: "file-exists",
2233
+ path: shownTo
2234
+ });
2235
+ const content = applyHunks("", filePatch.hunks, shownTo);
2236
+ staged.set(absolutePath, content);
2237
+ results.push({
2238
+ path: shownTo,
2239
+ action: "created",
2240
+ hunks: filePatch.hunks.length,
2241
+ hash: _warlock_js_fs.fs.hash.string(content)
2242
+ });
2243
+ continue;
2244
+ }
2245
+ const source = from.absolutePath;
2246
+ const current = await load(source, shownFrom);
2247
+ if (current === null) throw new WorkspaceEditError(`"${shownFrom}" does not exist; the patch cannot change it.`, {
2248
+ type: "file-missing",
2249
+ path: shownFrom
2250
+ });
2251
+ const updated = applyHunks(current, filePatch.hunks, shownFrom);
2252
+ if (filePatch.kind === "delete") {
2253
+ if (updated !== "") throw new WorkspaceEditError(`The patch deletes "${shownFrom}" but its hunks do not remove all of the file's content.`, {
2254
+ type: "hunk-failed",
2255
+ path: shownFrom,
2256
+ hunkIndex: Math.max(filePatch.hunks.length - 1, 0)
2257
+ });
2258
+ staged.set(source, null);
2259
+ results.push({
2260
+ path: shownFrom,
2261
+ action: "deleted",
2262
+ hunks: filePatch.hunks.length
2263
+ });
2264
+ continue;
2265
+ }
2266
+ if (filePatch.kind === "rename") {
2267
+ const destination = to.absolutePath;
2268
+ if (destination !== source && await load(destination, shownTo) !== null) throw new WorkspaceEditError(`"${shownTo}" already exists; the patch cannot rename "${shownFrom}" onto it.`, {
2269
+ type: "file-exists",
2270
+ path: shownTo
2271
+ });
2272
+ staged.set(source, null);
2273
+ staged.set(destination, updated);
2274
+ results.push({
2275
+ path: shownTo,
2276
+ action: "renamed",
2277
+ oldPath: shownFrom,
2278
+ hunks: filePatch.hunks.length,
2279
+ hash: _warlock_js_fs.fs.hash.string(updated)
2280
+ });
2281
+ continue;
2282
+ }
2283
+ staged.set(source, updated);
2284
+ results.push({
2285
+ path: shownFrom,
2286
+ action: "modified",
2287
+ hunks: filePatch.hunks.length,
2288
+ hash: _warlock_js_fs.fs.hash.string(updated)
2289
+ });
2290
+ }
2291
+ if (input.dryRun === true) return {
2292
+ files: results,
2293
+ dryRun: true
2294
+ };
2295
+ const touched = [];
2296
+ try {
2297
+ for (const [absolutePath, content] of staged) if (content !== null && content !== (original.get(absolutePath) ?? null)) {
2298
+ await io.write(absolutePath, content);
2299
+ touched.push(absolutePath);
2300
+ }
2301
+ for (const [absolutePath, content] of staged) if (content === null && original.get(absolutePath) !== null && original.has(absolutePath)) {
2302
+ await io.remove(absolutePath);
2303
+ touched.push(absolutePath);
2304
+ }
2305
+ } catch (error) {
2306
+ const failures = [];
2307
+ for (const absolutePath of touched.reverse()) try {
2308
+ const before = original.get(absolutePath) ?? null;
2309
+ if (before === null) {
2310
+ if (await io.exists(absolutePath)) await io.remove(absolutePath);
2311
+ } else await io.write(absolutePath, before);
2312
+ } catch {
2313
+ failures.push(io.display(absolutePath));
2314
+ }
2315
+ const reason = error instanceof Error ? error.message : String(error);
2316
+ throw new WorkspaceEditError(`Writing the patch failed (${reason}); ${failures.length === 0 ? "every change was rolled back" : `could not restore: ${failures.join(", ")}`}.`, {
2317
+ type: "patch-invalid",
2318
+ path: ""
2319
+ });
2320
+ }
2321
+ return {
2322
+ files: results,
2323
+ dryRun: false
2324
+ };
2325
+ };
2326
+ return lockPaths.reduceRight((inner, lockPath) => () => io.withLock(lockPath, inner), run)();
2327
+ }
2328
+ }
2329
+
2330
+ //#endregion
2331
+ //#region ../../packages/ai-workspace/src/patch/apply-patch-generic.ts
2332
+ /** Most lines the generic reader asks `readFile` for. */
2333
+ const MAX_READ_LINES = 1e6;
2334
+ /**
2335
+ * Raw content of a file through `ops.readFile`, which returns numbered lines.
2336
+ * The numbering is stripped and the trailing newline restored by checking the
2337
+ * result against the file's hash, so the content is exact or the read fails.
2338
+ */
2339
+ async function readRaw(ops, filePath) {
2340
+ const result = await ops.readFile(filePath, {
2341
+ offset: 1,
2342
+ limit: MAX_READ_LINES
2343
+ });
2344
+ if (result.truncated || result.bytesCapped) throw new WorkspaceEditError(`"${filePath}" is too large to read in full; it cannot be patched.`, {
2345
+ type: "patch-invalid",
2346
+ path: filePath
2347
+ });
2348
+ const joined = (result.totalLines === 0 ? [] : result.content.split("\n").map((line) => line.replace(/^ *\d+\t/, ""))).join("\n");
2349
+ for (const candidate of [
2350
+ joined + "\n",
2351
+ joined,
2352
+ ""
2353
+ ]) if (_warlock_js_fs.fs.hash.string(candidate) === result.hash) return candidate;
2354
+ throw new WorkspaceEditError(`Could not read "${filePath}" exactly; it cannot be patched.`, {
2355
+ type: "patch-invalid",
2356
+ path: filePath
2357
+ });
2358
+ }
2359
+ /**
2360
+ * `applyPatch` for a custom {@link WorkspaceOps} that does not provide its
2361
+ * own: the same engine and the same all-or-nothing behaviour, built on the
2362
+ * ops it does have (`exists`, `readFile`, `writeFile`, `remove`). Every
2363
+ * hunk of every file is checked in memory first; the writes then run, and if
2364
+ * one fails the ones already done are put back.
2365
+ */
2366
+ function applyPatchWithOps(ops, input) {
2367
+ const chain = /* @__PURE__ */ new Map();
2368
+ return executePatch(input, {
2369
+ resolve: async (inputPath) => {
2370
+ const key = node_path.default.posix.normalize(inputPath.replace(/\\/g, "/"));
2371
+ return {
2372
+ absolutePath: key,
2373
+ relativePath: key
2374
+ };
2375
+ },
2376
+ exists: (key) => ops.exists(key),
2377
+ read: (key) => readRaw(ops, key),
2378
+ write: async (key, content) => {
2379
+ await ops.writeFile(key, content);
2380
+ },
2381
+ remove: (key) => ops.remove(key),
2382
+ withLock: async (key, task) => {
2383
+ const current = (chain.get(key) ?? Promise.resolve()).then(task, task);
2384
+ chain.set(key, current.catch(() => void 0));
2385
+ return current;
2386
+ },
2387
+ display: (key) => key
2388
+ });
2389
+ }
2390
+
1903
2391
  //#endregion
1904
2392
  //#region ../../packages/ai-workspace/src/shell-jobs.ts
1905
2393
  /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
@@ -2405,6 +2893,21 @@ var Ops = class {
2405
2893
  };
2406
2894
  });
2407
2895
  }
2896
+ async applyPatch(input) {
2897
+ return executePatch(input, {
2898
+ resolve: (inputPath) => resolveInJail(this.policy, inputPath),
2899
+ exists: (absolutePath) => this.backend.exists(absolutePath),
2900
+ stat: this.backend.stat ? (absolutePath) => this.backend.stat(absolutePath) : void 0,
2901
+ read: (absolutePath) => this.backend.readFile(absolutePath),
2902
+ write: async (absolutePath, content) => {
2903
+ await this.backend.mkdir(node_path.default.dirname(absolutePath));
2904
+ await this.backend.writeFile(absolutePath, content);
2905
+ },
2906
+ remove: (absolutePath) => this.backend.remove(absolutePath),
2907
+ withLock: (key, task) => this.withPathLock(key, task),
2908
+ display: (absolutePath) => node_path.default.relative(this.policy.cwd, absolutePath)
2909
+ });
2910
+ }
2408
2911
  /**
2409
2912
  * Check a command line against the shell policy: every command in it
2410
2913
  * (argv or shell mode), then — shell mode — every redirection target and
@@ -3370,120 +3873,6 @@ function isMockBackendSeed(seed) {
3370
3873
  return typeof seed.files === "object" || typeof seed.commands === "object";
3371
3874
  }
3372
3875
 
3373
- //#endregion
3374
- //#region ../../packages/ai-workspace/src/tools/schema.ts
3375
- /**
3376
- * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
3377
- * builders for the workspace tools' input validation. The package pins
3378
- * only `@warlock.js/ai` and `@warlock.js/fs` as runtime dependencies, so
3379
- * rather than pull in a schema library we hand-roll the few shapes the
3380
- * file tools need — exactly the pattern `@warlock.js/ai`'s own `tool()`
3381
- * tests use. Each builder returns a `StandardSchemaV1`, which is what
3382
- * `tool({ input })` validates against before calling `execute`.
3383
- *
3384
- * These intentionally cover only the primitive cases the FILE tools
3385
- * require (`string`, `optional string`, `optional number`, `optional
3386
- * boolean`, and an `object` of fields). They are not a general-purpose
3387
- * validator.
3388
- */
3389
- /** The vendor tag stamped on every issue these builders produce. */
3390
- const VENDOR = "ai-workspace";
3391
- /** Attach a JSON Schema description to a hand-rolled Standard Schema. */
3392
- function withJsonSchema(schema, jsonSchema) {
3393
- return Object.assign(schema, { jsonSchema });
3394
- }
3395
- /** Required string field — rejects anything that is not a string. */
3396
- function stringField() {
3397
- return (value, key) => {
3398
- if (typeof value === "string") return { value };
3399
- return { issues: [{
3400
- message: `"${key}" must be a string`,
3401
- path: [key]
3402
- }] };
3403
- };
3404
- }
3405
- /**
3406
- * Optional string field — accepts `undefined` (the property absent or
3407
- * explicitly undefined) or a string, and rejects every other type.
3408
- */
3409
- function optionalStringField() {
3410
- return (value, key) => {
3411
- if (value === void 0) return { value: void 0 };
3412
- if (typeof value === "string") return { value };
3413
- return { issues: [{
3414
- message: `"${key}" must be a string when provided`,
3415
- path: [key]
3416
- }] };
3417
- };
3418
- }
3419
- /**
3420
- * Optional finite-number field — accepts `undefined` or a finite number,
3421
- * rejecting `NaN`/`Infinity` and non-number types.
3422
- */
3423
- function optionalNumberField() {
3424
- return (value, key) => {
3425
- if (value === void 0) return { value: void 0 };
3426
- if (typeof value === "number" && Number.isFinite(value)) return { value };
3427
- return { issues: [{
3428
- message: `"${key}" must be a finite number when provided`,
3429
- path: [key]
3430
- }] };
3431
- };
3432
- }
3433
- /** Optional boolean field — accepts `undefined` or a boolean. */
3434
- function optionalBooleanField() {
3435
- return (value, key) => {
3436
- if (value === void 0) return { value: void 0 };
3437
- if (typeof value === "boolean") return { value };
3438
- return { issues: [{
3439
- message: `"${key}" must be a boolean when provided`,
3440
- path: [key]
3441
- }] };
3442
- };
3443
- }
3444
- /**
3445
- * Optional array-of-strings field — accepts `undefined` or an array whose
3446
- * every element is a string (at most `maxItems` of them).
3447
- */
3448
- function optionalStringArrayField(maxItems = 50) {
3449
- return (value, key) => {
3450
- if (value === void 0) return { value: void 0 };
3451
- if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
3452
- return { issues: [{
3453
- message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
3454
- path: [key]
3455
- }] };
3456
- };
3457
- }
3458
- function objectSchema(shape, jsonSchema) {
3459
- const schema = buildObjectSchema(shape);
3460
- return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
3461
- }
3462
- /** The validator behind {@link objectSchema}. */
3463
- function buildObjectSchema(shape) {
3464
- return { "~standard": {
3465
- version: 1,
3466
- vendor: VENDOR,
3467
- validate(input) {
3468
- if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
3469
- const source = input;
3470
- const issues = [];
3471
- const result = {};
3472
- for (const key of Object.keys(shape)) {
3473
- const field = shape[key];
3474
- const outcome = field(source[key], key);
3475
- if ("issues" in outcome) {
3476
- issues.push(...outcome.issues);
3477
- continue;
3478
- }
3479
- if (outcome.value !== void 0) result[key] = outcome.value;
3480
- }
3481
- if (issues.length > 0) return { issues };
3482
- return { value: result };
3483
- }
3484
- } };
3485
- }
3486
-
3487
3876
  //#endregion
3488
3877
  //#region ../../packages/ai-workspace/src/tools/json-schemas.ts
3489
3878
  /**
@@ -3530,6 +3919,15 @@ const EDIT_FILE_JSON_SCHEMA = {
3530
3919
  "newString"
3531
3920
  ]
3532
3921
  };
3922
+ /** `apply_patch`. */
3923
+ const APPLY_PATCH_JSON_SCHEMA = {
3924
+ type: "object",
3925
+ properties: {
3926
+ patch: string("Unified diff (--- a/file, +++ b/file, @@ hunks; git style with rename from/to, new file, deleted file is fine). Applies to all files or none. Binary files are refused."),
3927
+ dryRun: boolean("Check that the patch applies without writing anything.")
3928
+ },
3929
+ required: ["patch"]
3930
+ };
3533
3931
  /** `multi_edit`. */
3534
3932
  const MULTI_EDIT_JSON_SCHEMA = {
3535
3933
  type: "object",
@@ -3657,6 +4055,174 @@ const TODO_READ_JSON_SCHEMA = {
3657
4055
  properties: {}
3658
4056
  };
3659
4057
 
4058
+ //#endregion
4059
+ //#region ../../packages/ai-workspace/src/tools/schema.ts
4060
+ /**
4061
+ * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
4062
+ * builders for the workspace tools' input validation. The package pins
4063
+ * only `@warlock.js/ai` and `@warlock.js/fs` as runtime dependencies, so
4064
+ * rather than pull in a schema library we hand-roll the few shapes the
4065
+ * file tools need — exactly the pattern `@warlock.js/ai`'s own `tool()`
4066
+ * tests use. Each builder returns a `StandardSchemaV1`, which is what
4067
+ * `tool({ input })` validates against before calling `execute`.
4068
+ *
4069
+ * These intentionally cover only the primitive cases the FILE tools
4070
+ * require (`string`, `optional string`, `optional number`, `optional
4071
+ * boolean`, and an `object` of fields). They are not a general-purpose
4072
+ * validator.
4073
+ */
4074
+ /** The vendor tag stamped on every issue these builders produce. */
4075
+ const VENDOR = "ai-workspace";
4076
+ /** Attach a JSON Schema description to a hand-rolled Standard Schema. */
4077
+ function withJsonSchema(schema, jsonSchema) {
4078
+ return Object.assign(schema, { jsonSchema });
4079
+ }
4080
+ /** Required string field — rejects anything that is not a string. */
4081
+ function stringField() {
4082
+ return (value, key) => {
4083
+ if (typeof value === "string") return { value };
4084
+ return { issues: [{
4085
+ message: `"${key}" must be a string`,
4086
+ path: [key]
4087
+ }] };
4088
+ };
4089
+ }
4090
+ /**
4091
+ * Optional string field — accepts `undefined` (the property absent or
4092
+ * explicitly undefined) or a string, and rejects every other type.
4093
+ */
4094
+ function optionalStringField() {
4095
+ return (value, key) => {
4096
+ if (value === void 0) return { value: void 0 };
4097
+ if (typeof value === "string") return { value };
4098
+ return { issues: [{
4099
+ message: `"${key}" must be a string when provided`,
4100
+ path: [key]
4101
+ }] };
4102
+ };
4103
+ }
4104
+ /**
4105
+ * Optional finite-number field — accepts `undefined` or a finite number,
4106
+ * rejecting `NaN`/`Infinity` and non-number types.
4107
+ */
4108
+ function optionalNumberField() {
4109
+ return (value, key) => {
4110
+ if (value === void 0) return { value: void 0 };
4111
+ if (typeof value === "number" && Number.isFinite(value)) return { value };
4112
+ return { issues: [{
4113
+ message: `"${key}" must be a finite number when provided`,
4114
+ path: [key]
4115
+ }] };
4116
+ };
4117
+ }
4118
+ /** Optional boolean field — accepts `undefined` or a boolean. */
4119
+ function optionalBooleanField() {
4120
+ return (value, key) => {
4121
+ if (value === void 0) return { value: void 0 };
4122
+ if (typeof value === "boolean") return { value };
4123
+ return { issues: [{
4124
+ message: `"${key}" must be a boolean when provided`,
4125
+ path: [key]
4126
+ }] };
4127
+ };
4128
+ }
4129
+ /**
4130
+ * Optional array-of-strings field — accepts `undefined` or an array whose
4131
+ * every element is a string (at most `maxItems` of them).
4132
+ */
4133
+ function optionalStringArrayField(maxItems = 50) {
4134
+ return (value, key) => {
4135
+ if (value === void 0) return { value: void 0 };
4136
+ if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
4137
+ return { issues: [{
4138
+ message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
4139
+ path: [key]
4140
+ }] };
4141
+ };
4142
+ }
4143
+ function objectSchema(shape, jsonSchema) {
4144
+ const schema = buildObjectSchema(shape);
4145
+ return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
4146
+ }
4147
+ /** The validator behind {@link objectSchema}. */
4148
+ function buildObjectSchema(shape) {
4149
+ return { "~standard": {
4150
+ version: 1,
4151
+ vendor: VENDOR,
4152
+ validate(input) {
4153
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
4154
+ const source = input;
4155
+ const issues = [];
4156
+ const result = {};
4157
+ for (const key of Object.keys(shape)) {
4158
+ const field = shape[key];
4159
+ const outcome = field(source[key], key);
4160
+ if ("issues" in outcome) {
4161
+ issues.push(...outcome.issues);
4162
+ continue;
4163
+ }
4164
+ if (outcome.value !== void 0) result[key] = outcome.value;
4165
+ }
4166
+ if (issues.length > 0) return { issues };
4167
+ return { value: result };
4168
+ }
4169
+ } };
4170
+ }
4171
+
4172
+ //#endregion
4173
+ //#region ../../packages/ai-workspace/src/tools/apply-patch.ts
4174
+ /** Default tool name exposed to the LLM. */
4175
+ const DEFAULT_NAME$7 = "apply_patch";
4176
+ /** Input schema for the `apply_patch` tool. */
4177
+ const inputSchema$7 = withJsonSchema({ "~standard": {
4178
+ version: 1,
4179
+ vendor: "ai-workspace",
4180
+ validate(value) {
4181
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
4182
+ const candidate = value;
4183
+ const issues = [];
4184
+ if (typeof candidate.patch !== "string" || candidate.patch.trim() === "") issues.push({
4185
+ message: "\"patch\" must be a non-empty string",
4186
+ path: ["patch"]
4187
+ });
4188
+ if (candidate.dryRun !== void 0 && typeof candidate.dryRun !== "boolean") issues.push({
4189
+ message: "\"dryRun\" must be a boolean when provided",
4190
+ path: ["dryRun"]
4191
+ });
4192
+ if (issues.length > 0 || typeof candidate.patch !== "string") return { issues };
4193
+ return { value: {
4194
+ patch: candidate.patch,
4195
+ ...candidate.dryRun === true ? { dryRun: true } : {}
4196
+ } };
4197
+ }
4198
+ } }, APPLY_PATCH_JSON_SCHEMA);
4199
+ /**
4200
+ * Build the agent-facing `apply_patch` tool over a workspace's policy-enforced
4201
+ * {@link WorkspaceOps}: apply a unified diff that modifies, creates, deletes
4202
+ * and renames files. It is **atomic** — every hunk of every file applies, or
4203
+ * nothing on disk changes — every path stays inside the workspace jail, and a
4204
+ * patch that touches a binary file is refused with a clear error. A failure
4205
+ * surfaces as a `WorkspaceEditError` in the result's `error` field.
4206
+ *
4207
+ * @param ops - The shared, policy-enforced operation layer.
4208
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4209
+ *
4210
+ * @example
4211
+ * const patch = makeApplyPatchTool(ops);
4212
+ * const { data, error } = await patch.invoke({
4213
+ * patch: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
4214
+ * });
4215
+ */
4216
+ function makeApplyPatchTool(ops, options) {
4217
+ return (0, _warlock_js_ai.tool)({
4218
+ name: options?.name ?? DEFAULT_NAME$7,
4219
+ description: "Apply a unified diff to the workspace: modify, create (--- /dev/null), delete (+++ /dev/null) and rename (git rename from/to) files in one call. All of it applies or none of it does. Hunks are located by their context, so small line drift is fine; binary files are refused. Use dryRun to check first.",
4220
+ action: (input) => input.dryRun === true ? "Checking a patch" : "Applying a patch",
4221
+ input: inputSchema$7,
4222
+ execute: (input) => ops.applyPatch ? ops.applyPatch(input) : applyPatchWithOps(ops, input)
4223
+ });
4224
+ }
4225
+
3660
4226
  //#endregion
3661
4227
  //#region ../../packages/ai-workspace/src/tools/edit-file.ts
3662
4228
  /** Default tool name exposed to the LLM. */
@@ -4470,6 +5036,7 @@ const ALL_TOOL_NAMES = [
4470
5036
  "readFile",
4471
5037
  "editFile",
4472
5038
  "multiEdit",
5039
+ "applyPatch",
4473
5040
  "writeFile",
4474
5041
  "listDir",
4475
5042
  "runShell",
@@ -4549,6 +5116,7 @@ var WorkspaceImpl = class WorkspaceImpl {
4549
5116
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
4550
5117
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
4551
5118
  multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
5119
+ applyPatch: (opts) => erase(makeApplyPatchTool(this.ops, opts)),
4552
5120
  writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
4553
5121
  listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
4554
5122
  runShell: (opts) => erase(makeRunShellTool(this.ops, {
@@ -4575,6 +5143,7 @@ var WorkspaceImpl = class WorkspaceImpl {
4575
5143
  readFile: (opts) => build("readFile", opts),
4576
5144
  editFile: (opts) => build("editFile", opts),
4577
5145
  multiEdit: (opts) => build("multiEdit", opts),
5146
+ applyPatch: (opts) => build("applyPatch", opts),
4578
5147
  writeFile: (opts) => build("writeFile", opts),
4579
5148
  listDir: (opts) => build("listDir", opts),
4580
5149
  runShell: (opts) => build("runShell", opts),
@@ -4614,6 +5183,10 @@ var WorkspaceImpl = class WorkspaceImpl {
4614
5183
  this.assertWritable("multiEdit");
4615
5184
  return this.ops.multiEdit(input);
4616
5185
  }
5186
+ async applyPatch(input) {
5187
+ this.assertWritable("applyPatch");
5188
+ return this.ops.applyPatch ? this.ops.applyPatch(input) : applyPatchWithOps(this.ops, input);
5189
+ }
4617
5190
  listDir(dirPath, opts) {
4618
5191
  return this.ops.listDir(dirPath, opts);
4619
5192
  }
@@ -4734,12 +5307,15 @@ exports.WorkspaceEditError = WorkspaceEditError;
4734
5307
  exports.WorkspaceJobError = WorkspaceJobError;
4735
5308
  exports.WorkspacePolicyError = WorkspacePolicyError;
4736
5309
  exports.analyzeArgvCommand = analyzeArgvCommand;
5310
+ exports.applyHunks = applyHunks;
5311
+ exports.applyPatchWithOps = applyPatchWithOps;
4737
5312
  exports.buildEnv = buildEnv;
4738
5313
  exports.checkShellCommand = checkShellCommand;
4739
5314
  exports.createLocalBackend = createLocalBackend;
4740
5315
  exports.createMockBackend = createMockBackend;
4741
5316
  exports.createOps = createOps;
4742
5317
  exports.isCommandAllowed = isCommandAllowed;
5318
+ exports.makeApplyPatchTool = makeApplyPatchTool;
4743
5319
  exports.makeEditFileTool = makeEditFileTool;
4744
5320
  exports.makeGlobTool = makeGlobTool;
4745
5321
  exports.makeGrepTool = makeGrepTool;
@@ -4753,6 +5329,7 @@ exports.makeShellOutputTool = makeShellOutputTool;
4753
5329
  exports.makeTodoReadTool = makeTodoReadTool;
4754
5330
  exports.makeTodoWriteTool = makeTodoWriteTool;
4755
5331
  exports.makeWriteFileTool = makeWriteFileTool;
5332
+ exports.parsePatch = parsePatch;
4756
5333
  exports.resolveInJail = resolveInJail;
4757
5334
  exports.tokenizeCommand = tokenizeCommand;
4758
5335
  exports.workspace = workspace;