@expo/code-review-cli 0.6.0 → 0.8.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 (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -0,0 +1,42 @@
1
+ import { open } from "node:fs/promises";
2
+ // @ref LLP 0007#ecr-ci-the-trusted-root-run [constrained-by] — a missing/oversized context file WARNs and continues in ci; never fails checks
3
+ /**
4
+ * Hard read ceiling for a `--context-file`. The file is read once in the command
5
+ * layer, byte-bounded here, then head/tail capped again for the prompt
6
+ * (CONTEXT_FILE_MAX_CHARS in prompts.ts). This ceiling bounds the read itself so a
7
+ * multi-gigabyte path can't exhaust memory before the prompt cap ever applies.
8
+ */
9
+ export const MAX_CONTEXT_FILE_BYTES = 1_048_576; // 1 MiB
10
+ const READ_CHUNK_BYTES = 65_536;
11
+ /**
12
+ * Read an external context file as UTF-8 text. Throws on a missing/unreadable path
13
+ * or one over MAX_CONTEXT_FILE_BYTES — the command layer decides whether that is
14
+ * fatal (`ecr review`) or a warn-and-continue (`ecr ci`). The ceiling is enforced
15
+ * DURING the read, not by a stat beforehand: special files (`/dev/zero`, proc
16
+ * entries) report a small or zero size but read without end, and a regular file
17
+ * can grow between a stat and the read. Invalid UTF-8 decodes lossily
18
+ * (replacement chars); control chars are stripped later by sanitizeUntrusted.
19
+ */
20
+ export async function readContextFile(filePath) {
21
+ const handle = await open(filePath, "r");
22
+ try {
23
+ const chunks = [];
24
+ let total = 0;
25
+ for (;;) {
26
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
27
+ const { bytesRead } = await handle.read(chunk, 0, READ_CHUNK_BYTES);
28
+ if (bytesRead === 0) {
29
+ break;
30
+ }
31
+ total += bytesRead;
32
+ if (total > MAX_CONTEXT_FILE_BYTES) {
33
+ throw new Error(`context file too large (> 1 MiB): ${filePath}`);
34
+ }
35
+ chunks.push(chunk.subarray(0, bytesRead));
36
+ }
37
+ return Buffer.concat(chunks).toString("utf8");
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ }
@@ -9,9 +9,9 @@ import { parseCoordinatorOutput } from "./schema.js";
9
9
  // cap is a backstop. It runs AFTER all passes, so this adds to the worst-case
10
10
  // serial chain — keep it within the CI job timeout (see review.ts / workflows).
11
11
  const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
12
- export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = []) {
12
+ export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = [], stackManifest) {
13
13
  const system = buildCoordinatorSystem(config);
14
- const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes);
14
+ const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes, stackManifest);
15
15
  const { value, cost, tokens, truncated, model } = await promptAndParse(handle, {
16
16
  agent: "coordinator",
17
17
  system,
@@ -32,6 +32,7 @@ export function parseUnifiedDiff(diffText) {
32
32
  flush();
33
33
  return entries;
34
34
  }
35
+ // @ref LLP 0004#unified-diff-parsing [implements] — binary flagged, not dropped; noise filtering is the sole exclusion point
35
36
  function patchToEntry(patch) {
36
37
  const lines = patch.split("\n");
37
38
  const header = lines[0] ?? "";
@@ -1,36 +1,274 @@
1
- import { execFile } from "node:child_process";
1
+ // @ref LLP 0003#subprocess-spawning-rules [implements] — the only sanctioned child-process spawn path in the codebase
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  const execFileAsync = promisify(execFile);
4
7
  /**
5
8
  * Run a command capturing stdout/stderr. Never interpolates a shell, so
6
9
  * arguments are passed verbatim and are not subject to shell injection.
10
+ *
11
+ * With `input`, the call routes through spawn so the text can be streamed to
12
+ * stdin; every other caller keeps the execFile path unchanged.
7
13
  */
8
14
  export async function run(command, args, options = {}) {
15
+ if (options.input !== undefined) {
16
+ return runWithInput(command, args, options, options.input);
17
+ }
9
18
  const check = options.check ?? true;
10
19
  try {
11
20
  const { stdout, stderr } = await execFileAsync(command, args, {
12
21
  cwd: options.cwd,
13
22
  maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024,
14
23
  encoding: "utf8",
24
+ env: options.env,
25
+ timeout: options.timeout,
26
+ killSignal: options.killSignal,
15
27
  });
16
28
  return { stdout, stderr, code: 0 };
17
29
  }
18
30
  catch (error) {
19
31
  const err = error;
20
- if (!check) {
21
- return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", code: err.code ?? 1 };
32
+ // Same timeout contract as runWithInput: a child our own `timeout` killed
33
+ // resolves with `timedOut: true` (even under check) instead of a generic
34
+ // throw, so callers see one shape regardless of which path ran.
35
+ const timedOut = options.timeout !== undefined && err.killed === true;
36
+ if (!check || timedOut) {
37
+ return {
38
+ stdout: err.stdout ?? "",
39
+ stderr: err.stderr ?? "",
40
+ code: err.code ?? 1,
41
+ signal: err.signal,
42
+ timedOut: timedOut || undefined,
43
+ };
22
44
  }
23
45
  throw new Error(`Command failed: ${command} ${args.join(" ")}\n${err.stderr ?? err.message ?? ""}`.trim());
24
46
  }
25
47
  }
48
+ /**
49
+ * Kill callbacks for children still running, so an interrupt or exit never
50
+ * orphans them. Detached children live in their own process group (see
51
+ * runWithInput), so SIGINT from Ctrl-C reaches only this process — without this,
52
+ * an aborted review leaves a credential-bearing `claude` running unbounded.
53
+ */
54
+ const liveChildKillers = new Set();
55
+ let childCleanupInstalled = false;
56
+ function installChildCleanup() {
57
+ if (childCleanupInstalled) {
58
+ return;
59
+ }
60
+ childCleanupInstalled = true;
61
+ const killAll = () => {
62
+ for (const kill of liveChildKillers) {
63
+ kill();
64
+ }
65
+ };
66
+ process.on("exit", killAll);
67
+ for (const signal of ["SIGINT", "SIGTERM"]) {
68
+ process.on(signal, () => {
69
+ killAll();
70
+ // Re-raise the conventional exit code; registering a handler suppressed
71
+ // Node's default termination.
72
+ process.exitCode = signal === "SIGINT" ? 130 : 143;
73
+ process.exit();
74
+ });
75
+ }
76
+ }
77
+ /**
78
+ * spawn-based variant that feeds `input` to the child's stdin. Collects
79
+ * stdout/stderr up to `maxBuffer`, enforces `timeout`/`killSignal` manually, and
80
+ * resolves the same RunResult shape (with `timedOut`/`overflowed` set).
81
+ *
82
+ * The deadline is enforced with our own timers rather than spawn's native
83
+ * `timeout`: spawn sends `killSignal` once, to the direct child only, with no
84
+ * SIGKILL escalation — a child that traps SIGTERM, or a shim wrapper
85
+ * (volta/mise/asdf) whose grandchild holds the work, would run unbounded. Here a
86
+ * child launched detached forms its own process group, we signal the whole group,
87
+ * and a grace timer escalates to SIGKILL.
88
+ */
89
+ // @ref LLP 0003#subprocess-spawning-rules [implements] — own timeout/kill enforcement (process-group kill, SIGKILL escalation) instead of spawn's native timeout, which can't reach a grandchild
90
+ function runWithInput(command, args, options, input) {
91
+ const check = options.check ?? true;
92
+ const maxBuffer = options.maxBuffer ?? 64 * 1024 * 1024;
93
+ return new Promise((resolve, reject) => {
94
+ const detached = process.platform !== "win32";
95
+ const child = spawn(command, args, {
96
+ cwd: options.cwd,
97
+ env: options.env,
98
+ detached,
99
+ });
100
+ let stdout = "";
101
+ let stderr = "";
102
+ let overflowed = false;
103
+ let timedOut = false;
104
+ let killTimer;
105
+ let graceTimer;
106
+ const clearTimers = () => {
107
+ if (killTimer)
108
+ clearTimeout(killTimer);
109
+ if (graceTimer)
110
+ clearTimeout(graceTimer);
111
+ };
112
+ // Signal the whole process group when detached so a shim wrapper's grandchild
113
+ // is killed too. On Windows there are no process groups — kill the tree via
114
+ // taskkill instead, for the same reason.
115
+ const killChild = (sig) => {
116
+ try {
117
+ if (process.platform === "win32" && child.pid !== undefined) {
118
+ // A no-op error handler is required: an async spawn failure (ENOENT/EPERM)
119
+ // has no other listener here and would otherwise throw unhandled and
120
+ // crash the parent, defeating the point of this cleanup path.
121
+ spawn(taskkillPath(), ["/pid", String(child.pid), "/T", "/F"]).on("error", () => { });
122
+ }
123
+ else if (detached && child.pid !== undefined) {
124
+ process.kill(-child.pid, sig);
125
+ }
126
+ else {
127
+ child.kill(sig);
128
+ }
129
+ }
130
+ catch {
131
+ // Already exited, or the group is gone — nothing to kill.
132
+ }
133
+ };
134
+ const emergencyKill = () => killChild("SIGKILL");
135
+ installChildCleanup();
136
+ liveChildKillers.add(emergencyKill);
137
+ if (options.timeout && options.timeout > 0) {
138
+ killTimer = setTimeout(() => {
139
+ timedOut = true;
140
+ killChild(options.killSignal ?? "SIGTERM");
141
+ graceTimer = setTimeout(() => killChild("SIGKILL"), 5000);
142
+ graceTimer.unref?.();
143
+ }, options.timeout);
144
+ killTimer.unref?.();
145
+ }
146
+ const cap = (current, chunk) => {
147
+ if (current.length >= maxBuffer) {
148
+ overflowed = true;
149
+ return current;
150
+ }
151
+ const next = current + chunk;
152
+ // Check AFTER appending too — a single oversized chunk must both flag the
153
+ // overflow and stay capped, not sail through because the pre-append length
154
+ // was still under the limit.
155
+ if (next.length > maxBuffer) {
156
+ overflowed = true;
157
+ return next.slice(0, maxBuffer);
158
+ }
159
+ return next;
160
+ };
161
+ child.stdout.setEncoding("utf8");
162
+ child.stderr.setEncoding("utf8");
163
+ child.stdout.on("data", (chunk) => {
164
+ stdout = cap(stdout, chunk);
165
+ });
166
+ child.stderr.on("data", (chunk) => {
167
+ stderr = cap(stderr, chunk);
168
+ });
169
+ // A late I/O error on either stream (e.g. the process group getting
170
+ // SIGKILLed mid-read) would otherwise throw unhandled and crash the
171
+ // parent; the close handler reports the real outcome regardless.
172
+ child.stdout.on("error", () => { });
173
+ child.stderr.on("error", () => { });
174
+ child.on("error", (error) => {
175
+ clearTimers();
176
+ liveChildKillers.delete(emergencyKill);
177
+ if (!check) {
178
+ // A spawn error (ENOENT/EACCES) fires before any stderr can be
179
+ // captured, so fall back to error.message rather than resolving
180
+ // with an unexplained empty stderr.
181
+ resolve({ stdout, stderr: stderr || error.message, code: 1, timedOut, overflowed });
182
+ return;
183
+ }
184
+ reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${error.message}`.trim()));
185
+ });
186
+ child.on("close", (code, signal) => {
187
+ clearTimers();
188
+ liveChildKillers.delete(emergencyKill);
189
+ const exitCode = code ?? 1;
190
+ if (overflowed && check) {
191
+ reject(new Error(`Command output exceeded ${maxBuffer} bytes: ${command}`));
192
+ return;
193
+ }
194
+ if (exitCode !== 0 && check && !timedOut) {
195
+ reject(new Error(`Command failed: ${command} ${args.join(" ")}\n${stderr}`.trim()));
196
+ return;
197
+ }
198
+ resolve({
199
+ stdout,
200
+ stderr,
201
+ code: exitCode,
202
+ signal: signal ?? undefined,
203
+ timedOut,
204
+ overflowed,
205
+ });
206
+ });
207
+ child.stdin.on("error", () => {
208
+ // A child that exits before reading stdin (e.g. bad args) closes the pipe;
209
+ // ignore EPIPE — the close handler reports the real outcome.
210
+ });
211
+ child.stdin.end(input);
212
+ });
213
+ }
214
+ /**
215
+ * Memoized trusted resolutions for the host `git`/`gh` binaries, keyed by name so
216
+ * git()'s many callers share ONE which/where lookup instead of each spawning their
217
+ * own. See resolveTrustedTool.
218
+ */
219
+ const trustedToolResolutions = new Map();
220
+ /**
221
+ * Resolve `git`/`gh` to a trusted ABSOLUTE path, refusing any binary that resolves
222
+ * INSIDE the reviewed tree. Every git/gh spawn goes through this, never a bare name.
223
+ *
224
+ * A review has chdir'd into the untrusted PR-head tree (and `ecr review` of a local
225
+ * branch, plus `ecr ci`/doctor, operate on an untrusted checkout). libuv on Windows
226
+ * searches the child's cwd BEFORE PATH when the command is a BARE NAME, so a
227
+ * PR-committed `git.bat`/`gh.exe` at the repo root would win the lookup and run with
228
+ * ambient secrets (GH_TOKEN, model creds) in its environment. Spawning a resolved
229
+ * absolute path does no cwd search at all — the same property that fixed `claude`
230
+ * and `opencode`. resolveOnPath itself does the which/where lookup from tmpdir(), so
231
+ * the in-tree shim is never even FOUND on POSIX or Windows; pathInside is the backstop.
232
+ *
233
+ * Memoized per name: resolveOnPath is cwd-INDEPENDENT (it looks up from tmpdir), so a
234
+ * first call can never cache a cwd-tainted value, and the host binary is stable for
235
+ * the process. The caller's cwd is NOT changed — only the binary is resolved, so
236
+ * git/gh keep operating on their target tree. Throws (not null) so callers that
237
+ * assume a working git/gh fail loudly rather than silently spawning nothing.
238
+ */
239
+ // @ref LLP 0003#subprocess-spawning-rules [implements] — trusted absolute-path resolution plus in-tree refusal (pathInside) for git/gh, mirroring resolveClaudeCli/resolveOpencodeCli
240
+ export function resolveTrustedTool(name) {
241
+ let resolution = trustedToolResolutions.get(name);
242
+ if (!resolution) {
243
+ resolution = (async () => {
244
+ const resolved = await resolveOnPath(name);
245
+ if (!resolved) {
246
+ throw new Error(`The \`${name}\` CLI is not installed or not on PATH.`);
247
+ }
248
+ if (pathInside(resolved, process.cwd())) {
249
+ throw new Error(`refusing to run a \`${name}\` binary found inside the reviewed tree (${resolved}) — ` +
250
+ `install ${name} on the host and remove it from the repository.`);
251
+ }
252
+ return resolved;
253
+ })();
254
+ trustedToolResolutions.set(name, resolution);
255
+ }
256
+ return resolution;
257
+ }
258
+ /** Test-only: drop memoized git/gh resolutions so a test can re-resolve under a changed cwd/PATH. */
259
+ export function resetTrustedToolCache() {
260
+ trustedToolResolutions.clear();
261
+ }
26
262
  export async function git(args, cwd) {
27
- const { stdout } = await run("git", args, { cwd });
263
+ const gitPath = await resolveTrustedTool("git");
264
+ const { stdout } = await run(gitPath, args, { cwd });
28
265
  return stdout;
29
266
  }
30
267
  /** Resolve owner/repo from the current checkout via gh (for PR-targeting commands). */
31
268
  export async function resolveRepo(cwd) {
32
269
  try {
33
- const { stdout } = await run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
270
+ const gh = await resolveTrustedTool("gh");
271
+ const { stdout } = await run(gh, ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], {
34
272
  cwd,
35
273
  });
36
274
  const repo = stdout.trim();
@@ -52,10 +290,45 @@ export async function repoRoot(cwd) {
52
290
  return null;
53
291
  }
54
292
  }
55
- /** Whether an executable is resolvable on PATH. */
56
- export async function onPath(command) {
57
- const { code } = await run(process.platform === "win32" ? "where" : "which", [command], {
293
+ // @ref LLP 0003#subprocess-spawning-rules [implements] resolves from tmpdir(), never the process's own (possibly PR-tree) cwd, so a Windows cwd-search hijack can't find an in-tree shim
294
+ /** Absolute path of an executable on PATH (first match), or null if unresolved. */
295
+ export async function resolveOnPath(command) {
296
+ // SECURITY: run the lookup from a trusted directory, never the inherited cwd.
297
+ // During a review the process has chdir'd into the untrusted PR-head tree, and
298
+ // Windows `where` searches the CURRENT DIRECTORY before PATH — a committed
299
+ // `claude.exe` at the repo root would win the lookup and be executed with the
300
+ // engine's credentials in its environment. tmpdir() is host-controlled.
301
+ const { stdout, code } = await run(process.platform === "win32" ? "where" : "which", [command], {
58
302
  check: false,
303
+ cwd: tmpdir(),
59
304
  });
60
- return code === 0;
305
+ if (code !== 0) {
306
+ return null;
307
+ }
308
+ return stdout.trim().split("\n")[0]?.trim() || null;
309
+ }
310
+ /**
311
+ * Whether `filePath` lies inside `dir` (after resolution). Used as the backstop
312
+ * that refuses to execute a binary resolved from inside the reviewed tree.
313
+ */
314
+ export function pathInside(filePath, dir) {
315
+ const rel = path.relative(path.resolve(dir), path.resolve(filePath));
316
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
317
+ }
318
+ /**
319
+ * Absolute path to Windows' `taskkill`, so a process-tree kill never spawns a BARE
320
+ * `taskkill` — during a review the cwd is the untrusted PR-head tree, and Windows
321
+ * resolves a bare name against the current directory before PATH, so a PR-committed
322
+ * `taskkill.exe`/`.bat` at the tree root could otherwise run in place of the real one
323
+ * (with ambient secrets in its env) on the timeout-kill path. An absolute path does no
324
+ * search at all. `taskkill` always lives in System32, and `SystemRoot` is set by
325
+ * Windows, never by the PR. Callers stay win32-guarded.
326
+ */
327
+ export function taskkillPath() {
328
+ const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
329
+ return path.join(root, "System32", "taskkill.exe");
330
+ }
331
+ /** Whether an executable is resolvable on PATH. */
332
+ export async function onPath(command) {
333
+ return (await resolveOnPath(command)) !== null;
61
334
  }
package/build/core/log.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0002#run-log-and-observability-sinks [implements] — one JSON line per run for cost/latency auditability
1
2
  import { appendFile, mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  /**
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0004#noise-filtering [implements] — pre-agent signal gate; impure (reads cwd/disk), swallows read errors to null
1
2
  import { mkdir, open, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  const LOCKFILES = new Set(["yarn.lock", "package-lock.json", "pnpm-lock.yaml", "bun.lock"]);
@@ -32,6 +33,7 @@ export async function filterNoise(entries, options = {}, cwd = process.cwd()) {
32
33
  }
33
34
  return { kept, filtered };
34
35
  }
36
+ // @ref LLP 0004#noise-filtering [constrained-by] — marker checks are header-scoped only; whole-file scan self-filters this module
35
37
  async function noiseReason(entry, options, cwd) {
36
38
  if (entry.binary) {
37
39
  return "binary file (no textual diff)";
@@ -70,6 +72,7 @@ async function noiseReason(entry, options, cwd) {
70
72
  }
71
73
  /** How many leading lines of a file count as its (generation) header. */
72
74
  const HEADER_LINES = 5;
75
+ // @ref LLP 0004#the-mini-glob-dialect [constrained-by] — no sentinel bytes: a NUL sentinel once made this file classify as binary to git
73
76
  /** Minimal glob: supports `**` (crosses `/`) and `*` (within a segment). */
74
77
  export function matchesIgnore(filePath, pattern) {
75
78
  // Translate the glob to a regex in a single pass, escaping metacharacters
@@ -128,6 +131,7 @@ async function readFileHead(absPath, bytes = 4096) {
128
131
  return null;
129
132
  }
130
133
  }
134
+ // @ref LLP 0004#chunk-sizing-signal [implements] — sole size metric for chunk packing; the packing policy itself lives in review.ts
131
135
  /** Count added + removed lines in a unified-diff patch (ignores +++/--- headers). */
132
136
  export function countChangedLines(patch) {
133
137
  let count = 0;
@@ -141,6 +145,7 @@ export function countChangedLines(patch) {
141
145
  }
142
146
  return count;
143
147
  }
148
+ // @ref LLP 0004#patch-workspace [implements] — filenames sanitized against traversal/collisions from untrusted diff paths
144
149
  /**
145
150
  * Write one patch file per changed file plus a shared manifest, all inside the
146
151
  * repo (so the OpenCode read tool can reach them). Agents are pointed at these