@robmclarty/volley 0.7.0 → 0.8.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
@@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { 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 { filesystem_logger, tee_logger } from "fascicle/adapters";
8
8
  import { lookup } from "node:dns";
9
9
  import { Readability } from "@mozilla/readability";
10
10
  import ipaddr from "ipaddr.js";
@@ -315,6 +315,13 @@ function error_kind(err) {
315
315
  * and 404s. */
316
316
  const DEFAULT_OLLAMA_URL = "http://localhost:11434";
317
317
  const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
318
+ /** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
319
+ const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
320
+ /** The variable an `openrouter` seat's API key is read from. */
321
+ const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
322
+ /** The variable a `claude_cli` seat's API key is read from, which the Claude
323
+ * CLI also reads on its own. */
324
+ const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
318
325
  /** Loopback authorities the container→host crossing rewrites. A base URL
319
326
  * pointing at one of these is local to wherever volley runs; contained, that is
320
327
  * *inside* the sandbox container, where the host LLM daemon is unreachable
@@ -361,9 +368,15 @@ const SEAT_URL_SOURCES = {
361
368
  env: "VOLLEY_LMSTUDIO_URL",
362
369
  default_url: DEFAULT_LMSTUDIO_URL,
363
370
  normalize: strip_trailing_slashes
371
+ },
372
+ openrouter: {
373
+ env: "VOLLEY_OPENROUTER_URL",
374
+ default_url: DEFAULT_OPENROUTER_URL,
375
+ normalize: strip_trailing_slashes
364
376
  }
365
377
  };
366
- /** The providers that take a seat URL (`--builder-url` / `--critic-url`). */
378
+ /** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
379
+ * so get their endpoint probed by `--dry-run`. */
367
380
  const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
368
381
  /** Whether a seat on `provider` reaches its model at a base URL volley
369
382
  * configures, and so takes a URL of its own. */
@@ -376,16 +389,25 @@ function present(value) {
376
389
  return trimmed === "" ? null : trimmed;
377
390
  }
378
391
  /** 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. */
392
+ * provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
393
+ * `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
394
+ * other seat's URL. The result is normalized, then a loopback authority is
395
+ * crossed to the host LLM endpoint when volley runs contained. Both seats can
396
+ * be loopback URLs on different ports, and each crosses on its own port. */
384
397
  function seat_base_url(provider, seat_url, env) {
385
398
  const source = SEAT_URL_SOURCES[provider];
386
399
  const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
387
400
  return cross_to_host_gateway(source.normalize(raw), env);
388
401
  }
402
+ /** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
403
+ * or blank key is a config error naming the variable, which `resolve_config`
404
+ * raises before any work, because fascicle's own "requires a non-empty
405
+ * api_key" doesn't say where the key comes from. */
406
+ function openrouter_api_key(env) {
407
+ const key = present(env[OPENROUTER_KEY_VAR]);
408
+ if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
409
+ return key;
410
+ }
389
411
  function load_pricing_overrides(path) {
390
412
  if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
391
413
  try {
@@ -398,29 +420,44 @@ function load_pricing_overrides(path) {
398
420
  * peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
399
421
  * fascicle only when the provider actually runs. The entry is complete, because
400
422
  * `Engine.with_providers` replaces a provider's whole entry: whatever this
401
- * returns is all a seat's derived engine knows about the provider.
423
+ * returns is all a seat's derived engine knows about the provider, so an
424
+ * `openrouter` entry carries its key and its transport along with its URL.
425
+ *
426
+ * `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
427
+ * so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
428
+ * traffic to volley.
402
429
  *
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.
430
+ * For the local providers, native transport is the deferred future bridge, not
431
+ * adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
432
+ * selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
433
+ * so no field is needed today. Flipping later is a one-line change — add
434
+ * `transport: 'native'` to the ollama/lmstudio entry returned below, which both
435
+ * seats' engines then share. No URL re-pointing is required: `seat_base_url`
436
+ * already yields the daemon-root URL the native `/api/chat` transport wants
437
+ * (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
438
+ * base-URL fix produces.
411
439
  * Kept as a proven-once-then-reverted option so the transport stays a clean
412
440
  * second variable in the model-vs-transport comparison. Flipping it today would
413
441
  * also lose the reasoning: fascicle's native Ollama adapter drops
414
442
  * `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
415
443
  * thinking seat's reasoning would vanish from the terminal and the trajectory.
416
444
  *
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
445
+ * Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
446
+ * when volley runs contained, so an in-container model client reaches the
419
447
  * host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
420
448
  * override is unset and this is a no-op. */
421
449
  function seat_provider_config(provider, seat_url, env) {
422
450
  const base_url = seat_base_url(provider, seat_url, env);
423
- return provider === "ollama" ? { ollama: { base_url } } : { lmstudio: { base_url } };
451
+ switch (provider) {
452
+ case "ollama": return { ollama: { base_url } };
453
+ case "lmstudio": return { lmstudio: { base_url } };
454
+ case "openrouter": return { openrouter: {
455
+ api_key: openrouter_api_key(env),
456
+ base_url,
457
+ transport: "native",
458
+ x_title: "volley"
459
+ } };
460
+ }
424
461
  }
425
462
  /** Per-run engine. `claude_cli` is always configured with `options.workspace`
426
463
  * (the run's build root) as its session cwd; any URL-taking provider selected
@@ -440,7 +477,7 @@ function create_volley_engine(options) {
440
477
  setting_sources: ["project", "local"],
441
478
  auth_mode,
442
479
  ...binary !== void 0 && binary.length > 0 ? { binary } : {},
443
- ...auth_mode === "api_key" && env["ANTHROPIC_API_KEY"] !== void 0 ? { api_key: env["ANTHROPIC_API_KEY"] } : {}
480
+ ...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
444
481
  } };
445
482
  if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
446
483
  if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
@@ -490,18 +527,24 @@ const DEFAULT_SANDBOX_IMAGE = "volley-sandbox:latest";
490
527
  const BUILDER_PROVIDERS = [
491
528
  "claude_cli",
492
529
  "ollama",
493
- "lmstudio"
530
+ "lmstudio",
531
+ "openrouter"
532
+ ];
533
+ /** Builder providers that drive their model through volley's own tool loop —
534
+ * a real `bash` (write + exec), wherever the model runs. Refused unless volley
535
+ * is running inside a container (the correct blast-radius default) or the
536
+ * operator explicitly opts out; `claude_cli` has its own permission model and
537
+ * is exempt. */
538
+ const TOOL_LOOP_BUILDER_PROVIDERS = [
539
+ "ollama",
540
+ "lmstudio",
541
+ "openrouter"
494
542
  ];
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
543
  const CRITIC_PROVIDERS = [
502
544
  "claude_cli",
503
545
  "ollama",
504
- "lmstudio"
546
+ "lmstudio",
547
+ "openrouter"
505
548
  ];
506
549
  const CRITIC_PRESETS = [
507
550
  "reviewer",
@@ -580,7 +623,8 @@ function resolve_sandbox_image(raw, env) {
580
623
  }
581
624
  /** The `--allow-unsandboxed-builder` flag/config value wins; otherwise
582
625
  * `VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1` (or `=true`) opts out. Any other value
583
- * (incl. `0`/`false`/unset) leaves the local builder refused unless contained. */
626
+ * (incl. `0`/`false`/unset) leaves a tool-loop builder refused unless
627
+ * contained. */
584
628
  function resolve_allow_unsandboxed_builder(raw, env) {
585
629
  if (raw.allow_unsandboxed_builder === true) return true;
586
630
  const env_value = env["VOLLEY_ALLOW_UNSANDBOXED_BUILDER"];
@@ -600,7 +644,7 @@ function resolve_seat_url(seat, value, provider) {
600
644
  if (typeof value !== "string") throw config_error(`${setting} must be a URL string; got a ${typeof value}`);
601
645
  const url = value.trim();
602
646
  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}`);
647
+ 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
648
  let parsed;
605
649
  try {
606
650
  parsed = new URL(url);
@@ -612,6 +656,16 @@ function resolve_seat_url(seat, value, provider) {
612
656
  if (parsed.search !== "" || parsed.hash !== "") throw config_error(`${setting} must not carry a query or fragment; got: ${url}`);
613
657
  return url;
614
658
  }
659
+ /** `--state-dir` (`state_dir`): the root each run's state sits under at
660
+ * `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
661
+ * place, or null to keep `<workspace>/.volley/`. An empty value counts as unset,
662
+ * as it does for a seat URL. */
663
+ function resolve_state_dir(value, cwd) {
664
+ if (value === void 0 || value === null) return null;
665
+ if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
666
+ const path = value.trim();
667
+ return path === "" ? null : resolve(cwd, path);
668
+ }
615
669
  /** volley *detects* it is running inside its sandbox container rather than
616
670
  * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
617
671
  * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
@@ -624,6 +678,12 @@ function detect_containment(env) {
624
678
  const value = env["VOLLEY_CONTAINED"];
625
679
  return value === "1" || value === "true";
626
680
  }
681
+ /** Whether a tool-loop builder's commands would reach the host with nothing in
682
+ * between: volley isn't contained, and the operator hasn't opted out with
683
+ * `--allow-unsandboxed-builder` or `VOLLEY_ALLOW_UNSANDBOXED_BUILDER`. */
684
+ function unguarded_builder(raw, env) {
685
+ return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
686
+ }
627
687
  /** A caller's run id names the run's `volley/<run id>` branch, so it is held to
628
688
  * what a branch name accepts without quoting: letters, digits, `-`, and `_`,
629
689
  * starting with a letter or digit so it never reads as an option. */
@@ -656,12 +716,13 @@ function resolve_config(raw, options = {}) {
656
716
  if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
657
717
  const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
658
718
  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.`);
719
+ if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, 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.`);
660
720
  const builder_max_steps = resolve_builder_max_steps(raw, env);
661
721
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
662
722
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
663
723
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
664
724
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
725
+ if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
665
726
  if (contained) {
666
727
  const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
667
728
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
@@ -675,6 +736,7 @@ function resolve_config(raw, options = {}) {
675
736
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
676
737
  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
738
  const sandbox_image = resolve_sandbox_image(raw, env);
739
+ const state_dir = resolve_state_dir(raw.state_dir, cwd);
678
740
  const run_id = resolve_run_id(options.run_id);
679
741
  const check = raw.check ?? "auto";
680
742
  return {
@@ -704,6 +766,7 @@ function resolve_config(raw, options = {}) {
704
766
  gate_paths: [...gate_paths],
705
767
  fail_on_gate_edit: raw.fail_on_gate_edit ?? false,
706
768
  sandbox_image,
769
+ state_dir,
707
770
  workspace,
708
771
  verbose: raw.verbose ?? false,
709
772
  quiet: raw.quiet ?? false,
@@ -782,8 +845,9 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
782
845
  */
783
846
  const READ_FILE_MAX_BYTES = 2e5;
784
847
  const LIST_MAX_ENTRIES = 2e3;
785
- /** Directories never worth handing a critic; they bury signal and blow the
786
- * traversal budget. */
848
+ /** Directories never worth handing a seat; they bury signal and blow the
849
+ * traversal budget. The acp seats' `list_files` and `search` skip the same
850
+ * names (`./acp_tools.ts`), so a seat sees one tree under acp and on the CLI. */
787
851
  const IGNORED_DIRS = /* @__PURE__ */ new Set([
788
852
  "node_modules",
789
853
  ".git",
@@ -1295,7 +1359,19 @@ function builder_tools(workspace, options = {}) {
1295
1359
  max_bytes: options.fetch_max_bytes ?? FETCH_MAX_BYTES
1296
1360
  })
1297
1361
  };
1298
- const finish = {
1362
+ return [
1363
+ ...read_only_tools(workspace),
1364
+ write_file,
1365
+ edit_file,
1366
+ bash,
1367
+ fetch,
1368
+ finish_tool()
1369
+ ];
1370
+ }
1371
+ /** The terminal `finish` tool, on its own for a builder set that brings its
1372
+ * own file and command tools (`../acp_tools.ts`). */
1373
+ function finish_tool() {
1374
+ return {
1299
1375
  name: "finish",
1300
1376
  description: "Declare the task complete. Calling this tool ends your turn — do not call it until the work is done and verified.",
1301
1377
  input_schema: finish_input,
@@ -1304,14 +1380,6 @@ function builder_tools(workspace, options = {}) {
1304
1380
  return `finished: ${finish_input.parse(raw).summary}`;
1305
1381
  }
1306
1382
  };
1307
- return [
1308
- ...read_only_tools(workspace),
1309
- write_file,
1310
- edit_file,
1311
- bash,
1312
- fetch,
1313
- finish
1314
- ];
1315
1383
  }
1316
1384
  //#endregion
1317
1385
  //#region src/cost.ts
@@ -1339,9 +1407,13 @@ function add_usage(a, b) {
1339
1407
  }
1340
1408
  return sum;
1341
1409
  }
1410
+ /** The providers whose calls carry the charge they report, which fascicle
1411
+ * records as the call's cost in place of a pricing table's estimate: the
1412
+ * Claude CLI's own total, and OpenRouter's per-response charge. */
1413
+ const REPORTING_PROVIDERS = /* @__PURE__ */ new Set(["claude_cli", "openrouter"]);
1342
1414
  function cost_source_of(result) {
1343
1415
  if (result.cost === void 0) return "unknown";
1344
- return result.model_resolved.provider === "claude_cli" ? "provider_reported" : "engine_derived";
1416
+ return REPORTING_PROVIDERS.has(result.model_resolved.provider) ? "provider_reported" : "engine_derived";
1345
1417
  }
1346
1418
  /** One phase's record from its generate result. The duration is fascicle's
1347
1419
  * call-level wall clock (`GenerateResult.timing`: every turn and the tools
@@ -1430,27 +1502,36 @@ async function prewarm_ollama_model(base_url, model, abort) {
1430
1502
  //#endregion
1431
1503
  //#region src/workspace.ts
1432
1504
  /**
1433
- * Workspace lifecycle: `.volley/` initialization, stale-run
1505
+ * Workspace lifecycle: run-state initialization, stale-run
1434
1506
  * backup rotation, path helpers, and optional git checkpoints.
1435
1507
  */
1436
- function volley_path(workspace, ...parts) {
1437
- return join(workspace, ".volley", ...parts);
1508
+ /** Where a run keeps its config, summary, trajectory, feedback, verdict, and
1509
+ * iterations: `<state_dir>/<run_id>/` under a state root, else
1510
+ * `<workspace>/.volley/`. */
1511
+ function run_state_dir(run) {
1512
+ return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
1513
+ }
1514
+ function state_path(run, ...parts) {
1515
+ return join(run_state_dir(run), ...parts);
1438
1516
  }
1439
- function iteration_dir(workspace, iteration) {
1440
- return volley_path(workspace, "iterations", String(iteration).padStart(3, "0"));
1517
+ function iteration_dir(run, iteration) {
1518
+ return state_path(run, "iterations", String(iteration).padStart(3, "0"));
1441
1519
  }
1442
1520
  /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
1443
- * `.volley/` rotation here and the worktree rotation so both preserve
1521
+ * run-state rotation here and the worktree rotation so both preserve
1444
1522
  * prior state under the same never-destroy naming. */
1445
1523
  function backup_stamp(date = /* @__PURE__ */ new Date()) {
1446
1524
  return date.toISOString().replace(/[:.]/g, "-");
1447
1525
  }
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 });
1526
+ /** Fresh-run initialization: rotate a stale state directory aside (a prior
1527
+ * `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
1528
+ * Under a state root each run has a directory of its own, so only a reused
1529
+ * `--run-id` ever finds one to rotate. Resume runs skip rotation
1530
+ * (`preserve: true`). */
1531
+ function initialize_run_state(run, options = {}) {
1532
+ const dir = run_state_dir(run);
1533
+ if (existsSync(dir) && options.preserve !== true) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
1534
+ mkdirSync(join(dir, "iterations"), { recursive: true });
1454
1535
  }
1455
1536
  function write_resolved_config(config) {
1456
1537
  const record = {
@@ -1479,9 +1560,10 @@ function write_resolved_config(config) {
1479
1560
  gate_paths: config.gate_paths,
1480
1561
  fail_on_gate_edit: config.fail_on_gate_edit,
1481
1562
  sandbox_image: config.sandbox_image,
1563
+ state_dir: config.state_dir,
1482
1564
  workspace: config.workspace
1483
1565
  };
1484
- writeFileSync(volley_path(config.workspace, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1566
+ writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1485
1567
  }
1486
1568
  /**
1487
1569
  * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
@@ -1879,7 +1961,7 @@ async function with_worktree(options, body) {
1879
1961
  /**
1880
1962
  * Builder invocation: one call to the builder leaf per iteration is one
1881
1963
  * complete agentic session in the workspace — Claude Code under `claude_cli`,
1882
- * or a local model driving volley's tools.
1964
+ * or a model driving volley's tools on any other provider, local or hosted.
1883
1965
  */
1884
1966
  const BUILDER_ALLOWED_TOOLS = [
1885
1967
  "Read",
@@ -1901,7 +1983,7 @@ function compose_builder_system() {
1901
1983
  }
1902
1984
  /** Walk up from this module to the package root (works from src/ under tsx
1903
1985
  * and from dist/ in the published package, which ships src/builder/presets). */
1904
- function presets_dir$1() {
1986
+ function builder_presets_dir() {
1905
1987
  let dir = dirname(fileURLToPath(import.meta.url));
1906
1988
  for (let depth = 0; depth < 6; depth += 1) {
1907
1989
  const candidate = join(dir, "src", "builder", "presets");
@@ -1910,36 +1992,48 @@ function presets_dir$1() {
1910
1992
  }
1911
1993
  throw config_error("builder presets directory not found");
1912
1994
  }
1913
- function compose_builder_system_local() {
1995
+ /** The harness append each tool set's builder reads: one naming volley's own
1996
+ * tools, or one naming fascicle's workspace tools under `volley acp` and
1997
+ * saying what a call the user refused means. */
1998
+ const HARNESS_APPENDS = {
1999
+ volley: "harness_append_local.md",
2000
+ acp: "harness_append_acp.md"
2001
+ };
2002
+ function compose_builder_system_local(tool_set = "volley") {
1914
2003
  return `${[
1915
2004
  "You are the builder inside the volley harness: an autonomous agent",
1916
2005
  "iterating on a workspace until it satisfies a task and its acceptance",
1917
2006
  "criteria."
1918
- ].join("\n")}\n\n${readFileSync(join(presets_dir$1(), "harness_append_local.md"), "utf8").trimEnd()}`;
2007
+ ].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
1919
2008
  }
1920
2009
  /** The builder system prompt for the active provider: the `claude_cli`
1921
2010
  * 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();
2011
+ * CLI builder, which never takes an injected set, or the local prompt naming
2012
+ * the seat's tool set for every provider that runs volley's tool loop,
2013
+ * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
2014
+ function resolve_builder_system(config, tool_set) {
2015
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
1927
2016
  }
1928
2017
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
1929
2018
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
1930
2019
  * The `claude_cli` builder is confined at the CLI permission layer (allowlist +
1931
2020
  * 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) {
2021
+ * from v2, and takes no injected tools or approval handler. Every other
2022
+ * builder, local or `openrouter`, brings none, so volley supplies its whole
2023
+ * workspace tool surface (or the set the caller injected in its place, with
2024
+ * the handler that answers its approvals) plus the five per-call loop knobs
2025
+ * and **no** schema (it produces a workspace, not a verdict); per-call values
2026
+ * win over engine defaults. */
2027
+ function builder_tool_options(deps) {
2028
+ const { config } = deps;
2029
+ const bash_executor = deps.bash_executor ?? null;
1937
2030
  if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
1938
2031
  allowed_tools: [...BUILDER_ALLOWED_TOOLS],
1939
2032
  extra_args: ["--permission-mode", config.builder_permission_mode]
1940
2033
  } } };
1941
2034
  return {
1942
- tools: builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}),
2035
+ tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
2036
+ ...deps.on_tool_approval !== void 0 ? { on_tool_approval: deps.on_tool_approval } : {},
1943
2037
  max_steps: config.builder_max_steps,
1944
2038
  tool_error_policy: "feed_back",
1945
2039
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
@@ -1976,9 +2070,9 @@ function make_builder_step(deps) {
1976
2070
  description: "model call: one agentic session in the build root",
1977
2071
  provider: config.builder_provider,
1978
2072
  model: config.builder_model,
1979
- system: resolve_builder_system(config),
2073
+ system: resolve_builder_system(config, deps.tool_set),
1980
2074
  on_chunk: deps.on_chunk,
1981
- ...builder_tool_options(config, deps.bash_executor ?? null)
2075
+ ...builder_tool_options(deps)
1982
2076
  });
1983
2077
  }
1984
2078
  async function run_builder(deps, state, ctx) {
@@ -2022,11 +2116,30 @@ function presets_dir() {
2022
2116
  }
2023
2117
  throw config_error("critic presets directory not found");
2024
2118
  }
2025
- function resolve_critic_prompt(config) {
2119
+ /** The harness append that names the critic's reads. The CLI critic reads
2120
+ * via built-in Read/Grep/Glob and never takes an injected set; every other
2121
+ * critic, local or `openrouter`, reads via volley's read_file/search_files/
2122
+ * list_files, or under `volley acp` via fascicle's read_file/list_files/
2123
+ * search. */
2124
+ function harness_append_file(config, tool_set) {
2125
+ if (config.critic_provider === "claude_cli") return "harness_append.md";
2126
+ return tool_set === "acp" ? "harness_append_acp.md" : "harness_append_local.md";
2127
+ }
2128
+ function resolve_critic_prompt(config, tool_set = "volley") {
2026
2129
  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()}`;
2130
+ const append = readFileSync(join(presets_dir(), harness_append_file(config, tool_set)), "utf8");
2131
+ return `${base.trimEnd()}\n\n${place_iterations_dir(append, config).trimEnd()}`;
2132
+ }
2133
+ const ITERATIONS_DIR = "{{iterations_dir}}";
2134
+ /** Fill the harness append's line about prior iteration state with where the
2135
+ * run's `iterations/` sits, relative to the build root the critic reads from,
2136
+ * or drop the line when the run's state is outside that root: a confined
2137
+ * critic can't reach it, and naming it would only spend the critic's steps on
2138
+ * a path it can't open. */
2139
+ function place_iterations_dir(append, config) {
2140
+ const path = relative(build_root(config.workspace, config.worktree), state_path(config, "iterations"));
2141
+ const outside = path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path);
2142
+ return append.split("\n").filter((line) => !(outside && line.includes(ITERATIONS_DIR))).join("\n").replaceAll(ITERATIONS_DIR, `${path}/`);
2030
2143
  }
2031
2144
  /** One-letter status column, so a hundred-path list stays readable. */
2032
2145
  const STATUS_MARKS = {
@@ -2106,8 +2219,9 @@ function compose_critic_prompt(input) {
2106
2219
  //#endregion
2107
2220
  //#region src/critic/run.ts
2108
2221
  /**
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
2222
+ * Critic invocation: a read-only session (`claude_cli`, or a local or
2223
+ * `openrouter` model with volley's read tools) returning a schema-validated
2224
+ * structured verdict. The
2111
2225
  * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2112
2226
  */
2113
2227
  const CRITIC_ALLOWED_TOOLS = [
@@ -2122,12 +2236,12 @@ const verdict_schema = z.object({
2122
2236
  unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2123
2237
  });
2124
2238
  /** 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.
2239
+ * the CLI permission layer (allowlist + explicit disallow); every other critic,
2240
+ * local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
2241
+ * the set the caller injected in their place) and no write path at all.
2128
2242
  *
2129
- * The two providers also enforce `verdict_schema` by two different paths, both
2130
- * verified live. The claude_cli critic compiles the schema for
2243
+ * The providers also enforce `verdict_schema` by different paths, the first
2244
+ * two verified live. The claude_cli critic compiles the schema for
2131
2245
  * `claude --json-schema`; that only works because fascicle 0.14.0's
2132
2246
  * `compile_schema` strips the top-level `$schema`/`$id` that zod v4 stamps (the
2133
2247
  * CLI rejects them) — `live_smoke` asserts a structured verdict comes back, so a
@@ -2137,16 +2251,22 @@ const verdict_schema = z.object({
2137
2251
  * structurally guaranteed at decode time, with no prompt-parse-repair;
2138
2252
  * `live_local_builder` exercises that path. Unchanged in v3: on ai_sdk
2139
2253
  * 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) {
2254
+ * would move to `provider_options.ollama.format` (parked). An `openrouter`
2255
+ * critic rides fascicle's native OpenAI-compatible core, which has no
2256
+ * constrained decode: the schema travels in the prompt, and the reply is parsed
2257
+ * and repaired. */
2258
+ function critic_tool_options(config, tools) {
2142
2259
  if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
2143
2260
  allowed_tools: [...CRITIC_ALLOWED_TOOLS],
2144
2261
  extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
2145
2262
  } } };
2146
- return { tools: read_only_tools(build_root(config.workspace, config.worktree)) };
2263
+ return {
2264
+ tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
2265
+ max_steps: 10
2266
+ };
2147
2267
  }
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
2268
+ /** Is this critic failure the transient provider stream death the ladder
2269
+ * retries? Every provider but `claude_cli` (that path is proven and its
2150
2270
  * retries cost real money), and fascicle's typed `provider_error` only (no
2151
2271
  * message string-matching). A user abort never gets here: `retry` and
2152
2272
  * `fallback` pass control-flow signals through untouched, and `retry` checks the
@@ -2155,6 +2275,23 @@ function is_retryable_critic_error(config, err) {
2155
2275
  if (config.critic_provider === "claude_cli") return false;
2156
2276
  return error_kind(err) === "provider_error";
2157
2277
  }
2278
+ /** Did a schema call end without a verdict because the tool loop ran out —
2279
+ * its step budget spent, or its last turn a tool call — rather than because
2280
+ * the response was cut off or blocked? fascicle throws
2281
+ * `incomplete_generation_error` for every such finish; only these two are
2282
+ * fixed by taking the tools away. Matched on `kind` and `finish_reason`. */
2283
+ function is_step_exhaustion(err) {
2284
+ if (error_kind(err) !== "incomplete_generation_error") return false;
2285
+ const finish = err.finish_reason;
2286
+ return finish === "max_steps" || finish === "tool_calls";
2287
+ }
2288
+ /** Does this failure send a local critic to the tool-less rung? A stream death
2289
+ * the retry rung gave up on, or step exhaustion, which no retry would fix. A
2290
+ * `length` or `content_filter` finish is neither, and stays fatal. */
2291
+ function is_degradable_critic_error(config, err) {
2292
+ if (config.critic_provider === "claude_cli") return false;
2293
+ return error_kind(err) === "provider_error" || is_step_exhaustion(err);
2294
+ }
2158
2295
  /** fascicle's `provider_error.cause_kind` is `... | undefined`; fold the
2159
2296
  * missing case into `'unknown'` so a recorded retry always names a cause. */
2160
2297
  function retry_cause_kind_of(err) {
@@ -2173,7 +2310,8 @@ function finalize_critic(state, result, model, mark, duration_ms) {
2173
2310
  ...next.critic,
2174
2311
  retries: mark.retries,
2175
2312
  ...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
2176
- ...mark.critic_degraded === true ? { critic_degraded: true } : {}
2313
+ ...mark.critic_degraded === true ? { critic_degraded: true } : {},
2314
+ ...mark.degrade_cause !== void 0 ? { degrade_cause: mark.degrade_cause } : {}
2177
2315
  },
2178
2316
  verdict: result.content.verdict,
2179
2317
  feedback: result.content.feedback,
@@ -2203,7 +2341,7 @@ function critic_call_config(deps) {
2203
2341
  engine: deps.engine,
2204
2342
  provider: config.critic_provider,
2205
2343
  model: config.critic_model,
2206
- system: resolve_critic_prompt(config),
2344
+ system: resolve_critic_prompt(config, deps.tool_set),
2207
2345
  schema: verdict_schema,
2208
2346
  on_chunk: deps.on_chunk
2209
2347
  };
@@ -2215,7 +2353,7 @@ function make_critic_tools_step(deps) {
2215
2353
  ...critic_call_config(deps),
2216
2354
  id: "critic_tools",
2217
2355
  description: "model call with read-only workspace tools",
2218
- ...critic_tool_options(deps.config)
2356
+ ...critic_tool_options(deps.config, deps.tools)
2219
2357
  });
2220
2358
  }
2221
2359
  /** Rung 2's leaf: the same critic with no tools at all, so nothing enters
@@ -2228,9 +2366,28 @@ function make_critic_toolless_step(deps) {
2228
2366
  description: "model call judging from the check output and a file list"
2229
2367
  });
2230
2368
  }
2231
- /** The ladder's retry predicate for this run's critic provider. */
2369
+ const ladder_traces = /* @__PURE__ */ new WeakMap();
2370
+ /** The ladder's retry predicate for this run's critic provider: stream deaths
2371
+ * only. It also traces every failure it sees, so a degraded verdict can say how
2372
+ * many retries preceded it and why. A first attempt starts a fresh critique, so
2373
+ * it forgets the last one's retries. */
2232
2374
  function critic_retryable(config) {
2233
- return (err) => is_retryable_critic_error(config, err);
2375
+ let retried_cause;
2376
+ return (err, attempt) => {
2377
+ if (attempt === 1) retried_cause = void 0;
2378
+ if (typeof err === "object" && err !== null) ladder_traces.set(err, {
2379
+ attempt,
2380
+ retried_cause
2381
+ });
2382
+ const retryable = is_retryable_critic_error(config, err);
2383
+ if (retryable) retried_cause = retry_cause_kind_of(err);
2384
+ return retryable;
2385
+ };
2386
+ }
2387
+ /** The ladder's fallback predicate: a stream death the retry rung gave up on,
2388
+ * or step exhaustion, which it never retries. */
2389
+ function critic_degradable(config) {
2390
+ return (err) => is_degradable_critic_error(config, err);
2234
2391
  }
2235
2392
  /** Rung 1's projection: a verdict the tool-bearing critic produced, marked with
2236
2393
  * how many stream deaths it took and the last one's cause. */
@@ -2244,27 +2401,42 @@ function with_retries(outcome) {
2244
2401
  }
2245
2402
  };
2246
2403
  }
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. */
2404
+ /** Rung 2's answer, marked degraded so it never passes silently as a full
2405
+ * critique. Why it degraded, and the retries before it, are the ladder's to
2406
+ * stamp (`with_fallback_cause`), since the tool-less call can't see them. */
2249
2407
  function as_degraded(result) {
2250
2408
  return {
2251
2409
  result,
2252
2410
  mark: {
2253
- retries: 1,
2411
+ retries: 0,
2254
2412
  retry_cause_kind: void 0,
2255
2413
  critic_degraded: true
2256
2414
  }
2257
2415
  };
2258
2416
  }
2259
2417
  /** 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. */
2418
+ * mark comes from the error that sent it there.
2419
+ *
2420
+ * - A stream death the retry rung gave up on: every retry was spent, and the
2421
+ * cause is that error's own.
2422
+ * - Step exhaustion, never retried: the retries are the stream deaths before
2423
+ * it, usually none, and the cause is the last of those.
2424
+ *
2425
+ * An error the retry rung never traced reads as one that spent every retry. */
2261
2426
  function with_fallback_cause(outcome) {
2262
2427
  if (outcome.source === "primary") return outcome.value;
2428
+ const err = outcome.primary_error;
2429
+ const trace = typeof err === "object" && err !== null ? ladder_traces.get(err) : void 0;
2430
+ const retries = trace === void 0 ? 1 : trace.attempt - 1;
2431
+ const exhausted = is_step_exhaustion(err);
2432
+ const retry_cause_kind = exhausted ? trace?.retried_cause : retry_cause_kind_of(err);
2263
2433
  return {
2264
2434
  ...outcome.value,
2265
2435
  mark: {
2266
2436
  ...outcome.value.mark,
2267
- retry_cause_kind: retry_cause_kind_of(outcome.primary_error)
2437
+ retries,
2438
+ retry_cause_kind,
2439
+ degrade_cause: exhausted ? "step_exhaustion" : "stream_error"
2268
2440
  }
2269
2441
  };
2270
2442
  }
@@ -2374,14 +2546,50 @@ function status_of(value, converged) {
2374
2546
  */
2375
2547
  const CHECK_ARTIFACT_MAX_CHARS = 8e3;
2376
2548
  const CHECKRIDE_SCHEMA_VERSION = 1;
2549
+ /** How long a check's output may stay open after its shell exits, while
2550
+ * something that escaped its process group still holds it, before the reads
2551
+ * stop so the check can finish. */
2552
+ const OUTPUT_GRACE_MS = 2e3;
2553
+ /** Kill a check's whole process group with SIGKILL. A test runner's workers,
2554
+ * and anything the check started in the background, sit in the shell's group,
2555
+ * so killing the shell alone would leave them running. Windows has no process
2556
+ * groups to signal, so there only the shell goes. */
2557
+ function kill_group(proc) {
2558
+ if (proc.pid === void 0) return;
2559
+ try {
2560
+ if (process.platform === "win32") proc.kill("SIGKILL");
2561
+ else process.kill(-proc.pid, "SIGKILL");
2562
+ } catch {}
2563
+ }
2564
+ /** Run a check to its end in a process group of its own, capturing its
2565
+ * output. The abort kills the whole group and rejects with `aborted_error`,
2566
+ * and the shell's exit kills whatever it left behind, so a stopped run leaves
2567
+ * no test workers or servers running. */
2377
2568
  function spawn_capture(cmd, args, options) {
2569
+ const { abort } = options;
2570
+ if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
2378
2571
  return new Promise((resolve_promise, reject) => {
2379
2572
  const proc = spawn(cmd, args, {
2380
2573
  cwd: options.cwd,
2381
- ...options.abort !== void 0 ? { signal: options.abort } : {}
2574
+ ...options.env !== void 0 ? { env: options.env } : {},
2575
+ detached: process.platform !== "win32",
2576
+ stdio: [
2577
+ "ignore",
2578
+ "pipe",
2579
+ "pipe"
2580
+ ]
2382
2581
  });
2383
2582
  let stdout = "";
2384
2583
  let stderr = "";
2584
+ let grace;
2585
+ const on_abort = () => {
2586
+ kill_group(proc);
2587
+ reject(new aborted_error("check aborted"));
2588
+ };
2589
+ const finish = () => {
2590
+ clearTimeout(grace);
2591
+ abort?.removeEventListener("abort", on_abort);
2592
+ };
2385
2593
  proc.stdout.on("data", (chunk) => {
2386
2594
  stdout += chunk.toString();
2387
2595
  });
@@ -2389,19 +2597,25 @@ function spawn_capture(cmd, args, options) {
2389
2597
  stderr += chunk.toString();
2390
2598
  });
2391
2599
  proc.on("error", (err) => {
2392
- if (err.name === "AbortError") {
2393
- reject(new aborted_error("check aborted"));
2394
- return;
2395
- }
2600
+ finish();
2396
2601
  reject(check_error(`check command failed to start: ${err.message}`));
2397
2602
  });
2603
+ proc.on("exit", () => {
2604
+ kill_group(proc);
2605
+ grace = setTimeout(() => {
2606
+ proc.stdout.destroy();
2607
+ proc.stderr.destroy();
2608
+ }, OUTPUT_GRACE_MS);
2609
+ });
2398
2610
  proc.on("close", (code) => {
2611
+ finish();
2399
2612
  resolve_promise({
2400
2613
  exit_code: code,
2401
2614
  stdout,
2402
2615
  stderr
2403
2616
  });
2404
2617
  });
2618
+ abort?.addEventListener("abort", on_abort, { once: true });
2405
2619
  });
2406
2620
  }
2407
2621
  /** Best-effort parse of the summary contract. `pnpm exec` talks over the
@@ -2465,13 +2679,14 @@ function read_failing_artifacts(workspace, failing) {
2465
2679
  });
2466
2680
  }
2467
2681
  async function run_checkride(opts) {
2682
+ const { workspace, ...spawn_options } = opts;
2468
2683
  const proc = await spawn_capture("pnpm", [
2469
2684
  "exec",
2470
2685
  "checkride",
2471
2686
  "--json"
2472
2687
  ], {
2473
- cwd: opts.workspace,
2474
- ...opts.abort !== void 0 ? { abort: opts.abort } : {}
2688
+ ...spawn_options,
2689
+ cwd: workspace
2475
2690
  });
2476
2691
  if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
2477
2692
  const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
@@ -2495,12 +2710,13 @@ async function run_checkride(opts) {
2495
2710
  * (exit 4), not an iterable failure.
2496
2711
  */
2497
2712
  async function run_command_check(opts) {
2713
+ const { command, workspace, ...spawn_options } = opts;
2498
2714
  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 } : {}
2715
+ const proc = await spawn_capture("sh", ["-c", command], {
2716
+ ...spawn_options,
2717
+ cwd: workspace
2502
2718
  });
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}`);
2719
+ if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
2504
2720
  const log = `${proc.stdout}${proc.stderr}`;
2505
2721
  return {
2506
2722
  ran: true,
@@ -2524,6 +2740,23 @@ function skipped_check(reason) {
2524
2740
  detail: []
2525
2741
  };
2526
2742
  }
2743
+ /** What a rejected check says in place of its output. */
2744
+ const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
2745
+ /** A check the user rejected when it asked to run: recorded as a failed
2746
+ * check, with the refusal as its output, so a round can't converge on work
2747
+ * nothing checked, and the critic hears why there's no result. */
2748
+ function rejected_check(runner) {
2749
+ return {
2750
+ ran: true,
2751
+ runner,
2752
+ ok: false,
2753
+ exit_code: null,
2754
+ duration_ms: 0,
2755
+ failing_slots: [],
2756
+ detail: [],
2757
+ log: CHECK_REJECTED
2758
+ };
2759
+ }
2527
2760
  //#endregion
2528
2761
  //#region src/iteration.ts
2529
2762
  /**
@@ -2534,9 +2767,9 @@ function skipped_check(reason) {
2534
2767
  function write_json(path, value) {
2535
2768
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
2536
2769
  }
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`);
2770
+ function persist_verdict_files(config, feedback, verdict) {
2771
+ writeFileSync(state_path(config, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}\n`);
2772
+ writeFileSync(state_path(config, "verdict"), `${verdict}\n`);
2540
2773
  }
2541
2774
  function phase_summary(record) {
2542
2775
  if (record === null) return null;
@@ -2555,17 +2788,18 @@ function phase_summary(record) {
2555
2788
  ...record.retries !== void 0 ? { retries: record.retries } : {},
2556
2789
  ...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
2557
2790
  ...record.critic_degraded === true ? { critic_degraded: true } : {},
2791
+ ...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
2558
2792
  ...record.throughput !== void 0 ? { throughput: record.throughput } : {}
2559
2793
  };
2560
2794
  }
2561
2795
  /** Archive one completed iteration: verdict files, check artifacts, and the
2562
2796
  * per-iteration summary. */
2563
2797
  function archive_iteration(config, state) {
2564
- const dir = iteration_dir(config.workspace, state.iteration);
2798
+ const dir = iteration_dir(config, state.iteration);
2565
2799
  mkdirSync(join(dir, "check"), { recursive: true });
2566
2800
  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")));
2801
+ persist_verdict_files(config, state.feedback, state.verdict);
2802
+ writeFileSync(join(dir, "feedback.md"), readFileSync(state_path(config, "feedback.md")));
2569
2803
  writeFileSync(join(dir, "verdict"), `${state.verdict}\n`);
2570
2804
  }
2571
2805
  const check = state.check;
@@ -2620,7 +2854,7 @@ function run_result_from_state(config, state, status, completed_at) {
2620
2854
  //#endregion
2621
2855
  //#region src/summary.ts
2622
2856
  /**
2623
- * Enriched run summary (`.volley/summary.json`): the `RunResult` projection plus
2857
+ * Enriched run summary (the run's `summary.json`): the `RunResult` projection plus
2624
2858
  * a `comparison` block that makes the two blessed examples' runs diff-able.
2625
2859
  * The block gathers the seven comparison metrics — transport,
2626
2860
  * iterations-to-converge, wall-clock, cost, verdict, the deterministic check
@@ -2635,22 +2869,27 @@ function run_result_from_state(config, state, status, completed_at) {
2635
2869
  * surviving branch on `salvaged_branch`, which teardown only names after the
2636
2870
  * final status is known.
2637
2871
  * Each write recomputes the comparison from what is currently archived under
2638
- * `.volley/iterations/`, so the trajectory grows as the run does.
2872
+ * the run's `iterations/`, so the trajectory grows as the run does.
2639
2873
  */
2640
2874
  function transport_of(provider) {
2641
- return provider === "claude_cli" ? "claude_cli" : "ai_sdk";
2875
+ switch (provider) {
2876
+ case "claude_cli": return "claude_cli";
2877
+ case "openrouter": return "native";
2878
+ case "ollama":
2879
+ case "lmstudio": return "ai_sdk";
2880
+ }
2642
2881
  }
2643
2882
  /** Read the archived per-iteration summaries in order. A missing archive root
2644
2883
  * (a run that failed before the first `record`) or an unreadable entry yields an
2645
2884
  * empty/short list — the run summary still writes, just without that trajectory
2646
2885
  * detail. */
2647
- function read_iteration_archives(workspace) {
2648
- const root = volley_path(workspace, "iterations");
2886
+ function read_iteration_archives(config) {
2887
+ const root = state_path(config, "iterations");
2649
2888
  if (!existsSync(root)) return [];
2650
2889
  const dirs = readdirSync(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
2651
2890
  const archives = [];
2652
2891
  for (const dir of dirs) {
2653
- const path = volley_path(workspace, "iterations", dir, "summary.json");
2892
+ const path = state_path(config, "iterations", dir, "summary.json");
2654
2893
  if (!existsSync(path)) continue;
2655
2894
  try {
2656
2895
  archives.push(JSON.parse(readFileSync(path, "utf8")));
@@ -2672,6 +2911,13 @@ function check_trajectory(archives) {
2672
2911
  function any_critic_degraded(archives) {
2673
2912
  return archives.some((archive) => archive.critic?.critic_degraded === true);
2674
2913
  }
2914
+ /** Each distinct reason an archived critic verdict degraded. An archive from
2915
+ * before the cause was recorded reads as a stream error, the only way a verdict
2916
+ * could degrade then. */
2917
+ function critic_degrade_causes(archives) {
2918
+ const causes = archives.filter((archive) => archive.critic?.critic_degraded === true).map((archive) => archive.critic?.degrade_cause ?? "stream_error");
2919
+ return [...new Set(causes)];
2920
+ }
2675
2921
  /** Every gate path the run's builder touched, across all iterations: an edit in
2676
2922
  * iteration 1 that a later iteration reverted still happened, so the run-level
2677
2923
  * answer is the union rather than the last iteration's list. */
@@ -2697,7 +2943,7 @@ function salvage_stats(archives) {
2697
2943
  * archive for the check trajectory and salvage aggregate. Pure over its inputs
2698
2944
  * plus the on-disk archive; `write_run_summary` persists the result. */
2699
2945
  function build_run_summary(config, result) {
2700
- const archives = read_iteration_archives(config.workspace);
2946
+ const archives = read_iteration_archives(config);
2701
2947
  const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
2702
2948
  return {
2703
2949
  ...result,
@@ -2714,17 +2960,54 @@ function build_run_summary(config, result) {
2714
2960
  gate_edits: all_gate_edits(archives),
2715
2961
  unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
2716
2962
  local_salvage: salvage_stats(archives),
2717
- critic_degraded: any_critic_degraded(archives)
2963
+ critic_degraded: any_critic_degraded(archives),
2964
+ critic_degrade_causes: critic_degrade_causes(archives)
2718
2965
  }
2719
2966
  };
2720
2967
  }
2721
- /** Write the enriched summary to `.volley/summary.json` (the projection the
2968
+ /** Write the enriched summary to the run's `summary.json` (the projection the
2722
2969
  * examples' comparison is read from). */
2723
2970
  function write_run_summary(config, result) {
2724
- writeFileSync(volley_path(config.workspace, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
2971
+ writeFileSync(state_path(config, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
2725
2972
  }
2726
2973
  //#endregion
2727
2974
  //#region src/phases.ts
2975
+ /**
2976
+ * The step bodies behind the nodes of `./flow.ts`, one function per node. Each
2977
+ * takes the loop's carry-state and returns the next one. The phase modules they
2978
+ * call (`./builder.ts`, `./check/`, `./critic/`) do the work; these bodies add
2979
+ * what surrounds it: the renderer lines an operator watches, change detection,
2980
+ * git checkpoints, the per-iteration archive, and the progress events a
2981
+ * trajectory reader follows the run by.
2982
+ */
2983
+ /** The step id the check's card is recorded under: the check step's own, in
2984
+ * `./flow.ts`, so each round's check counts as a fresh run of it and opens a
2985
+ * card of its own. */
2986
+ const CHECK_STEP_ID = "check";
2987
+ /** The raw call id and tool name of the check's card. The name sits outside
2988
+ * the workspace tools, so nothing dresses the card as one of theirs. */
2989
+ const CHECK_CALL = {
2990
+ id: "check",
2991
+ name: "check"
2992
+ };
2993
+ function emit(ctx, event) {
2994
+ ctx.emit(event);
2995
+ }
2996
+ /** Record one chunk of the check's card, the way `model_call` records a tool
2997
+ * call's, under a streamed run only, since nothing reads a card otherwise. */
2998
+ function record_check_chunk(ctx, chunk) {
2999
+ if (ctx.streaming) ctx.trajectory.record({
3000
+ kind: "model_chunk",
3001
+ step_id: CHECK_STEP_ID,
3002
+ chunk
3003
+ });
3004
+ }
3005
+ /** The command line the check runs, or null when no check is configured. */
3006
+ function check_command(config) {
3007
+ if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
3008
+ if (config.check_resolved === "command") return config.check;
3009
+ return null;
3010
+ }
2728
3011
  /** How many gate edits a warning names before it summarises the rest. */
2729
3012
  const LISTED_GATE_EDITS = 5;
2730
3013
  /**
@@ -2742,18 +3025,42 @@ function report_changes(config, renderer, changes) {
2742
3025
  const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
2743
3026
  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)." : ""));
2744
3027
  }
2745
- async function execute_check(config, abort) {
3028
+ /** Run the configured check, checkride or a command, with the environment
3029
+ * the caller set for it, else volley's own. */
3030
+ async function execute_check(deps, abort) {
3031
+ const { config } = deps;
2746
3032
  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({
3033
+ const options = {
3034
+ abort,
3035
+ ...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
3036
+ };
3037
+ return config.check_resolved === "checkride" ? run_checkride({
3038
+ ...options,
3039
+ workspace: root
3040
+ }) : run_command_check({
3041
+ ...options,
2752
3042
  command: config.check,
2753
- workspace: root,
3043
+ workspace: root
3044
+ });
3045
+ }
3046
+ /** Whether the check may run `command`: yes, unless the caller set an
3047
+ * approval and it answers no. It asks on the check's card under the command
3048
+ * line as its tool name, which titles the prompt the way the card is titled,
3049
+ * and which names none of fascicle's gated tools, so the prompt offers Allow
3050
+ * and Reject alone, never an "always" that would cover the builder's commands
3051
+ * too. A stop while it asks ends the check as a stop, not a rejection. */
3052
+ async function check_allowed(deps, command, abort) {
3053
+ const approve = deps.check?.on_approval;
3054
+ if (approve === void 0) return true;
3055
+ const allowed = await approve({
3056
+ tool_name: command,
3057
+ input: { command },
3058
+ tool_call_id: CHECK_CALL.id,
3059
+ step_index: 0,
2754
3060
  abort
2755
3061
  });
2756
- return skipped_check("none");
3062
+ if (abort.aborted) throw new aborted_error("check aborted");
3063
+ return allowed;
2757
3064
  }
2758
3065
  /** Open the next iteration: advance the counter and clear every per-iteration
2759
3066
  * field, so nothing from the last round leaks into this one. */
@@ -2775,6 +3082,10 @@ function next_iteration(s) {
2775
3082
  async function build_phase(deps, builder, s, ctx) {
2776
3083
  const { config, renderer } = deps;
2777
3084
  const next = next_iteration(s);
3085
+ emit(ctx, {
3086
+ volley: "round_start",
3087
+ round: next.iteration
3088
+ });
2778
3089
  renderer.phase_start(next.iteration, "builder");
2779
3090
  const built = await run_builder({
2780
3091
  builder,
@@ -2806,20 +3117,79 @@ function skip_verify_phase(deps, s) {
2806
3117
  check: skipped_check("cost_cap")
2807
3118
  };
2808
3119
  }
2809
- /** `check`: the deterministic gate over the tree the builder wrote. */
3120
+ /** Open the check's card on a streamed run: the call's start, then the
3121
+ * command as its input, the two chunks `model_call` records for a tool call
3122
+ * before it runs. */
3123
+ function open_check_card(ctx, command) {
3124
+ record_check_chunk(ctx, {
3125
+ kind: "tool_call_start",
3126
+ ...CHECK_CALL,
3127
+ step_index: 0
3128
+ });
3129
+ record_check_chunk(ctx, {
3130
+ kind: "tool_call_end",
3131
+ id: CHECK_CALL.id,
3132
+ input: { command },
3133
+ step_index: 0
3134
+ });
3135
+ }
3136
+ /** Close the check's card with its result, which the card's reader renders,
3137
+ * or with the error that stopped the check before it had one. */
3138
+ function close_check_card(ctx, outcome) {
3139
+ record_check_chunk(ctx, "result" in outcome ? {
3140
+ kind: "tool_result",
3141
+ id: CHECK_CALL.id,
3142
+ output: outcome.result,
3143
+ step_index: 0
3144
+ } : {
3145
+ kind: "tool_result",
3146
+ id: CHECK_CALL.id,
3147
+ error: { message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) },
3148
+ step_index: 0
3149
+ });
3150
+ }
3151
+ /** `check`: the deterministic gate over the tree the builder wrote. A check
3152
+ * that runs a command emits its start and end, and on a streamed run also
3153
+ * records its card as a tool call, so a reader of the stream shows it running
3154
+ * while it runs. When the caller set an approval, the check asks on that card
3155
+ * first, and a rejected check closes it failed and is recorded as failed. */
2810
3156
  async function check_phase(deps, s, ctx) {
2811
3157
  const { config, renderer } = deps;
2812
3158
  renderer.phase_start(s.iteration, "check");
3159
+ const command = check_command(config);
3160
+ if (command === null) {
3161
+ renderer.info("no deterministic check configured; loop is critic-gated only");
3162
+ return {
3163
+ ...s,
3164
+ check: skipped_check("none")
3165
+ };
3166
+ }
3167
+ emit(ctx, {
3168
+ volley: "check_start",
3169
+ round: s.iteration,
3170
+ command
3171
+ });
3172
+ open_check_card(ctx, command);
2813
3173
  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");
3174
+ const allowed = await check_allowed(deps, command, ctx.abort);
3175
+ const result = allowed ? await execute_check(deps, ctx.abort) : rejected_check(config.check_resolved);
3176
+ close_check_card(ctx, allowed ? { result } : { error: CHECK_REJECTED });
3177
+ emit(ctx, {
3178
+ volley: "check_end",
3179
+ round: s.iteration,
3180
+ ok: result.ok,
3181
+ exit_code: result.exit_code,
3182
+ failing_slots: result.failing_slots,
3183
+ duration_ms: result.duration_ms
3184
+ });
3185
+ renderer.phase_end(s.iteration, "check", result.ok, !allowed ? "rejected" : result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0);
2817
3186
  return {
2818
3187
  ...s,
2819
3188
  check: result,
2820
3189
  check_duration_ms: s.check_duration_ms + result.duration_ms
2821
3190
  };
2822
3191
  } catch (err) {
3192
+ close_check_card(ctx, { error: err });
2823
3193
  if (error_kind(err) === "check_error") throw err;
2824
3194
  throw phase_error("check", s.iteration, err);
2825
3195
  }
@@ -2832,6 +3202,14 @@ async function critique_phase(deps, critic, s, ctx) {
2832
3202
  critic,
2833
3203
  config: deps.config
2834
3204
  }, s, ctx);
3205
+ if (critiqued.verdict !== null) emit(ctx, {
3206
+ volley: "verdict",
3207
+ round: critiqued.iteration,
3208
+ verdict: critiqued.verdict,
3209
+ feedback: critiqued.feedback ?? "",
3210
+ unmet_criteria: critiqued.unmet_criteria,
3211
+ degraded: critiqued.critic?.critic_degraded === true
3212
+ });
2835
3213
  renderer.phase_end(critiqued.iteration, "critic", critiqued.verdict === "approved", critiqued.verdict ?? void 0);
2836
3214
  renderer.cost_line(critiqued.iteration, "critic", critiqued.critic?.cost_usd ?? null, critiqued.total_cost_usd);
2837
3215
  return critiqued;
@@ -2867,30 +3245,32 @@ function record_phase(deps, s) {
2867
3245
  * │ │ └─ else sequence
2868
3246
  * │ │ ├─ check the deterministic gate: checkride, a command, or none
2869
3247
  * │ │ └─ 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
3248
+ * │ │ └─ critic fallback: judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps
3249
+ * │ │ ├─ retry one more try after a stream death, on any provider but claude_cli
2872
3250
  * │ │ │ └─ critic_tools model call with read-only workspace tools
2873
3251
  * │ │ └─ pipe mark the verdict degraded
2874
3252
  * │ │ └─ critic_toolless model call judging from the check output and a file list
2875
3253
  * │ └─ record archive the iteration and rewrite the run summary
2876
3254
  * └─ guard gate stop on approval over a green check, the cost cap, or a gate edit
2877
3255
  */
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. */
3256
+ /** The critic arm: the degradation ladder. A critic on any provider but
3257
+ * `claude_cli` whose tool-bearing stream dies is retried at once (the death is
3258
+ * stochastic parser failure, not load, so waiting buys nothing), then judged
3259
+ * once without tools. One that runs out of steps skips the retry, since
3260
+ * another identical tool loop would only run out again, and goes straight to
3261
+ * the tool-less pass. Anything else propagates from either rung untouched. */
2882
3262
  function build_critic(deps) {
2883
3263
  const retryable = critic_retryable(deps.config);
2884
3264
  return fallback(retry(make_critic_tools_step(deps), {
2885
- description: "one more try after a stream death, local providers only",
3265
+ description: "one more try after a stream death, on any provider but claude_cli",
2886
3266
  max_attempts: 2,
2887
3267
  backoff_ms: 0,
2888
3268
  when: retryable,
2889
3269
  project: with_retries
2890
3270
  }), pipe(make_critic_toolless_step(deps), as_degraded, { description: "mark the verdict degraded" }), {
2891
3271
  name: "critic",
2892
- description: "judge without tools if a local critic's stream keeps dying",
2893
- when: retryable,
3272
+ description: "judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps",
3273
+ when: critic_degradable(deps.config),
2894
3274
  handoff: (prompt) => toolless_critic_prompt(deps.config, prompt),
2895
3275
  project: with_fallback_cause
2896
3276
  });
@@ -2898,13 +3278,13 @@ function build_critic(deps) {
2898
3278
  function build_flow(deps, resume_from) {
2899
3279
  const { config, renderer } = deps;
2900
3280
  const builder = make_builder_step({
2901
- engine: deps.engines.builder,
3281
+ ...deps.seats.builder,
2902
3282
  config,
2903
3283
  on_chunk: renderer.builder_chunk,
2904
3284
  bash_executor: deps.bash_executor
2905
3285
  });
2906
3286
  const critic = build_critic({
2907
- engine: deps.engines.critic,
3287
+ ...deps.seats.critic,
2908
3288
  config,
2909
3289
  on_chunk: renderer.critic_chunk
2910
3290
  });
@@ -2954,19 +3334,19 @@ function build_flow(deps, resume_from) {
2954
3334
  * substrate.
2955
3335
  *
2956
3336
  * run_volley
2957
- * ├─ initialize the workspace (or preserve it, on resume)
3337
+ * ├─ initialize the run's state (or preserve it, on resume)
2958
3338
  * ├─ with_worktree off unless --worktree
2959
3339
  * │ ├─ capture the change baseline
2960
- * │ ├─ run_loop run(build_flow(...)), then the final summary
3340
+ * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
2961
3341
  * │ └─ integrate a successful --worktree --git run only
2962
3342
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
2963
3343
  */
2964
3344
  async function run_volley(config, deps, resume_from = null) {
2965
3345
  const { renderer } = deps;
2966
3346
  if (resume_from === null) {
2967
- initialize_workspace(config.workspace);
3347
+ initialize_run_state(config);
2968
3348
  write_resolved_config(config);
2969
- } else initialize_workspace(config.workspace, { preserve: true });
3349
+ } else initialize_run_state(config, { preserve: true });
2970
3350
  report_worktree_fate(config, renderer);
2971
3351
  const branch = worktree_branch(config.run_id);
2972
3352
  const fate = worktree_fate(config);
@@ -3007,18 +3387,24 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
3007
3387
  critic: deps.engine
3008
3388
  };
3009
3389
  const flow = build_flow({
3010
- engines,
3390
+ seats: {
3391
+ builder: {
3392
+ ...deps.seats?.builder,
3393
+ engine: engines.builder
3394
+ },
3395
+ critic: {
3396
+ ...deps.seats?.critic,
3397
+ engine: engines.critic
3398
+ }
3399
+ },
3011
3400
  config,
3012
3401
  renderer,
3013
3402
  bash_executor,
3014
- baseline
3403
+ baseline,
3404
+ ...deps.check !== void 0 ? { check: deps.check } : {}
3015
3405
  }, resume_from);
3016
3406
  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
- });
3407
+ const { value, converged } = await execute_flow(config, deps, flow, { resume_from });
3022
3408
  const result = run_result_from_state(config, value, status_of(value, converged), (/* @__PURE__ */ new Date()).toISOString());
3023
3409
  write_run_summary(config, result);
3024
3410
  return result;
@@ -3029,6 +3415,45 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
3029
3415
  await dispose_seat_engines(engines);
3030
3416
  }
3031
3417
  }
3418
+ /** Run the flow into its file trajectory and, when the caller passed one, its
3419
+ * own sink. Without a sink this is the one `run` it has always been. With one,
3420
+ * it goes through `run.stream`, because only a streamed run records each model
3421
+ * call's chunks as `model_chunk` events, which are what an outer consumer maps
3422
+ * to text, tool cards, and approval prompts. The sink is that consumer, so the
3423
+ * handle's own event stream is closed at once rather than left to buffer, and
3424
+ * the file skips the chunks, so it holds the same kinds of event either way.
3425
+ *
3426
+ * The file goes first in the tee because the first sink's span ids are the
3427
+ * ones the run hands out as parents, so the file's parent links stay whole.
3428
+ * The sink sees parent ids it never issued, which costs an ACP consumer
3429
+ * nothing: it counts a step's runs by the `id` on each `span_start`. */
3430
+ function execute_flow(config, deps, flow, input) {
3431
+ const file = filesystem_logger({ output_path: state_path(config, "trajectory.jsonl") });
3432
+ const options = {
3433
+ ...deps.abort !== void 0 ? { abort: deps.abort } : {},
3434
+ ...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
3435
+ };
3436
+ if (deps.trajectory === void 0) return run(flow, input, {
3437
+ ...options,
3438
+ trajectory: file
3439
+ });
3440
+ const handle = run.stream(flow, input, {
3441
+ ...options,
3442
+ trajectory: tee_logger(without_model_chunks(file), deps.trajectory)
3443
+ });
3444
+ handle.events[Symbol.asyncIterator]().return?.();
3445
+ return handle.result;
3446
+ }
3447
+ /** A logger that passes `inner` every event but a model call's streamed
3448
+ * chunks. */
3449
+ function without_model_chunks(inner) {
3450
+ return {
3451
+ ...inner,
3452
+ record: (event) => {
3453
+ if (event.kind !== "model_chunk") inner.record(event);
3454
+ }
3455
+ };
3456
+ }
3032
3457
  /** Best-effort status stamp on the run summary when the loop threw; the
3033
3458
  * on-disk iteration state is already consistent (record runs per round). */
3034
3459
  function finalize_failed_run(config, err) {
@@ -3036,7 +3461,7 @@ function finalize_failed_run(config, err) {
3036
3461
  const cause = kind === "phase_error" ? error_kind(err.cause) : null;
3037
3462
  const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
3038
3463
  try {
3039
- const summary_path = volley_path(config.workspace, "summary.json");
3464
+ const summary_path = state_path(config, "summary.json");
3040
3465
  write_run_summary(config, {
3041
3466
  ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
3042
3467
  status,
@@ -3089,6 +3514,7 @@ function summarize_value(value, max = 120) {
3089
3514
  function create_renderer(options) {
3090
3515
  const write = options.write ?? ((text) => process.stderr.write(text));
3091
3516
  const { mode, color } = options;
3517
+ const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
3092
3518
  const silent = mode === "json";
3093
3519
  const streaming = mode === "default" || mode === "verbose";
3094
3520
  let mid_line = false;
@@ -3110,7 +3536,7 @@ function create_renderer(options) {
3110
3536
  mid_line = !chunk.text.endsWith("\n");
3111
3537
  return;
3112
3538
  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);
3539
+ const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS, see_trajectory) : summarize_value(chunk.input);
3114
3540
  line(`${GLYPH_TOOL} ${paint(role, chunk.id, color)} ${summary}`);
3115
3541
  return;
3116
3542
  }
@@ -3118,7 +3544,7 @@ function create_renderer(options) {
3118
3544
  const ok = chunk.error === void 0;
3119
3545
  if (mode === "verbose") {
3120
3546
  const body = ok ? summarize_output(chunk.output) : chunk.error?.message ?? "";
3121
- line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, "see .volley/trajectory.jsonl")}`);
3547
+ line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, see_trajectory)}`);
3122
3548
  } else line(`${ok ? "✅" : "❌"} ${ok ? "" : summarize_value(chunk.error?.message)}`);
3123
3549
  return;
3124
3550
  }