@navels/neal 0.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Generic-agentic tool layer: the six neal-defined AI SDK tools, jailed to a
3
+ * root directory. Ported from `spike/generic-agentic/src/tools.ts` (copied,
4
+ * never imported — the spike stays a frozen historical reference) with the
5
+ * Phase 1 hardening from the integration plan:
6
+ *
7
+ * - an event-emission hook so tool execution surfaces the existing provider
8
+ * event grammar (`tool_started`, `tool_progress`, `command_completed`,
9
+ * `file_changed`) without new event types;
10
+ * - per-tool error attribution: every error-as-result emits a
11
+ * `tool_progress` payload with `toolName` and `isError: true` so run
12
+ * records are self-sufficient;
13
+ * - role-scoped factories: `createCoderToolset` (all six writer tools),
14
+ * `createPlanAuthorToolset` (read plus plan-artifact writes, no shell), and
15
+ * `createReadOnlyToolset` (`read_file`, `list_dir`, `grep`, plus the read-only
16
+ * `git_diff` range inspector) so the Phase 2 tool-capable reviewer is a
17
+ * registration change, not a tool change.
18
+ *
19
+ * `git_diff` exists because reviewer prompts name a commit range as the
20
+ * source of truth, but plain file reads can only see the head state: they
21
+ * cannot reveal deletions, renames, or what specifically changed in the
22
+ * range. The tool is a neal-owned read-only query (a fixed `git diff`
23
+ * argv — never a shell string), with refs validated against a conservative
24
+ * pattern and the optional path filter jailed lexically (lexically only:
25
+ * deleted paths must remain addressable, and git only ever resolves the
26
+ * filter against repository-relative paths).
27
+ *
28
+ * Cross-cutting constraints are enforced in code, not prompting:
29
+ *
30
+ * - every `path` argument resolves inside `rootDir`: lexically
31
+ * (resolveJailedPath) and against symlink escapes via realpath comparison
32
+ * (resolveExistingJailedPath / resolveWritableJailedPath);
33
+ * - every tool result is truncated to 16 KiB with an explicit marker;
34
+ * - tool errors are returned as tool results (never thrown out of the loop)
35
+ * so the model can self-correct.
36
+ *
37
+ * Trust level note: path-jailing tool arguments does not sandbox the `run`
38
+ * tool — shell commands execute with the same trust as vendor writers.
39
+ */
40
+ import { exec, execFile } from 'node:child_process';
41
+ import { promises as fs } from 'node:fs';
42
+ import path from 'node:path';
43
+ import { tool } from 'ai';
44
+ import { z } from 'zod';
45
+ export const RESULT_BYTE_LIMIT = 16 * 1024;
46
+ export const READ_FILE_BYTE_LIMIT = 256 * 1024;
47
+ export const GREP_MAX_MATCHING_LINES = 200;
48
+ export const DEFAULT_RUN_TIMEOUT_MS = 120_000;
49
+ export const GIT_DIFF_TIMEOUT_MS = 30_000;
50
+ /**
51
+ * Conservative git revision pattern for `git_diff` arguments: SHAs, branch
52
+ * and tag names, `HEAD`, and suffix operators (`~`, `^`). Must start with an
53
+ * alphanumeric so a revision can never be parsed as a git option, and must
54
+ * not contain whitespace, `..`, or shell metacharacters. The tool composes
55
+ * `base..head` itself, so range syntax in a single argument is rejected.
56
+ */
57
+ const GIT_REVISION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/^~-]*$/;
58
+ const BINARY_SNIFF_BYTES = 8 * 1024;
59
+ const TOOL_ERROR_PREFIX = 'Error: ';
60
+ /**
61
+ * Resolves `relativePath` against `rootDir` and throws when the argument is
62
+ * absolute or the resolution escapes the root (`..` etc.). Exported for
63
+ * direct testing.
64
+ */
65
+ export function resolveJailedPath(rootDir, relativePath) {
66
+ if (path.isAbsolute(relativePath)) {
67
+ throw new Error(`absolute paths are not allowed: ${relativePath}`);
68
+ }
69
+ const root = path.resolve(rootDir);
70
+ const resolved = path.resolve(root, relativePath);
71
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
72
+ throw new Error(`path escapes the working directory: ${relativePath}`);
73
+ }
74
+ return resolved;
75
+ }
76
+ function assertInsideRealRoot(realPath, realRoot, original) {
77
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
78
+ throw new Error(`path escapes the working directory via symlink: ${original}`);
79
+ }
80
+ }
81
+ /**
82
+ * Resolves an existing path for reading/listing/grepping/editing: lexical
83
+ * jail check first, then a realpath comparison against the real root so
84
+ * symlinks inside the jail cannot reach outside it.
85
+ */
86
+ export async function resolveExistingJailedPath(rootDir, relativePath) {
87
+ const lexical = resolveJailedPath(rootDir, relativePath);
88
+ const realRoot = await fs.realpath(path.resolve(rootDir));
89
+ const real = await fs.realpath(lexical);
90
+ assertInsideRealRoot(real, realRoot, relativePath);
91
+ return real;
92
+ }
93
+ /**
94
+ * Resolves a path for writing: lexical jail check, then (a) the deepest
95
+ * existing ancestor of the target's parent must realpath inside the real
96
+ * root (catches symlinked intermediate directories), and (b) an existing
97
+ * final-path symlink must also realpath inside the real root (never write
98
+ * through an escaping symlink).
99
+ */
100
+ export async function resolveWritableJailedPath(rootDir, relativePath) {
101
+ const lexical = resolveJailedPath(rootDir, relativePath);
102
+ const realRoot = await fs.realpath(path.resolve(rootDir));
103
+ let ancestor = path.dirname(lexical);
104
+ let realAncestor = null;
105
+ while (realAncestor === null) {
106
+ try {
107
+ realAncestor = await fs.realpath(ancestor);
108
+ }
109
+ catch (error) {
110
+ const code = error.code;
111
+ const parent = path.dirname(ancestor);
112
+ if (code === 'ENOENT' && parent !== ancestor) {
113
+ ancestor = parent;
114
+ continue;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ assertInsideRealRoot(realAncestor, realRoot, relativePath);
120
+ try {
121
+ const stats = await fs.lstat(lexical);
122
+ if (stats.isSymbolicLink()) {
123
+ const real = await fs.realpath(lexical);
124
+ assertInsideRealRoot(real, realRoot, relativePath);
125
+ return real;
126
+ }
127
+ }
128
+ catch (error) {
129
+ if (error.code !== 'ENOENT') {
130
+ throw error;
131
+ }
132
+ }
133
+ return lexical;
134
+ }
135
+ /** True when a tool result string is an error-as-result produced by this layer. */
136
+ export function isToolErrorResult(result) {
137
+ return result.startsWith(TOOL_ERROR_PREFIX);
138
+ }
139
+ /** Bounds a tool result to 16 KiB, appending an explicit truncation marker. */
140
+ export function truncateResult(text) {
141
+ const totalBytes = Buffer.byteLength(text, 'utf8');
142
+ if (totalBytes <= RESULT_BYTE_LIMIT) {
143
+ return text;
144
+ }
145
+ const shown = Buffer.from(text, 'utf8')
146
+ .subarray(0, RESULT_BYTE_LIMIT)
147
+ .toString('utf8');
148
+ return `${shown}[truncated: showing ${RESULT_BYTE_LIMIT} of ${totalBytes} bytes]`;
149
+ }
150
+ /** Runs a tool body, converting thrown errors into bounded error results. */
151
+ async function safeResult(run) {
152
+ try {
153
+ return truncateResult(await run());
154
+ }
155
+ catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ return truncateResult(`${TOOL_ERROR_PREFIX}${message}`);
158
+ }
159
+ }
160
+ async function readFileBody(rootDir, relativePath) {
161
+ const resolved = await resolveExistingJailedPath(rootDir, relativePath);
162
+ const stats = await fs.stat(resolved);
163
+ if (!stats.isFile()) {
164
+ throw new Error(`not a regular file: ${relativePath}`);
165
+ }
166
+ if (stats.size > READ_FILE_BYTE_LIMIT) {
167
+ throw new Error(`file too large to read: ${relativePath} is ${stats.size} bytes (limit ${READ_FILE_BYTE_LIMIT})`);
168
+ }
169
+ const content = await fs.readFile(resolved);
170
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
171
+ throw new Error(`refusing to read binary file (NUL byte detected): ${relativePath}`);
172
+ }
173
+ return content.toString('utf8');
174
+ }
175
+ async function writeFileBody(rootDir, relativePath, content, allowedWritePaths) {
176
+ const resolved = await resolveWritableJailedPath(rootDir, relativePath);
177
+ await assertWriteAllowed(rootDir, resolved, allowedWritePaths);
178
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
179
+ await fs.writeFile(resolved, content, 'utf8');
180
+ return `wrote ${Buffer.byteLength(content, 'utf8')} bytes to ${relativePath}`;
181
+ }
182
+ function countOccurrences(haystack, needle) {
183
+ if (needle.length === 0) {
184
+ return 0;
185
+ }
186
+ let count = 0;
187
+ let index = haystack.indexOf(needle);
188
+ while (index !== -1) {
189
+ count += 1;
190
+ index = haystack.indexOf(needle, index + needle.length);
191
+ }
192
+ return count;
193
+ }
194
+ async function editFileBody(rootDir, relativePath, oldText, newText, allowedWritePaths) {
195
+ const resolved = await resolveExistingJailedPath(rootDir, relativePath);
196
+ await assertWriteAllowed(rootDir, resolved, allowedWritePaths);
197
+ const content = await fs.readFile(resolved, 'utf8');
198
+ const matches = countOccurrences(content, oldText);
199
+ if (matches === 0) {
200
+ throw new Error(`no match for the old string in ${relativePath}; nothing was changed`);
201
+ }
202
+ if (matches > 1) {
203
+ throw new Error(`the old string matches ${matches} times in ${relativePath}; it must match exactly once. Include more surrounding context to make it unique`);
204
+ }
205
+ const updated = content.replace(oldText, newText);
206
+ await fs.writeFile(resolved, updated, 'utf8');
207
+ return `edited ${relativePath}: replaced 1 occurrence`;
208
+ }
209
+ async function assertWriteAllowed(rootDir, resolvedPath, allowedWritePaths) {
210
+ if (allowedWritePaths === undefined) {
211
+ return;
212
+ }
213
+ const resolvedReal = await realpathOrResolved(resolvedPath);
214
+ for (const allowedPath of allowedWritePaths) {
215
+ const allowedResolved = path.isAbsolute(allowedPath)
216
+ ? path.resolve(allowedPath)
217
+ : resolveJailedPath(rootDir, allowedPath);
218
+ const allowedReal = await realpathOrResolved(allowedResolved);
219
+ if (resolvedReal === allowedReal) {
220
+ return;
221
+ }
222
+ }
223
+ const root = await realpathOrResolved(path.resolve(rootDir));
224
+ const displayPath = path.relative(root, resolvedReal) || '.';
225
+ const allowedDisplay = allowedWritePaths.length > 0 ? allowedWritePaths.join(', ') : '(none)';
226
+ throw new Error(`write access denied for ${displayPath}; this phase may only write: ${allowedDisplay}`);
227
+ }
228
+ async function realpathOrResolved(candidatePath) {
229
+ try {
230
+ return await fs.realpath(candidatePath);
231
+ }
232
+ catch (error) {
233
+ if (error.code !== 'ENOENT') {
234
+ throw error;
235
+ }
236
+ }
237
+ let ancestor = path.dirname(path.resolve(candidatePath));
238
+ const missingParts = [path.basename(candidatePath)];
239
+ while (true) {
240
+ try {
241
+ return path.join(await fs.realpath(ancestor), ...missingParts);
242
+ }
243
+ catch (error) {
244
+ if (error.code !== 'ENOENT') {
245
+ throw error;
246
+ }
247
+ const parent = path.dirname(ancestor);
248
+ if (parent === ancestor) {
249
+ return path.resolve(candidatePath);
250
+ }
251
+ missingParts.unshift(path.basename(ancestor));
252
+ ancestor = parent;
253
+ }
254
+ }
255
+ }
256
+ async function listDirBody(rootDir, relativePath) {
257
+ const resolved = await resolveExistingJailedPath(rootDir, relativePath);
258
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
259
+ if (entries.length === 0) {
260
+ return '(empty directory)';
261
+ }
262
+ return entries
263
+ .sort((a, b) => a.name.localeCompare(b.name))
264
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
265
+ .join('\n');
266
+ }
267
+ async function collectFiles(dir, files) {
268
+ const entries = await fs.readdir(dir, { withFileTypes: true });
269
+ entries.sort((a, b) => a.name.localeCompare(b.name));
270
+ for (const entry of entries) {
271
+ if (entry.isDirectory()) {
272
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
273
+ continue;
274
+ }
275
+ await collectFiles(path.join(dir, entry.name), files);
276
+ }
277
+ else if (entry.isFile()) {
278
+ files.push(path.join(dir, entry.name));
279
+ }
280
+ }
281
+ }
282
+ async function grepBody(rootDir, pattern, relativePath) {
283
+ const root = await fs.realpath(path.resolve(rootDir));
284
+ const start = relativePath === undefined ? root : await resolveExistingJailedPath(rootDir, relativePath);
285
+ let regex;
286
+ try {
287
+ regex = new RegExp(pattern);
288
+ }
289
+ catch (error) {
290
+ throw new Error(`invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
291
+ }
292
+ const files = [];
293
+ const startStats = await fs.stat(start);
294
+ if (startStats.isDirectory()) {
295
+ await collectFiles(start, files);
296
+ }
297
+ else {
298
+ files.push(start);
299
+ }
300
+ const lines = [];
301
+ let capped = false;
302
+ outer: for (const file of files) {
303
+ const content = await fs.readFile(file);
304
+ if (content.subarray(0, BINARY_SNIFF_BYTES).includes(0)) {
305
+ continue; // skip binary files
306
+ }
307
+ const rel = path.relative(root, file) || path.basename(file);
308
+ const fileLines = content.toString('utf8').split('\n');
309
+ for (let i = 0; i < fileLines.length; i += 1) {
310
+ if (regex.test(fileLines[i])) {
311
+ lines.push(`${rel}:${i + 1}:${fileLines[i]}`);
312
+ if (lines.length >= GREP_MAX_MATCHING_LINES) {
313
+ capped = true;
314
+ break outer;
315
+ }
316
+ }
317
+ }
318
+ }
319
+ if (lines.length === 0) {
320
+ return `no matches for /${pattern}/`;
321
+ }
322
+ if (capped) {
323
+ lines.push(`[grep output capped at ${GREP_MAX_MATCHING_LINES} matching lines]`);
324
+ }
325
+ return lines.join('\n');
326
+ }
327
+ function assertGitRevision(value, label) {
328
+ if (!GIT_REVISION_PATTERN.test(value) || value.includes('..')) {
329
+ throw new Error(`invalid git ${label} revision: ${JSON.stringify(value)}. Pass a single commit SHA, branch, tag, or HEAD-relative revision; the tool composes the base..head range itself.`);
330
+ }
331
+ return value;
332
+ }
333
+ /**
334
+ * Read-only committed-range diff: a fixed `git diff` argv (never a shell
335
+ * string) against the repository at `rootDir`. The optional path filter is
336
+ * jailed lexically only — a deleted file does not exist on disk, so the
337
+ * realpath checks used by the read tools would wrongly reject exactly the
338
+ * paths this tool exists to reveal; git resolves the filter against
339
+ * repository-relative paths, never the wider filesystem. The no-command
340
+ * guarantee is enforced in code, not configuration: `--no-ext-diff` and
341
+ * `--no-textconv` suppress operator-configured diff drivers, `GIT_`
342
+ * environment overrides (GIT_EXTERNAL_DIFF, GIT_DIR, GIT_WORK_TREE, ...)
343
+ * are stripped from the child environment, pathspec magic (`:`-prefixed
344
+ * filters) is rejected, and the `--` separator is always emitted so a
345
+ * revision can never be reparsed as a path.
346
+ */
347
+ function gitDiffBody(rootDir, base, head, relativePath, stat) {
348
+ const range = `${assertGitRevision(base, 'base')}..${assertGitRevision(head, 'head')}`;
349
+ const gitArgs = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--find-renames'];
350
+ if (stat) {
351
+ gitArgs.push('--stat');
352
+ }
353
+ gitArgs.push(range, '--');
354
+ if (relativePath !== undefined) {
355
+ if (relativePath.startsWith(':')) {
356
+ throw new Error(`pathspec magic is not allowed: ${relativePath}`);
357
+ }
358
+ resolveJailedPath(rootDir, relativePath);
359
+ gitArgs.push(relativePath);
360
+ }
361
+ const childEnv = {};
362
+ for (const [key, value] of Object.entries(process.env)) {
363
+ if (!key.startsWith('GIT_')) {
364
+ childEnv[key] = value;
365
+ }
366
+ }
367
+ return new Promise((resolve, reject) => {
368
+ execFile('git', gitArgs, {
369
+ cwd: path.resolve(rootDir),
370
+ env: childEnv,
371
+ timeout: GIT_DIFF_TIMEOUT_MS,
372
+ maxBuffer: 10 * 1024 * 1024,
373
+ }, (error, stdout, stderr) => {
374
+ if (error) {
375
+ const detail = stderr.trim() || error.message;
376
+ reject(new Error(`git diff ${range} failed: ${detail}`));
377
+ return;
378
+ }
379
+ if (stdout.length === 0) {
380
+ resolve(`(no differences for ${range}${relativePath === undefined ? '' : ` -- ${relativePath}`})`);
381
+ return;
382
+ }
383
+ resolve(stdout);
384
+ });
385
+ });
386
+ }
387
+ function runBody(rootDir, command, timeoutMs) {
388
+ return new Promise((resolve) => {
389
+ exec(command, { cwd: path.resolve(rootDir), timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
390
+ const timedOut = Boolean(error && error.killed);
391
+ let exitCode;
392
+ if (error) {
393
+ exitCode = typeof error.code === 'number' ? error.code : null;
394
+ }
395
+ else {
396
+ exitCode = 0;
397
+ }
398
+ const parts = [];
399
+ if (timedOut) {
400
+ parts.push(`command timed out after ${timeoutMs}ms and was killed`);
401
+ }
402
+ parts.push(`exit code: ${exitCode === null ? 'none (process killed before exiting)' : exitCode}`);
403
+ parts.push(`stdout:\n${stdout}`);
404
+ parts.push(`stderr:\n${stderr}`);
405
+ resolve({ text: parts.join('\n'), exitCode });
406
+ });
407
+ });
408
+ }
409
+ /** Jail-relative display path for `file_changed` events. */
410
+ function jailRelativePath(rootDir, relativePath) {
411
+ return path.relative(path.resolve(rootDir), resolveJailedPath(rootDir, relativePath));
412
+ }
413
+ function buildTools(rootDir, options) {
414
+ const runTimeoutMs = options?.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
415
+ const allowedWritePaths = options?.allowedWritePaths;
416
+ const allowRun = options?.allowRun ?? true;
417
+ const hook = options?.emitToolEvent;
418
+ /** Telemetry must never break tool execution; emitter failures are swallowed. */
419
+ const emit = (event) => {
420
+ if (!hook) {
421
+ return;
422
+ }
423
+ try {
424
+ hook(event);
425
+ }
426
+ catch {
427
+ // Errors-as-results is the loop contract; a throwing emitter must not
428
+ // convert a tool result into a thrown error or hide a real result.
429
+ }
430
+ };
431
+ /**
432
+ * Wraps a tool body with the shared event grammar: `tool_started` on
433
+ * entry, then on completion either an `isError: true` `tool_progress`
434
+ * carrying the error result (per-tool error attribution) or an
435
+ * `isError: false` `tool_progress` carrying a short success summary.
436
+ */
437
+ const instrumented = async (toolName, body, summarize, onSuccess) => {
438
+ emit({ type: 'tool_started', toolName });
439
+ const result = await safeResult(body);
440
+ if (isToolErrorResult(result)) {
441
+ emit({ type: 'tool_progress', toolName, message: result, isError: true });
442
+ return result;
443
+ }
444
+ onSuccess?.(result);
445
+ emit({ type: 'tool_progress', toolName, message: summarize(result), isError: false });
446
+ return result;
447
+ };
448
+ return {
449
+ read_file: tool({
450
+ description: 'Read a UTF-8 text file inside the working directory. Rejects files larger than 256 KiB and binary files.',
451
+ inputSchema: z.object({
452
+ path: z.string().describe('File path relative to the working directory'),
453
+ }),
454
+ execute: (input) => instrumented('read_file', () => readFileBody(rootDir, input.path), (result) => `read ${input.path} (${Buffer.byteLength(result, 'utf8')} bytes)`),
455
+ }),
456
+ write_file: tool({
457
+ description: 'Write a whole file inside the working directory, creating parent directories as needed. Overwrites any existing content.',
458
+ inputSchema: z.object({
459
+ path: z.string().describe('File path relative to the working directory'),
460
+ content: z.string().describe('Full new file content'),
461
+ }),
462
+ execute: (input) => instrumented('write_file', () => writeFileBody(rootDir, input.path, input.content, allowedWritePaths), (result) => result, () => emit({
463
+ type: 'file_changed',
464
+ toolName: 'write_file',
465
+ files: [jailRelativePath(rootDir, input.path)],
466
+ })),
467
+ }),
468
+ edit_file: tool({
469
+ description: 'Replace one exact occurrence of `old` with `new` in a file. `old` must match the file content exactly once; include enough surrounding context to make it unique.',
470
+ inputSchema: z.object({
471
+ path: z.string().describe('File path relative to the working directory'),
472
+ old: z.string().describe('Exact existing text to replace (must match exactly once)'),
473
+ new: z.string().describe('Replacement text'),
474
+ }),
475
+ execute: (input) => instrumented('edit_file', () => editFileBody(rootDir, input.path, input.old, input.new, allowedWritePaths), (result) => result, () => emit({
476
+ type: 'file_changed',
477
+ toolName: 'edit_file',
478
+ files: [jailRelativePath(rootDir, input.path)],
479
+ })),
480
+ }),
481
+ list_dir: tool({
482
+ description: 'List the entries of a directory inside the working directory (non-recursive). Directories are marked with a trailing slash.',
483
+ inputSchema: z.object({
484
+ path: z
485
+ .string()
486
+ .describe('Directory path relative to the working directory; use "." for the root'),
487
+ }),
488
+ execute: (input) => instrumented('list_dir', () => listDirBody(rootDir, input.path), (result) => `listed ${input.path} (${result === '(empty directory)' ? 0 : result.split('\n').length} entries)`),
489
+ }),
490
+ git_diff: tool({
491
+ description: 'Show the committed git diff between two commits of the working-directory repository (read-only). This is the only way to see exactly what a commit range changed, including deletions and renames that reading head-state files cannot reveal. Output is truncated to 16 KiB: call with stat:true first for the changed-file overview of a large range, then request per-path diffs.',
492
+ inputSchema: z.object({
493
+ base: z.string().describe('Base revision of the range (commit SHA, branch, tag, or HEAD-relative revision)'),
494
+ head: z.string().describe('Head revision of the range (commit SHA, branch, tag, or HEAD-relative revision)'),
495
+ path: z
496
+ .string()
497
+ .optional()
498
+ .describe('Limit the diff to one file or directory, relative to the working directory; deleted paths are valid here'),
499
+ stat: z
500
+ .boolean()
501
+ .optional()
502
+ .describe('When true, return the diffstat summary instead of the patch — use this first on large ranges'),
503
+ }),
504
+ execute: (input) => instrumented('git_diff', () => gitDiffBody(rootDir, input.base, input.head, input.path, input.stat), (result) => `git_diff ${input.base}..${input.head}${input.path === undefined ? '' : ` -- ${input.path}`}${input.stat ? ' --stat' : ''} (${Buffer.byteLength(result, 'utf8')} bytes)`),
505
+ }),
506
+ grep: tool({
507
+ description: 'Search file contents recursively with a JavaScript regular expression, skipping node_modules and dotted directories. Output is capped at 200 matching lines.',
508
+ inputSchema: z.object({
509
+ pattern: z.string().describe('JavaScript regular expression source'),
510
+ path: z
511
+ .string()
512
+ .optional()
513
+ .describe('Directory or file to search, relative to the working directory (default: root)'),
514
+ }),
515
+ execute: (input) => instrumented('grep', () => grepBody(rootDir, input.pattern, input.path), (result) => result.startsWith('no matches for ')
516
+ ? result
517
+ : `grep /${input.pattern}/: ${result.split('\n').filter((line) => !line.startsWith('[grep output capped'))
518
+ .length} matching line(s)`),
519
+ }),
520
+ run: tool({
521
+ description: `Run a shell command in the working directory with a ${runTimeoutMs}ms timeout. Returns the exit code, stdout, and stderr.`,
522
+ inputSchema: z.object({
523
+ command: z.string().describe('Shell command to execute'),
524
+ }),
525
+ execute: async (input) => {
526
+ emit({ type: 'tool_started', toolName: 'run' });
527
+ if (!allowRun) {
528
+ const result = truncateResult(`${TOOL_ERROR_PREFIX}shell commands are disabled in this phase; use read_file, list_dir, grep, write_file, or edit_file within the allowed write paths`);
529
+ emit({ type: 'tool_progress', toolName: 'run', message: result, isError: true });
530
+ return result;
531
+ }
532
+ let outcome;
533
+ try {
534
+ outcome = await runBody(rootDir, input.command, runTimeoutMs);
535
+ }
536
+ catch (error) {
537
+ // Unexpected infrastructure failure (exec callback contract broke):
538
+ // keep the errors-as-results posture and attribute the error.
539
+ const message = error instanceof Error ? error.message : String(error);
540
+ const result = truncateResult(`${TOOL_ERROR_PREFIX}${message}`);
541
+ emit({ type: 'tool_progress', toolName: 'run', message: result, isError: true });
542
+ return result;
543
+ }
544
+ const result = truncateResult(outcome.text);
545
+ emit({
546
+ type: 'command_completed',
547
+ toolName: 'run',
548
+ command: input.command,
549
+ exitCode: outcome.exitCode,
550
+ output: result,
551
+ cwd: path.resolve(rootDir),
552
+ });
553
+ return result;
554
+ },
555
+ }),
556
+ };
557
+ }
558
+ /**
559
+ * The full coder toolset: the six writer tools (`read_file`, `write_file`,
560
+ * `edit_file`, `list_dir`, `grep`, `run`) jailed to `rootDir`. The coder has
561
+ * shell access through `run`, so it does not get the reviewer-oriented
562
+ * `git_diff` query tool — coder behavior is byte-stable across Phase 2.
563
+ */
564
+ export function createCoderToolset(rootDir, options) {
565
+ const { read_file, write_file, edit_file, list_dir, grep, run } = buildTools(rootDir, options);
566
+ return { read_file, write_file, edit_file, list_dir, grep, run };
567
+ }
568
+ /**
569
+ * The plan-author toolset: read/inspect tools plus bounded file writers, with
570
+ * no shell tool exposed. Plan phases pass `allowedWritePaths` for the active
571
+ * plan artifact, preventing implementation edits before execution.
572
+ */
573
+ export function createPlanAuthorToolset(rootDir, options) {
574
+ const { read_file, write_file, edit_file, list_dir, grep } = buildTools(rootDir, options);
575
+ return { read_file, write_file, edit_file, list_dir, grep };
576
+ }
577
+ /**
578
+ * The read-only toolset: exactly `read_file`, `list_dir`, `grep`, and
579
+ * `git_diff` — no write or shell access. `git_diff` gives reviewers actual
580
+ * commit-range visibility (deletions included) without shell access; see the
581
+ * module doc.
582
+ */
583
+ export function createReadOnlyToolset(rootDir, options) {
584
+ const { read_file, list_dir, grep, git_diff } = buildTools(rootDir, options);
585
+ return { read_file, list_dir, grep, git_diff };
586
+ }