@robmclarty/volley 0.9.0 → 0.10.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 +30 -0
- package/README.md +63 -17
- package/dist/cli.js +873 -555
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +35 -7
- package/dist/index.js +732 -506
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/builder/presets/harness_append_acp.md +4 -3
- package/src/builder/presets/harness_append_local.md +3 -2
package/dist/index.js
CHANGED
|
@@ -39,6 +39,231 @@ function error_kind(err) {
|
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
41
|
//#endregion
|
|
42
|
+
//#region src/engine.ts
|
|
43
|
+
/**
|
|
44
|
+
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
45
|
+
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
46
|
+
* mockable in tests and confines provider knowledge to one seam.
|
|
47
|
+
*/
|
|
48
|
+
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
49
|
+
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
50
|
+
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
51
|
+
* and 404s. */
|
|
52
|
+
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
53
|
+
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
54
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
55
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
56
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
57
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
58
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
59
|
+
* CLI also reads on its own. */
|
|
60
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
61
|
+
/** Every variable a seat reads a credential from. Every process volley starts
|
|
62
|
+
* runs without them (the check, the builder's commands, and volley's own git
|
|
63
|
+
* and checkride calls, which run hooks and filters the builder can write), so
|
|
64
|
+
* nothing the builder writes can read the keys its seats spend. */
|
|
65
|
+
const SEAT_CREDENTIAL_VARS = [OPENROUTER_KEY_VAR, ANTHROPIC_KEY_VAR];
|
|
66
|
+
/** The environment every process volley starts runs with: `env`, `PATH`
|
|
67
|
+
* included, less every variable a seat reads a credential from. */
|
|
68
|
+
function command_env(env) {
|
|
69
|
+
const stripped = new Set(SEAT_CREDENTIAL_VARS);
|
|
70
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0 && !stripped.has(entry[0])));
|
|
71
|
+
}
|
|
72
|
+
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
73
|
+
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
74
|
+
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
75
|
+
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
76
|
+
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
77
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
78
|
+
"localhost",
|
|
79
|
+
"127.0.0.1",
|
|
80
|
+
"[::1]",
|
|
81
|
+
"0.0.0.0"
|
|
82
|
+
]);
|
|
83
|
+
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
84
|
+
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
85
|
+
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
86
|
+
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
87
|
+
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
88
|
+
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
89
|
+
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
90
|
+
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
91
|
+
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
92
|
+
function cross_to_host_gateway(base_url, env) {
|
|
93
|
+
const host = env["VOLLEY_MODEL_HOST"];
|
|
94
|
+
if (host === void 0 || host === "") return base_url;
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(base_url);
|
|
98
|
+
} catch {
|
|
99
|
+
return base_url;
|
|
100
|
+
}
|
|
101
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
102
|
+
parsed.hostname = host;
|
|
103
|
+
return parsed.toString().replace(/\/$/, "");
|
|
104
|
+
}
|
|
105
|
+
function strip_trailing_slashes(url) {
|
|
106
|
+
return url.replace(/\/+$/, "");
|
|
107
|
+
}
|
|
108
|
+
const SEAT_URL_SOURCES = {
|
|
109
|
+
ollama: {
|
|
110
|
+
env: "VOLLEY_OLLAMA_URL",
|
|
111
|
+
default_url: DEFAULT_OLLAMA_URL,
|
|
112
|
+
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
113
|
+
},
|
|
114
|
+
lmstudio: {
|
|
115
|
+
env: "VOLLEY_LMSTUDIO_URL",
|
|
116
|
+
default_url: DEFAULT_LMSTUDIO_URL,
|
|
117
|
+
normalize: strip_trailing_slashes
|
|
118
|
+
},
|
|
119
|
+
openrouter: {
|
|
120
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
121
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
122
|
+
normalize: strip_trailing_slashes
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
126
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
127
|
+
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
128
|
+
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
129
|
+
* configures, and so takes a URL of its own. */
|
|
130
|
+
function takes_seat_url(provider) {
|
|
131
|
+
return SEAT_URL_PROVIDERS.includes(provider);
|
|
132
|
+
}
|
|
133
|
+
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
134
|
+
function present(value) {
|
|
135
|
+
const trimmed = value?.trim() ?? "";
|
|
136
|
+
return trimmed === "" ? null : trimmed;
|
|
137
|
+
}
|
|
138
|
+
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
139
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
140
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
141
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
142
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
143
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
144
|
+
function seat_base_url(provider, seat_url, env) {
|
|
145
|
+
const source = SEAT_URL_SOURCES[provider];
|
|
146
|
+
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
147
|
+
return cross_to_host_gateway(source.normalize(raw), env);
|
|
148
|
+
}
|
|
149
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
150
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
151
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
152
|
+
* api_key" doesn't say where the key comes from. */
|
|
153
|
+
function openrouter_api_key(env) {
|
|
154
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
155
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
156
|
+
return key;
|
|
157
|
+
}
|
|
158
|
+
function load_pricing_overrides(path) {
|
|
159
|
+
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
167
|
+
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
168
|
+
* fascicle only when the provider actually runs. The entry is complete, because
|
|
169
|
+
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
170
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
171
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
172
|
+
*
|
|
173
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
174
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
175
|
+
* traffic to volley.
|
|
176
|
+
*
|
|
177
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
178
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
179
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
180
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
181
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
182
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
183
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
184
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
185
|
+
* base-URL fix produces.
|
|
186
|
+
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
187
|
+
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
188
|
+
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
189
|
+
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
190
|
+
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
191
|
+
*
|
|
192
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
193
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
194
|
+
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
195
|
+
* override is unset and this is a no-op. */
|
|
196
|
+
function seat_provider_config(provider, seat_url, env) {
|
|
197
|
+
const base_url = seat_base_url(provider, seat_url, env);
|
|
198
|
+
switch (provider) {
|
|
199
|
+
case "ollama": return { ollama: { base_url } };
|
|
200
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
201
|
+
case "openrouter": return { openrouter: {
|
|
202
|
+
api_key: openrouter_api_key(env),
|
|
203
|
+
base_url,
|
|
204
|
+
transport: "native",
|
|
205
|
+
x_title: "volley"
|
|
206
|
+
} };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
210
|
+
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
211
|
+
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
212
|
+
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
213
|
+
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
214
|
+
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
215
|
+
* fallback provider URLs, and pricing come from the environment. */
|
|
216
|
+
function create_volley_engine(options) {
|
|
217
|
+
const env = options.env ?? process.env;
|
|
218
|
+
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
219
|
+
if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
|
|
220
|
+
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
221
|
+
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
222
|
+
const providers = { claude_cli: {
|
|
223
|
+
default_cwd: options.workspace,
|
|
224
|
+
setting_sources: ["project", "local"],
|
|
225
|
+
auth_mode,
|
|
226
|
+
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
227
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
228
|
+
} };
|
|
229
|
+
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
230
|
+
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
231
|
+
return create_engine({
|
|
232
|
+
providers,
|
|
233
|
+
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
237
|
+
* per provider when it builds an engine, so two seats on the same provider at
|
|
238
|
+
* different URLs need two engines: the critic's is derived from the builder's
|
|
239
|
+
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
240
|
+
* overrides only the shared provider's entry. Every other pairing, including a
|
|
241
|
+
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
242
|
+
async function create_seat_engines(options) {
|
|
243
|
+
const env = options.env ?? process.env;
|
|
244
|
+
const builder = create_volley_engine(options);
|
|
245
|
+
const provider = options.critic_provider;
|
|
246
|
+
if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
|
|
247
|
+
builder,
|
|
248
|
+
critic: builder
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
builder,
|
|
253
|
+
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
254
|
+
};
|
|
255
|
+
} catch (err) {
|
|
256
|
+
await builder.dispose();
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Dispose each distinct seat engine once. Both disposals start together, so
|
|
261
|
+
* one that fails doesn't keep the other from running. */
|
|
262
|
+
async function dispose_seat_engines(engines) {
|
|
263
|
+
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
264
|
+
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
42
267
|
//#region src/changes.ts
|
|
43
268
|
/**
|
|
44
269
|
* What the builder changed, and whether it touched the gate.
|
|
@@ -111,6 +336,7 @@ function git$1(root, args) {
|
|
|
111
336
|
try {
|
|
112
337
|
const result = spawnSync("git", args, {
|
|
113
338
|
cwd: root,
|
|
339
|
+
env: command_env(process.env),
|
|
114
340
|
encoding: "utf8",
|
|
115
341
|
maxBuffer: 33554432
|
|
116
342
|
});
|
|
@@ -310,256 +536,42 @@ function collect_changes(options) {
|
|
|
310
536
|
* `none` disables the gate; anything else is a shell command — unless it is
|
|
311
537
|
* recognizably checkride itself, which gets the structured runner.
|
|
312
538
|
*/
|
|
313
|
-
function package_check_script_is_checkride(workspace) {
|
|
314
|
-
const path = join(workspace, "package.json");
|
|
315
|
-
if (!existsSync(path)) return false;
|
|
316
|
-
try {
|
|
317
|
-
const script = JSON.parse(readFileSync(path, "utf8")).scripts?.["check"];
|
|
318
|
-
return typeof script === "string" && /(^|\s)checkride(\s|$)/.test(script);
|
|
319
|
-
} catch {
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
function checkride_detected(workspace) {
|
|
324
|
-
return existsSync(join(workspace, "checkride.config.json")) || package_check_script_is_checkride(workspace) || existsSync(join(workspace, "node_modules", ".bin", "checkride"));
|
|
325
|
-
}
|
|
326
|
-
function resolve_check_runner(check, workspace) {
|
|
327
|
-
if (check === "none") return "none";
|
|
328
|
-
if (check === "auto") return checkride_detected(workspace) ? "checkride" : "none";
|
|
329
|
-
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
330
|
-
}
|
|
331
|
-
//#endregion
|
|
332
|
-
//#region src/containment.ts
|
|
333
|
-
/**
|
|
334
|
-
* Whether volley is running inside its sandbox container. A leaf, so the
|
|
335
|
-
* config resolution, the preflight, and the run-config signing can all ask
|
|
336
|
-
* without importing one another.
|
|
337
|
-
*/
|
|
338
|
-
/** volley *detects* it is running inside its sandbox container rather than
|
|
339
|
-
* starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
|
|
340
|
-
* volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
|
|
341
|
-
* flags> <image> volley run …` is recognized as contained out of the box. Kept
|
|
342
|
-
* env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
|
|
343
|
-
* and testable, and so a CI job that itself runs in an unrelated container does
|
|
344
|
-
* not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
|
|
345
|
-
* the operator uses `--allow-unsandboxed-builder`). */
|
|
346
|
-
function detect_containment(env) {
|
|
347
|
-
const value = env["VOLLEY_CONTAINED"];
|
|
348
|
-
return value === "1" || value === "true";
|
|
349
|
-
}
|
|
350
|
-
//#endregion
|
|
351
|
-
//#region src/engine.ts
|
|
352
|
-
/**
|
|
353
|
-
* The ONLY module that calls fascicle's `create_engine`. Every
|
|
354
|
-
* other module receives an `Engine`-shaped value, which keeps the engine
|
|
355
|
-
* mockable in tests and confines provider knowledge to one seam.
|
|
356
|
-
*/
|
|
357
|
-
/** LM Studio's OpenAI-compatible server and Ollama's server-root defaults.
|
|
358
|
-
* The Ollama URL is the server root, NOT `…/api`: `ai-sdk-ollama` appends
|
|
359
|
-
* `/api/...` itself, so a base URL ending in `/api` requests `/api/api/chat`
|
|
360
|
-
* and 404s. */
|
|
361
|
-
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
362
|
-
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
363
|
-
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
364
|
-
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
365
|
-
/** The variable an `openrouter` seat's API key is read from. */
|
|
366
|
-
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
367
|
-
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
368
|
-
* CLI also reads on its own. */
|
|
369
|
-
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
370
|
-
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
371
|
-
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
372
|
-
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
373
|
-
* except across the boundary at `VOLLEY_MODEL_HOST`. `URL.hostname` keeps an
|
|
374
|
-
* IPv6 literal's brackets, so the IPv6 loopback is spelled `[::1]`. */
|
|
375
|
-
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
376
|
-
"localhost",
|
|
377
|
-
"127.0.0.1",
|
|
378
|
-
"[::1]",
|
|
379
|
-
"0.0.0.0"
|
|
380
|
-
]);
|
|
381
|
-
/** Cross a loopback base URL to the host LLM endpoint. When volley
|
|
382
|
-
* runs inside its sandbox container the local model daemon is on the *host*, so
|
|
383
|
-
* the example's `docker run` injects `VOLLEY_MODEL_HOST=host.docker.internal`
|
|
384
|
-
* (the host-gateway allowlist target; see `sandbox_invocation` in
|
|
385
|
-
* `src/sandbox.ts`). This normalizer *consumes that override* and swaps a
|
|
386
|
-
* loopback host for the gateway, so an unchanged `http://localhost:11434` still
|
|
387
|
-
* reaches the host from in-container. A non-loopback URL (a real remote daemon)
|
|
388
|
-
* is left untouched, and an unset `VOLLEY_MODEL_HOST` (the host / unsandboxed
|
|
389
|
-
* path) is a no-op — so the default and all-Claude paths are unaffected. */
|
|
390
|
-
function cross_to_host_gateway(base_url, env) {
|
|
391
|
-
const host = env["VOLLEY_MODEL_HOST"];
|
|
392
|
-
if (host === void 0 || host === "") return base_url;
|
|
393
|
-
let parsed;
|
|
394
|
-
try {
|
|
395
|
-
parsed = new URL(base_url);
|
|
396
|
-
} catch {
|
|
397
|
-
return base_url;
|
|
398
|
-
}
|
|
399
|
-
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
|
|
400
|
-
parsed.hostname = host;
|
|
401
|
-
return parsed.toString().replace(/\/$/, "");
|
|
402
|
-
}
|
|
403
|
-
function strip_trailing_slashes(url) {
|
|
404
|
-
return url.replace(/\/+$/, "");
|
|
405
|
-
}
|
|
406
|
-
const SEAT_URL_SOURCES = {
|
|
407
|
-
ollama: {
|
|
408
|
-
env: "VOLLEY_OLLAMA_URL",
|
|
409
|
-
default_url: DEFAULT_OLLAMA_URL,
|
|
410
|
-
normalize: (url) => strip_trailing_slashes(url).replace(/\/api$/, "")
|
|
411
|
-
},
|
|
412
|
-
lmstudio: {
|
|
413
|
-
env: "VOLLEY_LMSTUDIO_URL",
|
|
414
|
-
default_url: DEFAULT_LMSTUDIO_URL,
|
|
415
|
-
normalize: strip_trailing_slashes
|
|
416
|
-
},
|
|
417
|
-
openrouter: {
|
|
418
|
-
env: "VOLLEY_OPENROUTER_URL",
|
|
419
|
-
default_url: DEFAULT_OPENROUTER_URL,
|
|
420
|
-
normalize: strip_trailing_slashes
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
424
|
-
* so get their endpoint probed by `--dry-run`. */
|
|
425
|
-
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
426
|
-
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
427
|
-
* configures, and so takes a URL of its own. */
|
|
428
|
-
function takes_seat_url(provider) {
|
|
429
|
-
return SEAT_URL_PROVIDERS.includes(provider);
|
|
430
|
-
}
|
|
431
|
-
/** An absent, empty, or all-whitespace value counts as unset. */
|
|
432
|
-
function present(value) {
|
|
433
|
-
const trimmed = value?.trim() ?? "";
|
|
434
|
-
return trimmed === "" ? null : trimmed;
|
|
435
|
-
}
|
|
436
|
-
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
437
|
-
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
438
|
-
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
439
|
-
* other seat's URL. The result is normalized, then a loopback authority is
|
|
440
|
-
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
441
|
-
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
442
|
-
function seat_base_url(provider, seat_url, env) {
|
|
443
|
-
const source = SEAT_URL_SOURCES[provider];
|
|
444
|
-
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
445
|
-
return cross_to_host_gateway(source.normalize(raw), env);
|
|
446
|
-
}
|
|
447
|
-
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
448
|
-
* or blank key is a config error naming the variable, which `resolve_config`
|
|
449
|
-
* raises before any work, because fascicle's own "requires a non-empty
|
|
450
|
-
* api_key" doesn't say where the key comes from. */
|
|
451
|
-
function openrouter_api_key(env) {
|
|
452
|
-
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
453
|
-
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
454
|
-
return key;
|
|
455
|
-
}
|
|
456
|
-
function load_pricing_overrides(path) {
|
|
457
|
-
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
458
|
-
try {
|
|
459
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
460
|
-
} catch (err) {
|
|
461
|
-
throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
/** A seat's provider entry, with the base URL resolved for that seat. The ai-sdk
|
|
465
|
-
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
466
|
-
* fascicle only when the provider actually runs. The entry is complete, because
|
|
467
|
-
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
468
|
-
* returns is all a seat's derived engine knows about the provider, so an
|
|
469
|
-
* `openrouter` entry carries its key and its transport along with its URL.
|
|
470
|
-
*
|
|
471
|
-
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
472
|
-
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
473
|
-
* traffic to volley.
|
|
474
|
-
*
|
|
475
|
-
* For the local providers, native transport is the deferred future bridge, not
|
|
476
|
-
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
477
|
-
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
478
|
-
* so no field is needed today. Flipping later is a one-line change — add
|
|
479
|
-
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
480
|
-
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
481
|
-
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
482
|
-
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
483
|
-
* base-URL fix produces.
|
|
484
|
-
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
485
|
-
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
486
|
-
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
487
|
-
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
488
|
-
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
489
|
-
*
|
|
490
|
-
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
491
|
-
* when volley runs contained, so an in-container model client reaches the
|
|
492
|
-
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
493
|
-
* override is unset and this is a no-op. */
|
|
494
|
-
function seat_provider_config(provider, seat_url, env) {
|
|
495
|
-
const base_url = seat_base_url(provider, seat_url, env);
|
|
496
|
-
switch (provider) {
|
|
497
|
-
case "ollama": return { ollama: { base_url } };
|
|
498
|
-
case "lmstudio": return { lmstudio: { base_url } };
|
|
499
|
-
case "openrouter": return { openrouter: {
|
|
500
|
-
api_key: openrouter_api_key(env),
|
|
501
|
-
base_url,
|
|
502
|
-
transport: "native",
|
|
503
|
-
x_title: "volley"
|
|
504
|
-
} };
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
508
|
-
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
509
|
-
* by the builder or critic role is wired in alongside it, at that seat's URL,
|
|
510
|
-
* so one engine drives both roles. An engine holds one URL per provider, so on
|
|
511
|
-
* a provider both seats share it carries the builder's; `create_seat_engines`
|
|
512
|
-
* gives the critic its own engine when its URL differs. Binary, auth mode,
|
|
513
|
-
* fallback provider URLs, and pricing come from the environment. */
|
|
514
|
-
function create_volley_engine(options) {
|
|
515
|
-
const env = options.env ?? process.env;
|
|
516
|
-
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
517
|
-
if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") throw config_error(`VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`);
|
|
518
|
-
const binary = env["VOLLEY_CLAUDE_BIN"];
|
|
519
|
-
const pricing_path = env["VOLLEY_PRICING_PATH"];
|
|
520
|
-
const providers = { claude_cli: {
|
|
521
|
-
default_cwd: options.workspace,
|
|
522
|
-
setting_sources: ["project", "local"],
|
|
523
|
-
auth_mode,
|
|
524
|
-
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
525
|
-
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
526
|
-
} };
|
|
527
|
-
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
528
|
-
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
529
|
-
return create_engine({
|
|
530
|
-
providers,
|
|
531
|
-
...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
|
-
/** One engine per seat, shared unless it can't be. fascicle fixes one base URL
|
|
535
|
-
* per provider when it builds an engine, so two seats on the same provider at
|
|
536
|
-
* different URLs need two engines: the critic's is derived from the builder's
|
|
537
|
-
* with `with_providers`, which keeps claude_cli, auth, and pricing and
|
|
538
|
-
* overrides only the shared provider's entry. Every other pairing, including a
|
|
539
|
-
* run that sets no seat URL at all, gets the one engine a run has always had. */
|
|
540
|
-
async function create_seat_engines(options) {
|
|
541
|
-
const env = options.env ?? process.env;
|
|
542
|
-
const builder = create_volley_engine(options);
|
|
543
|
-
const provider = options.critic_provider;
|
|
544
|
-
if (provider !== options.builder_provider || !takes_seat_url(provider) || seat_base_url(provider, options.builder_url ?? null, env) === seat_base_url(provider, options.critic_url ?? null, env)) return {
|
|
545
|
-
builder,
|
|
546
|
-
critic: builder
|
|
547
|
-
};
|
|
548
|
-
try {
|
|
549
|
-
return {
|
|
550
|
-
builder,
|
|
551
|
-
critic: builder.with_providers(seat_provider_config(provider, options.critic_url ?? null, env))
|
|
552
|
-
};
|
|
553
|
-
} catch (err) {
|
|
554
|
-
await builder.dispose();
|
|
555
|
-
throw err;
|
|
539
|
+
function package_check_script_is_checkride(workspace) {
|
|
540
|
+
const path = join(workspace, "package.json");
|
|
541
|
+
if (!existsSync(path)) return false;
|
|
542
|
+
try {
|
|
543
|
+
const script = JSON.parse(readFileSync(path, "utf8")).scripts?.["check"];
|
|
544
|
+
return typeof script === "string" && /(^|\s)checkride(\s|$)/.test(script);
|
|
545
|
+
} catch {
|
|
546
|
+
return false;
|
|
556
547
|
}
|
|
557
548
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
549
|
+
function checkride_detected(workspace) {
|
|
550
|
+
return existsSync(join(workspace, "checkride.config.json")) || package_check_script_is_checkride(workspace) || existsSync(join(workspace, "node_modules", ".bin", "checkride"));
|
|
551
|
+
}
|
|
552
|
+
function resolve_check_runner(check, workspace) {
|
|
553
|
+
if (check === "none") return "none";
|
|
554
|
+
if (check === "auto") return checkride_detected(workspace) ? "checkride" : "none";
|
|
555
|
+
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/containment.ts
|
|
559
|
+
/**
|
|
560
|
+
* Whether volley is running inside its sandbox container. A leaf, so the
|
|
561
|
+
* config resolution, the preflight, and the run-config signing can all ask
|
|
562
|
+
* without importing one another.
|
|
563
|
+
*/
|
|
564
|
+
/** volley *detects* it is running inside its sandbox container rather than
|
|
565
|
+
* starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
|
|
566
|
+
* volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
|
|
567
|
+
* flags> <image> volley run …` is recognized as contained out of the box. Kept
|
|
568
|
+
* env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
|
|
569
|
+
* and testable, and so a CI job that itself runs in an unrelated container does
|
|
570
|
+
* not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
|
|
571
|
+
* the operator uses `--allow-unsandboxed-builder`). */
|
|
572
|
+
function detect_containment(env) {
|
|
573
|
+
const value = env["VOLLEY_CONTAINED"];
|
|
574
|
+
return value === "1" || value === "true";
|
|
563
575
|
}
|
|
564
576
|
//#endregion
|
|
565
577
|
//#region src/trust.ts
|
|
@@ -722,6 +734,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
722
734
|
builder_url: config.builder_url,
|
|
723
735
|
builder_max_steps: config.builder_max_steps,
|
|
724
736
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
737
|
+
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
725
738
|
critic_model: config.critic_model,
|
|
726
739
|
critic_provider: config.critic_provider,
|
|
727
740
|
critic_url: config.critic_url,
|
|
@@ -749,6 +762,30 @@ function write_resolved_config(config, env = process.env) {
|
|
|
749
762
|
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
750
763
|
return true;
|
|
751
764
|
}
|
|
765
|
+
/** The identity volley's own commits fall back to. git won't commit without a
|
|
766
|
+
* name and an email, and the sandbox image carries no git config, so inside
|
|
767
|
+
* it a checkpoint or a worktree's salvage commit failed outright; a machine
|
|
768
|
+
* never set up for git can be the same. `.invalid` is reserved, so the email
|
|
769
|
+
* can't reach anyone. */
|
|
770
|
+
const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
|
|
771
|
+
/** The `-c` flags that give a commit in `cwd` the fallback identity for each
|
|
772
|
+
* part git has no value for. One the repo or the user configured wins, and so
|
|
773
|
+
* does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
|
|
774
|
+
* any config. Put them ahead of the subcommand. */
|
|
775
|
+
function commit_identity(cwd) {
|
|
776
|
+
return FALLBACK_IDENTITY.flatMap(([key, value]) => {
|
|
777
|
+
const set = spawnSync("git", [
|
|
778
|
+
"config",
|
|
779
|
+
"--get",
|
|
780
|
+
key
|
|
781
|
+
], {
|
|
782
|
+
cwd,
|
|
783
|
+
env: command_env(process.env),
|
|
784
|
+
encoding: "utf8"
|
|
785
|
+
});
|
|
786
|
+
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
787
|
+
});
|
|
788
|
+
}
|
|
752
789
|
/**
|
|
753
790
|
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
754
791
|
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
@@ -771,14 +808,21 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
771
808
|
"add",
|
|
772
809
|
"-A",
|
|
773
810
|
...pathspec
|
|
774
|
-
], {
|
|
811
|
+
], {
|
|
812
|
+
cwd: root,
|
|
813
|
+
env: command_env(process.env)
|
|
814
|
+
});
|
|
775
815
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
776
816
|
const commit = spawnSync("git", [
|
|
817
|
+
...commit_identity(root),
|
|
777
818
|
"commit",
|
|
778
819
|
"-m",
|
|
779
820
|
message,
|
|
780
821
|
"--no-verify"
|
|
781
|
-
], {
|
|
822
|
+
], {
|
|
823
|
+
cwd: root,
|
|
824
|
+
env: command_env(process.env)
|
|
825
|
+
});
|
|
782
826
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
783
827
|
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
784
828
|
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
@@ -802,17 +846,20 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
802
846
|
branch
|
|
803
847
|
], {
|
|
804
848
|
cwd: workspace,
|
|
849
|
+
env: command_env(process.env),
|
|
805
850
|
encoding: "utf8"
|
|
806
851
|
});
|
|
807
852
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
808
853
|
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
809
854
|
const commit = spawnSync("git", [
|
|
855
|
+
...commit_identity(workspace),
|
|
810
856
|
"commit",
|
|
811
857
|
"-m",
|
|
812
858
|
message,
|
|
813
859
|
"--no-verify"
|
|
814
860
|
], {
|
|
815
861
|
cwd: workspace,
|
|
862
|
+
env: command_env(process.env),
|
|
816
863
|
encoding: "utf8"
|
|
817
864
|
});
|
|
818
865
|
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
@@ -820,6 +867,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
820
867
|
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
821
868
|
}
|
|
822
869
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
870
|
+
const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
|
|
823
871
|
const DEFAULT_CRITIC = "reviewer";
|
|
824
872
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
825
873
|
const DEFAULT_CRITIC_PROVIDER = "claude_cli";
|
|
@@ -910,6 +958,7 @@ const CRITIQUE_REFUSED = [
|
|
|
910
958
|
["builder_url", "--builder-url"],
|
|
911
959
|
["builder_max_steps", "--builder-max-steps"],
|
|
912
960
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
961
|
+
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
913
962
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
914
963
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
915
964
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -933,6 +982,22 @@ const BUILDER_MAX_TOOL_CALLS = {
|
|
|
933
982
|
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
934
983
|
fallback: 1
|
|
935
984
|
};
|
|
985
|
+
/** The builders whose turns are capped when nothing sets a cap: the local
|
|
986
|
+
* ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
|
|
987
|
+
* `num_predict` run to the model's trained context less the prompt, which is
|
|
988
|
+
* how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
|
|
989
|
+
* OpenRouter has its own output limit, and a request for more than it allows
|
|
990
|
+
* can be refused, so it's capped only when asked. */
|
|
991
|
+
const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
|
|
992
|
+
/** The output cap's flag and env var, and its default for `provider`: 16k
|
|
993
|
+
* tokens on a local builder, else the provider's own limit (null). */
|
|
994
|
+
function builder_max_output_tokens_count(provider) {
|
|
995
|
+
return {
|
|
996
|
+
flag: "--builder-max-output-tokens",
|
|
997
|
+
env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
|
|
998
|
+
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
936
1001
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
937
1002
|
* over the default, and either must be a positive integer. Local builder
|
|
938
1003
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -1066,6 +1131,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1066
1131
|
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, builder_env)) throw config_error(`builder-provider '${builder_provider}' runs volley's tool loop with a real bash (write + exec) — refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run …, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`);
|
|
1067
1132
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1068
1133
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
1134
|
+
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
1069
1135
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
1070
1136
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
1071
1137
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -1103,6 +1169,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1103
1169
|
builder_url,
|
|
1104
1170
|
builder_max_steps,
|
|
1105
1171
|
builder_max_tool_calls,
|
|
1172
|
+
builder_max_output_tokens,
|
|
1106
1173
|
allow_unsandboxed_builder,
|
|
1107
1174
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
1108
1175
|
critic_provider,
|
|
@@ -1182,6 +1249,34 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
1182
1249
|
if (warning !== null) warn(warning);
|
|
1183
1250
|
}
|
|
1184
1251
|
//#endregion
|
|
1252
|
+
//#region src/builder/countdown.ts
|
|
1253
|
+
/** How many of a session's last steps carry the countdown: five, or a tenth
|
|
1254
|
+
* of a long budget, but never more than half of it, so a short session still
|
|
1255
|
+
* starts without one. */
|
|
1256
|
+
function countdown_window(max_steps) {
|
|
1257
|
+
return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
|
|
1258
|
+
}
|
|
1259
|
+
/** The note for a turn with `steps_left` steps left, this one included.
|
|
1260
|
+
* `check` names how to run the check. On the last step fascicle runs only a
|
|
1261
|
+
* call that ends the session, which for the builder is `finish`. */
|
|
1262
|
+
function countdown_note(steps_left, check) {
|
|
1263
|
+
if (steps_left <= 1) return "[volley] This is the last step of this session, and only finish runs on it. Call finish now, with a summary that says what's done and what isn't verified.";
|
|
1264
|
+
return `[volley] ${String(steps_left)} steps left in this session, this one included, and on the last one only finish runs. If you haven't run ${check} since your last edit, run it now, then fix what it reports or call finish.`;
|
|
1265
|
+
}
|
|
1266
|
+
/** The `prepare_step` hook that adds the countdown to a session of
|
|
1267
|
+
* `max_steps` steps. */
|
|
1268
|
+
function step_countdown(max_steps, check) {
|
|
1269
|
+
const window = countdown_window(max_steps);
|
|
1270
|
+
return ({ step_index, messages }) => {
|
|
1271
|
+
const steps_left = max_steps - step_index;
|
|
1272
|
+
if (steps_left > window) return void 0;
|
|
1273
|
+
return { messages: [...messages, {
|
|
1274
|
+
role: "user",
|
|
1275
|
+
content: countdown_note(steps_left, check)
|
|
1276
|
+
}] };
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
//#endregion
|
|
1185
1280
|
//#region src/workspace_tools.ts
|
|
1186
1281
|
/**
|
|
1187
1282
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -1292,6 +1387,7 @@ function read_only_tools(workspace) {
|
|
|
1292
1387
|
return [
|
|
1293
1388
|
{
|
|
1294
1389
|
name: "read_file",
|
|
1390
|
+
read_only: true,
|
|
1295
1391
|
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
1296
1392
|
input_schema: read_file_input,
|
|
1297
1393
|
execute: (raw) => {
|
|
@@ -1302,6 +1398,7 @@ function read_only_tools(workspace) {
|
|
|
1302
1398
|
},
|
|
1303
1399
|
{
|
|
1304
1400
|
name: "search_files",
|
|
1401
|
+
read_only: true,
|
|
1305
1402
|
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
|
|
1306
1403
|
input_schema: search_files_input,
|
|
1307
1404
|
execute: (raw) => {
|
|
@@ -1331,6 +1428,7 @@ function read_only_tools(workspace) {
|
|
|
1331
1428
|
},
|
|
1332
1429
|
{
|
|
1333
1430
|
name: "list_files",
|
|
1431
|
+
read_only: true,
|
|
1334
1432
|
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
1335
1433
|
input_schema: list_files_input,
|
|
1336
1434
|
execute: (raw) => {
|
|
@@ -1642,14 +1740,17 @@ async function run_fetch(raw, ctx, config) {
|
|
|
1642
1740
|
* containment this runs *inside* volley's hardened container against the
|
|
1643
1741
|
* bind-mounted worktree — the container is the isolation boundary, no
|
|
1644
1742
|
* `docker exec` hop — and it is likewise the executor on the
|
|
1645
|
-
* `--allow-unsandboxed-builder` host escape hatch.
|
|
1646
|
-
*
|
|
1743
|
+
* `--allow-unsandboxed-builder` host escape hatch. A command runs with
|
|
1744
|
+
* volley's environment less the seats' keys, so nothing the builder runs can
|
|
1745
|
+
* read the key its seat spends. `status` is null when a signal (the timeout
|
|
1746
|
+
* SIGKILL) killed the process.
|
|
1647
1747
|
*/
|
|
1648
1748
|
function host_bash_executor(cwd) {
|
|
1649
1749
|
return (command, options) => {
|
|
1650
1750
|
const result = spawnSync(command, {
|
|
1651
1751
|
shell: true,
|
|
1652
1752
|
cwd,
|
|
1753
|
+
env: command_env(process.env),
|
|
1653
1754
|
encoding: "utf8",
|
|
1654
1755
|
timeout: options.timeout_ms,
|
|
1655
1756
|
killSignal: "SIGKILL",
|
|
@@ -1737,17 +1838,259 @@ function builder_tools(workspace, options = {}) {
|
|
|
1737
1838
|
finish_tool()
|
|
1738
1839
|
];
|
|
1739
1840
|
}
|
|
1740
|
-
/** The terminal `finish` tool, on its own for a builder set that brings its
|
|
1741
|
-
* own file and command tools (`../acp_tools.ts`). */
|
|
1742
|
-
function finish_tool() {
|
|
1841
|
+
/** The terminal `finish` tool, on its own for a builder set that brings its
|
|
1842
|
+
* own file and command tools (`../acp_tools.ts`). */
|
|
1843
|
+
function finish_tool() {
|
|
1844
|
+
return {
|
|
1845
|
+
name: "finish",
|
|
1846
|
+
description: "Declare the task complete. Calling this tool ends your turn — do not call it until the work is done and verified.",
|
|
1847
|
+
input_schema: finish_input,
|
|
1848
|
+
ends_turn: true,
|
|
1849
|
+
execute: (raw) => {
|
|
1850
|
+
return `finished: ${finish_input.parse(raw).summary}`;
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
//#endregion
|
|
1855
|
+
//#region src/check/checkride.ts
|
|
1856
|
+
/**
|
|
1857
|
+
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
1858
|
+
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
1859
|
+
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
1860
|
+
* actually said, not a normalized digest. The digest checkride writes on a red
|
|
1861
|
+
* run is kept beside them for the next builder, which needs a short list of
|
|
1862
|
+
* what to fix more than the whole of every slot's output.
|
|
1863
|
+
*/
|
|
1864
|
+
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
1865
|
+
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
1866
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
1867
|
+
* something that escaped its process group still holds it, before the reads
|
|
1868
|
+
* stop so the check can finish. */
|
|
1869
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
1870
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
1871
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
1872
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
1873
|
+
* groups to signal, so there only the shell goes. */
|
|
1874
|
+
function kill_group(proc) {
|
|
1875
|
+
if (proc.pid === void 0) return;
|
|
1876
|
+
try {
|
|
1877
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
1878
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
1879
|
+
} catch {}
|
|
1880
|
+
}
|
|
1881
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
1882
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
1883
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
1884
|
+
* no test workers or servers running. */
|
|
1885
|
+
function spawn_capture(cmd, args, options) {
|
|
1886
|
+
const { abort } = options;
|
|
1887
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
1888
|
+
return new Promise((resolve_promise, reject) => {
|
|
1889
|
+
const proc = spawn(cmd, args, {
|
|
1890
|
+
cwd: options.cwd,
|
|
1891
|
+
env: options.env ?? command_env(process.env),
|
|
1892
|
+
detached: process.platform !== "win32",
|
|
1893
|
+
stdio: [
|
|
1894
|
+
"ignore",
|
|
1895
|
+
"pipe",
|
|
1896
|
+
"pipe"
|
|
1897
|
+
]
|
|
1898
|
+
});
|
|
1899
|
+
let stdout = "";
|
|
1900
|
+
let stderr = "";
|
|
1901
|
+
let grace;
|
|
1902
|
+
const on_abort = () => {
|
|
1903
|
+
kill_group(proc);
|
|
1904
|
+
reject(new aborted_error("check aborted"));
|
|
1905
|
+
};
|
|
1906
|
+
const finish = () => {
|
|
1907
|
+
clearTimeout(grace);
|
|
1908
|
+
abort?.removeEventListener("abort", on_abort);
|
|
1909
|
+
};
|
|
1910
|
+
proc.stdout.on("data", (chunk) => {
|
|
1911
|
+
stdout += chunk.toString();
|
|
1912
|
+
});
|
|
1913
|
+
proc.stderr.on("data", (chunk) => {
|
|
1914
|
+
stderr += chunk.toString();
|
|
1915
|
+
});
|
|
1916
|
+
proc.on("error", (err) => {
|
|
1917
|
+
finish();
|
|
1918
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
1919
|
+
});
|
|
1920
|
+
proc.on("exit", () => {
|
|
1921
|
+
kill_group(proc);
|
|
1922
|
+
grace = setTimeout(() => {
|
|
1923
|
+
proc.stdout.destroy();
|
|
1924
|
+
proc.stderr.destroy();
|
|
1925
|
+
}, OUTPUT_GRACE_MS);
|
|
1926
|
+
});
|
|
1927
|
+
proc.on("close", (code) => {
|
|
1928
|
+
finish();
|
|
1929
|
+
resolve_promise({
|
|
1930
|
+
exit_code: code,
|
|
1931
|
+
stdout,
|
|
1932
|
+
stderr
|
|
1933
|
+
});
|
|
1934
|
+
});
|
|
1935
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
1939
|
+
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
1940
|
+
* install line ahead of the JSON), so the object is extracted between the
|
|
1941
|
+
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
1942
|
+
* schema_version warns and still parses; no JSON object at all is a harness
|
|
1943
|
+
* error (exit 4). */
|
|
1944
|
+
function parse_checkride_summary(stdout) {
|
|
1945
|
+
const start = stdout.indexOf("{");
|
|
1946
|
+
const end = stdout.lastIndexOf("}");
|
|
1947
|
+
let raw;
|
|
1948
|
+
try {
|
|
1949
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
1950
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
1951
|
+
} catch {
|
|
1952
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
1953
|
+
}
|
|
1954
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
1955
|
+
const summary = raw;
|
|
1956
|
+
return {
|
|
1957
|
+
summary,
|
|
1958
|
+
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
1962
|
+
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
1963
|
+
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
1964
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
1965
|
+
try {
|
|
1966
|
+
return parse_checkride_summary(stdout);
|
|
1967
|
+
} catch (err) {
|
|
1968
|
+
const file = join(workspace, ".check", "summary.json");
|
|
1969
|
+
if (!existsSync(file)) throw err;
|
|
1970
|
+
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
function failing_checks(summary) {
|
|
1974
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
1975
|
+
}
|
|
1976
|
+
/** Whether `path` sits under `dir`. */
|
|
1977
|
+
function within(dir, path) {
|
|
1978
|
+
const from_dir = relative(dir, path);
|
|
1979
|
+
return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
|
|
1980
|
+
}
|
|
1981
|
+
/** Per failing slot: the raw artifact (`<output_file>` when present, else
|
|
1982
|
+
* `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
|
|
1983
|
+
* iteration's copy of it, truncated with a pointer to the file. The names come
|
|
1984
|
+
* from a summary the builder could have written, so a path that leaves
|
|
1985
|
+
* `check_dir` has no artifact, rather than putting a file from elsewhere in
|
|
1986
|
+
* front of a model. */
|
|
1987
|
+
function read_failing_artifacts(check_dir, failing) {
|
|
1988
|
+
return failing.map((check) => {
|
|
1989
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
1990
|
+
const path = join(check_dir, file);
|
|
1991
|
+
if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
|
|
1992
|
+
slot: check.name,
|
|
1993
|
+
path: null,
|
|
1994
|
+
content: "(no artifact found)",
|
|
1995
|
+
truncated: false
|
|
1996
|
+
};
|
|
1997
|
+
const raw = readFileSync(path, "utf8");
|
|
1998
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
1999
|
+
return {
|
|
2000
|
+
slot: check.name,
|
|
2001
|
+
path,
|
|
2002
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
2003
|
+
truncated
|
|
2004
|
+
};
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
/** checkride's digest of a red run (`.check/digest.md`, written under
|
|
2008
|
+
* `--digest` and removed on a green one), or undefined when there is none. It
|
|
2009
|
+
* is capped by checkride itself, at 8 kB. */
|
|
2010
|
+
function read_digest(workspace) {
|
|
2011
|
+
const path = join(workspace, ".check", "digest.md");
|
|
2012
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
2013
|
+
}
|
|
2014
|
+
async function run_checkride(opts) {
|
|
2015
|
+
const { workspace, ...spawn_options } = opts;
|
|
2016
|
+
const proc = await spawn_capture("pnpm", [
|
|
2017
|
+
"exec",
|
|
2018
|
+
"checkride",
|
|
2019
|
+
"--json",
|
|
2020
|
+
"--digest"
|
|
2021
|
+
], {
|
|
2022
|
+
...spawn_options,
|
|
2023
|
+
cwd: workspace
|
|
2024
|
+
});
|
|
2025
|
+
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2026
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2027
|
+
const failing = failing_checks(summary);
|
|
2028
|
+
const digest = summary.ok ? void 0 : read_digest(opts.workspace);
|
|
2029
|
+
return {
|
|
2030
|
+
ran: true,
|
|
2031
|
+
runner: "checkride",
|
|
2032
|
+
ok: summary.ok,
|
|
2033
|
+
exit_code: proc.exit_code,
|
|
2034
|
+
duration_ms: summary.total_duration_ms,
|
|
2035
|
+
failing_slots: failing.map((c) => c.name),
|
|
2036
|
+
detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
|
|
2037
|
+
summary,
|
|
2038
|
+
...digest !== void 0 ? { digest } : {}
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
//#endregion
|
|
2042
|
+
//#region src/check/command.ts
|
|
2043
|
+
/**
|
|
2044
|
+
* Generic command runner: `sh -c <command>`, combined
|
|
2045
|
+
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2046
|
+
* (exit 4), not an iterable failure.
|
|
2047
|
+
*/
|
|
2048
|
+
async function run_command_check(opts) {
|
|
2049
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
2050
|
+
const started = Date.now();
|
|
2051
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2052
|
+
...spawn_options,
|
|
2053
|
+
cwd: workspace
|
|
2054
|
+
});
|
|
2055
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2056
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
2057
|
+
return {
|
|
2058
|
+
ran: true,
|
|
2059
|
+
runner: "command",
|
|
2060
|
+
ok: proc.exit_code === 0,
|
|
2061
|
+
exit_code: proc.exit_code,
|
|
2062
|
+
duration_ms: Date.now() - started,
|
|
2063
|
+
failing_slots: [],
|
|
2064
|
+
detail: [],
|
|
2065
|
+
log
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
function skipped_check(reason) {
|
|
2069
|
+
return {
|
|
2070
|
+
ran: false,
|
|
2071
|
+
runner: "none",
|
|
2072
|
+
ok: reason === "none",
|
|
2073
|
+
exit_code: null,
|
|
2074
|
+
duration_ms: 0,
|
|
2075
|
+
failing_slots: [],
|
|
2076
|
+
detail: []
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
/** What a rejected check says in place of its output. */
|
|
2080
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
2081
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
2082
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
2083
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
2084
|
+
function rejected_check(runner) {
|
|
1743
2085
|
return {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2086
|
+
ran: true,
|
|
2087
|
+
runner,
|
|
2088
|
+
ok: false,
|
|
2089
|
+
exit_code: null,
|
|
2090
|
+
duration_ms: 0,
|
|
2091
|
+
failing_slots: [],
|
|
2092
|
+
detail: [],
|
|
2093
|
+
log: CHECK_REJECTED
|
|
1751
2094
|
};
|
|
1752
2095
|
}
|
|
1753
2096
|
//#endregion
|
|
@@ -1790,6 +2133,7 @@ function cost_source_of(result) {
|
|
|
1790
2133
|
* its whole degradation ladder. */
|
|
1791
2134
|
function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
|
|
1792
2135
|
const rate = throughput(result);
|
|
2136
|
+
const truncated = result.steps.filter((step) => step.truncated === true).length;
|
|
1793
2137
|
return {
|
|
1794
2138
|
provider: result.model_resolved.provider,
|
|
1795
2139
|
model,
|
|
@@ -1801,6 +2145,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
1801
2145
|
finish_reason: result.finish_reason,
|
|
1802
2146
|
tool_calls: result.tool_calls.length,
|
|
1803
2147
|
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
2148
|
+
...truncated > 0 ? { truncated_turns: truncated } : {},
|
|
1804
2149
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
1805
2150
|
};
|
|
1806
2151
|
}
|
|
@@ -1971,6 +2316,7 @@ function report_worktree_fate(config, sink) {
|
|
|
1971
2316
|
function git(cwd, args) {
|
|
1972
2317
|
const result = spawnSync("git", args, {
|
|
1973
2318
|
cwd,
|
|
2319
|
+
env: command_env(process.env),
|
|
1974
2320
|
encoding: "utf8"
|
|
1975
2321
|
});
|
|
1976
2322
|
if (result.error !== void 0) throw config_error(`git is required for --worktree but could not run: ${String(result.error)}`);
|
|
@@ -1997,6 +2343,7 @@ function git_quiet(cwd, args) {
|
|
|
1997
2343
|
try {
|
|
1998
2344
|
const result = spawnSync("git", args, {
|
|
1999
2345
|
cwd,
|
|
2346
|
+
env: command_env(process.env),
|
|
2000
2347
|
encoding: "utf8"
|
|
2001
2348
|
});
|
|
2002
2349
|
return {
|
|
@@ -2120,6 +2467,7 @@ function salvage_worktree(repo, path, branch) {
|
|
|
2120
2467
|
if (existsSync(path)) {
|
|
2121
2468
|
git_quiet(path, ["add", "-A"]);
|
|
2122
2469
|
git_quiet(path, [
|
|
2470
|
+
...commit_identity(path),
|
|
2123
2471
|
"commit",
|
|
2124
2472
|
"-m",
|
|
2125
2473
|
`volley: salvage unintegrated worktree (${branch})`,
|
|
@@ -2248,14 +2596,9 @@ const HARNESS_APPENDS = {
|
|
|
2248
2596
|
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
2249
2597
|
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
2250
2598
|
* turn's calls in order, so a read can't race an edit, but each change or
|
|
2251
|
-
* command is still worth reading before the next.
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
`2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
|
|
2255
|
-
" make one change or command per turn, and read its result before",
|
|
2256
|
-
" deciding the next call."
|
|
2257
|
-
].join("\n");
|
|
2258
|
-
}
|
|
2599
|
+
* command is still worth reading before the next. Reads don't count toward
|
|
2600
|
+
* the cap, so the step names no number. */
|
|
2601
|
+
const BATCHED_CALLS = ["2. Batch independent reads into one turn, but make one change or command per", " turn, and read its result before deciding the next call."].join("\n");
|
|
2259
2602
|
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
2260
2603
|
const identity = [
|
|
2261
2604
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -2263,7 +2606,7 @@ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
|
2263
2606
|
"criteria."
|
|
2264
2607
|
].join("\n");
|
|
2265
2608
|
const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
|
|
2266
|
-
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN,
|
|
2609
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
|
|
2267
2610
|
}
|
|
2268
2611
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
2269
2612
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -2274,15 +2617,25 @@ function resolve_builder_system(config, tool_set) {
|
|
|
2274
2617
|
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
2275
2618
|
}
|
|
2276
2619
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
2620
|
+
/** How the step countdown tells the builder to run the check: the command
|
|
2621
|
+
* volley gates on, with the digest and without `--json`, the configured
|
|
2622
|
+
* command, or the project's own tests when no check gates the run. A
|
|
2623
|
+
* workspace can hold checkride with no `check` script, so not `pnpm check`. */
|
|
2624
|
+
function check_hint(config) {
|
|
2625
|
+
if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
|
|
2626
|
+
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2627
|
+
return "the project's tests";
|
|
2628
|
+
}
|
|
2277
2629
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2278
2630
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2279
2631
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
2280
2632
|
* from v2, and takes no injected tools or approval handler. Every other
|
|
2281
2633
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2282
2634
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2283
|
-
* the handler that answers its approvals)
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2635
|
+
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2636
|
+
* countdown (`./builder/countdown.ts`), the output cap when one is set, and
|
|
2637
|
+
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2638
|
+
* over engine defaults. */
|
|
2286
2639
|
function builder_tool_options(deps) {
|
|
2287
2640
|
const { config } = deps;
|
|
2288
2641
|
const bash_executor = deps.bash_executor ?? null;
|
|
@@ -2296,9 +2649,75 @@ function builder_tool_options(deps) {
|
|
|
2296
2649
|
max_steps: config.builder_max_steps,
|
|
2297
2650
|
tool_error_policy: "feed_back",
|
|
2298
2651
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2299
|
-
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
2652
|
+
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2653
|
+
prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
|
|
2654
|
+
...config.builder_max_output_tokens !== null ? {
|
|
2655
|
+
max_tokens: config.builder_max_output_tokens,
|
|
2656
|
+
truncated_turn_policy: "continue"
|
|
2657
|
+
} : {}
|
|
2300
2658
|
};
|
|
2301
2659
|
}
|
|
2660
|
+
/** How much of the last check's output the builder's prompt carries: a few KB,
|
|
2661
|
+
* whatever the runner. checkride's digest already fits in 8 kB, and the builder
|
|
2662
|
+
* can run the check again for the rest. */
|
|
2663
|
+
const PREVIOUS_CHECK_MAX_CHARS = 6e3;
|
|
2664
|
+
/** The first `max` characters of `text`, saying where it was cut. */
|
|
2665
|
+
function head(text, max, rest) {
|
|
2666
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
|
|
2667
|
+
}
|
|
2668
|
+
/** The last `max` characters of `text`, saying what came before was cut. */
|
|
2669
|
+
function tail(text, max) {
|
|
2670
|
+
return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
|
|
2671
|
+
}
|
|
2672
|
+
/** The part of a failed check the builder reads first: checkride's digest,
|
|
2673
|
+
* else the start of each failing slot's raw output, else the end of a
|
|
2674
|
+
* command's output, where test runners and compilers sum up. */
|
|
2675
|
+
function check_excerpt(check) {
|
|
2676
|
+
const max = PREVIOUS_CHECK_MAX_CHARS;
|
|
2677
|
+
if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
|
|
2678
|
+
if (check.detail.length > 0) return head(check.detail.map((artifact) => {
|
|
2679
|
+
const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
|
|
2680
|
+
return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
|
|
2681
|
+
}).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
|
|
2682
|
+
return tail((check.log ?? "").trimEnd(), max);
|
|
2683
|
+
}
|
|
2684
|
+
/** The check section of a later iteration's prompt: what the deterministic
|
|
2685
|
+
* gate itself said about the last session's work, so the builder starts from
|
|
2686
|
+
* its words rather than the critic's account of them, or re-running it to
|
|
2687
|
+
* find out. Empty unless that check ran and failed. A check the user rejected
|
|
2688
|
+
* when it asked to run is recorded as failed but said nothing, and the
|
|
2689
|
+
* section says so rather than call the work failed. */
|
|
2690
|
+
function previous_check_section(check) {
|
|
2691
|
+
if (check === null || !check.ran || check.ok) return [];
|
|
2692
|
+
const heading = [
|
|
2693
|
+
"",
|
|
2694
|
+
"PREVIOUS CHECK RESULT",
|
|
2695
|
+
"---------------------"
|
|
2696
|
+
];
|
|
2697
|
+
if (check.log === "the check was rejected when it asked to run, so it never ran") return [
|
|
2698
|
+
...heading,
|
|
2699
|
+
"The check never ran on the work your last session left: it was rejected when it",
|
|
2700
|
+
"asked to run, so nothing has verified that work yet."
|
|
2701
|
+
];
|
|
2702
|
+
const runner = check.runner === "checkride" ? "checkride, " : "";
|
|
2703
|
+
const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
|
|
2704
|
+
const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
|
|
2705
|
+
if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
|
|
2706
|
+
const excerpt = check_excerpt(check);
|
|
2707
|
+
if (excerpt.length > 0) lines.push("", excerpt);
|
|
2708
|
+
return lines;
|
|
2709
|
+
}
|
|
2710
|
+
/** The line a later iteration's prompt carries when the last session ran out
|
|
2711
|
+
* of steps: it never got to verify, so this one should check first. */
|
|
2712
|
+
function step_limit_lines(step_limit_hit) {
|
|
2713
|
+
if (step_limit_hit === null) return [];
|
|
2714
|
+
return [
|
|
2715
|
+
"",
|
|
2716
|
+
`Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
|
|
2717
|
+
"it may never have verified its work. Run the check first this time, and keep",
|
|
2718
|
+
"enough steps in hand to verify again before you finish."
|
|
2719
|
+
];
|
|
2720
|
+
}
|
|
2302
2721
|
function compose_builder_prompt(input) {
|
|
2303
2722
|
const parts = [
|
|
2304
2723
|
"TASK",
|
|
@@ -2312,7 +2731,8 @@ function compose_builder_prompt(input) {
|
|
|
2312
2731
|
`ITERATION: ${input.iteration}`
|
|
2313
2732
|
];
|
|
2314
2733
|
if (input.iteration > 1 && input.feedback !== null) parts.push("", "PREVIOUS CRITIC FEEDBACK", "------------------------", input.feedback, "", "Address the feedback above. The workspace already contains your prior work;", "read the current state, then make the necessary changes.");
|
|
2315
|
-
if (input.
|
|
2734
|
+
if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
|
|
2735
|
+
if (input.checkride) parts.push("", "The definition of done for this workspace includes checkride exiting 0. Run", "`pnpm exec checkride --digest`, the check volley gates on, after each batch of", "edits and again before you finish. When it fails, read .check/digest.md", "first: it lists each failing slot's first findings. Open a slot's raw", "output under .check/ only if the digest isn't enough, then fix and re-run.");
|
|
2316
2736
|
return parts.join("\n");
|
|
2317
2737
|
}
|
|
2318
2738
|
function builder_max_steps_warning(max_steps) {
|
|
@@ -2334,7 +2754,9 @@ function make_builder_step(deps) {
|
|
|
2334
2754
|
...builder_tool_options(deps)
|
|
2335
2755
|
});
|
|
2336
2756
|
}
|
|
2337
|
-
|
|
2757
|
+
/** Run one builder session on the opened iteration `state`, told what the
|
|
2758
|
+
* iteration before it left behind (`previous`, null on the first). */
|
|
2759
|
+
async function run_builder(deps, state, previous, ctx) {
|
|
2338
2760
|
const { config } = deps;
|
|
2339
2761
|
try {
|
|
2340
2762
|
if (config.builder_provider === "ollama") {
|
|
@@ -2347,10 +2769,16 @@ async function run_builder(deps, state, ctx) {
|
|
|
2347
2769
|
criteria: config.criteria,
|
|
2348
2770
|
feedback: state.feedback,
|
|
2349
2771
|
iteration: state.iteration,
|
|
2350
|
-
checkride: config.check_resolved === "checkride"
|
|
2772
|
+
checkride: config.check_resolved === "checkride",
|
|
2773
|
+
previous
|
|
2351
2774
|
}));
|
|
2352
|
-
|
|
2353
|
-
|
|
2775
|
+
const built = accumulate(state, "builder", result, config.builder_model);
|
|
2776
|
+
if (result.finish_reason !== "max_steps") return built;
|
|
2777
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
2778
|
+
return {
|
|
2779
|
+
...built,
|
|
2780
|
+
builder_max_steps_hits: built.builder_max_steps_hits + 1
|
|
2781
|
+
};
|
|
2354
2782
|
} catch (err) {
|
|
2355
2783
|
throw phase_error("builder", state.iteration, err);
|
|
2356
2784
|
}
|
|
@@ -2759,6 +3187,7 @@ function initial_state() {
|
|
|
2759
3187
|
builder_cost_usd: 0,
|
|
2760
3188
|
critic_cost_usd: 0,
|
|
2761
3189
|
check_duration_ms: 0,
|
|
3190
|
+
builder_max_steps_hits: 0,
|
|
2762
3191
|
iteration_cost_usd: 0,
|
|
2763
3192
|
halt: null,
|
|
2764
3193
|
cost_warned: false
|
|
@@ -2802,227 +3231,6 @@ function status_of(value, converged) {
|
|
|
2802
3231
|
return converged ? "success" : "budget_exhausted";
|
|
2803
3232
|
}
|
|
2804
3233
|
//#endregion
|
|
2805
|
-
//#region src/check/checkride.ts
|
|
2806
|
-
/**
|
|
2807
|
-
* checkride runner: spawn `pnpm exec checkride --json`, gate on the
|
|
2808
|
-
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
2809
|
-
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
2810
|
-
* actually said, not a normalized digest.
|
|
2811
|
-
*/
|
|
2812
|
-
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2813
|
-
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2814
|
-
/** How long a check's output may stay open after its shell exits, while
|
|
2815
|
-
* something that escaped its process group still holds it, before the reads
|
|
2816
|
-
* stop so the check can finish. */
|
|
2817
|
-
const OUTPUT_GRACE_MS = 2e3;
|
|
2818
|
-
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2819
|
-
* and anything the check started in the background, sit in the shell's group,
|
|
2820
|
-
* so killing the shell alone would leave them running. Windows has no process
|
|
2821
|
-
* groups to signal, so there only the shell goes. */
|
|
2822
|
-
function kill_group(proc) {
|
|
2823
|
-
if (proc.pid === void 0) return;
|
|
2824
|
-
try {
|
|
2825
|
-
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2826
|
-
else process.kill(-proc.pid, "SIGKILL");
|
|
2827
|
-
} catch {}
|
|
2828
|
-
}
|
|
2829
|
-
/** Run a check to its end in a process group of its own, capturing its
|
|
2830
|
-
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2831
|
-
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2832
|
-
* no test workers or servers running. */
|
|
2833
|
-
function spawn_capture(cmd, args, options) {
|
|
2834
|
-
const { abort } = options;
|
|
2835
|
-
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2836
|
-
return new Promise((resolve_promise, reject) => {
|
|
2837
|
-
const proc = spawn(cmd, args, {
|
|
2838
|
-
cwd: options.cwd,
|
|
2839
|
-
...options.env !== void 0 ? { env: options.env } : {},
|
|
2840
|
-
detached: process.platform !== "win32",
|
|
2841
|
-
stdio: [
|
|
2842
|
-
"ignore",
|
|
2843
|
-
"pipe",
|
|
2844
|
-
"pipe"
|
|
2845
|
-
]
|
|
2846
|
-
});
|
|
2847
|
-
let stdout = "";
|
|
2848
|
-
let stderr = "";
|
|
2849
|
-
let grace;
|
|
2850
|
-
const on_abort = () => {
|
|
2851
|
-
kill_group(proc);
|
|
2852
|
-
reject(new aborted_error("check aborted"));
|
|
2853
|
-
};
|
|
2854
|
-
const finish = () => {
|
|
2855
|
-
clearTimeout(grace);
|
|
2856
|
-
abort?.removeEventListener("abort", on_abort);
|
|
2857
|
-
};
|
|
2858
|
-
proc.stdout.on("data", (chunk) => {
|
|
2859
|
-
stdout += chunk.toString();
|
|
2860
|
-
});
|
|
2861
|
-
proc.stderr.on("data", (chunk) => {
|
|
2862
|
-
stderr += chunk.toString();
|
|
2863
|
-
});
|
|
2864
|
-
proc.on("error", (err) => {
|
|
2865
|
-
finish();
|
|
2866
|
-
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2867
|
-
});
|
|
2868
|
-
proc.on("exit", () => {
|
|
2869
|
-
kill_group(proc);
|
|
2870
|
-
grace = setTimeout(() => {
|
|
2871
|
-
proc.stdout.destroy();
|
|
2872
|
-
proc.stderr.destroy();
|
|
2873
|
-
}, OUTPUT_GRACE_MS);
|
|
2874
|
-
});
|
|
2875
|
-
proc.on("close", (code) => {
|
|
2876
|
-
finish();
|
|
2877
|
-
resolve_promise({
|
|
2878
|
-
exit_code: code,
|
|
2879
|
-
stdout,
|
|
2880
|
-
stderr
|
|
2881
|
-
});
|
|
2882
|
-
});
|
|
2883
|
-
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2884
|
-
});
|
|
2885
|
-
}
|
|
2886
|
-
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
2887
|
-
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
2888
|
-
* install line ahead of the JSON), so the object is extracted between the
|
|
2889
|
-
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
2890
|
-
* schema_version warns and still parses; no JSON object at all is a harness
|
|
2891
|
-
* error (exit 4). */
|
|
2892
|
-
function parse_checkride_summary(stdout) {
|
|
2893
|
-
const start = stdout.indexOf("{");
|
|
2894
|
-
const end = stdout.lastIndexOf("}");
|
|
2895
|
-
let raw;
|
|
2896
|
-
try {
|
|
2897
|
-
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
2898
|
-
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
2899
|
-
} catch {
|
|
2900
|
-
throw check_error("checkride --json produced unparseable stdout");
|
|
2901
|
-
}
|
|
2902
|
-
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
2903
|
-
const summary = raw;
|
|
2904
|
-
return {
|
|
2905
|
-
summary,
|
|
2906
|
-
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
2907
|
-
};
|
|
2908
|
-
}
|
|
2909
|
-
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
2910
|
-
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
2911
|
-
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
2912
|
-
function parse_summary_with_fallback(stdout, workspace) {
|
|
2913
|
-
try {
|
|
2914
|
-
return parse_checkride_summary(stdout);
|
|
2915
|
-
} catch (err) {
|
|
2916
|
-
const file = join(workspace, ".check", "summary.json");
|
|
2917
|
-
if (!existsSync(file)) throw err;
|
|
2918
|
-
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
function failing_checks(summary) {
|
|
2922
|
-
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
2923
|
-
}
|
|
2924
|
-
/** Per failing slot: the raw artifact (`.check/<output_file>` when present,
|
|
2925
|
-
* else `.check/<slot>.stdout.txt`), truncated with a pointer to the file. */
|
|
2926
|
-
function read_failing_artifacts(workspace, failing) {
|
|
2927
|
-
return failing.map((check) => {
|
|
2928
|
-
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
2929
|
-
const path = join(workspace, ".check", file);
|
|
2930
|
-
if (!existsSync(path)) return {
|
|
2931
|
-
slot: check.name,
|
|
2932
|
-
path: null,
|
|
2933
|
-
content: "(no artifact found)",
|
|
2934
|
-
truncated: false
|
|
2935
|
-
};
|
|
2936
|
-
const raw = readFileSync(path, "utf8");
|
|
2937
|
-
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
2938
|
-
return {
|
|
2939
|
-
slot: check.name,
|
|
2940
|
-
path,
|
|
2941
|
-
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
2942
|
-
truncated
|
|
2943
|
-
};
|
|
2944
|
-
});
|
|
2945
|
-
}
|
|
2946
|
-
async function run_checkride(opts) {
|
|
2947
|
-
const { workspace, ...spawn_options } = opts;
|
|
2948
|
-
const proc = await spawn_capture("pnpm", [
|
|
2949
|
-
"exec",
|
|
2950
|
-
"checkride",
|
|
2951
|
-
"--json"
|
|
2952
|
-
], {
|
|
2953
|
-
...spawn_options,
|
|
2954
|
-
cwd: workspace
|
|
2955
|
-
});
|
|
2956
|
-
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2957
|
-
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2958
|
-
const failing = failing_checks(summary);
|
|
2959
|
-
return {
|
|
2960
|
-
ran: true,
|
|
2961
|
-
runner: "checkride",
|
|
2962
|
-
ok: summary.ok,
|
|
2963
|
-
exit_code: proc.exit_code,
|
|
2964
|
-
duration_ms: summary.total_duration_ms,
|
|
2965
|
-
failing_slots: failing.map((c) => c.name),
|
|
2966
|
-
detail: read_failing_artifacts(opts.workspace, failing),
|
|
2967
|
-
summary
|
|
2968
|
-
};
|
|
2969
|
-
}
|
|
2970
|
-
//#endregion
|
|
2971
|
-
//#region src/check/command.ts
|
|
2972
|
-
/**
|
|
2973
|
-
* Generic command runner: `sh -c <command>`, combined
|
|
2974
|
-
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2975
|
-
* (exit 4), not an iterable failure.
|
|
2976
|
-
*/
|
|
2977
|
-
async function run_command_check(opts) {
|
|
2978
|
-
const { command, workspace, ...spawn_options } = opts;
|
|
2979
|
-
const started = Date.now();
|
|
2980
|
-
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2981
|
-
...spawn_options,
|
|
2982
|
-
cwd: workspace
|
|
2983
|
-
});
|
|
2984
|
-
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2985
|
-
const log = `${proc.stdout}${proc.stderr}`;
|
|
2986
|
-
return {
|
|
2987
|
-
ran: true,
|
|
2988
|
-
runner: "command",
|
|
2989
|
-
ok: proc.exit_code === 0,
|
|
2990
|
-
exit_code: proc.exit_code,
|
|
2991
|
-
duration_ms: Date.now() - started,
|
|
2992
|
-
failing_slots: [],
|
|
2993
|
-
detail: [],
|
|
2994
|
-
log
|
|
2995
|
-
};
|
|
2996
|
-
}
|
|
2997
|
-
function skipped_check(reason) {
|
|
2998
|
-
return {
|
|
2999
|
-
ran: false,
|
|
3000
|
-
runner: "none",
|
|
3001
|
-
ok: reason === "none",
|
|
3002
|
-
exit_code: null,
|
|
3003
|
-
duration_ms: 0,
|
|
3004
|
-
failing_slots: [],
|
|
3005
|
-
detail: []
|
|
3006
|
-
};
|
|
3007
|
-
}
|
|
3008
|
-
/** What a rejected check says in place of its output. */
|
|
3009
|
-
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
3010
|
-
/** A check the user rejected when it asked to run: recorded as a failed
|
|
3011
|
-
* check, with the refusal as its output, so a round can't converge on work
|
|
3012
|
-
* nothing checked, and the critic hears why there's no result. */
|
|
3013
|
-
function rejected_check(runner) {
|
|
3014
|
-
return {
|
|
3015
|
-
ran: true,
|
|
3016
|
-
runner,
|
|
3017
|
-
ok: false,
|
|
3018
|
-
exit_code: null,
|
|
3019
|
-
duration_ms: 0,
|
|
3020
|
-
failing_slots: [],
|
|
3021
|
-
detail: [],
|
|
3022
|
-
log: CHECK_REJECTED
|
|
3023
|
-
};
|
|
3024
|
-
}
|
|
3025
|
-
//#endregion
|
|
3026
3234
|
//#region src/render/format.ts
|
|
3027
3235
|
/**
|
|
3028
3236
|
* Display formatting: glyph taxonomy, role colors, truncation.
|
|
@@ -3090,6 +3298,7 @@ function phase_summary(record) {
|
|
|
3090
3298
|
tool_calls: record.tool_calls,
|
|
3091
3299
|
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
3092
3300
|
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
3301
|
+
...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
|
|
3093
3302
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
3094
3303
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
3095
3304
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
@@ -3112,6 +3321,8 @@ function archive_iteration(config, state) {
|
|
|
3112
3321
|
if (check.runner === "checkride") {
|
|
3113
3322
|
if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
|
|
3114
3323
|
for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
|
|
3324
|
+
if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
|
|
3325
|
+
if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
|
|
3115
3326
|
} else {
|
|
3116
3327
|
writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
|
|
3117
3328
|
writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
|
|
@@ -3152,6 +3363,7 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
3152
3363
|
builder_cost_usd: state.builder_cost_usd,
|
|
3153
3364
|
critic_cost_usd: state.critic_cost_usd,
|
|
3154
3365
|
check_duration_ms: state.check_duration_ms,
|
|
3366
|
+
builder_max_steps_hits: state.builder_max_steps_hits,
|
|
3155
3367
|
final_verdict: state.verdict,
|
|
3156
3368
|
salvaged_branch: null,
|
|
3157
3369
|
run_state_dir: run_state_dir(config),
|
|
@@ -3311,7 +3523,7 @@ function record_check_chunk(ctx, chunk) {
|
|
|
3311
3523
|
}
|
|
3312
3524
|
/** The command line the check runs, or null when no check is configured. */
|
|
3313
3525
|
function check_command(config) {
|
|
3314
|
-
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3526
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
|
|
3315
3527
|
if (config.check_resolved === "command") return config.check;
|
|
3316
3528
|
return null;
|
|
3317
3529
|
}
|
|
@@ -3342,13 +3554,14 @@ function report_changes(config, renderer, changes) {
|
|
|
3342
3554
|
renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
|
|
3343
3555
|
}
|
|
3344
3556
|
/** Run the configured check, checkride or a command, with the environment
|
|
3345
|
-
* the caller set for it, else volley's own
|
|
3557
|
+
* the caller set for it, else volley's own less the seats' keys, since the
|
|
3558
|
+
* check runs code the builder wrote. */
|
|
3346
3559
|
async function execute_check(deps, abort) {
|
|
3347
3560
|
const { config } = deps;
|
|
3348
3561
|
const root = build_root(config.workspace, config.worktree);
|
|
3349
3562
|
const options = {
|
|
3350
3563
|
abort,
|
|
3351
|
-
|
|
3564
|
+
env: deps.check?.env ?? command_env(process.env)
|
|
3352
3565
|
};
|
|
3353
3566
|
return config.check_resolved === "checkride" ? run_checkride({
|
|
3354
3567
|
...options,
|
|
@@ -3393,6 +3606,17 @@ function next_iteration(s) {
|
|
|
3393
3606
|
unmet_criteria: []
|
|
3394
3607
|
};
|
|
3395
3608
|
}
|
|
3609
|
+
/** What the builder of the iteration after `s` hears about it beyond the
|
|
3610
|
+
* critic's feedback: its check, and whether its builder ran out of steps. Null
|
|
3611
|
+
* before the first iteration. A resumed run's state carries both, read back
|
|
3612
|
+
* from the last archived iteration. */
|
|
3613
|
+
function previous_round(config, s) {
|
|
3614
|
+
if (s.iteration === 0) return null;
|
|
3615
|
+
return {
|
|
3616
|
+
check: s.check,
|
|
3617
|
+
step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3396
3620
|
/** What changed in the build root since the baseline, reported as it's
|
|
3397
3621
|
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3398
3622
|
* builder's work this way after every build, and a critique measures the
|
|
@@ -3423,7 +3647,7 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3423
3647
|
builder,
|
|
3424
3648
|
config,
|
|
3425
3649
|
warn: renderer.warn
|
|
3426
|
-
}, next, ctx);
|
|
3650
|
+
}, next, previous_round(config, s), ctx);
|
|
3427
3651
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3428
3652
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3429
3653
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
@@ -3841,8 +4065,10 @@ function finalize_failed_run(config, err) {
|
|
|
3841
4065
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
3842
4066
|
try {
|
|
3843
4067
|
const summary_path = state_path(config, "summary.json");
|
|
4068
|
+
const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
3844
4069
|
write_run_summary(config, {
|
|
3845
|
-
...
|
|
4070
|
+
...prior,
|
|
4071
|
+
builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
|
|
3846
4072
|
run_state_dir: run_state_dir(config),
|
|
3847
4073
|
status,
|
|
3848
4074
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|