@ttfw/envoi 1.0.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 (283) hide show
  1. package/README.md +238 -0
  2. package/dist/commands/app.d.ts +2 -0
  3. package/dist/commands/app.d.ts.map +1 -0
  4. package/dist/commands/app.js +31 -0
  5. package/dist/commands/app.js.map +1 -0
  6. package/dist/commands/autonomy.d.ts +6 -0
  7. package/dist/commands/autonomy.d.ts.map +1 -0
  8. package/dist/commands/autonomy.js +89 -0
  9. package/dist/commands/autonomy.js.map +1 -0
  10. package/dist/commands/builder.d.ts +13 -0
  11. package/dist/commands/builder.d.ts.map +1 -0
  12. package/dist/commands/builder.js +142 -0
  13. package/dist/commands/builder.js.map +1 -0
  14. package/dist/commands/idea.d.ts +12 -0
  15. package/dist/commands/idea.d.ts.map +1 -0
  16. package/dist/commands/idea.js +79 -0
  17. package/dist/commands/idea.js.map +1 -0
  18. package/dist/commands/init.d.ts +18 -0
  19. package/dist/commands/init.d.ts.map +1 -0
  20. package/dist/commands/init.js +423 -0
  21. package/dist/commands/init.js.map +1 -0
  22. package/dist/commands/mode.d.ts +13 -0
  23. package/dist/commands/mode.d.ts.map +1 -0
  24. package/dist/commands/mode.js +96 -0
  25. package/dist/commands/mode.js.map +1 -0
  26. package/dist/commands/onboard.d.ts +37 -0
  27. package/dist/commands/onboard.d.ts.map +1 -0
  28. package/dist/commands/onboard.js +743 -0
  29. package/dist/commands/onboard.js.map +1 -0
  30. package/dist/commands/pr-note.d.ts +8 -0
  31. package/dist/commands/pr-note.d.ts.map +1 -0
  32. package/dist/commands/pr-note.js +27 -0
  33. package/dist/commands/pr-note.js.map +1 -0
  34. package/dist/commands/undo.d.ts +7 -0
  35. package/dist/commands/undo.d.ts.map +1 -0
  36. package/dist/commands/undo.js +59 -0
  37. package/dist/commands/undo.js.map +1 -0
  38. package/dist/commands/update.d.ts +24 -0
  39. package/dist/commands/update.d.ts.map +1 -0
  40. package/dist/commands/update.js +248 -0
  41. package/dist/commands/update.js.map +1 -0
  42. package/dist/constants/report_codes.d.ts +29 -0
  43. package/dist/constants/report_codes.d.ts.map +1 -0
  44. package/dist/constants/report_codes.js +69 -0
  45. package/dist/constants/report_codes.js.map +1 -0
  46. package/dist/index.d.ts +3 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +675 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/lib/autonomy.d.ts +16 -0
  51. package/dist/lib/autonomy.d.ts.map +1 -0
  52. package/dist/lib/autonomy.js +38 -0
  53. package/dist/lib/autonomy.js.map +1 -0
  54. package/dist/lib/blocked.d.ts +87 -0
  55. package/dist/lib/blocked.d.ts.map +1 -0
  56. package/dist/lib/blocked.js +134 -0
  57. package/dist/lib/blocked.js.map +1 -0
  58. package/dist/lib/branding.d.ts +13 -0
  59. package/dist/lib/branding.d.ts.map +1 -0
  60. package/dist/lib/branding.js +19 -0
  61. package/dist/lib/branding.js.map +1 -0
  62. package/dist/lib/claude.d.ts +42 -0
  63. package/dist/lib/claude.d.ts.map +1 -0
  64. package/dist/lib/claude.js +291 -0
  65. package/dist/lib/claude.js.map +1 -0
  66. package/dist/lib/config.d.ts +71 -0
  67. package/dist/lib/config.d.ts.map +1 -0
  68. package/dist/lib/config.js +410 -0
  69. package/dist/lib/config.js.map +1 -0
  70. package/dist/lib/diff.d.ts +150 -0
  71. package/dist/lib/diff.d.ts.map +1 -0
  72. package/dist/lib/diff.js +257 -0
  73. package/dist/lib/diff.js.map +1 -0
  74. package/dist/lib/doctor.d.ts +67 -0
  75. package/dist/lib/doctor.d.ts.map +1 -0
  76. package/dist/lib/doctor.js +211 -0
  77. package/dist/lib/doctor.js.map +1 -0
  78. package/dist/lib/fingerprint.d.ts +27 -0
  79. package/dist/lib/fingerprint.d.ts.map +1 -0
  80. package/dist/lib/fingerprint.js +116 -0
  81. package/dist/lib/fingerprint.js.map +1 -0
  82. package/dist/lib/fs.d.ts +93 -0
  83. package/dist/lib/fs.d.ts.map +1 -0
  84. package/dist/lib/fs.js +179 -0
  85. package/dist/lib/fs.js.map +1 -0
  86. package/dist/lib/git.d.ts +177 -0
  87. package/dist/lib/git.d.ts.map +1 -0
  88. package/dist/lib/git.js +355 -0
  89. package/dist/lib/git.js.map +1 -0
  90. package/dist/lib/git_branching.d.ts +84 -0
  91. package/dist/lib/git_branching.d.ts.map +1 -0
  92. package/dist/lib/git_branching.js +327 -0
  93. package/dist/lib/git_branching.js.map +1 -0
  94. package/dist/lib/gitignore.d.ts +26 -0
  95. package/dist/lib/gitignore.d.ts.map +1 -0
  96. package/dist/lib/gitignore.js +119 -0
  97. package/dist/lib/gitignore.js.map +1 -0
  98. package/dist/lib/guardrails.d.ts +232 -0
  99. package/dist/lib/guardrails.d.ts.map +1 -0
  100. package/dist/lib/guardrails.js +323 -0
  101. package/dist/lib/guardrails.js.map +1 -0
  102. package/dist/lib/history.d.ts +110 -0
  103. package/dist/lib/history.d.ts.map +1 -0
  104. package/dist/lib/history.js +236 -0
  105. package/dist/lib/history.js.map +1 -0
  106. package/dist/lib/index.d.ts +29 -0
  107. package/dist/lib/index.d.ts.map +1 -0
  108. package/dist/lib/index.js +29 -0
  109. package/dist/lib/index.js.map +1 -0
  110. package/dist/lib/json-extract.d.ts +42 -0
  111. package/dist/lib/json-extract.d.ts.map +1 -0
  112. package/dist/lib/json-extract.js +201 -0
  113. package/dist/lib/json-extract.js.map +1 -0
  114. package/dist/lib/judge.d.ts +237 -0
  115. package/dist/lib/judge.d.ts.map +1 -0
  116. package/dist/lib/judge.js +501 -0
  117. package/dist/lib/judge.js.map +1 -0
  118. package/dist/lib/lock.d.ts +79 -0
  119. package/dist/lib/lock.d.ts.map +1 -0
  120. package/dist/lib/lock.js +254 -0
  121. package/dist/lib/lock.js.map +1 -0
  122. package/dist/lib/migration.d.ts +9 -0
  123. package/dist/lib/migration.d.ts.map +1 -0
  124. package/dist/lib/migration.js +74 -0
  125. package/dist/lib/migration.js.map +1 -0
  126. package/dist/lib/paths.d.ts +18 -0
  127. package/dist/lib/paths.d.ts.map +1 -0
  128. package/dist/lib/paths.js +27 -0
  129. package/dist/lib/paths.js.map +1 -0
  130. package/dist/lib/preflight.d.ts +33 -0
  131. package/dist/lib/preflight.d.ts.map +1 -0
  132. package/dist/lib/preflight.js +177 -0
  133. package/dist/lib/preflight.js.map +1 -0
  134. package/dist/lib/prompt_budget.d.ts +18 -0
  135. package/dist/lib/prompt_budget.d.ts.map +1 -0
  136. package/dist/lib/prompt_budget.js +36 -0
  137. package/dist/lib/prompt_budget.js.map +1 -0
  138. package/dist/lib/report.d.ts +102 -0
  139. package/dist/lib/report.d.ts.map +1 -0
  140. package/dist/lib/report.js +347 -0
  141. package/dist/lib/report.js.map +1 -0
  142. package/dist/lib/reviewer-flow.d.ts +80 -0
  143. package/dist/lib/reviewer-flow.d.ts.map +1 -0
  144. package/dist/lib/reviewer-flow.js +138 -0
  145. package/dist/lib/reviewer-flow.js.map +1 -0
  146. package/dist/lib/reviewer.d.ts +53 -0
  147. package/dist/lib/reviewer.d.ts.map +1 -0
  148. package/dist/lib/reviewer.js +199 -0
  149. package/dist/lib/reviewer.js.map +1 -0
  150. package/dist/lib/risk.d.ts +127 -0
  151. package/dist/lib/risk.d.ts.map +1 -0
  152. package/dist/lib/risk.js +192 -0
  153. package/dist/lib/risk.js.map +1 -0
  154. package/dist/lib/rollback.d.ts +143 -0
  155. package/dist/lib/rollback.d.ts.map +1 -0
  156. package/dist/lib/rollback.js +244 -0
  157. package/dist/lib/rollback.js.map +1 -0
  158. package/dist/lib/schema.d.ts +47 -0
  159. package/dist/lib/schema.d.ts.map +1 -0
  160. package/dist/lib/schema.js +91 -0
  161. package/dist/lib/schema.js.map +1 -0
  162. package/dist/lib/scope.d.ts +89 -0
  163. package/dist/lib/scope.d.ts.map +1 -0
  164. package/dist/lib/scope.js +135 -0
  165. package/dist/lib/scope.js.map +1 -0
  166. package/dist/lib/self_update.d.ts +13 -0
  167. package/dist/lib/self_update.d.ts.map +1 -0
  168. package/dist/lib/self_update.js +172 -0
  169. package/dist/lib/self_update.js.map +1 -0
  170. package/dist/lib/state.d.ts +143 -0
  171. package/dist/lib/state.d.ts.map +1 -0
  172. package/dist/lib/state.js +258 -0
  173. package/dist/lib/state.js.map +1 -0
  174. package/dist/lib/tick.d.ts +310 -0
  175. package/dist/lib/tick.d.ts.map +1 -0
  176. package/dist/lib/tick.js +424 -0
  177. package/dist/lib/tick.js.map +1 -0
  178. package/dist/lib/transport.d.ts +145 -0
  179. package/dist/lib/transport.d.ts.map +1 -0
  180. package/dist/lib/transport.js +237 -0
  181. package/dist/lib/transport.js.map +1 -0
  182. package/dist/lib/verdict_labels.d.ts +5 -0
  183. package/dist/lib/verdict_labels.d.ts.map +1 -0
  184. package/dist/lib/verdict_labels.js +25 -0
  185. package/dist/lib/verdict_labels.js.map +1 -0
  186. package/dist/lib/verify-safety.d.ts +63 -0
  187. package/dist/lib/verify-safety.d.ts.map +1 -0
  188. package/dist/lib/verify-safety.js +123 -0
  189. package/dist/lib/verify-safety.js.map +1 -0
  190. package/dist/lib/verify.d.ts +139 -0
  191. package/dist/lib/verify.d.ts.map +1 -0
  192. package/dist/lib/verify.js +311 -0
  193. package/dist/lib/verify.js.map +1 -0
  194. package/dist/lib/workspace_state.d.ts +79 -0
  195. package/dist/lib/workspace_state.d.ts.map +1 -0
  196. package/dist/lib/workspace_state.js +283 -0
  197. package/dist/lib/workspace_state.js.map +1 -0
  198. package/dist/runner/builder.d.ts +58 -0
  199. package/dist/runner/builder.d.ts.map +1 -0
  200. package/dist/runner/builder.js +775 -0
  201. package/dist/runner/builder.js.map +1 -0
  202. package/dist/runner/builder_parse.d.ts +37 -0
  203. package/dist/runner/builder_parse.d.ts.map +1 -0
  204. package/dist/runner/builder_parse.js +76 -0
  205. package/dist/runner/builder_parse.js.map +1 -0
  206. package/dist/runner/index.d.ts +9 -0
  207. package/dist/runner/index.d.ts.map +1 -0
  208. package/dist/runner/index.js +7 -0
  209. package/dist/runner/index.js.map +1 -0
  210. package/dist/runner/loop.d.ts +51 -0
  211. package/dist/runner/loop.d.ts.map +1 -0
  212. package/dist/runner/loop.js +221 -0
  213. package/dist/runner/loop.js.map +1 -0
  214. package/dist/runner/orchestrator.d.ts +67 -0
  215. package/dist/runner/orchestrator.d.ts.map +1 -0
  216. package/dist/runner/orchestrator.js +376 -0
  217. package/dist/runner/orchestrator.js.map +1 -0
  218. package/dist/runner/tick.d.ts +10 -0
  219. package/dist/runner/tick.d.ts.map +1 -0
  220. package/dist/runner/tick.js +1639 -0
  221. package/dist/runner/tick.js.map +1 -0
  222. package/dist/types/blocked.d.ts +52 -0
  223. package/dist/types/blocked.d.ts.map +1 -0
  224. package/dist/types/blocked.js +8 -0
  225. package/dist/types/blocked.js.map +1 -0
  226. package/dist/types/builder.d.ts +25 -0
  227. package/dist/types/builder.d.ts.map +1 -0
  228. package/dist/types/builder.js +7 -0
  229. package/dist/types/builder.js.map +1 -0
  230. package/dist/types/claude.d.ts +86 -0
  231. package/dist/types/claude.d.ts.map +1 -0
  232. package/dist/types/claude.js +48 -0
  233. package/dist/types/claude.js.map +1 -0
  234. package/dist/types/config.d.ts +384 -0
  235. package/dist/types/config.d.ts.map +1 -0
  236. package/dist/types/config.js +7 -0
  237. package/dist/types/config.js.map +1 -0
  238. package/dist/types/index.d.ts +18 -0
  239. package/dist/types/index.d.ts.map +1 -0
  240. package/dist/types/index.js +8 -0
  241. package/dist/types/index.js.map +1 -0
  242. package/dist/types/lock.d.ts +21 -0
  243. package/dist/types/lock.d.ts.map +1 -0
  244. package/dist/types/lock.js +8 -0
  245. package/dist/types/lock.js.map +1 -0
  246. package/dist/types/preflight.d.ts +49 -0
  247. package/dist/types/preflight.d.ts.map +1 -0
  248. package/dist/types/preflight.js +8 -0
  249. package/dist/types/preflight.js.map +1 -0
  250. package/dist/types/report.d.ts +161 -0
  251. package/dist/types/report.d.ts.map +1 -0
  252. package/dist/types/report.js +8 -0
  253. package/dist/types/report.js.map +1 -0
  254. package/dist/types/reviewer.d.ts +66 -0
  255. package/dist/types/reviewer.d.ts.map +1 -0
  256. package/dist/types/reviewer.js +5 -0
  257. package/dist/types/reviewer.js.map +1 -0
  258. package/dist/types/state.d.ts +124 -0
  259. package/dist/types/state.d.ts.map +1 -0
  260. package/dist/types/state.js +20 -0
  261. package/dist/types/state.js.map +1 -0
  262. package/dist/types/task.d.ts +117 -0
  263. package/dist/types/task.d.ts.map +1 -0
  264. package/dist/types/task.js +7 -0
  265. package/dist/types/task.js.map +1 -0
  266. package/dist/types/workspace_state.d.ts +125 -0
  267. package/dist/types/workspace_state.d.ts.map +1 -0
  268. package/dist/types/workspace_state.js +10 -0
  269. package/dist/types/workspace_state.js.map +1 -0
  270. package/envoi.config.json +191 -0
  271. package/package.json +52 -0
  272. package/relais/prompts/.gitkeep +0 -0
  273. package/relais/prompts/builder.system.txt +13 -0
  274. package/relais/prompts/builder.user.txt +15 -0
  275. package/relais/prompts/orchestrator.system.txt +37 -0
  276. package/relais/prompts/orchestrator.user.txt +34 -0
  277. package/relais/prompts/reviewer.system.txt +33 -0
  278. package/relais/prompts/reviewer.user.txt +35 -0
  279. package/relais/schemas/.gitkeep +0 -0
  280. package/relais/schemas/builder_result.schema.json +29 -0
  281. package/relais/schemas/report.schema.json +195 -0
  282. package/relais/schemas/reviewer_result.schema.json +70 -0
  283. package/relais/schemas/task.schema.json +155 -0
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Builder (Hands) implementation.
3
+ *
4
+ * Invokes Claude Code with bypassPermissions mode and restricted tools to execute tasks.
5
+ */
6
+ import { lstat, readFile, writeFile, mkdir, unlink, access } from 'node:fs/promises';
7
+ import { join, resolve, relative } from 'node:path';
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { constants } from 'node:fs';
11
+ const execFileAsync = promisify(execFile);
12
+ import { invokeClaudeCode } from '../lib/claude.js';
13
+ import { matchesGlob } from '../lib/scope.js';
14
+ import { loadSchema, validateWithSchema } from '../lib/schema.js';
15
+ import { isInterruptedError } from '../types/claude.js';
16
+ import { parseBuilderResultRaw } from './builder_parse.js';
17
+ import { resolveInWorkspace } from '../lib/paths.js';
18
+ import { resolveBuilderPermissionMode } from '../lib/autonomy.js';
19
+ function isDebugEnabled() {
20
+ return process.env.ENVOI_DEBUG === '1';
21
+ }
22
+ /**
23
+ * Builds the builder user prompt by loading the template and interpolating placeholders.
24
+ *
25
+ * @param config - Envoi configuration
26
+ * @param task - Task to execute
27
+ * @returns The interpolated user prompt
28
+ */
29
+ export async function buildBuilderPrompt(config, task) {
30
+ const workspaceDir = config.workspace_dir;
31
+ const userPromptPath = resolveInWorkspace(workspaceDir, config.builder.claude_code.user_prompt_file);
32
+ // Load user prompt template
33
+ let template;
34
+ try {
35
+ template = await readFile(userPromptPath, 'utf-8');
36
+ }
37
+ catch (error) {
38
+ throw new Error(`Failed to read builder user prompt template from ${userPromptPath}: ${error instanceof Error ? error.message : String(error)}`);
39
+ }
40
+ // Interpolate placeholders
41
+ const replacements = {
42
+ '{{TASK_JSON}}': JSON.stringify(task),
43
+ '{{ALLOWED_GLOBS}}': task.scope.allowed_globs.join(', '),
44
+ '{{FORBIDDEN_GLOBS}}': task.scope.forbidden_globs.join(', '),
45
+ '{{ALLOW_NEW_FILES}}': task.scope.allow_new_files ? 'true' : 'false',
46
+ '{{ALLOW_LOCKFILE_CHANGES}}': task.scope.allow_lockfile_changes ? 'true' : 'false',
47
+ '{{MAX_FILES_TOUCHED}}': task.diff_limits.max_files_touched.toString(),
48
+ '{{MAX_LINES_CHANGED}}': task.diff_limits.max_lines_changed.toString(),
49
+ };
50
+ let prompt = template;
51
+ for (const [placeholder, value] of Object.entries(replacements)) {
52
+ prompt = prompt.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), value);
53
+ }
54
+ return prompt;
55
+ }
56
+ // Cache for loaded builder result schema
57
+ let builderResultSchemaCache = null;
58
+ /**
59
+ * Extracts file paths from unified diff headers (lines starting with +++ or ---).
60
+ */
61
+ function parsePatchPaths(patch) {
62
+ const paths = [];
63
+ const lines = patch.split('\n');
64
+ for (const line of lines) {
65
+ if (line.startsWith('+++ ') || line.startsWith('--- ')) {
66
+ const match = line.match(/^[+-]{3} [ab]\/(.+)$/);
67
+ if (match) {
68
+ paths.push(match[1]);
69
+ }
70
+ }
71
+ }
72
+ return [...new Set(paths)];
73
+ }
74
+ /**
75
+ * Validates a single path against security rules: no .., no leading /, no null bytes, must resolve inside repo.
76
+ */
77
+ function validatePatchPath(path, repoRoot) {
78
+ if (path.includes('\0')) {
79
+ return { valid: false, reason: 'Path contains null byte' };
80
+ }
81
+ if (path.startsWith('/')) {
82
+ return { valid: false, reason: 'Absolute path not allowed' };
83
+ }
84
+ if (path.includes('..')) {
85
+ return { valid: false, reason: 'Parent directory traversal (..) not allowed' };
86
+ }
87
+ const resolved = resolve(repoRoot, path);
88
+ const rel = relative(repoRoot, resolved);
89
+ if (rel.startsWith('..') || rel.startsWith('/')) {
90
+ return { valid: false, reason: 'Path resolves outside repository root' };
91
+ }
92
+ return { valid: true };
93
+ }
94
+ /**
95
+ * Checks if a path is within task scope using allowed_globs and forbidden_globs.
96
+ * Forbidden is checked first (deny wins).
97
+ */
98
+ function checkPatchScope(path, allowedGlobs, forbiddenGlobs) {
99
+ if (matchesGlob(path, forbiddenGlobs)) {
100
+ return { allowed: false, reason: 'Path matches forbidden glob' };
101
+ }
102
+ if (allowedGlobs.length > 0 && !matchesGlob(path, allowedGlobs)) {
103
+ return { allowed: false, reason: 'Path does not match any allowed glob' };
104
+ }
105
+ return { allowed: true };
106
+ }
107
+ /**
108
+ * Checks if a path is a symlink or any parent directory segment is a symlink.
109
+ * Uses lstat (not stat) so symlinks are detected without following them.
110
+ * Non-existent paths are OK (patch may create new files).
111
+ */
112
+ async function isSymlinkOrHasSymlinkParent(filePath, repoRoot) {
113
+ const fullPath = resolve(repoRoot, filePath);
114
+ const rel = relative(repoRoot, fullPath);
115
+ if (rel.startsWith('..') || rel === '')
116
+ return { isSymlink: false };
117
+ const segments = rel.split('/');
118
+ let currentPath = repoRoot;
119
+ for (const segment of segments) {
120
+ currentPath = join(currentPath, segment);
121
+ try {
122
+ const stats = await lstat(currentPath);
123
+ if (stats.isSymbolicLink()) {
124
+ return { isSymlink: true, symlinkPath: currentPath };
125
+ }
126
+ }
127
+ catch {
128
+ // Path doesn't exist yet - that's OK for new files
129
+ break;
130
+ }
131
+ }
132
+ return { isSymlink: false };
133
+ }
134
+ /**
135
+ * Writes the patch to a temp file and runs git apply.
136
+ *
137
+ * @param patch - Raw patch content (unified diff)
138
+ * @param workspaceDir - Workspace directory (for .tmp)
139
+ * @param repoRoot - Repository root (cwd and --directory for git apply)
140
+ * @returns success, output, and optional error message
141
+ */
142
+ async function applyPatch(patch, workspaceDir, repoRoot) {
143
+ const tmpDir = join(workspaceDir, '.tmp');
144
+ await mkdir(tmpDir, { recursive: true });
145
+ const patchFile = join(tmpDir, 'patch.diff');
146
+ await writeFile(patchFile, patch, 'utf-8');
147
+ try {
148
+ const { stdout, stderr } = await execFileAsync('git', [
149
+ 'apply',
150
+ '--whitespace=nowarn',
151
+ `--directory=${repoRoot}`,
152
+ patchFile,
153
+ ], { cwd: repoRoot });
154
+ return { success: true, output: (stdout ?? '') + (stderr ?? '') };
155
+ }
156
+ catch (error) {
157
+ const err = error;
158
+ return {
159
+ success: false,
160
+ output: err.stdout ?? '',
161
+ error: err.stderr ?? err.message ?? String(error),
162
+ };
163
+ }
164
+ }
165
+ /**
166
+ * Handles patch builder mode.
167
+ *
168
+ * Validates and applies a unified diff patch from task.builder.patch.
169
+ * Security: validates paths, rejects traversal, checks symlinks, enforces scope.
170
+ *
171
+ * STOP_PATCH_* codes (including STOP_PATCH_APPLY_FAILED) trigger rollback via the
172
+ * existing tick pipeline: builder returns success=false, tick emits a stop report,
173
+ * and the orchestrator can treat it as a stop (e.g. state rollback). See docs/NEW-PLAN.md PR4.
174
+ *
175
+ * @see docs/NEW-PLAN.md PR4
176
+ */
177
+ async function handlePatchMode(config, task) {
178
+ const patch = task.builder?.patch ?? '';
179
+ const paths = parsePatchPaths(patch);
180
+ const repoRoot = config.workspace_dir;
181
+ for (const path of paths) {
182
+ const validation = validatePatchPath(path, repoRoot);
183
+ if (!validation.valid) {
184
+ const message = `Invalid patch path '${path}': ${validation.reason}`;
185
+ return {
186
+ success: false,
187
+ result: null,
188
+ rawResponse: message,
189
+ durationMs: 0,
190
+ builderOutputValid: false,
191
+ validationErrors: ['STOP_PATCH_INVALID_PATH'],
192
+ turnsRequested: task.builder.max_turns,
193
+ turnsUsed: null,
194
+ };
195
+ }
196
+ }
197
+ const allowedGlobs = task.scope.allowed_globs.length > 0
198
+ ? task.scope.allowed_globs
199
+ : config.scope.default_allowed_globs;
200
+ const forbiddenGlobs = task.scope.forbidden_globs.length > 0
201
+ ? task.scope.forbidden_globs
202
+ : config.scope.default_forbidden_globs;
203
+ for (const path of paths) {
204
+ const scopeCheck = checkPatchScope(path, allowedGlobs, forbiddenGlobs);
205
+ if (!scopeCheck.allowed) {
206
+ const message = `Patch path '${path}' violates scope: ${scopeCheck.reason}`;
207
+ return {
208
+ success: false,
209
+ result: null,
210
+ rawResponse: message,
211
+ durationMs: 0,
212
+ builderOutputValid: false,
213
+ validationErrors: ['STOP_PATCH_SCOPE_VIOLATION'],
214
+ turnsRequested: task.builder.max_turns,
215
+ turnsUsed: null,
216
+ };
217
+ }
218
+ }
219
+ for (const path of paths) {
220
+ const symlinkCheck = await isSymlinkOrHasSymlinkParent(path, repoRoot);
221
+ if (symlinkCheck.isSymlink) {
222
+ const message = `Symlink detected in patch path: ${symlinkCheck.symlinkPath}`;
223
+ return {
224
+ success: false,
225
+ result: null,
226
+ rawResponse: message,
227
+ durationMs: 0,
228
+ builderOutputValid: false,
229
+ validationErrors: ['STOP_PATCH_SYMLINK'],
230
+ turnsRequested: task.builder.max_turns,
231
+ turnsUsed: null,
232
+ };
233
+ }
234
+ }
235
+ const applyResult = await applyPatch(patch, config.workspace_dir, repoRoot);
236
+ if (!applyResult.success) {
237
+ const patchFile = join(config.workspace_dir, '.tmp', 'patch.diff');
238
+ try {
239
+ await unlink(patchFile);
240
+ }
241
+ catch {
242
+ /* ignore cleanup errors */
243
+ }
244
+ return {
245
+ success: false,
246
+ result: null,
247
+ rawResponse: `git apply failed: ${applyResult.error}`,
248
+ durationMs: 0,
249
+ builderOutputValid: false,
250
+ validationErrors: ['STOP_PATCH_APPLY_FAILED'],
251
+ turnsRequested: task.builder.max_turns,
252
+ turnsUsed: null,
253
+ };
254
+ }
255
+ const result = {
256
+ summary: 'Patch applied successfully',
257
+ files_intended: paths,
258
+ commands_ran: ['git apply --whitespace=nowarn --directory=<repoRoot> .tmp/patch.diff'],
259
+ notes: [applyResult.output.trim() || 'Applied.'],
260
+ };
261
+ return {
262
+ success: true,
263
+ result,
264
+ rawResponse: applyResult.output,
265
+ durationMs: 0,
266
+ builderOutputValid: true,
267
+ validationErrors: [],
268
+ turnsRequested: task.builder.max_turns,
269
+ turnsUsed: null,
270
+ };
271
+ }
272
+ /**
273
+ * Checks if a command exists and is executable by searching PATH.
274
+ * Does not use shell - manually searches PATH environment variable.
275
+ *
276
+ * @param command - Command name to search for
277
+ * @returns Path to executable if found, null otherwise
278
+ */
279
+ async function findCommandInPath(command) {
280
+ // If command contains a path separator, treat it as an absolute or relative path
281
+ if (command.includes('/') || (process.platform === 'win32' && command.includes('\\'))) {
282
+ try {
283
+ await access(command, constants.F_OK | constants.X_OK);
284
+ return command;
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ }
290
+ // Search PATH
291
+ const pathEnv = process.env.PATH || '';
292
+ const pathDirs = pathEnv.split(process.platform === 'win32' ? ';' : ':');
293
+ for (const dir of pathDirs) {
294
+ if (!dir)
295
+ continue;
296
+ const fullPath = join(dir, command);
297
+ try {
298
+ await access(fullPath, constants.F_OK | constants.X_OK);
299
+ return fullPath;
300
+ }
301
+ catch {
302
+ // Continue searching
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+ /**
308
+ * Validates that a path is a safe relative path under workspace_dir.
309
+ * Rejects absolute paths, paths with '..', and paths that resolve outside workspace.
310
+ *
311
+ * @param path - Path to validate
312
+ * @param workspaceDir - Workspace directory
313
+ * @returns Validation result with reason if invalid
314
+ */
315
+ function validateOutputFilePath(path, workspaceDir) {
316
+ if (path.includes('\0')) {
317
+ return { valid: false, reason: 'Path contains null byte' };
318
+ }
319
+ if (path.startsWith('/') || (process.platform === 'win32' && /^[A-Za-z]:/.test(path))) {
320
+ return { valid: false, reason: 'Absolute path not allowed' };
321
+ }
322
+ if (path.includes('..')) {
323
+ return { valid: false, reason: 'Parent directory traversal (..) not allowed' };
324
+ }
325
+ const resolved = resolve(workspaceDir, path);
326
+ const rel = relative(workspaceDir, resolved);
327
+ if (rel.startsWith('..') || rel.startsWith('/')) {
328
+ return { valid: false, reason: 'Path resolves outside workspace directory' };
329
+ }
330
+ return { valid: true };
331
+ }
332
+ /**
333
+ * Handles cursor builder mode.
334
+ *
335
+ * Delegates build to an external process (e.g., Cursor IDE).
336
+ * Writes TASK.json to workspace, spawns the external driver,
337
+ * waits for completion, then reads and validates the result file.
338
+ *
339
+ * Preflight checks:
340
+ * - Validates cursor.output_file is a safe relative path
341
+ * - Verifies cursor.command exists and is executable
342
+ *
343
+ * @see docs/NEW-PLAN.md PR5
344
+ */
345
+ async function handleCursorMode(config, task) {
346
+ const startTime = Date.now();
347
+ const cursor = config.builder.cursor;
348
+ if (!cursor) {
349
+ return {
350
+ success: false,
351
+ result: null,
352
+ rawResponse: 'Cursor config not defined',
353
+ durationMs: Date.now() - startTime,
354
+ builderOutputValid: false,
355
+ validationErrors: ['STOP_CURSOR_CONFIG_MISSING'],
356
+ turnsRequested: task.builder.max_turns,
357
+ turnsUsed: null,
358
+ };
359
+ }
360
+ // Preflight: Validate output_file path safety
361
+ const outputFileValidation = validateOutputFilePath(cursor.output_file, config.workspace_dir);
362
+ if (!outputFileValidation.valid) {
363
+ return {
364
+ success: false,
365
+ result: null,
366
+ rawResponse: `Invalid output_file path '${cursor.output_file}': ${outputFileValidation.reason}. ` +
367
+ `Output file must be a safe relative path under workspace directory (no absolute paths, no '..').`,
368
+ durationMs: Date.now() - startTime,
369
+ builderOutputValid: false,
370
+ validationErrors: ['STOP_BUILDER_CLI_ERROR'],
371
+ turnsRequested: task.builder.max_turns,
372
+ turnsUsed: null,
373
+ parseErrorKind: 'cli_error',
374
+ };
375
+ }
376
+ // Preflight: Verify command exists and is executable
377
+ const commandPath = await findCommandInPath(cursor.command);
378
+ if (!commandPath) {
379
+ return {
380
+ success: false,
381
+ result: null,
382
+ rawResponse: `Command '${cursor.command}' not found or not executable. ` +
383
+ `Please install the driver or update config.builder.cursor.command in envoi.config.json.`,
384
+ durationMs: Date.now() - startTime,
385
+ builderOutputValid: false,
386
+ validationErrors: ['BLOCKED_BUILDER_COMMAND_NOT_FOUND'],
387
+ turnsRequested: task.builder.max_turns,
388
+ turnsUsed: null,
389
+ };
390
+ }
391
+ const taskJsonPath = join(config.workspace_dir, 'TASK.json');
392
+ const outputPath = resolveInWorkspace(config.workspace_dir, cursor.output_file);
393
+ const schemaPath = resolveInWorkspace(config.workspace_dir, config.builder.claude_code.builder_result_schema_file);
394
+ const driverKind = cursor.driver_kind ?? 'external';
395
+ const builderContractEnv = {
396
+ ...process.env,
397
+ ENVOI_BUILDER_PROTOCOL: 'v2_machine',
398
+ ENVOI_DRIVER_KIND: driverKind,
399
+ ENVOI_WORKSPACE_DIR: config.workspace_dir,
400
+ ENVOI_TASK_PATH: taskJsonPath,
401
+ ENVOI_OUTPUT_PATH: outputPath,
402
+ ENVOI_SCHEMA_PATH: schemaPath,
403
+ };
404
+ // Write TASK.json for external driver
405
+ try {
406
+ await writeFile(taskJsonPath, JSON.stringify(task, null, 2), 'utf-8');
407
+ }
408
+ catch (error) {
409
+ return {
410
+ success: false,
411
+ result: null,
412
+ rawResponse: `Failed to write TASK.json: ${error instanceof Error ? error.message : String(error)}`,
413
+ durationMs: Date.now() - startTime,
414
+ builderOutputValid: false,
415
+ validationErrors: ['STOP_BUILDER_CLI_ERROR'],
416
+ turnsRequested: task.builder.max_turns,
417
+ turnsUsed: null,
418
+ parseErrorKind: 'cli_error',
419
+ };
420
+ }
421
+ // Spawn external driver
422
+ try {
423
+ const args = [...cursor.args];
424
+ if (cursor.driver_kind === 'cursor_agent') {
425
+ const agentPrompt = [
426
+ 'ENVOI_BUILDER_PROTOCOL=v2_machine',
427
+ `TASK_PATH=${taskJsonPath}`,
428
+ `OUTPUT_PATH=${outputPath}`,
429
+ `SCHEMA_PATH=${schemaPath}`,
430
+ 'OUTPUT_KEYS=summary,files_intended,commands_ran,notes',
431
+ 'SINGLE_PASS=1',
432
+ 'NO_QUESTIONS=1',
433
+ 'Write exactly one JSON object to OUTPUT_PATH, then exit.',
434
+ ].join('\n');
435
+ console.log('[BUILD] Builder protocol: v2_machine');
436
+ args.push(agentPrompt);
437
+ }
438
+ await execFileAsync(commandPath, args, {
439
+ // Run from repo root (the CLI chdirToRepoRoot() ensures this in real usage).
440
+ cwd: process.cwd(),
441
+ timeout: cursor.timeout_seconds * 1000,
442
+ env: builderContractEnv,
443
+ });
444
+ }
445
+ catch (error) {
446
+ const err = error;
447
+ // Detect timeout: Node kills the process and sets killed=true
448
+ if (err.killed && (err.signal === 'SIGTERM' || err.signal === 'SIGKILL')) {
449
+ return {
450
+ success: false,
451
+ result: null,
452
+ rawResponse: `External driver timed out after ${cursor.timeout_seconds}s`,
453
+ durationMs: Date.now() - startTime,
454
+ builderOutputValid: false,
455
+ validationErrors: ['STOP_BUILDER_TIMEOUT'],
456
+ turnsRequested: task.builder.max_turns,
457
+ turnsUsed: null,
458
+ parseErrorKind: 'cli_error',
459
+ };
460
+ }
461
+ // Other spawn errors
462
+ return {
463
+ success: false,
464
+ result: null,
465
+ rawResponse: err.message ?? String(error),
466
+ durationMs: Date.now() - startTime,
467
+ builderOutputValid: false,
468
+ validationErrors: ['STOP_BUILDER_CLI_ERROR'],
469
+ turnsRequested: task.builder.max_turns,
470
+ turnsUsed: null,
471
+ parseErrorKind: 'cli_error',
472
+ };
473
+ }
474
+ // Read output file
475
+ let rawOutput;
476
+ try {
477
+ rawOutput = await readFile(outputPath, 'utf-8');
478
+ }
479
+ catch (error) {
480
+ return {
481
+ success: false,
482
+ result: null,
483
+ rawResponse: `Failed to read output file: ${error instanceof Error ? error.message : String(error)}`,
484
+ durationMs: Date.now() - startTime,
485
+ builderOutputValid: false,
486
+ validationErrors: ['STOP_BUILDER_CLI_ERROR'],
487
+ turnsRequested: task.builder.max_turns,
488
+ turnsUsed: null,
489
+ parseErrorKind: 'cli_error',
490
+ };
491
+ }
492
+ // Parse JSON
493
+ let parsed;
494
+ try {
495
+ parsed = JSON.parse(rawOutput);
496
+ }
497
+ catch (error) {
498
+ return {
499
+ success: false,
500
+ result: null,
501
+ rawResponse: rawOutput,
502
+ durationMs: Date.now() - startTime,
503
+ builderOutputValid: false,
504
+ validationErrors: ['STOP_BUILDER_JSON_PARSE'],
505
+ turnsRequested: task.builder.max_turns,
506
+ turnsUsed: null,
507
+ parseErrorKind: 'json_parse',
508
+ };
509
+ }
510
+ // Validate against builder_result schema
511
+ let schema;
512
+ try {
513
+ schema = await loadSchema(schemaPath);
514
+ }
515
+ catch (error) {
516
+ // Schema loading failure - check shape manually
517
+ if (typeof parsed === 'object' &&
518
+ parsed !== null &&
519
+ 'summary' in parsed &&
520
+ 'files_intended' in parsed &&
521
+ 'commands_ran' in parsed &&
522
+ 'notes' in parsed) {
523
+ return {
524
+ success: true,
525
+ result: parsed,
526
+ rawResponse: rawOutput,
527
+ durationMs: Date.now() - startTime,
528
+ builderOutputValid: true,
529
+ validationErrors: [],
530
+ turnsRequested: task.builder.max_turns,
531
+ turnsUsed: null,
532
+ };
533
+ }
534
+ return {
535
+ success: false,
536
+ result: null,
537
+ rawResponse: rawOutput,
538
+ durationMs: Date.now() - startTime,
539
+ builderOutputValid: false,
540
+ validationErrors: ['STOP_BUILDER_SHAPE_INVALID'],
541
+ turnsRequested: task.builder.max_turns,
542
+ turnsUsed: null,
543
+ parseErrorKind: 'shape',
544
+ };
545
+ }
546
+ const validation = validateWithSchema(parsed, schema);
547
+ if (!validation.valid) {
548
+ return {
549
+ success: false,
550
+ result: null,
551
+ rawResponse: rawOutput,
552
+ durationMs: Date.now() - startTime,
553
+ builderOutputValid: false,
554
+ validationErrors: ['STOP_BUILDER_SCHEMA_INVALID'],
555
+ turnsRequested: task.builder.max_turns,
556
+ turnsUsed: null,
557
+ parseErrorKind: 'schema',
558
+ };
559
+ }
560
+ return {
561
+ success: true,
562
+ result: validation.data,
563
+ rawResponse: rawOutput,
564
+ durationMs: Date.now() - startTime,
565
+ builderOutputValid: true,
566
+ validationErrors: [],
567
+ turnsRequested: task.builder.max_turns,
568
+ turnsUsed: null,
569
+ };
570
+ }
571
+ /**
572
+ * Runs the builder to execute a task.
573
+ *
574
+ * The builder invokes Claude Code with bypassPermissions mode and restricted tools.
575
+ * Output parsing is lenient by default (strict_builder_json=false), meaning invalid JSON
576
+ * won't cause a failure, but builderOutputValid will be false.
577
+ *
578
+ * @param state - Current tick state (must have a task)
579
+ * @param task - Task to execute
580
+ * @param signal - Optional AbortSignal for cancellation
581
+ * @returns BuilderInvocationResult with result or error
582
+ */
583
+ export async function runBuilder(state, task, signal) {
584
+ const config = state.config;
585
+ // Guard: builder must be present (schema enforces control XOR builder)
586
+ if (!task.builder) {
587
+ return {
588
+ success: false,
589
+ result: null,
590
+ rawResponse: 'Task has no builder configuration',
591
+ durationMs: 0,
592
+ builderOutputValid: false,
593
+ validationErrors: ['STOP_NO_BUILDER'],
594
+ turnsRequested: 0,
595
+ turnsUsed: null,
596
+ };
597
+ }
598
+ if (task.builder.mode === 'patch') {
599
+ return await handlePatchMode(config, task);
600
+ }
601
+ if (task.builder.mode === 'cursor') {
602
+ return await handleCursorMode(config, task);
603
+ }
604
+ const workspaceDir = config.workspace_dir;
605
+ const startTime = Date.now();
606
+ // Validate and clamp max_turns
607
+ const requestedTurns = task.builder.max_turns;
608
+ const maxTurnsLimit = config.builder.claude_code.max_turns;
609
+ const clampedTurns = Math.max(1, Math.min(requestedTurns, maxTurnsLimit));
610
+ if (requestedTurns !== clampedTurns) {
611
+ console.warn(`Task ${task.task_id}: max_turns ${requestedTurns} clamped to ${clampedTurns} (limit: ${maxTurnsLimit})`);
612
+ }
613
+ // Load system prompt
614
+ const systemPromptPath = resolveInWorkspace(workspaceDir, config.builder.claude_code.system_prompt_file);
615
+ let systemPrompt;
616
+ try {
617
+ systemPrompt = await readFile(systemPromptPath, 'utf-8');
618
+ }
619
+ catch (error) {
620
+ return {
621
+ success: false,
622
+ result: null,
623
+ rawResponse: '',
624
+ durationMs: Date.now() - startTime,
625
+ builderOutputValid: false,
626
+ validationErrors: [],
627
+ turnsRequested: requestedTurns,
628
+ turnsUsed: null,
629
+ };
630
+ }
631
+ // Load builder result schema (cache after first load)
632
+ if (builderResultSchemaCache === null) {
633
+ const schemaPath = resolveInWorkspace(workspaceDir, config.builder.claude_code.builder_result_schema_file);
634
+ try {
635
+ builderResultSchemaCache = await loadSchema(schemaPath);
636
+ }
637
+ catch (error) {
638
+ // Schema loading failure is non-fatal if strict_builder_json is false
639
+ // But we still want to try to parse JSON if possible
640
+ console.warn(`Failed to load builder result schema: ${error instanceof Error ? error.message : String(error)}`);
641
+ }
642
+ }
643
+ const model = config.models.builder_model;
644
+ const timeout = config.runner.max_tick_seconds * 1000; // Convert to milliseconds
645
+ const allowedTools = config.builder.claude_code.allowed_tools;
646
+ const strictBuilderJson = config.builder.claude_code.strict_builder_json;
647
+ // Build user prompt
648
+ let userPrompt;
649
+ try {
650
+ userPrompt = await buildBuilderPrompt(config, task);
651
+ }
652
+ catch (error) {
653
+ return {
654
+ success: false,
655
+ result: null,
656
+ rawResponse: '',
657
+ durationMs: Date.now() - startTime,
658
+ builderOutputValid: false,
659
+ validationErrors: [],
660
+ turnsRequested: requestedTurns,
661
+ turnsUsed: null,
662
+ };
663
+ }
664
+ try {
665
+ const response = await invokeClaudeCode(config.claude_code_cli, {
666
+ prompt: userPrompt,
667
+ maxTurns: clampedTurns,
668
+ permissionMode: resolveBuilderPermissionMode(config),
669
+ model,
670
+ allowedTools,
671
+ systemPrompt,
672
+ timeout,
673
+ signal,
674
+ });
675
+ // Extract num_turns from raw response if available
676
+ let turnsUsed = null;
677
+ if (response.raw && typeof response.raw === 'object' && 'num_turns' in response.raw) {
678
+ const numTurns = response.raw.num_turns;
679
+ if (typeof numTurns === 'number') {
680
+ turnsUsed = numTurns;
681
+ }
682
+ }
683
+ const durationMs = Date.now() - startTime;
684
+ if (!response.success || !response.result) {
685
+ // Invocation failure - extract subtype for better error classification
686
+ const rawObj = response.raw;
687
+ const subtype = typeof rawObj?.subtype === 'string' ? rawObj.subtype : '';
688
+ const errorInfo = subtype ? `CLI error: ${subtype}` : (response.result || 'Unknown error');
689
+ return {
690
+ success: false,
691
+ result: null,
692
+ rawResponse: errorInfo,
693
+ durationMs,
694
+ builderOutputValid: false,
695
+ validationErrors: subtype ? [`STOP_BUILDER_${subtype.toUpperCase()}`] : [],
696
+ turnsRequested: requestedTurns,
697
+ turnsUsed,
698
+ parseErrorKind: 'cli_error',
699
+ tokenUsage: response.tokenUsage ?? null,
700
+ };
701
+ }
702
+ // Parse and validate builder output using pure parser
703
+ if (isDebugEnabled()) {
704
+ console.log(`[BUILDER_DEBUG] Raw response (first 500 chars): ${response.result.substring(0, 500)}`);
705
+ }
706
+ const parseResult = parseBuilderResultRaw(response.result, builderResultSchemaCache ?? undefined);
707
+ if (parseResult.ok) {
708
+ // Success with valid JSON
709
+ return {
710
+ success: true,
711
+ result: parseResult.value,
712
+ rawResponse: response.result,
713
+ durationMs,
714
+ builderOutputValid: true,
715
+ validationErrors: [],
716
+ turnsRequested: requestedTurns,
717
+ turnsUsed,
718
+ tokenUsage: response.tokenUsage ?? null,
719
+ };
720
+ }
721
+ // Parse failed - handle based on strictBuilderJson and task_kind
722
+ // Question tasks must fail-closed on invalid output
723
+ const mustFailClosed = strictBuilderJson || task.task_kind === 'question';
724
+ if (mustFailClosed) {
725
+ return {
726
+ success: false,
727
+ result: null,
728
+ rawResponse: response.result,
729
+ durationMs,
730
+ builderOutputValid: false,
731
+ validationErrors: [parseResult.message],
732
+ turnsRequested: requestedTurns,
733
+ turnsUsed,
734
+ parseErrorKind: parseResult.kind,
735
+ tokenUsage: response.tokenUsage ?? null,
736
+ };
737
+ }
738
+ // Lenient mode: return success but mark output as invalid
739
+ return {
740
+ success: true,
741
+ result: null,
742
+ rawResponse: response.result,
743
+ durationMs,
744
+ builderOutputValid: false,
745
+ validationErrors: [parseResult.message],
746
+ turnsRequested: requestedTurns,
747
+ turnsUsed,
748
+ parseErrorKind: parseResult.kind,
749
+ tokenUsage: response.tokenUsage ?? null,
750
+ };
751
+ }
752
+ catch (error) {
753
+ // Re-throw InterruptedError to propagate to tick level
754
+ if (isInterruptedError(error)) {
755
+ throw error;
756
+ }
757
+ // Debug logging gated by ENVOI_DEBUG
758
+ if (isDebugEnabled()) {
759
+ console.log(`[BUILDER_DEBUG] Exception: ${error instanceof Error ? error.message : String(error)}`);
760
+ }
761
+ // Capture error message in rawResponse for proper classification
762
+ const errorMessage = error instanceof Error ? error.message : String(error);
763
+ return {
764
+ success: false,
765
+ result: null,
766
+ rawResponse: `Builder invocation error: ${errorMessage}`,
767
+ durationMs: Date.now() - startTime,
768
+ builderOutputValid: false,
769
+ validationErrors: [],
770
+ turnsRequested: requestedTurns,
771
+ turnsUsed: null,
772
+ };
773
+ }
774
+ }
775
+ //# sourceMappingURL=builder.js.map