@robmclarty/volley 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/LICENSE +201 -0
- package/README.md +514 -0
- package/dist/cli.js +3405 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +349 -0
- package/dist/index.js +2659 -0
- package/dist/index.js.map +1 -0
- package/package.json +94 -0
- package/src/builder/presets/harness_append_local.md +40 -0
- package/src/critic/presets/harness_append.md +15 -0
- package/src/critic/presets/harness_append_local.md +20 -0
- package/src/critic/presets/optimizer.md +8 -0
- package/src/critic/presets/researcher.md +7 -0
- package/src/critic/presets/reviewer.md +9 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3405 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
|
|
5
|
+
import { dirname as dirname4, join as join11 } from "path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
7
|
+
import { cac } from "cac";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from "fs";
|
|
11
|
+
import { isAbsolute, resolve } from "path";
|
|
12
|
+
import { pathToFileURL } from "url";
|
|
13
|
+
import { randomUUID } from "crypto";
|
|
14
|
+
|
|
15
|
+
// src/changes.ts
|
|
16
|
+
import { spawnSync } from "child_process";
|
|
17
|
+
var DEFAULT_GATE_PATTERNS = [
|
|
18
|
+
"**/*.test.*",
|
|
19
|
+
"**/*.spec.*",
|
|
20
|
+
"**/*_test.*",
|
|
21
|
+
"**/test/**",
|
|
22
|
+
"**/tests/**",
|
|
23
|
+
"**/__tests__/**",
|
|
24
|
+
"**/spec/**",
|
|
25
|
+
"**/fixtures/**",
|
|
26
|
+
"**/checkride.config.*",
|
|
27
|
+
"**/vitest.config.*",
|
|
28
|
+
"**/jest.config.*",
|
|
29
|
+
"**/playwright.config.*",
|
|
30
|
+
"**/package.json",
|
|
31
|
+
".github/workflows/**"
|
|
32
|
+
];
|
|
33
|
+
var EXCLUDED_PREFIXES = [".volley/", ".volley.bak.", ".check/", ".git/", "node_modules/"];
|
|
34
|
+
var DEFAULT_LIMIT = 200;
|
|
35
|
+
function git(root, args) {
|
|
36
|
+
try {
|
|
37
|
+
const result = spawnSync("git", args, {
|
|
38
|
+
cwd: root,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
maxBuffer: 32 * 1024 * 1024
|
|
41
|
+
});
|
|
42
|
+
return { status: result.status ?? -1, stdout: result.stdout ?? "" };
|
|
43
|
+
} catch {
|
|
44
|
+
return { status: -1, stdout: "" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function in_git_repo(root) {
|
|
48
|
+
return git(root, ["rev-parse", "--git-dir"]).status === 0;
|
|
49
|
+
}
|
|
50
|
+
function capture_baseline(root) {
|
|
51
|
+
const head = git(root, ["rev-parse", "HEAD"]);
|
|
52
|
+
if (head.status !== 0) return null;
|
|
53
|
+
const sha = head.stdout.trim();
|
|
54
|
+
return sha.length > 0 ? sha : null;
|
|
55
|
+
}
|
|
56
|
+
function change_status(code) {
|
|
57
|
+
const letter = code.charAt(0);
|
|
58
|
+
if (letter === "A") return "added";
|
|
59
|
+
if (letter === "M" || letter === "T") return "modified";
|
|
60
|
+
if (letter === "D") return "deleted";
|
|
61
|
+
if (letter === "R" || letter === "C") return "renamed";
|
|
62
|
+
return "unknown";
|
|
63
|
+
}
|
|
64
|
+
function parse_name_status(stdout) {
|
|
65
|
+
const tokens = stdout.split("\0").filter((token) => token.length > 0);
|
|
66
|
+
const changes = [];
|
|
67
|
+
let index = 0;
|
|
68
|
+
while (index < tokens.length) {
|
|
69
|
+
const code = tokens[index] ?? "";
|
|
70
|
+
const paired = code.startsWith("R") || code.startsWith("C");
|
|
71
|
+
const path = tokens[index + (paired ? 2 : 1)];
|
|
72
|
+
if (path !== void 0) changes.push({ path, status: change_status(code) });
|
|
73
|
+
index += paired ? 3 : 2;
|
|
74
|
+
}
|
|
75
|
+
return changes;
|
|
76
|
+
}
|
|
77
|
+
function parse_paths(stdout) {
|
|
78
|
+
return stdout.split("\0").filter((path) => path.length > 0);
|
|
79
|
+
}
|
|
80
|
+
function excluded(path) {
|
|
81
|
+
return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
82
|
+
}
|
|
83
|
+
function glob_to_regexp(pattern) {
|
|
84
|
+
let source = "";
|
|
85
|
+
let index = 0;
|
|
86
|
+
while (index < pattern.length) {
|
|
87
|
+
const char = pattern.charAt(index);
|
|
88
|
+
if (char === "*" && pattern.charAt(index + 1) === "*") {
|
|
89
|
+
const slashed = pattern.charAt(index + 2) === "/";
|
|
90
|
+
source += slashed ? "(?:.*/)?" : ".*";
|
|
91
|
+
index += slashed ? 3 : 2;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (char === "*") {
|
|
95
|
+
source += "[^/]*";
|
|
96
|
+
index += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === "?") {
|
|
100
|
+
source += "[^/]";
|
|
101
|
+
index += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
105
|
+
index += 1;
|
|
106
|
+
}
|
|
107
|
+
return new RegExp(`^${source}$`);
|
|
108
|
+
}
|
|
109
|
+
function matches_any(path, patterns) {
|
|
110
|
+
return patterns.some((pattern) => glob_to_regexp(pattern).test(path));
|
|
111
|
+
}
|
|
112
|
+
function by_path(left, right) {
|
|
113
|
+
if (left.path < right.path) return -1;
|
|
114
|
+
return left.path > right.path ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
function collect_changes(options) {
|
|
117
|
+
const { root, baseline } = options;
|
|
118
|
+
if (!in_git_repo(root)) return null;
|
|
119
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
120
|
+
const tracked = baseline === null ? [] : parse_name_status(
|
|
121
|
+
git(root, ["diff", "--name-status", "--relative", "-z", baseline, "--", "."]).stdout
|
|
122
|
+
);
|
|
123
|
+
const untracked = parse_paths(
|
|
124
|
+
git(root, ["ls-files", "--others", "--exclude-standard", "-z"]).stdout
|
|
125
|
+
).map((path) => ({ path, status: "added" }));
|
|
126
|
+
const seen = /* @__PURE__ */ new Set();
|
|
127
|
+
const files = [];
|
|
128
|
+
for (const change of [...tracked, ...untracked]) {
|
|
129
|
+
if (excluded(change.path) || seen.has(change.path)) continue;
|
|
130
|
+
seen.add(change.path);
|
|
131
|
+
files.push({ ...change, gate: matches_any(change.path, options.gate_patterns) });
|
|
132
|
+
}
|
|
133
|
+
const sorted = files.toSorted(by_path);
|
|
134
|
+
return {
|
|
135
|
+
baseline,
|
|
136
|
+
// Over the full list, before the cap: truncation must never hide a gate edit.
|
|
137
|
+
gate_edits: sorted.filter((file) => file.gate).map((file) => file.path),
|
|
138
|
+
total: sorted.length,
|
|
139
|
+
files: sorted.slice(0, limit),
|
|
140
|
+
truncated: sorted.length > limit
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/types.ts
|
|
145
|
+
function config_error(message) {
|
|
146
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
147
|
+
}
|
|
148
|
+
function check_error(message) {
|
|
149
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
150
|
+
}
|
|
151
|
+
function phase_error(phase, iteration, cause) {
|
|
152
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
153
|
+
return Object.assign(
|
|
154
|
+
new Error(`${phase} failed on iteration ${iteration}: ${detail}`),
|
|
155
|
+
{ kind: "phase_error", phase, iteration, cause }
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
function error_kind(err) {
|
|
159
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
160
|
+
const kind = err.kind;
|
|
161
|
+
return typeof kind === "string" ? kind : null;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/config.ts
|
|
167
|
+
var DEFAULT_BUILDER_MODEL = "opus";
|
|
168
|
+
var DEFAULT_CRITIC_MODEL = "opus";
|
|
169
|
+
var DEFAULT_MAX_ITERATIONS = 10;
|
|
170
|
+
var DEFAULT_BUILDER_MAX_STEPS = 50;
|
|
171
|
+
var DEFAULT_CHECK = "auto";
|
|
172
|
+
var DEFAULT_CRITIC = "reviewer";
|
|
173
|
+
var DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
174
|
+
var DEFAULT_CRITIC_PROVIDER = "claude_cli";
|
|
175
|
+
var DEFAULT_PERMISSION_MODE = "acceptEdits";
|
|
176
|
+
var DEFAULT_SANDBOX_IMAGE = "volley-sandbox:latest";
|
|
177
|
+
var BUILDER_PROVIDERS = [
|
|
178
|
+
"claude_cli",
|
|
179
|
+
"ollama",
|
|
180
|
+
"lmstudio"
|
|
181
|
+
];
|
|
182
|
+
var LOCAL_BUILDER_PROVIDERS = [
|
|
183
|
+
"ollama",
|
|
184
|
+
"lmstudio"
|
|
185
|
+
];
|
|
186
|
+
var CRITIC_PROVIDERS = [
|
|
187
|
+
"claude_cli",
|
|
188
|
+
"ollama",
|
|
189
|
+
"lmstudio"
|
|
190
|
+
];
|
|
191
|
+
var CRITIC_PRESETS = [
|
|
192
|
+
"reviewer",
|
|
193
|
+
"optimizer",
|
|
194
|
+
"researcher"
|
|
195
|
+
];
|
|
196
|
+
var PERMISSION_MODES = [
|
|
197
|
+
"acceptEdits",
|
|
198
|
+
"bypassPermissions"
|
|
199
|
+
];
|
|
200
|
+
function expand_at_file(value, cwd) {
|
|
201
|
+
if (!value.startsWith("@")) return value;
|
|
202
|
+
const path = resolve(cwd, value.slice(1));
|
|
203
|
+
if (!existsSync(path)) {
|
|
204
|
+
throw config_error(`file not found: ${path} (from ${value})`);
|
|
205
|
+
}
|
|
206
|
+
return readFileSync(path, "utf8");
|
|
207
|
+
}
|
|
208
|
+
function is_critic_preset(value) {
|
|
209
|
+
return CRITIC_PRESETS.includes(value);
|
|
210
|
+
}
|
|
211
|
+
async function load_config_file(path) {
|
|
212
|
+
const abs = resolve(path);
|
|
213
|
+
if (!existsSync(abs)) {
|
|
214
|
+
throw config_error(`config file not found: ${abs}`);
|
|
215
|
+
}
|
|
216
|
+
let loaded;
|
|
217
|
+
try {
|
|
218
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
219
|
+
loaded = mod.default;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
222
|
+
throw config_error(`failed to load config file ${abs}: ${detail}`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof loaded !== "object" || loaded === null) {
|
|
225
|
+
throw config_error(`config file ${abs} must default-export a VolleyConfig object`);
|
|
226
|
+
}
|
|
227
|
+
return loaded;
|
|
228
|
+
}
|
|
229
|
+
function validate_workspace(workspace) {
|
|
230
|
+
const abs = isAbsolute(workspace) ? workspace : resolve(workspace);
|
|
231
|
+
if (!existsSync(abs)) {
|
|
232
|
+
throw config_error(`workspace not found: ${abs}`);
|
|
233
|
+
}
|
|
234
|
+
if (!statSync(abs).isDirectory()) {
|
|
235
|
+
throw config_error(`workspace is not a directory: ${abs}`);
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
accessSync(abs, constants.W_OK);
|
|
239
|
+
} catch {
|
|
240
|
+
throw config_error(`workspace not writable: ${abs}`);
|
|
241
|
+
}
|
|
242
|
+
return abs;
|
|
243
|
+
}
|
|
244
|
+
function resolve_critic(critic, cwd) {
|
|
245
|
+
if (is_critic_preset(critic)) {
|
|
246
|
+
return { critic_preset: critic, critic_prompt_path: null };
|
|
247
|
+
}
|
|
248
|
+
const path = resolve(cwd, critic);
|
|
249
|
+
if (!existsSync(path)) {
|
|
250
|
+
throw config_error(
|
|
251
|
+
`--critic must be one of ${CRITIC_PRESETS.join(", ")} or a path to a prompt file; got: ${critic}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return { critic_preset: "custom", critic_prompt_path: path };
|
|
255
|
+
}
|
|
256
|
+
function resolve_builder_max_steps(raw, env) {
|
|
257
|
+
if (raw.builder_max_steps !== void 0) {
|
|
258
|
+
return validate_builder_max_steps(raw.builder_max_steps, raw.builder_max_steps);
|
|
259
|
+
}
|
|
260
|
+
const env_value = env["VOLLEY_BUILDER_MAX_STEPS"];
|
|
261
|
+
if (env_value !== void 0 && env_value !== "") {
|
|
262
|
+
return validate_builder_max_steps(Number(env_value), env_value);
|
|
263
|
+
}
|
|
264
|
+
return DEFAULT_BUILDER_MAX_STEPS;
|
|
265
|
+
}
|
|
266
|
+
function validate_builder_max_steps(value, original) {
|
|
267
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
268
|
+
throw config_error(
|
|
269
|
+
`--builder-max-steps / VOLLEY_BUILDER_MAX_STEPS must be a positive integer; got: ${String(original)}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
function resolve_sandbox_image(raw, env) {
|
|
275
|
+
if (raw.sandbox_image !== void 0 && raw.sandbox_image !== "") {
|
|
276
|
+
return raw.sandbox_image;
|
|
277
|
+
}
|
|
278
|
+
const env_value = env["VOLLEY_SANDBOX_IMAGE"];
|
|
279
|
+
if (env_value !== void 0 && env_value !== "") {
|
|
280
|
+
return env_value;
|
|
281
|
+
}
|
|
282
|
+
return DEFAULT_SANDBOX_IMAGE;
|
|
283
|
+
}
|
|
284
|
+
function resolve_allow_unsandboxed_builder(raw, env) {
|
|
285
|
+
if (raw.allow_unsandboxed_builder === true) return true;
|
|
286
|
+
const env_value = env["VOLLEY_ALLOW_UNSANDBOXED_BUILDER"];
|
|
287
|
+
return env_value === "1" || env_value === "true";
|
|
288
|
+
}
|
|
289
|
+
function detect_containment(env) {
|
|
290
|
+
const value = env["VOLLEY_CONTAINED"];
|
|
291
|
+
return value === "1" || value === "true";
|
|
292
|
+
}
|
|
293
|
+
function resolve_config(raw, options = {}) {
|
|
294
|
+
const cwd = options.cwd ?? process.cwd();
|
|
295
|
+
const env = options.env ?? process.env;
|
|
296
|
+
if (typeof raw.prompt !== "string" || raw.prompt.length === 0) {
|
|
297
|
+
throw config_error("missing required option: --prompt");
|
|
298
|
+
}
|
|
299
|
+
if (typeof raw.workspace !== "string" || raw.workspace.length === 0) {
|
|
300
|
+
throw config_error("missing required option: --workspace");
|
|
301
|
+
}
|
|
302
|
+
if (typeof raw.criteria !== "string" || raw.criteria.length === 0) {
|
|
303
|
+
throw config_error("missing required option: --criteria");
|
|
304
|
+
}
|
|
305
|
+
const workspace = validate_workspace(resolve(cwd, raw.workspace));
|
|
306
|
+
const prompt = expand_at_file(raw.prompt, cwd);
|
|
307
|
+
const criteria = expand_at_file(raw.criteria, cwd);
|
|
308
|
+
const max_iterations = raw.max_iterations ?? DEFAULT_MAX_ITERATIONS;
|
|
309
|
+
if (!Number.isInteger(max_iterations) || max_iterations < 1) {
|
|
310
|
+
throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
|
|
311
|
+
}
|
|
312
|
+
const max_cost_usd = raw.max_cost_usd ?? null;
|
|
313
|
+
if (max_cost_usd !== null && !(typeof max_cost_usd === "number" && max_cost_usd > 0)) {
|
|
314
|
+
throw config_error(`--max-cost-usd must be a positive number; got: ${String(raw.max_cost_usd)}`);
|
|
315
|
+
}
|
|
316
|
+
const builder_permission_mode = raw.builder_permission_mode ?? DEFAULT_PERMISSION_MODE;
|
|
317
|
+
if (!PERMISSION_MODES.includes(builder_permission_mode)) {
|
|
318
|
+
throw config_error(
|
|
319
|
+
`--builder-permission-mode must be one of ${PERMISSION_MODES.join(", ")}; got: ${String(raw.builder_permission_mode)}`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const { critic_preset, critic_prompt_path } = resolve_critic(
|
|
323
|
+
raw.critic ?? DEFAULT_CRITIC,
|
|
324
|
+
cwd
|
|
325
|
+
);
|
|
326
|
+
const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
|
|
327
|
+
if (!BUILDER_PROVIDERS.includes(builder_provider)) {
|
|
328
|
+
throw config_error(
|
|
329
|
+
`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
|
|
333
|
+
const contained = detect_containment(env);
|
|
334
|
+
if (LOCAL_BUILDER_PROVIDERS.includes(builder_provider) && !contained && !allow_unsandboxed_builder) {
|
|
335
|
+
throw config_error(
|
|
336
|
+
`builder-provider '${builder_provider}' runs a local model with a real bash (write + exec) \u2014 refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run \u2026, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const builder_max_steps = resolve_builder_max_steps(raw, env);
|
|
340
|
+
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
341
|
+
if (!CRITIC_PROVIDERS.includes(critic_provider)) {
|
|
342
|
+
throw config_error(
|
|
343
|
+
`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (contained) {
|
|
347
|
+
const claude_roles = [
|
|
348
|
+
builder_provider === "claude_cli" ? "builder" : null,
|
|
349
|
+
critic_provider === "claude_cli" ? "critic" : null
|
|
350
|
+
].filter((role) => role !== null);
|
|
351
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
352
|
+
if (claude_roles.length > 0 && auth_mode !== "api_key") {
|
|
353
|
+
throw config_error(
|
|
354
|
+
`claude_cli (${claude_roles.join(", ")}) cannot use its subscription/OAuth token inside a container \u2014 it is mangled there. Set VOLLEY_AUTH_MODE=api_key (with ANTHROPIC_API_KEY) to drive Claude contained, or run claude_cli on the host uncontained.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (raw.git_checkpoints === true && !existsSync(resolve(workspace, ".git"))) {
|
|
359
|
+
throw config_error(`--git requires the workspace to be a git repository: ${workspace}`);
|
|
360
|
+
}
|
|
361
|
+
const worktree = raw.worktree ?? false;
|
|
362
|
+
if (worktree && !existsSync(resolve(workspace, ".git"))) {
|
|
363
|
+
throw config_error(`--worktree requires the workspace to be a git repository: ${workspace}`);
|
|
364
|
+
}
|
|
365
|
+
const discard_worktree = raw.discard_worktree ?? false;
|
|
366
|
+
if (discard_worktree && !worktree) {
|
|
367
|
+
throw config_error(
|
|
368
|
+
"--discard-worktree applies only to a --worktree run: there is no worktree to discard"
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
|
|
372
|
+
if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) {
|
|
373
|
+
throw config_error("gate_paths must be an array of glob strings");
|
|
374
|
+
}
|
|
375
|
+
const sandbox_image = resolve_sandbox_image(raw, env);
|
|
376
|
+
return {
|
|
377
|
+
version: 2,
|
|
378
|
+
run_id: options.run_id ?? randomUUID(),
|
|
379
|
+
started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
380
|
+
prompt,
|
|
381
|
+
criteria,
|
|
382
|
+
check: raw.check ?? DEFAULT_CHECK,
|
|
383
|
+
check_resolved: "none",
|
|
384
|
+
builder_model: raw.builder_model ?? DEFAULT_BUILDER_MODEL,
|
|
385
|
+
builder_provider,
|
|
386
|
+
builder_max_steps,
|
|
387
|
+
allow_unsandboxed_builder,
|
|
388
|
+
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
389
|
+
critic_provider,
|
|
390
|
+
builder_permission_mode,
|
|
391
|
+
critic_preset,
|
|
392
|
+
critic_prompt_path,
|
|
393
|
+
max_iterations,
|
|
394
|
+
max_cost_usd,
|
|
395
|
+
git_checkpoints: raw.git_checkpoints ?? false,
|
|
396
|
+
worktree,
|
|
397
|
+
discard_worktree,
|
|
398
|
+
gate_paths: [...gate_paths],
|
|
399
|
+
fail_on_gate_edit: raw.fail_on_gate_edit ?? false,
|
|
400
|
+
sandbox_image,
|
|
401
|
+
workspace,
|
|
402
|
+
verbose: raw.verbose ?? false,
|
|
403
|
+
quiet: raw.quiet ?? false,
|
|
404
|
+
json: raw.json ?? false,
|
|
405
|
+
show_thinking: raw.show_thinking ?? true
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/check/detect.ts
|
|
410
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
411
|
+
import { join } from "path";
|
|
412
|
+
function package_check_script_is_checkride(workspace) {
|
|
413
|
+
const path = join(workspace, "package.json");
|
|
414
|
+
if (!existsSync2(path)) return false;
|
|
415
|
+
try {
|
|
416
|
+
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
417
|
+
const script = pkg.scripts?.["check"];
|
|
418
|
+
return typeof script === "string" && /(^|\s)checkride(\s|$)/.test(script);
|
|
419
|
+
} catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function checkride_detected(workspace) {
|
|
424
|
+
return existsSync2(join(workspace, "checkride.config.json")) || package_check_script_is_checkride(workspace) || existsSync2(join(workspace, "node_modules", ".bin", "checkride"));
|
|
425
|
+
}
|
|
426
|
+
function resolve_check_runner(check, workspace) {
|
|
427
|
+
if (check === "none") return "none";
|
|
428
|
+
if (check === "auto") {
|
|
429
|
+
return checkride_detected(workspace) ? "checkride" : "none";
|
|
430
|
+
}
|
|
431
|
+
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/exit_codes.ts
|
|
435
|
+
var EXIT_SUCCESS = 0;
|
|
436
|
+
var EXIT_BUDGET_EXHAUSTED = 2;
|
|
437
|
+
var EXIT_BUILDER_ERROR = 3;
|
|
438
|
+
var EXIT_CHECK_ERROR = 4;
|
|
439
|
+
var EXIT_CONFIG_ERROR = 5;
|
|
440
|
+
var EXIT_CRITIC_ERROR = 6;
|
|
441
|
+
var EXIT_COST_CAP = 7;
|
|
442
|
+
var EXIT_GATE_EDIT = 8;
|
|
443
|
+
var EXIT_INTERRUPTED = 130;
|
|
444
|
+
function exit_code_for_status(status) {
|
|
445
|
+
switch (status) {
|
|
446
|
+
case "success":
|
|
447
|
+
return EXIT_SUCCESS;
|
|
448
|
+
case "budget_exhausted":
|
|
449
|
+
return EXIT_BUDGET_EXHAUSTED;
|
|
450
|
+
case "cost_cap_reached":
|
|
451
|
+
return EXIT_COST_CAP;
|
|
452
|
+
case "gate_edit_blocked":
|
|
453
|
+
return EXIT_GATE_EDIT;
|
|
454
|
+
case "interrupted":
|
|
455
|
+
return EXIT_INTERRUPTED;
|
|
456
|
+
default:
|
|
457
|
+
return EXIT_BUILDER_ERROR;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
var PROVIDER_ERROR_KINDS = /* @__PURE__ */ new Set([
|
|
461
|
+
"provider_error",
|
|
462
|
+
"provider_auth_error",
|
|
463
|
+
"provider_not_configured_error",
|
|
464
|
+
"provider_capability_error",
|
|
465
|
+
"claude_cli_error",
|
|
466
|
+
"rate_limit_error",
|
|
467
|
+
"engine_disposed_error",
|
|
468
|
+
"timeout_error"
|
|
469
|
+
]);
|
|
470
|
+
function exit_code_for_error(err) {
|
|
471
|
+
const kind = error_kind(err);
|
|
472
|
+
if (kind === "aborted_error") return EXIT_INTERRUPTED;
|
|
473
|
+
if (kind === "config_error" || kind === "engine_config_error") {
|
|
474
|
+
return EXIT_CONFIG_ERROR;
|
|
475
|
+
}
|
|
476
|
+
if (kind === "check_error") return EXIT_CHECK_ERROR;
|
|
477
|
+
if (kind === "schema_validation_error") return EXIT_CRITIC_ERROR;
|
|
478
|
+
if (kind === "phase_error") {
|
|
479
|
+
const phase = err.phase;
|
|
480
|
+
const cause_kind = error_kind(err.cause);
|
|
481
|
+
if (cause_kind === "aborted_error") return EXIT_INTERRUPTED;
|
|
482
|
+
if (phase === "critic") return EXIT_CRITIC_ERROR;
|
|
483
|
+
if (phase === "check") return EXIT_CHECK_ERROR;
|
|
484
|
+
return EXIT_BUILDER_ERROR;
|
|
485
|
+
}
|
|
486
|
+
if (kind !== null && PROVIDER_ERROR_KINDS.has(kind)) {
|
|
487
|
+
return EXIT_BUILDER_ERROR;
|
|
488
|
+
}
|
|
489
|
+
return EXIT_BUILDER_ERROR;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/iteration.ts
|
|
493
|
+
import {
|
|
494
|
+
existsSync as existsSync4,
|
|
495
|
+
mkdirSync as mkdirSync2,
|
|
496
|
+
readFileSync as readFileSync3,
|
|
497
|
+
readdirSync,
|
|
498
|
+
rmSync,
|
|
499
|
+
writeFileSync as writeFileSync2
|
|
500
|
+
} from "fs";
|
|
501
|
+
import { basename, join as join3 } from "path";
|
|
502
|
+
|
|
503
|
+
// src/cost.ts
|
|
504
|
+
var EMPTY_USAGE = { input_tokens: 0, output_tokens: 0 };
|
|
505
|
+
function add_usage(a, b) {
|
|
506
|
+
const sum = {
|
|
507
|
+
input_tokens: a.input_tokens + b.input_tokens,
|
|
508
|
+
output_tokens: a.output_tokens + b.output_tokens
|
|
509
|
+
};
|
|
510
|
+
const optional = ["cached_input_tokens", "cache_write_tokens", "reasoning_tokens"];
|
|
511
|
+
for (const key of optional) {
|
|
512
|
+
const total = (a[key] ?? 0) + (b[key] ?? 0);
|
|
513
|
+
if (a[key] !== void 0 || b[key] !== void 0) sum[key] = total;
|
|
514
|
+
}
|
|
515
|
+
return sum;
|
|
516
|
+
}
|
|
517
|
+
function cost_source_of(result) {
|
|
518
|
+
if (result.cost === void 0) return "unknown";
|
|
519
|
+
return result.model_resolved.provider === "claude_cli" ? "provider_reported" : "engine_derived";
|
|
520
|
+
}
|
|
521
|
+
function session_of(result) {
|
|
522
|
+
const reported = result.provider_reported?.["claude_cli"];
|
|
523
|
+
return {
|
|
524
|
+
session_id: typeof reported?.session_id === "string" ? reported.session_id : null,
|
|
525
|
+
duration_ms: typeof reported?.duration_ms === "number" ? reported.duration_ms : 0
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function phase_record(result, model) {
|
|
529
|
+
const { session_id, duration_ms } = session_of(result);
|
|
530
|
+
return {
|
|
531
|
+
provider: result.model_resolved.provider,
|
|
532
|
+
model,
|
|
533
|
+
session_id,
|
|
534
|
+
duration_ms,
|
|
535
|
+
usage: result.usage,
|
|
536
|
+
cost_usd: result.cost?.total_usd ?? null,
|
|
537
|
+
cost_source: cost_source_of(result),
|
|
538
|
+
finish_reason: result.finish_reason,
|
|
539
|
+
tool_calls: result.tool_calls.length,
|
|
540
|
+
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function accumulate(state, role, result, model) {
|
|
544
|
+
const record = phase_record(result, model);
|
|
545
|
+
const cost = record.cost_usd ?? 0;
|
|
546
|
+
return {
|
|
547
|
+
...state,
|
|
548
|
+
[role]: record,
|
|
549
|
+
total_usage: add_usage(state.total_usage, result.usage),
|
|
550
|
+
total_cost_usd: state.total_cost_usd + cost,
|
|
551
|
+
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
552
|
+
builder_cost_usd: state.builder_cost_usd + (role === "builder" ? cost : 0),
|
|
553
|
+
critic_cost_usd: state.critic_cost_usd + (role === "critic" ? cost : 0),
|
|
554
|
+
cost_warned: state.cost_warned || record.cost_usd === null
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function cost_cap_hit(config, state) {
|
|
558
|
+
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/workspace.ts
|
|
562
|
+
import { existsSync as existsSync3, mkdirSync, renameSync, writeFileSync } from "fs";
|
|
563
|
+
import { join as join2 } from "path";
|
|
564
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
565
|
+
function volley_path(workspace, ...parts) {
|
|
566
|
+
return join2(workspace, ".volley", ...parts);
|
|
567
|
+
}
|
|
568
|
+
function iteration_dir(workspace, iteration) {
|
|
569
|
+
return volley_path(workspace, "iterations", String(iteration).padStart(3, "0"));
|
|
570
|
+
}
|
|
571
|
+
function backup_stamp(date = /* @__PURE__ */ new Date()) {
|
|
572
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
573
|
+
}
|
|
574
|
+
function initialize_workspace(workspace, options = {}) {
|
|
575
|
+
const dir = volley_path(workspace);
|
|
576
|
+
if (existsSync3(dir) && options.preserve !== true) {
|
|
577
|
+
renameSync(dir, join2(workspace, `.volley.bak.${backup_stamp()}`));
|
|
578
|
+
}
|
|
579
|
+
mkdirSync(volley_path(workspace, "iterations"), { recursive: true });
|
|
580
|
+
}
|
|
581
|
+
function write_resolved_config(config) {
|
|
582
|
+
const record = {
|
|
583
|
+
version: config.version,
|
|
584
|
+
run_id: config.run_id,
|
|
585
|
+
started_at: config.started_at,
|
|
586
|
+
prompt: config.prompt,
|
|
587
|
+
criteria: config.criteria,
|
|
588
|
+
check: config.check,
|
|
589
|
+
check_resolved: config.check_resolved,
|
|
590
|
+
builder_model: config.builder_model,
|
|
591
|
+
builder_provider: config.builder_provider,
|
|
592
|
+
builder_max_steps: config.builder_max_steps,
|
|
593
|
+
critic_model: config.critic_model,
|
|
594
|
+
critic_provider: config.critic_provider,
|
|
595
|
+
builder_permission_mode: config.builder_permission_mode,
|
|
596
|
+
critic_preset: config.critic_preset,
|
|
597
|
+
critic_prompt_path: config.critic_prompt_path,
|
|
598
|
+
max_iterations: config.max_iterations,
|
|
599
|
+
max_cost_usd: config.max_cost_usd,
|
|
600
|
+
git_checkpoints: config.git_checkpoints,
|
|
601
|
+
worktree: config.worktree,
|
|
602
|
+
discard_worktree: config.discard_worktree,
|
|
603
|
+
gate_paths: config.gate_paths,
|
|
604
|
+
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
605
|
+
sandbox_image: config.sandbox_image,
|
|
606
|
+
workspace: config.workspace
|
|
607
|
+
};
|
|
608
|
+
writeFileSync(
|
|
609
|
+
volley_path(config.workspace, "config.json"),
|
|
610
|
+
`${JSON.stringify(record, null, 2)}
|
|
611
|
+
`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
function git_checkpoint(root, message) {
|
|
615
|
+
if (!existsSync3(join2(root, ".git"))) {
|
|
616
|
+
throw config_error(`git checkpoints require a git repository: ${root}`);
|
|
617
|
+
}
|
|
618
|
+
const add = spawnSync2("git", ["add", "-A"], { cwd: root });
|
|
619
|
+
if (add.error !== void 0 || add.status !== 0) {
|
|
620
|
+
throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
621
|
+
}
|
|
622
|
+
const commit = spawnSync2("git", ["commit", "-m", message, "--no-verify"], {
|
|
623
|
+
cwd: root
|
|
624
|
+
});
|
|
625
|
+
if (commit.error !== void 0) {
|
|
626
|
+
throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
627
|
+
}
|
|
628
|
+
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
629
|
+
if (commit.status !== 0 && !out.includes("nothing to commit")) {
|
|
630
|
+
throw check_error(`git commit failed: ${out.trim()}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function integrate_worktree(workspace, branch, message) {
|
|
634
|
+
if (!existsSync3(join2(workspace, ".git"))) {
|
|
635
|
+
throw config_error(`--worktree integration requires a git repository: ${workspace}`);
|
|
636
|
+
}
|
|
637
|
+
const squash = spawnSync2("git", ["merge", "--squash", branch], {
|
|
638
|
+
cwd: workspace,
|
|
639
|
+
encoding: "utf8"
|
|
640
|
+
});
|
|
641
|
+
if (squash.error !== void 0) {
|
|
642
|
+
throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
643
|
+
}
|
|
644
|
+
if (squash.status !== 0) {
|
|
645
|
+
throw check_error(
|
|
646
|
+
`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
const commit = spawnSync2("git", ["commit", "-m", message, "--no-verify"], {
|
|
650
|
+
cwd: workspace,
|
|
651
|
+
encoding: "utf8"
|
|
652
|
+
});
|
|
653
|
+
if (commit.error !== void 0) {
|
|
654
|
+
throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
655
|
+
}
|
|
656
|
+
const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
|
|
657
|
+
if (commit.status !== 0 && !out.includes("nothing to commit")) {
|
|
658
|
+
throw check_error(`git commit failed: ${out.trim()}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// src/iteration.ts
|
|
663
|
+
function write_json(path, value) {
|
|
664
|
+
writeFileSync2(path, `${JSON.stringify(value, null, 2)}
|
|
665
|
+
`);
|
|
666
|
+
}
|
|
667
|
+
function persist_verdict_files(workspace, feedback, verdict) {
|
|
668
|
+
writeFileSync2(volley_path(workspace, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}
|
|
669
|
+
`);
|
|
670
|
+
writeFileSync2(volley_path(workspace, "verdict"), `${verdict}
|
|
671
|
+
`);
|
|
672
|
+
}
|
|
673
|
+
function phase_summary(record) {
|
|
674
|
+
if (record === null) return null;
|
|
675
|
+
return {
|
|
676
|
+
provider: record.provider,
|
|
677
|
+
model: record.model,
|
|
678
|
+
session_id: record.session_id,
|
|
679
|
+
duration_ms: record.duration_ms,
|
|
680
|
+
usage: record.usage,
|
|
681
|
+
cost_usd: record.cost_usd,
|
|
682
|
+
cost_source: record.cost_source,
|
|
683
|
+
finish_reason: record.finish_reason,
|
|
684
|
+
tool_calls: record.tool_calls,
|
|
685
|
+
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
686
|
+
// Salvage rate (a health metric): the share of tool calls recovered from
|
|
687
|
+
// assistant text. A high rate on a local run means the model's native
|
|
688
|
+
// tool-call encoding is drifting from its runtime's parser (pin them as one
|
|
689
|
+
// unit). 0 when the phase made no tool calls (the CLI builder's own loop).
|
|
690
|
+
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
691
|
+
// Degradation-ladder bookkeeping, critic-only: the provider-error
|
|
692
|
+
// retries that preceded this verdict, their cause, and whether it was rendered
|
|
693
|
+
// by the tool-less fallback. Emitted only when set, so the builder record and
|
|
694
|
+
// old summary consumers are untouched. `critic_degraded` is the on-disk
|
|
695
|
+
// half of the contract — the comparison block reads it back from here.
|
|
696
|
+
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
697
|
+
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
698
|
+
...record.critic_degraded === true ? { critic_degraded: true } : {}
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function archive_iteration(config, state) {
|
|
702
|
+
const dir = iteration_dir(config.workspace, state.iteration);
|
|
703
|
+
mkdirSync2(join3(dir, "check"), { recursive: true });
|
|
704
|
+
if (state.verdict !== null && state.feedback !== null) {
|
|
705
|
+
persist_verdict_files(config.workspace, state.feedback, state.verdict);
|
|
706
|
+
writeFileSync2(join3(dir, "feedback.md"), readFileSync3(volley_path(config.workspace, "feedback.md")));
|
|
707
|
+
writeFileSync2(join3(dir, "verdict"), `${state.verdict}
|
|
708
|
+
`);
|
|
709
|
+
}
|
|
710
|
+
const check = state.check;
|
|
711
|
+
if (check !== null && check.ran) {
|
|
712
|
+
if (check.runner === "checkride") {
|
|
713
|
+
if (check.summary !== void 0) {
|
|
714
|
+
write_json(join3(dir, "check", "summary.json"), check.summary);
|
|
715
|
+
}
|
|
716
|
+
for (const artifact of check.detail) {
|
|
717
|
+
if (artifact.path !== null && existsSync4(artifact.path)) {
|
|
718
|
+
writeFileSync2(join3(dir, "check", basename(artifact.path)), readFileSync3(artifact.path));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
writeFileSync2(join3(dir, "check", "check.log"), check.log ?? "");
|
|
723
|
+
writeFileSync2(join3(dir, "check", "check.exit"), `${String(check.exit_code)}
|
|
724
|
+
`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const completed_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
728
|
+
write_json(join3(dir, "summary.json"), {
|
|
729
|
+
iteration: state.iteration,
|
|
730
|
+
started_at: state.iteration_started_at,
|
|
731
|
+
completed_at,
|
|
732
|
+
duration_ms: Date.parse(completed_at) - Date.parse(state.iteration_started_at),
|
|
733
|
+
builder: phase_summary(state.builder),
|
|
734
|
+
check: check === null ? null : {
|
|
735
|
+
ran: check.ran,
|
|
736
|
+
runner: check.runner,
|
|
737
|
+
ok: check.ok,
|
|
738
|
+
exit_code: check.exit_code,
|
|
739
|
+
duration_ms: check.duration_ms,
|
|
740
|
+
failing_slots: check.failing_slots
|
|
741
|
+
},
|
|
742
|
+
critic: phase_summary(state.critic),
|
|
743
|
+
// What the builder reached for this iteration (`src/changes.ts`), archived
|
|
744
|
+
// beside the check's verdict so the two can be read against each other after
|
|
745
|
+
// the fact: green + a gate edit is a different result from green alone.
|
|
746
|
+
// Null when the build root is not a git repository. Additive.
|
|
747
|
+
changes: state.changes,
|
|
748
|
+
verdict: state.verdict,
|
|
749
|
+
// The criteria the critic judged unmet, verbatim. The run-level summary
|
|
750
|
+
// reads the last iteration's list, so a non-converged run says what it was
|
|
751
|
+
// still missing when it stopped instead of only that it stopped. Additive.
|
|
752
|
+
unmet_criteria: state.unmet_criteria,
|
|
753
|
+
iteration_cost_usd: state.iteration_cost_usd,
|
|
754
|
+
iteration_total_cost_usd: state.total_cost_usd
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function run_result_from_state(config, state, status, completed_at) {
|
|
758
|
+
return {
|
|
759
|
+
run_id: config.run_id,
|
|
760
|
+
status,
|
|
761
|
+
started_at: config.started_at,
|
|
762
|
+
completed_at,
|
|
763
|
+
iterations_completed: state.iteration,
|
|
764
|
+
total_usage: state.total_usage,
|
|
765
|
+
total_cost_usd: state.total_cost_usd,
|
|
766
|
+
builder_cost_usd: state.builder_cost_usd,
|
|
767
|
+
critic_cost_usd: state.critic_cost_usd,
|
|
768
|
+
check_duration_ms: state.check_duration_ms,
|
|
769
|
+
final_verdict: state.verdict,
|
|
770
|
+
// The loop itself never salvages: the orchestrator re-stamps this after
|
|
771
|
+
// teardown when a converged `--worktree` run's work was kept on its branch,
|
|
772
|
+
// which is the only moment the branch's survival is known.
|
|
773
|
+
salvaged_branch: null
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function load_resume_state(workspace, run_id) {
|
|
777
|
+
const config_path = volley_path(workspace, "config.json");
|
|
778
|
+
if (!existsSync4(config_path)) {
|
|
779
|
+
throw config_error(`no resumable run found: ${config_path} missing`);
|
|
780
|
+
}
|
|
781
|
+
const recorded = JSON.parse(readFileSync3(config_path, "utf8"));
|
|
782
|
+
if (recorded["run_id"] !== run_id) {
|
|
783
|
+
throw config_error(
|
|
784
|
+
`run ${run_id} not found in ${workspace} (recorded run: ${String(recorded["run_id"])})`
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const iterations_root = volley_path(workspace, "iterations");
|
|
788
|
+
const dirs = existsSync4(iterations_root) ? readdirSync(iterations_root).filter((d) => /^\d{3}$/.test(d)).toSorted() : [];
|
|
789
|
+
let completed = 0;
|
|
790
|
+
for (const dir of dirs) {
|
|
791
|
+
const full = join3(iterations_root, dir);
|
|
792
|
+
if (existsSync4(join3(full, "summary.json"))) {
|
|
793
|
+
completed = Number(dir);
|
|
794
|
+
} else {
|
|
795
|
+
rmSync(full, { recursive: true, force: true });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const summary_path = volley_path(workspace, "summary.json");
|
|
799
|
+
const summary = existsSync4(summary_path) ? JSON.parse(readFileSync3(summary_path, "utf8")) : null;
|
|
800
|
+
const feedback_path = volley_path(workspace, "feedback.md");
|
|
801
|
+
const feedback = existsSync4(feedback_path) ? readFileSync3(feedback_path, "utf8") : null;
|
|
802
|
+
const raw_config = {
|
|
803
|
+
prompt: String(recorded["prompt"]),
|
|
804
|
+
workspace: String(recorded["workspace"]),
|
|
805
|
+
criteria: String(recorded["criteria"]),
|
|
806
|
+
check: String(recorded["check"]),
|
|
807
|
+
builder_model: String(recorded["builder_model"]),
|
|
808
|
+
builder_provider: recorded["builder_provider"] ?? "claude_cli",
|
|
809
|
+
...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
|
|
810
|
+
critic_model: String(recorded["critic_model"]),
|
|
811
|
+
critic_provider: recorded["critic_provider"] ?? "claude_cli",
|
|
812
|
+
builder_permission_mode: recorded["builder_permission_mode"],
|
|
813
|
+
critic: recorded["critic_preset"] === "custom" ? String(recorded["critic_prompt_path"]) : String(recorded["critic_preset"]),
|
|
814
|
+
max_iterations: Number(recorded["max_iterations"]),
|
|
815
|
+
max_cost_usd: recorded["max_cost_usd"],
|
|
816
|
+
git_checkpoints: recorded["git_checkpoints"] === true,
|
|
817
|
+
worktree: recorded["worktree"] === true,
|
|
818
|
+
discard_worktree: recorded["discard_worktree"] === true,
|
|
819
|
+
...Array.isArray(recorded["gate_paths"]) ? { gate_paths: recorded["gate_paths"] } : {},
|
|
820
|
+
fail_on_gate_edit: recorded["fail_on_gate_edit"] === true,
|
|
821
|
+
...recorded["sandbox_image"] === void 0 ? {} : { sandbox_image: recorded["sandbox_image"] }
|
|
822
|
+
};
|
|
823
|
+
const state = {
|
|
824
|
+
iteration: completed,
|
|
825
|
+
iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
826
|
+
feedback,
|
|
827
|
+
verdict: null,
|
|
828
|
+
unmet_criteria: [],
|
|
829
|
+
check: null,
|
|
830
|
+
// Re-measured by the resumed run's first `build` step, against a baseline
|
|
831
|
+
// captured at resume — a resumed run reports what it changes from there.
|
|
832
|
+
changes: null,
|
|
833
|
+
builder: null,
|
|
834
|
+
critic: null,
|
|
835
|
+
total_usage: summary?.total_usage ?? EMPTY_USAGE,
|
|
836
|
+
total_cost_usd: summary?.total_cost_usd ?? 0,
|
|
837
|
+
builder_cost_usd: summary?.builder_cost_usd ?? 0,
|
|
838
|
+
critic_cost_usd: summary?.critic_cost_usd ?? 0,
|
|
839
|
+
check_duration_ms: summary?.check_duration_ms ?? 0,
|
|
840
|
+
iteration_cost_usd: 0,
|
|
841
|
+
halt: null,
|
|
842
|
+
cost_warned: false
|
|
843
|
+
};
|
|
844
|
+
return {
|
|
845
|
+
raw_config,
|
|
846
|
+
run_id,
|
|
847
|
+
started_at: String(recorded["started_at"]),
|
|
848
|
+
state,
|
|
849
|
+
iterations_completed: completed
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/matrix.ts
|
|
854
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "fs";
|
|
855
|
+
import { join as join9 } from "path";
|
|
856
|
+
|
|
857
|
+
// src/orchestrator.ts
|
|
858
|
+
import { existsSync as existsSync11, readFileSync as readFileSync11 } from "fs";
|
|
859
|
+
import { loop, run, sequence, step } from "fascicle";
|
|
860
|
+
import { filesystem_logger } from "fascicle/adapters";
|
|
861
|
+
|
|
862
|
+
// src/builder.ts
|
|
863
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
|
|
864
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
865
|
+
import { fileURLToPath } from "url";
|
|
866
|
+
|
|
867
|
+
// src/builder/context_check.ts
|
|
868
|
+
var MIN_RECOMMENDED_NUM_CTX = 16384;
|
|
869
|
+
var PROBE_TIMEOUT_MS = 2e3;
|
|
870
|
+
function context_warning(model, num_ctx) {
|
|
871
|
+
if (num_ctx === null || num_ctx >= MIN_RECOMMENDED_NUM_CTX) return null;
|
|
872
|
+
return `local builder model '${model}' is configured with num_ctx ${String(num_ctx)}, below the ~${String(MIN_RECOMMENDED_NUM_CTX)} volley recommends: a small context window silently truncates the tool schemas (the #1 local tool-calling failure). Raise it (e.g. a Modelfile \`PARAMETER num_ctx 16384\`) or expect the model to ignore its tools.`;
|
|
873
|
+
}
|
|
874
|
+
function parse_num_ctx(parameters) {
|
|
875
|
+
if (parameters === null) return null;
|
|
876
|
+
const match = /^\s*num_ctx\s+(\d+)\s*$/m.exec(parameters);
|
|
877
|
+
if (match === null) return null;
|
|
878
|
+
const value = Number(match[1]);
|
|
879
|
+
return Number.isFinite(value) ? value : null;
|
|
880
|
+
}
|
|
881
|
+
async function probe_ollama_num_ctx(base_url, model, abort) {
|
|
882
|
+
const signals = [AbortSignal.timeout(PROBE_TIMEOUT_MS)];
|
|
883
|
+
if (abort !== void 0) signals.push(abort);
|
|
884
|
+
try {
|
|
885
|
+
const res = await fetch(`${base_url.replace(/\/$/, "")}/api/show`, {
|
|
886
|
+
method: "POST",
|
|
887
|
+
headers: { "content-type": "application/json" },
|
|
888
|
+
body: JSON.stringify({ model }),
|
|
889
|
+
signal: AbortSignal.any(signals)
|
|
890
|
+
});
|
|
891
|
+
if (!res.ok) return null;
|
|
892
|
+
const body = await res.json();
|
|
893
|
+
return parse_num_ctx(typeof body.parameters === "string" ? body.parameters : null);
|
|
894
|
+
} catch {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
async function warn_small_local_context(provider, model, base_url, warn, abort) {
|
|
899
|
+
if (provider !== "ollama") return;
|
|
900
|
+
const warning = context_warning(model, await probe_ollama_num_ctx(base_url, model, abort));
|
|
901
|
+
if (warning !== null) warn(warning);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/builder/tools.ts
|
|
905
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
906
|
+
import { lookup as dns_lookup } from "dns";
|
|
907
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
908
|
+
import { dirname } from "path";
|
|
909
|
+
import { Readability } from "@mozilla/readability";
|
|
910
|
+
import ipaddr from "ipaddr.js";
|
|
911
|
+
import { parseHTML } from "linkedom";
|
|
912
|
+
import TurndownService from "turndown";
|
|
913
|
+
import { Agent, buildConnector, interceptors, request } from "undici";
|
|
914
|
+
import { z as z2 } from "zod";
|
|
915
|
+
|
|
916
|
+
// src/workspace_tools.ts
|
|
917
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
918
|
+
import { isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve2, sep } from "path";
|
|
919
|
+
import { z } from "zod";
|
|
920
|
+
var READ_FILE_MAX_BYTES = 2e5;
|
|
921
|
+
var SEARCH_MAX_MATCHES = 200;
|
|
922
|
+
var LIST_MAX_ENTRIES = 2e3;
|
|
923
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
924
|
+
"node_modules",
|
|
925
|
+
".git",
|
|
926
|
+
".volley",
|
|
927
|
+
".check",
|
|
928
|
+
"dist",
|
|
929
|
+
".cache"
|
|
930
|
+
]);
|
|
931
|
+
function contain(workspace, rel) {
|
|
932
|
+
const root = resolve2(workspace);
|
|
933
|
+
const candidate = isAbsolute2(rel) ? resolve2(rel) : resolve2(root, rel);
|
|
934
|
+
const within = candidate === root || candidate.startsWith(`${root}${sep}`);
|
|
935
|
+
if (!within) {
|
|
936
|
+
throw new Error(`path escapes the workspace: ${rel}`);
|
|
937
|
+
}
|
|
938
|
+
return candidate;
|
|
939
|
+
}
|
|
940
|
+
function walk(root, start, limit) {
|
|
941
|
+
const out = [];
|
|
942
|
+
const stack = [start];
|
|
943
|
+
while (stack.length > 0 && out.length < limit) {
|
|
944
|
+
const dir = stack.pop();
|
|
945
|
+
let entries;
|
|
946
|
+
try {
|
|
947
|
+
entries = readdirSync2(dir);
|
|
948
|
+
} catch {
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
for (const name of entries) {
|
|
952
|
+
const abs = join4(dir, name);
|
|
953
|
+
let is_dir;
|
|
954
|
+
try {
|
|
955
|
+
is_dir = statSync2(abs).isDirectory();
|
|
956
|
+
} catch {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (is_dir) {
|
|
960
|
+
if (!IGNORED_DIRS.has(name)) stack.push(abs);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
out.push({ rel: relative(root, abs), abs });
|
|
964
|
+
if (out.length >= limit) break;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
function build_regex(pattern, flags) {
|
|
970
|
+
try {
|
|
971
|
+
return new RegExp(pattern, flags);
|
|
972
|
+
} catch (err) {
|
|
973
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
974
|
+
throw new Error(`invalid regular expression: ${detail}`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
var read_file_input = z.object({
|
|
978
|
+
path: z.string().describe("Workspace-relative path to the file to read.")
|
|
979
|
+
});
|
|
980
|
+
var search_files_input = z.object({
|
|
981
|
+
pattern: z.string().describe("JavaScript regular expression to match against each line."),
|
|
982
|
+
path: z.string().optional().describe("Optional workspace-relative subdirectory to limit the search to."),
|
|
983
|
+
ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
|
|
984
|
+
});
|
|
985
|
+
var list_files_input = z.object({
|
|
986
|
+
path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
|
|
987
|
+
contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
|
|
988
|
+
});
|
|
989
|
+
function read_only_tools(workspace) {
|
|
990
|
+
const read_file = {
|
|
991
|
+
name: "read_file",
|
|
992
|
+
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
993
|
+
input_schema: read_file_input,
|
|
994
|
+
execute: (raw) => {
|
|
995
|
+
const input = read_file_input.parse(raw);
|
|
996
|
+
const abs = contain(workspace, input.path);
|
|
997
|
+
const content = readFileSync4(abs, "utf8");
|
|
998
|
+
return content.length > READ_FILE_MAX_BYTES ? `${content.slice(0, READ_FILE_MAX_BYTES)}
|
|
999
|
+
\u2026 [truncated at ${String(READ_FILE_MAX_BYTES)} bytes]` : content;
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
const search_files = {
|
|
1003
|
+
name: "search_files",
|
|
1004
|
+
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(SEARCH_MAX_MATCHES)} "relative/path:line: text" hits.`,
|
|
1005
|
+
input_schema: search_files_input,
|
|
1006
|
+
execute: (raw) => {
|
|
1007
|
+
const input = search_files_input.parse(raw);
|
|
1008
|
+
const start = input.path !== void 0 ? contain(workspace, input.path) : resolve2(workspace);
|
|
1009
|
+
const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
|
|
1010
|
+
const files = walk(resolve2(workspace), start, LIST_MAX_ENTRIES);
|
|
1011
|
+
const hits = [];
|
|
1012
|
+
for (const file of files) {
|
|
1013
|
+
if (hits.length >= SEARCH_MAX_MATCHES) break;
|
|
1014
|
+
let content;
|
|
1015
|
+
try {
|
|
1016
|
+
content = readFileSync4(file.abs, "utf8");
|
|
1017
|
+
} catch {
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const lines = content.split("\n");
|
|
1021
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1022
|
+
if (hits.length >= SEARCH_MAX_MATCHES) break;
|
|
1023
|
+
const line = lines[i];
|
|
1024
|
+
if (regex.test(line)) hits.push(`${file.rel}:${String(i + 1)}: ${line.trim()}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (hits.length === 0) return "no matches";
|
|
1028
|
+
const capped = hits.length >= SEARCH_MAX_MATCHES ? `
|
|
1029
|
+
\u2026 [capped at ${String(SEARCH_MAX_MATCHES)} matches]` : "";
|
|
1030
|
+
return `${hits.join("\n")}${capped}`;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
const list_files = {
|
|
1034
|
+
name: "list_files",
|
|
1035
|
+
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
1036
|
+
input_schema: list_files_input,
|
|
1037
|
+
execute: (raw) => {
|
|
1038
|
+
const input = list_files_input.parse(raw);
|
|
1039
|
+
const start = input.path !== void 0 ? contain(workspace, input.path) : resolve2(workspace);
|
|
1040
|
+
let files = walk(resolve2(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
|
|
1041
|
+
if (input.contains !== void 0) {
|
|
1042
|
+
const needle = input.contains;
|
|
1043
|
+
files = files.filter((f) => f.includes(needle));
|
|
1044
|
+
}
|
|
1045
|
+
if (files.length === 0) return "no files";
|
|
1046
|
+
const capped = files.length >= LIST_MAX_ENTRIES ? `
|
|
1047
|
+
\u2026 [capped at ${String(LIST_MAX_ENTRIES)} entries]` : "";
|
|
1048
|
+
return `${files.join("\n")}${capped}`;
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
return [read_file, search_files, list_files];
|
|
1052
|
+
}
|
|
1053
|
+
function workspace_inventory(root, limit = LIST_MAX_ENTRIES) {
|
|
1054
|
+
const base = resolve2(root);
|
|
1055
|
+
const files = walk(base, base, limit);
|
|
1056
|
+
if (files.length === 0) return "no files";
|
|
1057
|
+
const lines = files.map((file) => {
|
|
1058
|
+
let size;
|
|
1059
|
+
try {
|
|
1060
|
+
size = statSync2(file.abs).size;
|
|
1061
|
+
} catch {
|
|
1062
|
+
size = 0;
|
|
1063
|
+
}
|
|
1064
|
+
return `${file.rel} (${String(size)} bytes)`;
|
|
1065
|
+
});
|
|
1066
|
+
const capped = files.length >= limit ? `
|
|
1067
|
+
\u2026 [capped at ${String(limit)} entries]` : "";
|
|
1068
|
+
return `${lines.join("\n")}${capped}`;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// src/builder/tools.ts
|
|
1072
|
+
var WRITE_FILE_MAX_BYTES = 1e6;
|
|
1073
|
+
var BASH_TIMEOUT_MS = 6e5;
|
|
1074
|
+
var BASH_MAX_OUTPUT_BYTES = 1e5;
|
|
1075
|
+
var BASH_CAPTURE_MAX_BYTES = 1e7;
|
|
1076
|
+
var FETCH_MAX_BYTES = 2e5;
|
|
1077
|
+
var FETCH_MAX_CHARS = 5e3;
|
|
1078
|
+
var FETCH_MAX_REDIRECTS = 5;
|
|
1079
|
+
var FETCH_TIMEOUT_MS = 3e4;
|
|
1080
|
+
var FETCH_MIN_EXTRACT_CHARS = 200;
|
|
1081
|
+
var FETCH_USER_AGENT = "volley-local-builder";
|
|
1082
|
+
var write_file_input = z2.object({
|
|
1083
|
+
path: z2.string().describe("Workspace-relative path to write. Parent directories are created."),
|
|
1084
|
+
content: z2.string().describe("Full UTF-8 content of the file. Overwrites any existing content.")
|
|
1085
|
+
});
|
|
1086
|
+
var edit_file_input = z2.object({
|
|
1087
|
+
path: z2.string().describe("Workspace-relative path of the file to edit."),
|
|
1088
|
+
old_str: z2.string().min(1).describe("Exact text to replace. Must occur exactly once in the file."),
|
|
1089
|
+
new_str: z2.string().describe("Replacement text.")
|
|
1090
|
+
});
|
|
1091
|
+
var bash_input = z2.object({
|
|
1092
|
+
command: z2.string().describe("Shell command to run in the workspace directory.")
|
|
1093
|
+
});
|
|
1094
|
+
var finish_input = z2.object({
|
|
1095
|
+
summary: z2.string().describe("Short summary of what was accomplished.")
|
|
1096
|
+
});
|
|
1097
|
+
var fetch_input = z2.object({
|
|
1098
|
+
url: z2.string().describe("Absolute http(s) URL to fetch."),
|
|
1099
|
+
max_chars: z2.number().int().positive().optional().describe(`Maximum characters of extracted markdown to return (default ${String(FETCH_MAX_CHARS)}).`),
|
|
1100
|
+
start_index: z2.number().int().min(0).optional().describe("Character offset into the extracted markdown to start from, for paging a long page. Default 0.")
|
|
1101
|
+
});
|
|
1102
|
+
function count_occurrences(haystack, needle) {
|
|
1103
|
+
return haystack.split(needle).length - 1;
|
|
1104
|
+
}
|
|
1105
|
+
function truncate_bytes(text, max) {
|
|
1106
|
+
if (Buffer.byteLength(text, "utf8") <= max) return text;
|
|
1107
|
+
const clipped = Buffer.from(text, "utf8").subarray(0, max).toString("utf8");
|
|
1108
|
+
return `${clipped}
|
|
1109
|
+
\u2026 [truncated at ${String(max)} bytes]`;
|
|
1110
|
+
}
|
|
1111
|
+
function is_forbidden_address(address) {
|
|
1112
|
+
let parsed;
|
|
1113
|
+
try {
|
|
1114
|
+
parsed = ipaddr.parse(address);
|
|
1115
|
+
} catch {
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
const canonical = "isIPv4MappedAddress" in parsed && parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed;
|
|
1119
|
+
return canonical.range() !== "unicast";
|
|
1120
|
+
}
|
|
1121
|
+
var default_resolver = (hostname, options, callback) => {
|
|
1122
|
+
dns_lookup(hostname, options, callback);
|
|
1123
|
+
};
|
|
1124
|
+
function guarded_lookup(is_forbidden, resolve5) {
|
|
1125
|
+
return (hostname, options, callback) => {
|
|
1126
|
+
resolve5(hostname, options, (err, address, family) => {
|
|
1127
|
+
if (err !== null) {
|
|
1128
|
+
callback(err, "", 0);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const resolved = Array.isArray(address) ? address.map((entry) => entry.address) : [address];
|
|
1132
|
+
const blocked = resolved.find((entry) => is_forbidden(entry));
|
|
1133
|
+
if (blocked !== void 0) {
|
|
1134
|
+
callback(ssrf_error(blocked, hostname), "", 0);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
callback(null, address, family);
|
|
1138
|
+
});
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
function ssrf_error(address, hostname) {
|
|
1142
|
+
const err = new Error(
|
|
1143
|
+
`blocked non-unicast address ${address} for host "${hostname}"`
|
|
1144
|
+
);
|
|
1145
|
+
err.code = "ERR_SSRF_BLOCKED";
|
|
1146
|
+
return err;
|
|
1147
|
+
}
|
|
1148
|
+
function create_ssrf_agent(is_forbidden, resolve5) {
|
|
1149
|
+
const base = buildConnector({ lookup: guarded_lookup(is_forbidden, resolve5), timeout: FETCH_TIMEOUT_MS });
|
|
1150
|
+
const connector = (options, callback) => {
|
|
1151
|
+
base(options, (err, socket) => {
|
|
1152
|
+
if (err !== null) {
|
|
1153
|
+
callback(err, null);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (socket === null) {
|
|
1157
|
+
callback(new Error("connection produced no socket"), null);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
const remote = socket.remoteAddress;
|
|
1161
|
+
if (remote !== void 0 && is_forbidden(remote)) {
|
|
1162
|
+
socket.destroy();
|
|
1163
|
+
callback(ssrf_error(remote, options.hostname), null);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
callback(null, socket);
|
|
1167
|
+
});
|
|
1168
|
+
};
|
|
1169
|
+
return new Agent({ connect: connector, headersTimeout: FETCH_TIMEOUT_MS, bodyTimeout: FETCH_TIMEOUT_MS });
|
|
1170
|
+
}
|
|
1171
|
+
async function read_body_capped(body, max_bytes) {
|
|
1172
|
+
const chunks = [];
|
|
1173
|
+
let total = 0;
|
|
1174
|
+
for await (const chunk of body) {
|
|
1175
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1176
|
+
const remaining = max_bytes - total;
|
|
1177
|
+
if (buf.length >= remaining) {
|
|
1178
|
+
chunks.push(buf.subarray(0, remaining));
|
|
1179
|
+
break;
|
|
1180
|
+
}
|
|
1181
|
+
chunks.push(buf);
|
|
1182
|
+
total += buf.length;
|
|
1183
|
+
}
|
|
1184
|
+
return Buffer.concat(chunks);
|
|
1185
|
+
}
|
|
1186
|
+
function header_value(value) {
|
|
1187
|
+
if (Array.isArray(value)) return value[0] ?? "";
|
|
1188
|
+
return value ?? "";
|
|
1189
|
+
}
|
|
1190
|
+
function content_kind(content_type, body) {
|
|
1191
|
+
const ct = content_type.toLowerCase();
|
|
1192
|
+
if (ct.includes("html") || ct.includes("xml")) return "html";
|
|
1193
|
+
if (ct.startsWith("text/") || ct.includes("json") || ct.includes("javascript") || ct.includes("csv")) {
|
|
1194
|
+
return "text";
|
|
1195
|
+
}
|
|
1196
|
+
if (ct === "") return body.trimStart().startsWith("<") ? "html" : "text";
|
|
1197
|
+
return "binary";
|
|
1198
|
+
}
|
|
1199
|
+
function extract_markdown(html) {
|
|
1200
|
+
const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
|
1201
|
+
turndown.remove(["script", "style", "noscript"]);
|
|
1202
|
+
let body_markdown = "";
|
|
1203
|
+
let title = "";
|
|
1204
|
+
try {
|
|
1205
|
+
const { document } = parseHTML(html);
|
|
1206
|
+
const article = new Readability(document).parse();
|
|
1207
|
+
if (article !== null) {
|
|
1208
|
+
title = (article.title ?? "").trim();
|
|
1209
|
+
if (article.content) {
|
|
1210
|
+
body_markdown = turndown.turndown(article.content).trim();
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
} catch {
|
|
1214
|
+
body_markdown = "";
|
|
1215
|
+
}
|
|
1216
|
+
if (body_markdown.length < FETCH_MIN_EXTRACT_CHARS) {
|
|
1217
|
+
let fallback = "";
|
|
1218
|
+
try {
|
|
1219
|
+
fallback = turndown.turndown(html).trim();
|
|
1220
|
+
} catch {
|
|
1221
|
+
fallback = "";
|
|
1222
|
+
}
|
|
1223
|
+
if (fallback.length > body_markdown.length) body_markdown = fallback;
|
|
1224
|
+
}
|
|
1225
|
+
const prefix = title.length > 0 && !body_markdown.startsWith(`# ${title}`) ? `# ${title}
|
|
1226
|
+
|
|
1227
|
+
` : "";
|
|
1228
|
+
return `${prefix}${body_markdown}`.trim();
|
|
1229
|
+
}
|
|
1230
|
+
function paginate(full, max_chars, start_index) {
|
|
1231
|
+
if (start_index >= full.length) {
|
|
1232
|
+
return `(no more content: start_index ${String(start_index)} is at or past the end of the ${String(full.length)}-character document)`;
|
|
1233
|
+
}
|
|
1234
|
+
const slice = full.slice(start_index, start_index + max_chars);
|
|
1235
|
+
const next = start_index + slice.length;
|
|
1236
|
+
if (next >= full.length) return slice;
|
|
1237
|
+
const remaining = full.length - next;
|
|
1238
|
+
return `${slice}
|
|
1239
|
+
|
|
1240
|
+
<truncated: ${String(remaining)} more characters. Call fetch again with start_index=${String(next)} to continue.>`;
|
|
1241
|
+
}
|
|
1242
|
+
function error_detail(err) {
|
|
1243
|
+
const parts = [];
|
|
1244
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1245
|
+
let current = err;
|
|
1246
|
+
while (current !== void 0 && current !== null && !seen.has(current)) {
|
|
1247
|
+
seen.add(current);
|
|
1248
|
+
if (current instanceof Error) {
|
|
1249
|
+
const code = current.code;
|
|
1250
|
+
parts.push(code !== void 0 ? `${current.message} [${code}]` : current.message);
|
|
1251
|
+
current = current.cause;
|
|
1252
|
+
} else {
|
|
1253
|
+
parts.push(typeof current === "string" ? current : `non-error value (${typeof current})`);
|
|
1254
|
+
break;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return parts.length > 0 ? parts.join(" <- ") : "unknown error";
|
|
1258
|
+
}
|
|
1259
|
+
async function run_fetch(raw, ctx, config) {
|
|
1260
|
+
const input = fetch_input.parse(raw);
|
|
1261
|
+
const max_chars = input.max_chars ?? FETCH_MAX_CHARS;
|
|
1262
|
+
const start_index = input.start_index ?? 0;
|
|
1263
|
+
let target;
|
|
1264
|
+
try {
|
|
1265
|
+
target = new URL(input.url);
|
|
1266
|
+
} catch {
|
|
1267
|
+
return `error: not a valid absolute URL: ${input.url}`;
|
|
1268
|
+
}
|
|
1269
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
1270
|
+
return `error: unsupported URL scheme "${target.protocol}" \u2014 only http and https are fetched`;
|
|
1271
|
+
}
|
|
1272
|
+
const agent = create_ssrf_agent(config.is_forbidden, config.resolve);
|
|
1273
|
+
const dispatcher = agent.compose(interceptors.redirect({ maxRedirections: FETCH_MAX_REDIRECTS }));
|
|
1274
|
+
try {
|
|
1275
|
+
const res = await request(target.href, {
|
|
1276
|
+
dispatcher,
|
|
1277
|
+
signal: ctx.abort,
|
|
1278
|
+
headersTimeout: FETCH_TIMEOUT_MS,
|
|
1279
|
+
bodyTimeout: FETCH_TIMEOUT_MS,
|
|
1280
|
+
headers: {
|
|
1281
|
+
"user-agent": FETCH_USER_AGENT,
|
|
1282
|
+
accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
|
|
1283
|
+
// No compression: keep the pipeline simple and the byte cap honest.
|
|
1284
|
+
"accept-encoding": "identity"
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
if (res.statusCode >= 400) {
|
|
1288
|
+
await res.body.dump();
|
|
1289
|
+
return `error: HTTP ${String(res.statusCode)} fetching ${target.href}`;
|
|
1290
|
+
}
|
|
1291
|
+
const bytes = await read_body_capped(res.body, config.max_bytes);
|
|
1292
|
+
const content_type = header_value(res.headers["content-type"]);
|
|
1293
|
+
const text = bytes.toString("utf8");
|
|
1294
|
+
if (text.trim().length === 0) {
|
|
1295
|
+
return `error: empty response body from ${target.href}`;
|
|
1296
|
+
}
|
|
1297
|
+
const kind = content_kind(content_type, text);
|
|
1298
|
+
if (kind === "binary") {
|
|
1299
|
+
return `error: cannot extract non-text content (content-type: ${content_type}) from ${target.href}`;
|
|
1300
|
+
}
|
|
1301
|
+
const markdown = kind === "html" ? extract_markdown(text) : text.trim();
|
|
1302
|
+
if (markdown.length === 0) {
|
|
1303
|
+
return `error: no readable content extracted from ${target.href}`;
|
|
1304
|
+
}
|
|
1305
|
+
return paginate(markdown, max_chars, start_index);
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
return `error: could not fetch ${target.href}: ${error_detail(err)}`;
|
|
1308
|
+
} finally {
|
|
1309
|
+
await agent.close().catch(() => void 0);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function host_bash_executor(cwd) {
|
|
1313
|
+
return (command, options) => {
|
|
1314
|
+
const result = spawnSync3(command, {
|
|
1315
|
+
shell: true,
|
|
1316
|
+
cwd,
|
|
1317
|
+
encoding: "utf8",
|
|
1318
|
+
timeout: options.timeout_ms,
|
|
1319
|
+
killSignal: "SIGKILL",
|
|
1320
|
+
maxBuffer: options.max_capture_bytes
|
|
1321
|
+
});
|
|
1322
|
+
return {
|
|
1323
|
+
status: result.status,
|
|
1324
|
+
stdout: result.stdout ?? "",
|
|
1325
|
+
stderr: result.stderr ?? "",
|
|
1326
|
+
timed_out: result.error?.code === "ETIMEDOUT"
|
|
1327
|
+
};
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
function builder_tools(workspace, options = {}) {
|
|
1331
|
+
const bash_timeout_ms = options.bash_timeout_ms ?? BASH_TIMEOUT_MS;
|
|
1332
|
+
const bash_max_output_bytes = options.bash_max_output_bytes ?? BASH_MAX_OUTPUT_BYTES;
|
|
1333
|
+
const bash_executor = options.bash_executor ?? host_bash_executor(workspace);
|
|
1334
|
+
const write_file = {
|
|
1335
|
+
name: "write_file",
|
|
1336
|
+
description: `Write a UTF-8 text file in the workspace, creating parent directories and overwriting any existing content. Rejects content over ${String(WRITE_FILE_MAX_BYTES)} bytes.`,
|
|
1337
|
+
input_schema: write_file_input,
|
|
1338
|
+
execute: (raw) => {
|
|
1339
|
+
const input = write_file_input.parse(raw);
|
|
1340
|
+
const abs = contain(workspace, input.path);
|
|
1341
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
1342
|
+
if (bytes > WRITE_FILE_MAX_BYTES) {
|
|
1343
|
+
throw new Error(
|
|
1344
|
+
`content is ${String(bytes)} bytes, over the ${String(WRITE_FILE_MAX_BYTES)}-byte write cap`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
mkdirSync3(dirname(abs), { recursive: true });
|
|
1348
|
+
writeFileSync3(abs, input.content, "utf8");
|
|
1349
|
+
return `wrote ${String(bytes)} bytes to ${input.path}`;
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
const edit_file = {
|
|
1353
|
+
name: "edit_file",
|
|
1354
|
+
description: "Replace one exact occurrence of old_str with new_str in a workspace file. old_str must match the file content exactly once \u2014 on 0 or multiple matches nothing is changed and an error is returned; read the file and retry with a longer, unique old_str.",
|
|
1355
|
+
input_schema: edit_file_input,
|
|
1356
|
+
execute: (raw) => {
|
|
1357
|
+
const input = edit_file_input.parse(raw);
|
|
1358
|
+
const abs = contain(workspace, input.path);
|
|
1359
|
+
const content = readFileSync5(abs, "utf8");
|
|
1360
|
+
const matches = count_occurrences(content, input.old_str);
|
|
1361
|
+
if (matches === 0) {
|
|
1362
|
+
return `error: old_str not found in ${input.path} (0 matches). No change made. Read the file and retry with an exact excerpt of its current content.`;
|
|
1363
|
+
}
|
|
1364
|
+
if (matches > 1) {
|
|
1365
|
+
return `error: old_str matches ${String(matches)} times in ${input.path}. No change made. Include more surrounding context to make old_str unique.`;
|
|
1366
|
+
}
|
|
1367
|
+
writeFileSync3(abs, content.replace(input.old_str, input.new_str), "utf8");
|
|
1368
|
+
return `edit applied to ${input.path}`;
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
const bash = {
|
|
1372
|
+
name: "bash",
|
|
1373
|
+
description: `Run a shell command in the workspace and return its exit code, stdout, and stderr. Each call is independent \u2014 no shell state (working directory, environment, variables) carries over between commands. A non-zero exit is a normal result, not an error: read it and adjust. Each stream is truncated past ${String(bash_max_output_bytes)} bytes, and the command is killed if it runs longer than ${String(bash_timeout_ms)}ms.`,
|
|
1374
|
+
input_schema: bash_input,
|
|
1375
|
+
// Stateless per command (the executor is injectable without touching
|
|
1376
|
+
// this contract — under containment it is the local `spawnSync` running in-container),
|
|
1377
|
+
// and never throws on the command's own failure — a non-zero exit or a
|
|
1378
|
+
// timeout is *returned* as `{ exit_code, stdout, stderr }` for the model to
|
|
1379
|
+
// read. The executor buffers up to `BASH_CAPTURE_MAX_BYTES` (OOM guard)
|
|
1380
|
+
// before we truncate to the model-facing cap.
|
|
1381
|
+
execute: (raw) => {
|
|
1382
|
+
const input = bash_input.parse(raw);
|
|
1383
|
+
const outcome = bash_executor(input.command, {
|
|
1384
|
+
timeout_ms: bash_timeout_ms,
|
|
1385
|
+
max_capture_bytes: BASH_CAPTURE_MAX_BYTES
|
|
1386
|
+
});
|
|
1387
|
+
let stderr = truncate_bytes(outcome.stderr, bash_max_output_bytes);
|
|
1388
|
+
if (outcome.timed_out) {
|
|
1389
|
+
const note = `[command timed out after ${String(bash_timeout_ms)}ms and was killed]`;
|
|
1390
|
+
stderr = stderr.length > 0 ? `${stderr}
|
|
1391
|
+
${note}` : note;
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
exit_code: outcome.status,
|
|
1395
|
+
stdout: truncate_bytes(outcome.stdout, bash_max_output_bytes),
|
|
1396
|
+
stderr
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
const fetch2 = {
|
|
1401
|
+
name: "fetch",
|
|
1402
|
+
description: `Fetch an http(s) URL and return its main content converted to Markdown. Returns at most max_chars characters (default ${String(FETCH_MAX_CHARS)}); when the page is longer, a trailer names the start_index to pass to read the next slice. Private, loopback, and link-local addresses are refused. A blocked, non-HTML, or failed request returns an error string you can read and act on.`,
|
|
1403
|
+
input_schema: fetch_input,
|
|
1404
|
+
// Native readability pipeline with connection-time SSRF protection; a
|
|
1405
|
+
// blocked / non-HTML / HTTP error is returned as a tool result, not
|
|
1406
|
+
// thrown. The classifier and resolver are overridable for tests only.
|
|
1407
|
+
execute: (raw, ctx) => run_fetch(raw, ctx, {
|
|
1408
|
+
is_forbidden: options.fetch_is_address_forbidden ?? is_forbidden_address,
|
|
1409
|
+
resolve: options.fetch_lookup ?? default_resolver,
|
|
1410
|
+
max_bytes: options.fetch_max_bytes ?? FETCH_MAX_BYTES
|
|
1411
|
+
})
|
|
1412
|
+
};
|
|
1413
|
+
const finish = {
|
|
1414
|
+
name: "finish",
|
|
1415
|
+
description: "Declare the task complete. Calling this tool ends your turn \u2014 do not call it until the work is done and verified.",
|
|
1416
|
+
input_schema: finish_input,
|
|
1417
|
+
// A successful `finish` ends the loop deterministically. The summary
|
|
1418
|
+
// is recorded in the trajectory like any tool call and otherwise ignored
|
|
1419
|
+
// — the workspace, not the self-report, is truth.
|
|
1420
|
+
ends_turn: true,
|
|
1421
|
+
execute: (raw) => {
|
|
1422
|
+
const input = finish_input.parse(raw);
|
|
1423
|
+
return `finished: ${input.summary}`;
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
return [...read_only_tools(workspace), write_file, edit_file, bash, fetch2, finish];
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/engine.ts
|
|
1430
|
+
import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
|
|
1431
|
+
import { create_engine } from "fascicle";
|
|
1432
|
+
var DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
1433
|
+
var DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
1434
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
1435
|
+
function cross_to_host_gateway(base_url, env) {
|
|
1436
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
1437
|
+
if (host === void 0 || host === "") return base_url;
|
|
1438
|
+
let parsed;
|
|
1439
|
+
try {
|
|
1440
|
+
parsed = new URL(base_url);
|
|
1441
|
+
} catch {
|
|
1442
|
+
return base_url;
|
|
1443
|
+
}
|
|
1444
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
1445
|
+
parsed.hostname = host;
|
|
1446
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1447
|
+
}
|
|
1448
|
+
function resolve_ollama_base_url(env) {
|
|
1449
|
+
const raw = env["VOLLEY_OLLAMA_URL"] ?? DEFAULT_OLLAMA_URL;
|
|
1450
|
+
const normalized = raw.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
1451
|
+
return cross_to_host_gateway(normalized, env);
|
|
1452
|
+
}
|
|
1453
|
+
function load_pricing_overrides(path) {
|
|
1454
|
+
if (!existsSync5(path)) {
|
|
1455
|
+
throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1461
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${detail}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
function is_local_provider(provider) {
|
|
1465
|
+
return provider === "ollama" || provider === "lmstudio";
|
|
1466
|
+
}
|
|
1467
|
+
function local_provider_config(provider, env) {
|
|
1468
|
+
if (provider === "ollama") {
|
|
1469
|
+
return { ollama: { base_url: resolve_ollama_base_url(env) } };
|
|
1470
|
+
}
|
|
1471
|
+
const lmstudio_url = env["VOLLEY_LMSTUDIO_URL"] ?? DEFAULT_LMSTUDIO_URL;
|
|
1472
|
+
return { lmstudio: { base_url: cross_to_host_gateway(lmstudio_url, env) } };
|
|
1473
|
+
}
|
|
1474
|
+
function create_volley_engine(options) {
|
|
1475
|
+
const env = options.env ?? process.env;
|
|
1476
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
1477
|
+
if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") {
|
|
1478
|
+
throw config_error(
|
|
1479
|
+
`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
1483
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
1484
|
+
const local_providers = /* @__PURE__ */ new Set();
|
|
1485
|
+
if (is_local_provider(options.builder_provider)) local_providers.add(options.builder_provider);
|
|
1486
|
+
if (is_local_provider(options.critic_provider)) local_providers.add(options.critic_provider);
|
|
1487
|
+
const providers = {
|
|
1488
|
+
claude_cli: {
|
|
1489
|
+
default_cwd: options.workspace,
|
|
1490
|
+
setting_sources: ["project", "local"],
|
|
1491
|
+
auth_mode,
|
|
1492
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
1493
|
+
...auth_mode === "api_key" && env["ANTHROPIC_API_KEY"] !== void 0 ? { api_key: env["ANTHROPIC_API_KEY"] } : {}
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
for (const provider of local_providers) {
|
|
1497
|
+
Object.assign(providers, local_provider_config(provider, env));
|
|
1498
|
+
}
|
|
1499
|
+
return create_engine({
|
|
1500
|
+
providers,
|
|
1501
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/prewarm.ts
|
|
1506
|
+
var PREWARM_TIMEOUT_MS = 3e5;
|
|
1507
|
+
async function prewarm_ollama_model(base_url, model, abort) {
|
|
1508
|
+
const signals = [AbortSignal.timeout(PREWARM_TIMEOUT_MS)];
|
|
1509
|
+
if (abort !== void 0) signals.push(abort);
|
|
1510
|
+
try {
|
|
1511
|
+
await fetch(`${base_url.replace(/\/$/, "")}/api/generate`, {
|
|
1512
|
+
method: "POST",
|
|
1513
|
+
headers: { "content-type": "application/json" },
|
|
1514
|
+
body: JSON.stringify({ model }),
|
|
1515
|
+
signal: AbortSignal.any(signals)
|
|
1516
|
+
});
|
|
1517
|
+
} catch {
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// src/worktree.ts
|
|
1522
|
+
import { existsSync as existsSync6, renameSync as renameSync2, rmSync as rmSync2, symlinkSync } from "fs";
|
|
1523
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
1524
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
1525
|
+
function worktree_path(workspace) {
|
|
1526
|
+
return `${resolve3(workspace)}.worktree`;
|
|
1527
|
+
}
|
|
1528
|
+
function worktree_branch(run_id) {
|
|
1529
|
+
return `volley/${run_id}`;
|
|
1530
|
+
}
|
|
1531
|
+
function build_root(workspace, worktree) {
|
|
1532
|
+
return worktree ? worktree_path(workspace) : workspace;
|
|
1533
|
+
}
|
|
1534
|
+
function worktree_fate(config) {
|
|
1535
|
+
if (!config.worktree) return "none";
|
|
1536
|
+
if (config.git_checkpoints) return "integrate";
|
|
1537
|
+
if (config.discard_worktree) return "discard";
|
|
1538
|
+
return "salvage";
|
|
1539
|
+
}
|
|
1540
|
+
var RUN_BRANCH_PATTERN = "volley/<run id>";
|
|
1541
|
+
function worktree_notice(config) {
|
|
1542
|
+
const fate = worktree_fate(config);
|
|
1543
|
+
if (fate === "none") return null;
|
|
1544
|
+
if (fate === "integrate") {
|
|
1545
|
+
return {
|
|
1546
|
+
level: "info",
|
|
1547
|
+
message: "worktree: --worktree --git \u2014 a successful run squash-merges its run branch onto the workspace branch at teardown; any other outcome is discarded."
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
if (fate === "discard") {
|
|
1551
|
+
return {
|
|
1552
|
+
level: "info",
|
|
1553
|
+
message: "worktree: --discard-worktree \u2014 the run builds in an isolated worktree whose effects are thrown away at teardown, converged or not. Verdicts only; no branch is kept."
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
return {
|
|
1557
|
+
level: "warn",
|
|
1558
|
+
message: `worktree: --worktree without --git \u2014 a successful run will NOT be integrated into the workspace branch, and the builder writes only inside the sibling worktree, so your working tree stays as it is. The run's work is committed onto its own branch (${RUN_BRANCH_PATTERN}) at teardown and left there for \`git switch\` / \`git cherry-pick\`. Add --git to squash-merge it onto the workspace branch instead, or --discard-worktree to throw it away deliberately.`
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
function report_worktree_fate(config, sink) {
|
|
1562
|
+
const notice = worktree_notice(config);
|
|
1563
|
+
if (notice === null) return;
|
|
1564
|
+
if (notice.level === "warn") {
|
|
1565
|
+
sink.warn(notice.message);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
sink.info(notice.message);
|
|
1569
|
+
}
|
|
1570
|
+
function git2(cwd, args) {
|
|
1571
|
+
const result = spawnSync4("git", args, { cwd, encoding: "utf8" });
|
|
1572
|
+
if (result.error !== void 0) {
|
|
1573
|
+
throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
1574
|
+
}
|
|
1575
|
+
return { status: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1576
|
+
}
|
|
1577
|
+
function git_ok(cwd, args) {
|
|
1578
|
+
const result = git2(cwd, args);
|
|
1579
|
+
if (result.status !== 0) {
|
|
1580
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
1581
|
+
throw check_error(`git ${args.join(" ")} failed: ${detail}`);
|
|
1582
|
+
}
|
|
1583
|
+
return result;
|
|
1584
|
+
}
|
|
1585
|
+
function git_quiet(cwd, args) {
|
|
1586
|
+
try {
|
|
1587
|
+
const result = spawnSync4("git", args, { cwd, encoding: "utf8" });
|
|
1588
|
+
return { status: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1589
|
+
} catch {
|
|
1590
|
+
return { status: -1, stdout: "", stderr: "" };
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
function branch_exists(repo, branch) {
|
|
1594
|
+
return git2(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
|
|
1595
|
+
}
|
|
1596
|
+
function create_worktree(options) {
|
|
1597
|
+
const repo = resolve3(options.workspace);
|
|
1598
|
+
if (!existsSync6(join5(repo, ".git"))) {
|
|
1599
|
+
throw config_error(`--worktree requires the workspace to be a git repository: ${repo}`);
|
|
1600
|
+
}
|
|
1601
|
+
const path = worktree_path(repo);
|
|
1602
|
+
const base = options.base ?? "HEAD";
|
|
1603
|
+
const log = options.log ?? (() => {
|
|
1604
|
+
});
|
|
1605
|
+
if (existsSync6(path)) {
|
|
1606
|
+
const backup = `${path}.bak.${backup_stamp()}`;
|
|
1607
|
+
renameSync2(path, backup);
|
|
1608
|
+
git2(repo, ["worktree", "prune"]);
|
|
1609
|
+
log(`worktree: rotated existing ${path} aside to ${backup}`);
|
|
1610
|
+
}
|
|
1611
|
+
if (branch_exists(repo, options.branch)) {
|
|
1612
|
+
const backup_branch = `${options.branch}.bak.${backup_stamp()}`;
|
|
1613
|
+
git_ok(repo, ["branch", "-m", options.branch, backup_branch]);
|
|
1614
|
+
log(`worktree: rotated existing branch ${options.branch} aside to ${backup_branch}`);
|
|
1615
|
+
}
|
|
1616
|
+
git_ok(repo, ["worktree", "add", "-b", options.branch, path, base]);
|
|
1617
|
+
const modules = join5(repo, "node_modules");
|
|
1618
|
+
if (existsSync6(modules) && !existsSync6(join5(path, "node_modules"))) {
|
|
1619
|
+
symlinkSync(modules, join5(path, "node_modules"), "dir");
|
|
1620
|
+
log(`worktree: linked node_modules from ${repo}`);
|
|
1621
|
+
}
|
|
1622
|
+
return { path, branch: options.branch };
|
|
1623
|
+
}
|
|
1624
|
+
function teardown_worktree(options) {
|
|
1625
|
+
const repo = resolve3(options.workspace);
|
|
1626
|
+
const path = worktree_path(repo);
|
|
1627
|
+
const outcome = options.salvage === true ? salvage_worktree(repo, path, options.branch) : { salvaged_branch: null, kept_path: null };
|
|
1628
|
+
if (outcome.kept_path !== null) return outcome;
|
|
1629
|
+
git_quiet(repo, ["worktree", "remove", "--force", path]);
|
|
1630
|
+
if (outcome.salvaged_branch === null) {
|
|
1631
|
+
git_quiet(repo, ["branch", "-D", options.branch]);
|
|
1632
|
+
}
|
|
1633
|
+
git_quiet(repo, ["worktree", "prune"]);
|
|
1634
|
+
if (existsSync6(path)) {
|
|
1635
|
+
try {
|
|
1636
|
+
rmSync2(path, { recursive: true, force: true });
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return outcome;
|
|
1641
|
+
}
|
|
1642
|
+
function salvage_worktree(repo, path, branch) {
|
|
1643
|
+
if (existsSync6(path)) {
|
|
1644
|
+
git_quiet(path, ["add", "-A"]);
|
|
1645
|
+
git_quiet(path, [
|
|
1646
|
+
"commit",
|
|
1647
|
+
"-m",
|
|
1648
|
+
`volley: salvage unintegrated worktree (${branch})`,
|
|
1649
|
+
"--no-verify"
|
|
1650
|
+
]);
|
|
1651
|
+
if (worktree_dirty(path)) return { salvaged_branch: null, kept_path: path };
|
|
1652
|
+
}
|
|
1653
|
+
return { salvaged_branch: branch_ahead(repo, branch) ? branch : null, kept_path: null };
|
|
1654
|
+
}
|
|
1655
|
+
function worktree_dirty(path) {
|
|
1656
|
+
const status = git_quiet(path, ["status", "--porcelain"]);
|
|
1657
|
+
return status.status === 0 && status.stdout.trim().length > 0;
|
|
1658
|
+
}
|
|
1659
|
+
function branch_ahead(repo, branch) {
|
|
1660
|
+
const ahead = git_quiet(repo, ["rev-list", "--count", `HEAD..${branch}`]);
|
|
1661
|
+
return ahead.status === 0 && Number(ahead.stdout.trim()) > 0;
|
|
1662
|
+
}
|
|
1663
|
+
async function with_worktree(options, body) {
|
|
1664
|
+
if (!options.enabled) {
|
|
1665
|
+
return { value: await body(), salvaged_branch: null, kept_path: null };
|
|
1666
|
+
}
|
|
1667
|
+
create_worktree({
|
|
1668
|
+
workspace: options.workspace,
|
|
1669
|
+
branch: options.branch,
|
|
1670
|
+
...options.log !== void 0 ? { log: options.log } : {}
|
|
1671
|
+
});
|
|
1672
|
+
let discard_on_exit = true;
|
|
1673
|
+
try {
|
|
1674
|
+
const value = await body();
|
|
1675
|
+
if (options.salvage?.(value) !== true) {
|
|
1676
|
+
return { value, salvaged_branch: null, kept_path: null };
|
|
1677
|
+
}
|
|
1678
|
+
discard_on_exit = false;
|
|
1679
|
+
return {
|
|
1680
|
+
value,
|
|
1681
|
+
...teardown_worktree({
|
|
1682
|
+
workspace: options.workspace,
|
|
1683
|
+
branch: options.branch,
|
|
1684
|
+
salvage: true
|
|
1685
|
+
})
|
|
1686
|
+
};
|
|
1687
|
+
} finally {
|
|
1688
|
+
if (discard_on_exit) {
|
|
1689
|
+
teardown_worktree({ workspace: options.workspace, branch: options.branch });
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// src/builder.ts
|
|
1695
|
+
var BUILDER_ALLOWED_TOOLS = [
|
|
1696
|
+
"Read",
|
|
1697
|
+
"Write",
|
|
1698
|
+
"Edit",
|
|
1699
|
+
"MultiEdit",
|
|
1700
|
+
"Bash",
|
|
1701
|
+
"Grep",
|
|
1702
|
+
"Glob",
|
|
1703
|
+
"WebFetch"
|
|
1704
|
+
];
|
|
1705
|
+
function compose_builder_system() {
|
|
1706
|
+
return [
|
|
1707
|
+
"You are the builder inside the volley harness: an autonomous agent",
|
|
1708
|
+
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
1709
|
+
"criteria. Work directly in the current working directory. Plan",
|
|
1710
|
+
"internally, make the changes, and verify your own work before finishing."
|
|
1711
|
+
].join("\n");
|
|
1712
|
+
}
|
|
1713
|
+
function presets_dir() {
|
|
1714
|
+
let dir = dirname2(fileURLToPath(import.meta.url));
|
|
1715
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
1716
|
+
const candidate = join6(dir, "src", "builder", "presets");
|
|
1717
|
+
if (existsSync7(candidate)) return candidate;
|
|
1718
|
+
dir = dirname2(dir);
|
|
1719
|
+
}
|
|
1720
|
+
throw config_error("builder presets directory not found");
|
|
1721
|
+
}
|
|
1722
|
+
function compose_builder_system_local() {
|
|
1723
|
+
const identity = [
|
|
1724
|
+
"You are the builder inside the volley harness: an autonomous agent",
|
|
1725
|
+
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
1726
|
+
"criteria."
|
|
1727
|
+
].join("\n");
|
|
1728
|
+
const append = readFileSync7(join6(presets_dir(), "harness_append_local.md"), "utf8");
|
|
1729
|
+
return `${identity}
|
|
1730
|
+
|
|
1731
|
+
${append.trimEnd()}`;
|
|
1732
|
+
}
|
|
1733
|
+
function resolve_builder_system(config) {
|
|
1734
|
+
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local();
|
|
1735
|
+
}
|
|
1736
|
+
var BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
1737
|
+
function builder_tool_options(config, bash_executor) {
|
|
1738
|
+
if (config.builder_provider === "claude_cli") {
|
|
1739
|
+
return {
|
|
1740
|
+
provider_options: {
|
|
1741
|
+
claude_cli: {
|
|
1742
|
+
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
1743
|
+
extra_args: ["--permission-mode", config.builder_permission_mode]
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
return {
|
|
1749
|
+
// Containment root re-points to the worktree under `--worktree`, so
|
|
1750
|
+
// the model's writes, edits, and `bash` cwd land there and leave the
|
|
1751
|
+
// workspace untouched. When the sandbox is active its `docker exec` executor
|
|
1752
|
+
// runs `bash` in the container against the bind-mounted worktree; the
|
|
1753
|
+
// host `spawnSync` default stands in otherwise.
|
|
1754
|
+
tools: builder_tools(
|
|
1755
|
+
build_root(config.workspace, config.worktree),
|
|
1756
|
+
bash_executor !== null ? { bash_executor } : {}
|
|
1757
|
+
),
|
|
1758
|
+
max_steps: config.builder_max_steps,
|
|
1759
|
+
tool_error_policy: "feed_back",
|
|
1760
|
+
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
1761
|
+
max_tool_calls_per_step: 1
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
function compose_builder_prompt(input) {
|
|
1765
|
+
const parts = [
|
|
1766
|
+
"TASK",
|
|
1767
|
+
"----",
|
|
1768
|
+
input.task,
|
|
1769
|
+
"",
|
|
1770
|
+
"ACCEPTANCE CRITERIA",
|
|
1771
|
+
"-------------------",
|
|
1772
|
+
input.criteria,
|
|
1773
|
+
"",
|
|
1774
|
+
`ITERATION: ${input.iteration}`
|
|
1775
|
+
];
|
|
1776
|
+
if (input.iteration > 1 && input.feedback !== null) {
|
|
1777
|
+
parts.push(
|
|
1778
|
+
"",
|
|
1779
|
+
"PREVIOUS CRITIC FEEDBACK",
|
|
1780
|
+
"------------------------",
|
|
1781
|
+
input.feedback,
|
|
1782
|
+
"",
|
|
1783
|
+
"Address the feedback above. The workspace already contains your prior work;",
|
|
1784
|
+
"read the current state, then make the necessary changes."
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
if (input.checkride) {
|
|
1788
|
+
parts.push(
|
|
1789
|
+
"",
|
|
1790
|
+
"The definition of done for this workspace includes `pnpm check` (checkride)",
|
|
1791
|
+
"exiting 0. Run it yourself before finishing; on failure, read",
|
|
1792
|
+
".check/summary.json, then the failing slot's raw output, fix, and re-run."
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
return parts.join("\n");
|
|
1796
|
+
}
|
|
1797
|
+
function builder_max_steps_warning(max_steps) {
|
|
1798
|
+
return `builder hit the ${String(max_steps)}-step limit without calling finish; handing the partial workspace to check + critic (raise --builder-max-steps if this recurs)`;
|
|
1799
|
+
}
|
|
1800
|
+
async function run_builder(deps, state, ctx) {
|
|
1801
|
+
const { engine, config } = deps;
|
|
1802
|
+
try {
|
|
1803
|
+
if (config.builder_provider === "ollama") {
|
|
1804
|
+
const base_url = resolve_ollama_base_url(process.env);
|
|
1805
|
+
await warn_small_local_context(
|
|
1806
|
+
config.builder_provider,
|
|
1807
|
+
config.builder_model,
|
|
1808
|
+
base_url,
|
|
1809
|
+
deps.warn,
|
|
1810
|
+
ctx.abort
|
|
1811
|
+
);
|
|
1812
|
+
await prewarm_ollama_model(base_url, config.builder_model, ctx.abort);
|
|
1813
|
+
}
|
|
1814
|
+
const result = await engine.generate({
|
|
1815
|
+
provider: config.builder_provider,
|
|
1816
|
+
model: config.builder_model,
|
|
1817
|
+
system: resolve_builder_system(config),
|
|
1818
|
+
prompt: compose_builder_prompt({
|
|
1819
|
+
task: config.prompt,
|
|
1820
|
+
criteria: config.criteria,
|
|
1821
|
+
feedback: state.feedback,
|
|
1822
|
+
iteration: state.iteration,
|
|
1823
|
+
checkride: config.check_resolved === "checkride"
|
|
1824
|
+
}),
|
|
1825
|
+
abort: ctx.abort,
|
|
1826
|
+
trajectory: ctx.trajectory,
|
|
1827
|
+
on_chunk: deps.on_chunk,
|
|
1828
|
+
...builder_tool_options(config, deps.bash_executor ?? null)
|
|
1829
|
+
});
|
|
1830
|
+
if (result.finish_reason === "max_steps") {
|
|
1831
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
1832
|
+
}
|
|
1833
|
+
return accumulate(state, "builder", result, config.builder_model);
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
throw phase_error("builder", state.iteration, err);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// src/check/checkride.ts
|
|
1840
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
1841
|
+
import { join as join7 } from "path";
|
|
1842
|
+
import { spawn } from "child_process";
|
|
1843
|
+
import { aborted_error } from "fascicle";
|
|
1844
|
+
var CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
1845
|
+
var CHECKRIDE_SCHEMA_VERSION = 1;
|
|
1846
|
+
function spawn_capture(cmd, args, options) {
|
|
1847
|
+
return new Promise((resolve_promise, reject) => {
|
|
1848
|
+
const proc = spawn(cmd, args, {
|
|
1849
|
+
cwd: options.cwd,
|
|
1850
|
+
...options.abort !== void 0 ? { signal: options.abort } : {}
|
|
1851
|
+
});
|
|
1852
|
+
let stdout = "";
|
|
1853
|
+
let stderr = "";
|
|
1854
|
+
proc.stdout.on("data", (chunk) => {
|
|
1855
|
+
stdout += chunk.toString();
|
|
1856
|
+
});
|
|
1857
|
+
proc.stderr.on("data", (chunk) => {
|
|
1858
|
+
stderr += chunk.toString();
|
|
1859
|
+
});
|
|
1860
|
+
proc.on("error", (err) => {
|
|
1861
|
+
if (err.name === "AbortError") {
|
|
1862
|
+
reject(new aborted_error("check aborted"));
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
1866
|
+
});
|
|
1867
|
+
proc.on("close", (code) => {
|
|
1868
|
+
resolve_promise({ exit_code: code, stdout, stderr });
|
|
1869
|
+
});
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
function parse_checkride_summary(stdout) {
|
|
1873
|
+
const start = stdout.indexOf("{");
|
|
1874
|
+
const end = stdout.lastIndexOf("}");
|
|
1875
|
+
let raw;
|
|
1876
|
+
try {
|
|
1877
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
1878
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
1879
|
+
} catch {
|
|
1880
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
1881
|
+
}
|
|
1882
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) {
|
|
1883
|
+
throw check_error("checkride summary missing checks array");
|
|
1884
|
+
}
|
|
1885
|
+
const summary = raw;
|
|
1886
|
+
const warning = summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`;
|
|
1887
|
+
return { summary, warning };
|
|
1888
|
+
}
|
|
1889
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
1890
|
+
try {
|
|
1891
|
+
return parse_checkride_summary(stdout);
|
|
1892
|
+
} catch (err) {
|
|
1893
|
+
const file = join7(workspace, ".check", "summary.json");
|
|
1894
|
+
if (!existsSync8(file)) throw err;
|
|
1895
|
+
return parse_checkride_summary(readFileSync8(file, "utf8"));
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
function failing_checks(summary) {
|
|
1899
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
1900
|
+
}
|
|
1901
|
+
function read_failing_artifacts(workspace, failing) {
|
|
1902
|
+
return failing.map((check) => {
|
|
1903
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
1904
|
+
const path = join7(workspace, ".check", file);
|
|
1905
|
+
if (!existsSync8(path)) {
|
|
1906
|
+
return { slot: check.name, path: null, content: "(no artifact found)", truncated: false };
|
|
1907
|
+
}
|
|
1908
|
+
const raw = readFileSync8(path, "utf8");
|
|
1909
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
1910
|
+
return {
|
|
1911
|
+
slot: check.name,
|
|
1912
|
+
path,
|
|
1913
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
1914
|
+
truncated
|
|
1915
|
+
};
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
async function run_checkride(opts) {
|
|
1919
|
+
const proc = await spawn_capture("pnpm", ["exec", "checkride", "--json"], {
|
|
1920
|
+
cwd: opts.workspace,
|
|
1921
|
+
...opts.abort !== void 0 ? { abort: opts.abort } : {}
|
|
1922
|
+
});
|
|
1923
|
+
if (proc.exit_code === 2) {
|
|
1924
|
+
throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
1925
|
+
}
|
|
1926
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
1927
|
+
const failing = failing_checks(summary);
|
|
1928
|
+
return {
|
|
1929
|
+
ran: true,
|
|
1930
|
+
runner: "checkride",
|
|
1931
|
+
ok: summary.ok,
|
|
1932
|
+
exit_code: proc.exit_code,
|
|
1933
|
+
duration_ms: summary.total_duration_ms,
|
|
1934
|
+
failing_slots: failing.map((c) => c.name),
|
|
1935
|
+
detail: read_failing_artifacts(opts.workspace, failing),
|
|
1936
|
+
summary
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/check/command.ts
|
|
1941
|
+
async function run_command_check(opts) {
|
|
1942
|
+
const started = Date.now();
|
|
1943
|
+
const proc = await spawn_capture("sh", ["-c", opts.command], {
|
|
1944
|
+
cwd: opts.workspace,
|
|
1945
|
+
...opts.abort !== void 0 ? { abort: opts.abort } : {}
|
|
1946
|
+
});
|
|
1947
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) {
|
|
1948
|
+
throw check_error(
|
|
1949
|
+
`check command failed to start (exit ${String(proc.exit_code)}): ${opts.command}`
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
1953
|
+
return {
|
|
1954
|
+
ran: true,
|
|
1955
|
+
runner: "command",
|
|
1956
|
+
ok: proc.exit_code === 0,
|
|
1957
|
+
exit_code: proc.exit_code,
|
|
1958
|
+
duration_ms: Date.now() - started,
|
|
1959
|
+
failing_slots: [],
|
|
1960
|
+
detail: [],
|
|
1961
|
+
log
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
function skipped_check(reason) {
|
|
1965
|
+
return {
|
|
1966
|
+
ran: false,
|
|
1967
|
+
runner: "none",
|
|
1968
|
+
// With no check configured the loop is critic-gated only, so the gate is
|
|
1969
|
+
// open (`ok: true`). A cost-cap skip must not look like a passing gate.
|
|
1970
|
+
ok: reason === "none",
|
|
1971
|
+
exit_code: null,
|
|
1972
|
+
duration_ms: 0,
|
|
1973
|
+
failing_slots: [],
|
|
1974
|
+
detail: []
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// src/summary.ts
|
|
1979
|
+
import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1980
|
+
function transport_of(provider) {
|
|
1981
|
+
return provider === "claude_cli" ? "claude_cli" : "ai_sdk";
|
|
1982
|
+
}
|
|
1983
|
+
function read_iteration_archives(workspace) {
|
|
1984
|
+
const root = volley_path(workspace, "iterations");
|
|
1985
|
+
if (!existsSync9(root)) return [];
|
|
1986
|
+
const dirs = readdirSync3(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
|
|
1987
|
+
const archives = [];
|
|
1988
|
+
for (const dir of dirs) {
|
|
1989
|
+
const path = volley_path(workspace, "iterations", dir, "summary.json");
|
|
1990
|
+
if (!existsSync9(path)) continue;
|
|
1991
|
+
try {
|
|
1992
|
+
archives.push(JSON.parse(readFileSync9(path, "utf8")));
|
|
1993
|
+
} catch {
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return archives;
|
|
1997
|
+
}
|
|
1998
|
+
function check_trajectory(archives) {
|
|
1999
|
+
return archives.map((archive, index) => ({
|
|
2000
|
+
iteration: typeof archive.iteration === "number" ? archive.iteration : index + 1,
|
|
2001
|
+
ran: archive.check?.ran === true,
|
|
2002
|
+
ok: archive.check?.ok === true,
|
|
2003
|
+
failing_slots: archive.check?.failing_slots ?? []
|
|
2004
|
+
}));
|
|
2005
|
+
}
|
|
2006
|
+
function any_critic_degraded(archives) {
|
|
2007
|
+
return archives.some((archive) => archive.critic?.critic_degraded === true);
|
|
2008
|
+
}
|
|
2009
|
+
function all_gate_edits(archives) {
|
|
2010
|
+
const edits = /* @__PURE__ */ new Set();
|
|
2011
|
+
for (const archive of archives) {
|
|
2012
|
+
for (const path of archive.changes?.gate_edits ?? []) edits.add(path);
|
|
2013
|
+
}
|
|
2014
|
+
return [...edits].toSorted();
|
|
2015
|
+
}
|
|
2016
|
+
function salvage_stats(archives) {
|
|
2017
|
+
let tool_calls = 0;
|
|
2018
|
+
let salvaged_tool_calls = 0;
|
|
2019
|
+
for (const archive of archives) {
|
|
2020
|
+
tool_calls += archive.builder?.tool_calls ?? 0;
|
|
2021
|
+
salvaged_tool_calls += archive.builder?.salvaged_tool_calls ?? 0;
|
|
2022
|
+
}
|
|
2023
|
+
return {
|
|
2024
|
+
tool_calls,
|
|
2025
|
+
salvaged_tool_calls,
|
|
2026
|
+
rate: tool_calls === 0 ? 0 : salvaged_tool_calls / tool_calls
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
function build_run_summary(config, result) {
|
|
2030
|
+
const archives = read_iteration_archives(config.workspace);
|
|
2031
|
+
const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
|
|
2032
|
+
return {
|
|
2033
|
+
...result,
|
|
2034
|
+
comparison: {
|
|
2035
|
+
builder_transport: transport_of(config.builder_provider),
|
|
2036
|
+
critic_transport: transport_of(config.critic_provider),
|
|
2037
|
+
iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
|
|
2038
|
+
wall_clock_ms,
|
|
2039
|
+
total_cost_usd: result.total_cost_usd,
|
|
2040
|
+
builder_cost_usd: result.builder_cost_usd,
|
|
2041
|
+
critic_cost_usd: result.critic_cost_usd,
|
|
2042
|
+
final_verdict: result.final_verdict,
|
|
2043
|
+
check_trajectory: check_trajectory(archives),
|
|
2044
|
+
gate_edits: all_gate_edits(archives),
|
|
2045
|
+
unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
|
|
2046
|
+
local_salvage: salvage_stats(archives),
|
|
2047
|
+
critic_degraded: any_critic_degraded(archives)
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
function write_run_summary(config, result) {
|
|
2052
|
+
writeFileSync4(
|
|
2053
|
+
volley_path(config.workspace, "summary.json"),
|
|
2054
|
+
`${JSON.stringify(build_run_summary(config, result), null, 2)}
|
|
2055
|
+
`
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/critic/run.ts
|
|
2060
|
+
import { z as z3 } from "zod";
|
|
2061
|
+
|
|
2062
|
+
// src/critic/prompt.ts
|
|
2063
|
+
import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
|
|
2064
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
2065
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2066
|
+
function presets_dir2() {
|
|
2067
|
+
let dir = dirname3(fileURLToPath2(import.meta.url));
|
|
2068
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
2069
|
+
const candidate = join8(dir, "src", "critic", "presets");
|
|
2070
|
+
if (existsSync10(candidate)) return candidate;
|
|
2071
|
+
const local = join8(dir, "presets");
|
|
2072
|
+
if (existsSync10(local)) return local;
|
|
2073
|
+
dir = dirname3(dir);
|
|
2074
|
+
}
|
|
2075
|
+
throw config_error("critic presets directory not found");
|
|
2076
|
+
}
|
|
2077
|
+
function resolve_critic_prompt(config) {
|
|
2078
|
+
const base = config.critic_prompt_path !== null ? readFileSync10(config.critic_prompt_path, "utf8") : readFileSync10(join8(presets_dir2(), `${config.critic_preset}.md`), "utf8");
|
|
2079
|
+
const append_file = config.critic_provider === "claude_cli" ? "harness_append.md" : "harness_append_local.md";
|
|
2080
|
+
const append = readFileSync10(join8(presets_dir2(), append_file), "utf8");
|
|
2081
|
+
return `${base.trimEnd()}
|
|
2082
|
+
|
|
2083
|
+
${append.trimEnd()}`;
|
|
2084
|
+
}
|
|
2085
|
+
var STATUS_MARKS = {
|
|
2086
|
+
added: "A",
|
|
2087
|
+
modified: "M",
|
|
2088
|
+
deleted: "D",
|
|
2089
|
+
renamed: "R",
|
|
2090
|
+
unknown: "?"
|
|
2091
|
+
};
|
|
2092
|
+
function format_change(file) {
|
|
2093
|
+
return ` ${STATUS_MARKS[file.status]} ${file.path}`;
|
|
2094
|
+
}
|
|
2095
|
+
function format_changes_section(changes) {
|
|
2096
|
+
if (changes === null) return null;
|
|
2097
|
+
const scope = changes.baseline === null ? "this run" : `baseline ${changes.baseline.slice(0, 8)}`;
|
|
2098
|
+
if (changes.total === 0) {
|
|
2099
|
+
return [
|
|
2100
|
+
`BUILDER CHANGES (since ${scope})`,
|
|
2101
|
+
"------------------------------",
|
|
2102
|
+
"The builder changed no files. Unless the acceptance criteria were already",
|
|
2103
|
+
"met before this run, that is itself grounds for changes_requested."
|
|
2104
|
+
].join("\n");
|
|
2105
|
+
}
|
|
2106
|
+
const lines = [
|
|
2107
|
+
`BUILDER CHANGES (since ${scope})`,
|
|
2108
|
+
"------------------------------",
|
|
2109
|
+
"Review the work as a change, not just as a finished tree. Read the files",
|
|
2110
|
+
"below with your tools as needed.",
|
|
2111
|
+
"",
|
|
2112
|
+
...changes.files.map(format_change)
|
|
2113
|
+
];
|
|
2114
|
+
if (changes.truncated) {
|
|
2115
|
+
lines.push(
|
|
2116
|
+
` \u2026 showing ${String(changes.files.length)} of ${String(changes.total)} changed paths`
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
if (changes.gate_edits.length > 0) {
|
|
2120
|
+
lines.push(
|
|
2121
|
+
"",
|
|
2122
|
+
`GATE EDITS (${String(changes.gate_edits.length)})`,
|
|
2123
|
+
"-----------",
|
|
2124
|
+
"These changed paths are part of the gate \u2014 the tests, fixtures, and check",
|
|
2125
|
+
"configuration that decide whether this work passes. A check the builder can",
|
|
2126
|
+
"edit proves less than one it cannot.",
|
|
2127
|
+
"",
|
|
2128
|
+
...changes.gate_edits.map((path) => ` ! ${path}`),
|
|
2129
|
+
"",
|
|
2130
|
+
"Judge whether each of these edits was necessary and legitimate for the task",
|
|
2131
|
+
"as specified. If any of them weakened the check, deleted a case, or moved a",
|
|
2132
|
+
"target rather than meeting it, the work does not meet the criteria: return",
|
|
2133
|
+
"changes_requested, name the file, and say what was weakened."
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
return lines.join("\n");
|
|
2137
|
+
}
|
|
2138
|
+
function format_check_section(check) {
|
|
2139
|
+
if (!check.ran) {
|
|
2140
|
+
return "No deterministic check ran for this iteration. Base your verdict on workspace inspection alone.";
|
|
2141
|
+
}
|
|
2142
|
+
const lines = [
|
|
2143
|
+
`DETERMINISTIC CHECK (${check.runner})`,
|
|
2144
|
+
"------------------------------------",
|
|
2145
|
+
`result: ${check.ok ? "PASSED" : "FAILED"} (exit ${String(check.exit_code)})`
|
|
2146
|
+
];
|
|
2147
|
+
if (check.failing_slots.length > 0) {
|
|
2148
|
+
lines.push(`failing slots: ${check.failing_slots.join(", ")}`);
|
|
2149
|
+
}
|
|
2150
|
+
if (check.summary !== void 0) {
|
|
2151
|
+
lines.push("", "summary.json:", "```json", JSON.stringify(check.summary, null, 2), "```");
|
|
2152
|
+
}
|
|
2153
|
+
for (const artifact of check.detail) {
|
|
2154
|
+
lines.push(
|
|
2155
|
+
"",
|
|
2156
|
+
`--- raw output for failing slot "${artifact.slot}"${artifact.truncated ? ` (truncated; full output archived at ${artifact.path ?? "n/a"})` : ""} ---`,
|
|
2157
|
+
artifact.content
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
if (check.log !== void 0 && check.log.length > 0) {
|
|
2161
|
+
lines.push("", "check output:", "```", check.log, "```");
|
|
2162
|
+
}
|
|
2163
|
+
return lines.join("\n");
|
|
2164
|
+
}
|
|
2165
|
+
function compose_critic_prompt(input) {
|
|
2166
|
+
const changes = format_changes_section(input.changes);
|
|
2167
|
+
return [
|
|
2168
|
+
"ACCEPTANCE CRITERIA",
|
|
2169
|
+
"-------------------",
|
|
2170
|
+
input.criteria,
|
|
2171
|
+
"",
|
|
2172
|
+
`ITERATION: ${input.iteration}`,
|
|
2173
|
+
"",
|
|
2174
|
+
format_check_section(input.check),
|
|
2175
|
+
...changes === null ? [] : ["", changes],
|
|
2176
|
+
"",
|
|
2177
|
+
"Review the workspace against the acceptance criteria and return your",
|
|
2178
|
+
"structured verdict."
|
|
2179
|
+
].join("\n");
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// src/critic/run.ts
|
|
2183
|
+
var CRITIC_ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
|
|
2184
|
+
var CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2185
|
+
var verdict_schema = z3.object({
|
|
2186
|
+
verdict: z3.enum(["approved", "changes_requested"]),
|
|
2187
|
+
feedback: z3.string().describe(
|
|
2188
|
+
"Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."
|
|
2189
|
+
),
|
|
2190
|
+
unmet_criteria: z3.array(z3.string()).describe(
|
|
2191
|
+
"The specific acceptance criteria judged unmet, verbatim. Empty when approved."
|
|
2192
|
+
)
|
|
2193
|
+
});
|
|
2194
|
+
function critic_tool_options(config) {
|
|
2195
|
+
if (config.critic_provider === "claude_cli") {
|
|
2196
|
+
return {
|
|
2197
|
+
provider_options: {
|
|
2198
|
+
claude_cli: {
|
|
2199
|
+
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2200
|
+
extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
return { tools: read_only_tools(build_root(config.workspace, config.worktree)) };
|
|
2206
|
+
}
|
|
2207
|
+
var MAX_CRITIC_RETRIES = 1;
|
|
2208
|
+
function is_retryable_critic_error(config, ctx, err) {
|
|
2209
|
+
if (config.critic_provider === "claude_cli") return false;
|
|
2210
|
+
if (ctx.abort.aborted) return false;
|
|
2211
|
+
return error_kind(err) === "provider_error";
|
|
2212
|
+
}
|
|
2213
|
+
function retry_cause_kind_of(err) {
|
|
2214
|
+
const cause = err.cause_kind;
|
|
2215
|
+
return cause === "provider_5xx" || cause === "network" ? cause : "unknown";
|
|
2216
|
+
}
|
|
2217
|
+
function finalize_critic(state, result, model, mark) {
|
|
2218
|
+
const next = accumulate(state, "critic", result, model);
|
|
2219
|
+
return {
|
|
2220
|
+
...next,
|
|
2221
|
+
critic: next.critic === null ? null : {
|
|
2222
|
+
...next.critic,
|
|
2223
|
+
retries: mark.retries,
|
|
2224
|
+
...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
|
|
2225
|
+
...mark.critic_degraded === true ? { critic_degraded: true } : {}
|
|
2226
|
+
},
|
|
2227
|
+
verdict: result.content.verdict,
|
|
2228
|
+
feedback: result.content.feedback,
|
|
2229
|
+
unmet_criteria: result.content.unmet_criteria
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
function toolless_critic_prompt(config, tool_prompt) {
|
|
2233
|
+
return [
|
|
2234
|
+
tool_prompt,
|
|
2235
|
+
"",
|
|
2236
|
+
"NOTE: file-read tools are unavailable for this review. Judge from the",
|
|
2237
|
+
"acceptance criteria, the deterministic check output above, and the workspace",
|
|
2238
|
+
"file inventory below (paths and sizes only \u2014 file contents are not shown).",
|
|
2239
|
+
"",
|
|
2240
|
+
"WORKSPACE FILE INVENTORY",
|
|
2241
|
+
"------------------------",
|
|
2242
|
+
workspace_inventory(build_root(config.workspace, config.worktree))
|
|
2243
|
+
].join("\n");
|
|
2244
|
+
}
|
|
2245
|
+
var CANARY_PROMPT = [
|
|
2246
|
+
"CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
|
|
2247
|
+
'First, call the read_file tool on the path "package.json".',
|
|
2248
|
+
"Whatever the tool returns (content or an error), immediately answer with",
|
|
2249
|
+
'verdict "approved", feedback exactly "canary", and empty unmet_criteria.'
|
|
2250
|
+
].join("\n");
|
|
2251
|
+
var CANARY_TOOLLESS_PROMPT = [
|
|
2252
|
+
"CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
|
|
2253
|
+
'Answer with verdict "approved", feedback exactly "canary", and empty',
|
|
2254
|
+
"unmet_criteria."
|
|
2255
|
+
].join("\n");
|
|
2256
|
+
function canary_detail(err) {
|
|
2257
|
+
return err instanceof Error ? err.message : String(err);
|
|
2258
|
+
}
|
|
2259
|
+
async function critic_canary(engine, config, env = process.env) {
|
|
2260
|
+
if (config.critic_provider === "ollama") {
|
|
2261
|
+
await prewarm_ollama_model(resolve_ollama_base_url(env), config.critic_model);
|
|
2262
|
+
}
|
|
2263
|
+
const base = {
|
|
2264
|
+
provider: config.critic_provider,
|
|
2265
|
+
model: config.critic_model,
|
|
2266
|
+
system: resolve_critic_prompt(config),
|
|
2267
|
+
schema: verdict_schema
|
|
2268
|
+
};
|
|
2269
|
+
try {
|
|
2270
|
+
await engine.generate({
|
|
2271
|
+
...base,
|
|
2272
|
+
prompt: CANARY_PROMPT,
|
|
2273
|
+
...critic_tool_options(config)
|
|
2274
|
+
});
|
|
2275
|
+
return { outcome: "ok" };
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
if (error_kind(err) !== "provider_error") {
|
|
2278
|
+
return { outcome: "failed", detail: canary_detail(err) };
|
|
2279
|
+
}
|
|
2280
|
+
const tool_death = canary_detail(err);
|
|
2281
|
+
try {
|
|
2282
|
+
await engine.generate({ ...base, prompt: CANARY_TOOLLESS_PROMPT });
|
|
2283
|
+
return { outcome: "degraded", detail: tool_death };
|
|
2284
|
+
} catch (fallback_err) {
|
|
2285
|
+
return { outcome: "failed", detail: canary_detail(fallback_err) };
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
async function run_critic(deps, state, ctx) {
|
|
2290
|
+
const { engine, config } = deps;
|
|
2291
|
+
if (state.check === null) {
|
|
2292
|
+
throw phase_error("critic", state.iteration, new Error("check phase did not run"));
|
|
2293
|
+
}
|
|
2294
|
+
const check = state.check;
|
|
2295
|
+
try {
|
|
2296
|
+
if (config.critic_provider === "ollama") {
|
|
2297
|
+
await prewarm_ollama_model(
|
|
2298
|
+
resolve_ollama_base_url(process.env),
|
|
2299
|
+
config.critic_model,
|
|
2300
|
+
ctx.abort
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
const base = {
|
|
2304
|
+
provider: config.critic_provider,
|
|
2305
|
+
model: config.critic_model,
|
|
2306
|
+
system: resolve_critic_prompt(config),
|
|
2307
|
+
schema: verdict_schema,
|
|
2308
|
+
abort: ctx.abort,
|
|
2309
|
+
trajectory: ctx.trajectory,
|
|
2310
|
+
on_chunk: deps.on_chunk
|
|
2311
|
+
};
|
|
2312
|
+
const tool_prompt = compose_critic_prompt({
|
|
2313
|
+
criteria: config.criteria,
|
|
2314
|
+
iteration: state.iteration,
|
|
2315
|
+
check,
|
|
2316
|
+
changes: state.changes
|
|
2317
|
+
});
|
|
2318
|
+
let retry_cause_kind;
|
|
2319
|
+
for (let attempt = 0; attempt <= MAX_CRITIC_RETRIES; attempt += 1) {
|
|
2320
|
+
try {
|
|
2321
|
+
const result2 = await engine.generate({
|
|
2322
|
+
...base,
|
|
2323
|
+
prompt: tool_prompt,
|
|
2324
|
+
...critic_tool_options(config)
|
|
2325
|
+
});
|
|
2326
|
+
return finalize_critic(state, result2, config.critic_model, {
|
|
2327
|
+
retries: attempt,
|
|
2328
|
+
retry_cause_kind
|
|
2329
|
+
});
|
|
2330
|
+
} catch (err) {
|
|
2331
|
+
if (!is_retryable_critic_error(config, ctx, err)) throw err;
|
|
2332
|
+
retry_cause_kind = retry_cause_kind_of(err);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
const result = await engine.generate({
|
|
2336
|
+
...base,
|
|
2337
|
+
prompt: toolless_critic_prompt(config, tool_prompt)
|
|
2338
|
+
});
|
|
2339
|
+
return finalize_critic(state, result, config.critic_model, {
|
|
2340
|
+
retries: MAX_CRITIC_RETRIES,
|
|
2341
|
+
retry_cause_kind,
|
|
2342
|
+
critic_degraded: true
|
|
2343
|
+
});
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
throw phase_error("critic", state.iteration, err);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// src/orchestrator.ts
|
|
2350
|
+
function initial_state() {
|
|
2351
|
+
return {
|
|
2352
|
+
iteration: 0,
|
|
2353
|
+
iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2354
|
+
feedback: null,
|
|
2355
|
+
verdict: null,
|
|
2356
|
+
unmet_criteria: [],
|
|
2357
|
+
check: null,
|
|
2358
|
+
changes: null,
|
|
2359
|
+
builder: null,
|
|
2360
|
+
critic: null,
|
|
2361
|
+
total_usage: EMPTY_USAGE,
|
|
2362
|
+
total_cost_usd: 0,
|
|
2363
|
+
builder_cost_usd: 0,
|
|
2364
|
+
critic_cost_usd: 0,
|
|
2365
|
+
check_duration_ms: 0,
|
|
2366
|
+
iteration_cost_usd: 0,
|
|
2367
|
+
halt: null,
|
|
2368
|
+
cost_warned: false
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
function gate_edit_halt(config, state) {
|
|
2372
|
+
return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
|
|
2373
|
+
}
|
|
2374
|
+
function gate(config, state) {
|
|
2375
|
+
const gate_edit = gate_edit_halt(config, state);
|
|
2376
|
+
const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
|
|
2377
|
+
const cap = cost_cap_hit(config, state);
|
|
2378
|
+
const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
|
|
2379
|
+
return {
|
|
2380
|
+
stop: success || cap || gate_edit,
|
|
2381
|
+
state: { ...state, halt }
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
function status_of(value, converged) {
|
|
2385
|
+
if (value.halt === "gate_edit") return "gate_edit_blocked";
|
|
2386
|
+
if (value.halt === "cost_cap") return "cost_cap_reached";
|
|
2387
|
+
return converged ? "success" : "budget_exhausted";
|
|
2388
|
+
}
|
|
2389
|
+
var LISTED_GATE_EDITS = 5;
|
|
2390
|
+
function report_changes(config, renderer, changes) {
|
|
2391
|
+
if (changes === null) return;
|
|
2392
|
+
renderer.info(
|
|
2393
|
+
`changes: ${String(changes.total)} file(s) changed since the run baseline` + (changes.truncated ? ` (listing the first ${String(changes.files.length)})` : "")
|
|
2394
|
+
);
|
|
2395
|
+
if (changes.gate_edits.length === 0) return;
|
|
2396
|
+
const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
|
|
2397
|
+
const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
|
|
2398
|
+
renderer.warn(
|
|
2399
|
+
`gate edit: the builder changed ${String(changes.gate_edits.length)} file(s) that decide whether its work passes \u2014 ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when the builder can edit the gate; the critic is told about this and the run summary records it.` + // The halt lands at the loop guard, after this iteration's check and
|
|
2400
|
+
// critic have had their say — so promise the outcome, not the timing.
|
|
2401
|
+
(config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : "")
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
async function execute_check(config, abort) {
|
|
2405
|
+
const root = build_root(config.workspace, config.worktree);
|
|
2406
|
+
if (config.check_resolved === "checkride") {
|
|
2407
|
+
return run_checkride({ workspace: root, abort });
|
|
2408
|
+
}
|
|
2409
|
+
if (config.check_resolved === "command") {
|
|
2410
|
+
return run_command_check({ command: config.check, workspace: root, abort });
|
|
2411
|
+
}
|
|
2412
|
+
return skipped_check("none");
|
|
2413
|
+
}
|
|
2414
|
+
async function run_volley(config, deps, resume_from = null) {
|
|
2415
|
+
const { renderer } = deps;
|
|
2416
|
+
if (resume_from === null) {
|
|
2417
|
+
initialize_workspace(config.workspace);
|
|
2418
|
+
write_resolved_config(config);
|
|
2419
|
+
} else {
|
|
2420
|
+
initialize_workspace(config.workspace, { preserve: true });
|
|
2421
|
+
}
|
|
2422
|
+
report_worktree_fate(config, renderer);
|
|
2423
|
+
const branch = worktree_branch(config.run_id);
|
|
2424
|
+
const fate = worktree_fate(config);
|
|
2425
|
+
const outcome = await with_worktree(
|
|
2426
|
+
{
|
|
2427
|
+
enabled: config.worktree,
|
|
2428
|
+
workspace: config.workspace,
|
|
2429
|
+
branch,
|
|
2430
|
+
log: (message) => renderer.info(message),
|
|
2431
|
+
// The one case teardown must keep rather than force-delete: a converged run
|
|
2432
|
+
// that integrated nothing, whose only copy of its work is the worktree.
|
|
2433
|
+
salvage: (result) => fate === "salvage" && result.status === "success"
|
|
2434
|
+
},
|
|
2435
|
+
async () => {
|
|
2436
|
+
const baseline = capture_baseline(build_root(config.workspace, config.worktree));
|
|
2437
|
+
const result = await run_loop(config, deps, resume_from, null, baseline);
|
|
2438
|
+
if (fate === "integrate" && result.status === "success") {
|
|
2439
|
+
integrate_worktree(config.workspace, branch, `volley run ${config.run_id}: integrate worktree (squash)`);
|
|
2440
|
+
renderer.info(`worktree: squash-merged ${branch} onto the workspace branch`);
|
|
2441
|
+
}
|
|
2442
|
+
return result;
|
|
2443
|
+
}
|
|
2444
|
+
);
|
|
2445
|
+
if (outcome.kept_path !== null) {
|
|
2446
|
+
renderer.warn(
|
|
2447
|
+
`worktree: could not commit this run's work onto ${branch}, so the checkout at ${outcome.kept_path} was left standing rather than deleted \u2014 its files are intact, and the next --worktree run rotates the directory aside instead of destroying it.`
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
if (outcome.salvaged_branch === null) return outcome.value;
|
|
2451
|
+
renderer.info(
|
|
2452
|
+
`worktree: this run was not integrated; its work is preserved on branch ${outcome.salvaged_branch} (git switch ${outcome.salvaged_branch}, or cherry-pick it).`
|
|
2453
|
+
);
|
|
2454
|
+
const salvaged = { ...outcome.value, salvaged_branch: outcome.salvaged_branch };
|
|
2455
|
+
write_run_summary(config, salvaged);
|
|
2456
|
+
return salvaged;
|
|
2457
|
+
}
|
|
2458
|
+
async function run_loop(config, deps, resume_from, bash_executor, baseline) {
|
|
2459
|
+
const { renderer } = deps;
|
|
2460
|
+
const engine = deps.engine ?? create_volley_engine({
|
|
2461
|
+
workspace: config.workspace,
|
|
2462
|
+
builder_provider: config.builder_provider,
|
|
2463
|
+
critic_provider: config.critic_provider
|
|
2464
|
+
});
|
|
2465
|
+
const build = step("build", async (s, ctx) => {
|
|
2466
|
+
const next = {
|
|
2467
|
+
...s,
|
|
2468
|
+
iteration: s.iteration + 1,
|
|
2469
|
+
iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2470
|
+
iteration_cost_usd: 0,
|
|
2471
|
+
builder: null,
|
|
2472
|
+
critic: null,
|
|
2473
|
+
check: null,
|
|
2474
|
+
verdict: null,
|
|
2475
|
+
unmet_criteria: []
|
|
2476
|
+
};
|
|
2477
|
+
renderer.phase_start(next.iteration, "builder");
|
|
2478
|
+
const built = await run_builder(
|
|
2479
|
+
{ engine, config, on_chunk: renderer.builder_chunk, warn: renderer.warn, bash_executor },
|
|
2480
|
+
next,
|
|
2481
|
+
ctx
|
|
2482
|
+
);
|
|
2483
|
+
renderer.phase_end(built.iteration, "builder", true);
|
|
2484
|
+
renderer.cost_line(
|
|
2485
|
+
built.iteration,
|
|
2486
|
+
"builder",
|
|
2487
|
+
built.builder?.cost_usd ?? null,
|
|
2488
|
+
built.total_cost_usd
|
|
2489
|
+
);
|
|
2490
|
+
if (built.cost_warned && !s.cost_warned) {
|
|
2491
|
+
renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
2492
|
+
}
|
|
2493
|
+
const changes = collect_changes({
|
|
2494
|
+
root: build_root(config.workspace, config.worktree),
|
|
2495
|
+
baseline,
|
|
2496
|
+
gate_patterns: config.gate_paths
|
|
2497
|
+
});
|
|
2498
|
+
report_changes(config, renderer, changes);
|
|
2499
|
+
if (config.git_checkpoints) {
|
|
2500
|
+
git_checkpoint(
|
|
2501
|
+
build_root(config.workspace, config.worktree),
|
|
2502
|
+
`volley iter ${String(built.iteration)}: build`
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
return { ...built, changes };
|
|
2506
|
+
});
|
|
2507
|
+
const check = step("check", async (s, ctx) => {
|
|
2508
|
+
if (cost_cap_hit(config, s)) {
|
|
2509
|
+
renderer.warn("cost cap crossed during build; skipping check and critic");
|
|
2510
|
+
return { ...s, check: skipped_check("cost_cap") };
|
|
2511
|
+
}
|
|
2512
|
+
renderer.phase_start(s.iteration, "check");
|
|
2513
|
+
try {
|
|
2514
|
+
const result = await execute_check(config, ctx.abort);
|
|
2515
|
+
if (result.ran) {
|
|
2516
|
+
renderer.phase_end(
|
|
2517
|
+
s.iteration,
|
|
2518
|
+
"check",
|
|
2519
|
+
result.ok,
|
|
2520
|
+
result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0
|
|
2521
|
+
);
|
|
2522
|
+
} else {
|
|
2523
|
+
renderer.info("no deterministic check configured; loop is critic-gated only");
|
|
2524
|
+
}
|
|
2525
|
+
return {
|
|
2526
|
+
...s,
|
|
2527
|
+
check: result,
|
|
2528
|
+
check_duration_ms: s.check_duration_ms + result.duration_ms
|
|
2529
|
+
};
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
if (error_kind(err) === "check_error") throw err;
|
|
2532
|
+
throw phase_error("check", s.iteration, err);
|
|
2533
|
+
}
|
|
2534
|
+
});
|
|
2535
|
+
const critique = step("critique", async (s, ctx) => {
|
|
2536
|
+
if (s.check !== null && !s.check.ran && cost_cap_hit(config, s)) {
|
|
2537
|
+
return s;
|
|
2538
|
+
}
|
|
2539
|
+
renderer.phase_start(s.iteration, "critic");
|
|
2540
|
+
const critiqued = await run_critic(
|
|
2541
|
+
{ engine, config, on_chunk: renderer.critic_chunk },
|
|
2542
|
+
s,
|
|
2543
|
+
ctx
|
|
2544
|
+
);
|
|
2545
|
+
renderer.phase_end(
|
|
2546
|
+
critiqued.iteration,
|
|
2547
|
+
"critic",
|
|
2548
|
+
critiqued.verdict === "approved",
|
|
2549
|
+
critiqued.verdict ?? void 0
|
|
2550
|
+
);
|
|
2551
|
+
renderer.cost_line(
|
|
2552
|
+
critiqued.iteration,
|
|
2553
|
+
"critic",
|
|
2554
|
+
critiqued.critic?.cost_usd ?? null,
|
|
2555
|
+
critiqued.total_cost_usd
|
|
2556
|
+
);
|
|
2557
|
+
return critiqued;
|
|
2558
|
+
});
|
|
2559
|
+
const record = step("record", (s) => {
|
|
2560
|
+
archive_iteration(config, s);
|
|
2561
|
+
write_run_summary(config, run_result_from_state(config, s, "running", null));
|
|
2562
|
+
if (config.git_checkpoints) {
|
|
2563
|
+
git_checkpoint(
|
|
2564
|
+
build_root(config.workspace, config.worktree),
|
|
2565
|
+
`volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
return s;
|
|
2569
|
+
});
|
|
2570
|
+
const flow = loop({
|
|
2571
|
+
name: "volley",
|
|
2572
|
+
init: (input) => input.resume_from ?? initial_state(),
|
|
2573
|
+
body: sequence([build, check, critique, record]),
|
|
2574
|
+
guard: step("gate", (s) => gate(config, s)),
|
|
2575
|
+
finish: (s) => s,
|
|
2576
|
+
max_rounds: config.max_iterations - (resume_from?.iteration ?? 0)
|
|
2577
|
+
});
|
|
2578
|
+
try {
|
|
2579
|
+
const { value, converged } = await run(
|
|
2580
|
+
flow,
|
|
2581
|
+
{ resume_from },
|
|
2582
|
+
{
|
|
2583
|
+
trajectory: filesystem_logger({
|
|
2584
|
+
output_path: volley_path(config.workspace, "trajectory.jsonl")
|
|
2585
|
+
}),
|
|
2586
|
+
...deps.abort !== void 0 ? { abort: deps.abort } : {},
|
|
2587
|
+
...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
|
|
2588
|
+
}
|
|
2589
|
+
);
|
|
2590
|
+
const status = status_of(value, converged);
|
|
2591
|
+
const result = run_result_from_state(config, value, status, (/* @__PURE__ */ new Date()).toISOString());
|
|
2592
|
+
write_run_summary(config, result);
|
|
2593
|
+
return result;
|
|
2594
|
+
} catch (err) {
|
|
2595
|
+
finalize_failed_run(config, err);
|
|
2596
|
+
throw err;
|
|
2597
|
+
} finally {
|
|
2598
|
+
await engine.dispose();
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
function finalize_failed_run(config, err) {
|
|
2602
|
+
const kind = error_kind(err);
|
|
2603
|
+
const cause = kind === "phase_error" ? error_kind(err.cause) : null;
|
|
2604
|
+
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
2605
|
+
try {
|
|
2606
|
+
const summary_path = volley_path(config.workspace, "summary.json");
|
|
2607
|
+
const prior = existsSync11(summary_path) ? JSON.parse(readFileSync11(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
2608
|
+
write_run_summary(config, {
|
|
2609
|
+
...prior,
|
|
2610
|
+
status,
|
|
2611
|
+
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2612
|
+
});
|
|
2613
|
+
} catch {
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/matrix.ts
|
|
2618
|
+
var EXIT_MATRIX_INCOMPLETE = 1;
|
|
2619
|
+
var DEFAULT_REPEAT = 1;
|
|
2620
|
+
function parse_model_list(value, flag) {
|
|
2621
|
+
const items = (value ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
2622
|
+
if (items.length === 0) {
|
|
2623
|
+
throw config_error(`${flag} must be a comma-separated list of at least one model`);
|
|
2624
|
+
}
|
|
2625
|
+
return items;
|
|
2626
|
+
}
|
|
2627
|
+
function parse_repeat(value) {
|
|
2628
|
+
if (value === void 0) return DEFAULT_REPEAT;
|
|
2629
|
+
const repeat = Number(value);
|
|
2630
|
+
if (!Number.isInteger(repeat) || repeat < 1) {
|
|
2631
|
+
throw config_error(`--repeat must be a positive integer; got: ${String(value)}`);
|
|
2632
|
+
}
|
|
2633
|
+
return repeat;
|
|
2634
|
+
}
|
|
2635
|
+
function combo_config(base, combo) {
|
|
2636
|
+
return {
|
|
2637
|
+
...base,
|
|
2638
|
+
builder_model: combo.builder,
|
|
2639
|
+
critic_model: combo.critic,
|
|
2640
|
+
worktree: true,
|
|
2641
|
+
discard_worktree: true
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
var default_run_combo = async (base, combo, renderer) => {
|
|
2645
|
+
const config = resolve_config(combo_config(base, combo));
|
|
2646
|
+
config.check_resolved = resolve_check_runner(config.check, config.workspace);
|
|
2647
|
+
try {
|
|
2648
|
+
const result = await run_volley(config, { renderer });
|
|
2649
|
+
return build_run_summary(config, result);
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
const recovered = read_run_summary(config.workspace);
|
|
2652
|
+
if (recovered === null) throw err;
|
|
2653
|
+
return recovered;
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
function read_run_summary(workspace) {
|
|
2657
|
+
const path = volley_path(workspace, "summary.json");
|
|
2658
|
+
if (!existsSync12(path)) return null;
|
|
2659
|
+
try {
|
|
2660
|
+
return JSON.parse(readFileSync12(path, "utf8"));
|
|
2661
|
+
} catch {
|
|
2662
|
+
return null;
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
var REASON_MAX = 44;
|
|
2666
|
+
function clip(text) {
|
|
2667
|
+
const flat = text.replaceAll(/\s+/g, " ").trim();
|
|
2668
|
+
return flat.length > REASON_MAX ? `${flat.slice(0, REASON_MAX - 1)}\u2026` : flat;
|
|
2669
|
+
}
|
|
2670
|
+
function last_failing_slots(summary) {
|
|
2671
|
+
const ran = summary.comparison.check_trajectory.filter((point) => point.ran);
|
|
2672
|
+
return ran.at(-1)?.failing_slots ?? [];
|
|
2673
|
+
}
|
|
2674
|
+
function failure_reason(summary) {
|
|
2675
|
+
if (summary.status === "success") return null;
|
|
2676
|
+
if (summary.status === "cost_cap_reached") return "cost cap";
|
|
2677
|
+
if (summary.status === "interrupted") return "interrupted";
|
|
2678
|
+
if (summary.status === "gate_edit_blocked") {
|
|
2679
|
+
return clip(`gate edit: ${summary.comparison.gate_edits.join(", ")}`);
|
|
2680
|
+
}
|
|
2681
|
+
const failing = last_failing_slots(summary);
|
|
2682
|
+
if (failing.length > 0) return clip(`check: ${failing.join(", ")}`);
|
|
2683
|
+
const unmet = summary.comparison.unmet_criteria;
|
|
2684
|
+
if (unmet.length === 1) return clip(`unmet: ${unmet[0] ?? ""}`);
|
|
2685
|
+
if (unmet.length > 1) return clip(`unmet: ${String(unmet.length)} criteria`);
|
|
2686
|
+
return summary.status === "error" ? "run error" : String(summary.status);
|
|
2687
|
+
}
|
|
2688
|
+
function attempt_from_summary(attempt, summary) {
|
|
2689
|
+
const c = summary.comparison;
|
|
2690
|
+
return {
|
|
2691
|
+
attempt,
|
|
2692
|
+
status: summary.status,
|
|
2693
|
+
converged: summary.status === "success",
|
|
2694
|
+
iterations_to_converge: c.iterations_to_converge,
|
|
2695
|
+
wall_clock_ms: c.wall_clock_ms,
|
|
2696
|
+
total_cost_usd: c.total_cost_usd,
|
|
2697
|
+
final_verdict: c.final_verdict,
|
|
2698
|
+
failing_slots: last_failing_slots(summary),
|
|
2699
|
+
unmet_criteria: c.unmet_criteria,
|
|
2700
|
+
gate_edits: c.gate_edits,
|
|
2701
|
+
critic_degraded: c.critic_degraded,
|
|
2702
|
+
tool_calls: c.local_salvage.tool_calls,
|
|
2703
|
+
salvaged_tool_calls: c.local_salvage.salvaged_tool_calls,
|
|
2704
|
+
error: null,
|
|
2705
|
+
reason: failure_reason(summary)
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function broke_attempt(attempt, message) {
|
|
2709
|
+
return {
|
|
2710
|
+
attempt,
|
|
2711
|
+
status: "broke",
|
|
2712
|
+
converged: false,
|
|
2713
|
+
iterations_to_converge: null,
|
|
2714
|
+
wall_clock_ms: null,
|
|
2715
|
+
total_cost_usd: null,
|
|
2716
|
+
final_verdict: null,
|
|
2717
|
+
failing_slots: [],
|
|
2718
|
+
unmet_criteria: [],
|
|
2719
|
+
gate_edits: [],
|
|
2720
|
+
critic_degraded: false,
|
|
2721
|
+
tool_calls: 0,
|
|
2722
|
+
salvaged_tool_calls: 0,
|
|
2723
|
+
error: message,
|
|
2724
|
+
reason: clip(`broke: ${message}`)
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
function mean(values) {
|
|
2728
|
+
if (values.length === 0) return null;
|
|
2729
|
+
return values.reduce((total, value) => total + value, 0) / values.length;
|
|
2730
|
+
}
|
|
2731
|
+
function defined_numbers(values) {
|
|
2732
|
+
return values.filter((value) => value !== null);
|
|
2733
|
+
}
|
|
2734
|
+
function aggregate_row(combo, attempts) {
|
|
2735
|
+
const converged = attempts.filter((attempt) => attempt.converged);
|
|
2736
|
+
const tool_calls = attempts.reduce((total, attempt) => total + attempt.tool_calls, 0);
|
|
2737
|
+
const salvaged = attempts.reduce((total, attempt) => total + attempt.salvaged_tool_calls, 0);
|
|
2738
|
+
const gate_edits = new Set(attempts.flatMap((attempt) => attempt.gate_edits));
|
|
2739
|
+
return {
|
|
2740
|
+
builder: combo.builder,
|
|
2741
|
+
critic: combo.critic,
|
|
2742
|
+
runs: attempts.length,
|
|
2743
|
+
converged: converged.length,
|
|
2744
|
+
mean_iterations: mean(defined_numbers(converged.map((a) => a.iterations_to_converge))),
|
|
2745
|
+
mean_wall_clock_ms: mean(defined_numbers(attempts.map((a) => a.wall_clock_ms))),
|
|
2746
|
+
mean_cost_usd: mean(defined_numbers(attempts.map((a) => a.total_cost_usd))),
|
|
2747
|
+
salvage_rate: tool_calls === 0 ? null : salvaged / tool_calls,
|
|
2748
|
+
critic_degraded: attempts.some((attempt) => attempt.critic_degraded),
|
|
2749
|
+
gate_edits: [...gate_edits].toSorted(),
|
|
2750
|
+
reason: attempts.findLast((attempt) => !attempt.converged)?.reason ?? null,
|
|
2751
|
+
attempts
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
function cross_product(builders, critics) {
|
|
2755
|
+
return builders.flatMap((builder) => critics.map((critic) => ({ builder, critic })));
|
|
2756
|
+
}
|
|
2757
|
+
function combo_dir(matrix_dir, combo) {
|
|
2758
|
+
return join9(matrix_dir, `${slug(combo.builder)}__${slug(combo.critic)}`);
|
|
2759
|
+
}
|
|
2760
|
+
function slug(model) {
|
|
2761
|
+
return model.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
2762
|
+
}
|
|
2763
|
+
function persist_attempt(matrix_dir, combo, attempt, summary) {
|
|
2764
|
+
const dir = join9(combo_dir(matrix_dir, combo), `run-${String(attempt).padStart(2, "0")}`);
|
|
2765
|
+
mkdirSync4(dir, { recursive: true });
|
|
2766
|
+
writeFileSync5(join9(dir, "summary.json"), `${JSON.stringify(summary, null, 2)}
|
|
2767
|
+
`);
|
|
2768
|
+
}
|
|
2769
|
+
function persist_matrix(matrix_dir, rows) {
|
|
2770
|
+
mkdirSync4(matrix_dir, { recursive: true });
|
|
2771
|
+
const path = join9(matrix_dir, "matrix.json");
|
|
2772
|
+
writeFileSync5(path, `${JSON.stringify({ combos: rows }, null, 2)}
|
|
2773
|
+
`);
|
|
2774
|
+
return path;
|
|
2775
|
+
}
|
|
2776
|
+
async function run_combo_attempts(options) {
|
|
2777
|
+
const { combo, renderer, repeat, tag } = options;
|
|
2778
|
+
const attempts = [];
|
|
2779
|
+
let ok = true;
|
|
2780
|
+
for (let attempt = 1; attempt <= repeat; attempt += 1) {
|
|
2781
|
+
const label = repeat === 1 ? tag : `${tag} run ${String(attempt)}/${String(repeat)}`;
|
|
2782
|
+
renderer.info(`${label}: builder=${combo.builder} critic=${combo.critic}`);
|
|
2783
|
+
try {
|
|
2784
|
+
const summary = await options.run_combo(options.base, combo, renderer, attempt);
|
|
2785
|
+
persist_attempt(options.matrix_dir, combo, attempt, summary);
|
|
2786
|
+
const result = attempt_from_summary(attempt, summary);
|
|
2787
|
+
attempts.push(result);
|
|
2788
|
+
renderer.info(`${label}: \u2192 ${result.status}${result.reason === null ? "" : ` (${result.reason})`}`);
|
|
2789
|
+
} catch (err) {
|
|
2790
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2791
|
+
attempts.push(broke_attempt(attempt, message));
|
|
2792
|
+
ok = false;
|
|
2793
|
+
renderer.warn(`${label}: produced no summary: ${message}`);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return { attempts, ok };
|
|
2797
|
+
}
|
|
2798
|
+
async function run_matrix(options) {
|
|
2799
|
+
const { base, matrix_dir, renderer } = options;
|
|
2800
|
+
const run_combo = options.run_combo ?? default_run_combo;
|
|
2801
|
+
const repeat = options.repeat ?? DEFAULT_REPEAT;
|
|
2802
|
+
const combos = cross_product(options.builders, options.critics);
|
|
2803
|
+
renderer.info(
|
|
2804
|
+
`matrix: ${String(combos.length)} seat(s) \xD7 ${String(repeat)} run(s) = ${String(combos.length * repeat)} run(s), serially`
|
|
2805
|
+
);
|
|
2806
|
+
const rows = [];
|
|
2807
|
+
let ok = true;
|
|
2808
|
+
let index = 0;
|
|
2809
|
+
for (const combo of combos) {
|
|
2810
|
+
index += 1;
|
|
2811
|
+
const outcome = await run_combo_attempts({
|
|
2812
|
+
base,
|
|
2813
|
+
matrix_dir,
|
|
2814
|
+
renderer,
|
|
2815
|
+
combo,
|
|
2816
|
+
repeat,
|
|
2817
|
+
run_combo,
|
|
2818
|
+
tag: `matrix [${String(index)}/${String(combos.length)}]`
|
|
2819
|
+
});
|
|
2820
|
+
ok = ok && outcome.ok;
|
|
2821
|
+
rows.push(aggregate_row(combo, outcome.attempts));
|
|
2822
|
+
}
|
|
2823
|
+
renderer.info(render_matrix_table(rows));
|
|
2824
|
+
renderer.info(`matrix: wrote ${persist_matrix(matrix_dir, rows)}`);
|
|
2825
|
+
return { rows, ok };
|
|
2826
|
+
}
|
|
2827
|
+
var MATRIX_COLUMNS = [
|
|
2828
|
+
"builder",
|
|
2829
|
+
"critic",
|
|
2830
|
+
"pass",
|
|
2831
|
+
"iters",
|
|
2832
|
+
"wall",
|
|
2833
|
+
"cost",
|
|
2834
|
+
"salvage",
|
|
2835
|
+
"flags",
|
|
2836
|
+
"why"
|
|
2837
|
+
];
|
|
2838
|
+
function format_wall(ms) {
|
|
2839
|
+
return ms === null ? "\u2014" : `${(ms / 1e3).toFixed(1)}s`;
|
|
2840
|
+
}
|
|
2841
|
+
function format_rate(rate) {
|
|
2842
|
+
return rate === null ? "\u2014" : `${String(Math.round(rate * 100))}%`;
|
|
2843
|
+
}
|
|
2844
|
+
function format_cost(usd) {
|
|
2845
|
+
return usd === null ? "\u2014" : `$${usd.toFixed(2)}`;
|
|
2846
|
+
}
|
|
2847
|
+
function format_iterations(iterations) {
|
|
2848
|
+
return iterations === null ? "\u2014" : iterations.toFixed(1);
|
|
2849
|
+
}
|
|
2850
|
+
function format_flags(row) {
|
|
2851
|
+
const flags = [
|
|
2852
|
+
row.critic_degraded ? "deg" : null,
|
|
2853
|
+
row.gate_edits.length > 0 ? "gate" : null
|
|
2854
|
+
].filter((flag) => flag !== null);
|
|
2855
|
+
return flags.length === 0 ? "\u2014" : flags.join(",");
|
|
2856
|
+
}
|
|
2857
|
+
function cell_values(row) {
|
|
2858
|
+
return [
|
|
2859
|
+
row.builder,
|
|
2860
|
+
row.critic,
|
|
2861
|
+
`${String(row.converged)}/${String(row.runs)}`,
|
|
2862
|
+
format_iterations(row.mean_iterations),
|
|
2863
|
+
format_wall(row.mean_wall_clock_ms),
|
|
2864
|
+
format_cost(row.mean_cost_usd),
|
|
2865
|
+
format_rate(row.salvage_rate),
|
|
2866
|
+
format_flags(row),
|
|
2867
|
+
row.reason ?? "\u2014"
|
|
2868
|
+
];
|
|
2869
|
+
}
|
|
2870
|
+
function render_matrix_table(rows) {
|
|
2871
|
+
const header = [...MATRIX_COLUMNS];
|
|
2872
|
+
const body = rows.map(cell_values);
|
|
2873
|
+
const widths = header.map(
|
|
2874
|
+
(title, col) => Math.max(title.length, ...body.map((cells) => (cells[col] ?? "").length))
|
|
2875
|
+
);
|
|
2876
|
+
const render_line = (cells) => cells.map((cell, col) => cell.padEnd(widths[col] ?? 0)).join(" ").trimEnd();
|
|
2877
|
+
const separator = widths.map((w) => "\u2500".repeat(w)).join(" ");
|
|
2878
|
+
return [render_line(header), separator, ...body.map(render_line)].join("\n");
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// src/preflight.ts
|
|
2882
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
2883
|
+
import { existsSync as existsSync13 } from "fs";
|
|
2884
|
+
import { join as join10, resolve as resolve4 } from "path";
|
|
2885
|
+
var REQUIRED_TOOLCHAIN = ["node", "pnpm", "git"];
|
|
2886
|
+
var ENDPOINT_PROBE_TIMEOUT_MS = 3e3;
|
|
2887
|
+
function is_local_builder(provider) {
|
|
2888
|
+
return provider === "ollama" || provider === "lmstudio";
|
|
2889
|
+
}
|
|
2890
|
+
function is_local_critic(provider) {
|
|
2891
|
+
return provider === "ollama" || provider === "lmstudio";
|
|
2892
|
+
}
|
|
2893
|
+
function model_endpoint(provider, env) {
|
|
2894
|
+
if (provider === "ollama") return resolve_ollama_base_url(env);
|
|
2895
|
+
const lmstudio_url = env["VOLLEY_LMSTUDIO_URL"] ?? DEFAULT_LMSTUDIO_URL;
|
|
2896
|
+
return cross_to_host_gateway(lmstudio_url, env);
|
|
2897
|
+
}
|
|
2898
|
+
function toolchain_missing(tools = REQUIRED_TOOLCHAIN) {
|
|
2899
|
+
return tools.filter((tool) => {
|
|
2900
|
+
const result = spawnSync5(tool, ["--version"], { stdio: "ignore" });
|
|
2901
|
+
return result.error !== void 0 || result.status !== 0;
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
function worktree_creatable(workspace) {
|
|
2905
|
+
const repo = resolve4(workspace);
|
|
2906
|
+
const version = spawnSync5("git", ["--version"], { stdio: "ignore" });
|
|
2907
|
+
if (version.error !== void 0 || version.status !== 0) {
|
|
2908
|
+
return { ok: false, detail: "git is not available" };
|
|
2909
|
+
}
|
|
2910
|
+
if (!existsSync13(resolve4(repo, ".git"))) {
|
|
2911
|
+
return { ok: false, detail: `${repo} is not a git repository` };
|
|
2912
|
+
}
|
|
2913
|
+
const inside = spawnSync5("git", ["-C", repo, "rev-parse", "--is-inside-work-tree"], {
|
|
2914
|
+
encoding: "utf8"
|
|
2915
|
+
});
|
|
2916
|
+
if (inside.status !== 0 || (inside.stdout ?? "").trim() !== "true") {
|
|
2917
|
+
return { ok: false, detail: `${repo} is not a git work tree` };
|
|
2918
|
+
}
|
|
2919
|
+
return { ok: true, detail: repo };
|
|
2920
|
+
}
|
|
2921
|
+
async function endpoint_reachable(url, timeout_ms = ENDPOINT_PROBE_TIMEOUT_MS) {
|
|
2922
|
+
try {
|
|
2923
|
+
await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeout_ms) });
|
|
2924
|
+
return true;
|
|
2925
|
+
} catch {
|
|
2926
|
+
return false;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
function default_canary_engine(config, env) {
|
|
2930
|
+
return create_volley_engine({
|
|
2931
|
+
workspace: config.workspace,
|
|
2932
|
+
critic_provider: config.critic_provider,
|
|
2933
|
+
env
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
function report_gate_posture(config, renderer) {
|
|
2937
|
+
const root = build_root(config.workspace, config.worktree);
|
|
2938
|
+
const detectable = capture_baseline(root) !== null || existsSync13(join10(root, ".git"));
|
|
2939
|
+
if (config.fail_on_gate_edit && !detectable) {
|
|
2940
|
+
renderer.warn(
|
|
2941
|
+
"--fail-on-gate-edit is set, but the build root is not a git repository, so there is no baseline to diff against and gate edits cannot be detected. The flag will not fire: run against a git workspace to get the refusal you asked for."
|
|
2942
|
+
);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
if (config.gate_paths.length === 0) {
|
|
2946
|
+
renderer.info("gate: no gate paths configured; builder edits to tests and check config are not flagged.");
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
renderer.info(
|
|
2950
|
+
`gate: watching ${String(config.gate_paths.length)} path pattern(s)` + (config.fail_on_gate_edit ? " \u2014 a builder edit to any of them halts the run (--fail-on-gate-edit, exit 8)." : " \u2014 edits are reported to the critic and recorded in the summary, not refused.")
|
|
2951
|
+
);
|
|
2952
|
+
}
|
|
2953
|
+
function run_checkride_doctor(workspace) {
|
|
2954
|
+
const doctor = spawnSync5("pnpm", ["exec", "checkride", "doctor"], {
|
|
2955
|
+
cwd: workspace,
|
|
2956
|
+
stdio: ["ignore", "ignore", "inherit"]
|
|
2957
|
+
});
|
|
2958
|
+
return doctor.error === void 0 && doctor.status === 0;
|
|
2959
|
+
}
|
|
2960
|
+
async function preflight(config, renderer, env, probes = {}) {
|
|
2961
|
+
const provider = config.builder_provider;
|
|
2962
|
+
const local_builder = is_local_builder(provider);
|
|
2963
|
+
renderer.info(`dry run: config valid (run ${config.run_id})`);
|
|
2964
|
+
renderer.info(`workspace: ${config.workspace}`);
|
|
2965
|
+
renderer.info(`check: ${config.check} (resolved: ${config.check_resolved})`);
|
|
2966
|
+
renderer.info(`builder model: ${config.builder_model} (provider: ${config.builder_provider})`);
|
|
2967
|
+
renderer.info(`builder max steps: ${config.builder_max_steps}`);
|
|
2968
|
+
if (local_builder) {
|
|
2969
|
+
const contained = env["VOLLEY_CONTAINED"] === "1" || env["VOLLEY_CONTAINED"] === "true";
|
|
2970
|
+
renderer.info(
|
|
2971
|
+
contained ? "containment: inside the sandbox container (VOLLEY_CONTAINED)" : "containment: uncontained on the host (--allow-unsandboxed-builder)"
|
|
2972
|
+
);
|
|
2973
|
+
renderer.info(`sandbox image: ${config.sandbox_image}`);
|
|
2974
|
+
}
|
|
2975
|
+
renderer.info(
|
|
2976
|
+
`critic: ${config.critic_preset}${config.critic_prompt_path !== null ? ` (${config.critic_prompt_path})` : ""}`
|
|
2977
|
+
);
|
|
2978
|
+
renderer.info(`critic model: ${config.critic_model} (provider: ${config.critic_provider})`);
|
|
2979
|
+
report_worktree_fate(config, renderer);
|
|
2980
|
+
report_gate_posture(config, renderer);
|
|
2981
|
+
if (config.check_resolved === "checkride") {
|
|
2982
|
+
const doctor = probes.checkride_doctor ?? run_checkride_doctor;
|
|
2983
|
+
if (!doctor(config.workspace)) {
|
|
2984
|
+
renderer.error("checkride doctor failed; fix the workspace check pipeline before running");
|
|
2985
|
+
return EXIT_CONFIG_ERROR;
|
|
2986
|
+
}
|
|
2987
|
+
renderer.info("checkride doctor: ok");
|
|
2988
|
+
}
|
|
2989
|
+
if (is_local_builder(provider)) {
|
|
2990
|
+
const missing = (probes.toolchain_missing ?? toolchain_missing)(REQUIRED_TOOLCHAIN);
|
|
2991
|
+
if (missing.length > 0) {
|
|
2992
|
+
renderer.error(`preflight: missing toolchain in the sandbox: ${missing.join(", ")}`);
|
|
2993
|
+
return EXIT_CONFIG_ERROR;
|
|
2994
|
+
}
|
|
2995
|
+
renderer.info(`toolchain: ${REQUIRED_TOOLCHAIN.join(", ")} present`);
|
|
2996
|
+
if (config.worktree) {
|
|
2997
|
+
const worktree = (probes.worktree_creatable ?? worktree_creatable)(config.workspace);
|
|
2998
|
+
if (!worktree.ok) {
|
|
2999
|
+
renderer.error(`preflight: worktree not creatable: ${worktree.detail}`);
|
|
3000
|
+
return EXIT_CONFIG_ERROR;
|
|
3001
|
+
}
|
|
3002
|
+
renderer.info(`worktree: creatable (${worktree.detail})`);
|
|
3003
|
+
}
|
|
3004
|
+
const endpoint = model_endpoint(provider, env);
|
|
3005
|
+
const reachable = await (probes.endpoint_reachable ?? endpoint_reachable)(endpoint);
|
|
3006
|
+
if (!reachable) {
|
|
3007
|
+
renderer.error(`preflight: host LLM endpoint unreachable: ${endpoint}`);
|
|
3008
|
+
return EXIT_CONFIG_ERROR;
|
|
3009
|
+
}
|
|
3010
|
+
renderer.info(`host LLM endpoint: reachable (${endpoint})`);
|
|
3011
|
+
}
|
|
3012
|
+
if (is_local_critic(config.critic_provider)) {
|
|
3013
|
+
const engine = (probes.canary_engine ?? default_canary_engine)(config, env);
|
|
3014
|
+
const seat = `model '${config.critic_model}' in the critic seat (provider: ${config.critic_provider})`;
|
|
3015
|
+
try {
|
|
3016
|
+
const canary = await critic_canary(engine, config, env);
|
|
3017
|
+
if (canary.outcome === "failed") {
|
|
3018
|
+
renderer.error(`preflight: critic canary failed \u2014 ${seat}: ${canary.detail}`);
|
|
3019
|
+
return EXIT_CONFIG_ERROR;
|
|
3020
|
+
}
|
|
3021
|
+
if (canary.outcome === "degraded") {
|
|
3022
|
+
renderer.warn(
|
|
3023
|
+
`critic canary: ${seat} died on the tool-bearing call (${canary.detail}) but survived tool-less; a real run will likely degrade to the tool-less fallback and be marked critic_degraded`
|
|
3024
|
+
);
|
|
3025
|
+
} else {
|
|
3026
|
+
renderer.info(`critic canary: ok (${seat} answered through the real tool wiring)`);
|
|
3027
|
+
}
|
|
3028
|
+
} finally {
|
|
3029
|
+
await engine.dispose();
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return EXIT_SUCCESS;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// src/render/format.ts
|
|
3036
|
+
import pc from "picocolors";
|
|
3037
|
+
var GLYPH_TOOL = "\u{1F527}";
|
|
3038
|
+
var GLYPH_OK = "\u2705";
|
|
3039
|
+
var GLYPH_ERR = "\u274C";
|
|
3040
|
+
var GLYPH_COST = "\u{1F4B0}";
|
|
3041
|
+
var GLYPH_PHASE_START = "\u25B6";
|
|
3042
|
+
var GLYPH_PHASE_OK = "\u2713";
|
|
3043
|
+
var GLYPH_PHASE_FAIL = "\u2717";
|
|
3044
|
+
var VERBOSE_TRUNCATE_CHARS = 4e3;
|
|
3045
|
+
function colors_enabled(env = process.env, is_tty = process.stderr.isTTY === true) {
|
|
3046
|
+
if (env["NO_COLOR"] !== void 0 || env["VOLLEY_NO_COLOR"] !== void 0) {
|
|
3047
|
+
return false;
|
|
3048
|
+
}
|
|
3049
|
+
return is_tty;
|
|
3050
|
+
}
|
|
3051
|
+
function paint(role, text, color) {
|
|
3052
|
+
if (!color) return text;
|
|
3053
|
+
switch (role) {
|
|
3054
|
+
case "builder":
|
|
3055
|
+
return pc.cyan(text);
|
|
3056
|
+
case "critic":
|
|
3057
|
+
return pc.yellow(text);
|
|
3058
|
+
case "check":
|
|
3059
|
+
return pc.green(text);
|
|
3060
|
+
case "cost":
|
|
3061
|
+
return pc.magenta(text);
|
|
3062
|
+
case "error":
|
|
3063
|
+
return pc.bold(pc.red(text));
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
function dim(text, color) {
|
|
3067
|
+
return color ? pc.dim(text) : text;
|
|
3068
|
+
}
|
|
3069
|
+
function truncate(text, max, pointer) {
|
|
3070
|
+
if (text.length <= max) return text;
|
|
3071
|
+
return `${text.slice(0, max)}
|
|
3072
|
+
\u2026 [truncated at ${String(max)} chars; ${pointer}]`;
|
|
3073
|
+
}
|
|
3074
|
+
function format_usd(value) {
|
|
3075
|
+
return value === null ? "$?" : `$${value.toFixed(3)}`;
|
|
3076
|
+
}
|
|
3077
|
+
function summarize_value(value, max = 120) {
|
|
3078
|
+
let text;
|
|
3079
|
+
try {
|
|
3080
|
+
text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
|
|
3081
|
+
} catch {
|
|
3082
|
+
text = String(value);
|
|
3083
|
+
}
|
|
3084
|
+
const flat = text.replace(/\s+/g, " ");
|
|
3085
|
+
return flat.length > max ? `${flat.slice(0, max)}\u2026` : flat;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// src/render/renderer.ts
|
|
3089
|
+
function create_renderer(options) {
|
|
3090
|
+
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
3091
|
+
const { mode, color } = options;
|
|
3092
|
+
const silent = mode === "json";
|
|
3093
|
+
const streaming = mode === "default" || mode === "verbose";
|
|
3094
|
+
let mid_line = false;
|
|
3095
|
+
const line = (text) => {
|
|
3096
|
+
if (silent) return;
|
|
3097
|
+
write(`${mid_line ? "\n" : ""}${text}
|
|
3098
|
+
`);
|
|
3099
|
+
mid_line = false;
|
|
3100
|
+
};
|
|
3101
|
+
const chunk_renderer = (role) => (chunk) => {
|
|
3102
|
+
if (!streaming) return;
|
|
3103
|
+
switch (chunk.kind) {
|
|
3104
|
+
case "text": {
|
|
3105
|
+
write(paint(role, chunk.text, color));
|
|
3106
|
+
mid_line = !chunk.text.endsWith("\n");
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
case "reasoning": {
|
|
3110
|
+
if (!options.show_thinking) return;
|
|
3111
|
+
write(dim(chunk.text, color));
|
|
3112
|
+
mid_line = !chunk.text.endsWith("\n");
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
case "tool_call_end": {
|
|
3116
|
+
const summary = mode === "verbose" ? truncate(
|
|
3117
|
+
JSON.stringify(chunk.input, null, 2) ?? "",
|
|
3118
|
+
VERBOSE_TRUNCATE_CHARS,
|
|
3119
|
+
"see .volley/trajectory.jsonl"
|
|
3120
|
+
) : summarize_value(chunk.input);
|
|
3121
|
+
line(`${GLYPH_TOOL} ${paint(role, chunk.id, color)} ${summary}`);
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
case "tool_result": {
|
|
3125
|
+
const ok = chunk.error === void 0;
|
|
3126
|
+
if (mode === "verbose") {
|
|
3127
|
+
const body = ok ? summarize_output(chunk.output) : chunk.error?.message ?? "";
|
|
3128
|
+
line(`${ok ? GLYPH_OK : GLYPH_ERR} ${truncate(body, VERBOSE_TRUNCATE_CHARS, "see .volley/trajectory.jsonl")}`);
|
|
3129
|
+
} else {
|
|
3130
|
+
line(`${ok ? GLYPH_OK : GLYPH_ERR} ${ok ? "" : summarize_value(chunk.error?.message)}`);
|
|
3131
|
+
}
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
case "tool_call_start": {
|
|
3135
|
+
line(`${GLYPH_TOOL} ${paint(role, chunk.name, color)} \u2026`);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
default:
|
|
3139
|
+
return;
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
return {
|
|
3143
|
+
builder_chunk: chunk_renderer("builder"),
|
|
3144
|
+
critic_chunk: chunk_renderer("critic"),
|
|
3145
|
+
phase_start: (iteration, phase) => {
|
|
3146
|
+
if (mode === "json") return;
|
|
3147
|
+
line(paint(phase, `${GLYPH_PHASE_START} [iter ${String(iteration)}] ${phase}`, color));
|
|
3148
|
+
},
|
|
3149
|
+
phase_end: (iteration, phase, ok, detail) => {
|
|
3150
|
+
if (mode === "json") return;
|
|
3151
|
+
const glyph = ok ? GLYPH_PHASE_OK : GLYPH_PHASE_FAIL;
|
|
3152
|
+
const suffix = detail !== void 0 ? ` \u2014 ${detail}` : "";
|
|
3153
|
+
line(paint(phase, `${glyph} [iter ${String(iteration)}] ${phase}${suffix}`, color));
|
|
3154
|
+
},
|
|
3155
|
+
cost_line: (iteration, phase, phase_cost, run_total) => {
|
|
3156
|
+
if (mode === "json") return;
|
|
3157
|
+
const cap = options.max_cost_usd !== null ? ` / ${format_usd(options.max_cost_usd)}` : "";
|
|
3158
|
+
line(
|
|
3159
|
+
paint(
|
|
3160
|
+
"cost",
|
|
3161
|
+
`${GLYPH_COST} [iter ${String(iteration)}] [${phase}] phase: ${format_usd(phase_cost)} | run: ${format_usd(run_total)}${cap}`,
|
|
3162
|
+
color
|
|
3163
|
+
)
|
|
3164
|
+
);
|
|
3165
|
+
},
|
|
3166
|
+
info: (message) => {
|
|
3167
|
+
line(message);
|
|
3168
|
+
},
|
|
3169
|
+
warn: (message) => {
|
|
3170
|
+
line(paint("critic", `warning: ${message}`, color));
|
|
3171
|
+
},
|
|
3172
|
+
error: (message) => {
|
|
3173
|
+
if (silent) {
|
|
3174
|
+
write(`error: ${message}
|
|
3175
|
+
`);
|
|
3176
|
+
return;
|
|
3177
|
+
}
|
|
3178
|
+
line(paint("error", `error: ${message}`, color));
|
|
3179
|
+
},
|
|
3180
|
+
final_summary: (result) => {
|
|
3181
|
+
if (silent) return;
|
|
3182
|
+
line("");
|
|
3183
|
+
line(`run ${result.run_id}: ${result.status}`);
|
|
3184
|
+
line(
|
|
3185
|
+
`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`
|
|
3186
|
+
);
|
|
3187
|
+
line(
|
|
3188
|
+
paint(
|
|
3189
|
+
"cost",
|
|
3190
|
+
`${GLYPH_COST} total: ${format_usd(result.total_cost_usd)} (builder ${format_usd(result.builder_cost_usd)}, critic ${format_usd(result.critic_cost_usd)})`,
|
|
3191
|
+
color
|
|
3192
|
+
)
|
|
3193
|
+
);
|
|
3194
|
+
line(
|
|
3195
|
+
`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
function summarize_output(output) {
|
|
3201
|
+
if (typeof output === "string") return output;
|
|
3202
|
+
try {
|
|
3203
|
+
return JSON.stringify(output) ?? "";
|
|
3204
|
+
} catch {
|
|
3205
|
+
return String(output);
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// src/cli.ts
|
|
3210
|
+
function package_version() {
|
|
3211
|
+
let dir = dirname4(fileURLToPath3(import.meta.url));
|
|
3212
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
3213
|
+
const manifest = join11(dir, "package.json");
|
|
3214
|
+
if (existsSync14(manifest)) {
|
|
3215
|
+
try {
|
|
3216
|
+
const version = JSON.parse(readFileSync13(manifest, "utf8")).version;
|
|
3217
|
+
if (typeof version === "string") return version;
|
|
3218
|
+
} catch {
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
dir = dirname4(dir);
|
|
3222
|
+
}
|
|
3223
|
+
return "unknown";
|
|
3224
|
+
}
|
|
3225
|
+
function parse_glob_list(value) {
|
|
3226
|
+
return value.split(",").map((glob) => glob.trim()).filter((glob) => glob.length > 0);
|
|
3227
|
+
}
|
|
3228
|
+
function render_mode(config) {
|
|
3229
|
+
if (config.json) return "json";
|
|
3230
|
+
if (config.quiet) return "quiet";
|
|
3231
|
+
if (config.verbose) return "verbose";
|
|
3232
|
+
return "default";
|
|
3233
|
+
}
|
|
3234
|
+
function make_renderer(config) {
|
|
3235
|
+
return create_renderer({
|
|
3236
|
+
mode: render_mode(config),
|
|
3237
|
+
show_thinking: config.show_thinking,
|
|
3238
|
+
color: colors_enabled(),
|
|
3239
|
+
max_cost_usd: config.max_cost_usd
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
function warn_api_key_meter(renderer) {
|
|
3243
|
+
if (process.env["ANTHROPIC_API_KEY"] !== void 0) {
|
|
3244
|
+
renderer.warn(
|
|
3245
|
+
"ANTHROPIC_API_KEY is set: the claude CLI will bill this run against the API key, not a subscription. Know which meter you are on."
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
function warn_unsandboxed_builder(config, renderer) {
|
|
3250
|
+
if (config.builder_provider !== "claude_cli" && config.allow_unsandboxed_builder) {
|
|
3251
|
+
renderer.warn(
|
|
3252
|
+
`UNSANDBOXED BUILDER: '${config.builder_provider}' runs a local model with a real bash (write + exec) directly on this machine in ${config.workspace} \u2014 no container isolation. Proceeding because --allow-unsandboxed-builder / VOLLEY_ALLOW_UNSANDBOXED_BUILDER is set; run volley inside its sandbox container for blast-radius containment instead.`
|
|
3253
|
+
);
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
function merge_flags(base, flags) {
|
|
3257
|
+
return {
|
|
3258
|
+
...base,
|
|
3259
|
+
...flags.prompt !== void 0 ? { prompt: flags.prompt } : {},
|
|
3260
|
+
...flags.workspace !== void 0 ? { workspace: flags.workspace } : {},
|
|
3261
|
+
...flags.criteria !== void 0 ? { criteria: flags.criteria } : {},
|
|
3262
|
+
...flags.check !== void 0 ? { check: flags.check } : {},
|
|
3263
|
+
...flags.builderModel !== void 0 ? { builder_model: flags.builderModel } : {},
|
|
3264
|
+
...flags.builderProvider !== void 0 ? { builder_provider: flags.builderProvider } : {},
|
|
3265
|
+
...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
|
|
3266
|
+
...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
|
|
3267
|
+
...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
|
|
3268
|
+
...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
|
|
3269
|
+
...flags.builderPermissionMode !== void 0 ? { builder_permission_mode: flags.builderPermissionMode } : {},
|
|
3270
|
+
...flags.critic !== void 0 ? { critic: flags.critic } : {},
|
|
3271
|
+
...flags.maxIterations !== void 0 ? { max_iterations: Number(flags.maxIterations) } : {},
|
|
3272
|
+
...flags.maxCostUsd !== void 0 ? { max_cost_usd: Number(flags.maxCostUsd) } : {},
|
|
3273
|
+
...flags.git === true ? { git_checkpoints: true } : {},
|
|
3274
|
+
...flags.worktree === true ? { worktree: true } : {},
|
|
3275
|
+
...flags.discardWorktree === true ? { discard_worktree: true } : {},
|
|
3276
|
+
...flags.gatePaths !== void 0 ? { gate_paths: parse_glob_list(flags.gatePaths) } : {},
|
|
3277
|
+
...flags.failOnGateEdit === true ? { fail_on_gate_edit: true } : {},
|
|
3278
|
+
...flags.sandboxImage !== void 0 ? { sandbox_image: flags.sandboxImage } : {},
|
|
3279
|
+
...flags.dryRun === true ? { dry_run: true } : {},
|
|
3280
|
+
...flags.json === true ? { json: true } : {},
|
|
3281
|
+
...flags.verbose === true ? { verbose: true } : {},
|
|
3282
|
+
...flags.quiet === true ? { quiet: true } : {},
|
|
3283
|
+
...flags.thinking === false ? { show_thinking: false } : {}
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
function emit_result(config, result, renderer) {
|
|
3287
|
+
renderer.final_summary(result);
|
|
3288
|
+
if (config.json) {
|
|
3289
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3290
|
+
`);
|
|
3291
|
+
}
|
|
3292
|
+
return exit_code_for_status(result.status);
|
|
3293
|
+
}
|
|
3294
|
+
function report_error(err) {
|
|
3295
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3296
|
+
process.stderr.write(`volley: ${message}
|
|
3297
|
+
`);
|
|
3298
|
+
if (error_kind(err) === "phase_error") {
|
|
3299
|
+
const cause = err.cause;
|
|
3300
|
+
if (cause instanceof Error && cause.message !== message) {
|
|
3301
|
+
process.stderr.write(` cause: ${cause.message}
|
|
3302
|
+
`);
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
return exit_code_for_error(err);
|
|
3306
|
+
}
|
|
3307
|
+
async function main(argv) {
|
|
3308
|
+
const cli = cac("volley");
|
|
3309
|
+
let exit_code = EXIT_SUCCESS;
|
|
3310
|
+
cli.command("", "Run the builder/critic loop").option("--prompt <prompt>", "Task prompt (string or @file)").option("--workspace <path>", "Workspace directory (must exist and be writable)").option("--criteria <criteria>", "Acceptance criteria (string or @file)").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`).option("--builder-model <model>", "Builder model").option("--builder-provider <name>", "claude_cli | ollama | lmstudio (default: claude_cli)").option("--builder-max-steps <n>", "Local builder tool-loop step cap per iteration (default: 50)").option("--allow-unsandboxed-builder", "Permit a local builder to run without a sandbox (real host bash + write)").option("--critic-model <model>", "Critic model").option("--critic-provider <name>", "claude_cli | ollama | lmstudio (default: claude_cli)").option("--builder-permission-mode <mode>", "acceptEdits | bypassPermissions").option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--max-iterations <n>", "Iteration cap").option("--max-cost-usd <usd>", "Hard USD ceiling, enforced in the loop guard").option("--git", "Auto-commit after each phase (workspace must be a git repo)").option("--worktree", "Isolate the builder run in a per-run git worktree (workspace must be a git repo)").option("--discard-worktree", "Throw a --worktree run's effects away at teardown instead of keeping them on the run branch").option("--gate-paths <globs>", "Comma-separated globs naming the gate (tests, fixtures, check config); replaces the defaults").option("--fail-on-gate-edit", "Halt the run if the builder edits a gate path (exit 8) instead of reporting it").option("--sandbox-image <tag>", `Container image for the local-builder sandbox (default: ${DEFAULT_SANDBOX_IMAGE})`).option("--dry-run", "Validate config (and checkride doctor) without running").option("--config <path>", "TypeScript config file exporting a VolleyConfig").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs (truncated at 4000 chars)").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks (still recorded in the trajectory)").action(async (flags) => {
|
|
3311
|
+
const base = flags.config !== void 0 ? await load_config_file(flags.config) : {};
|
|
3312
|
+
const merged = merge_flags(base, flags);
|
|
3313
|
+
const config = resolve_config(merged);
|
|
3314
|
+
config.check_resolved = resolve_check_runner(config.check, config.workspace);
|
|
3315
|
+
const renderer = make_renderer(config);
|
|
3316
|
+
warn_api_key_meter(renderer);
|
|
3317
|
+
warn_unsandboxed_builder(config, renderer);
|
|
3318
|
+
if (merged.dry_run === true) {
|
|
3319
|
+
exit_code = await preflight(config, renderer, process.env);
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
const result = await run_volley(config, { renderer });
|
|
3323
|
+
exit_code = emit_result(config, result, renderer);
|
|
3324
|
+
});
|
|
3325
|
+
cli.command("resume <run_id>", "Resume a run from its last completed iteration").option("--workspace <path>", "Workspace containing the .volley run state", {
|
|
3326
|
+
default: "."
|
|
3327
|
+
}).option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks").action(async (run_id, flags) => {
|
|
3328
|
+
const workspace = flags.workspace ?? ".";
|
|
3329
|
+
const resume = load_resume_state(workspace, run_id);
|
|
3330
|
+
const merged = merge_flags(resume.raw_config, {
|
|
3331
|
+
...flags.json === true ? { json: true } : {},
|
|
3332
|
+
...flags.verbose === true ? { verbose: true } : {},
|
|
3333
|
+
...flags.quiet === true ? { quiet: true } : {},
|
|
3334
|
+
...flags.thinking === false ? { thinking: false } : {}
|
|
3335
|
+
});
|
|
3336
|
+
const config = resolve_config(merged, {
|
|
3337
|
+
run_id: resume.run_id,
|
|
3338
|
+
started_at: resume.started_at
|
|
3339
|
+
});
|
|
3340
|
+
config.check_resolved = resolve_check_runner(config.check, config.workspace);
|
|
3341
|
+
if (resume.iterations_completed >= config.max_iterations) {
|
|
3342
|
+
throw config_error(
|
|
3343
|
+
`run already completed ${String(resume.iterations_completed)} of ${String(config.max_iterations)} iterations`
|
|
3344
|
+
);
|
|
3345
|
+
}
|
|
3346
|
+
const renderer = make_renderer(config);
|
|
3347
|
+
warn_api_key_meter(renderer);
|
|
3348
|
+
warn_unsandboxed_builder(config, renderer);
|
|
3349
|
+
renderer.info(
|
|
3350
|
+
`resuming run ${run_id} at iteration ${String(resume.iterations_completed + 1)}`
|
|
3351
|
+
);
|
|
3352
|
+
const result = await run_volley(config, { renderer }, resume.state);
|
|
3353
|
+
exit_code = emit_result(config, result, renderer);
|
|
3354
|
+
});
|
|
3355
|
+
cli.command("matrix", "Sweep builder\xD7critic model combos serially over one config").option("--config <path>", "Base VolleyConfig (TS/JS): the task, workspace, providers, and caps held fixed").option("--builders <models>", "Comma-separated builder models to sweep").option("--critics <models>", "Comma-separated critic models to sweep").option("--workspace <path>", "Override the base config workspace").option("--repeat <n>", "Attempts per seat (default: 1); a pass rate needs more than one").option("--json", "Machine mode: aggregate JSON on stdout, no table").option("--verbose", "Show full builder/critic streams per combo").option("--quiet", "Per-combo phase transitions and the final table only").action(async (flags) => {
|
|
3356
|
+
if (flags.config === void 0) {
|
|
3357
|
+
throw config_error("volley matrix requires --config <path> (the base task to sweep)");
|
|
3358
|
+
}
|
|
3359
|
+
const builders = parse_model_list(flags.builders, "--builders");
|
|
3360
|
+
const critics = parse_model_list(flags.critics, "--critics");
|
|
3361
|
+
const repeat = parse_repeat(flags.repeat);
|
|
3362
|
+
const base = await load_config_file(flags.config);
|
|
3363
|
+
const merged = merge_flags(base, {
|
|
3364
|
+
...flags.workspace !== void 0 ? { workspace: flags.workspace } : {},
|
|
3365
|
+
...flags.json === true ? { json: true } : {},
|
|
3366
|
+
...flags.verbose === true ? { verbose: true } : {},
|
|
3367
|
+
...flags.quiet === true ? { quiet: true } : {}
|
|
3368
|
+
});
|
|
3369
|
+
const base_config = resolve_config({ ...merged, worktree: true, discard_worktree: true });
|
|
3370
|
+
const renderer = make_renderer(base_config);
|
|
3371
|
+
warn_api_key_meter(renderer);
|
|
3372
|
+
warn_unsandboxed_builder(base_config, renderer);
|
|
3373
|
+
const outcome = await run_matrix({
|
|
3374
|
+
base: merged,
|
|
3375
|
+
builders,
|
|
3376
|
+
critics,
|
|
3377
|
+
matrix_dir: join11(base_config.workspace, ".volley-matrix"),
|
|
3378
|
+
renderer,
|
|
3379
|
+
repeat
|
|
3380
|
+
});
|
|
3381
|
+
if (base_config.json) {
|
|
3382
|
+
process.stdout.write(`${JSON.stringify({ combos: outcome.rows }, null, 2)}
|
|
3383
|
+
`);
|
|
3384
|
+
}
|
|
3385
|
+
exit_code = outcome.ok ? EXIT_SUCCESS : EXIT_MATRIX_INCOMPLETE;
|
|
3386
|
+
});
|
|
3387
|
+
cli.help();
|
|
3388
|
+
cli.version(package_version());
|
|
3389
|
+
try {
|
|
3390
|
+
cli.parse(argv, { run: false });
|
|
3391
|
+
await cli.runMatchedCommand();
|
|
3392
|
+
} catch (err) {
|
|
3393
|
+
return report_error(err);
|
|
3394
|
+
}
|
|
3395
|
+
return exit_code;
|
|
3396
|
+
}
|
|
3397
|
+
if (import.meta.main) {
|
|
3398
|
+
const code = await main(process.argv);
|
|
3399
|
+
process.exitCode = code;
|
|
3400
|
+
}
|
|
3401
|
+
export {
|
|
3402
|
+
package_version,
|
|
3403
|
+
warn_unsandboxed_builder
|
|
3404
|
+
};
|
|
3405
|
+
//# sourceMappingURL=cli.js.map
|