@warlock.js/ai-workspace 4.5.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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -0
  4. package/cjs/index.cjs +1609 -0
  5. package/cjs/index.cjs.map +1 -0
  6. package/esm/backends/local.d.mts +22 -0
  7. package/esm/backends/local.d.mts.map +1 -0
  8. package/esm/backends/local.mjs +208 -0
  9. package/esm/backends/local.mjs.map +1 -0
  10. package/esm/backends/mock.d.mts +62 -0
  11. package/esm/backends/mock.d.mts.map +1 -0
  12. package/esm/backends/mock.mjs +167 -0
  13. package/esm/backends/mock.mjs.map +1 -0
  14. package/esm/contracts/index.d.mts +5 -0
  15. package/esm/contracts/tool-io.type.d.mts +149 -0
  16. package/esm/contracts/tool-io.type.d.mts.map +1 -0
  17. package/esm/contracts/workspace-backend.contract.d.mts +69 -0
  18. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -0
  19. package/esm/contracts/workspace-ops.contract.d.mts +72 -0
  20. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -0
  21. package/esm/contracts/workspace-policy.type.d.mts +86 -0
  22. package/esm/contracts/workspace-policy.type.d.mts.map +1 -0
  23. package/esm/contracts/workspace.contract.d.mts +131 -0
  24. package/esm/contracts/workspace.contract.d.mts.map +1 -0
  25. package/esm/errors.d.mts +100 -0
  26. package/esm/errors.d.mts.map +1 -0
  27. package/esm/errors.mjs +58 -0
  28. package/esm/errors.mjs.map +1 -0
  29. package/esm/index.d.mts +20 -0
  30. package/esm/index.mjs +15 -0
  31. package/esm/ops.d.mts +25 -0
  32. package/esm/ops.d.mts.map +1 -0
  33. package/esm/ops.mjs +294 -0
  34. package/esm/ops.mjs.map +1 -0
  35. package/esm/policy/policy.d.mts +71 -0
  36. package/esm/policy/policy.d.mts.map +1 -0
  37. package/esm/policy/policy.mjs +184 -0
  38. package/esm/policy/policy.mjs.map +1 -0
  39. package/esm/tools/edit-file.d.mts +40 -0
  40. package/esm/tools/edit-file.d.mts.map +1 -0
  41. package/esm/tools/edit-file.mjs +57 -0
  42. package/esm/tools/edit-file.mjs.map +1 -0
  43. package/esm/tools/glob.d.mts +37 -0
  44. package/esm/tools/glob.d.mts.map +1 -0
  45. package/esm/tools/glob.mjs +45 -0
  46. package/esm/tools/glob.mjs.map +1 -0
  47. package/esm/tools/grep.d.mts +36 -0
  48. package/esm/tools/grep.d.mts.map +1 -0
  49. package/esm/tools/grep.mjs +51 -0
  50. package/esm/tools/grep.mjs.map +1 -0
  51. package/esm/tools/read-file.d.mts +35 -0
  52. package/esm/tools/read-file.d.mts.map +1 -0
  53. package/esm/tools/read-file.mjs +64 -0
  54. package/esm/tools/read-file.mjs.map +1 -0
  55. package/esm/tools/run-shell.d.mts +35 -0
  56. package/esm/tools/run-shell.d.mts.map +1 -0
  57. package/esm/tools/run-shell.mjs +65 -0
  58. package/esm/tools/run-shell.mjs.map +1 -0
  59. package/esm/tools/run-tests.d.mts +40 -0
  60. package/esm/tools/run-tests.d.mts.map +1 -0
  61. package/esm/tools/run-tests.mjs +67 -0
  62. package/esm/tools/run-tests.mjs.map +1 -0
  63. package/esm/tools/schema.mjs +111 -0
  64. package/esm/tools/schema.mjs.map +1 -0
  65. package/esm/tools/write-file.d.mts +33 -0
  66. package/esm/tools/write-file.d.mts.map +1 -0
  67. package/esm/tools/write-file.mjs +52 -0
  68. package/esm/tools/write-file.mjs.map +1 -0
  69. package/esm/workspace.d.mts +54 -0
  70. package/esm/workspace.d.mts.map +1 -0
  71. package/esm/workspace.mjs +210 -0
  72. package/esm/workspace.mjs.map +1 -0
  73. package/llms-full.txt +231 -0
  74. package/llms.txt +10 -0
  75. package/package.json +42 -0
  76. package/skills/README.md +13 -0
  77. package/skills/build-loop-agent/SKILL.md +100 -0
  78. package/skills/use-a-workspace/SKILL.md +117 -0
