@robmclarty/volley 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
- import { randomUUID } from "node:crypto";
4
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { aborted_error, branch, claude_cli_reported, create_engine, fallback, loop, model_call, pipe, retry, run, sequence, step, throughput } from "fascicle";
7
- import { filesystem_logger } from "fascicle/adapters";
7
+ import { homedir } from "node:os";
8
+ import { filesystem_logger, tee_logger } from "fascicle/adapters";
8
9
  import { lookup } from "node:dns";
9
10
  import { Readability } from "@mozilla/readability";
10
11
  import ipaddr from "ipaddr.js";
@@ -13,6 +14,31 @@ import TurndownService from "turndown";
13
14
  import { Agent, buildConnector, interceptors, request } from "undici";
14
15
  import { z } from "zod";
15
16
  import pc from "picocolors";
17
+ //#region src/types.ts
18
+ function config_error(message) {
19
+ return Object.assign(new Error(message), { kind: "config_error" });
20
+ }
21
+ function check_error(message) {
22
+ return Object.assign(new Error(message), { kind: "check_error" });
23
+ }
24
+ /** Wraps a provider/schema failure with the phase and iteration it hit. */
25
+ function phase_error(phase, iteration, cause) {
26
+ const detail = cause instanceof Error ? cause.message : String(cause);
27
+ return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
28
+ kind: "phase_error",
29
+ phase,
30
+ iteration,
31
+ cause
32
+ });
33
+ }
34
+ function error_kind(err) {
35
+ if (typeof err === "object" && err !== null && "kind" in err) {
36
+ const kind = err.kind;
37
+ return typeof kind === "string" ? kind : null;
38
+ }
39
+ return null;
40
+ }
41
+ //#endregion
16
42
  //#region src/changes.ts
17
43
  /**
18
44
  * What the builder changed, and whether it touched the gate.
@@ -117,6 +143,29 @@ function capture_baseline(root) {
117
143
  const sha = head.stdout.trim();
118
144
  return sha.length > 0 ? sha : null;
119
145
  }
146
+ /**
147
+ * The commit a critique's change is measured from: `rev` (`--baseline`)
148
+ * resolved in `root`, so the change is everything that differs from it,
149
+ * committed or not, untracked files included. With no revision named it's
150
+ * HEAD, so the uncommitted and untracked edits are the change, and, as for a
151
+ * run, null where there's no repository or no commit yet. A named revision
152
+ * that isn't a commit there, or one named where there's no repository, is a
153
+ * config error.
154
+ */
155
+ function resolve_baseline(root, rev) {
156
+ if (rev === void 0) return capture_baseline(root);
157
+ if (!in_git_repo(root)) throw config_error(`--baseline needs the workspace to be a git repository: ${root}`);
158
+ const resolved = git$1(root, [
159
+ "rev-parse",
160
+ "--verify",
161
+ "--quiet",
162
+ "--end-of-options",
163
+ `${rev}^{commit}`
164
+ ]);
165
+ const sha = resolved.stdout.trim();
166
+ if (resolved.status !== 0 || sha.length === 0) throw config_error(`--baseline names no commit in ${root}: ${rev}`);
167
+ return sha;
168
+ }
120
169
  /** git's status letters, folded onto the four cases worth naming. Rename and
121
170
  * copy both record the destination path — where the content lives now. */
