@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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/cjs/index.cjs +1609 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/backends/local.d.mts +22 -0
- package/esm/backends/local.d.mts.map +1 -0
- package/esm/backends/local.mjs +208 -0
- package/esm/backends/local.mjs.map +1 -0
- package/esm/backends/mock.d.mts +62 -0
- package/esm/backends/mock.d.mts.map +1 -0
- package/esm/backends/mock.mjs +167 -0
- package/esm/backends/mock.mjs.map +1 -0
- package/esm/contracts/index.d.mts +5 -0
- package/esm/contracts/tool-io.type.d.mts +149 -0
- package/esm/contracts/tool-io.type.d.mts.map +1 -0
- package/esm/contracts/workspace-backend.contract.d.mts +69 -0
- package/esm/contracts/workspace-backend.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-ops.contract.d.mts +72 -0
- package/esm/contracts/workspace-ops.contract.d.mts.map +1 -0
- package/esm/contracts/workspace-policy.type.d.mts +86 -0
- package/esm/contracts/workspace-policy.type.d.mts.map +1 -0
- package/esm/contracts/workspace.contract.d.mts +131 -0
- package/esm/contracts/workspace.contract.d.mts.map +1 -0
- package/esm/errors.d.mts +100 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +58 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/index.d.mts +20 -0
- package/esm/index.mjs +15 -0
- package/esm/ops.d.mts +25 -0
- package/esm/ops.d.mts.map +1 -0
- package/esm/ops.mjs +294 -0
- package/esm/ops.mjs.map +1 -0
- package/esm/policy/policy.d.mts +71 -0
- package/esm/policy/policy.d.mts.map +1 -0
- package/esm/policy/policy.mjs +184 -0
- package/esm/policy/policy.mjs.map +1 -0
- package/esm/tools/edit-file.d.mts +40 -0
- package/esm/tools/edit-file.d.mts.map +1 -0
- package/esm/tools/edit-file.mjs +57 -0
- package/esm/tools/edit-file.mjs.map +1 -0
- package/esm/tools/glob.d.mts +37 -0
- package/esm/tools/glob.d.mts.map +1 -0
- package/esm/tools/glob.mjs +45 -0
- package/esm/tools/glob.mjs.map +1 -0
- package/esm/tools/grep.d.mts +36 -0
- package/esm/tools/grep.d.mts.map +1 -0
- package/esm/tools/grep.mjs +51 -0
- package/esm/tools/grep.mjs.map +1 -0
- package/esm/tools/read-file.d.mts +35 -0
- package/esm/tools/read-file.d.mts.map +1 -0
- package/esm/tools/read-file.mjs +64 -0
- package/esm/tools/read-file.mjs.map +1 -0
- package/esm/tools/run-shell.d.mts +35 -0
- package/esm/tools/run-shell.d.mts.map +1 -0
- package/esm/tools/run-shell.mjs +65 -0
- package/esm/tools/run-shell.mjs.map +1 -0
- package/esm/tools/run-tests.d.mts +40 -0
- package/esm/tools/run-tests.d.mts.map +1 -0
- package/esm/tools/run-tests.mjs +67 -0
- package/esm/tools/run-tests.mjs.map +1 -0
- package/esm/tools/schema.mjs +111 -0
- package/esm/tools/schema.mjs.map +1 -0
- package/esm/tools/write-file.d.mts +33 -0
- package/esm/tools/write-file.d.mts.map +1 -0
- package/esm/tools/write-file.mjs +52 -0
- package/esm/tools/write-file.mjs.map +1 -0
- package/esm/workspace.d.mts +54 -0
- package/esm/workspace.d.mts.map +1 -0
- package/esm/workspace.mjs +210 -0
- package/esm/workspace.mjs.map +1 -0
- package/llms-full.txt +231 -0
- package/llms.txt +10 -0
- package/package.json +42 -0
- package/skills/README.md +13 -0
- package/skills/build-loop-agent/SKILL.md +100 -0
- package/skills/use-a-workspace/SKILL.md +117 -0
package/llms-full.txt
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Warlock AI Workspace — full skills
|
|
2
|
+
|
|
3
|
+
> Package: `@warlock.js/ai-workspace`
|
|
4
|
+
|
|
5
|
+
> Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/ai-workspace/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
|
|
6
|
+
|
|
7
|
+
## build-loop-agent `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
name: build-loop-agent
|
|
11
|
+
description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Build a build-loop agent — read → edit → run tests, until green
|
|
15
|
+
|
|
16
|
+
The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import "@warlock.js/ai-workspace";
|
|
20
|
+
import { ai } from "@warlock.js/ai";
|
|
21
|
+
|
|
22
|
+
const ws = ai.workspace({
|
|
23
|
+
cwd: "/srv/acme-api",
|
|
24
|
+
denyPaths: [".git/**", ".env*"],
|
|
25
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const dev = ai.agent({
|
|
29
|
+
model,
|
|
30
|
+
tools: ws.tools.all(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
|
|
31
|
+
maxTrips: 25, // give the loop room to iterate
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Why the loop works: errors as data
|
|
38
|
+
|
|
39
|
+
A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
|
|
40
|
+
|
|
41
|
+
## Give the test command a path
|
|
42
|
+
|
|
43
|
+
`run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
|
|
44
|
+
|
|
45
|
+
- the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
|
|
46
|
+
- the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
|
|
47
|
+
|
|
48
|
+
Override the base command per agent: `ws.tools.runTests({ command: "pnpm test" })`. A `pattern` the model supplies is appended as a filter (e.g. `pnpm test cart-total`).
|
|
49
|
+
|
|
50
|
+
## Least-privilege variants
|
|
51
|
+
|
|
52
|
+
### A read-only reviewer
|
|
53
|
+
|
|
54
|
+
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools *and* rejects the mutating direct methods):
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const reviewer = ai.agent({
|
|
58
|
+
model,
|
|
59
|
+
tools: ws.tools.pick("readFile", "grep", "glob"),
|
|
60
|
+
});
|
|
61
|
+
await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
|
|
62
|
+
|
|
63
|
+
// Equivalent projection of the same jail:
|
|
64
|
+
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Scope an agent to one package
|
|
68
|
+
|
|
69
|
+
`scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
|
|
73
|
+
const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Scaffold from code, then hand off
|
|
77
|
+
|
|
78
|
+
You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
|
|
82
|
+
await ws.exec("npm install zod");
|
|
83
|
+
await dev.execute("Implement the handlers declared in src/routes.ts.");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
|
|
87
|
+
|
|
88
|
+
## Share one workspace across a team
|
|
89
|
+
|
|
90
|
+
One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
|
|
94
|
+
|
|
95
|
+
ai.supervisor({
|
|
96
|
+
members: [
|
|
97
|
+
ai.agent({ /* executor */ model, tools: ws.tools.all() }),
|
|
98
|
+
ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
|
|
99
|
+
ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## See also
|
|
105
|
+
|
|
106
|
+
- [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
|
|
107
|
+
- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
|
|
108
|
+
- [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
## use-a-workspace `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
name: use-a-workspace
|
|
115
|
+
description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# Use a workspace — a policy jail for an agent's filesystem + shell
|
|
119
|
+
|
|
120
|
+
`ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import "@warlock.js/ai-workspace";
|
|
124
|
+
import { ai } from "@warlock.js/ai";
|
|
125
|
+
|
|
126
|
+
const ws = ai.workspace({
|
|
127
|
+
cwd: "/srv/acme-api",
|
|
128
|
+
denyPaths: [".git/**", ".env*"],
|
|
129
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
|
|
130
|
+
read: { defaultLines: 2_000 },
|
|
131
|
+
backend: "local", // default; use "mock" for hermetic tests
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## A policy jail, NOT OS isolation
|
|
136
|
+
|
|
137
|
+
This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an *allowed* process does once it runs. Real isolation (container / microVM) is a later phase.
|
|
138
|
+
|
|
139
|
+
## The policy surface
|
|
140
|
+
|
|
141
|
+
| Field | What it does |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
|
|
144
|
+
| `allowPaths` | Extra readable roots outside `cwd`. |
|
|
145
|
+
| `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
|
|
146
|
+
| `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading token. **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
|
|
147
|
+
| `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
|
|
148
|
+
| `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
|
|
149
|
+
| `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
|
|
150
|
+
| `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
|
|
151
|
+
| `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
|
|
152
|
+
|
|
153
|
+
> **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
|
|
154
|
+
|
|
155
|
+
## Two callers, one jail
|
|
156
|
+
|
|
157
|
+
### Agent-facing — `ws.tools.*`
|
|
158
|
+
|
|
159
|
+
Each tool is a `ToolContract` ready for `ai.agent({ tools })`. The seven LLM-visible tool names are `read_file`, `edit_file`, `write_file`, `run_shell`, `run_tests`, `grep`, `glob`.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
ws.tools.all(); // all seven, canonical order
|
|
163
|
+
ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
|
|
164
|
+
ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
|
|
165
|
+
ws.tools.runTests({ command: "pnpm test" }); // override the test base command
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Human-facing — direct methods
|
|
169
|
+
|
|
170
|
+
The same policy seam, callable from code (no LLM):
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
|
|
174
|
+
await ws.writeFile("src/new.ts", "export const x = 1;\n");
|
|
175
|
+
await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
|
|
176
|
+
const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
|
|
177
|
+
const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
|
|
178
|
+
const paths = await ws.glob("src/models/**/*.ts");
|
|
179
|
+
await ws.exists("package.json");
|
|
180
|
+
await ws.mkdir("src/generated");
|
|
181
|
+
await ws.remove("dist");
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Read-before-edit (the stale-hash guard)
|
|
185
|
+
|
|
186
|
+
`readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
|
|
187
|
+
|
|
188
|
+
## Errors flow as data, not throws
|
|
189
|
+
|
|
190
|
+
Policy and edit failures are typed errors extending the `@warlock.js/ai` `AIError` base. Inside a tool the `tool()` wrapper catches them and surfaces them in `{ error }`, so the agent reads the failure and self-corrects. Branch on `.type`:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await ws.exec("rm -rf /");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
|
|
199
|
+
console.warn(`Blocked: ${error.command}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"`.
|
|
205
|
+
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
|
|
206
|
+
|
|
207
|
+
## Composition — `readonly()` and `scope()`
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
|
|
211
|
+
const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file` + `grep` + `glob` only, and `writeFile` / `editFile` / `exec` / `mkdir` / `remove` throw a `WorkspacePolicyError`. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
|
|
215
|
+
|
|
216
|
+
## Hermetic tests with the mock backend
|
|
217
|
+
|
|
218
|
+
`backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
|
|
222
|
+
// (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## See also
|
|
226
|
+
|
|
227
|
+
- [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
|
|
228
|
+
- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
|
|
229
|
+
- [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
|
|
230
|
+
|
|
231
|
+
|
package/llms.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Warlock AI Workspace
|
|
2
|
+
|
|
3
|
+
> Package: `@warlock.js/ai-workspace`
|
|
4
|
+
|
|
5
|
+
> Policy-jailed filesystem + shell workspace for @warlock.js/ai agents
|
|
6
|
+
|
|
7
|
+
## Skills
|
|
8
|
+
|
|
9
|
+
- [build-loop-agent](@warlock.js/ai-workspace/build-loop-agent/SKILL.md): Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, 'agent that fixes a failing test', 'make the suite green', 'coding agent that edits a repo', 'build-loop agent', 'self-correcting agent', 'read-only code reviewer', 'scaffold then hand off to an agent', 'share one workspace across a supervisor', 'least-privilege agent tools', 'errors as tool data'; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.
|
|
10
|
+
- [use-a-workspace](@warlock.js/ai-workspace/use-a-workspace/SKILL.md): Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; 'give an agent file tools', 'jail an agent to a directory', 'let an agent read/write/grep a repo', 'read-only workspace for a reviewer', 'scope an agent to a subdirectory', 'run a shell command under policy', 'edit a file with a stale-hash guard'; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@warlock.js/ai-workspace",
|
|
3
|
+
"description": "Policy-jailed filesystem + shell workspace for @warlock.js/ai agents",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"warlock",
|
|
6
|
+
"ai",
|
|
7
|
+
"workspace",
|
|
8
|
+
"agent",
|
|
9
|
+
"filesystem",
|
|
10
|
+
"shell",
|
|
11
|
+
"sandbox",
|
|
12
|
+
"tools"
|
|
13
|
+
],
|
|
14
|
+
"author": "Hasan Zohdy",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/warlockjs/ai-workspace"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@warlock.js/fs": "4.5.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@warlock.js/ai": "4.5.0"
|
|
25
|
+
},
|
|
26
|
+
"version": "4.5.0",
|
|
27
|
+
"main": "./cjs/index.cjs",
|
|
28
|
+
"module": "./esm/index.mjs",
|
|
29
|
+
"types": "./esm/index.d.mts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./esm/index.d.mts",
|
|
34
|
+
"default": "./esm/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "./esm/index.d.mts",
|
|
38
|
+
"default": "./cjs/index.cjs"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# `@warlock.js/ai-workspace` — skills index
|
|
2
|
+
|
|
3
|
+
Per-task skills. All cross-references use the form `@warlock.js/<pkg>/<skill>/SKILL.md`.
|
|
4
|
+
|
|
5
|
+
## Skills
|
|
6
|
+
|
|
7
|
+
### [`use-a-workspace/`](./use-a-workspace/SKILL.md)
|
|
8
|
+
|
|
9
|
+
Build a policy-jailed filesystem + shell workspace with `ai.workspace(policy)` and operate it from both ends — the agent-facing `ws.tools.*` (the seven tools + `all()` / `pick(...)`) and the human-facing direct methods (`readFile` / `writeFile` / `editFile` / `exec` / `grep` / `glob` / `exists` / `mkdir` / `remove`). Covers the policy surface (the `cwd` jail, `allowPaths` / `denyPaths`, the fail-closed shell allow/deny + `inheritEnv`, read caps), the local vs mock backend, the read-before-edit stale-hash guard, the typed `WorkspacePolicyError` / `WorkspaceEditError`, and the `readonly()` / `scope(subdir)` projections. Load when constructing a workspace, choosing a policy, vending tools to an agent, or driving the filesystem programmatically.
|
|
10
|
+
|
|
11
|
+
### [`build-loop-agent/`](./build-loop-agent/SKILL.md)
|
|
12
|
+
|
|
13
|
+
Wire a workspace into a coding agent that closes the read → edit → run-tests loop on its own — `ai.agent({ tools: ws.tools.all() })` so a failing suite comes back as tool *data* and the model self-corrects until green. Covers the minimal build-loop wiring, why errors-as-data drives the loop, the shell policy a test command needs (`shell.allow` + `inheritEnv: ["PATH"]`), the read-only reviewer via `tools.pick` / `readonly()`, scaffolding from code before handing off, and sharing one jail across a supervisor's members. Load when building an agent that edits a repo, runs tests, reviews code, or scaffolds then hands off.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-loop-agent
|
|
3
|
+
description: 'Wire a @warlock.js/ai-workspace into a coding agent that closes the read → edit → run-tests loop on its own. Triggers: `ws.tools.all()`, `ai.agent({ tools: ws.tools.all() })`, `maxTrips`, `run_tests`, `run_shell`, ''agent that fixes a failing test'', ''make the suite green'', ''coding agent that edits a repo'', ''build-loop agent'', ''self-correcting agent'', ''read-only code reviewer'', ''scaffold then hand off to an agent'', ''share one workspace across a supervisor'', ''least-privilege agent tools'', ''errors as tool data''; typical wiring `const ws = ai.workspace({ cwd, shell }); ai.agent({ model, tools: ws.tools.all(), maxTrips })`. Skip: constructing the workspace / its policy / direct methods / readonly / scope — `@warlock.js/ai-workspace/use-a-workspace/SKILL.md`; the agent loop / maxTrips / events themselves — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a build-loop agent — read → edit → run tests, until green
|
|
7
|
+
|
|
8
|
+
The point of a workspace is to hand its tools to an agent and let it close the loop itself: read the failing spec, edit the source, run the tests, read the failure, fix again. The failure comes back as tool **data** (a non-zero `exitCode`, the captured `stderr`), not a thrown error — so the model can see what broke and self-correct.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import "@warlock.js/ai-workspace";
|
|
12
|
+
import { ai } from "@warlock.js/ai";
|
|
13
|
+
|
|
14
|
+
const ws = ai.workspace({
|
|
15
|
+
cwd: "/srv/acme-api",
|
|
16
|
+
denyPaths: [".git/**", ".env*"],
|
|
17
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 120_000 },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const dev = ai.agent({
|
|
21
|
+
model,
|
|
22
|
+
tools: ws.tools.all(), // read_file, edit_file, write_file, run_shell, run_tests, grep, glob
|
|
23
|
+
maxTrips: 25, // give the loop room to iterate
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await dev.execute("`npm test` fails on the cart-total suite. Make it green.");
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why the loop works: errors as data
|
|
30
|
+
|
|
31
|
+
A failing test isn't a tool error — it's a normal result with `exitCode !== 0`. `run_tests` (and `run_shell`) return `{ exitCode, stdout, stderr, timedOut, truncated }` as the tool's `data`, so the model reads the failure on the next trip and tries again. The same is true of the policy/edit guards: a jail escape, a denied command, a non-unique edit, or a stale hash all come back as tool-error data the agent can react to, never a thrown run-killer. Give `maxTrips` enough headroom for the read → edit → test cycle to converge.
|
|
32
|
+
|
|
33
|
+
## Give the test command a path
|
|
34
|
+
|
|
35
|
+
`run_tests` defaults to `npm test`. It runs through the **same shell policy** as `run_shell`, so the agent can't run it unless:
|
|
36
|
+
|
|
37
|
+
- the executable basename is allowed — `shell: { allow: ["npm", "node"] }`, and
|
|
38
|
+
- the env can find it — `inheritEnv: ["PATH"]` (the workspace never inherits `process.env` wholesale).
|
|
39
|
+
|
|
40
|
+
Override the base command per agent: `ws.tools.runTests({ command: "pnpm test" })`. A `pattern` the model supplies is appended as a filter (e.g. `pnpm test cart-total`).
|
|
41
|
+
|
|
42
|
+
## Least-privilege variants
|
|
43
|
+
|
|
44
|
+
### A read-only reviewer
|
|
45
|
+
|
|
46
|
+
A reviewer should never write or run a shell. Pick exactly the read tools, or hand it a `readonly()` projection (which omits the mutating tools *and* rejects the mutating direct methods):
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const reviewer = ai.agent({
|
|
50
|
+
model,
|
|
51
|
+
tools: ws.tools.pick("readFile", "grep", "glob"),
|
|
52
|
+
});
|
|
53
|
+
await reviewer.execute("Review src/cart for off-by-one bugs and summarize.");
|
|
54
|
+
|
|
55
|
+
// Equivalent projection of the same jail:
|
|
56
|
+
ai.agent({ model, tools: ws.readonly().tools.all() }); // read_file + grep + glob only
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Scope an agent to one package
|
|
60
|
+
|
|
61
|
+
`scope(subdir)` narrows the jail so a per-package agent can't reach outside it — one sub-agent per package, isolated blast radius:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
const apiDev = ai.agent({ model, tools: ws.scope("packages/api").tools.all() });
|
|
65
|
+
const webDev = ai.agent({ model, tools: ws.scope("packages/web").tools.all() });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Scaffold from code, then hand off
|
|
69
|
+
|
|
70
|
+
You don't have to let the agent do everything. Seed structure (and install deps) with the direct methods, then hand the tools to the agent for the creative part:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await ws.writeFile("src/routes.ts", ROUTES_BOILERPLATE);
|
|
74
|
+
await ws.exec("npm install zod");
|
|
75
|
+
await dev.execute("Implement the handlers declared in src/routes.ts.");
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For a purely mechanical change, skip the LLM entirely: `ws.glob(...)` + a loop of `ws.editFile(...)` under the stale-hash guard.
|
|
79
|
+
|
|
80
|
+
## Share one workspace across a team
|
|
81
|
+
|
|
82
|
+
One workspace is one jail and one tree. Pass it to several members of a supervisor, varying only the tool slice each gets — they all read/write the **same** files:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const ws = ai.workspace({ cwd, shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] } });
|
|
86
|
+
|
|
87
|
+
ai.supervisor({
|
|
88
|
+
members: [
|
|
89
|
+
ai.agent({ /* executor */ model, tools: ws.tools.all() }),
|
|
90
|
+
ai.agent({ /* reviewer */ model, tools: ws.readonly().tools.all() }),
|
|
91
|
+
ai.agent({ /* tester */ model, tools: ws.tools.pick("readFile", "runTests") }),
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## See also
|
|
97
|
+
|
|
98
|
+
- [`@warlock.js/ai-workspace/use-a-workspace/SKILL.md`](@warlock.js/ai-workspace/use-a-workspace/SKILL.md) — building the workspace, the policy surface, the direct methods, `readonly` / `scope`, and the typed errors.
|
|
99
|
+
- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — the agent loop, `maxTrips`, and the run report.
|
|
100
|
+
- [`@warlock.js/ai/run-supervisor/SKILL.md`](@warlock.js/ai/run-supervisor/SKILL.md) — coordinating the members that share one workspace.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: use-a-workspace
|
|
3
|
+
description: 'Build and operate a policy-jailed filesystem + shell workspace with @warlock.js/ai-workspace. Triggers: `ai.workspace`, `workspace(`, `WorkspacePolicy`, `WorkspaceShellPolicy`, `WorkspaceToolName`, `ws.tools.all`, `ws.tools.pick`, `ws.readFile`, `ws.writeFile`, `ws.editFile`, `ws.exec`, `ws.grep`, `ws.glob`, `ws.readonly`, `ws.scope`, `WorkspacePolicyError`, `WorkspaceEditError`, `denyPaths`, `inheritEnv`, `expectHash`, `read_file`/`edit_file`/`write_file`/`run_shell`/`run_tests`; ''give an agent file tools'', ''jail an agent to a directory'', ''let an agent read/write/grep a repo'', ''read-only workspace for a reviewer'', ''scope an agent to a subdirectory'', ''run a shell command under policy'', ''edit a file with a stale-hash guard''; typical import `import "@warlock.js/ai-workspace"; import { ai } from "@warlock.js/ai"`. Skip: building the agent loop that consumes the tools — `@warlock.js/ai-workspace/build-loop-agent/SKILL.md`; defining a non-filesystem custom tool — `@warlock.js/ai/define-ai-tool/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Use a workspace — a policy jail for an agent's filesystem + shell
|
|
7
|
+
|
|
8
|
+
`ai.workspace(policy)` returns a **workspace**: a bounded place an agent reads, writes, and runs commands. One policy, one shared rule set, two ways to touch it — the agent uses `ws.tools.*`; you use the direct methods. Import the package once for its side effect so the verb is registered on the shared `ai`:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import "@warlock.js/ai-workspace";
|
|
12
|
+
import { ai } from "@warlock.js/ai";
|
|
13
|
+
|
|
14
|
+
const ws = ai.workspace({
|
|
15
|
+
cwd: "/srv/acme-api",
|
|
16
|
+
denyPaths: [".git/**", ".env*"],
|
|
17
|
+
shell: { allow: ["npm", "node"], inheritEnv: ["PATH"], timeoutMs: 60_000 },
|
|
18
|
+
read: { defaultLines: 2_000 },
|
|
19
|
+
backend: "local", // default; use "mock" for hermetic tests
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## A policy jail, NOT OS isolation
|
|
24
|
+
|
|
25
|
+
This is least-privilege guardrails for a **trusted** agent — not a sandbox around hostile code. It enforces a `realpath`-resolved `cwd` jail, path allow/deny, a fail-closed shell allow/deny list, timeouts, output caps, and a non-inherited environment. It does **not** contain what an *allowed* process does once it runs. Real isolation (container / microVM) is a later phase.
|
|
26
|
+
|
|
27
|
+
## The policy surface
|
|
28
|
+
|
|
29
|
+
| Field | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `cwd` | **Absolute** jail root. Every path resolves against it and must stay inside. |
|
|
32
|
+
| `allowPaths` | Extra readable roots outside `cwd`. |
|
|
33
|
+
| `denyPaths` | Globs (`*`, `**`, `?`) blocked **even inside `cwd`** — e.g. `[".git/**", ".env*"]`. A bare dir name (`"node_modules"`) also blocks its contents. |
|
|
34
|
+
| `shell.allow` / `shell.deny` | Executable **basenames** matched against the command's leading token. **Deny wins.** An `allow` list is exhaustive (fail-closed); no `shell` block at all ⇒ nothing may run. |
|
|
35
|
+
| `shell.inheritEnv` | Opt-in `process.env` keys to pass through (e.g. `["PATH"]`). **Nothing leaks in otherwise** — a command can't find `node`/`npm` without `PATH`. |
|
|
36
|
+
| `shell.env` | Explicit env vars injected into every spawned process (override inherited on collision). |
|
|
37
|
+
| `shell.timeoutMs` / `shell.maxOutputBytes` | Per-command wall-clock cap (SIGKILL on expiry) and stdout/stderr byte cap. |
|
|
38
|
+
| `read.defaultLines` / `read.maxBytes` | Default read window + a byte ceiling. |
|
|
39
|
+
| `backend` | `"local"` (default — real disk) or `"mock"` (in-memory, for tests). |
|
|
40
|
+
|
|
41
|
+
> **The most common gotcha:** `run_shell` fails to find `node`/`npm` because the env is empty. Add `shell: { allow: ["npm", "node"], inheritEnv: ["PATH"] }`.
|
|
42
|
+
|
|
43
|
+
## Two callers, one jail
|
|
44
|
+
|
|
45
|
+
### Agent-facing — `ws.tools.*`
|
|
46
|
+
|
|
47
|
+
Each tool is a `ToolContract` ready for `ai.agent({ tools })`. The seven LLM-visible tool names are `read_file`, `edit_file`, `write_file`, `run_shell`, `run_tests`, `grep`, `glob`.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
ws.tools.all(); // all seven, canonical order
|
|
51
|
+
ws.tools.pick("readFile", "grep", "glob"); // a least-privilege subset
|
|
52
|
+
ws.tools.readFile({ name: "open" }); // one tool, optionally renamed
|
|
53
|
+
ws.tools.runTests({ command: "pnpm test" }); // override the test base command
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Human-facing — direct methods
|
|
57
|
+
|
|
58
|
+
The same policy seam, callable from code (no LLM):
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const { content, hash, totalLines } = await ws.readFile("src/index.ts", { offset: 1, limit: 200 });
|
|
62
|
+
await ws.writeFile("src/new.ts", "export const x = 1;\n");
|
|
63
|
+
await ws.editFile({ path: "src/index.ts", oldString: "old", newString: "new", expectHash: hash });
|
|
64
|
+
const { exitCode, stdout, stderr, timedOut, truncated } = await ws.exec("npm test");
|
|
65
|
+
const { matches } = await ws.grep("TODO", { glob: "src/**/*.ts", ignoreCase: true });
|
|
66
|
+
const paths = await ws.glob("src/models/**/*.ts");
|
|
67
|
+
await ws.exists("package.json");
|
|
68
|
+
await ws.mkdir("src/generated");
|
|
69
|
+
await ws.remove("dist");
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Read-before-edit (the stale-hash guard)
|
|
73
|
+
|
|
74
|
+
`readFile` returns a SHA-256 `hash` of the full file. Pass it to `editFile`'s `expectHash`; if the file changed since you read it, the edit is rejected as stale so you re-read before clobbering. `editFile` also requires the `oldString` to be **exact and unique** — pass `replaceAll: true` to replace every occurrence, or include more surrounding context to disambiguate.
|
|
75
|
+
|
|
76
|
+
## Errors flow as data, not throws
|
|
77
|
+
|
|
78
|
+
Policy and edit failures are typed errors extending the `@warlock.js/ai` `AIError` base. Inside a tool the `tool()` wrapper catches them and surfaces them in `{ error }`, so the agent reads the failure and self-corrects. Branch on `.type`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { WorkspacePolicyError, WorkspaceEditError } from "@warlock.js/ai-workspace";
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await ws.exec("rm -rf /");
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof WorkspacePolicyError && error.type === "denied-command") {
|
|
87
|
+
console.warn(`Blocked: ${error.command}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `WorkspacePolicyError.type`: `"path-escape"` (jail escape / deny glob) | `"denied-command"`.
|
|
93
|
+
- `WorkspaceEditError.type`: `"not-found"` | `"not-unique"` | `"stale-hash"`.
|
|
94
|
+
|
|
95
|
+
## Composition — `readonly()` and `scope()`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const ro = ws.readonly(); // vends only read/grep/glob; mutating direct methods reject
|
|
99
|
+
const api = ws.scope("packages/api"); // a sub-jailed workspace rooted at the subdir
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`readonly()` is the least-privilege reviewer projection — `tools.all()` on it returns `read_file` + `grep` + `glob` only, and `writeFile` / `editFile` / `exec` / `mkdir` / `remove` throw a `WorkspacePolicyError`. `scope(subdir)` narrows `cwd` to `join(cwd, subdir)` (same sub-policies, same backend), so a per-package agent gets a tighter blast radius.
|
|
103
|
+
|
|
104
|
+
## Hermetic tests with the mock backend
|
|
105
|
+
|
|
106
|
+
`backend: "mock"` swaps the local disk for an in-memory `Map` + scripted `exec` — same policy, same tool/method surface, no filesystem:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const ws = ai.workspace({ cwd: "/srv/app", backend: "mock", shell: { allow: ["npm"] } });
|
|
110
|
+
// (Seed files / scripted commands via createMockBackend when constructing a backend directly.)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## See also
|
|
114
|
+
|
|
115
|
+
- [`@warlock.js/ai-workspace/build-loop-agent/SKILL.md`](@warlock.js/ai-workspace/build-loop-agent/SKILL.md) — wiring these tools into a read → edit → test agent loop.
|
|
116
|
+
- [`@warlock.js/ai/run-ai-agent/SKILL.md`](@warlock.js/ai/run-ai-agent/SKILL.md) — running the agent that consumes `ws.tools.all()`.
|
|
117
|
+
- [`@warlock.js/ai/define-ai-tool/SKILL.md`](@warlock.js/ai/define-ai-tool/SKILL.md) — the `tool()` / `ToolContract` seam every workspace tool is built on.
|