package/cjs/index.cjs ADDED
@@ -0,0 +1,1609 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) {
14
+ __defProp(to, key, {
15
+ get: ((k) => from[k]).bind(null, key),
16
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
17
+ });
18
+ }
19
+ }
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
24
+ value: mod,
25
+ enumerable: true
26
+ }) : target, mod));
27
+
28
+ //#endregion
29
+ let _warlock_js_ai = require("@warlock.js/ai");
30
+ let node_path = require("node:path");
31
+ node_path = __toESM(node_path, 1);
32
+ let node_fs_promises = require("node:fs/promises");
33
+ let _warlock_js_fs = require("@warlock.js/fs");
34
+ let node_child_process = require("node:child_process");
35
+ let node_process = require("node:process");
36
+
37
+ //#region ../@warlock.js/ai-workspace/src/errors.ts
38
+ /**
39
+ * The workspace policy engine refused an operation — a path escaped the
40
+ * jail (or hit a deny glob), or a shell command's executable was not
41
+ * allowed.
42
+ *
43
+ * **Surface.** This is returned to the agent as tool-error *data*, never
44
+ * a thrown run-killer — the agent reads the failure and self-corrects.
45
+ * Extends the framework `AIError` (category `"tool"`, code
46
+ * `TOOL_EXEC_FAILED`) so it flows through the same typed error contract
47
+ * as every other AI error; branch on `error.type` for the specific
48
+ * violation.
49
+ *
50
+ * @example
51
+ * if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
52
+ * console.warn(`Blocked command: ${error.command}`);
53
+ * }
54
+ */
55
+ var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
56
+ constructor(message, options) {
57
+ super("TOOL_EXEC_FAILED", message, options);
58
+ this.name = "WorkspacePolicyError";
59
+ this.type = options.type;
60
+ this.path = options.path;
61
+ this.command = options.command;
62
+ }
63
+ };
64
+ /**
65
+ * An `editFile` operation was rejected by the read-before-edit guard:
66
+ * the `oldString` was absent, matched non-uniquely without `replaceAll`,
67
+ * or the file's hash no longer matched the supplied `expectHash`.
68
+ *
69
+ * **Surface.** Like {@link WorkspacePolicyError}, returned to the agent
70
+ * as tool-error *data* so it can re-read and retry. Extends `AIError`
71
+ * (category `"tool"`, code `TOOL_EXEC_FAILED`); branch on `error.type`.
72
+ *
73
+ * @example
74
+ * if (error instanceof WorkspaceEditError && error.type === "stale-hash") {
75
+ * // re-read the file and retry the edit with the fresh hash
76
+ * }
77
+ */
78
+ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
79
+ constructor(message, options) {
80
+ super("TOOL_EXEC_FAILED", message, options);
81
+ this.name = "WorkspaceEditError";
82
+ this.type = options.type;
83
+ this.path = options.path;
84
+ this.matches = options.matches;
85
+ this.expectedHash = options.expectedHash;
86
+ this.actualHash = options.actualHash;
87
+ }
88
+ };
89
+
90
+ //#endregion
91
+ //#region ../@warlock.js/ai-workspace/src/policy/policy.ts
92
+ /**
93
+ * Resolve the canonical absolute form of `target`, collapsing any
94
+ * symlinks. `target` may not yet exist (a fresh `writeFile`/`mkdir`),
95
+ * so we realpath the deepest **existing** ancestor and re-attach the
96
+ * non-existent tail — a symlinked ancestor still cannot smuggle the
97
+ * path out of the jail, while genuinely new leaves stay creatable.
98
+ */
99
+ async function canonicalize$1(target) {
100
+ let resolvedTarget = node_path.default.resolve(target);
101
+ const tail = [];
102
+ while (true) try {
103
+ const real = await (0, node_fs_promises.realpath)(resolvedTarget);
104
+ return tail.length > 0 ? node_path.default.join(real, ...tail) : real;
105
+ } catch (error) {
106
+ if (error.code !== "ENOENT") throw error;
107
+ const parent = node_path.default.dirname(resolvedTarget);
108
+ if (parent === resolvedTarget) return node_path.default.join(resolvedTarget, ...tail);
109
+ tail.unshift(node_path.default.basename(resolvedTarget));
110
+ resolvedTarget = parent;
111
+ }
112
+ }
113
+ /**
114
+ * Whether `child` is contained within `root` (or equals it), comparing
115
+ * canonical absolute paths. Guards against the `/srv/app-evil` vs
116
+ * `/srv/app` prefix-collision by anchoring on a path separator.
117
+ */
118
+ function isInside(child, root) {
119
+ const relative = node_path.default.relative(root, child);
120
+ return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
121
+ }
122
+ /**
123
+ * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
124
+ * `?`) into an anchored `RegExp` over a `/`-separated relative path.
125
+ * `**` spans path separators; a single `*` does not.
126
+ */
127
+ function globToRegExp$1(glob) {
128
+ let source = "";
129
+ for (let index = 0; index < glob.length; index++) {
130
+ const char = glob[index];
131
+ if (char === "*") {
132
+ if (glob[index + 1] === "*") {
133
+ source += ".*";
134
+ index++;
135
+ if (glob[index + 1] === "/") index++;
136
+ } else source += "[^/]*";
137
+ continue;
138
+ }
139
+ if (char === "?") {
140
+ source += "[^/]";
141
+ continue;
142
+ }
143
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
144
+ }
145
+ return new RegExp(`^${source}$`);
146
+ }
147
+ /**
148
+ * Whether a workspace-relative (`/`-separated) path matches any of the
149
+ * policy's `denyPaths` globs. A deny glob also blocks everything beneath
150
+ * a matched directory (`".git/**"` blocks `.git/config`).
151
+ */
152
+ function matchesDeny(relativePath, denyPaths) {
153
+ return denyPaths.some((glob) => {
154
+ if (globToRegExp$1(glob).test(relativePath)) return true;
155
+ if (!glob.includes("*") && !glob.includes("?")) {
156
+ const prefix = glob.endsWith("/") ? glob : `${glob}/`;
157
+ return relativePath.startsWith(prefix);
158
+ }
159
+ return false;
160
+ });
161
+ }
162
+ /**
163
+ * Resolve and jail a single input path against a {@link WorkspacePolicy}.
164
+ *
165
+ * The input is resolved against `policy.cwd`, canonicalized (symlinks in
166
+ * existing ancestors collapsed so a symlinked directory cannot escape
167
+ * the jail), then accepted **only** when it sits under `cwd` or one of
168
+ * the `allowPaths` roots. A path that escapes, or that matches any
169
+ * `denyPaths` glob even while inside `cwd`, is rejected with a
170
+ * {@link WorkspacePolicyError} of type `"path-escape"`.
171
+ *
172
+ * @param policy - The bounding policy (its `cwd` is the jail root).
173
+ * @param inputPath - A workspace-relative or absolute path to resolve.
174
+ * @returns The canonical absolute path plus its `/`-separated relative form.
175
+ * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
176
+ *
177
+ * @example
178
+ * const { absolutePath } = await resolveInJail(policy, "src/index.ts");
179
+ */
180
+ async function resolveInJail(policy, inputPath) {
181
+ const jailRoot = await canonicalize$1(policy.cwd);
182
+ const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
183
+ const insideCwd = isInside(absolutePath, jailRoot);
184
+ const allowRoots = policy.allowPaths ?? [];
185
+ let insideAllow = false;
186
+ if (!insideCwd) {
187
+ for (const root of allowRoots) if (isInside(absolutePath, await canonicalize$1(root))) {
188
+ insideAllow = true;
189
+ break;
190
+ }
191
+ }
192
+ if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
193
+ type: "path-escape",
194
+ path: inputPath
195
+ });
196
+ const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
197
+ if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
198
+ if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
199
+ type: "path-escape",
200
+ path: inputPath
201
+ });
202
+ }
203
+ return {
204
+ absolutePath,
205
+ relativePath: relativeToJail
206
+ };
207
+ }
208
+ /**
209
+ * Extract the leading executable basename from a command line — the
210
+ * token the shell allow/deny policy is keyed on. `"npm run build"` →
211
+ * `"npm"`; `"/usr/bin/node app.js"` → `"node"`; `"node.exe app"` →
212
+ * `"node"` (the `.exe`/`.cmd`/`.bat` Windows extension is stripped).
213
+ */
214
+ function leadingExecutable(command) {
215
+ const firstToken = command.trim().split(/\s+/)[0] ?? "";
216
+ return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
217
+ }
218
+ /**
219
+ * Whether a shell command is permitted by the policy's `shell` sub-policy.
220
+ *
221
+ * The command's leading executable basename is matched against
222
+ * `shell.deny` then `shell.allow`. **Deny always wins.** When
223
+ * `shell.allow` is set, the executable MUST appear in it (fail-closed
224
+ * allowlist); when `allow` is absent/empty, any non-denied command is
225
+ * permitted. An absent `shell` block means no command may run at all.
226
+ *
227
+ * Returns a plain `boolean` rather than throwing — the ops layer raises
228
+ * the {@link WorkspacePolicyError} so the thrown context (`command`)
229
+ * lives next to the call site.
230
+ *
231
+ * @example
232
+ * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
233
+ * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "rm -rf /"); // false
234
+ */
235
+ function isCommandAllowed(policy, command) {
236
+ const shell = policy.shell;
237
+ if (!shell) return false;
238
+ const executable = leadingExecutable(command);
239
+ if (executable === "") return false;
240
+ if (shell.deny && shell.deny.includes(executable)) return false;
241
+ if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
242
+ return true;
243
+ }
244
+ /**
245
+ * Build the exact environment a spawned process receives — `process.env`
246
+ * is **never** inherited wholesale. The result is
247
+ * `{ ...pick(process.env, inheritEnv), ...shell.env }`, so a command
248
+ * cannot see `PATH` (and thus often cannot find `node`/`npm`) unless the
249
+ * policy opts in via `shell.inheritEnv: ["PATH"]`. Explicit `shell.env`
250
+ * values override inherited ones on key collision.
251
+ *
252
+ * @example
253
+ * buildEnv({ cwd, shell: { inheritEnv: ["PATH"], env: { CI: "1" } } });
254
+ * // → { PATH: <process PATH>, CI: "1" }
255
+ */
256
+ function buildEnv(policy) {
257
+ const shell = policy.shell;
258
+ const env = {};
259
+ if (!shell) return env;
260
+ for (const key of shell.inheritEnv ?? []) {
261
+ const value = process.env[key];
262
+ if (value !== void 0) env[key] = value;
263
+ }
264
+ if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
265
+ return env;
266
+ }
267
+
268
+ //#endregion
269
+ //#region ../@warlock.js/ai-workspace/src/ops.ts
270
+ /** Default line window a read returns when the policy sets no `defaultLines`. */
271
+ const DEFAULT_READ_LINES = 2e3;
272
+ /** Hard ceiling on grep matches returned, so a broad pattern can't flood. */
273
+ const DEFAULT_MAX_GREP_MATCHES = 1e3;
274
+ /** Default per-command output byte cap when the policy sets none. */
275
+ const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
276
+ /**
277
+ * Number the lines of `content` `cat -n` style: a right-aligned line
278
+ * number (min width 6), a tab, then the line. `startLine` is the 1-based
279
+ * number of the first line in the window.
280
+ */
281
+ function numberLines(content, startLine) {
282
+ return content.split("\n").map((line, index) => {
283
+ const lineNumber = startLine + index;
284
+ return `${String(lineNumber).padStart(6, " ")}\t${line}`;
285
+ }).join("\n");
286
+ }
287
+ /**
288
+ * Clip a captured stream at `maxBytes` (measured in UTF-8 bytes).
289
+ * Returns the possibly-clipped string plus whether clipping occurred.
290
+ */
291
+ function capOutput(value, maxBytes) {
292
+ const bytes = Buffer.from(value, "utf8");
293
+ if (bytes.byteLength <= maxBytes) return {
294
+ value,
295
+ truncated: false
296
+ };
297
+ return {
298
+ value: bytes.subarray(0, maxBytes).toString("utf8"),
299
+ truncated: true
300
+ };
301
+ }
302
+ /**
303
+ * Minimal glob match over a `/`-separated relative path. Supports `**`
304
+ * (spans separators), `*` (within a segment), and `?` (one non-separator
305
+ * char) — enough for the workspace's `glob`/grep narrowing without
306
+ * pulling in a runtime dependency.
307
+ */
308
+ function globToRegExp(glob) {
309
+ let source = "";
310
+ for (let index = 0; index < glob.length; index++) {
311
+ const char = glob[index];
312
+ if (char === "*") {
313
+ if (glob[index + 1] === "*") {
314
+ source += ".*";
315
+ index++;
316
+ if (glob[index + 1] === "/") index++;
317
+ } else source += "[^/]*";
318
+ continue;
319
+ }
320
+ if (char === "?") {
321
+ source += "[^/]";
322
+ continue;
323
+ }
324
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
325
+ }
326
+ return new RegExp(`^${source}$`);
327
+ }
328
+ /**
329
+ * The internal, single-instance implementation of {@link WorkspaceOps}.
330
+ * Holds the backend + policy and is the one place the jail, command
331
+ * gating, read caps, and the read-before-edit guard are enforced — both
332
+ * the agent-facing tools and the human-facing direct methods funnel
333
+ * through this object, so there is exactly one set of rules.
334
+ */
335
+ var Ops = class {
336
+ constructor(backend, policy) {
337
+ this.backend = backend;
338
+ this.policy = policy;
339
+ }
340
+ /**
341
+ * Recursively collect every file under `absDir` as absolute paths,
342
+ * via the backend's `list` (so it works over disk or the in-memory
343
+ * mock). Directories are descended; files are accumulated.
344
+ */
345
+ async walkFiles(absDir) {
346
+ const found = [];
347
+ const entries = await this.backend.list(absDir);
348
+ await Promise.all(entries.map(async (entry) => {
349
+ if (await this.isDirectory(entry)) {
350
+ const nested = await this.walkFiles(entry);
351
+ found.push(...nested);
352
+ } else found.push(entry);
353
+ }));
354
+ return found;
355
+ }
356
+ /** Whether an absolute path is a directory, by attempting to list it. */
357
+ async isDirectory(absPath) {
358
+ try {
359
+ await this.backend.list(absPath);
360
+ return true;
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
365
+ async readFile(inputPath, opts) {
366
+ const { absolutePath } = await resolveInJail(this.policy, inputPath);
367
+ const raw = await this.backend.readFile(absolutePath);
368
+ const hash = (0, _warlock_js_fs.hashString)(raw);
369
+ const lines = raw.split("\n");
370
+ const totalLines = lines.length;
371
+ const offset = Math.max(1, opts?.offset ?? 1);
372
+ const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
373
+ const startIndex = offset - 1;
374
+ return {
375
+ content: numberLines(lines.slice(startIndex, startIndex + limit).join("\n"), offset),
376
+ hash,
377
+ totalLines
378
+ };
379
+ }
380
+ async writeFile(inputPath, content) {
381
+ const { absolutePath } = await resolveInJail(this.policy, inputPath);
382
+ const parent = node_path.default.dirname(absolutePath);
383
+ await this.backend.mkdir(parent);
384
+ await this.backend.writeFile(absolutePath, content);
385
+ return {
386
+ hash: (0, _warlock_js_fs.hashString)(content),
387
+ bytesWritten: Buffer.byteLength(content, "utf8")
388
+ };
389
+ }
390
+ async editFile(input) {
391
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
392
+ const current = await this.backend.readFile(absolutePath);
393
+ const currentHash = (0, _warlock_js_fs.hashString)(current);
394
+ if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; the edit is stale.`, {
395
+ type: "stale-hash",
396
+ path: relativePath || input.path,
397
+ expectedHash: input.expectHash,
398
+ actualHash: currentHash
399
+ });
400
+ const occurrences = countOccurrences(current, input.oldString);
401
+ if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
402
+ type: "not-found",
403
+ path: relativePath || input.path,
404
+ matches: 0
405
+ });
406
+ if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
407
+ type: "not-unique",
408
+ path: relativePath || input.path,
409
+ matches: occurrences
410
+ });
411
+ const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
412
+ await this.backend.writeFile(absolutePath, updated);
413
+ return {
414
+ path: relativePath || input.path,
415
+ replacements: input.replaceAll ? occurrences : 1,
416
+ hash: (0, _warlock_js_fs.hashString)(updated)
417
+ };
418
+ }
419
+ async exec(command, opts) {
420
+ if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
421
+ type: "denied-command",
422
+ command
423
+ });
424
+ const shell = this.policy.shell;
425
+ const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
426
+ const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
427
+ const result = await this.backend.exec(command, {
428
+ cwd: this.policy.cwd,
429
+ timeoutMs,
430
+ env: buildEnv(this.policy)
431
+ });
432
+ const stdout = capOutput(result.stdout, maxOutputBytes);
433
+ const stderr = capOutput(result.stderr, maxOutputBytes);
434
+ return {
435
+ exitCode: result.exitCode,
436
+ stdout: stdout.value,
437
+ stderr: stderr.value,
438
+ truncated: stdout.truncated || stderr.truncated,
439
+ timedOut: result.timedOut
440
+ };
441
+ }
442
+ async grep(pattern, opts) {
443
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
444
+ const flags = opts?.ignoreCase ? "i" : "";
445
+ const regex = new RegExp(pattern, flags);
446
+ const globRegex = opts?.glob ? globToRegExp(opts.glob) : void 0;
447
+ const files = await this.walkFiles(jailRoot);
448
+ const matches = [];
449
+ for (const absFile of files) {
450
+ const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
451
+ if (globRegex && !globRegex.test(relativePath)) continue;
452
+ if (this.isDenied(relativePath)) continue;
453
+ let content;
454
+ try {
455
+ content = await this.backend.readFile(absFile);
456
+ } catch {
457
+ continue;
458
+ }
459
+ const lines = content.split("\n");
460
+ for (let index = 0; index < lines.length; index++) if (regex.test(lines[index])) {
461
+ matches.push({
462
+ path: relativePath,
463
+ line: index + 1,
464
+ text: lines[index]
465
+ });
466
+ if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
467
+ matches,
468
+ total: matches.length
469
+ };
470
+ }
471
+ }
472
+ return {
473
+ matches,
474
+ total: matches.length
475
+ };
476
+ }
477
+ async glob(pattern) {
478
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
479
+ const regex = globToRegExp(pattern);
480
+ const files = await this.walkFiles(jailRoot);
481
+ const matched = [];
482
+ for (const absFile of files) {
483
+ const relativePath = node_path.default.relative(jailRoot, absFile).split(node_path.default.sep).join("/");
484
+ if (this.isDenied(relativePath)) continue;
485
+ if (regex.test(relativePath)) matched.push(relativePath);
486
+ }
487
+ return matched.sort();
488
+ }
489
+ async exists(inputPath) {
490
+ const { absolutePath } = await resolveInJail(this.policy, inputPath);
491
+ return this.backend.exists(absolutePath);
492
+ }
493
+ async mkdir(inputPath) {
494
+ const { absolutePath } = await resolveInJail(this.policy, inputPath);
495
+ await this.backend.mkdir(absolutePath);
496
+ }
497
+ async remove(inputPath) {
498
+ const { absolutePath } = await resolveInJail(this.policy, inputPath);
499
+ await this.backend.remove(absolutePath);
500
+ }
501
+ /** Whether a `/`-separated relative path hits the policy deny list. */
502
+ isDenied(relativePath) {
503
+ const denyPaths = this.policy.denyPaths;
504
+ if (!denyPaths || denyPaths.length === 0) return false;
505
+ return denyPaths.some((glob) => {
506
+ if (globToRegExp(glob).test(relativePath)) return true;
507
+ if (!glob.includes("*") && !glob.includes("?")) {
508
+ const prefix = glob.endsWith("/") ? glob : `${glob}/`;
509
+ return relativePath.startsWith(prefix);
510
+ }
511
+ return false;
512
+ });
513
+ }
514
+ };
515
+ /** Count non-overlapping occurrences of `needle` in `haystack`. */
516
+ function countOccurrences(haystack, needle) {
517
+ if (needle === "") return 0;
518
+ let count = 0;
519
+ let from = 0;
520
+ while (true) {
521
+ const index = haystack.indexOf(needle, from);
522
+ if (index === -1) break;
523
+ count++;
524
+ from = index + needle.length;
525
+ }
526
+ return count;
527
+ }
528
+ /** Replace the first occurrence of `needle` with `replacement`. */
529
+ function replaceFirst(haystack, needle, replacement) {
530
+ const index = haystack.indexOf(needle);
531
+ if (index === -1) return haystack;
532
+ return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);
533
+ }
534
+ /**
535
+ * Create the policy-enforced operation layer over a backend.
536
+ *
537
+ * The returned {@link WorkspaceOps} is the single seam both the
538
+ * agent-facing `.tools.*` factories and the human-facing direct methods
539
+ * delegate to — one jail, one command-gate, one read-before-edit guard,
540
+ * regardless of caller. Path inputs are workspace-relative and resolved
541
+ * against `policy.cwd`; escapes and denied commands surface as typed
542
+ * {@link WorkspacePolicyError} / {@link WorkspaceEditError}.
543
+ *
544
+ * @param backend - The dumb IO executor (local disk or in-memory mock).
545
+ * @param policy - The policy that bounds every operation.
546
+ *
547
+ * @example
548
+ * const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
549
+ * const { content, hash } = await ops.readFile("src/index.ts");
550
+ */
551
+ function createOps(backend, policy) {
552
+ return new Ops(backend, policy);
553
+ }
554
+
555
+ //#endregion
556
+ //#region ../@warlock.js/ai-workspace/src/backends/local.ts
557
+ /**
558
+ * Hard ceiling on captured `stdout`/`stderr` per stream, in bytes. The raw
559
+ * executor truncates each stream once this many bytes have accumulated so a
560
+ * runaway command cannot exhaust memory; the ops layer applies its own
561
+ * policy-driven cap (and the `truncated` flag) on top. Generous enough that
562
+ * ordinary command output is never clipped here.
563
+ */
564
+ const MAX_STREAM_BYTES = 10 * 1024 * 1024;
565
+ /**
566
+ * Append a chunk to a capped list of buffers, tracking the running byte
567
+ * total and stopping once {@link MAX_STREAM_BYTES} is reached. Trailing bytes
568
+ * past the cap are dropped rather than buffered.
569
+ */
570
+ function pushCapped(chunks, total, chunk) {
571
+ if (total >= MAX_STREAM_BYTES) return total;
572
+ const remaining = MAX_STREAM_BYTES - total;
573
+ if (chunk.length <= remaining) {
574
+ chunks.push(chunk);
575
+ return total + chunk.length;
576
+ }
577
+ chunks.push(chunk.subarray(0, remaining));
578
+ return MAX_STREAM_BYTES;
579
+ }
580
+ /**
581
+ * Force-kill a spawned command and its entire process tree.
582
+ *
583
+ * With `shell: true` the command runs under an intermediary shell
584
+ * (`cmd.exe` on Windows, `/bin/sh` elsewhere), so signalling the direct
585
+ * child only reaps the shell — a long-running grandchild (e.g. `node`)
586
+ * would survive, leaving the `exec` promise unsettled. We therefore kill
587
+ * the whole group:
588
+ * - **Windows** — `taskkill /T /F` walks and terminates the PID's tree.
589
+ * - **POSIX** — the child is spawned `detached`, becoming its own process
590
+ * group leader, so `process.kill(-pid)` SIGKILLs the group.
591
+ */
592
+ function killTree(pid, child) {
593
+ if (pid === void 0) {
594
+ child.kill("SIGKILL");
595
+ return;
596
+ }
597
+ if (node_process.platform === "win32") {
598
+ (0, node_child_process.spawn)("taskkill", [
599
+ "/pid",
600
+ String(pid),
601
+ "/T",
602
+ "/F"
603
+ ], { windowsHide: true });
604
+ return;
605
+ }
606
+ try {
607
+ process.kill(-pid, "SIGKILL");
608
+ } catch {
609
+ child.kill("SIGKILL");
610
+ }
611
+ }
612
+ /**
613
+ * The real-disk executor: every filesystem method delegates to
614
+ * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
615
+ * process via `node:child_process`. It is deliberately **policy-agnostic** —
616
+ * it receives already-resolved absolute paths and an already-resolved
617
+ * environment + timeout from the ops layer, and just performs the side
618
+ * effect. See {@link WorkspaceBackend} for the contract this implements.
619
+ *
620
+ * Constructed via {@link createLocalBackend}; the class itself is internal.
621
+ */
622
+ var LocalBackend = class {
623
+ /** Read a file's full UTF-8 content at an absolute path. */
624
+ async readFile(absPath) {
625
+ return (0, _warlock_js_fs.getFileAsync)(absPath);
626
+ }
627
+ /**
628
+ * Write full content to an absolute path. Uses `atomicWriteAsync`, so a
629
+ * concurrent reader never observes a half-written file, and missing parent
630
+ * directories are created.
631
+ */
632
+ async writeFile(absPath, content) {
633
+ await (0, _warlock_js_fs.atomicWriteAsync)(absPath, content);
634
+ }
635
+ /** Whether anything (file or directory) exists at an absolute path. */
636
+ async exists(absPath) {
637
+ return (0, _warlock_js_fs.pathExistsAsync)(absPath);
638
+ }
639
+ /** Create a directory (and any missing parents) at an absolute path; idempotent. */
640
+ async mkdir(absPath) {
641
+ await (0, _warlock_js_fs.ensureDirectoryAsync)(absPath);
642
+ }
643
+ /**
644
+ * Remove a file or directory tree at an absolute path. Stats the target to
645
+ * pick the right primitive — `removeDirectoryAsync` (recursive) for a
646
+ * directory, `unlinkAsync` for anything else. A path that does not exist is
647
+ * a no-op (both primitives swallow `ENOENT`).
648
+ */
649
+ async remove(absPath) {
650
+ let isDirectory = false;
651
+ try {
652
+ isDirectory = (await (0, _warlock_js_fs.statsAsync)(absPath)).isDirectory();
653
+ } catch (error) {
654
+ if (error?.code === "ENOENT") return;
655
+ throw error;
656
+ }
657
+ if (isDirectory) {
658
+ await (0, _warlock_js_fs.removeDirectoryAsync)(absPath);
659
+ return;
660
+ }
661
+ await (0, _warlock_js_fs.unlinkAsync)(absPath);
662
+ }
663
+ /** List immediate children of an absolute directory as absolute paths. */
664
+ async list(absDir) {
665
+ return (0, _warlock_js_fs.listAsync)(absDir);
666
+ }
667
+ /**
668
+ * Resolve symlinks and `..` segments to a canonical absolute path — the
669
+ * primitive the ops-layer jail uses to detect escapes. Delegates to
670
+ * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
671
+ * does not expose.
672
+ */
673
+ async realpath(absPath) {
674
+ return (0, node_fs_promises.realpath)(absPath);
675
+ }
676
+ /**
677
+ * Run a command and capture its outcome. The command line is executed
678
+ * through the platform shell (`shell: true`) so pipes/operators behave as a
679
+ * caller would expect; `cwd`, `env`, and the timeout are taken verbatim from
680
+ * the ops layer (the environment is NOT merged with `process.env`). On
681
+ * timeout the process is SIGKILLed and `timedOut` is set. `stdout`/`stderr`
682
+ * are captured and byte-capped per {@link MAX_STREAM_BYTES}.
683
+ *
684
+ * Never rejects for a non-zero exit, a missing executable, or a timeout —
685
+ * those are reported through the resolved {@link WorkspaceBackendExecResult}
686
+ * so the ops layer can surface them as tool-error data.
687
+ */
688
+ exec(command, opts = {}) {
689
+ return new Promise((resolve) => {
690
+ const child = (0, node_child_process.spawn)(command, {
691
+ cwd: opts.cwd,
692
+ env: opts.env,
693
+ shell: true,
694
+ windowsHide: true,
695
+ detached: node_process.platform !== "win32"
696
+ });
697
+ const stdoutChunks = [];
698
+ const stderrChunks = [];
699
+ let stdoutBytes = 0;
700
+ let stderrBytes = 0;
701
+ let timedOut = false;
702
+ let settled = false;
703
+ const timer = opts.timeoutMs !== void 0 && opts.timeoutMs > 0 ? setTimeout(() => {
704
+ timedOut = true;
705
+ killTree(child.pid, child);
706
+ }, opts.timeoutMs) : void 0;
707
+ const settle = (exitCode) => {
708
+ if (settled) return;
709
+ settled = true;
710
+ if (timer !== void 0) clearTimeout(timer);
711
+ resolve({
712
+ exitCode,
713
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
714
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
715
+ timedOut
716
+ });
717
+ };
718
+ child.stdout?.on("data", (chunk) => {
719
+ stdoutBytes = pushCapped(stdoutChunks, stdoutBytes, chunk);
720
+ });
721
+ child.stderr?.on("data", (chunk) => {
722
+ stderrBytes = pushCapped(stderrChunks, stderrBytes, chunk);
723
+ });
724
+ child.on("error", () => {
725
+ settle(127);
726
+ });
727
+ child.on("close", (code, signal) => {
728
+ if (code === null) {
729
+ settle(128 + (signal === "SIGKILL" ? 9 : 1));
730
+ return;
731
+ }
732
+ settle(code);
733
+ });
734
+ });
735
+ }
736
+ };
737
+ /**
738
+ * Create the **local** workspace backend — the default executor that runs the
739
+ * workspace over the real disk (`@warlock.js/fs`) and the local shell
740
+ * (`node:child_process`).
741
+ *
742
+ * The returned object is policy-agnostic: it expects already-jail-resolved
743
+ * absolute paths and an already-resolved environment/timeout from the ops
744
+ * layer. Pair it with {@link WorkspaceOps} for the actual cwd jail,
745
+ * allow/deny lists, hashing, and output policy.
746
+ *
747
+ * @example
748
+ * const backend = createLocalBackend();
749
+ * await backend.writeFile("/srv/app/src/index.ts", "export const x = 1;");
750
+ * const { exitCode } = await backend.exec("node -v", { cwd: "/srv/app" });
751
+ */
752
+ function createLocalBackend() {
753
+ return new LocalBackend();
754
+ }
755
+
756
+ //#endregion
757
+ //#region ../@warlock.js/ai-workspace/src/backends/mock.ts
758
+ /**
759
+ * Normalize an absolute path to a stable in-memory key: forward slashes,
760
+ * collapsed duplicate separators, and resolved `.` / `..` segments. There
761
+ * are no symlinks in memory, so this is a pure lexical canonicalization —
762
+ * exactly what the backend's `realpath` promises.
763
+ */
764
+ function canonicalize(absPath) {
765
+ const unified = absPath.replace(/\\/g, "/");
766
+ const driveMatch = unified.match(/^([a-zA-Z]:)?\/?/);
767
+ const prefix = driveMatch ? driveMatch[0] : "";
768
+ const rest = unified.slice(prefix.length);
769
+ const resolved = [];
770
+ for (const segment of rest.split("/")) {
771
+ if (segment === "" || segment === ".") continue;
772
+ if (segment === "..") {
773
+ resolved.pop();
774
+ continue;
775
+ }
776
+ resolved.push(segment);
777
+ }
778
+ const joined = resolved.join("/");
779
+ const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
780
+ return joined.length > 0 ? `${normalizedPrefix}${joined}` : normalizedPrefix;
781
+ }
782
+ /** All ancestor directory keys of a canonical path, root-first. */
783
+ function ancestorsOf(canonicalPath) {
784
+ const lastSlash = canonicalPath.lastIndexOf("/");
785
+ if (lastSlash <= 0) return [];
786
+ const parent = canonicalPath.slice(0, lastSlash);
787
+ const result = ancestorsOf(parent);
788
+ result.push(parent);
789
+ return result;
790
+ }
791
+ /**
792
+ * The in-memory executor behind {@link createMockBackend}. Holds the file
793
+ * tree in a `Map`, the directory set in a `Set`, and the scripted command
794
+ * table in a second `Map` — no disk, no child processes, fully
795
+ * deterministic. Construct it via the factory, never directly.
796
+ */
797
+ var MockBackend = class {
798
+ constructor(seed) {
799
+ this.files = /* @__PURE__ */ new Map();
800
+ this.directories = /* @__PURE__ */ new Set();
801
+ this.commands = /* @__PURE__ */ new Map();
802
+ for (const [path, content] of Object.entries(seed?.files ?? {})) {
803
+ const canonical = canonicalize(path);
804
+ this.files.set(canonical, content);
805
+ this.registerAncestorDirectories(canonical);
806
+ }
807
+ for (const [command, result] of Object.entries(seed?.commands ?? {})) this.commands.set(command, result);
808
+ }
809
+ /** Record every ancestor directory of a path as existing. */
810
+ registerAncestorDirectories(canonicalPath) {
811
+ for (const ancestor of ancestorsOf(canonicalPath)) this.directories.add(ancestor);
812
+ }
813
+ async readFile(absPath) {
814
+ const canonical = canonicalize(absPath);
815
+ const content = this.files.get(canonical);
816
+ if (content === void 0) throw new Error(`Mock backend: no such file: ${canonical}`);
817
+ return content;
818
+ }
819
+ async writeFile(absPath, content) {
820
+ const canonical = canonicalize(absPath);
821
+ this.files.set(canonical, content);
822
+ this.registerAncestorDirectories(canonical);
823
+ }
824
+ async exists(absPath) {
825
+ const canonical = canonicalize(absPath);
826
+ return this.files.has(canonical) || this.directories.has(canonical);
827
+ }
828
+ async mkdir(absPath) {
829
+ const canonical = canonicalize(absPath);
830
+ this.directories.add(canonical);
831
+ this.registerAncestorDirectories(canonical);
832
+ }
833
+ async remove(absPath) {
834
+ const canonical = canonicalize(absPath);
835
+ const prefix = `${canonical}/`;
836
+ for (const file of [...this.files.keys()]) if (file === canonical || file.startsWith(prefix)) this.files.delete(file);
837
+ for (const directory of [...this.directories]) if (directory === canonical || directory.startsWith(prefix)) this.directories.delete(directory);
838
+ }
839
+ async list(absDir) {
840
+ const canonical = canonicalize(absDir);
841
+ const prefix = canonical === "/" ? "/" : `${canonical}/`;
842
+ const children = /* @__PURE__ */ new Set();
843
+ const collect = (key) => {
844
+ if (!key.startsWith(prefix) || key === canonical) return;
845
+ const remainder = key.slice(prefix.length);
846
+ const nextSlash = remainder.indexOf("/");
847
+ const childName = nextSlash === -1 ? remainder : remainder.slice(0, nextSlash);
848
+ if (childName.length > 0) children.add(`${prefix}${childName}`);
849
+ };
850
+ for (const file of this.files.keys()) collect(file);
851
+ for (const directory of this.directories) collect(directory);
852
+ return [...children].sort();
853
+ }
854
+ async realpath(absPath) {
855
+ return canonicalize(absPath);
856
+ }
857
+ async exec(command, _opts) {
858
+ const scripted = this.commands.get(command);
859
+ return {
860
+ exitCode: scripted?.exitCode ?? 0,
861
+ stdout: scripted?.stdout ?? "",
862
+ stderr: scripted?.stderr ?? "",
863
+ timedOut: scripted?.timedOut ?? false
864
+ };
865
+ }
866
+ };
867
+ /**
868
+ * Create an in-memory {@link WorkspaceBackend} for fast, disk-free tests.
869
+ *
870
+ * Every IO method operates on a `Map` of `absolutePath -> content` (with a
871
+ * companion directory set), so reads, writes, existence checks, `mkdir`,
872
+ * recursive `remove`, `list`, and `realpath` all run synchronously in
873
+ * memory with no filesystem access. `exec` is **scripted**: a registered
874
+ * `command -> result` table is consulted by exact command line, and any
875
+ * unregistered command resolves to a successful `0`-exit no-op with empty
876
+ * output.
877
+ *
878
+ * `realpath` is a pure lexical canonicalization (forward slashes,
879
+ * collapsed separators, resolved `.`/`..`) — there are no symlinks in
880
+ * memory — which is exactly what the jail resolver expects.
881
+ *
882
+ * The single positional `seed` accepts either the shorthand
883
+ * `Record<string, string>` of file contents (matching the design's
884
+ * `ai.workspace.mock(seed)` signature) or the richer {@link MockBackendSeed}
885
+ * with both `files` and scripted `commands`.
886
+ *
887
+ * @example
888
+ * // Shorthand: seed files only.
889
+ * const backend = createMockBackend({ "/srv/app/src/index.ts": "export const x = 1;" });
890
+ * await backend.readFile("/srv/app/src/index.ts"); // "export const x = 1;"
891
+ *
892
+ * @example
893
+ * // Full seed: files plus a scripted command.
894
+ * const backend = createMockBackend({
895
+ * files: { "/srv/app/package.json": "{}" },
896
+ * commands: { "npm test": { exitCode: 1, stderr: "1 failing" } },
897
+ * });
898
+ * await backend.exec("npm test"); // { exitCode: 1, stderr: "1 failing", ... }
899
+ * await backend.exec("echo hi"); // { exitCode: 0, stdout: "", ... } (no-op)
900
+ */
901
+ function createMockBackend(seed) {
902
+ return new MockBackend(normalizeSeed(seed));
903
+ }
904
+ /** Coerce the dual-shaped `seed` argument into a {@link MockBackendSeed}. */
905
+ function normalizeSeed(seed) {
906
+ if (seed === void 0) return;
907
+ if (isMockBackendSeed(seed)) return seed;
908
+ return { files: seed };
909
+ }
910
+ /**
911
+ * Whether `seed` is the structured {@link MockBackendSeed} (has a `files`
912
+ * or `commands` key) rather than the flat `path -> content` shorthand. A
913
+ * plain shorthand map whose only key happens to be named `files` is
914
+ * treated as structured — callers wanting that literal path should use the
915
+ * explicit `{ files: { files: "..." } }` form.
916
+ */
917
+ function isMockBackendSeed(seed) {
918
+ return typeof seed.files === "object" || typeof seed.commands === "object";
919
+ }
920
+
921
+ //#endregion
922
+ //#region ../@warlock.js/ai-workspace/src/tools/schema.ts
923
+ /**
924
+ * Tiny, dependency-free [Standard Schema](https://standardschema.dev)
925
+ * builders for the workspace tools' input validation. The package pins
926
+ * only `@warlock.js/ai` and `@warlock.js/fs` as runtime dependencies, so
927
+ * rather than pull in a schema library we hand-roll the few shapes the
928
+ * file tools need — exactly the pattern `@warlock.js/ai`'s own `tool()`
929
+ * tests use. Each builder returns a `StandardSchemaV1`, which is what
930
+ * `tool({ input })` validates against before calling `execute`.
931
+ *
932
+ * These intentionally cover only the primitive cases the FILE tools
933
+ * require (`string`, `optional string`, `optional number`, `optional
934
+ * boolean`, and an `object` of fields). They are not a general-purpose
935
+ * validator.
936
+ */
937
+ /** The vendor tag stamped on every issue these builders produce. */
938
+ const VENDOR = "ai-workspace";
939
+ /** Required string field — rejects anything that is not a string. */
940
+ function stringField() {
941
+ return (value, key) => {
942
+ if (typeof value === "string") return { value };
943
+ return { issues: [{
944
+ message: `"${key}" must be a string`,
945
+ path: [key]
946
+ }] };
947
+ };
948
+ }
949
+ /**
950
+ * Optional string field — accepts `undefined` (the property absent or
951
+ * explicitly undefined) or a string, and rejects every other type.
952
+ */
953
+ function optionalStringField() {
954
+ return (value, key) => {
955
+ if (value === void 0) return { value: void 0 };
956
+ if (typeof value === "string") return { value };
957
+ return { issues: [{
958
+ message: `"${key}" must be a string when provided`,
959
+ path: [key]
960
+ }] };
961
+ };
962
+ }
963
+ /**
964
+ * Optional finite-number field — accepts `undefined` or a finite number,
965
+ * rejecting `NaN`/`Infinity` and non-number types.
966
+ */
967
+ function optionalNumberField() {
968
+ return (value, key) => {
969
+ if (value === void 0) return { value: void 0 };
970
+ if (typeof value === "number" && Number.isFinite(value)) return { value };
971
+ return { issues: [{
972
+ message: `"${key}" must be a finite number when provided`,
973
+ path: [key]
974
+ }] };
975
+ };
976
+ }
977
+ /** Optional boolean field — accepts `undefined` or a boolean. */
978
+ function optionalBooleanField() {
979
+ return (value, key) => {
980
+ if (value === void 0) return { value: void 0 };
981
+ if (typeof value === "boolean") return { value };
982
+ return { issues: [{
983
+ message: `"${key}" must be a boolean when provided`,
984
+ path: [key]
985
+ }] };
986
+ };
987
+ }
988
+ /**
989
+ * Build a {@link StandardSchemaV1} for a flat object whose every property
990
+ * is validated by a {@link FieldValidator}. The input must be a non-null
991
+ * object; each declared field is validated and the (possibly coerced)
992
+ * values are collected into the typed result. All field issues are merged
993
+ * so the caller sees every problem at once.
994
+ *
995
+ * `T` is constrained to `object` rather than `Record<string, unknown>` so
996
+ * the tool IO `interface`s (which carry no implicit string index
997
+ * signature) satisfy it directly — only the declared keys in `shape` are
998
+ * ever read, so a string index signature is never required.
999
+ *
1000
+ * @example
1001
+ * const schema = objectSchema<{ path: string; limit?: number }>({
1002
+ * path: stringField(),
1003
+ * limit: optionalNumberField(),
1004
+ * });
1005
+ */
1006
+ function objectSchema(shape) {
1007
+ return { "~standard": {
1008
+ version: 1,
1009
+ vendor: VENDOR,
1010
+ validate(input) {
1011
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
1012
+ const source = input;
1013
+ const issues = [];
1014
+ const result = {};
1015
+ for (const key of Object.keys(shape)) {
1016
+ const field = shape[key];
1017
+ const outcome = field(source[key], key);
1018
+ if ("issues" in outcome) {
1019
+ issues.push(...outcome.issues);
1020
+ continue;
1021
+ }
1022
+ if (outcome.value !== void 0) result[key] = outcome.value;
1023
+ }
1024
+ if (issues.length > 0) return { issues };
1025
+ return { value: result };
1026
+ }
1027
+ } };
1028
+ }
1029
+
1030
+ //#endregion
1031
+ //#region ../@warlock.js/ai-workspace/src/tools/edit-file.ts
1032
+ /** Default tool name exposed to the LLM. */
1033
+ const DEFAULT_NAME$2 = "edit_file";
1034
+ /** Input schema for the `edit_file` tool. */
1035
+ const inputSchema$2 = objectSchema({
1036
+ path: stringField(),
1037
+ oldString: stringField(),
1038
+ newString: stringField(),
1039
+ replaceAll: optionalBooleanField(),
1040
+ expectHash: optionalStringField()
1041
+ });
1042
+ /**
1043
+ * Build the agent-facing `edit_file` tool over a workspace's policy-
1044
+ * enforced {@link WorkspaceOps}.
1045
+ *
1046
+ * The tool validates `{ path, oldString, newString, replaceAll?,
1047
+ * expectHash? }` against a Standard Schema, then delegates to
1048
+ * `ops.editFile`, which applies the exact-string replacement under the
1049
+ * read-before-edit guard and returns the replacement count plus the
1050
+ * post-edit `hash`.
1051
+ *
1052
+ * **Errors flow as data.** A non-unique `oldString` (without
1053
+ * `replaceAll`), a missing `oldString`, or a stale `expectHash` cause
1054
+ * `ops` to throw a `WorkspaceEditError`; the `tool()` wrapper catches it
1055
+ * and surfaces it in the returned `{ error }` field — `invoke()` never
1056
+ * throws — so the agent can re-read and retry.
1057
+ *
1058
+ * @param ops - The shared, policy-enforced operation layer.
1059
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
1060
+ *
1061
+ * @example
1062
+ * const editTool = makeEditFileTool(ops);
1063
+ * const { data, error } = await editTool.invoke({
1064
+ * path: "src/index.ts",
1065
+ * oldString: "const a = 1;",
1066
+ * newString: "const a = 2;",
1067
+ * expectHash,
1068
+ * });
1069
+ * if (error) console.warn(error.message); // e.g. stale-hash → re-read
1070
+ */
1071
+ function makeEditFileTool(ops, options) {
1072
+ return (0, _warlock_js_ai.tool)({
1073
+ name: options?.name ?? DEFAULT_NAME$2,
1074
+ description: "Replace an exact substring in a workspace file. oldString must match uniquely unless replaceAll is set. Pass expectHash (from read_file) to reject the edit if the file changed since you read it. Returns the number of replacements and the new content hash.",
1075
+ input: inputSchema$2,
1076
+ async execute(input) {
1077
+ return ops.editFile(input);
1078
+ }
1079
+ });
1080
+ }
1081
+
1082
+ //#endregion
1083
+ //#region ../@warlock.js/ai-workspace/src/tools/glob.ts
1084
+ /**
1085
+ * Standard Schema for {@link GlobInput} — a single required `pattern`
1086
+ * string. Built on the package's shared, dependency-free schema builders
1087
+ * (no schema library, matching the validator idiom the `@warlock.js/ai`
1088
+ * tool runtime expects).
1089
+ */
1090
+ const globInputSchema = objectSchema({ pattern: stringField() });
1091
+ /**
1092
+ * Build the agent-facing `glob` tool — resolve a glob pattern to the
1093
+ * matching workspace-relative paths within the jail. The returned
1094
+ * {@link ToolContract} validates the LLM's arguments, delegates to
1095
+ * {@link WorkspaceOps.glob} (which returns a bare sorted `string[]`), and
1096
+ * wraps the result in a {@link GlobResult} so the agent always reads a
1097
+ * stable `{ paths }` envelope. The jail and `denyPaths` filtering are
1098
+ * enforced in the shared ops layer; a policy violation surfaces as typed
1099
+ * tool-error *data* via the runtime's `invoke()` wrapper.
1100
+ *
1101
+ * @param ops - The policy-enforced operation layer to delegate to.
1102
+ * @param options - Optional `{ name }` override for the vended tool name.
1103
+ * @returns A {@link ToolContract} the agent can call as `glob`.
1104
+ *
1105
+ * @example
1106
+ * const glob = makeGlobTool(ops);
1107
+ * const { data } = await glob.invoke({ pattern: "src/models/**\/*.ts" });
1108
+ * console.log(data?.paths);
1109
+ */
1110
+ function makeGlobTool(ops, options) {
1111
+ return (0, _warlock_js_ai.tool)({
1112
+ name: options?.name ?? "glob",
1113
+ description: "Find files in the workspace whose path matches a glob pattern (supports `*`, `**`, and `?`). Returns the matching workspace-relative paths, sorted.",
1114
+ action: (input) => `Finding files matching ${input.pattern}`,
1115
+ input: globInputSchema,
1116
+ async execute(input) {
1117
+ return { paths: await ops.glob(input.pattern) };
1118
+ }
1119
+ });
1120
+ }
1121
+
1122
+ //#endregion
1123
+ //#region ../@warlock.js/ai-workspace/src/tools/grep.ts
1124
+ /**
1125
+ * Standard Schema for {@link GrepInput} — `pattern` is a required string;
1126
+ * `glob` and `ignoreCase` are optional. Built on the package's shared,
1127
+ * dependency-free schema builders (no schema library, matching the
1128
+ * validator idiom the `@warlock.js/ai` tool runtime expects).
1129
+ */
1130
+ const grepInputSchema = objectSchema({
1131
+ pattern: stringField(),
1132
+ glob: optionalStringField(),
1133
+ ignoreCase: optionalBooleanField()
1134
+ });
1135
+ /**
1136
+ * Build the agent-facing `grep` tool — a regex content search across the
1137
+ * jailed file set. The returned {@link ToolContract} validates the LLM's
1138
+ * arguments, then delegates verbatim to {@link WorkspaceOps.grep}, so the
1139
+ * policy jail, `denyPaths` filtering, and match cap are enforced in the
1140
+ * single shared ops layer rather than duplicated here. A policy violation
1141
+ * (e.g. a jail-resolution failure) surfaces as typed tool-error *data*
1142
+ * via the runtime's `invoke()` wrapper, never as a thrown run-killer.
1143
+ *
1144
+ * @param ops - The policy-enforced operation layer to delegate to.
1145
+ * @param options - Optional `{ name }` override for the vended tool name.
1146
+ * @returns A {@link ToolContract} the agent can call as `grep`.
1147
+ *
1148
+ * @example
1149
+ * const grep = makeGrepTool(ops);
1150
+ * const { data } = await grep.invoke({ pattern: "TODO", glob: "src/*.ts" });
1151
+ * console.log(data?.total, data?.matches);
1152
+ */
1153
+ function makeGrepTool(ops, options) {
1154
+ return (0, _warlock_js_ai.tool)({
1155
+ name: options?.name ?? "grep",
1156
+ description: "Search file contents across the workspace for a regular-expression pattern. Optionally narrow the scanned files with a glob and match case-insensitively. Returns every matching line with its file path and 1-based line number.",
1157
+ action: (input) => `Searching for /${input.pattern}/`,
1158
+ input: grepInputSchema,
1159
+ async execute(input) {
1160
+ return ops.grep(input.pattern, {
1161
+ glob: input.glob,
1162
+ ignoreCase: input.ignoreCase
1163
+ });
1164
+ }
1165
+ });
1166
+ }
1167
+
1168
+ //#endregion
1169
+ //#region ../@warlock.js/ai-workspace/src/tools/read-file.ts
1170
+ /** Default tool name exposed to the LLM. */
1171
+ const DEFAULT_NAME$1 = "read_file";
1172
+ /** Input schema for the `read_file` tool. */
1173
+ const inputSchema$1 = objectSchema({
1174
+ path: stringField(),
1175
+ startLine: optionalNumberField(),
1176
+ limit: optionalNumberField()
1177
+ });
1178
+ /**
1179
+ * Build the agent-facing `read_file` tool over a workspace's policy-
1180
+ * enforced {@link WorkspaceOps}.
1181
+ *
1182
+ * The tool validates `{ path, startLine?, limit? }` against a Standard
1183
+ * Schema, then delegates to `ops.readFile`, mapping the result into the
1184
+ * agent wire shape {@link ReadFileResult} — the `hash` an agent must
1185
+ * carry into a later `edit_file` (read-before-edit), plus the `startLine`
1186
+ * / `endLine` / `truncated` window metadata derived from the requested
1187
+ * range and the file's `totalLines`.
1188
+ *
1189
+ * **Errors flow as data.** Policy violations (a jail escape) are thrown
1190
+ * by `ops`; the `tool()` wrapper catches them and surfaces them in the
1191
+ * returned `{ error }` field — `invoke()` never throws — so the agent can
1192
+ * read the failure and self-correct.
1193
+ *
1194
+ * @param ops - The shared, policy-enforced operation layer.
1195
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
1196
+ *
1197
+ * @example
1198
+ * const readTool = makeReadFileTool(ops);
1199
+ * const { data, error } = await readTool.invoke({ path: "src/index.ts" });
1200
+ * if (!error) console.log(data.hash); // feed into edit_file's expectHash
1201
+ */
1202
+ function makeReadFileTool(ops, options) {
1203
+ return (0, _warlock_js_ai.tool)({
1204
+ name: options?.name ?? DEFAULT_NAME$1,
1205
+ description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files.",
1206
+ input: inputSchema$1,
1207
+ async execute(input) {
1208
+ const startLine = input.startLine !== void 0 ? Math.max(1, input.startLine) : 1;
1209
+ const { content, hash, totalLines } = await ops.readFile(input.path, {
1210
+ offset: startLine,
1211
+ limit: input.limit
1212
+ });
1213
+ const returnedLines = content.length === 0 ? 0 : content.split("\n").length;
1214
+ const endLine = Math.min(totalLines, startLine + Math.max(returnedLines, 1) - 1);
1215
+ return {
1216
+ content,
1217
+ startLine,
1218
+ endLine,
1219
+ totalLines,
1220
+ truncated: endLine < totalLines,
1221
+ hash
1222
+ };
1223
+ }
1224
+ });
1225
+ }
1226
+
1227
+ //#endregion
1228
+ //#region ../@warlock.js/ai-workspace/src/tools/run-shell.ts
1229
+ /** The default tool name `run_shell` is exposed to the LLM under. */
1230
+ const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
1231
+ /**
1232
+ * Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
1233
+ * model's arguments without a runtime schema dependency: `command` must be
1234
+ * a non-empty string, and `timeoutMs` (when present) a positive number.
1235
+ * Invalid args surface as a `SchemaValidationError` in the tool result's
1236
+ * `error` field rather than reaching `ops.exec`.
1237
+ */
1238
+ const runShellInputSchema = { "~standard": {
1239
+ version: 1,
1240
+ vendor: "@warlock.js/ai-workspace",
1241
+ validate: (value) => {
1242
+ if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
1243
+ const candidate = value;
1244
+ if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
1245
+ message: "command must be a non-empty string",
1246
+ path: ["command"]
1247
+ }] };
1248
+ if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
1249
+ message: "timeoutMs must be a positive number",
1250
+ path: ["timeoutMs"]
1251
+ }] };
1252
+ const result = { command: candidate.command };
1253
+ if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
1254
+ return { value: result };
1255
+ }
1256
+ } };
1257
+ /**
1258
+ * Build the `run_shell` tool — a {@link ToolContract} that runs a single
1259
+ * shell command through the policy-enforced {@link WorkspaceOps} layer.
1260
+ *
1261
+ * The command's leading executable basename is gated against the shell
1262
+ * allow/deny policy by `ops.exec`; a blocked command throws a
1263
+ * `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
1264
+ * in the result's `error` field (never a thrown run-killer), so the agent
1265
+ * reads the refusal as tool data and self-corrects. A command that runs
1266
+ * but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
1267
+ * in `data` for the agent to inspect.
1268
+ *
1269
+ * @param ops - The policy-enforced operation layer to delegate `exec` to.
1270
+ * @param options - Optional tool-name override.
1271
+ *
1272
+ * @example
1273
+ * const runShell = makeRunShellTool(ops);
1274
+ * const { data, error } = await runShell.invoke({ command: "npm run build" });
1275
+ * if (error) handleDenied(error);
1276
+ * else console.log(data.exitCode, data.stdout);
1277
+ */
1278
+ function makeRunShellTool(ops, options) {
1279
+ return (0, _warlock_js_ai.tool)({
1280
+ name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
1281
+ description: "Run a single shell command inside the workspace. The command's executable must be permitted by the shell policy; output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error.",
1282
+ action: (input) => `Running \`${input.command}\``,
1283
+ input: runShellInputSchema,
1284
+ execute: (input) => ops.exec(input.command, { timeoutMs: input.timeoutMs })
1285
+ });
1286
+ }
1287
+
1288
+ //#endregion
1289
+ //#region ../@warlock.js/ai-workspace/src/tools/run-tests.ts
1290
+ /** The default tool name `run_tests` is exposed to the LLM under. */
1291
+ const DEFAULT_RUN_TESTS_TOOL_NAME = "run_tests";
1292
+ /** The default command run when no `command` override is configured. */
1293
+ const DEFAULT_TEST_COMMAND = "npm test";
1294
+ /**
1295
+ * Hand-rolled Standard Schema for {@link RunTestsInput}. `pattern` is the
1296
+ * only field and is optional; when present it must be a string. Validation
1297
+ * happens without a runtime schema dependency, mirroring the wider tool
1298
+ * layer.
1299
+ */
1300
+ const runTestsInputSchema = { "~standard": {
1301
+ version: 1,
1302
+ vendor: "@warlock.js/ai-workspace",
1303
+ validate: (value) => {
1304
+ if (value === void 0 || value === null) return { value: {} };
1305
+ if (typeof value !== "object") return { issues: [{ message: "expected an object" }] };
1306
+ const candidate = value;
1307
+ if (candidate.pattern !== void 0 && typeof candidate.pattern !== "string") return { issues: [{
1308
+ message: "pattern must be a string",
1309
+ path: ["pattern"]
1310
+ }] };
1311
+ const result = {};
1312
+ if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
1313
+ return { value: result };
1314
+ }
1315
+ } };
1316
+ /**
1317
+ * Build the `run_tests` tool — a {@link ToolContract} convenience over
1318
+ * `run_shell` that runs the workspace's configured test command through
1319
+ * the policy-enforced {@link WorkspaceOps} layer.
1320
+ *
1321
+ * The base command defaults to `"npm test"` and can be overridden via
1322
+ * `options.command`. When the model passes a `pattern`, it is appended to
1323
+ * the command as a path/suite filter forwarded to the runner (e.g.
1324
+ * `"npm test src/cart"`). Like `run_shell`, the resolved command's
1325
+ * executable is gated by the shell policy — a denial surfaces in the
1326
+ * result's `error` field — and a non-zero exit (failing tests) comes back
1327
+ * as `data` for the agent to read and fix.
1328
+ *
1329
+ * @param ops - The policy-enforced operation layer to delegate `exec` to.
1330
+ * @param options - Optional tool-name and base-command overrides.
1331
+ *
1332
+ * @example
1333
+ * const runTests = makeRunTestsTool(ops, { command: "pnpm test" });
1334
+ * const { data } = await runTests.invoke({ pattern: "cart-total" });
1335
+ * if (data.exitCode !== 0) inspect(data.stderr);
1336
+ */
1337
+ function makeRunTestsTool(ops, options) {
1338
+ const baseCommand = options?.command ?? DEFAULT_TEST_COMMAND;
1339
+ return (0, _warlock_js_ai.tool)({
1340
+ name: options?.name ?? DEFAULT_RUN_TESTS_TOOL_NAME,
1341
+ description: "Run the workspace's test suite, optionally narrowed to a path or name pattern forwarded to the test runner. Failing tests return a non-zero exit code as data, not an error.",
1342
+ action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
1343
+ input: runTestsInputSchema,
1344
+ execute: (input) => {
1345
+ const command = input.pattern ? `${baseCommand} ${input.pattern}` : baseCommand;
1346
+ return ops.exec(command);
1347
+ }
1348
+ });
1349
+ }
1350
+
1351
+ //#endregion
1352
+ //#region ../@warlock.js/ai-workspace/src/tools/write-file.ts
1353
+ /** Default tool name exposed to the LLM. */
1354
+ const DEFAULT_NAME = "write_file";
1355
+ /** Input schema for the `write_file` tool. */
1356
+ const inputSchema = objectSchema({
1357
+ path: stringField(),
1358
+ content: stringField()
1359
+ });
1360
+ /**
1361
+ * Build the agent-facing `write_file` tool over a workspace's policy-
1362
+ * enforced {@link WorkspaceOps}.
1363
+ *
1364
+ * The tool validates `{ path, content }` against a Standard Schema, then
1365
+ * delegates to `ops.writeFile`, which atomically writes the full content
1366
+ * (creating parent directories) and returns the byte count and content
1367
+ * `hash`. The tool re-attaches the workspace-relative `path` so the
1368
+ * result matches the {@link WriteFileResult} wire shape.
1369
+ *
1370
+ * **Errors flow as data.** A jail escape is thrown by `ops`; the
1371
+ * `tool()` wrapper catches it and surfaces it in the returned `{ error }`
1372
+ * field — `invoke()` never throws.
1373
+ *
1374
+ * @param ops - The shared, policy-enforced operation layer.
1375
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
1376
+ *
1377
+ * @example
1378
+ * const writeTool = makeWriteFileTool(ops);
1379
+ * const { data } = await writeTool.invoke({ path: "src/new.ts", content: "export {};" });
1380
+ * console.log(data.bytesWritten, data.hash);
1381
+ */
1382
+ function makeWriteFileTool(ops, options) {
1383
+ return (0, _warlock_js_ai.tool)({
1384
+ name: options?.name ?? DEFAULT_NAME,
1385
+ description: "Write full content to a workspace file, creating it (and any parent directories) if absent and overwriting it otherwise. The write is atomic. Returns the bytes written and the new content hash.",
1386
+ input: inputSchema,
1387
+ async execute(input) {
1388
+ const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
1389
+ return {
1390
+ path: input.path,
1391
+ bytesWritten,
1392
+ hash
1393
+ };
1394
+ }
1395
+ });
1396
+ }
1397
+
1398
+ //#endregion
1399
+ //#region ../@warlock.js/ai-workspace/src/workspace.ts
1400
+ /**
1401
+ * The full set of tool names a writable workspace vends, in a stable
1402
+ * canonical order so `tools.all()` is deterministic.
1403
+ */
1404
+ const ALL_TOOL_NAMES = [
1405
+ "readFile",
1406
+ "editFile",
1407
+ "writeFile",
1408
+ "runShell",
1409
+ "runTests",
1410
+ "grep",
1411
+ "glob"
1412
+ ];
1413
+ /**
1414
+ * The subset a {@link Workspace.readonly} projection exposes — the
1415
+ * non-mutating tools only. `editFile` / `writeFile` / `runShell` /
1416
+ * `runTests` are deliberately omitted so a reviewer agent has no path to
1417
+ * change the tree.
1418
+ */
1419
+ const READONLY_TOOL_NAMES = [
1420
+ "readFile",
1421
+ "grep",
1422
+ "glob"
1423
+ ];
1424
+ /**
1425
+ * Choose the dumb IO executor for a policy. `"mock"` selects the
1426
+ * in-memory backend (hermetic tests); anything else — including the
1427
+ * `"local"` default and an absent `backend` — selects the real-disk
1428
+ * local backend.
1429
+ */
1430
+ function selectBackend(policy) {
1431
+ if (policy.backend === "mock") return createMockBackend();
1432
+ return createLocalBackend();
1433
+ }
1434
+ /**
1435
+ * The internal {@link Workspace} implementation. Holds the resolved
1436
+ * backend, the policy, and the single shared {@link WorkspaceOps} seam
1437
+ * that both the agent-facing `.tools.*` factories and the human-facing
1438
+ * direct methods funnel through — one jail, one rule set, two callers.
1439
+ *
1440
+ * The `allowedTools` set narrows what `tools.*` will vend and which
1441
+ * mutating direct methods are permitted: a full workspace allows every
1442
+ * name; a {@link WorkspaceImpl.readonly} projection allows only the
1443
+ * read/grep/glob subset and rejects writes/edits/shell/mkdir/remove.
1444
+ *
1445
+ * Constructed via {@link workspace}; the class itself is internal.
1446
+ */
1447
+ var WorkspaceImpl = class WorkspaceImpl {
1448
+ constructor(policy, allowedTools = ALL_TOOL_NAMES) {
1449
+ this.policy = policy;
1450
+ this.allowedTools = new Set(allowedTools);
1451
+ const backend = selectBackend(policy);
1452
+ this.ops = createOps(backend, policy);
1453
+ this.tools = this.buildTools();
1454
+ }
1455
+ /**
1456
+ * Assemble the agent-facing tool namespace. Each factory builds its
1457
+ * tool over the shared `ops`; `all()` returns every *allowed* tool in
1458
+ * canonical order and `pick(...)` returns the named subset (silently
1459
+ * dropping any name this projection does not allow, so a `readonly()`
1460
+ * workspace can never be coaxed into vending a mutating tool).
1461
+ */
1462
+ buildTools() {
1463
+ const erase = (contract) => contract;
1464
+ const factories = {
1465
+ readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
1466
+ editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
1467
+ writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
1468
+ runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),
1469
+ runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),
1470
+ grep: (opts) => erase(makeGrepTool(this.ops, opts)),
1471
+ glob: (opts) => erase(makeGlobTool(this.ops, opts))
1472
+ };
1473
+ const build = (name, opts) => factories[name](opts);
1474
+ return {
1475
+ all: () => ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
1476
+ pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
1477
+ readFile: (opts) => build("readFile", opts),
1478
+ editFile: (opts) => build("editFile", opts),
1479
+ writeFile: (opts) => build("writeFile", opts),
1480
+ runShell: (opts) => build("runShell", opts),
1481
+ runTests: (opts) => build("runTests", opts),
1482
+ grep: (opts) => build("grep", opts),
1483
+ glob: (opts) => build("glob", opts)
1484
+ };
1485
+ }
1486
+ /**
1487
+ * Reject a mutating direct method on a read-only projection — surfaced
1488
+ * as a {@link WorkspacePolicyError} (the same typed error a denied
1489
+ * command produces) so a caller branches on `error.type`.
1490
+ */
1491
+ assertWritable(operation) {
1492
+ if (this.allowedTools.has("writeFile")) return;
1493
+ throw new WorkspacePolicyError(`Operation "${operation}" is not permitted on a read-only workspace.`, {
1494
+ type: "denied-command",
1495
+ command: operation
1496
+ });
1497
+ }
1498
+ readFile(filePath, opts) {
1499
+ return this.ops.readFile(filePath, opts);
1500
+ }
1501
+ async writeFile(filePath, content) {
1502
+ this.assertWritable("writeFile");
1503
+ return this.ops.writeFile(filePath, content);
1504
+ }
1505
+ async editFile(input) {
1506
+ this.assertWritable("editFile");
1507
+ return this.ops.editFile(input);
1508
+ }
1509
+ async exec(command, opts) {
1510
+ this.assertWritable("exec");
1511
+ return this.ops.exec(command, opts);
1512
+ }
1513
+ grep(pattern, opts) {
1514
+ return this.ops.grep(pattern, opts);
1515
+ }
1516
+ glob(pattern) {
1517
+ return this.ops.glob(pattern);
1518
+ }
1519
+ exists(filePath) {
1520
+ return this.ops.exists(filePath);
1521
+ }
1522
+ async mkdir(filePath) {
1523
+ this.assertWritable("mkdir");
1524
+ return this.ops.mkdir(filePath);
1525
+ }
1526
+ async remove(filePath) {
1527
+ this.assertWritable("remove");
1528
+ return this.ops.remove(filePath);
1529
+ }
1530
+ /**
1531
+ * A read-only projection over the SAME policy — only the read/grep/glob
1532
+ * tools are vended and every mutating direct method rejects with a
1533
+ * {@link WorkspacePolicyError}. A fresh ops/backend is built from the
1534
+ * identical policy, so the projection sees the same jailed tree.
1535
+ */
1536
+ readonly() {
1537
+ return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
1538
+ }
1539
+ /**
1540
+ * A sub-jailed view rooted at `subdir` (relative to this workspace's
1541
+ * `cwd`). Returns a brand-new workspace whose policy is this policy
1542
+ * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
1543
+ * same allow/deny/shell/read sub-policies, but a tighter jail root.
1544
+ */
1545
+ scope(subdir) {
1546
+ return new WorkspaceImpl({
1547
+ ...this.policy,
1548
+ cwd: node_path.default.join(this.policy.cwd, subdir)
1549
+ }, [...this.allowedTools]);
1550
+ }
1551
+ };
1552
+ /**
1553
+ * Build a {@link Workspace} — the integrator that wires a
1554
+ * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
1555
+ * layer, and the seven agent-facing tool factories.
1556
+ *
1557
+ * The backend is chosen from `policy.backend`: `"mock"` runs in memory
1558
+ * (hermetic tests); the `"local"` default (and any absent value) runs
1559
+ * over the real disk via `@warlock.js/fs` + `node:child_process`. The
1560
+ * returned workspace exposes:
1561
+ *
1562
+ * - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /
1563
+ * `runTests` / `grep` / `glob`, plus `all()` (every tool) and
1564
+ * `pick(...)` (a least-privilege subset).
1565
+ * - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /
1566
+ * `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1
1567
+ * to the shared ops layer.
1568
+ * - **`readonly()`** — a projection that vends only read/grep/glob and
1569
+ * rejects every mutating direct method.
1570
+ * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
1571
+ *
1572
+ * Available at runtime as `ai.workspace(policy)` once this module is
1573
+ * imported (it registers the verb on the shared `ai` object).
1574
+ *
1575
+ * @param policy - The policy bounding the workspace (its `cwd` is the jail root).
1576
+ * @returns A fully-wired {@link Workspace}.
1577
+ *
1578
+ * @example
1579
+ * const ws = workspace({ cwd: "/srv/acme-api", shell: { allow: ["npm"], inheritEnv: ["PATH"] } });
1580
+ * const dev = ai.agent({ model, tools: ws.tools.all() });
1581
+ * await dev.execute("Make the failing cart-total suite green.");
1582
+ *
1583
+ * @example
1584
+ * // Least-privilege reviewer — no write, no shell.
1585
+ * const reviewer = ai.agent({ model, tools: ws.readonly().tools.all() });
1586
+ */
1587
+ function workspace(policy) {
1588
+ return new WorkspaceImpl(policy);
1589
+ }
1590
+ _warlock_js_ai.ai.workspace = workspace;
1591
+
1592
+ //#endregion
1593
+ exports.WorkspaceEditError = WorkspaceEditError;
1594
+ exports.WorkspacePolicyError = WorkspacePolicyError;
1595
+ exports.buildEnv = buildEnv;
1596
+ exports.createLocalBackend = createLocalBackend;
1597
+ exports.createMockBackend = createMockBackend;
1598
+ exports.createOps = createOps;
1599
+ exports.isCommandAllowed = isCommandAllowed;
1600
+ exports.makeEditFileTool = makeEditFileTool;
1601
+ exports.makeGlobTool = makeGlobTool;
1602
+ exports.makeGrepTool = makeGrepTool;
1603
+ exports.makeReadFileTool = makeReadFileTool;
1604
+ exports.makeRunShellTool = makeRunShellTool;
1605
+ exports.makeRunTestsTool = makeRunTestsTool;
1606
+ exports.makeWriteFileTool = makeWriteFileTool;
1607
+ exports.resolveInJail = resolveInJail;
1608
+ exports.workspace = workspace;
1609
+ //# sourceMappingURL=index.cjs.map