122
171
  function change_status(code) {
@@ -153,7 +202,8 @@ function parse_name_status(stdout) {
153
202
  function parse_paths(stdout) {
154
203
  return stdout.split("\0").filter((path) => path.length > 0);
155
204
  }
156
- function excluded(path) {
205
+ function excluded(path, excluded_dir) {
206
+ if (excluded_dir !== null && path.startsWith(`${excluded_dir}/`)) return true;
157
207
  return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix));
158
208
  }
159
209
  /**
@@ -233,10 +283,11 @@ function collect_changes(options) {
233
283
  path,
234
284
  status: "added"
235
285
  }));
286
+ const excluded_dir = options.excluded_dir ?? null;
236
287
  const seen = /* @__PURE__ */ new Set();
237
288
  const files = [];
238
289
  for (const change of [...tracked, ...untracked]) {
239
- if (excluded(change.path) || seen.has(change.path)) continue;
290
+ if (excluded(change.path, excluded_dir) || seen.has(change.path)) continue;
240
291
  seen.add(change.path);
241
292
  files.push({
242
293
  ...change,
@@ -278,29 +329,23 @@ function resolve_check_runner(check, workspace) {
278
329
  return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
279
330
  }
280
331
  //#endregion
281
- //#region src/types.ts
282
- function config_error(message) {
283
- return Object.assign(new Error(message), { kind: "config_error" });
284
- }
285
- function check_error(message) {
286
- return Object.assign(new Error(message), { kind: "check_error" });
287
- }
288
- /** Wraps a provider/schema failure with the phase and iteration it hit. */
289
- function phase_error(phase, iteration, cause) {
290
- const detail = cause instanceof Error ? cause.message : String(cause);
291
- return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
292
- kind: "phase_error",
293
- phase,
294
- iteration,
295
- cause
296
- });
297
- }
298
- function error_kind(err) {
299
- if (typeof err === "object" && err !== null && "kind" in err) {
300
- const kind = err.kind;
301
- return typeof kind === "string" ? kind : null;
302
- }
303
- return null;
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";
304
349
  }
305
350
  //#endregion
306
351
  //#region src/engine.ts
@@ -315,6 +360,13 @@ function error_kind(err) {
315
360
  * and 404s. */
316
361
  const DEFAULT_OLLAMA_URL = "http://localhost:11434";
317
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";
318
370
  /** Loopback authorities the container→host crossing rewrites. A base URL
319
371
  * pointing at one of these is local to wherever volley runs; contained, that is
320
372
  * *inside* the sandbox container, where the host LLM daemon is unreachable
@@ -361,9 +413,15 @@ const SEAT_URL_SOURCES = {
361
413
  env: "VOLLEY_LMSTUDIO_URL",
362
414
  default_url: DEFAULT_LMSTUDIO_URL,
363
415
  normalize: strip_trailing_slashes
416
+ },
417
+ openrouter: {
418
+ env: "VOLLEY_OPENROUTER_URL",
419
+ default_url: DEFAULT_OPENROUTER_URL,
420
+ normalize: strip_trailing_slashes
364
421
  }
365
422
  };
366
- /** The providers that take a seat URL (`--builder-url` / `--critic-url`). */
423
+ /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
424
+ * so get their endpoint probed by `--dry-run`. */
367
425
  const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
368
426
  /** Whether a seat on `provider` reaches its model at a base URL volley
369
427
  * configures, and so takes a URL of its own. */
@@ -376,16 +434,25 @@ function present(value) {
376
434
  return trimmed === "" ? null : trimmed;
377
435
  }
378
436
  /** The base URL a seat's model calls go to: the seat's own URL, else the
379
- * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL`), else its
380
- * default. A seat never falls back to the other seat's URL. The result is
381
- * normalized, then a loopback authority is crossed to the host LLM endpoint
382
- * when volley runs contained. Both seats can be loopback URLs on different
383
- * ports, and each crosses on its own port. */
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. */
384
442
  function seat_base_url(provider, seat_url, env) {
385
443
  const source = SEAT_URL_SOURCES[provider];
386
444
  const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
387
445
  return cross_to_host_gateway(source.normalize(raw), env);
388
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
+ }
389
456
  function load_pricing_overrides(path) {
390
457
  if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
391
458
  try {
@@ -398,29 +465,44 @@ function load_pricing_overrides(path) {
398
465
  * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
399
466
  * fascicle only when the provider actually runs. The entry is complete, because
400
467
  * `Engine.with_providers` replaces a provider's whole entry: whatever this
401
- * returns is all a seat's derived engine knows about the provider.
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.
402
474
  *
403
- * Native transport is the deferred future bridge, not adopted here: volley stays
404
- * on the ai_sdk transport, which fascicle 0.14.0 already selects by default
405
- * (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`), so no field is
406
- * needed today. Flipping later is a one-line change — add `transport: 'native'`
407
- * to the ollama/lmstudio entry returned below, which both seats' engines then
408
- * share. No URL re-pointing is required: `seat_base_url` already yields the
409
- * daemon-root URL the native `/api/chat` transport wants (its adapter appends
410
- * `/api/chat` itself), which is exactly what the v0.3.1 base-URL fix produces.
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.
411
484
  * Kept as a proven-once-then-reverted option so the transport stays a clean
412
485
  * second variable in the model-vs-transport comparison. Flipping it today would
413
486
  * also lose the reasoning: fascicle's native Ollama adapter drops
414
487
  * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
415
488
  * thinking seat's reasoning would vanish from the terminal and the trajectory.
416
489
  *
417
- * Both providers' base URLs cross a loopback authority to `VOLLEY_MODEL_HOST` when
418
- * volley runs contained, so an in-container model client reaches the
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
419
492
  * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
420
493
  * override is unset and this is a no-op. */
421
494
  function seat_provider_config(provider, seat_url, env) {
422
495
  const base_url = seat_base_url(provider, seat_url, env);
423
- return provider === "ollama" ? { ollama: { base_url } } : { lmstudio: { base_url } };
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
+ }
424
506
  }
425
507
  /** Per-run engine. `claude_cli` is always configured with `options.workspace`
426
508
  * (the run's build root) as its session cwd; any URL-taking provider selected
@@ -440,7 +522,7 @@ function create_volley_engine(options) {
440
522
  setting_sources: ["project", "local"],
441
523
  auth_mode,
442
524
  ...binary !== void 0 && binary.length > 0 ? { binary } : {},
443
- ...auth_mode === "api_key" && env["ANTHROPIC_API_KEY"] !== void 0 ? { api_key: env["ANTHROPIC_API_KEY"] } : {}
525
+ ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
444
526
  } };
445
527
  if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
446
528
  if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
@@ -479,6 +561,264 @@ async function dispose_seat_engines(engines) {
479
561
  const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
480
562
  await Promise.all(distinct.map((engine) => engine.dispose()));
481
563
  }
564
+ //#endregion
565
+ //#region src/trust.ts
566
+ /**
567
+ * Which run configs a host-side resume may trust. A resume runs what a run's
568
+ * `config.json` says: its check, a shell command, and its seats, permission
569
+ * mode and prompt included. Inside the sandbox the builder runs arbitrary
570
+ * commands with write access to its own run's state, so a contained run's
571
+ * config is the builder's to rewrite, and resuming it on the host would run
572
+ * whatever the builder left there.
573
+ *
574
+ * So volley on the host signs each config it writes, an HMAC under a key of
575
+ * this machine's that never enters the container, and a host-side resume
576
+ * trusts only a config whose signature holds. Inside the sandbox nothing is
577
+ * signed, since the key isn't there and one made there would be in the
578
+ * builder's reach, so a contained run's config never carries a signature the
579
+ * host accepts, and the builder can't forge one without the key.
580
+ */
581
+ /** Prefixed to what is signed, so a signature over a run config can't pass for
582
+ * one over anything else under the same key. */
583
+ const DOMAIN = "volley run config v1\n";
584
+ /** Where this machine's key lives: `$XDG_CONFIG_HOME/volley/run-key`, else
585
+ * `~/.config/volley/run-key`, outside every state root, so a container a run
586
+ * root is mounted into still can't read it. A relative `XDG_CONFIG_HOME` is
587
+ * ignored, as the XDG spec says. */
588
+ function trust_key_path(env, home = homedir()) {
589
+ const xdg = env["XDG_CONFIG_HOME"];
590
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".config");
591
+ return join(base, "volley", "run-key");
592
+ }
593
+ function error_code(err) {
594
+ return err.code;
595
+ }
596
+ /** The key as stored, or null when there is none or it isn't one. */
597
+ function read_key(path) {
598
+ try {
599
+ const hex = readFileSync(path, "utf8").trim();
600
+ return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null;
601
+ } catch {
602
+ return null;
603
+ }
604
+ }
605
+ /** The key, created on first use: 32 random bytes in a file only its user can
606
+ * read, in a directory only its user can enter. Two runs that start together
607
+ * race to create it; the loser reads the winner's. */
608
+ function machine_key(env) {
609
+ const path = trust_key_path(env);
610
+ const existing = read_key(path);
611
+ if (existing !== null) return existing;
612
+ try {
613
+ mkdirSync(dirname(path), {
614
+ recursive: true,
615
+ mode: 448
616
+ });
617
+ writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, {
618
+ flag: "wx",
619
+ mode: 384
620
+ });
621
+ } catch (err) {
622
+ if (error_code(err) !== "EEXIST") return null;
623
+ }
624
+ return read_key(path);
625
+ }
626
+ function hmac(key, config_text) {
627
+ return createHmac("sha256", key).update(DOMAIN).update(config_text).digest("hex");
628
+ }
629
+ /** This machine's signature over a run's `config.json` text, or null where
630
+ * there's none to give: inside the sandbox, or when the key can't be read or
631
+ * created. */
632
+ function sign_config(config_text, env = process.env) {
633
+ if (detect_containment(env)) return null;
634
+ const key = machine_key(env);
635
+ return key === null ? null : hmac(key, config_text);
636
+ }
637
+ //#endregion
638
+ //#region src/workspace.ts
639
+ /**
640
+ * Workspace lifecycle: run-state initialization, stale-run
641
+ * backup rotation, path helpers, and optional git checkpoints.
642
+ */
643
+ /** A run id names the run's `volley/<run id>` branch and its state directory,
644
+ * so it is held to what both take without quoting: letters, digits, `-`, and
645
+ * `_`, starting with a letter or digit, so it never reads as an option and
646
+ * never climbs out of the directory it names. */
647
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
648
+ /** The root each run's state sits under when `--state-dir` names none:
649
+ * `$XDG_STATE_HOME/volley`, else `<home>/.local/state/volley`. A relative
650
+ * `XDG_STATE_HOME` is ignored, as the XDG spec says. `home` is the host's by
651
+ * default; the sandbox passes its container's to spell the same root inside. */
652
+ function default_state_root(env, home = homedir()) {
653
+ const xdg = env["XDG_STATE_HOME"];
654
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".local", "state");
655
+ return join(base, "volley");
656
+ }
657
+ /** Where a run keeps its config, summary, trajectory, feedback, verdict, and
658
+ * iterations: `<state_dir>/<run_id>/` under its state root, or, in the layout
659
+ * older runs used (`state_dir` null), `<workspace>/.volley/`. */
660
+ function run_state_dir(run) {
661
+ return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
662
+ }
663
+ function state_path(run, ...parts) {
664
+ return join(run_state_dir(run), ...parts);
665
+ }
666
+ function iteration_dir(run, iteration) {
667
+ return state_path(run, "iterations", String(iteration).padStart(3, "0"));
668
+ }
669
+ /** A state root that sits inside `root` (the build root), as the path from
670
+ * there in git's `/`-separated vocabulary, or null when it sits outside. A
671
+ * root inside the tree would otherwise pass for the builder's work: listed in
672
+ * the changes the critic reads, and committed by every `--git` checkpoint.
673
+ * The legacy `.volley/` (`state_dir` null) is excluded by name already. */
674
+ function state_root_within(root, state_dir) {
675
+ if (state_dir === null) return null;
676
+ const path = relative(resolve(root), resolve(state_dir));
677
+ if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return null;
678
+ return path.split(sep).join("/");
679
+ }
680
+ /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
681
+ * run-state rotation here and the worktree rotation so both preserve
682
+ * prior state under the same never-destroy naming. */
683
+ function backup_stamp(date = /* @__PURE__ */ new Date()) {
684
+ return date.toISOString().replace(/[:.]/g, "-");
685
+ }
686
+ /** Fresh-run initialization: rotate a stale state directory aside (a prior
687
+ * `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
688
+ * Under a state root each run has a directory of its own, so only a reused
689
+ * `--run-id` ever finds one to rotate. An empty one is used as it is: that is
690
+ * what a contained run is handed when its run directory is the mount, which
691
+ * can't be renamed. Resume runs skip rotation (`preserve: true`). */
692
+ function initialize_run_state(run, options = {}) {
693
+ const dir = run_state_dir(run);
694
+ if (existsSync(dir) && options.preserve !== true && readdirSync(dir).length > 0) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
695
+ if (run.state_dir !== null && !existsSync(run.state_dir)) mkdirSync(run.state_dir, {
696
+ recursive: true,
697
+ mode: 448
698
+ });
699
+ mkdirSync(join(dir, "iterations"), { recursive: true });
700
+ }
701
+ /** Beside a run's `config.json`: this machine's signature over it
702
+ * (`./trust.ts`), which a host-side resume checks before it trusts the
703
+ * config. */
704
+ const CONFIG_SIGNATURE = "config.sig";
705
+ /** Record the run's config, signed when volley runs on the host. Returns
706
+ * whether it was signed: inside the sandbox it never is, and on the host only
707
+ * when the machine's key can't be read or created. A signature a reused run
708
+ * directory held from before is removed rather than left to vouch for this
709
+ * config. */
710
+ function write_resolved_config(config, env = process.env) {
711
+ const record = {
712
+ version: config.version,
713
+ run_id: config.run_id,
714
+ started_at: config.started_at,
715
+ mode: config.mode,
716
+ prompt: config.prompt,
717
+ criteria: config.criteria,
718
+ check: config.check,
719
+ check_resolved: config.check_resolved,
720
+ builder_model: config.builder_model,
721
+ builder_provider: config.builder_provider,
722
+ builder_url: config.builder_url,
723
+ builder_max_steps: config.builder_max_steps,
724
+ builder_max_tool_calls: config.builder_max_tool_calls,
725
+ critic_model: config.critic_model,
726
+ critic_provider: config.critic_provider,
727
+ critic_url: config.critic_url,
728
+ builder_permission_mode: config.builder_permission_mode,
729
+ critic_preset: config.critic_preset,
730
+ critic_prompt_path: config.critic_prompt_path,
731
+ max_iterations: config.max_iterations,
732
+ max_cost_usd: config.max_cost_usd,
733
+ git_checkpoints: config.git_checkpoints,
734
+ worktree: config.worktree,
735
+ discard_worktree: config.discard_worktree,
736
+ gate_paths: config.gate_paths,
737
+ fail_on_gate_edit: config.fail_on_gate_edit,
738
+ sandbox_image: config.sandbox_image,
739
+ state_dir: config.state_dir,
740
+ workspace: config.workspace
741
+ };
742
+ const text = `${JSON.stringify(record, null, 2)}\n`;
743
+ writeFileSync(state_path(config, "config.json"), text);
744
+ const signature = sign_config(text, env);
745
+ if (signature === null) {
746
+ rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
747
+ return false;
748
+ }
749
+ writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
750
+ return true;
751
+ }
752
+ /**
753
+ * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
754
+ * here via the spawn `cwd`). `root` is the build root (`build_root`): the
755
+ * workspace on the default path, or the per-run worktree under `--worktree`, so
756
+ * checkpoints land on the worktree branch rather than the workspace's.
757
+ * A checkpoint with nothing to commit is fine; a missing git binary or repo is
758
+ * not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
759
+ * tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
760
+ * bare `git add -A` is, and `literal` reads the path as written, wildcards and
761
+ * all.
762
+ */
763
+ function git_checkpoint(root, message, excluded_dir = null) {
764
+ if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
765
+ const pathspec = excluded_dir === null ? [] : [
766
+ "--",
767
+ ":/",
768
+ `:(exclude,literal)${excluded_dir}`
769
+ ];
770
+ const add = spawnSync("git", [
771
+ "add",
772
+ "-A",
773
+ ...pathspec
774
+ ], { cwd: root });
775
+ if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
776
+ const commit = spawnSync("git", [
777
+ "commit",
778
+ "-m",
779
+ message,
780
+ "--no-verify"
781
+ ], { cwd: root });
782
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
783
+ const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
784
+ if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
785
+ }
786
+ /**
787
+ * Integration — bring a successful `--worktree --git` run's effects onto the
788
+ * workspace branch. The per-phase checkpoints on `branch` are the raw
789
+ * audit/replay trail; here they collapse into a single squash commit
790
+ * (`git merge --squash` then `commit`) on the workspace's current branch. The
791
+ * shared object store makes this a local, fetch-free merge, and — because the
792
+ * workspace branch never moved during the run (checkpoints went to the phase
793
+ * branch) — it is conflict-free by construction. A run whose phase branch has no
794
+ * new commits integrates to a no-op. Only successful runs integrate; an
795
+ * abandoned run's branch is discarded wholesale by the teardown trio instead.
796
+ */
797
+ function integrate_worktree(workspace, branch, message) {
798
+ if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
799
+ const squash = spawnSync("git", [
800
+ "merge",
801
+ "--squash",
802
+ branch
803
+ ], {
804
+ cwd: workspace,
805
+ encoding: "utf8"
806
+ });
807
+ if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
808
+ if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
809
+ const commit = spawnSync("git", [
810
+ "commit",
811
+ "-m",
812
+ message,
813
+ "--no-verify"
814
+ ], {
815
+ cwd: workspace,
816
+ encoding: "utf8"
817
+ });
818
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
819
+ const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
820
+ if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
821
+ }
482
822
  const DEFAULT_CRITIC_MODEL = "opus";
483
823
  const DEFAULT_CRITIC = "reviewer";
484
824
  const DEFAULT_BUILDER_PROVIDER = "claude_cli";
@@ -490,18 +830,24 @@ const DEFAULT_SANDBOX_IMAGE = "volley-sandbox:latest";
490
830
  const BUILDER_PROVIDERS = [
491
831
  "claude_cli",
492
832
  "ollama",
493
- "lmstudio"
833
+ "lmstudio",
834
+ "openrouter"
835
+ ];
836
+ /** Builder providers that drive their model through volley's own tool loop —
837
+ * a real `bash` (write + exec), wherever the model runs. Refused unless volley
838
+ * is running inside a container (the correct blast-radius default) or the
839
+ * operator explicitly opts out; `claude_cli` has its own permission model and
840
+ * is exempt. */
841
+ const TOOL_LOOP_BUILDER_PROVIDERS = [
842
+ "ollama",
843
+ "lmstudio",
844
+ "openrouter"
494
845
  ];
495
- /** Builder providers that run a local model through volley's own tool loop —
496
- * a real `bash` (write + exec). Refused unless volley is running
497
- * inside a container (the correct blast-radius default) or the operator
498
- * explicitly opts out; `claude_cli` has its own permission model and is
499
- * exempt. */
500
- const LOCAL_BUILDER_PROVIDERS = ["ollama", "lmstudio"];
501
846
  const CRITIC_PROVIDERS = [
502
847
  "claude_cli",
503
848
  "ollama",
504
- "lmstudio"
849
+ "lmstudio",
850
+ "openrouter"
505
851
  ];
506
852
  const CRITIC_PRESETS = [
507
853
  "reviewer",
@@ -555,17 +901,49 @@ function resolve_critic(critic, cwd) {
555
901
  critic_prompt_path: path
556
902
  };
557
903
  }
558
- /** `--builder-max-steps` (a merged flag/config value) wins over the
559
- * `VOLLEY_BUILDER_MAX_STEPS` env var, which wins over the default. Local
560
- * builder only; ignored by the `claude_cli` arm. */
561
- function resolve_builder_max_steps(raw, env) {
562
- if (raw.builder_max_steps !== void 0) return validate_builder_max_steps(raw.builder_max_steps, raw.builder_max_steps);
563
- const env_value = env["VOLLEY_BUILDER_MAX_STEPS"];
564
- if (env_value !== void 0 && env_value !== "") return validate_builder_max_steps(Number(env_value), env_value);
565
- return 50;
566
- }
567
- function validate_builder_max_steps(value, original) {
568
- if (!Number.isInteger(value) || value < 1) throw config_error(`--builder-max-steps / VOLLEY_BUILDER_MAX_STEPS must be a positive integer; got: ${String(original)}`);
904
+ /** What only a builder, or the loop around it, uses, with the flag that sets
905
+ * each: a critique has no use for any of them, so naming one is an error
906
+ * rather than something quietly ignored. */
907
+ const CRITIQUE_REFUSED = [
908
+ ["builder_model", "--builder-model"],
909
+ ["builder_provider", "--builder-provider"],
910
+ ["builder_url", "--builder-url"],
911
+ ["builder_max_steps", "--builder-max-steps"],
912
+ ["builder_max_tool_calls", "--builder-max-tool-calls"],
913
+ ["builder_permission_mode", "--builder-permission-mode"],
914
+ ["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
915
+ ["sandbox_image", "--sandbox-image"],
916
+ ["max_iterations", "--max-iterations"],
917
+ ["max_cost_usd", "--max-cost-usd"],
918
+ ["git_checkpoints", "--git"],
919
+ ["worktree", "--worktree"],
920
+ ["discard_worktree", "--discard-worktree"],
921
+ ["fail_on_gate_edit", "--fail-on-gate-edit"]
922
+ ];
923
+ function refuse_builder_settings(raw) {
924
+ for (const [field, flag] of CRITIQUE_REFUSED) if (raw[field] !== void 0 && raw[field] !== false) throw config_error(`volley critique refuses ${flag} (${field}): a critique runs no builder and no loop`);
925
+ }
926
+ const BUILDER_MAX_STEPS = {
927
+ flag: "--builder-max-steps",
928
+ env_var: "VOLLEY_BUILDER_MAX_STEPS",
929
+ fallback: 50
930
+ };
931
+ const BUILDER_MAX_TOOL_CALLS = {
932
+ flag: "--builder-max-tool-calls",
933
+ env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
934
+ fallback: 1
935
+ };
936
+ /** A builder count's flag or config value wins over its env var, which wins
937
+ * over the default, and either must be a positive integer. Local builder
938
+ * only; ignored by the `claude_cli` arm. */
939
+ function resolve_builder_count(value, env, count) {
940
+ if (value !== void 0) return positive_count(value, value, count);
941
+ const env_value = env[count.env_var];
942
+ if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
943
+ return count.fallback;
944
+ }
945
+ function positive_count(value, original, count) {
946
+ if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
569
947
  return value;
570
948
  }
571
949
  /** `--sandbox-image` (a merged flag/config value) wins over the
@@ -580,7 +958,8 @@ function resolve_sandbox_image(raw, env) {
580
958
  }
581
959
  /** The `--allow-unsandboxed-builder` flag/config value wins; otherwise
582
960
  * `VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1` (or `=true`) opts out. Any other value
583
- * (incl. `0`/`false`/unset) leaves the local builder refused unless contained. */
961
+ * (incl. `0`/`false`/unset) leaves a tool-loop builder refused unless
962
+ * contained. */
584
963
  function resolve_allow_unsandboxed_builder(raw, env) {
585
964
  if (raw.allow_unsandboxed_builder === true) return true;
586
965
  const env_value = env["VOLLEY_ALLOW_UNSANDBOXED_BUILDER"];
@@ -600,7 +979,7 @@ function resolve_seat_url(seat, value, provider) {
600
979
  if (typeof value !== "string") throw config_error(`${setting} must be a URL string; got a ${typeof value}`);
601
980
  const url = value.trim();
602
981
  if (url === "") return null;
603
- if (!takes_seat_url(provider)) throw config_error(`${setting} applies only to a ${seat} on ${SEAT_URL_PROVIDERS.join(" or ")}; ${seat}-provider is ${provider}`);
982
+ if (!takes_seat_url(provider)) throw config_error(`${setting} applies only to a ${seat} on ${new Intl.ListFormat("en", { type: "disjunction" }).format(SEAT_URL_PROVIDERS)}; ${seat}-provider is ${provider}`);
604
983
  let parsed;
605
984
  try {
606
985
  parsed = new URL(url);
@@ -612,22 +991,46 @@ function resolve_seat_url(seat, value, provider) {
612
991
  if (parsed.search !== "" || parsed.hash !== "") throw config_error(`${setting} must not carry a query or fragment; got: ${url}`);
613
992
  return url;
614
993
  }
615
- /** volley *detects* it is running inside its sandbox container rather than
616
- * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
617
- * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
618
- * flags> <image> volley run …` is recognized as contained out of the box. Kept
619
- * env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
620
- * and testable, and so a CI job that itself runs in an unrelated container does
621
- * not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
622
- * the operator uses `--allow-unsandboxed-builder`). */
623
- function detect_containment(env) {
624
- const value = env["VOLLEY_CONTAINED"];
625
- return value === "1" || value === "true";
994
+ /** `--state-dir` (`state_dir`): the root each run's state sits under at
995
+ * `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
996
+ * place. Unset, or empty as a seat URL may be, it's the default root,
997
+ * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
998
+ * workspace, where no check that reads every file can trip over the run's own
999
+ * feedback. */
1000
+ function resolve_state_dir(value, cwd, env) {
1001
+ if (value === void 0 || value === null) return default_state_root(env);
1002
+ if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
1003
+ const path = value.trim();
1004
+ return path === "" ? default_state_root(env) : resolve(cwd, path);
1005
+ }
1006
+ function is_writable_dir(path) {
1007
+ try {
1008
+ if (!statSync(path).isDirectory()) return false;
1009
+ accessSync(path, constants.W_OK);
1010
+ return true;
1011
+ } catch {
1012
+ return false;
1013
+ }
626
1014
  }
627
- /** A caller's run id names the run's `volley/<run id>` branch, so it is held to
628
- * what a branch name accepts without quoting: letters, digits, `-`, and `_`,
629
- * starting with a letter or digit so it never reads as an option. */
630
- const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
1015
+ /** The run's state directory must be one volley can write: the directory
1016
+ * itself when it's there, as a contained run's mount is, else the nearest
1017
+ * directory above it, where volley creates it. Checked before any spend,
1018
+ * because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
1019
+ * the sandbox the root filesystem is read-only, so that is what an unmounted
1020
+ * run directory looks like, and the message says what to mount. */
1021
+ function validate_run_state_dir(dir, contained) {
1022
+ let existing = dir;
1023
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
1024
+ if (is_writable_dir(existing)) return;
1025
+ throw config_error(contained ? `run state directory not writable: ${dir}. Inside the sandbox, mount the run's own host directory there (sandbox_run_args run_state, or -v <root>/<run id>:${dir}), created on the host first, and pass volley the same --run-id; or point --state-dir at a writable mount` : `run state directory not writable: ${dir}. Point --state-dir at a directory volley can write`);
1026
+ }
1027
+ /** Whether a tool-loop builder's commands would reach the host with nothing in
1028
+ * between: volley isn't contained, and the operator hasn't opted out with
1029
+ * `--allow-unsandboxed-builder` or `VOLLEY_ALLOW_UNSANDBOXED_BUILDER`. */
1030
+ function unguarded_builder(raw, env) {
1031
+ return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
1032
+ }
1033
+ /** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
631
1034
  function resolve_run_id(run_id) {
632
1035
  if (run_id === void 0) return randomUUID();
633
1036
  if (!RUN_ID_PATTERN.test(run_id)) throw config_error(`--run-id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_id}`);
@@ -639,13 +1042,17 @@ function resolve_run_id(run_id) {
639
1042
  function resolve_config(raw, options = {}) {
640
1043
  const cwd = options.cwd ?? process.cwd();
641
1044
  const env = options.env ?? process.env;
642
- if (typeof raw.prompt !== "string" || raw.prompt.length === 0) throw config_error("missing required option: --prompt");
1045
+ const critique = options.mode === "critique";
1046
+ if (critique) refuse_builder_settings(raw);
1047
+ const builder_env = critique ? {} : env;
1048
+ const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
1049
+ if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
643
1050
  if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
644
1051
  if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
645
1052
  const workspace = validate_workspace(resolve(cwd, raw.workspace));
646
- const prompt = expand_at_file(raw.prompt, cwd);
1053
+ const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
647
1054
  const criteria = expand_at_file(raw.criteria, cwd);
648
- const max_iterations = raw.max_iterations ?? 10;
1055
+ const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
649
1056
  if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
650
1057
  const max_cost_usd = raw.max_cost_usd ?? null;
651
1058
  if (max_cost_usd !== null && !(typeof max_cost_usd === "number" && max_cost_usd > 0)) throw config_error(`--max-cost-usd must be a positive number; got: ${String(raw.max_cost_usd)}`);
@@ -654,16 +1061,18 @@ function resolve_config(raw, options = {}) {
654
1061
  const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
655
1062
  const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
656
1063
  if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
657
- const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
1064
+ const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
658
1065
  const contained = detect_containment(env);
659
- if (LOCAL_BUILDER_PROVIDERS.includes(builder_provider) && !contained && !allow_unsandboxed_builder) throw config_error(`builder-provider '${builder_provider}' runs a local model 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.`);
660
- const builder_max_steps = resolve_builder_max_steps(raw, env);
1066
+ 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
+ const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
1068
+ const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
661
1069
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
662
1070
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
663
1071
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
664
1072
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
1073
+ if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
665
1074
  if (contained) {
666
- const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
1075
+ const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
667
1076
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
668
1077
  if (claude_roles.length > 0 && auth_mode !== "api_key") throw config_error(`claude_cli (${claude_roles.join(", ")}) cannot use its subscription/OAuth token inside a container — 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.`);
669
1078
  }
@@ -674,13 +1083,17 @@ function resolve_config(raw, options = {}) {
674
1083
  if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
675
1084
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
676
1085
  if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) throw config_error("gate_paths must be an array of glob strings");
677
- const sandbox_image = resolve_sandbox_image(raw, env);
1086
+ const sandbox_image = resolve_sandbox_image(raw, builder_env);
1087
+ const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
1088
+ if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
678
1089
  const run_id = resolve_run_id(options.run_id);
1090
+ if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
679
1091
  const check = raw.check ?? "auto";
680
1092
  return {
681
1093
  version: 2,
682
1094
  run_id,
683
1095
  started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1096
+ mode: critique ? "critique" : "run",
684
1097
  prompt,
685
1098
  criteria,
686
1099
  check,
@@ -689,6 +1102,7 @@ function resolve_config(raw, options = {}) {
689
1102
  builder_provider,
690
1103
  builder_url,
691
1104
  builder_max_steps,
1105
+ builder_max_tool_calls,
692
1106
  allow_unsandboxed_builder,
693
1107
  critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
694
1108
  critic_provider,
@@ -704,6 +1118,7 @@ function resolve_config(raw, options = {}) {
704
1118
  gate_paths: [...gate_paths],
705
1119
  fail_on_gate_edit: raw.fail_on_gate_edit ?? false,
706
1120
  sandbox_image,
1121
+ state_dir,
707
1122
  workspace,
708
1123
  verbose: raw.verbose ?? false,
709
1124
  quiet: raw.quiet ?? false,
@@ -782,8 +1197,9 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
782
1197
  */
783
1198
  const READ_FILE_MAX_BYTES = 2e5;
784
1199
  const LIST_MAX_ENTRIES = 2e3;
785
- /** Directories never worth handing a critic; they bury signal and blow the
786
- * traversal budget. */
1200
+ /** Directories never worth handing a seat; they bury signal and blow the
1201
+ * traversal budget. The acp seats' `list_files` and `search` skip the same
1202
+ * names (`./acp_tools.ts`), so a seat sees one tree under acp and on the CLI. */
787
1203
  const IGNORED_DIRS = /* @__PURE__ */ new Set([
788
1204
  "node_modules",
789
1205
  ".git",
@@ -796,7 +1212,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
796
1212
  * escape. Returns the absolute path; throws on containment violation. The
797
1213
  * `workspace` root passed here is the build root, which is the per-run git
798
1214
  * worktree under `--worktree` (`build_root`), not necessarily the
799
- * control-plane workspace that holds `.volley/`. */
1215
+ * workspace the run was started on. */
800
1216
  function contain(workspace, rel) {
801
1217
  const root = resolve(workspace);
802
1218
  const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
@@ -835,6 +1251,25 @@ function walk(root, start, limit) {
835
1251
  }
836
1252
  return out;
837
1253
  }
1254
+ /** The files a read tool's optional `path` covers: the whole tree, the tree
1255
+ * under a directory, or the one file it names. A path that doesn't exist
1256
+ * throws, so the seat reads why rather than an empty result that looks like
1257
+ * "nothing matched". */
1258
+ function scoped_files(workspace, path, limit) {
1259
+ const root = resolve(workspace);
1260
+ if (path === void 0) return walk(root, root, limit);
1261
+ const start = contain(workspace, path);
1262
+ let is_dir;
1263
+ try {
1264
+ is_dir = statSync(start).isDirectory();
1265
+ } catch (err) {
1266
+ throw new Error(`no such file or directory: ${path}`, { cause: err });
1267
+ }
1268
+ return is_dir ? walk(root, start, limit) : [{
1269
+ rel: relative(root, start),
1270
+ abs: start
1271
+ }];
1272
+ }
838
1273
  function build_regex(pattern, flags) {
839
1274
  try {
840
1275
  return new RegExp(pattern, flags);
@@ -846,11 +1281,11 @@ function build_regex(pattern, flags) {
846
1281
  const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
847
1282
  const search_files_input = z.object({
848
1283
  pattern: z.string().describe("JavaScript regular expression to match against each line."),
849
- path: z.string().optional().describe("Optional workspace-relative subdirectory to limit the search to."),
1284
+ path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
850
1285
  ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
851
1286
  });
852
1287
  const list_files_input = z.object({
853
- path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
1288
+ path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
854
1289
  contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
855
1290
  });
856
1291
  function read_only_tools(workspace) {
@@ -871,9 +1306,8 @@ function read_only_tools(workspace) {
871
1306
  input_schema: search_files_input,
872
1307
  execute: (raw) => {
873
1308
  const input = search_files_input.parse(raw);
874
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
875
1309
  const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
876
- const files = walk(resolve(workspace), start, LIST_MAX_ENTRIES);
1310
+ const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
877
1311
  const hits = [];
878
1312
  for (const file of files) {
879
1313
  if (hits.length >= 200) break;
@@ -901,8 +1335,7 @@ function read_only_tools(workspace) {
901
1335
  input_schema: list_files_input,
902
1336
  execute: (raw) => {
903
1337
  const input = list_files_input.parse(raw);
904
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
905
- let files = walk(resolve(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
1338
+ let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
906
1339
  if (input.contains !== void 0) {
907
1340
  const needle = input.contains;
908
1341
  files = files.filter((f) => f.includes(needle));
@@ -1295,7 +1728,19 @@ function builder_tools(workspace, options = {}) {
1295
1728
  max_bytes: options.fetch_max_bytes ?? FETCH_MAX_BYTES
1296
1729
  })
1297
1730
  };
1298
- const finish = {
1731
+ return [
1732
+ ...read_only_tools(workspace),
1733
+ write_file,
1734
+ edit_file,
1735
+ bash,
1736
+ fetch,
1737
+ finish_tool()
1738
+ ];
1739
+ }
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() {
1743
+ return {
1299
1744
  name: "finish",
1300
1745
  description: "Declare the task complete. Calling this tool ends your turn — do not call it until the work is done and verified.",
1301
1746
  input_schema: finish_input,
@@ -1304,14 +1749,6 @@ function builder_tools(workspace, options = {}) {
1304
1749
  return `finished: ${finish_input.parse(raw).summary}`;
1305
1750
  }
1306
1751
  };
1307
- return [
1308
- ...read_only_tools(workspace),
1309
- write_file,
1310
- edit_file,
1311
- bash,
1312
- fetch,
1313
- finish
1314
- ];
1315
1752
  }
1316
1753
  //#endregion
1317
1754
  //#region src/cost.ts
@@ -1339,9 +1776,13 @@ function add_usage(a, b) {
1339
1776
  }
1340
1777
  return sum;
1341
1778
  }
1779
+ /** The providers whose calls carry the charge they report, which fascicle
1780
+ * records as the call's cost in place of a pricing table's estimate: the
1781
+ * Claude CLI's own total, and OpenRouter's per-response charge. */
1782
+ const REPORTING_PROVIDERS = /* @__PURE__ */ new Set(["claude_cli", "openrouter"]);
1342
1783
  function cost_source_of(result) {
1343
1784
  if (result.cost === void 0) return "unknown";
1344
- return result.model_resolved.provider === "claude_cli" ? "provider_reported" : "engine_derived";
1785
+ return REPORTING_PROVIDERS.has(result.model_resolved.provider) ? "provider_reported" : "engine_derived";
1345
1786
  }
1346
1787
  /** One phase's record from its generate result. The duration is fascicle's
1347
1788
  * call-level wall clock (`GenerateResult.timing`: every turn and the tools
@@ -1376,170 +1817,56 @@ function accumulate(state, role, result, model, duration_ms) {
1376
1817
  iteration_cost_usd: state.iteration_cost_usd + cost,
1377
1818
  builder_cost_usd: state.builder_cost_usd + (role === "builder" ? cost : 0),
1378
1819
  critic_cost_usd: state.critic_cost_usd + (role === "critic" ? cost : 0),
1379
- cost_warned: state.cost_warned || record.cost_usd === null
1380
- };
1381
- }
1382
- function cost_cap_hit(config, state) {
1383
- return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
1384
- }
1385
- //#endregion
1386
- //#region src/prewarm.ts
1387
- /**
1388
- * Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
1389
- * in load + first prompt eval — longer than the provider path's in-request
1390
- * header timeout — so the first real `generate` dies with an opaque
1391
- * `stream interrupted: fetch failed`. Ollama's documented load call (a
1392
- * `/api/generate` POST with a model and no prompt) returns as soon as the
1393
- * model is resident, so absorbing the load here keeps the real call's
1394
- * time-to-first-byte inside its budget.
1395
- *
1396
- * Best-effort like the context probe: any failure (server down, unknown
1397
- * model, timeout) is swallowed — the real generate call surfaces the error
1398
- * with retry and reporting behind it. Ollama-only; LM Studio has no
1399
- * equivalent load-without-generating endpoint.
1400
- */
1401
- /** Generous ceiling on the load itself: a disk-cold 30B-class model takes
1402
- * tens of seconds to minutes. Past this, give up and let the real call try. */
1403
- const PREWARM_TIMEOUT_MS = 3e5;
1404
- /** Load `model` into the Ollama server's memory (`base_url` is the server
1405
- * root). Resolves when the model is resident, on any failure, or at the
1406
- * timeout — never throws, never gates the phase. An already-resident model
1407
- * returns immediately, so warming the same model for both roles is free.
1408
- *
1409
- * Load-bearing only on the ai_sdk transport: the cold-load death this
1410
- * absorbs is the ai-sdk/undici in-request header timeout, not an Ollama
1411
- * limit. On fascicle's native transport — the deferred future bridge, see
1412
- * `seat_provider_config` in engine.ts — the per-turn call is a bare
1413
- * `fetch(..., { signal })` with no default body timeout, so a cold model can
1414
- * take as long as it needs and this pre-warm's necessity drops away (keep it
1415
- * regardless: a resident model makes it a no-op). Native would also move the
1416
- * `num_ctx`/`keep_alive` levers to per-call `provider_options.ollama`, rather
1417
- * than the server-side Modelfile defaults volley probes today. */
1418
- async function prewarm_ollama_model(base_url, model, abort) {
1419
- const signals = [AbortSignal.timeout(PREWARM_TIMEOUT_MS)];
1420
- if (abort !== void 0) signals.push(abort);
1421
- try {
1422
- await fetch(`${base_url.replace(/\/$/, "")}/api/generate`, {
1423
- method: "POST",
1424
- headers: { "content-type": "application/json" },
1425
- body: JSON.stringify({ model }),
1426
- signal: AbortSignal.any(signals)
1427
- });
1428
- } catch {}
1429
- }
1430
- //#endregion
1431
- //#region src/workspace.ts
1432
- /**
1433
- * Workspace lifecycle: `.volley/` initialization, stale-run
1434
- * backup rotation, path helpers, and optional git checkpoints.
1435
- */
1436
- function volley_path(workspace, ...parts) {
1437
- return join(workspace, ".volley", ...parts);
1438
- }
1439
- function iteration_dir(workspace, iteration) {
1440
- return volley_path(workspace, "iterations", String(iteration).padStart(3, "0"));
1441
- }
1442
- /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
1443
- * `.volley/` rotation here and the worktree rotation so both preserve
1444
- * prior state under the same never-destroy naming. */
1445
- function backup_stamp(date = /* @__PURE__ */ new Date()) {
1446
- return date.toISOString().replace(/[:.]/g, "-");
1447
- }
1448
- /** Fresh-run initialization: rotate a stale `.volley/` aside, then create the
1449
- * directory tree. Resume runs skip rotation (`preserve: true`). */
1450
- function initialize_workspace(workspace, options = {}) {
1451
- const dir = volley_path(workspace);
1452
- if (existsSync(dir) && options.preserve !== true) renameSync(dir, join(workspace, `.volley.bak.${backup_stamp()}`));
1453
- mkdirSync(volley_path(workspace, "iterations"), { recursive: true });
1454
- }
1455
- function write_resolved_config(config) {
1456
- const record = {
1457
- version: config.version,
1458
- run_id: config.run_id,
1459
- started_at: config.started_at,
1460
- prompt: config.prompt,
1461
- criteria: config.criteria,
1462
- check: config.check,
1463
- check_resolved: config.check_resolved,
1464
- builder_model: config.builder_model,
1465
- builder_provider: config.builder_provider,
1466
- builder_url: config.builder_url,
1467
- builder_max_steps: config.builder_max_steps,
1468
- critic_model: config.critic_model,
1469
- critic_provider: config.critic_provider,
1470
- critic_url: config.critic_url,
1471
- builder_permission_mode: config.builder_permission_mode,
1472
- critic_preset: config.critic_preset,
1473
- critic_prompt_path: config.critic_prompt_path,
1474
- max_iterations: config.max_iterations,
1475
- max_cost_usd: config.max_cost_usd,
1476
- git_checkpoints: config.git_checkpoints,
1477
- worktree: config.worktree,
1478
- discard_worktree: config.discard_worktree,
1479
- gate_paths: config.gate_paths,
1480
- fail_on_gate_edit: config.fail_on_gate_edit,
1481
- sandbox_image: config.sandbox_image,
1482
- workspace: config.workspace
1483
- };
1484
- writeFileSync(volley_path(config.workspace, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1485
- }
1486
- /**
1487
- * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
1488
- * here via the spawn `cwd`). `root` is the build root (`build_root`): the
1489
- * workspace on the default path, or the per-run worktree under `--worktree`, so
1490
- * checkpoints land on the worktree branch rather than the workspace's.
1491
- * A checkpoint with nothing to commit is fine; a missing git binary or repo is
1492
- * not.
1493
- */
1494
- function git_checkpoint(root, message) {
1495
- if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
1496
- const add = spawnSync("git", ["add", "-A"], { cwd: root });
1497
- if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
1498
- const commit = spawnSync("git", [
1499
- "commit",
1500
- "-m",
1501
- message,
1502
- "--no-verify"
1503
- ], { cwd: root });
1504
- if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1505
- const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
1506
- if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
1507
- }
1508
- /**
1509
- * Integration — bring a successful `--worktree --git` run's effects onto the
1510
- * workspace branch. The per-phase checkpoints on `branch` are the raw
1511
- * audit/replay trail; here they collapse into a single squash commit
1512
- * (`git merge --squash` then `commit`) on the workspace's current branch. The
1513
- * shared object store makes this a local, fetch-free merge, and — because the
1514
- * workspace branch never moved during the run (checkpoints went to the phase
1515
- * branch) — it is conflict-free by construction. A run whose phase branch has no
1516
- * new commits integrates to a no-op. Only successful runs integrate; an
1517
- * abandoned run's branch is discarded wholesale by the teardown trio instead.
1518
- */
1519
- function integrate_worktree(workspace, branch, message) {
1520
- if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
1521
- const squash = spawnSync("git", [
1522
- "merge",
1523
- "--squash",
1524
- branch
1525
- ], {
1526
- cwd: workspace,
1527
- encoding: "utf8"
1528
- });
1529
- if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
1530
- if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
1531
- const commit = spawnSync("git", [
1532
- "commit",
1533
- "-m",
1534
- message,
1535
- "--no-verify"
1536
- ], {
1537
- cwd: workspace,
1538
- encoding: "utf8"
1539
- });
1540
- if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1541
- const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
1542
- if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
1820
+ cost_warned: state.cost_warned || record.cost_usd === null
1821
+ };
1822
+ }
1823
+ function cost_cap_hit(config, state) {
1824
+ return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
1825
+ }
1826
+ //#endregion
1827
+ //#region src/prewarm.ts
1828
+ /**
1829
+ * Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
1830
+ * in load + first prompt eval — longer than the provider path's in-request
1831
+ * header timeout — so the first real `generate` dies with an opaque
1832
+ * `stream interrupted: fetch failed`. Ollama's documented load call (a
1833
+ * `/api/generate` POST with a model and no prompt) returns as soon as the
1834
+ * model is resident, so absorbing the load here keeps the real call's
1835
+ * time-to-first-byte inside its budget.
1836
+ *
1837
+ * Best-effort like the context probe: any failure (server down, unknown
1838
+ * model, timeout) is swallowed — the real generate call surfaces the error
1839
+ * with retry and reporting behind it. Ollama-only; LM Studio has no
1840
+ * equivalent load-without-generating endpoint.
1841
+ */
1842
+ /** Generous ceiling on the load itself: a disk-cold 30B-class model takes
1843
+ * tens of seconds to minutes. Past this, give up and let the real call try. */
1844
+ const PREWARM_TIMEOUT_MS = 3e5;
1845
+ /** Load `model` into the Ollama server's memory (`base_url` is the server
1846
+ * root). Resolves when the model is resident, on any failure, or at the
1847
+ * timeout — never throws, never gates the phase. An already-resident model
1848
+ * returns immediately, so warming the same model for both roles is free.
1849
+ *
1850
+ * Load-bearing only on the ai_sdk transport: the cold-load death this
1851
+ * absorbs is the ai-sdk/undici in-request header timeout, not an Ollama
1852
+ * limit. On fascicle's native transport — the deferred future bridge, see
1853
+ * `seat_provider_config` in engine.ts — the per-turn call is a bare
1854
+ * `fetch(..., { signal })` with no default body timeout, so a cold model can
1855
+ * take as long as it needs and this pre-warm's necessity drops away (keep it
1856
+ * regardless: a resident model makes it a no-op). Native would also move the
1857
+ * `num_ctx`/`keep_alive` levers to per-call `provider_options.ollama`, rather
1858
+ * than the server-side Modelfile defaults volley probes today. */
1859
+ async function prewarm_ollama_model(base_url, model, abort) {
1860
+ const signals = [AbortSignal.timeout(PREWARM_TIMEOUT_MS)];
1861
+ if (abort !== void 0) signals.push(abort);
1862
+ try {
1863
+ await fetch(`${base_url.replace(/\/$/, "")}/api/generate`, {
1864
+ method: "POST",
1865
+ headers: { "content-type": "application/json" },
1866
+ body: JSON.stringify({ model }),
1867
+ signal: AbortSignal.any(signals)
1868
+ });
1869
+ } catch {}
1543
1870
  }
1544
1871
  //#endregion
1545
1872
  //#region src/worktree.ts
@@ -1574,9 +1901,8 @@ function worktree_branch(run_id) {
1574
1901
  * when `--worktree` is on, else the workspace itself. This is the containment
1575
1902
  * root the builder/critic file tools resolve through `contain()` and the
1576
1903
  * `bash` tool's cwd — re-pointing it moves writes into the worktree without
1577
- * disturbing the workspace. `.volley/` state, the resolved config, and the
1578
- * deterministic check stay under the workspace (the control plane), so only the
1579
- * model-driven effect surface moves.
1904
+ * disturbing the workspace. The check, the change report, and the checkpoints
1905
+ * follow it there; the run's state stays wherever `state_dir` puts it.
1580
1906
  */
1581
1907
  function build_root(workspace, worktree) {
1582
1908
  return worktree ? worktree_path(workspace) : workspace;
@@ -1879,7 +2205,7 @@ async function with_worktree(options, body) {
1879
2205
  /**
1880
2206
  * Builder invocation: one call to the builder leaf per iteration is one
1881
2207
  * complete agentic session in the workspace — Claude Code under `claude_cli`,
1882
- * or a local model driving volley's tools.
2208
+ * or a model driving volley's tools on any other provider, local or hosted.
1883
2209
  */
1884
2210
  const BUILDER_ALLOWED_TOOLS = [
1885
2211
  "Read",
@@ -1901,7 +2227,7 @@ function compose_builder_system() {
1901
2227
  }
1902
2228
  /** Walk up from this module to the package root (works from src/ under tsx
1903
2229
  * and from dist/ in the published package, which ships src/builder/presets). */
1904
- function presets_dir$1() {
2230
+ function builder_presets_dir() {
1905
2231
  let dir = dirname(fileURLToPath(import.meta.url));
1906
2232
  for (let depth = 0; depth < 6; depth += 1) {
1907
2233
  const candidate = join(dir, "src", "builder", "presets");
@@ -1910,40 +2236,67 @@ function presets_dir$1() {
1910
2236
  }
1911
2237
  throw config_error("builder presets directory not found");
1912
2238
  }
1913
- function compose_builder_system_local() {
1914
- return `${[
2239
+ /** The harness append each tool set's builder reads: one naming volley's own
2240
+ * tools, or one naming fascicle's workspace tools under `volley acp` and
2241
+ * saying what a call the user refused means. */
2242
+ const HARNESS_APPENDS = {
2243
+ volley: "harness_append_local.md",
2244
+ acp: "harness_append_acp.md"
2245
+ };
2246
+ /** Step 2 of "Work this way", as both harness appends word it for a builder
2247
+ * held to one tool call a turn. */
2248
+ const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
2249
+ /** Step 2 for a builder that may make several calls a turn: fascicle runs a
2250
+ * 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
+ function batched_calls(max_tool_calls) {
2253
+ return [
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
+ }
2259
+ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
2260
+ const identity = [
1915
2261
  "You are the builder inside the volley harness: an autonomous agent",
1916
2262
  "iterating on a workspace until it satisfies a task and its acceptance",
1917
2263
  "criteria."
1918
- ].join("\n")}\n\n${readFileSync(join(presets_dir$1(), "harness_append_local.md"), "utf8").trimEnd()}`;
2264
+ ].join("\n");
2265
+ 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, batched_calls(max_tool_calls)) : append).trimEnd()}`;
1919
2267
  }
1920
2268
  /** The builder system prompt for the active provider: the `claude_cli`
1921
2269
  * prompt (implicit built-in tools, "the current working directory") for the
1922
- * CLI builder, or the local prompt (volley's tool set, workspace-is-cwd, and
1923
- * the harness-enforced `finish` stop) for a local model. Mirrors the critic's
1924
- * `resolve_critic_prompt`. */
1925
- function resolve_builder_system(config) {
1926
- return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local();
2270
+ * CLI builder, which never takes an injected set, or the local prompt naming
2271
+ * the seat's tool set for every provider that runs volley's tool loop,
2272
+ * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
2273
+ function resolve_builder_system(config, tool_set) {
2274
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
1927
2275
  }
1928
2276
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
1929
2277
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
1930
2278
  * The `claude_cli` builder is confined at the CLI permission layer (allowlist +
1931
2279
  * permission mode) and brings its own built-in tools — this arm is unchanged
1932
- * from v2. A local builder brings none, so volley supplies its whole
1933
- * workspace tool surface plus the five per-call loop knobs and **no**
1934
- * schema (it produces a workspace, not a verdict); per-call values win over
1935
- * engine defaults. */
1936
- function builder_tool_options(config, bash_executor) {
2280
+ * from v2, and takes no injected tools or approval handler. Every other
2281
+ * builder, local or `openrouter`, brings none, so volley supplies its whole
2282
+ * workspace tool surface (or the set the caller injected in its place, with
2283
+ * the handler that answers its approvals) plus the five per-call loop knobs
2284
+ * and **no** schema (it produces a workspace, not a verdict); per-call values
2285
+ * win over engine defaults. */
2286
+ function builder_tool_options(deps) {
2287
+ const { config } = deps;
2288
+ const bash_executor = deps.bash_executor ?? null;
1937
2289
  if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
1938
2290
  allowed_tools: [...BUILDER_ALLOWED_TOOLS],
1939
2291
  extra_args: ["--permission-mode", config.builder_permission_mode]
1940
2292
  } } };
1941
2293
  return {
1942
- tools: builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}),
2294
+ tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
2295
+ ...deps.on_tool_approval !== void 0 ? { on_tool_approval: deps.on_tool_approval } : {},
1943
2296
  max_steps: config.builder_max_steps,
1944
2297
  tool_error_policy: "feed_back",
1945
2298
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
1946
- max_tool_calls_per_step: 1
2299
+ max_tool_calls_per_step: config.builder_max_tool_calls
1947
2300
  };
1948
2301
  }
1949
2302
  function compose_builder_prompt(input) {
@@ -1976,9 +2329,9 @@ function make_builder_step(deps) {
1976
2329
  description: "model call: one agentic session in the build root",
1977
2330
  provider: config.builder_provider,
1978
2331
  model: config.builder_model,
1979
- system: resolve_builder_system(config),
2332
+ system: resolve_builder_system(config, deps.tool_set),
1980
2333
  on_chunk: deps.on_chunk,
1981
- ...builder_tool_options(config, deps.bash_executor ?? null)
2334
+ ...builder_tool_options(deps)
1982
2335
  });
1983
2336
  }
1984
2337
  async function run_builder(deps, state, ctx) {
@@ -2022,11 +2375,30 @@ function presets_dir() {
2022
2375
  }
2023
2376
  throw config_error("critic presets directory not found");
2024
2377
  }
2025
- function resolve_critic_prompt(config) {
2378
+ /** The harness append that names the critic's reads. The CLI critic reads
2379
+ * via built-in Read/Grep/Glob and never takes an injected set; every other
2380
+ * critic, local or `openrouter`, reads via volley's read_file/search_files/
2381
+ * list_files, or under `volley acp` via fascicle's read_file/list_files/
2382
+ * search. */
2383
+ function harness_append_file(config, tool_set) {
2384
+ if (config.critic_provider === "claude_cli") return "harness_append.md";
2385
+ return tool_set === "acp" ? "harness_append_acp.md" : "harness_append_local.md";
2386
+ }
2387
+ function resolve_critic_prompt(config, tool_set = "volley") {
2026
2388
  const base = config.critic_prompt_path !== null ? readFileSync(config.critic_prompt_path, "utf8") : readFileSync(join(presets_dir(), `${config.critic_preset}.md`), "utf8");
2027
- const append_file = config.critic_provider === "claude_cli" ? "harness_append.md" : "harness_append_local.md";
2028
- const append = readFileSync(join(presets_dir(), append_file), "utf8");
2029
- return `${base.trimEnd()}\n\n${append.trimEnd()}`;
2389
+ const append = readFileSync(join(presets_dir(), harness_append_file(config, tool_set)), "utf8");
2390
+ return `${base.trimEnd()}\n\n${place_iterations_dir(append, config).trimEnd()}`;
2391
+ }
2392
+ const ITERATIONS_DIR = "{{iterations_dir}}";
2393
+ /** Fill the harness append's line about prior iteration state with where the
2394
+ * run's `iterations/` sits, relative to the build root the critic reads from,
2395
+ * or drop the line when the run's state is outside that root: a confined
2396
+ * critic can't reach it, and naming it would only spend the critic's steps on
2397
+ * a path it can't open. */
2398
+ function place_iterations_dir(append, config) {
2399
+ const path = relative(build_root(config.workspace, config.worktree), state_path(config, "iterations"));
2400
+ const outside = path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path);
2401
+ return append.split("\n").filter((line) => !(outside && line.includes(ITERATIONS_DIR))).join("\n").replaceAll(ITERATIONS_DIR, `${path}/`);
2030
2402
  }
2031
2403
  /** One-letter status column, so a hundred-path list stays readable. */
2032
2404
  const STATUS_MARKS = {
@@ -2106,9 +2478,10 @@ function compose_critic_prompt(input) {
2106
2478
  //#endregion
2107
2479
  //#region src/critic/run.ts
2108
2480
  /**
2109
- * Critic invocation: a read-only session (`claude_cli`, or a local model with
2110
- * volley's read tools) returning a schema-validated structured verdict. The
2111
- * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2481
+ * Critic invocation: a read-only session (`claude_cli`, or a local or
2482
+ * `openrouter` model with volley's read tools) returning a schema-validated
2483
+ * structured verdict. The
2484
+ * harness — not the critic — writes the run's `feedback.md` and `verdict`.
2112
2485
  */
2113
2486
  const CRITIC_ALLOWED_TOOLS = [
2114
2487
  "Read",
@@ -2116,18 +2489,24 @@ const CRITIC_ALLOWED_TOOLS = [
2116
2489
  "Glob"
2117
2490
  ];
2118
2491
  const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2492
+ /** A critic that holds tools isn't constrain-decoded, so nothing stops it
2493
+ * answering null for the feedback or the unmet list, as GLM did on approving.
2494
+ * Both fields allow null and read it as empty, so that answer is a verdict
2495
+ * rather than a schema failure that ends the run. fascicle sends the schema's
2496
+ * input side, so the model sees each field as its type or null, still
2497
+ * required; `.describe()` comes last so the description stays on the field. */
2119
2498
  const verdict_schema = z.object({
2120
2499
  verdict: z.enum(["approved", "changes_requested"]),
2121
- feedback: z.string().describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
2122
- unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2500
+ feedback: z.string().nullable().transform((feedback) => feedback ?? "").describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
2501
+ unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2123
2502
  });
2124
2503
  /** Read-only tool wiring per provider. The claude_cli critic is confined at
2125
- * the CLI permission layer (allowlist + explicit disallow); a local-model
2126
- * critic gets volley's own workspace-scoped read-only tools and no write
2127
- * path at all.
2504
+ * the CLI permission layer (allowlist + explicit disallow); every other critic,
2505
+ * local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
2506
+ * the set the caller injected in their place) and no write path at all.
2128
2507
  *
2129
- * The two providers also enforce `verdict_schema` by two different paths, both
2130
- * verified live. The claude_cli critic compiles the schema for
2508
+ * The providers also enforce `verdict_schema` by different paths, the first
2509
+ * two verified live. The claude_cli critic compiles the schema for
2131
2510
  * `claude --json-schema`; that only works because fascicle 0.14.0's
2132
2511
  * `compile_schema` strips the top-level `$schema`/`$id` that zod v4 stamps (the
2133
2512
  * CLI rejects them) — `live_smoke` asserts a structured verdict comes back, so a
@@ -2137,16 +2516,22 @@ const verdict_schema = z.object({
2137
2516
  * structurally guaranteed at decode time, with no prompt-parse-repair;
2138
2517
  * `live_local_builder` exercises that path. Unchanged in v3: on ai_sdk
2139
2518
  * constrained decode needs no wiring, and under the deferred native flip it
2140
- * would move to `provider_options.ollama.format` (parked). */
2141
- function critic_tool_options(config) {
2519
+ * would move to `provider_options.ollama.format` (parked). An `openrouter`
2520
+ * critic rides fascicle's native OpenAI-compatible core, which has no
2521
+ * constrained decode: the schema travels in the prompt, and the reply is parsed
2522
+ * and repaired. */
2523
+ function critic_tool_options(config, tools) {
2142
2524
  if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
2143
2525
  allowed_tools: [...CRITIC_ALLOWED_TOOLS],
2144
2526
  extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
2145
2527
  } } };
2146
- return { tools: read_only_tools(build_root(config.workspace, config.worktree)) };
2528
+ return {
2529
+ tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
2530
+ max_steps: 10
2531
+ };
2147
2532
  }
2148
- /** Is this critic failure the transient local-provider stream death the ladder
2149
- * retries? Local providers only (the `claude_cli` path is proven and its
2533
+ /** Is this critic failure the transient provider stream death the ladder
2534
+ * retries? Every provider but `claude_cli` (that path is proven and its
2150
2535
  * retries cost real money), and fascicle's typed `provider_error` only (no
2151
2536
  * message string-matching). A user abort never gets here: `retry` and
2152
2537
  * `fallback` pass control-flow signals through untouched, and `retry` checks the
@@ -2155,6 +2540,23 @@ function is_retryable_critic_error(config, err) {
2155
2540
  if (config.critic_provider === "claude_cli") return false;
2156
2541
  return error_kind(err) === "provider_error";
2157
2542
  }
2543
+ /** Did a schema call end without a verdict because the tool loop ran out —
2544
+ * its step budget spent, or its last turn a tool call — rather than because
2545
+ * the response was cut off or blocked? fascicle throws
2546
+ * `incomplete_generation_error` for every such finish; only these two are
2547
+ * fixed by taking the tools away. Matched on `kind` and `finish_reason`. */
2548
+ function is_step_exhaustion(err) {
2549
+ if (error_kind(err) !== "incomplete_generation_error") return false;
2550
+ const finish = err.finish_reason;
2551
+ return finish === "max_steps" || finish === "tool_calls";
2552
+ }
2553
+ /** Does this failure send a local critic to the tool-less rung? A stream death
2554
+ * the retry rung gave up on, or step exhaustion, which no retry would fix. A
2555
+ * `length` or `content_filter` finish is neither, and stays fatal. */
2556
+ function is_degradable_critic_error(config, err) {
2557
+ if (config.critic_provider === "claude_cli") return false;
2558
+ return error_kind(err) === "provider_error" || is_step_exhaustion(err);
2559
+ }
2158
2560
  /** fascicle's `provider_error.cause_kind` is `... | undefined`; fold the
2159
2561
  * missing case into `'unknown'` so a recorded retry always names a cause. */
2160
2562
  function retry_cause_kind_of(err) {
@@ -2173,7 +2575,8 @@ function finalize_critic(state, result, model, mark, duration_ms) {
2173
2575
  ...next.critic,
2174
2576
  retries: mark.retries,
2175
2577
  ...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
2176
- ...mark.critic_degraded === true ? { critic_degraded: true } : {}
2578
+ ...mark.critic_degraded === true ? { critic_degraded: true } : {},
2579
+ ...mark.degrade_cause !== void 0 ? { degrade_cause: mark.degrade_cause } : {}
2177
2580
  },
2178
2581
  verdict: result.content.verdict,
2179
2582
  feedback: result.content.feedback,
@@ -2203,7 +2606,7 @@ function critic_call_config(deps) {
2203
2606
  engine: deps.engine,
2204
2607
  provider: config.critic_provider,
2205
2608
  model: config.critic_model,
2206
- system: resolve_critic_prompt(config),
2609
+ system: resolve_critic_prompt(config, deps.tool_set),
2207
2610
  schema: verdict_schema,
2208
2611
  on_chunk: deps.on_chunk
2209
2612
  };
@@ -2215,7 +2618,7 @@ function make_critic_tools_step(deps) {
2215
2618
  ...critic_call_config(deps),
2216
2619
  id: "critic_tools",
2217
2620
  description: "model call with read-only workspace tools",
2218
- ...critic_tool_options(deps.config)
2621
+ ...critic_tool_options(deps.config, deps.tools)
2219
2622
  });
2220
2623
  }
2221
2624
  /** Rung 2's leaf: the same critic with no tools at all, so nothing enters
@@ -2228,9 +2631,28 @@ function make_critic_toolless_step(deps) {
2228
2631
  description: "model call judging from the check output and a file list"
2229
2632
  });
2230
2633
  }
2231
- /** The ladder's retry predicate for this run's critic provider. */
2634
+ const ladder_traces = /* @__PURE__ */ new WeakMap();
2635
+ /** The ladder's retry predicate for this run's critic provider: stream deaths
2636
+ * only. It also traces every failure it sees, so a degraded verdict can say how
2637
+ * many retries preceded it and why. A first attempt starts a fresh critique, so
2638
+ * it forgets the last one's retries. */
2232
2639
  function critic_retryable(config) {
2233
- return (err) => is_retryable_critic_error(config, err);
2640
+ let retried_cause;
2641
+ return (err, attempt) => {
2642
+ if (attempt === 1) retried_cause = void 0;
2643
+ if (typeof err === "object" && err !== null) ladder_traces.set(err, {
2644
+ attempt,
2645
+ retried_cause
2646
+ });
2647
+ const retryable = is_retryable_critic_error(config, err);
2648
+ if (retryable) retried_cause = retry_cause_kind_of(err);
2649
+ return retryable;
2650
+ };
2651
+ }
2652
+ /** The ladder's fallback predicate: a stream death the retry rung gave up on,
2653
+ * or step exhaustion, which it never retries. */
2654
+ function critic_degradable(config) {
2655
+ return (err) => is_degradable_critic_error(config, err);
2234
2656
  }
2235
2657
  /** Rung 1's projection: a verdict the tool-bearing critic produced, marked with
2236
2658
  * how many stream deaths it took and the last one's cause. */
@@ -2244,27 +2666,42 @@ function with_retries(outcome) {
2244
2666
  }
2245
2667
  };
2246
2668
  }
2247
- /** Rung 2's answer: every retry was spent, and the verdict is marked degraded
2248
- * so it never passes silently as a full critique. */
2669
+ /** Rung 2's answer, marked degraded so it never passes silently as a full
2670
+ * critique. Why it degraded, and the retries before it, are the ladder's to
2671
+ * stamp (`with_fallback_cause`), since the tool-less call can't see them. */
2249
2672
  function as_degraded(result) {
2250
2673
  return {
2251
2674
  result,
2252
2675
  mark: {
2253
- retries: 1,
2676
+ retries: 0,
2254
2677
  retry_cause_kind: void 0,
2255
2678
  critic_degraded: true
2256
2679
  }
2257
2680
  };
2258
2681
  }
2259
2682
  /** The ladder's projection: the tool-less backup cannot see why it ran, so the
2260
- * cause comes from the last stream death the retry rung gave up on. */
2683
+ * mark comes from the error that sent it there.
2684
+ *
2685
+ * - A stream death the retry rung gave up on: every retry was spent, and the
2686
+ * cause is that error's own.
2687
+ * - Step exhaustion, never retried: the retries are the stream deaths before
2688
+ * it, usually none, and the cause is the last of those.
2689
+ *
2690
+ * An error the retry rung never traced reads as one that spent every retry. */
2261
2691
  function with_fallback_cause(outcome) {
2262
2692
  if (outcome.source === "primary") return outcome.value;
2693
+ const err = outcome.primary_error;
2694
+ const trace = typeof err === "object" && err !== null ? ladder_traces.get(err) : void 0;
2695
+ const retries = trace === void 0 ? 1 : trace.attempt - 1;
2696
+ const exhausted = is_step_exhaustion(err);
2697
+ const retry_cause_kind = exhausted ? trace?.retried_cause : retry_cause_kind_of(err);
2263
2698
  return {
2264
2699
  ...outcome.value,
2265
2700
  mark: {
2266
2701
  ...outcome.value.mark,
2267
- retry_cause_kind: retry_cause_kind_of(outcome.primary_error)
2702
+ retries,
2703
+ retry_cause_kind,
2704
+ degrade_cause: exhausted ? "step_exhaustion" : "stream_error"
2268
2705
  }
2269
2706
  };
2270
2707
  }
@@ -2374,14 +2811,50 @@ function status_of(value, converged) {
2374
2811
  */
2375
2812
  const CHECK_ARTIFACT_MAX_CHARS = 8e3;
2376
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. */
2377
2833
  function spawn_capture(cmd, args, options) {
2834
+ const { abort } = options;
2835
+ if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
2378
2836
  return new Promise((resolve_promise, reject) => {
2379
2837
  const proc = spawn(cmd, args, {
2380
2838
  cwd: options.cwd,
2381
- ...options.abort !== void 0 ? { signal: options.abort } : {}
2839
+ ...options.env !== void 0 ? { env: options.env } : {},
2840
+ detached: process.platform !== "win32",
2841
+ stdio: [
2842
+ "ignore",
2843
+ "pipe",
2844
+ "pipe"
2845
+ ]
2382
2846
  });
2383
2847
  let stdout = "";
2384
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
+ };
2385
2858
  proc.stdout.on("data", (chunk) => {
2386
2859
  stdout += chunk.toString();
2387
2860
  });
@@ -2389,19 +2862,25 @@ function spawn_capture(cmd, args, options) {
2389
2862
  stderr += chunk.toString();
2390
2863
  });
2391
2864
  proc.on("error", (err) => {
2392
- if (err.name === "AbortError") {
2393
- reject(new aborted_error("check aborted"));
2394
- return;
2395
- }
2865
+ finish();
2396
2866
  reject(check_error(`check command failed to start: ${err.message}`));
2397
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
+ });
2398
2875
  proc.on("close", (code) => {
2876
+ finish();
2399
2877
  resolve_promise({
2400
2878
  exit_code: code,
2401
2879
  stdout,
2402
2880
  stderr
2403
2881
  });
2404
2882
  });
2883
+ abort?.addEventListener("abort", on_abort, { once: true });
2405
2884
  });
2406
2885
  }
2407
2886
  /** Best-effort parse of the summary contract. `pnpm exec` talks over the
@@ -2465,13 +2944,14 @@ function read_failing_artifacts(workspace, failing) {
2465
2944
  });
2466
2945
  }
2467
2946
  async function run_checkride(opts) {
2947
+ const { workspace, ...spawn_options } = opts;
2468
2948
  const proc = await spawn_capture("pnpm", [
2469
2949
  "exec",
2470
2950
  "checkride",
2471
2951
  "--json"
2472
2952
  ], {
2473
- cwd: opts.workspace,
2474
- ...opts.abort !== void 0 ? { abort: opts.abort } : {}
2953
+ ...spawn_options,
2954
+ cwd: workspace
2475
2955
  });
2476
2956
  if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
2477
2957
  const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
@@ -2495,12 +2975,13 @@ async function run_checkride(opts) {
2495
2975
  * (exit 4), not an iterable failure.
2496
2976
  */
2497
2977
  async function run_command_check(opts) {
2978
+ const { command, workspace, ...spawn_options } = opts;
2498
2979
  const started = Date.now();
2499
- const proc = await spawn_capture("sh", ["-c", opts.command], {
2500
- cwd: opts.workspace,
2501
- ...opts.abort !== void 0 ? { abort: opts.abort } : {}
2980
+ const proc = await spawn_capture("sh", ["-c", command], {
2981
+ ...spawn_options,
2982
+ cwd: workspace
2502
2983
  });
2503
- if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${opts.command}`);
2984
+ if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
2504
2985
  const log = `${proc.stdout}${proc.stderr}`;
2505
2986
  return {
2506
2987
  ran: true,
@@ -2524,6 +3005,63 @@ function skipped_check(reason) {
2524
3005
  detail: []
2525
3006
  };
2526
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
+ //#region src/render/format.ts
3027
+ /**
3028
+ * Display formatting: glyph taxonomy, role colors, truncation.
3029
+ * All human output goes to stderr; these helpers only build strings.
3030
+ */
3031
+ const GLYPH_TOOL = "🔧";
3032
+ const GLYPH_COST = "💰";
3033
+ const VERBOSE_TRUNCATE_CHARS = 4e3;
3034
+ function paint(role, text, color) {
3035
+ if (!color) return text;
3036
+ switch (role) {
3037
+ case "builder": return pc.cyan(text);
3038
+ case "critic": return pc.yellow(text);
3039
+ case "check": return pc.green(text);
3040
+ case "cost": return pc.magenta(text);
3041
+ case "error": return pc.bold(pc.red(text));
3042
+ }
3043
+ }
3044
+ function dim(text, color) {
3045
+ return color ? pc.dim(text) : text;
3046
+ }
3047
+ function truncate(text, max, pointer) {
3048
+ if (text.length <= max) return text;
3049
+ return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
3050
+ }
3051
+ function format_usd(value) {
3052
+ return value === null ? "$?" : `$${value.toFixed(3)}`;
3053
+ }
3054
+ /** One-line summary of a tool input for the default streaming mode. */
3055
+ function summarize_value(value, max = 120) {
3056
+ let text;
3057
+ try {
3058
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3059
+ } catch {
3060
+ text = String(value);
3061
+ }
3062
+ const flat = text.replace(/\s+/g, " ");
3063
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3064
+ }
2527
3065
  //#endregion
2528
3066
  //#region src/iteration.ts
2529
3067
  /**
@@ -2534,9 +3072,9 @@ function skipped_check(reason) {
2534
3072
  function write_json(path, value) {
2535
3073
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
2536
3074
  }
2537
- function persist_verdict_files(workspace, feedback, verdict) {
2538
- writeFileSync(volley_path(workspace, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}\n`);
2539
- writeFileSync(volley_path(workspace, "verdict"), `${verdict}\n`);
3075
+ function persist_verdict_files(config, feedback, verdict) {
3076
+ writeFileSync(state_path(config, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}\n`);
3077
+ writeFileSync(state_path(config, "verdict"), `${verdict}\n`);
2540
3078
  }
2541
3079
  function phase_summary(record) {
2542
3080
  if (record === null) return null;
@@ -2555,17 +3093,18 @@ function phase_summary(record) {
2555
3093
  ...record.retries !== void 0 ? { retries: record.retries } : {},
2556
3094
  ...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
2557
3095
  ...record.critic_degraded === true ? { critic_degraded: true } : {},
3096
+ ...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
2558
3097
  ...record.throughput !== void 0 ? { throughput: record.throughput } : {}
2559
3098
  };
2560
3099
  }
2561
3100
  /** Archive one completed iteration: verdict files, check artifacts, and the
2562
3101
  * per-iteration summary. */
2563
3102
  function archive_iteration(config, state) {
2564
- const dir = iteration_dir(config.workspace, state.iteration);
3103
+ const dir = iteration_dir(config, state.iteration);
2565
3104
  mkdirSync(join(dir, "check"), { recursive: true });
2566
3105
  if (state.verdict !== null && state.feedback !== null) {
2567
- persist_verdict_files(config.workspace, state.feedback, state.verdict);
2568
- writeFileSync(join(dir, "feedback.md"), readFileSync(volley_path(config.workspace, "feedback.md")));
3106
+ persist_verdict_files(config, state.feedback, state.verdict);
3107
+ writeFileSync(join(dir, "feedback.md"), readFileSync(state_path(config, "feedback.md")));
2569
3108
  writeFileSync(join(dir, "verdict"), `${state.verdict}\n`);
2570
3109
  }
2571
3110
  const check = state.check;
@@ -2614,13 +3153,15 @@ function run_result_from_state(config, state, status, completed_at) {
2614
3153
  critic_cost_usd: state.critic_cost_usd,
2615
3154
  check_duration_ms: state.check_duration_ms,
2616
3155
  final_verdict: state.verdict,
2617
- salvaged_branch: null
3156
+ salvaged_branch: null,
3157
+ run_state_dir: run_state_dir(config),
3158
+ mode: config.mode
2618
3159
  };
2619
3160
  }
2620
3161
  //#endregion
2621
3162
  //#region src/summary.ts
2622
3163
  /**
2623
- * Enriched run summary (`.volley/summary.json`): the `RunResult` projection plus
3164
+ * Enriched run summary (the run's `summary.json`): the `RunResult` projection plus
2624
3165
  * a `comparison` block that makes the two blessed examples' runs diff-able.
2625
3166
  * The block gathers the seven comparison metrics — transport,
2626
3167
  * iterations-to-converge, wall-clock, cost, verdict, the deterministic check
@@ -2635,22 +3176,27 @@ function run_result_from_state(config, state, status, completed_at) {
2635
3176
  * surviving branch on `salvaged_branch`, which teardown only names after the
2636
3177
  * final status is known.
2637
3178
  * Each write recomputes the comparison from what is currently archived under
2638
- * `.volley/iterations/`, so the trajectory grows as the run does.
3179
+ * the run's `iterations/`, so the trajectory grows as the run does.
2639
3180
  */
2640
3181
  function transport_of(provider) {
2641
- return provider === "claude_cli" ? "claude_cli" : "ai_sdk";
3182
+ switch (provider) {
3183
+ case "claude_cli": return "claude_cli";
3184
+ case "openrouter": return "native";
3185
+ case "ollama":
3186
+ case "lmstudio": return "ai_sdk";
3187
+ }
2642
3188
  }
2643
3189
  /** Read the archived per-iteration summaries in order. A missing archive root
2644
3190
  * (a run that failed before the first `record`) or an unreadable entry yields an
2645
3191
  * empty/short list — the run summary still writes, just without that trajectory
2646
3192
  * detail. */
2647
- function read_iteration_archives(workspace) {
2648
- const root = volley_path(workspace, "iterations");
3193
+ function read_iteration_archives(config) {
3194
+ const root = state_path(config, "iterations");
2649
3195
  if (!existsSync(root)) return [];
2650
3196
  const dirs = readdirSync(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
2651
3197
  const archives = [];
2652
3198
  for (const dir of dirs) {
2653
- const path = volley_path(workspace, "iterations", dir, "summary.json");
3199
+ const path = state_path(config, "iterations", dir, "summary.json");
2654
3200
  if (!existsSync(path)) continue;
2655
3201
  try {
2656
3202
  archives.push(JSON.parse(readFileSync(path, "utf8")));
@@ -2672,6 +3218,13 @@ function check_trajectory(archives) {
2672
3218
  function any_critic_degraded(archives) {
2673
3219
  return archives.some((archive) => archive.critic?.critic_degraded === true);
2674
3220
  }
3221
+ /** Each distinct reason an archived critic verdict degraded. An archive from
3222
+ * before the cause was recorded reads as a stream error, the only way a verdict
3223
+ * could degrade then. */
3224
+ function critic_degrade_causes(archives) {
3225
+ const causes = archives.filter((archive) => archive.critic?.critic_degraded === true).map((archive) => archive.critic?.degrade_cause ?? "stream_error");
3226
+ return [...new Set(causes)];
3227
+ }
2675
3228
  /** Every gate path the run's builder touched, across all iterations: an edit in
2676
3229
  * iteration 1 that a later iteration reverted still happened, so the run-level
2677
3230
  * answer is the union rather than the last iteration's list. */
@@ -2697,12 +3250,12 @@ function salvage_stats(archives) {
2697
3250
  * archive for the check trajectory and salvage aggregate. Pure over its inputs
2698
3251
  * plus the on-disk archive; `write_run_summary` persists the result. */
2699
3252
  function build_run_summary(config, result) {
2700
- const archives = read_iteration_archives(config.workspace);
3253
+ const archives = read_iteration_archives(config);
2701
3254
  const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
2702
3255
  return {
2703
3256
  ...result,
2704
3257
  comparison: {
2705
- builder_transport: transport_of(config.builder_provider),
3258
+ builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
2706
3259
  critic_transport: transport_of(config.critic_provider),
2707
3260
  iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2708
3261
  wall_clock_ms,
@@ -2714,17 +3267,54 @@ function build_run_summary(config, result) {
2714
3267
  gate_edits: all_gate_edits(archives),
2715
3268
  unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
2716
3269
  local_salvage: salvage_stats(archives),
2717
- critic_degraded: any_critic_degraded(archives)
3270
+ critic_degraded: any_critic_degraded(archives),
3271
+ critic_degrade_causes: critic_degrade_causes(archives)
2718
3272
  }
2719
3273
  };
2720
3274
  }
2721
- /** Write the enriched summary to `.volley/summary.json` (the projection the
3275
+ /** Write the enriched summary to the run's `summary.json` (the projection the
2722
3276
  * examples' comparison is read from). */
2723
3277
  function write_run_summary(config, result) {
2724
- writeFileSync(volley_path(config.workspace, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
3278
+ writeFileSync(state_path(config, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
2725
3279
  }
2726
3280
  //#endregion
2727
3281
  //#region src/phases.ts
3282
+ /**
3283
+ * The step bodies behind the nodes of `./flow.ts`, one function per node. Each
3284
+ * takes the loop's carry-state and returns the next one. The phase modules they
3285
+ * call (`./builder.ts`, `./check/`, `./critic/`) do the work; these bodies add
3286
+ * what surrounds it: the renderer lines an operator watches, change detection,
3287
+ * git checkpoints, the per-iteration archive, and the progress events a
3288
+ * trajectory reader follows the run by.
3289
+ */
3290
+ /** The step id the check's card is recorded under: the check step's own, in
3291
+ * `./flow.ts`, so each round's check counts as a fresh run of it and opens a
3292
+ * card of its own. */
3293
+ const CHECK_STEP_ID = "check";
3294
+ /** The raw call id and tool name of the check's card. The name sits outside
3295
+ * the workspace tools, so nothing dresses the card as one of theirs. */
3296
+ const CHECK_CALL = {
3297
+ id: "check",
3298
+ name: "check"
3299
+ };
3300
+ function emit(ctx, event) {
3301
+ ctx.emit(event);
3302
+ }
3303
+ /** Record one chunk of the check's card, the way `model_call` records a tool
3304
+ * call's, under a streamed run only, since nothing reads a card otherwise. */
3305
+ function record_check_chunk(ctx, chunk) {
3306
+ if (ctx.streaming) ctx.trajectory.record({
3307
+ kind: "model_chunk",
3308
+ step_id: CHECK_STEP_ID,
3309
+ chunk
3310
+ });
3311
+ }
3312
+ /** The command line the check runs, or null when no check is configured. */
3313
+ function check_command(config) {
3314
+ if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
3315
+ if (config.check_resolved === "command") return config.check;
3316
+ return null;
3317
+ }
2728
3318
  /** How many gate edits a warning names before it summarises the rest. */
2729
3319
  const LISTED_GATE_EDITS = 5;
2730
3320
  /**
@@ -2740,20 +3330,53 @@ function report_changes(config, renderer, changes) {
2740
3330
  if (changes.gate_edits.length === 0) return;
2741
3331
  const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
2742
3332
  const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
2743
- renderer.warn(`gate edit: the builder changed ${String(changes.gate_edits.length)} file(s) that decide whether its work passes — ${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.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
3333
+ const [who, whose, edits] = config.mode === "critique" ? [
3334
+ "the change under review touches",
3335
+ "whether it passes",
3336
+ "the change edits"
3337
+ ] : [
3338
+ "the builder changed",
3339
+ "whether its work passes",
3340
+ "the builder can edit"
3341
+ ];
3342
+ 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)." : ""));
2744
3343
  }
2745
- async function execute_check(config, abort) {
3344
+ /** Run the configured check, checkride or a command, with the environment
3345
+ * the caller set for it, else volley's own. */
3346
+ async function execute_check(deps, abort) {
3347
+ const { config } = deps;
2746
3348
  const root = build_root(config.workspace, config.worktree);
2747
- if (config.check_resolved === "checkride") return run_checkride({
2748
- workspace: root,
2749
- abort
2750
- });
2751
- if (config.check_resolved === "command") return run_command_check({
3349
+ const options = {
3350
+ abort,
3351
+ ...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
3352
+ };
3353
+ return config.check_resolved === "checkride" ? run_checkride({
3354
+ ...options,
3355
+ workspace: root
3356
+ }) : run_command_check({
3357
+ ...options,
2752
3358
  command: config.check,
2753
- workspace: root,
3359
+ workspace: root
3360
+ });
3361
+ }
3362
+ /** Whether the check may run `command`: yes, unless the caller set an
3363
+ * approval and it answers no. It asks on the check's card under the command
3364
+ * line as its tool name, which titles the prompt the way the card is titled,
3365
+ * and which names none of fascicle's gated tools, so the prompt offers Allow
3366
+ * and Reject alone, never an "always" that would cover the builder's commands
3367
+ * too. A stop while it asks ends the check as a stop, not a rejection. */
3368
+ async function check_allowed(deps, command, abort) {
3369
+ const approve = deps.check?.on_approval;
3370
+ if (approve === void 0) return true;
3371
+ const allowed = await approve({
3372
+ tool_name: command,
3373
+ input: { command },
3374
+ tool_call_id: CHECK_CALL.id,
3375
+ step_index: 0,
2754
3376
  abort
2755
3377
  });
2756
- return skipped_check("none");
3378
+ if (abort.aborted) throw new aborted_error("check aborted");
3379
+ return allowed;
2757
3380
  }
2758
3381
  /** Open the next iteration: advance the counter and clear every per-iteration
2759
3382
  * field, so nothing from the last round leaks into this one. */
@@ -2770,11 +3393,31 @@ function next_iteration(s) {
2770
3393
  unmet_criteria: []
2771
3394
  };
2772
3395
  }
3396
+ /** What changed in the build root since the baseline, reported as it's
3397
+ * measured: the count, and a warning for any gate edit. A run measures the
3398
+ * builder's work this way after every build, and a critique measures the
3399
+ * change under review the same way, so the critic is shown one shape either
3400
+ * way. */
3401
+ function measure_changes(deps, root) {
3402
+ const { config, renderer } = deps;
3403
+ const changes = collect_changes({
3404
+ root,
3405
+ baseline: deps.baseline,
3406
+ gate_patterns: config.gate_paths,
3407
+ excluded_dir: state_root_within(root, config.state_dir)
3408
+ });
3409
+ report_changes(config, renderer, changes);
3410
+ return changes;
3411
+ }
2773
3412
  /** `build`: one builder session edits the build root, then volley measures what
2774
3413
  * it changed and, under `--git`, checkpoints it. */
2775
3414
  async function build_phase(deps, builder, s, ctx) {
2776
3415
  const { config, renderer } = deps;
2777
3416
  const next = next_iteration(s);
3417
+ emit(ctx, {
3418
+ volley: "round_start",
3419
+ round: next.iteration
3420
+ });
2778
3421
  renderer.phase_start(next.iteration, "builder");
2779
3422
  const built = await run_builder({
2780
3423
  builder,
@@ -2784,18 +3427,28 @@ async function build_phase(deps, builder, s, ctx) {
2784
3427
  renderer.phase_end(built.iteration, "builder", true);
2785
3428
  renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
2786
3429
  if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
2787
- const changes = collect_changes({
2788
- root: build_root(config.workspace, config.worktree),
2789
- baseline: deps.baseline,
2790
- gate_patterns: config.gate_paths
2791
- });
2792
- report_changes(config, renderer, changes);
2793
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(built.iteration)}: build`);
3430
+ const root = build_root(config.workspace, config.worktree);
3431
+ const changes = measure_changes(deps, root);
3432
+ if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
2794
3433
  return {
2795
3434
  ...built,
2796
3435
  changes
2797
3436
  };
2798
3437
  }
3438
+ /** `measure`: a critique's one round opens, and the change under review is
3439
+ * measured from the baseline, as a run measures the builder's work. */
3440
+ function measure_phase(deps, s, ctx) {
3441
+ const next = next_iteration(s);
3442
+ emit(ctx, {
3443
+ volley: "round_start",
3444
+ round: next.iteration
3445
+ });
3446
+ const root = build_root(deps.config.workspace, deps.config.worktree);
3447
+ return {
3448
+ ...next,
3449
+ changes: measure_changes(deps, root)
3450
+ };
3451
+ }
2799
3452
  /** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
2800
3453
  * as skipped and no critic is called — a doomed iteration spends nothing more.
2801
3454
  * The loop guard then halts the run on the cap. */
@@ -2806,20 +3459,79 @@ function skip_verify_phase(deps, s) {
2806
3459
  check: skipped_check("cost_cap")
2807
3460
  };
2808
3461
  }
2809
- /** `check`: the deterministic gate over the tree the builder wrote. */
3462
+ /** Open the check's card on a streamed run: the call's start, then the
3463
+ * command as its input, the two chunks `model_call` records for a tool call
3464
+ * before it runs. */
3465
+ function open_check_card(ctx, command) {
3466
+ record_check_chunk(ctx, {
3467
+ kind: "tool_call_start",
3468
+ ...CHECK_CALL,
3469
+ step_index: 0
3470
+ });
3471
+ record_check_chunk(ctx, {
3472
+ kind: "tool_call_end",
3473
+ id: CHECK_CALL.id,
3474
+ input: { command },
3475
+ step_index: 0
3476
+ });
3477
+ }
3478
+ /** Close the check's card with its result, which the card's reader renders,
3479
+ * or with the error that stopped the check before it had one. */
3480
+ function close_check_card(ctx, outcome) {
3481
+ record_check_chunk(ctx, "result" in outcome ? {
3482
+ kind: "tool_result",
3483
+ id: CHECK_CALL.id,
3484
+ output: outcome.result,
3485
+ step_index: 0
3486
+ } : {
3487
+ kind: "tool_result",
3488
+ id: CHECK_CALL.id,
3489
+ error: { message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) },
3490
+ step_index: 0
3491
+ });
3492
+ }
3493
+ /** `check`: the deterministic gate over the tree the builder wrote. A check
3494
+ * that runs a command emits its start and end, and on a streamed run also
3495
+ * records its card as a tool call, so a reader of the stream shows it running
3496
+ * while it runs. When the caller set an approval, the check asks on that card
3497
+ * first, and a rejected check closes it failed and is recorded as failed. */
2810
3498
  async function check_phase(deps, s, ctx) {
2811
3499
  const { config, renderer } = deps;
2812
3500
  renderer.phase_start(s.iteration, "check");
3501
+ const command = check_command(config);
3502
+ if (command === null) {
3503
+ renderer.info("no deterministic check configured; loop is critic-gated only");
3504
+ return {
3505
+ ...s,
3506
+ check: skipped_check("none")
3507
+ };
3508
+ }
3509
+ emit(ctx, {
3510
+ volley: "check_start",
3511
+ round: s.iteration,
3512
+ command
3513
+ });
3514
+ open_check_card(ctx, command);
2813
3515
  try {
2814
- const result = await execute_check(config, ctx.abort);
2815
- if (result.ran) renderer.phase_end(s.iteration, "check", result.ok, result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0);
2816
- else renderer.info("no deterministic check configured; loop is critic-gated only");
3516
+ const allowed = await check_allowed(deps, command, ctx.abort);
3517
+ const result = allowed ? await execute_check(deps, ctx.abort) : rejected_check(config.check_resolved);
3518
+ close_check_card(ctx, allowed ? { result } : { error: CHECK_REJECTED });
3519
+ emit(ctx, {
3520
+ volley: "check_end",
3521
+ round: s.iteration,
3522
+ ok: result.ok,
3523
+ exit_code: result.exit_code,
3524
+ failing_slots: result.failing_slots,
3525
+ duration_ms: result.duration_ms
3526
+ });
3527
+ renderer.phase_end(s.iteration, "check", result.ok, !allowed ? "rejected" : result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0);
2817
3528
  return {
2818
3529
  ...s,
2819
3530
  check: result,
2820
3531
  check_duration_ms: s.check_duration_ms + result.duration_ms
2821
3532
  };
2822
3533
  } catch (err) {
3534
+ close_check_card(ctx, { error: err });
2823
3535
  if (error_kind(err) === "check_error") throw err;
2824
3536
  throw phase_error("check", s.iteration, err);
2825
3537
  }
@@ -2832,6 +3544,14 @@ async function critique_phase(deps, critic, s, ctx) {
2832
3544
  critic,
2833
3545
  config: deps.config
2834
3546
  }, s, ctx);
3547
+ if (critiqued.verdict !== null) emit(ctx, {
3548
+ volley: "verdict",
3549
+ round: critiqued.iteration,
3550
+ verdict: critiqued.verdict,
3551
+ feedback: critiqued.feedback ?? "",
3552
+ unmet_criteria: critiqued.unmet_criteria,
3553
+ degraded: critiqued.critic?.critic_degraded === true
3554
+ });
2835
3555
  renderer.phase_end(critiqued.iteration, "critic", critiqued.verdict === "approved", critiqued.verdict ?? void 0);
2836
3556
  renderer.cost_line(critiqued.iteration, "critic", critiqued.critic?.cost_usd ?? null, critiqued.total_cost_usd);
2837
3557
  return critiqued;
@@ -2842,7 +3562,10 @@ function record_phase(deps, s) {
2842
3562
  const { config } = deps;
2843
3563
  archive_iteration(config, s);
2844
3564
  write_run_summary(config, run_result_from_state(config, s, "running", null));
2845
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`);
3565
+ if (config.git_checkpoints) {
3566
+ const root = build_root(config.workspace, config.worktree);
3567
+ git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
3568
+ }
2846
3569
  return s;
2847
3570
  }
2848
3571
  //#endregion
@@ -2854,6 +3577,11 @@ function record_phase(deps, s) {
2854
3577
  * around the flow (workspace setup, the worktree lifecycle, `run`, the final
2855
3578
  * summary) in `./orchestrator.ts`.
2856
3579
  *
3580
+ * A critique (`build_critique_flow`) is one round over a change already made,
3581
+ * so it has no builder, no loop, and no guard: `measure`, which opens the
3582
+ * round and measures the change from its baseline, then the same `check`,
3583
+ * `critique`, and `record` a run's round ends with.
3584
+ *
2857
3585
  * fascicle's `describe.diagram` draws the tree below from the flow itself, and
2858
3586
  * `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
2859
3587
  * its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
@@ -2867,58 +3595,70 @@ function record_phase(deps, s) {
2867
3595
  * │ │ └─ else sequence
2868
3596
  * │ │ ├─ check the deterministic gate: checkride, a command, or none
2869
3597
  * │ │ └─ critique the read-only critic's verdict and feedback
2870
- * │ │ └─ critic fallback: judge without tools if a local critic's stream keeps dying
2871
- * │ │ ├─ retry one more try after a stream death, local providers only
3598
+ * │ │ └─ critic fallback: judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps
3599
+ * │ │ ├─ retry one more try after a stream death, on any provider but claude_cli
2872
3600
  * │ │ │ └─ critic_tools model call with read-only workspace tools
2873
3601
  * │ │ └─ pipe mark the verdict degraded
2874
3602
  * │ │ └─ critic_toolless model call judging from the check output and a file list
2875
3603
  * │ └─ record archive the iteration and rewrite the run summary
2876
3604
  * └─ guard gate stop on approval over a green check, the cost cap, or a gate edit
2877
3605
  */
2878
- /** The critic arm: the degradation ladder. A local critic whose tool-bearing
2879
- * stream dies is retried at once (the death is stochastic parser failure, not
2880
- * load, so waiting buys nothing), then judged once without tools; anything the
2881
- * ladder does not retry propagates from either rung untouched. */
3606
+ /** The critic arm: the degradation ladder. A critic on any provider but
3607
+ * `claude_cli` whose tool-bearing stream dies is retried at once (the death is
3608
+ * stochastic parser failure, not load, so waiting buys nothing), then judged
3609
+ * once without tools. One that runs out of steps skips the retry, since
3610
+ * another identical tool loop would only run out again, and goes straight to
3611
+ * the tool-less pass. Anything else propagates from either rung untouched. */
2882
3612
  function build_critic(deps) {
2883
3613
  const retryable = critic_retryable(deps.config);
2884
3614
  return fallback(retry(make_critic_tools_step(deps), {
2885
- description: "one more try after a stream death, local providers only",
3615
+ description: "one more try after a stream death, on any provider but claude_cli",
2886
3616
  max_attempts: 2,
2887
3617
  backoff_ms: 0,
2888
3618
  when: retryable,
2889
3619
  project: with_retries
2890
3620
  }), pipe(make_critic_toolless_step(deps), as_degraded, { description: "mark the verdict degraded" }), {
2891
3621
  name: "critic",
2892
- description: "judge without tools if a local critic's stream keeps dying",
2893
- when: retryable,
3622
+ description: "judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps",
3623
+ when: critic_degradable(deps.config),
2894
3624
  handoff: (prompt) => toolless_critic_prompt(deps.config, prompt),
2895
3625
  project: with_fallback_cause
2896
3626
  });
2897
3627
  }
3628
+ /** The steps a run and a critique share, built one way for both. */
3629
+ function check_step(deps) {
3630
+ return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
3631
+ }
3632
+ function critique_step(deps, seat) {
3633
+ const critic = build_critic({
3634
+ ...seat,
3635
+ config: deps.config,
3636
+ on_chunk: deps.renderer.critic_chunk
3637
+ });
3638
+ return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
3639
+ description: "the read-only critic's verdict and feedback",
3640
+ arm: critic
3641
+ });
3642
+ }
3643
+ function record_step(deps) {
3644
+ return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
3645
+ }
2898
3646
  function build_flow(deps, resume_from) {
2899
3647
  const { config, renderer } = deps;
2900
3648
  const builder = make_builder_step({
2901
- engine: deps.engines.builder,
3649
+ ...deps.seats.builder,
2902
3650
  config,
2903
3651
  on_chunk: renderer.builder_chunk,
2904
3652
  bash_executor: deps.bash_executor
2905
3653
  });
2906
- const critic = build_critic({
2907
- engine: deps.engines.critic,
2908
- config,
2909
- on_chunk: renderer.critic_chunk
2910
- });
2911
3654
  const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
2912
3655
  description: "open the iteration, run the builder, measure the change",
2913
3656
  arm: builder
2914
3657
  });
2915
3658
  const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s), { description: "record the check as skipped and call no critic" });
2916
- const check = step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
2917
- const critique = step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
2918
- description: "the read-only critic's verdict and feedback",
2919
- arm: critic
2920
- });
2921
- const record = step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
3659
+ const check = check_step(deps);
3660
+ const critique = critique_step(deps, deps.seats.critic);
3661
+ const record = record_step(deps);
2922
3662
  const verify = branch({
2923
3663
  name: "verify",
2924
3664
  description: "did the build alone cross the cost cap?",
@@ -2944,6 +3684,19 @@ function build_flow(deps, resume_from) {
2944
3684
  max_rounds: remaining_iterations(config, resume_from)
2945
3685
  });
2946
3686
  }
3687
+ /** A critique: one round over a change already made. `measure` opens it and
3688
+ * measures the change from the baseline, then the check, the critic, and the
3689
+ * record, as in a run's round. No loop and no guard: there is nothing to
3690
+ * iterate on, and the caller reads the outcome off the final state. */
3691
+ function build_critique_flow(deps) {
3692
+ const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
3693
+ return sequence([
3694
+ measure,
3695
+ check_step(deps),
3696
+ critique_step(deps, deps.critic),
3697
+ record_step(deps)
3698
+ ], { name: "critique_run" });
3699
+ }
2947
3700
  //#endregion
2948
3701
  //#region src/orchestrator.ts
2949
3702
  /**
@@ -2954,19 +3707,25 @@ function build_flow(deps, resume_from) {
2954
3707
  * substrate.
2955
3708
  *
2956
3709
  * run_volley
2957
- * ├─ initialize the workspace (or preserve it, on resume)
3710
+ * ├─ initialize the run's state (or preserve it, on resume)
2958
3711
  * ├─ with_worktree off unless --worktree
2959
3712
  * │ ├─ capture the change baseline
2960
- * │ ├─ run_loop run(build_flow(...)), then the final summary
3713
+ * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
2961
3714
  * │ └─ integrate a successful --worktree --git run only
2962
3715
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
3716
+ *
3717
+ * run_critique
3718
+ * ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
3719
+ * ├─ initialize the run's state
3720
+ * └─ run the critique flow one round on the critic's engine alone, then the final summary
2963
3721
  */
2964
3722
  async function run_volley(config, deps, resume_from = null) {
2965
3723
  const { renderer } = deps;
2966
3724
  if (resume_from === null) {
2967
- initialize_workspace(config.workspace);
2968
- write_resolved_config(config);
2969
- } else initialize_workspace(config.workspace, { preserve: true });
3725
+ initialize_run_state(config);
3726
+ if (!write_resolved_config(config) && !detect_containment(process.env)) renderer.warn(`could not sign this run's config with this machine's key (${trust_key_path(process.env)}), so resuming it here will need --trust-config`);
3727
+ } else initialize_run_state(config, { preserve: true });
3728
+ renderer.info(`state: ${run_state_dir(config)}`);
2970
3729
  report_worktree_fate(config, renderer);
2971
3730
  const branch = worktree_branch(config.run_id);
2972
3731
  const fate = worktree_fate(config);
@@ -3007,18 +3766,24 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
3007
3766
  critic: deps.engine
3008
3767
  };
3009
3768
  const flow = build_flow({
3010
- engines,
3769
+ seats: {
3770
+ builder: {
3771
+ ...deps.seats?.builder,
3772
+ engine: engines.builder
3773
+ },
3774
+ critic: {
3775
+ ...deps.seats?.critic,
3776
+ engine: engines.critic
3777
+ }
3778
+ },
3011
3779
  config,
3012
3780
  renderer,
3013
3781
  bash_executor,
3014
- baseline
3782
+ baseline,
3783
+ ...deps.check !== void 0 ? { check: deps.check } : {}
3015
3784
  }, resume_from);
3016
3785
  try {
3017
- const { value, converged } = await run(flow, { resume_from }, {
3018
- trajectory: filesystem_logger({ output_path: volley_path(config.workspace, "trajectory.jsonl") }),
3019
- ...deps.abort !== void 0 ? { abort: deps.abort } : {},
3020
- ...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
3021
- });
3786
+ const { value, converged } = await execute_flow(config, deps, flow, { resume_from });
3022
3787
  const result = run_result_from_state(config, value, status_of(value, converged), (/* @__PURE__ */ new Date()).toISOString());
3023
3788
  write_run_summary(config, result);
3024
3789
  return result;
@@ -3029,6 +3794,45 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
3029
3794
  await dispose_seat_engines(engines);
3030
3795
  }
3031
3796
  }
3797
+ /** Run the flow into its file trajectory and, when the caller passed one, its
3798
+ * own sink. Without a sink this is the one `run` it has always been. With one,
3799
+ * it goes through `run.stream`, because only a streamed run records each model
3800
+ * call's chunks as `model_chunk` events, which are what an outer consumer maps
3801
+ * to text, tool cards, and approval prompts. The sink is that consumer, so the
3802
+ * handle's own event stream is closed at once rather than left to buffer, and
3803
+ * the file skips the chunks, so it holds the same kinds of event either way.
3804
+ *
3805
+ * The file goes first in the tee because the first sink's span ids are the
3806
+ * ones the run hands out as parents, so the file's parent links stay whole.
3807
+ * The sink sees parent ids it never issued, which costs an ACP consumer
3808
+ * nothing: it counts a step's runs by the `id` on each `span_start`. */
3809
+ function execute_flow(config, deps, flow, input) {
3810
+ const file = filesystem_logger({ output_path: state_path(config, "trajectory.jsonl") });
3811
+ const options = {
3812
+ ...deps.abort !== void 0 ? { abort: deps.abort } : {},
3813
+ ...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
3814
+ };
3815
+ if (deps.trajectory === void 0) return run(flow, input, {
3816
+ ...options,
3817
+ trajectory: file
3818
+ });
3819
+ const handle = run.stream(flow, input, {
3820
+ ...options,
3821
+ trajectory: tee_logger(without_model_chunks(file), deps.trajectory)
3822
+ });
3823
+ handle.events[Symbol.asyncIterator]().return?.();
3824
+ return handle.result;
3825
+ }
3826
+ /** A logger that passes `inner` every event but a model call's streamed
3827
+ * chunks. */
3828
+ function without_model_chunks(inner) {
3829
+ return {
3830
+ ...inner,
3831
+ record: (event) => {
3832
+ if (event.kind !== "model_chunk") inner.record(event);
3833
+ }
3834
+ };
3835
+ }
3032
3836
  /** Best-effort status stamp on the run summary when the loop threw; the
3033
3837
  * on-disk iteration state is already consistent (record runs per round). */
3034
3838
  function finalize_failed_run(config, err) {
@@ -3036,59 +3840,64 @@ function finalize_failed_run(config, err) {
3036
3840
  const cause = kind === "phase_error" ? error_kind(err.cause) : null;
3037
3841
  const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
3038
3842
  try {
3039
- const summary_path = volley_path(config.workspace, "summary.json");
3843
+ const summary_path = state_path(config, "summary.json");
3040
3844
  write_run_summary(config, {
3041
3845
  ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
3846
+ run_state_dir: run_state_dir(config),
3042
3847
  status,
3043
3848
  completed_at: (/* @__PURE__ */ new Date()).toISOString()
3044
3849
  });
3045
3850
  } catch {}
3046
3851
  }
3047
- //#endregion
3048
- //#region src/render/format.ts
3049
3852
  /**
3050
- * Display formatting: glyph taxonomy, role colors, truncation.
3051
- * All human output goes to stderr; these helpers only build strings.
3853
+ * `volley critique`: the check and the critic, once, over a change already
3854
+ * made. No builder is constructed, and the engine serves the critic's seat
3855
+ * alone, so a critic can be measured on fixed, labeled changes without paying
3856
+ * for builder turns. It writes the state a one-round run does, with the mode
3857
+ * recorded, and ends `success` on approval over a green check, else
3858
+ * `rejected`.
3052
3859
  */
3053
- const GLYPH_TOOL = "🔧";
3054
- const GLYPH_COST = "💰";
3055
- const VERBOSE_TRUNCATE_CHARS = 4e3;
3056
- function paint(role, text, color) {
3057
- if (!color) return text;
3058
- switch (role) {
3059
- case "builder": return pc.cyan(text);
3060
- case "critic": return pc.yellow(text);
3061
- case "check": return pc.green(text);
3062
- case "cost": return pc.magenta(text);
3063
- case "error": 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)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
3072
- }
3073
- function format_usd(value) {
3074
- return value === null ? "$?" : `$${value.toFixed(3)}`;
3075
- }
3076
- /** One-line summary of a tool input for the default streaming mode. */
3077
- function summarize_value(value, max = 120) {
3078
- let text;
3860
+ async function run_critique(config, deps) {
3861
+ if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
3862
+ const { renderer } = deps;
3863
+ const baseline = resolve_baseline(config.workspace, deps.baseline);
3864
+ initialize_run_state(config);
3865
+ write_resolved_config(config);
3866
+ renderer.info(`state: ${run_state_dir(config)}`);
3867
+ renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
3868
+ const engine = deps.engine ?? create_volley_engine({
3869
+ workspace: config.workspace,
3870
+ critic_provider: config.critic_provider,
3871
+ critic_url: config.critic_url
3872
+ });
3873
+ const flow = build_critique_flow({
3874
+ config,
3875
+ renderer,
3876
+ baseline,
3877
+ critic: {
3878
+ ...deps.critic,
3879
+ engine
3880
+ },
3881
+ ...deps.check !== void 0 ? { check: deps.check } : {}
3882
+ });
3079
3883
  try {
3080
- text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3081
- } catch {
3082
- text = String(value);
3884
+ const value = await execute_flow(config, deps, flow, initial_state());
3885
+ const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
3886
+ write_run_summary(config, result);
3887
+ return result;
3888
+ } catch (err) {
3889
+ finalize_failed_run(config, err);
3890
+ throw err;
3891
+ } finally {
3892
+ await engine.dispose();
3083
3893
  }
3084
- const flat = text.replace(/\s+/g, " ");
3085
- return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3086
3894
  }
3087
3895
  //#endregion
3088
3896
  //#region src/render/renderer.ts
3089
3897
  function create_renderer(options) {
3090
3898
  const write = options.write ?? ((text) => process.stderr.write(text));
3091
3899
  const { mode, color } = options;
3900
+ const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
3092
3901
  const silent = mode === "json";
3093
3902
  const streaming = mode === "default" || mode === "verbose";
3094
3903
  let mid_line = false;
@@ -3110,7 +3919,7 @@ function create_renderer(options) {
3110
3919
  mid_line = !chunk.text.endsWith("\n");
3111
3920
  return;
3112
3921
  case "tool_call_end": {
3113
- const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS, "see .volley/trajectory.jsonl") : summarize_value(chunk.input);
3922
+ const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS, see_trajectory) : summarize_value(chunk.input);
3114
3923
  line(`${GLYPH_TOOL} ${paint(role, chunk.id, color)} ${summary}`);
3115
3924
  return;
3116
3925
  }
@@ -3118,7 +3927,7 @@ function create_renderer(options) {
3118
3927
  const ok = chunk.error === void 0;
3119
3928
  if (mode === "verbose") {
3120
3929
  const body = ok ? summarize_output(chunk.output) : chunk.error?.message ?? "";
3121
- line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, "see .volley/trajectory.jsonl")}`);
3930
+ line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, see_trajectory)}`);
3122
3931
  } else line(`${ok ? "✅" : "❌"} ${ok ? "" : summarize_value(chunk.error?.message)}`);
3123
3932
  return;
3124
3933
  }
@@ -3166,6 +3975,7 @@ function create_renderer(options) {
3166
3975
  line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
3167
3976
  line(paint("cost", `${GLYPH_COST} total: ${format_usd(result.total_cost_usd)} (builder ${format_usd(result.builder_cost_usd)}, critic ${format_usd(result.critic_cost_usd)})`, color));
3168
3977
  line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
3978
+ line(`state: ${result.run_state_dir}`);
3169
3979
  }
3170
3980
  };
3171
3981
  }
@@ -3187,7 +3997,7 @@ function summarize_output(output) {
3187
3997
  * volley detects it is already contained. The create/`exec`/reap/teardown
3188
3998
  * lifecycle and the `docker exec` bash executor were retired; what
3189
3999
  * remains here is the *spec* — pure, daemon-free flag-builders the blessed
3190
- * examples and the `--dry-run` preflight render into the `docker run` line,
4000
+ * examples and a parent harness render into the `docker run` line,
3191
4001
  * unit-testable without a daemon.
3192
4002
  *
3193
4003
  * Inside that container volley's `bash` is the local `host_bash_executor`
@@ -3212,6 +4022,13 @@ const SANDBOX_WORKDIR = "/workspace";
3212
4022
  * otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
3213
4023
  * brick tools that scribble there. */
3214
4024
  const SANDBOX_HOME = "/home/node";
4025
+ /** Where volley inside the container keeps each run's state when no
4026
+ * `--state-dir` names a root: the default root under the pinned `HOME`, since
4027
+ * the image sets no `XDG_STATE_HOME`. `sandbox_run_args`'s `run_state` binds one
4028
+ * run's host directory at that run's directory under it, so the state outlives
4029
+ * the `--rm` container. Exported so a harness can map a contained run's
4030
+ * `run_state_dir` back to the directory it mounted. */
4031
+ const SANDBOX_STATE_ROOT = default_state_root({}, SANDBOX_HOME);
3215
4032
  /** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
3216
4033
  * already provides it). It is both the `--add-host` target for the allowlist
3217
4034
  * bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
@@ -3243,7 +4060,7 @@ function network_run_args(network, network_name) {
3243
4060
  /**
3244
4061
  * The hardened `docker run` argv that launches volley inside its container.
3245
4062
  * Pure and exported so the flag set is unit-testable without a
3246
- * daemon and the blessed examples / `--dry-run` preflight can render it.
4063
+ * daemon and the blessed examples and a parent harness can render it.
3247
4064
  *
3248
4065
  * A one-shot, foreground `--rm` container — the whole run *is* this
3249
4066
  * container (an earlier design ran a detached `-d … sleep infinity` daemon volley
@@ -3255,6 +4072,25 @@ function network_run_args(network, network_name) {
3255
4072
  * the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
3256
4073
  * this way).
3257
4074
  *
4075
+ * `run_state` binds the run's own state directory on the host,
4076
+ * `<root>/<run_id>/`, at `<SANDBOX_STATE_ROOT>/<run_id>/`, where volley inside
4077
+ * keeps that run's state when no `--state-dir` names a root, so it outlives the
4078
+ * container at the path a run on the host would use. Only the one directory
4079
+ * is mounted, never the root: the builder inside runs arbitrary commands, so
4080
+ * it can rewrite its own run's state but can't read another run's trajectory
4081
+ * or plant a check in another run's config. Its own run's state is still the
4082
+ * builder's to rewrite, config.json included, so a host-side resume refuses
4083
+ * that config, which volley signs only outside the sandbox (`./trust.ts`), and
4084
+ * a directory holding a symlink: resume a contained run inside the container,
4085
+ * with the same mount. Create the
4086
+ * directory on the host first, empty (Docker on Linux makes a missing bind
4087
+ * source owned by root, which the container's user can't write), and pass the
4088
+ * same id to volley as `--run-id`. Keep it outside `build_root`, so the check
4089
+ * never reads it, and pass no `XDG_STATE_HOME` in `env`, which would move the
4090
+ * root off the mount. Omit it and no state is mounted: a contained run keeping
4091
+ * its state under the default root then stops before spending (exit 5),
4092
+ * since the read-only root filesystem leaves it nowhere to write.
4093
+ *
3258
4094
  * `store_volume` mounts the named volume as the pnpm store; omit it and no store
3259
4095
  * is mounted. A store is shared by every run that mounts it, so a caller that
3260
4096
  * installs dependencies in a separate container first can keep the builder's
@@ -3264,7 +4100,7 @@ function network_run_args(network, network_name) {
3264
4100
  * Never emits `--privileged` and refuses `--user 0`: volley must not hand
3265
4101
  * the sandbox root or full capabilities. Everything writable under the
3266
4102
  * `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
3267
- * is the one host-visible write path.
4103
+ * and, when one is named, the state root are the host-visible write paths.
3268
4104
  */
3269
4105
  function sandbox_run_args(options) {
3270
4106
  if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
@@ -3273,6 +4109,7 @@ function sandbox_run_args(options) {
3273
4109
  const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
3274
4110
  const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
3275
4111
  const labels = (options.labels ?? []).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
4112
+ const state = options.run_state !== void 0 && options.run_state !== null ? ["-v", run_state_mount(options.run_state)] : [];
3276
4113
  const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
3277
4114
  return [
3278
4115
  "run",
@@ -3306,6 +4143,7 @@ function sandbox_run_args(options) {
3306
4143
  ...env,
3307
4144
  "-v",
3308
4145
  `${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
4146
+ ...state,
3309
4147
  ...store,
3310
4148
  "-w",
3311
4149
  SANDBOX_WORKDIR,
@@ -3314,6 +4152,13 @@ function sandbox_run_args(options) {
3314
4152
  ...options.command ?? []
3315
4153
  ];
3316
4154
  }
4155
+ /** The `-v` value for one run's state directory. The run id becomes a path on
4156
+ * both sides, so it is held to volley's own `--run-id` rule, which keeps it to
4157
+ * one directory name. */
4158
+ function run_state_mount(run_state) {
4159
+ if (!RUN_ID_PATTERN.test(run_state.run_id)) throw config_error(`run_state.run_id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_state.run_id}`);
4160
+ return `${join(resolve(run_state.root), run_state.run_id)}:${SANDBOX_STATE_ROOT}/${run_state.run_id}`;
4161
+ }
3317
4162
  //#endregion
3318
4163
  //#region src/exit_codes.ts
3319
4164
  /**
@@ -3331,7 +4176,8 @@ const EXIT_INTERRUPTED = 130;
3331
4176
  function exit_code_for_status(status) {
3332
4177
  switch (status) {
3333
4178
  case "success": return 0;
3334
- case "budget_exhausted": return 2;
4179
+ case "budget_exhausted":
4180
+ case "rejected": return 2;
3335
4181
  case "cost_cap_reached": return 7;
3336
4182
  case "gate_edit_blocked": return 8;
3337
4183
  case "interrupted": return 130;
@@ -3366,6 +4212,6 @@ function exit_code_for_error(err) {
3366
4212
  return 3;
3367
4213
  }
3368
4214
  //#endregion
3369
- export { EXIT_BUDGET_EXHAUSTED, EXIT_BUILDER_ERROR, EXIT_CHECK_ERROR, EXIT_CONFIG_ERROR, EXIT_COST_CAP, EXIT_CRITIC_ERROR, EXIT_INTERRUPTED, EXIT_SUCCESS, SANDBOX_NETWORK_NAME, SANDBOX_NETWORK_SUBNET, create_renderer, exit_code_for_error, exit_code_for_status, expand_at_file, gate, initial_state, load_config_file, network_run_args, resolve_config, run_volley, sandbox_run_args, status_of, verdict_schema };
4215
+ export { EXIT_BUDGET_EXHAUSTED, EXIT_BUILDER_ERROR, EXIT_CHECK_ERROR, EXIT_CONFIG_ERROR, EXIT_COST_CAP, EXIT_CRITIC_ERROR, EXIT_INTERRUPTED, EXIT_SUCCESS, SANDBOX_NETWORK_NAME, SANDBOX_NETWORK_SUBNET, SANDBOX_STATE_ROOT, create_renderer, default_state_root, exit_code_for_error, exit_code_for_status, expand_at_file, gate, initial_state, load_config_file, network_run_args, resolve_config, run_critique, run_volley, sandbox_run_args, status_of, verdict_schema };
3370
4216
 
3371
4217
  //# sourceMappingURL=index.js.map