@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.
- package/README.md +238 -0
- package/dist/commands/app.d.ts +2 -0
- package/dist/commands/app.d.ts.map +1 -0
- package/dist/commands/app.js +31 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/autonomy.d.ts +6 -0
- package/dist/commands/autonomy.d.ts.map +1 -0
- package/dist/commands/autonomy.js +89 -0
- package/dist/commands/autonomy.js.map +1 -0
- package/dist/commands/builder.d.ts +13 -0
- package/dist/commands/builder.d.ts.map +1 -0
- package/dist/commands/builder.js +142 -0
- package/dist/commands/builder.js.map +1 -0
- package/dist/commands/idea.d.ts +12 -0
- package/dist/commands/idea.d.ts.map +1 -0
- package/dist/commands/idea.js +79 -0
- package/dist/commands/idea.js.map +1 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +423 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/mode.d.ts +13 -0
- package/dist/commands/mode.d.ts.map +1 -0
- package/dist/commands/mode.js +96 -0
- package/dist/commands/mode.js.map +1 -0
- package/dist/commands/onboard.d.ts +37 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +743 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/pr-note.d.ts +8 -0
- package/dist/commands/pr-note.d.ts.map +1 -0
- package/dist/commands/pr-note.js +27 -0
- package/dist/commands/pr-note.js.map +1 -0
- package/dist/commands/undo.d.ts +7 -0
- package/dist/commands/undo.d.ts.map +1 -0
- package/dist/commands/undo.js +59 -0
- package/dist/commands/undo.js.map +1 -0
- package/dist/commands/update.d.ts +24 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +248 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/constants/report_codes.d.ts +29 -0
- package/dist/constants/report_codes.d.ts.map +1 -0
- package/dist/constants/report_codes.js +69 -0
- package/dist/constants/report_codes.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +675 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/autonomy.d.ts +16 -0
- package/dist/lib/autonomy.d.ts.map +1 -0
- package/dist/lib/autonomy.js +38 -0
- package/dist/lib/autonomy.js.map +1 -0
- package/dist/lib/blocked.d.ts +87 -0
- package/dist/lib/blocked.d.ts.map +1 -0
- package/dist/lib/blocked.js +134 -0
- package/dist/lib/blocked.js.map +1 -0
- package/dist/lib/branding.d.ts +13 -0
- package/dist/lib/branding.d.ts.map +1 -0
- package/dist/lib/branding.js +19 -0
- package/dist/lib/branding.js.map +1 -0
- package/dist/lib/claude.d.ts +42 -0
- package/dist/lib/claude.d.ts.map +1 -0
- package/dist/lib/claude.js +291 -0
- package/dist/lib/claude.js.map +1 -0
- package/dist/lib/config.d.ts +71 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +410 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/diff.d.ts +150 -0
- package/dist/lib/diff.d.ts.map +1 -0
- package/dist/lib/diff.js +257 -0
- package/dist/lib/diff.js.map +1 -0
- package/dist/lib/doctor.d.ts +67 -0
- package/dist/lib/doctor.d.ts.map +1 -0
- package/dist/lib/doctor.js +211 -0
- package/dist/lib/doctor.js.map +1 -0
- package/dist/lib/fingerprint.d.ts +27 -0
- package/dist/lib/fingerprint.d.ts.map +1 -0
- package/dist/lib/fingerprint.js +116 -0
- package/dist/lib/fingerprint.js.map +1 -0
- package/dist/lib/fs.d.ts +93 -0
- package/dist/lib/fs.d.ts.map +1 -0
- package/dist/lib/fs.js +179 -0
- package/dist/lib/fs.js.map +1 -0
- package/dist/lib/git.d.ts +177 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +355 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/git_branching.d.ts +84 -0
- package/dist/lib/git_branching.d.ts.map +1 -0
- package/dist/lib/git_branching.js +327 -0
- package/dist/lib/git_branching.js.map +1 -0
- package/dist/lib/gitignore.d.ts +26 -0
- package/dist/lib/gitignore.d.ts.map +1 -0
- package/dist/lib/gitignore.js +119 -0
- package/dist/lib/gitignore.js.map +1 -0
- package/dist/lib/guardrails.d.ts +232 -0
- package/dist/lib/guardrails.d.ts.map +1 -0
- package/dist/lib/guardrails.js +323 -0
- package/dist/lib/guardrails.js.map +1 -0
- package/dist/lib/history.d.ts +110 -0
- package/dist/lib/history.d.ts.map +1 -0
- package/dist/lib/history.js +236 -0
- package/dist/lib/history.js.map +1 -0
- package/dist/lib/index.d.ts +29 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +29 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/json-extract.d.ts +42 -0
- package/dist/lib/json-extract.d.ts.map +1 -0
- package/dist/lib/json-extract.js +201 -0
- package/dist/lib/json-extract.js.map +1 -0
- package/dist/lib/judge.d.ts +237 -0
- package/dist/lib/judge.d.ts.map +1 -0
- package/dist/lib/judge.js +501 -0
- package/dist/lib/judge.js.map +1 -0
- package/dist/lib/lock.d.ts +79 -0
- package/dist/lib/lock.d.ts.map +1 -0
- package/dist/lib/lock.js +254 -0
- package/dist/lib/lock.js.map +1 -0
- package/dist/lib/migration.d.ts +9 -0
- package/dist/lib/migration.d.ts.map +1 -0
- package/dist/lib/migration.js +74 -0
- package/dist/lib/migration.js.map +1 -0
- package/dist/lib/paths.d.ts +18 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +27 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/preflight.d.ts +33 -0
- package/dist/lib/preflight.d.ts.map +1 -0
- package/dist/lib/preflight.js +177 -0
- package/dist/lib/preflight.js.map +1 -0
- package/dist/lib/prompt_budget.d.ts +18 -0
- package/dist/lib/prompt_budget.d.ts.map +1 -0
- package/dist/lib/prompt_budget.js +36 -0
- package/dist/lib/prompt_budget.js.map +1 -0
- package/dist/lib/report.d.ts +102 -0
- package/dist/lib/report.d.ts.map +1 -0
- package/dist/lib/report.js +347 -0
- package/dist/lib/report.js.map +1 -0
- package/dist/lib/reviewer-flow.d.ts +80 -0
- package/dist/lib/reviewer-flow.d.ts.map +1 -0
- package/dist/lib/reviewer-flow.js +138 -0
- package/dist/lib/reviewer-flow.js.map +1 -0
- package/dist/lib/reviewer.d.ts +53 -0
- package/dist/lib/reviewer.d.ts.map +1 -0
- package/dist/lib/reviewer.js +199 -0
- package/dist/lib/reviewer.js.map +1 -0
- package/dist/lib/risk.d.ts +127 -0
- package/dist/lib/risk.d.ts.map +1 -0
- package/dist/lib/risk.js +192 -0
- package/dist/lib/risk.js.map +1 -0
- package/dist/lib/rollback.d.ts +143 -0
- package/dist/lib/rollback.d.ts.map +1 -0
- package/dist/lib/rollback.js +244 -0
- package/dist/lib/rollback.js.map +1 -0
- package/dist/lib/schema.d.ts +47 -0
- package/dist/lib/schema.d.ts.map +1 -0
- package/dist/lib/schema.js +91 -0
- package/dist/lib/schema.js.map +1 -0
- package/dist/lib/scope.d.ts +89 -0
- package/dist/lib/scope.d.ts.map +1 -0
- package/dist/lib/scope.js +135 -0
- package/dist/lib/scope.js.map +1 -0
- package/dist/lib/self_update.d.ts +13 -0
- package/dist/lib/self_update.d.ts.map +1 -0
- package/dist/lib/self_update.js +172 -0
- package/dist/lib/self_update.js.map +1 -0
- package/dist/lib/state.d.ts +143 -0
- package/dist/lib/state.d.ts.map +1 -0
- package/dist/lib/state.js +258 -0
- package/dist/lib/state.js.map +1 -0
- package/dist/lib/tick.d.ts +310 -0
- package/dist/lib/tick.d.ts.map +1 -0
- package/dist/lib/tick.js +424 -0
- package/dist/lib/tick.js.map +1 -0
- package/dist/lib/transport.d.ts +145 -0
- package/dist/lib/transport.d.ts.map +1 -0
- package/dist/lib/transport.js +237 -0
- package/dist/lib/transport.js.map +1 -0
- package/dist/lib/verdict_labels.d.ts +5 -0
- package/dist/lib/verdict_labels.d.ts.map +1 -0
- package/dist/lib/verdict_labels.js +25 -0
- package/dist/lib/verdict_labels.js.map +1 -0
- package/dist/lib/verify-safety.d.ts +63 -0
- package/dist/lib/verify-safety.d.ts.map +1 -0
- package/dist/lib/verify-safety.js +123 -0
- package/dist/lib/verify-safety.js.map +1 -0
- package/dist/lib/verify.d.ts +139 -0
- package/dist/lib/verify.d.ts.map +1 -0
- package/dist/lib/verify.js +311 -0
- package/dist/lib/verify.js.map +1 -0
- package/dist/lib/workspace_state.d.ts +79 -0
- package/dist/lib/workspace_state.d.ts.map +1 -0
- package/dist/lib/workspace_state.js +283 -0
- package/dist/lib/workspace_state.js.map +1 -0
- package/dist/runner/builder.d.ts +58 -0
- package/dist/runner/builder.d.ts.map +1 -0
- package/dist/runner/builder.js +775 -0
- package/dist/runner/builder.js.map +1 -0
- package/dist/runner/builder_parse.d.ts +37 -0
- package/dist/runner/builder_parse.d.ts.map +1 -0
- package/dist/runner/builder_parse.js +76 -0
- package/dist/runner/builder_parse.js.map +1 -0
- package/dist/runner/index.d.ts +9 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/index.js +7 -0
- package/dist/runner/index.js.map +1 -0
- package/dist/runner/loop.d.ts +51 -0
- package/dist/runner/loop.d.ts.map +1 -0
- package/dist/runner/loop.js +221 -0
- package/dist/runner/loop.js.map +1 -0
- package/dist/runner/orchestrator.d.ts +67 -0
- package/dist/runner/orchestrator.d.ts.map +1 -0
- package/dist/runner/orchestrator.js +376 -0
- package/dist/runner/orchestrator.js.map +1 -0
- package/dist/runner/tick.d.ts +10 -0
- package/dist/runner/tick.d.ts.map +1 -0
- package/dist/runner/tick.js +1639 -0
- package/dist/runner/tick.js.map +1 -0
- package/dist/types/blocked.d.ts +52 -0
- package/dist/types/blocked.d.ts.map +1 -0
- package/dist/types/blocked.js +8 -0
- package/dist/types/blocked.js.map +1 -0
- package/dist/types/builder.d.ts +25 -0
- package/dist/types/builder.d.ts.map +1 -0
- package/dist/types/builder.js +7 -0
- package/dist/types/builder.js.map +1 -0
- package/dist/types/claude.d.ts +86 -0
- package/dist/types/claude.d.ts.map +1 -0
- package/dist/types/claude.js +48 -0
- package/dist/types/claude.js.map +1 -0
- package/dist/types/config.d.ts +384 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +8 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/lock.d.ts +21 -0
- package/dist/types/lock.d.ts.map +1 -0
- package/dist/types/lock.js +8 -0
- package/dist/types/lock.js.map +1 -0
- package/dist/types/preflight.d.ts +49 -0
- package/dist/types/preflight.d.ts.map +1 -0
- package/dist/types/preflight.js +8 -0
- package/dist/types/preflight.js.map +1 -0
- package/dist/types/report.d.ts +161 -0
- package/dist/types/report.d.ts.map +1 -0
- package/dist/types/report.js +8 -0
- package/dist/types/report.js.map +1 -0
- package/dist/types/reviewer.d.ts +66 -0
- package/dist/types/reviewer.d.ts.map +1 -0
- package/dist/types/reviewer.js +5 -0
- package/dist/types/reviewer.js.map +1 -0
- package/dist/types/state.d.ts +124 -0
- package/dist/types/state.d.ts.map +1 -0
- package/dist/types/state.js +20 -0
- package/dist/types/state.js.map +1 -0
- package/dist/types/task.d.ts +117 -0
- package/dist/types/task.d.ts.map +1 -0
- package/dist/types/task.js +7 -0
- package/dist/types/task.js.map +1 -0
- package/dist/types/workspace_state.d.ts +125 -0
- package/dist/types/workspace_state.d.ts.map +1 -0
- package/dist/types/workspace_state.js +10 -0
- package/dist/types/workspace_state.js.map +1 -0
- package/envoi.config.json +191 -0
- package/package.json +52 -0
- package/relais/prompts/.gitkeep +0 -0
- package/relais/prompts/builder.system.txt +13 -0
- package/relais/prompts/builder.user.txt +15 -0
- package/relais/prompts/orchestrator.system.txt +37 -0
- package/relais/prompts/orchestrator.user.txt +34 -0
- package/relais/prompts/reviewer.system.txt +33 -0
- package/relais/prompts/reviewer.user.txt +35 -0
- package/relais/schemas/.gitkeep +0 -0
- package/relais/schemas/builder_result.schema.json +29 -0
- package/relais/schemas/report.schema.json +195 -0
- package/relais/schemas/reviewer_result.schema.json +70 -0
- 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
|