@robmclarty/volley 0.8.0 → 0.10.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,9 +1,10 @@
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 { homedir } from "node:os";
7
8
  import { filesystem_logger, tee_logger } from "fascicle/adapters";
8
9
  import { lookup } from "node:dns";
9
10
  import { Readability } from "@mozilla/readability";
@@ -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
@@ -516,7 +561,292 @@ async function dispose_seat_engines(engines) {
516
561
  const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
517
562
  await Promise.all(distinct.map((engine) => engine.dispose()));
518
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
+ builder_max_output_tokens: config.builder_max_output_tokens,
726
+ critic_model: config.critic_model,
727
+ critic_provider: config.critic_provider,
728
+ critic_url: config.critic_url,
729
+ builder_permission_mode: config.builder_permission_mode,
730
+ critic_preset: config.critic_preset,
731
+ critic_prompt_path: config.critic_prompt_path,
732
+ max_iterations: config.max_iterations,
733
+ max_cost_usd: config.max_cost_usd,
734
+ git_checkpoints: config.git_checkpoints,
735
+ worktree: config.worktree,
736
+ discard_worktree: config.discard_worktree,
737
+ gate_paths: config.gate_paths,
738
+ fail_on_gate_edit: config.fail_on_gate_edit,
739
+ sandbox_image: config.sandbox_image,
740
+ state_dir: config.state_dir,
741
+ workspace: config.workspace
742
+ };
743
+ const text = `${JSON.stringify(record, null, 2)}\n`;
744
+ writeFileSync(state_path(config, "config.json"), text);
745
+ const signature = sign_config(text, env);
746
+ if (signature === null) {
747
+ rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
748
+ return false;
749
+ }
750
+ writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
751
+ return true;
752
+ }
753
+ /** The identity volley's own commits fall back to. git won't commit without a
754
+ * name and an email, and the sandbox image carries no git config, so inside
755
+ * it a checkpoint or a worktree's salvage commit failed outright; a machine
756
+ * never set up for git can be the same. `.invalid` is reserved, so the email
757
+ * can't reach anyone. */
758
+ const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
759
+ /** The `-c` flags that give a commit in `cwd` the fallback identity for each
760
+ * part git has no value for. One the repo or the user configured wins, and so
761
+ * does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
762
+ * any config. Put them ahead of the subcommand. */
763
+ function commit_identity(cwd) {
764
+ return FALLBACK_IDENTITY.flatMap(([key, value]) => {
765
+ const set = spawnSync("git", [
766
+ "config",
767
+ "--get",
768
+ key
769
+ ], {
770
+ cwd,
771
+ encoding: "utf8"
772
+ });
773
+ return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
774
+ });
775
+ }
776
+ /**
777
+ * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
778
+ * here via the spawn `cwd`). `root` is the build root (`build_root`): the
779
+ * workspace on the default path, or the per-run worktree under `--worktree`, so
780
+ * checkpoints land on the worktree branch rather than the workspace's.
781
+ * A checkpoint with nothing to commit is fine; a missing git binary or repo is
782
+ * not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
783
+ * tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
784
+ * bare `git add -A` is, and `literal` reads the path as written, wildcards and
785
+ * all.
786
+ */
787
+ function git_checkpoint(root, message, excluded_dir = null) {
788
+ if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
789
+ const pathspec = excluded_dir === null ? [] : [
790
+ "--",
791
+ ":/",
792
+ `:(exclude,literal)${excluded_dir}`
793
+ ];
794
+ const add = spawnSync("git", [
795
+ "add",
796
+ "-A",
797
+ ...pathspec
798
+ ], { cwd: root });
799
+ if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
800
+ const commit = spawnSync("git", [
801
+ ...commit_identity(root),
802
+ "commit",
803
+ "-m",
804
+ message,
805
+ "--no-verify"
806
+ ], { cwd: root });
807
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
808
+ const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
809
+ if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
810
+ }
811
+ /**
812
+ * Integration — bring a successful `--worktree --git` run's effects onto the
813
+ * workspace branch. The per-phase checkpoints on `branch` are the raw
814
+ * audit/replay trail; here they collapse into a single squash commit
815
+ * (`git merge --squash` then `commit`) on the workspace's current branch. The
816
+ * shared object store makes this a local, fetch-free merge, and — because the
817
+ * workspace branch never moved during the run (checkpoints went to the phase
818
+ * branch) — it is conflict-free by construction. A run whose phase branch has no
819
+ * new commits integrates to a no-op. Only successful runs integrate; an
820
+ * abandoned run's branch is discarded wholesale by the teardown trio instead.
821
+ */
822
+ function integrate_worktree(workspace, branch, message) {
823
+ if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
824
+ const squash = spawnSync("git", [
825
+ "merge",
826
+ "--squash",
827
+ branch
828
+ ], {
829
+ cwd: workspace,
830
+ encoding: "utf8"
831
+ });
832
+ if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
833
+ if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
834
+ const commit = spawnSync("git", [
835
+ ...commit_identity(workspace),
836
+ "commit",
837
+ "-m",
838
+ message,
839
+ "--no-verify"
840
+ ], {
841
+ cwd: workspace,
842
+ encoding: "utf8"
843
+ });
844
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
845
+ const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
846
+ if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
847
+ }
519
848
  const DEFAULT_CRITIC_MODEL = "opus";
849
+ const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
520
850
  const DEFAULT_CRITIC = "reviewer";
521
851
  const DEFAULT_BUILDER_PROVIDER = "claude_cli";
522
852
  const DEFAULT_CRITIC_PROVIDER = "claude_cli";
@@ -598,17 +928,66 @@ function resolve_critic(critic, cwd) {
598
928
  critic_prompt_path: path
599
929
  };
600
930
  }
601
- /** `--builder-max-steps` (a merged flag/config value) wins over the
602
- * `VOLLEY_BUILDER_MAX_STEPS` env var, which wins over the default. Local
603
- * builder only; ignored by the `claude_cli` arm. */
604
- function resolve_builder_max_steps(raw, env) {
605
- if (raw.builder_max_steps !== void 0) return validate_builder_max_steps(raw.builder_max_steps, raw.builder_max_steps);
606
- const env_value = env["VOLLEY_BUILDER_MAX_STEPS"];
607
- if (env_value !== void 0 && env_value !== "") return validate_builder_max_steps(Number(env_value), env_value);
608
- return 50;
609
- }
610
- function validate_builder_max_steps(value, original) {
611
- if (!Number.isInteger(value) || value < 1) throw config_error(`--builder-max-steps / VOLLEY_BUILDER_MAX_STEPS must be a positive integer; got: ${String(original)}`);
931
+ /** What only a builder, or the loop around it, uses, with the flag that sets
932
+ * each: a critique has no use for any of them, so naming one is an error
933
+ * rather than something quietly ignored. */
934
+ const CRITIQUE_REFUSED = [
935
+ ["builder_model", "--builder-model"],
936
+ ["builder_provider", "--builder-provider"],
937
+ ["builder_url", "--builder-url"],
938
+ ["builder_max_steps", "--builder-max-steps"],
939
+ ["builder_max_tool_calls", "--builder-max-tool-calls"],
940
+ ["builder_max_output_tokens", "--builder-max-output-tokens"],
941
+ ["builder_permission_mode", "--builder-permission-mode"],
942
+ ["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
943
+ ["sandbox_image", "--sandbox-image"],
944
+ ["max_iterations", "--max-iterations"],
945
+ ["max_cost_usd", "--max-cost-usd"],
946
+ ["git_checkpoints", "--git"],
947
+ ["worktree", "--worktree"],
948
+ ["discard_worktree", "--discard-worktree"],
949
+ ["fail_on_gate_edit", "--fail-on-gate-edit"]
950
+ ];
951
+ function refuse_builder_settings(raw) {
952
+ 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`);
953
+ }
954
+ const BUILDER_MAX_STEPS = {
955
+ flag: "--builder-max-steps",
956
+ env_var: "VOLLEY_BUILDER_MAX_STEPS",
957
+ fallback: 50
958
+ };
959
+ const BUILDER_MAX_TOOL_CALLS = {
960
+ flag: "--builder-max-tool-calls",
961
+ env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
962
+ fallback: 1
963
+ };
964
+ /** The builders whose turns are capped when nothing sets a cap: the local
965
+ * ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
966
+ * `num_predict` run to the model's trained context less the prompt, which is
967
+ * how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
968
+ * OpenRouter has its own output limit, and a request for more than it allows
969
+ * can be refused, so it's capped only when asked. */
970
+ const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
971
+ /** The output cap's flag and env var, and its default for `provider`: 16k
972
+ * tokens on a local builder, else the provider's own limit (null). */
973
+ function builder_max_output_tokens_count(provider) {
974
+ return {
975
+ flag: "--builder-max-output-tokens",
976
+ env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
977
+ fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
978
+ };
979
+ }
980
+ /** A builder count's flag or config value wins over its env var, which wins
981
+ * over the default, and either must be a positive integer. Local builder
982
+ * only; ignored by the `claude_cli` arm. */
983
+ function resolve_builder_count(value, env, count) {
984
+ if (value !== void 0) return positive_count(value, value, count);
985
+ const env_value = env[count.env_var];
986
+ if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
987
+ return count.fallback;
988
+ }
989
+ function positive_count(value, original, count) {
990
+ if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
612
991
  return value;
613
992
  }
614
993
  /** `--sandbox-image` (a merged flag/config value) wins over the
@@ -658,25 +1037,36 @@ function resolve_seat_url(seat, value, provider) {
658
1037
  }
659
1038
  /** `--state-dir` (`state_dir`): the root each run's state sits under at
660
1039
  * `<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;
1040
+ * place. Unset, or empty as a seat URL may be, it's the default root,
1041
+ * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
1042
+ * workspace, where no check that reads every file can trip over the run's own
1043
+ * feedback. */
1044
+ function resolve_state_dir(value, cwd, env) {
1045
+ if (value === void 0 || value === null) return default_state_root(env);
665
1046
  if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
666
1047
  const path = value.trim();
667
- return path === "" ? null : resolve(cwd, path);
1048
+ return path === "" ? default_state_root(env) : resolve(cwd, path);
668
1049
  }
669
- /** volley *detects* it is running inside its sandbox container rather than
670
- * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
671
- * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
672
- * flags> <image> volley run …` is recognized as contained out of the box. Kept
673
- * env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
674
- * and testable, and so a CI job that itself runs in an unrelated container does
675
- * not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
676
- * the operator uses `--allow-unsandboxed-builder`). */
677
- function detect_containment(env) {
678
- const value = env["VOLLEY_CONTAINED"];
679
- return value === "1" || value === "true";
1050
+ function is_writable_dir(path) {
1051
+ try {
1052
+ if (!statSync(path).isDirectory()) return false;
1053
+ accessSync(path, constants.W_OK);
1054
+ return true;
1055
+ } catch {
1056
+ return false;
1057
+ }
1058
+ }
1059
+ /** The run's state directory must be one volley can write: the directory
1060
+ * itself when it's there, as a contained run's mount is, else the nearest
1061
+ * directory above it, where volley creates it. Checked before any spend,
1062
+ * because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
1063
+ * the sandbox the root filesystem is read-only, so that is what an unmounted
1064
+ * run directory looks like, and the message says what to mount. */
1065
+ function validate_run_state_dir(dir, contained) {
1066
+ let existing = dir;
1067
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
1068
+ if (is_writable_dir(existing)) return;
1069
+ 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`);
680
1070
  }
681
1071
  /** Whether a tool-loop builder's commands would reach the host with nothing in
682
1072
  * between: volley isn't contained, and the operator hasn't opted out with
@@ -684,10 +1074,7 @@ function detect_containment(env) {
684
1074
  function unguarded_builder(raw, env) {
685
1075
  return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
686
1076
  }
687
- /** A caller's run id names the run's `volley/<run id>` branch, so it is held to
688
- * what a branch name accepts without quoting: letters, digits, `-`, and `_`,
689
- * starting with a letter or digit so it never reads as an option. */
690
- const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
1077
+ /** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
691
1078
  function resolve_run_id(run_id) {
692
1079
  if (run_id === void 0) return randomUUID();
693
1080
  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}`);
@@ -699,13 +1086,17 @@ function resolve_run_id(run_id) {
699
1086
  function resolve_config(raw, options = {}) {
700
1087
  const cwd = options.cwd ?? process.cwd();
701
1088
  const env = options.env ?? process.env;
702
- if (typeof raw.prompt !== "string" || raw.prompt.length === 0) throw config_error("missing required option: --prompt");
1089
+ const critique = options.mode === "critique";
1090
+ if (critique) refuse_builder_settings(raw);
1091
+ const builder_env = critique ? {} : env;
1092
+ const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
1093
+ if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
703
1094
  if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
704
1095
  if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
705
1096
  const workspace = validate_workspace(resolve(cwd, raw.workspace));
706
- const prompt = expand_at_file(raw.prompt, cwd);
1097
+ const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
707
1098
  const criteria = expand_at_file(raw.criteria, cwd);
708
- const max_iterations = raw.max_iterations ?? 10;
1099
+ const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
709
1100
  if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
710
1101
  const max_cost_usd = raw.max_cost_usd ?? null;
711
1102
  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)}`);
@@ -714,17 +1105,19 @@ function resolve_config(raw, options = {}) {
714
1105
  const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
715
1106
  const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
716
1107
  if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
717
- const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
1108
+ const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
718
1109
  const contained = detect_containment(env);
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.`);
720
- const builder_max_steps = resolve_builder_max_steps(raw, env);
1110
+ 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.`);
1111
+ const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
1112
+ const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
1113
+ const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
721
1114
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
722
1115
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
723
1116
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
724
1117
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
725
1118
  if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
726
1119
  if (contained) {
727
- const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
1120
+ const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
728
1121
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
729
1122
  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.`);
730
1123
  }
@@ -735,14 +1128,17 @@ function resolve_config(raw, options = {}) {
735
1128
  if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
736
1129
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
737
1130
  if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) throw config_error("gate_paths must be an array of glob strings");
738
- const sandbox_image = resolve_sandbox_image(raw, env);
739
- const state_dir = resolve_state_dir(raw.state_dir, cwd);
1131
+ const sandbox_image = resolve_sandbox_image(raw, builder_env);
1132
+ const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
1133
+ if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
740
1134
  const run_id = resolve_run_id(options.run_id);
1135
+ if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
741
1136
  const check = raw.check ?? "auto";
742
1137
  return {
743
1138
  version: 2,
744
1139
  run_id,
745
1140
  started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1141
+ mode: critique ? "critique" : "run",
746
1142
  prompt,
747
1143
  criteria,
748
1144
  check,
@@ -751,6 +1147,8 @@ function resolve_config(raw, options = {}) {
751
1147
  builder_provider,
752
1148
  builder_url,
753
1149
  builder_max_steps,
1150
+ builder_max_tool_calls,
1151
+ builder_max_output_tokens,
754
1152
  allow_unsandboxed_builder,
755
1153
  critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
756
1154
  critic_provider,
@@ -830,6 +1228,34 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
830
1228
  if (warning !== null) warn(warning);
831
1229
  }
832
1230
  //#endregion
1231
+ //#region src/builder/countdown.ts
1232
+ /** How many of a session's last steps carry the countdown: five, or a tenth
1233
+ * of a long budget, but never more than half of it, so a short session still
1234
+ * starts without one. */
1235
+ function countdown_window(max_steps) {
1236
+ return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
1237
+ }
1238
+ /** The note for a turn with `steps_left` steps left, this one included.
1239
+ * `check` names how to run the check. On the last step fascicle runs only a
1240
+ * call that ends the session, which for the builder is `finish`. */
1241
+ function countdown_note(steps_left, check) {
1242
+ if (steps_left <= 1) return "[volley] This is the last step of this session, and only finish runs on it. Call finish now, with a summary that says what's done and what isn't verified.";
1243
+ return `[volley] ${String(steps_left)} steps left in this session, this one included, and on the last one only finish runs. If you haven't run ${check} since your last edit, run it now, then fix what it reports or call finish.`;
1244
+ }
1245
+ /** The `prepare_step` hook that adds the countdown to a session of
1246
+ * `max_steps` steps. */
1247
+ function step_countdown(max_steps, check) {
1248
+ const window = countdown_window(max_steps);
1249
+ return ({ step_index, messages }) => {
1250
+ const steps_left = max_steps - step_index;
1251
+ if (steps_left > window) return void 0;
1252
+ return { messages: [...messages, {
1253
+ role: "user",
1254
+ content: countdown_note(steps_left, check)
1255
+ }] };
1256
+ };
1257
+ }
1258
+ //#endregion
833
1259
  //#region src/workspace_tools.ts
834
1260
  /**
835
1261
  * Shared workspace tools: `contain()` path confinement plus the read-only
@@ -860,7 +1286,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
860
1286
  * escape. Returns the absolute path; throws on containment violation. The
861
1287
  * `workspace` root passed here is the build root, which is the per-run git
862
1288
  * worktree under `--worktree` (`build_root`), not necessarily the
863
- * control-plane workspace that holds `.volley/`. */
1289
+ * workspace the run was started on. */
864
1290
  function contain(workspace, rel) {
865
1291
  const root = resolve(workspace);
866
1292
  const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
@@ -899,6 +1325,25 @@ function walk(root, start, limit) {
899
1325
  }
900
1326
  return out;
901
1327
  }
1328
+ /** The files a read tool's optional `path` covers: the whole tree, the tree
1329
+ * under a directory, or the one file it names. A path that doesn't exist
1330
+ * throws, so the seat reads why rather than an empty result that looks like
1331
+ * "nothing matched". */
1332
+ function scoped_files(workspace, path, limit) {
1333
+ const root = resolve(workspace);
1334
+ if (path === void 0) return walk(root, root, limit);
1335
+ const start = contain(workspace, path);
1336
+ let is_dir;
1337
+ try {
1338
+ is_dir = statSync(start).isDirectory();
1339
+ } catch (err) {
1340
+ throw new Error(`no such file or directory: ${path}`, { cause: err });
1341
+ }
1342
+ return is_dir ? walk(root, start, limit) : [{
1343
+ rel: relative(root, start),
1344
+ abs: start
1345
+ }];
1346
+ }
902
1347
  function build_regex(pattern, flags) {
903
1348
  try {
904
1349
  return new RegExp(pattern, flags);
@@ -910,17 +1355,18 @@ function build_regex(pattern, flags) {
910
1355
  const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
911
1356
  const search_files_input = z.object({
912
1357
  pattern: z.string().describe("JavaScript regular expression to match against each line."),
913
- path: z.string().optional().describe("Optional workspace-relative subdirectory to limit the search to."),
1358
+ path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
914
1359
  ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
915
1360
  });
916
1361
  const list_files_input = z.object({
917
- path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
1362
+ path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
918
1363
  contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
919
1364
  });
920
1365
  function read_only_tools(workspace) {
921
1366
  return [
922
1367
  {
923
1368
  name: "read_file",
1369
+ read_only: true,
924
1370
  description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
925
1371
  input_schema: read_file_input,
926
1372
  execute: (raw) => {
@@ -931,13 +1377,13 @@ function read_only_tools(workspace) {
931
1377
  },
932
1378
  {
933
1379
  name: "search_files",
1380
+ read_only: true,
934
1381
  description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
935
1382
  input_schema: search_files_input,
936
1383
  execute: (raw) => {
937
1384
  const input = search_files_input.parse(raw);
938
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
939
1385
  const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
940
- const files = walk(resolve(workspace), start, LIST_MAX_ENTRIES);
1386
+ const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
941
1387
  const hits = [];
942
1388
  for (const file of files) {
943
1389
  if (hits.length >= 200) break;
@@ -961,12 +1407,12 @@ function read_only_tools(workspace) {
961
1407
  },
962
1408
  {
963
1409
  name: "list_files",
1410
+ read_only: true,
964
1411
  description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
965
1412
  input_schema: list_files_input,
966
1413
  execute: (raw) => {
967
1414
  const input = list_files_input.parse(raw);
968
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
969
- let files = walk(resolve(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
1415
+ let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
970
1416
  if (input.contains !== void 0) {
971
1417
  const needle = input.contains;
972
1418
  files = files.filter((f) => f.includes(needle));
@@ -1382,15 +1828,257 @@ function finish_tool() {
1382
1828
  };
1383
1829
  }
1384
1830
  //#endregion
1385
- //#region src/cost.ts
1831
+ //#region src/check/checkride.ts
1386
1832
  /**
1387
- * Cost and usage accumulation. All pricing knowledge lives in
1388
- * fascicle; volley only folds `GenerateResult.usage` / `.cost` into loop
1389
- * state and enforces the cap predicate in the guard.
1833
+ * checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
1834
+ * `.check/summary.json` contract (`schema_version: 1`), and collect raw
1835
+ * failing-slot artifacts for the critic — the critic reads what the tool
1836
+ * actually said, not a normalized digest. The digest checkride writes on a red
1837
+ * run is kept beside them for the next builder, which needs a short list of
1838
+ * what to fix more than the whole of every slot's output.
1390
1839
  */
1391
- const EMPTY_USAGE = {
1392
- input_tokens: 0,
1393
- output_tokens: 0
1840
+ const CHECK_ARTIFACT_MAX_CHARS = 8e3;
1841
+ const CHECKRIDE_SCHEMA_VERSION = 1;
1842
+ /** How long a check's output may stay open after its shell exits, while
1843
+ * something that escaped its process group still holds it, before the reads
1844
+ * stop so the check can finish. */
1845
+ const OUTPUT_GRACE_MS = 2e3;
1846
+ /** Kill a check's whole process group with SIGKILL. A test runner's workers,
1847
+ * and anything the check started in the background, sit in the shell's group,
1848
+ * so killing the shell alone would leave them running. Windows has no process
1849
+ * groups to signal, so there only the shell goes. */
1850
+ function kill_group(proc) {
1851
+ if (proc.pid === void 0) return;
1852
+ try {
1853
+ if (process.platform === "win32") proc.kill("SIGKILL");
1854
+ else process.kill(-proc.pid, "SIGKILL");
1855
+ } catch {}
1856
+ }
1857
+ /** Run a check to its end in a process group of its own, capturing its
1858
+ * output. The abort kills the whole group and rejects with `aborted_error`,
1859
+ * and the shell's exit kills whatever it left behind, so a stopped run leaves
1860
+ * no test workers or servers running. */
1861
+ function spawn_capture(cmd, args, options) {
1862
+ const { abort } = options;
1863
+ if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
1864
+ return new Promise((resolve_promise, reject) => {
1865
+ const proc = spawn(cmd, args, {
1866
+ cwd: options.cwd,
1867
+ ...options.env !== void 0 ? { env: options.env } : {},
1868
+ detached: process.platform !== "win32",
1869
+ stdio: [
1870
+ "ignore",
1871
+ "pipe",
1872
+ "pipe"
1873
+ ]
1874
+ });
1875
+ let stdout = "";
1876
+ let stderr = "";
1877
+ let grace;
1878
+ const on_abort = () => {
1879
+ kill_group(proc);
1880
+ reject(new aborted_error("check aborted"));
1881
+ };
1882
+ const finish = () => {
1883
+ clearTimeout(grace);
1884
+ abort?.removeEventListener("abort", on_abort);
1885
+ };
1886
+ proc.stdout.on("data", (chunk) => {
1887
+ stdout += chunk.toString();
1888
+ });
1889
+ proc.stderr.on("data", (chunk) => {
1890
+ stderr += chunk.toString();
1891
+ });
1892
+ proc.on("error", (err) => {
1893
+ finish();
1894
+ reject(check_error(`check command failed to start: ${err.message}`));
1895
+ });
1896
+ proc.on("exit", () => {
1897
+ kill_group(proc);
1898
+ grace = setTimeout(() => {
1899
+ proc.stdout.destroy();
1900
+ proc.stderr.destroy();
1901
+ }, OUTPUT_GRACE_MS);
1902
+ });
1903
+ proc.on("close", (code) => {
1904
+ finish();
1905
+ resolve_promise({
1906
+ exit_code: code,
1907
+ stdout,
1908
+ stderr
1909
+ });
1910
+ });
1911
+ abort?.addEventListener("abort", on_abort, { once: true });
1912
+ });
1913
+ }
1914
+ /** Best-effort parse of the summary contract. `pnpm exec` talks over the
1915
+ * command's stdout (pnpm 11's dep-verify prints an "Already up to date"
1916
+ * install line ahead of the JSON), so the object is extracted between the
1917
+ * first `{` and the last `}` rather than parsed from byte 0. An unexpected
1918
+ * schema_version warns and still parses; no JSON object at all is a harness
1919
+ * error (exit 4). */
1920
+ function parse_checkride_summary(stdout) {
1921
+ const start = stdout.indexOf("{");
1922
+ const end = stdout.lastIndexOf("}");
1923
+ let raw;
1924
+ try {
1925
+ if (start === -1 || end < start) throw new Error("no JSON object in stdout");
1926
+ raw = JSON.parse(stdout.slice(start, end + 1));
1927
+ } catch {
1928
+ throw check_error("checkride --json produced unparseable stdout");
1929
+ }
1930
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
1931
+ const summary = raw;
1932
+ return {
1933
+ summary,
1934
+ warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
1935
+ };
1936
+ }
1937
+ /** Parse the summary from stdout, falling back to the on-disk contract:
1938
+ * `.check/summary.json` is what checkride documents; stdout is its mirror and
1939
+ * wrappers may talk over it beyond what the tolerant slice can recover. */
1940
+ function parse_summary_with_fallback(stdout, workspace) {
1941
+ try {
1942
+ return parse_checkride_summary(stdout);
1943
+ } catch (err) {
1944
+ const file = join(workspace, ".check", "summary.json");
1945
+ if (!existsSync(file)) throw err;
1946
+ return parse_checkride_summary(readFileSync(file, "utf8"));
1947
+ }
1948
+ }
1949
+ function failing_checks(summary) {
1950
+ return summary.checks.filter((c) => !c.ok && c.skipped !== true);
1951
+ }
1952
+ /** Whether `path` sits under `dir`. */
1953
+ function within(dir, path) {
1954
+ const from_dir = relative(dir, path);
1955
+ return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
1956
+ }
1957
+ /** Per failing slot: the raw artifact (`<output_file>` when present, else
1958
+ * `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
1959
+ * iteration's copy of it, truncated with a pointer to the file. The names come
1960
+ * from a summary the builder could have written, so a path that leaves
1961
+ * `check_dir` has no artifact, rather than putting a file from elsewhere in
1962
+ * front of a model. */
1963
+ function read_failing_artifacts(check_dir, failing) {
1964
+ return failing.map((check) => {
1965
+ const file = check.output_file ?? `${check.name}.stdout.txt`;
1966
+ const path = join(check_dir, file);
1967
+ if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
1968
+ slot: check.name,
1969
+ path: null,
1970
+ content: "(no artifact found)",
1971
+ truncated: false
1972
+ };
1973
+ const raw = readFileSync(path, "utf8");
1974
+ const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
1975
+ return {
1976
+ slot: check.name,
1977
+ path,
1978
+ content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
1979
+ truncated
1980
+ };
1981
+ });
1982
+ }
1983
+ /** checkride's digest of a red run (`.check/digest.md`, written under
1984
+ * `--digest` and removed on a green one), or undefined when there is none. It
1985
+ * is capped by checkride itself, at 8 kB. */
1986
+ function read_digest(workspace) {
1987
+ const path = join(workspace, ".check", "digest.md");
1988
+ return existsSync(path) ? readFileSync(path, "utf8") : void 0;
1989
+ }
1990
+ async function run_checkride(opts) {
1991
+ const { workspace, ...spawn_options } = opts;
1992
+ const proc = await spawn_capture("pnpm", [
1993
+ "exec",
1994
+ "checkride",
1995
+ "--json",
1996
+ "--digest"
1997
+ ], {
1998
+ ...spawn_options,
1999
+ cwd: workspace
2000
+ });
2001
+ if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
2002
+ const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
2003
+ const failing = failing_checks(summary);
2004
+ const digest = summary.ok ? void 0 : read_digest(opts.workspace);
2005
+ return {
2006
+ ran: true,
2007
+ runner: "checkride",
2008
+ ok: summary.ok,
2009
+ exit_code: proc.exit_code,
2010
+ duration_ms: summary.total_duration_ms,
2011
+ failing_slots: failing.map((c) => c.name),
2012
+ detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
2013
+ summary,
2014
+ ...digest !== void 0 ? { digest } : {}
2015
+ };
2016
+ }
2017
+ //#endregion
2018
+ //#region src/check/command.ts
2019
+ /**
2020
+ * Generic command runner: `sh -c <command>`, combined
2021
+ * output captured, exit-0 semantics. Command-not-found is an operator error
2022
+ * (exit 4), not an iterable failure.
2023
+ */
2024
+ async function run_command_check(opts) {
2025
+ const { command, workspace, ...spawn_options } = opts;
2026
+ const started = Date.now();
2027
+ const proc = await spawn_capture("sh", ["-c", command], {
2028
+ ...spawn_options,
2029
+ cwd: workspace
2030
+ });
2031
+ if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
2032
+ const log = `${proc.stdout}${proc.stderr}`;
2033
+ return {
2034
+ ran: true,
2035
+ runner: "command",
2036
+ ok: proc.exit_code === 0,
2037
+ exit_code: proc.exit_code,
2038
+ duration_ms: Date.now() - started,
2039
+ failing_slots: [],
2040
+ detail: [],
2041
+ log
2042
+ };
2043
+ }
2044
+ function skipped_check(reason) {
2045
+ return {
2046
+ ran: false,
2047
+ runner: "none",
2048
+ ok: reason === "none",
2049
+ exit_code: null,
2050
+ duration_ms: 0,
2051
+ failing_slots: [],
2052
+ detail: []
2053
+ };
2054
+ }
2055
+ /** What a rejected check says in place of its output. */
2056
+ const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
2057
+ /** A check the user rejected when it asked to run: recorded as a failed
2058
+ * check, with the refusal as its output, so a round can't converge on work
2059
+ * nothing checked, and the critic hears why there's no result. */
2060
+ function rejected_check(runner) {
2061
+ return {
2062
+ ran: true,
2063
+ runner,
2064
+ ok: false,
2065
+ exit_code: null,
2066
+ duration_ms: 0,
2067
+ failing_slots: [],
2068
+ detail: [],
2069
+ log: CHECK_REJECTED
2070
+ };
2071
+ }
2072
+ //#endregion
2073
+ //#region src/cost.ts
2074
+ /**
2075
+ * Cost and usage accumulation. All pricing knowledge lives in
2076
+ * fascicle; volley only folds `GenerateResult.usage` / `.cost` into loop
2077
+ * state and enforces the cap predicate in the guard.
2078
+ */
2079
+ const EMPTY_USAGE = {
2080
+ input_tokens: 0,
2081
+ output_tokens: 0
1394
2082
  };
1395
2083
  function add_usage(a, b) {
1396
2084
  const sum = {
@@ -1421,6 +2109,7 @@ function cost_source_of(result) {
1421
2109
  * its whole degradation ladder. */
1422
2110
  function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
1423
2111
  const rate = throughput(result);
2112
+ const truncated = result.steps.filter((step) => step.truncated === true).length;
1424
2113
  return {
1425
2114
  provider: result.model_resolved.provider,
1426
2115
  model,
@@ -1432,6 +2121,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
1432
2121
  finish_reason: result.finish_reason,
1433
2122
  tool_calls: result.tool_calls.length,
1434
2123
  salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
2124
+ ...truncated > 0 ? { truncated_turns: truncated } : {},
1435
2125
  ...rate !== void 0 ? { throughput: rate } : {}
1436
2126
  };
1437
2127
  }
@@ -1500,130 +2190,6 @@ async function prewarm_ollama_model(base_url, model, abort) {
1500
2190
  } catch {}
1501
2191
  }
1502
2192
  //#endregion
1503
- //#region src/workspace.ts
1504
- /**
1505
- * Workspace lifecycle: run-state initialization, stale-run
1506
- * backup rotation, path helpers, and optional git checkpoints.
1507
- */
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);
1516
- }
1517
- function iteration_dir(run, iteration) {
1518
- return state_path(run, "iterations", String(iteration).padStart(3, "0"));
1519
- }
1520
- /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
1521
- * run-state rotation here and the worktree rotation so both preserve
1522
- * prior state under the same never-destroy naming. */
1523
- function backup_stamp(date = /* @__PURE__ */ new Date()) {
1524
- return date.toISOString().replace(/[:.]/g, "-");
1525
- }
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 });
1535
- }
1536
- function write_resolved_config(config) {
1537
- const record = {
1538
- version: config.version,
1539
- run_id: config.run_id,
1540
- started_at: config.started_at,
1541
- prompt: config.prompt,
1542
- criteria: config.criteria,
1543
- check: config.check,
1544
- check_resolved: config.check_resolved,
1545
- builder_model: config.builder_model,
1546
- builder_provider: config.builder_provider,
1547
- builder_url: config.builder_url,
1548
- builder_max_steps: config.builder_max_steps,
1549
- critic_model: config.critic_model,
1550
- critic_provider: config.critic_provider,
1551
- critic_url: config.critic_url,
1552
- builder_permission_mode: config.builder_permission_mode,
1553
- critic_preset: config.critic_preset,
1554
- critic_prompt_path: config.critic_prompt_path,
1555
- max_iterations: config.max_iterations,
1556
- max_cost_usd: config.max_cost_usd,
1557
- git_checkpoints: config.git_checkpoints,
1558
- worktree: config.worktree,
1559
- discard_worktree: config.discard_worktree,
1560
- gate_paths: config.gate_paths,
1561
- fail_on_gate_edit: config.fail_on_gate_edit,
1562
- sandbox_image: config.sandbox_image,
1563
- state_dir: config.state_dir,
1564
- workspace: config.workspace
1565
- };
1566
- writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1567
- }
1568
- /**
1569
- * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
1570
- * here via the spawn `cwd`). `root` is the build root (`build_root`): the
1571
- * workspace on the default path, or the per-run worktree under `--worktree`, so
1572
- * checkpoints land on the worktree branch rather than the workspace's.
1573
- * A checkpoint with nothing to commit is fine; a missing git binary or repo is
1574
- * not.
1575
- */
1576
- function git_checkpoint(root, message) {
1577
- if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
1578
- const add = spawnSync("git", ["add", "-A"], { cwd: root });
1579
- if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
1580
- const commit = spawnSync("git", [
1581
- "commit",
1582
- "-m",
1583
- message,
1584
- "--no-verify"
1585
- ], { cwd: root });
1586
- if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1587
- const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
1588
- if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
1589
- }
1590
- /**
1591
- * Integration — bring a successful `--worktree --git` run's effects onto the
1592
- * workspace branch. The per-phase checkpoints on `branch` are the raw
1593
- * audit/replay trail; here they collapse into a single squash commit
1594
- * (`git merge --squash` then `commit`) on the workspace's current branch. The
1595
- * shared object store makes this a local, fetch-free merge, and — because the
1596
- * workspace branch never moved during the run (checkpoints went to the phase
1597
- * branch) — it is conflict-free by construction. A run whose phase branch has no
1598
- * new commits integrates to a no-op. Only successful runs integrate; an
1599
- * abandoned run's branch is discarded wholesale by the teardown trio instead.
1600
- */
1601
- function integrate_worktree(workspace, branch, message) {
1602
- if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
1603
- const squash = spawnSync("git", [
1604
- "merge",
1605
- "--squash",
1606
- branch
1607
- ], {
1608
- cwd: workspace,
1609
- encoding: "utf8"
1610
- });
1611
- if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
1612
- if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
1613
- const commit = spawnSync("git", [
1614
- "commit",
1615
- "-m",
1616
- message,
1617
- "--no-verify"
1618
- ], {
1619
- cwd: workspace,
1620
- encoding: "utf8"
1621
- });
1622
- if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1623
- const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
1624
- if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
1625
- }
1626
- //#endregion
1627
2193
  //#region src/worktree.ts
1628
2194
  /**
1629
2195
  * Git-worktree orchestration: isolate a builder run's
@@ -1656,9 +2222,8 @@ function worktree_branch(run_id) {
1656
2222
  * when `--worktree` is on, else the workspace itself. This is the containment
1657
2223
  * root the builder/critic file tools resolve through `contain()` and the
1658
2224
  * `bash` tool's cwd — re-pointing it moves writes into the worktree without
1659
- * disturbing the workspace. `.volley/` state, the resolved config, and the
1660
- * deterministic check stay under the workspace (the control plane), so only the
1661
- * model-driven effect surface moves.
2225
+ * disturbing the workspace. The check, the change report, and the checkpoints
2226
+ * follow it there; the run's state stays wherever `state_dir` puts it.
1662
2227
  */
1663
2228
  function build_root(workspace, worktree) {
1664
2229
  return worktree ? worktree_path(workspace) : workspace;
@@ -1876,6 +2441,7 @@ function salvage_worktree(repo, path, branch) {
1876
2441
  if (existsSync(path)) {
1877
2442
  git_quiet(path, ["add", "-A"]);
1878
2443
  git_quiet(path, [
2444
+ ...commit_identity(path),
1879
2445
  "commit",
1880
2446
  "-m",
1881
2447
  `volley: salvage unintegrated worktree (${branch})`,
@@ -1999,12 +2565,22 @@ const HARNESS_APPENDS = {
1999
2565
  volley: "harness_append_local.md",
2000
2566
  acp: "harness_append_acp.md"
2001
2567
  };
2002
- function compose_builder_system_local(tool_set = "volley") {
2003
- return `${[
2568
+ /** Step 2 of "Work this way", as both harness appends word it for a builder
2569
+ * held to one tool call a turn. */
2570
+ const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
2571
+ /** Step 2 for a builder that may make several calls a turn: fascicle runs a
2572
+ * turn's calls in order, so a read can't race an edit, but each change or
2573
+ * command is still worth reading before the next. Reads don't count toward
2574
+ * the cap, so the step names no number. */
2575
+ const BATCHED_CALLS = ["2. Batch independent reads into one turn, but make one change or command per", " turn, and read its result before deciding the next call."].join("\n");
2576
+ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
2577
+ const identity = [
2004
2578
  "You are the builder inside the volley harness: an autonomous agent",
2005
2579
  "iterating on a workspace until it satisfies a task and its acceptance",
2006
2580
  "criteria."
2007
- ].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
2581
+ ].join("\n");
2582
+ const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
2583
+ return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
2008
2584
  }
2009
2585
  /** The builder system prompt for the active provider: the `claude_cli`
2010
2586
  * prompt (implicit built-in tools, "the current working directory") for the
@@ -2012,18 +2588,28 @@ function compose_builder_system_local(tool_set = "volley") {
2012
2588
  * the seat's tool set for every provider that runs volley's tool loop,
2013
2589
  * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
2014
2590
  function resolve_builder_system(config, tool_set) {
2015
- return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
2591
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
2016
2592
  }
2017
2593
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
2594
+ /** How the step countdown tells the builder to run the check: the command
2595
+ * volley gates on, with the digest and without `--json`, the configured
2596
+ * command, or the project's own tests when no check gates the run. A
2597
+ * workspace can hold checkride with no `check` script, so not `pnpm check`. */
2598
+ function check_hint(config) {
2599
+ if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
2600
+ if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
2601
+ return "the project's tests";
2602
+ }
2018
2603
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
2019
2604
  * The `claude_cli` builder is confined at the CLI permission layer (allowlist +
2020
2605
  * permission mode) and brings its own built-in tools — this arm is unchanged
2021
2606
  * from v2, and takes no injected tools or approval handler. Every other
2022
2607
  * builder, local or `openrouter`, brings none, so volley supplies its whole
2023
2608
  * 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. */
2609
+ * the handler that answers its approvals), the per-call loop knobs, the step
2610
+ * countdown (`./builder/countdown.ts`), the output cap when one is set, and
2611
+ * **no** schema (it produces a workspace, not a verdict); per-call values win
2612
+ * over engine defaults. */
2027
2613
  function builder_tool_options(deps) {
2028
2614
  const { config } = deps;
2029
2615
  const bash_executor = deps.bash_executor ?? null;
@@ -2037,9 +2623,75 @@ function builder_tool_options(deps) {
2037
2623
  max_steps: config.builder_max_steps,
2038
2624
  tool_error_policy: "feed_back",
2039
2625
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
2040
- max_tool_calls_per_step: 1
2626
+ max_tool_calls_per_step: config.builder_max_tool_calls,
2627
+ prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
2628
+ ...config.builder_max_output_tokens !== null ? {
2629
+ max_tokens: config.builder_max_output_tokens,
2630
+ truncated_turn_policy: "continue"
2631
+ } : {}
2041
2632
  };
2042
2633
  }
2634
+ /** How much of the last check's output the builder's prompt carries: a few KB,
2635
+ * whatever the runner. checkride's digest already fits in 8 kB, and the builder
2636
+ * can run the check again for the rest. */
2637
+ const PREVIOUS_CHECK_MAX_CHARS = 6e3;
2638
+ /** The first `max` characters of `text`, saying where it was cut. */
2639
+ function head(text, max, rest) {
2640
+ return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
2641
+ }
2642
+ /** The last `max` characters of `text`, saying what came before was cut. */
2643
+ function tail(text, max) {
2644
+ return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
2645
+ }
2646
+ /** The part of a failed check the builder reads first: checkride's digest,
2647
+ * else the start of each failing slot's raw output, else the end of a
2648
+ * command's output, where test runners and compilers sum up. */
2649
+ function check_excerpt(check) {
2650
+ const max = PREVIOUS_CHECK_MAX_CHARS;
2651
+ if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
2652
+ if (check.detail.length > 0) return head(check.detail.map((artifact) => {
2653
+ const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
2654
+ return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
2655
+ }).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
2656
+ return tail((check.log ?? "").trimEnd(), max);
2657
+ }
2658
+ /** The check section of a later iteration's prompt: what the deterministic
2659
+ * gate itself said about the last session's work, so the builder starts from
2660
+ * its words rather than the critic's account of them, or re-running it to
2661
+ * find out. Empty unless that check ran and failed. A check the user rejected
2662
+ * when it asked to run is recorded as failed but said nothing, and the
2663
+ * section says so rather than call the work failed. */
2664
+ function previous_check_section(check) {
2665
+ if (check === null || !check.ran || check.ok) return [];
2666
+ const heading = [
2667
+ "",
2668
+ "PREVIOUS CHECK RESULT",
2669
+ "---------------------"
2670
+ ];
2671
+ if (check.log === "the check was rejected when it asked to run, so it never ran") return [
2672
+ ...heading,
2673
+ "The check never ran on the work your last session left: it was rejected when it",
2674
+ "asked to run, so nothing has verified that work yet."
2675
+ ];
2676
+ const runner = check.runner === "checkride" ? "checkride, " : "";
2677
+ const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
2678
+ const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
2679
+ if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
2680
+ const excerpt = check_excerpt(check);
2681
+ if (excerpt.length > 0) lines.push("", excerpt);
2682
+ return lines;
2683
+ }
2684
+ /** The line a later iteration's prompt carries when the last session ran out
2685
+ * of steps: it never got to verify, so this one should check first. */
2686
+ function step_limit_lines(step_limit_hit) {
2687
+ if (step_limit_hit === null) return [];
2688
+ return [
2689
+ "",
2690
+ `Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
2691
+ "it may never have verified its work. Run the check first this time, and keep",
2692
+ "enough steps in hand to verify again before you finish."
2693
+ ];
2694
+ }
2043
2695
  function compose_builder_prompt(input) {
2044
2696
  const parts = [
2045
2697
  "TASK",
@@ -2053,7 +2705,8 @@ function compose_builder_prompt(input) {
2053
2705
  `ITERATION: ${input.iteration}`
2054
2706
  ];
2055
2707
  if (input.iteration > 1 && input.feedback !== null) parts.push("", "PREVIOUS CRITIC FEEDBACK", "------------------------", input.feedback, "", "Address the feedback above. The workspace already contains your prior work;", "read the current state, then make the necessary changes.");
2056
- if (input.checkride) parts.push("", "The definition of done for this workspace includes `pnpm check` (checkride)", "exiting 0. Run it yourself before finishing; on failure, read", ".check/summary.json, then the failing slot's raw output, fix, and re-run.");
2708
+ if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
2709
+ if (input.checkride) parts.push("", "The definition of done for this workspace includes checkride exiting 0. Run", "`pnpm exec checkride --digest`, the check volley gates on, after each batch of", "edits and again before you finish. When it fails, read .check/digest.md", "first: it lists each failing slot's first findings. Open a slot's raw", "output under .check/ only if the digest isn't enough, then fix and re-run.");
2057
2710
  return parts.join("\n");
2058
2711
  }
2059
2712
  function builder_max_steps_warning(max_steps) {
@@ -2075,7 +2728,9 @@ function make_builder_step(deps) {
2075
2728
  ...builder_tool_options(deps)
2076
2729
  });
2077
2730
  }
2078
- async function run_builder(deps, state, ctx) {
2731
+ /** Run one builder session on the opened iteration `state`, told what the
2732
+ * iteration before it left behind (`previous`, null on the first). */
2733
+ async function run_builder(deps, state, previous, ctx) {
2079
2734
  const { config } = deps;
2080
2735
  try {
2081
2736
  if (config.builder_provider === "ollama") {
@@ -2088,10 +2743,16 @@ async function run_builder(deps, state, ctx) {
2088
2743
  criteria: config.criteria,
2089
2744
  feedback: state.feedback,
2090
2745
  iteration: state.iteration,
2091
- checkride: config.check_resolved === "checkride"
2746
+ checkride: config.check_resolved === "checkride",
2747
+ previous
2092
2748
  }));
2093
- if (result.finish_reason === "max_steps") deps.warn(builder_max_steps_warning(config.builder_max_steps));
2094
- return accumulate(state, "builder", result, config.builder_model);
2749
+ const built = accumulate(state, "builder", result, config.builder_model);
2750
+ if (result.finish_reason !== "max_steps") return built;
2751
+ deps.warn(builder_max_steps_warning(config.builder_max_steps));
2752
+ return {
2753
+ ...built,
2754
+ builder_max_steps_hits: built.builder_max_steps_hits + 1
2755
+ };
2095
2756
  } catch (err) {
2096
2757
  throw phase_error("builder", state.iteration, err);
2097
2758
  }
@@ -2222,7 +2883,7 @@ function compose_critic_prompt(input) {
2222
2883
  * Critic invocation: a read-only session (`claude_cli`, or a local or
2223
2884
  * `openrouter` model with volley's read tools) returning a schema-validated
2224
2885
  * structured verdict. The
2225
- * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2886
+ * harness — not the critic — writes the run's `feedback.md` and `verdict`.
2226
2887
  */
2227
2888
  const CRITIC_ALLOWED_TOOLS = [
2228
2889
  "Read",
@@ -2230,10 +2891,16 @@ const CRITIC_ALLOWED_TOOLS = [
2230
2891
  "Glob"
2231
2892
  ];
2232
2893
  const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2894
+ /** A critic that holds tools isn't constrain-decoded, so nothing stops it
2895
+ * answering null for the feedback or the unmet list, as GLM did on approving.
2896
+ * Both fields allow null and read it as empty, so that answer is a verdict
2897
+ * rather than a schema failure that ends the run. fascicle sends the schema's
2898
+ * input side, so the model sees each field as its type or null, still
2899
+ * required; `.describe()` comes last so the description stays on the field. */
2233
2900
  const verdict_schema = z.object({
2234
2901
  verdict: z.enum(["approved", "changes_requested"]),
2235
- feedback: z.string().describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
2236
- unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2902
+ 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."),
2903
+ unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2237
2904
  });
2238
2905
  /** Read-only tool wiring per provider. The claude_cli critic is confined at
2239
2906
  * the CLI permission layer (allowlist + explicit disallow); every other critic,
@@ -2492,270 +3159,90 @@ function initial_state() {
2492
3159
  total_usage: EMPTY_USAGE,
2493
3160
  total_cost_usd: 0,
2494
3161
  builder_cost_usd: 0,
2495
- critic_cost_usd: 0,
2496
- check_duration_ms: 0,
2497
- iteration_cost_usd: 0,
2498
- halt: null,
2499
- cost_warned: false
2500
- };
2501
- }
2502
- /** How many rounds the loop may still run: the whole budget on a fresh run, what
2503
- * the resumed run's completed iterations left of it otherwise. */
2504
- function remaining_iterations(config, resume_from) {
2505
- return config.max_iterations - (resume_from?.iteration ?? 0);
2506
- }
2507
- /** Did this iteration's builder edit the gate that judges it, with the operator
2508
- * having asked for that to stop the run? Reported either way (the critic prompt
2509
- * and the summary always carry it); only `--fail-on-gate-edit` makes it fatal. */
2510
- function gate_edit_halt(config, state) {
2511
- return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
2512
- }
2513
- /** All stopping conditions live here: acceptance, cost cap, the gate
2514
- * edit refusal, and — implicitly via `max_rounds` — the iteration budget.
2515
- * Success wins when it and the cap land on the same iteration.
2516
- *
2517
- * A gate edit is the one condition that *beats* success rather than losing to
2518
- * it: the run passed a check the builder had rewritten, so the pass is exactly
2519
- * what is in question. It stops the run instead of iterating, because a model
2520
- * that just edited the gate is not a promising candidate to be asked again. */
2521
- function gate(config, state) {
2522
- const gate_edit = gate_edit_halt(config, state);
2523
- const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
2524
- const cap = cost_cap_hit(config, state);
2525
- const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
2526
- return {
2527
- stop: success || cap || gate_edit,
2528
- state: {
2529
- ...state,
2530
- halt
2531
- }
2532
- };
2533
- }
2534
- function status_of(value, converged) {
2535
- if (value.halt === "gate_edit") return "gate_edit_blocked";
2536
- if (value.halt === "cost_cap") return "cost_cap_reached";
2537
- return converged ? "success" : "budget_exhausted";
2538
- }
2539
- //#endregion
2540
- //#region src/check/checkride.ts
2541
- /**
2542
- * checkride runner: spawn `pnpm exec checkride --json`, gate on the
2543
- * `.check/summary.json` contract (`schema_version: 1`), and collect raw
2544
- * failing-slot artifacts for the critic — the critic reads what the tool
2545
- * actually said, not a normalized digest.
2546
- */
2547
- const CHECK_ARTIFACT_MAX_CHARS = 8e3;
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. */
2568
- function spawn_capture(cmd, args, options) {
2569
- const { abort } = options;
2570
- if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
2571
- return new Promise((resolve_promise, reject) => {
2572
- const proc = spawn(cmd, args, {
2573
- cwd: options.cwd,
2574
- ...options.env !== void 0 ? { env: options.env } : {},
2575
- detached: process.platform !== "win32",
2576
- stdio: [
2577
- "ignore",
2578
- "pipe",
2579
- "pipe"
2580
- ]
2581
- });
2582
- let stdout = "";
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
- };
2593
- proc.stdout.on("data", (chunk) => {
2594
- stdout += chunk.toString();
2595
- });
2596
- proc.stderr.on("data", (chunk) => {
2597
- stderr += chunk.toString();
2598
- });
2599
- proc.on("error", (err) => {
2600
- finish();
2601
- reject(check_error(`check command failed to start: ${err.message}`));
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
- });
2610
- proc.on("close", (code) => {
2611
- finish();
2612
- resolve_promise({
2613
- exit_code: code,
2614
- stdout,
2615
- stderr
2616
- });
2617
- });
2618
- abort?.addEventListener("abort", on_abort, { once: true });
2619
- });
2620
- }
2621
- /** Best-effort parse of the summary contract. `pnpm exec` talks over the
2622
- * command's stdout (pnpm 11's dep-verify prints an "Already up to date"
2623
- * install line ahead of the JSON), so the object is extracted between the
2624
- * first `{` and the last `}` rather than parsed from byte 0. An unexpected
2625
- * schema_version warns and still parses; no JSON object at all is a harness
2626
- * error (exit 4). */
2627
- function parse_checkride_summary(stdout) {
2628
- const start = stdout.indexOf("{");
2629
- const end = stdout.lastIndexOf("}");
2630
- let raw;
2631
- try {
2632
- if (start === -1 || end < start) throw new Error("no JSON object in stdout");
2633
- raw = JSON.parse(stdout.slice(start, end + 1));
2634
- } catch {
2635
- throw check_error("checkride --json produced unparseable stdout");
2636
- }
2637
- if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
2638
- const summary = raw;
2639
- return {
2640
- summary,
2641
- warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
2642
- };
2643
- }
2644
- /** Parse the summary from stdout, falling back to the on-disk contract:
2645
- * `.check/summary.json` is what checkride documents; stdout is its mirror and
2646
- * wrappers may talk over it beyond what the tolerant slice can recover. */
2647
- function parse_summary_with_fallback(stdout, workspace) {
2648
- try {
2649
- return parse_checkride_summary(stdout);
2650
- } catch (err) {
2651
- const file = join(workspace, ".check", "summary.json");
2652
- if (!existsSync(file)) throw err;
2653
- return parse_checkride_summary(readFileSync(file, "utf8"));
2654
- }
3162
+ critic_cost_usd: 0,
3163
+ check_duration_ms: 0,
3164
+ builder_max_steps_hits: 0,
3165
+ iteration_cost_usd: 0,
3166
+ halt: null,
3167
+ cost_warned: false
3168
+ };
2655
3169
  }
2656
- function failing_checks(summary) {
2657
- return summary.checks.filter((c) => !c.ok && c.skipped !== true);
3170
+ /** How many rounds the loop may still run: the whole budget on a fresh run, what
3171
+ * the resumed run's completed iterations left of it otherwise. */
3172
+ function remaining_iterations(config, resume_from) {
3173
+ return config.max_iterations - (resume_from?.iteration ?? 0);
2658
3174
  }
2659
- /** Per failing slot: the raw artifact (`.check/<output_file>` when present,
2660
- * else `.check/<slot>.stdout.txt`), truncated with a pointer to the file. */
2661
- function read_failing_artifacts(workspace, failing) {
2662
- return failing.map((check) => {
2663
- const file = check.output_file ?? `${check.name}.stdout.txt`;
2664
- const path = join(workspace, ".check", file);
2665
- if (!existsSync(path)) return {
2666
- slot: check.name,
2667
- path: null,
2668
- content: "(no artifact found)",
2669
- truncated: false
2670
- };
2671
- const raw = readFileSync(path, "utf8");
2672
- const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
2673
- return {
2674
- slot: check.name,
2675
- path,
2676
- content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
2677
- truncated
2678
- };
2679
- });
3175
+ /** Did this iteration's builder edit the gate that judges it, with the operator
3176
+ * having asked for that to stop the run? Reported either way (the critic prompt
3177
+ * and the summary always carry it); only `--fail-on-gate-edit` makes it fatal. */
3178
+ function gate_edit_halt(config, state) {
3179
+ return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
2680
3180
  }
2681
- async function run_checkride(opts) {
2682
- const { workspace, ...spawn_options } = opts;
2683
- const proc = await spawn_capture("pnpm", [
2684
- "exec",
2685
- "checkride",
2686
- "--json"
2687
- ], {
2688
- ...spawn_options,
2689
- cwd: workspace
2690
- });
2691
- if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
2692
- const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
2693
- const failing = failing_checks(summary);
3181
+ /** All stopping conditions live here: acceptance, cost cap, the gate
3182
+ * edit refusal, and — implicitly via `max_rounds` — the iteration budget.
3183
+ * Success wins when it and the cap land on the same iteration.
3184
+ *
3185
+ * A gate edit is the one condition that *beats* success rather than losing to
3186
+ * it: the run passed a check the builder had rewritten, so the pass is exactly
3187
+ * what is in question. It stops the run instead of iterating, because a model
3188
+ * that just edited the gate is not a promising candidate to be asked again. */
3189
+ function gate(config, state) {
3190
+ const gate_edit = gate_edit_halt(config, state);
3191
+ const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
3192
+ const cap = cost_cap_hit(config, state);
3193
+ const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
2694
3194
  return {
2695
- ran: true,
2696
- runner: "checkride",
2697
- ok: summary.ok,
2698
- exit_code: proc.exit_code,
2699
- duration_ms: summary.total_duration_ms,
2700
- failing_slots: failing.map((c) => c.name),
2701
- detail: read_failing_artifacts(opts.workspace, failing),
2702
- summary
3195
+ stop: success || cap || gate_edit,
3196
+ state: {
3197
+ ...state,
3198
+ halt
3199
+ }
2703
3200
  };
2704
3201
  }
3202
+ function status_of(value, converged) {
3203
+ if (value.halt === "gate_edit") return "gate_edit_blocked";
3204
+ if (value.halt === "cost_cap") return "cost_cap_reached";
3205
+ return converged ? "success" : "budget_exhausted";
3206
+ }
2705
3207
  //#endregion
2706
- //#region src/check/command.ts
3208
+ //#region src/render/format.ts
2707
3209
  /**
2708
- * Generic command runner: `sh -c <command>`, combined
2709
- * output captured, exit-0 semantics. Command-not-found is an operator error
2710
- * (exit 4), not an iterable failure.
3210
+ * Display formatting: glyph taxonomy, role colors, truncation.
3211
+ * All human output goes to stderr; these helpers only build strings.
2711
3212
  */
2712
- async function run_command_check(opts) {
2713
- const { command, workspace, ...spawn_options } = opts;
2714
- const started = Date.now();
2715
- const proc = await spawn_capture("sh", ["-c", command], {
2716
- ...spawn_options,
2717
- cwd: workspace
2718
- });
2719
- if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
2720
- const log = `${proc.stdout}${proc.stderr}`;
2721
- return {
2722
- ran: true,
2723
- runner: "command",
2724
- ok: proc.exit_code === 0,
2725
- exit_code: proc.exit_code,
2726
- duration_ms: Date.now() - started,
2727
- failing_slots: [],
2728
- detail: [],
2729
- log
2730
- };
3213
+ const GLYPH_TOOL = "🔧";
3214
+ const GLYPH_COST = "💰";
3215
+ const VERBOSE_TRUNCATE_CHARS = 4e3;
3216
+ function paint(role, text, color) {
3217
+ if (!color) return text;
3218
+ switch (role) {
3219
+ case "builder": return pc.cyan(text);
3220
+ case "critic": return pc.yellow(text);
3221
+ case "check": return pc.green(text);
3222
+ case "cost": return pc.magenta(text);
3223
+ case "error": return pc.bold(pc.red(text));
3224
+ }
2731
3225
  }
2732
- function skipped_check(reason) {
2733
- return {
2734
- ran: false,
2735
- runner: "none",
2736
- ok: reason === "none",
2737
- exit_code: null,
2738
- duration_ms: 0,
2739
- failing_slots: [],
2740
- detail: []
2741
- };
3226
+ function dim(text, color) {
3227
+ return color ? pc.dim(text) : text;
2742
3228
  }
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
- };
3229
+ function truncate(text, max, pointer) {
3230
+ if (text.length <= max) return text;
3231
+ return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
3232
+ }
3233
+ function format_usd(value) {
3234
+ return value === null ? "$?" : `$${value.toFixed(3)}`;
3235
+ }
3236
+ /** One-line summary of a tool input for the default streaming mode. */
3237
+ function summarize_value(value, max = 120) {
3238
+ let text;
3239
+ try {
3240
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3241
+ } catch {
3242
+ text = String(value);
3243
+ }
3244
+ const flat = text.replace(/\s+/g, " ");
3245
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
2759
3246
  }
2760
3247
  //#endregion
2761
3248
  //#region src/iteration.ts
@@ -2785,6 +3272,7 @@ function phase_summary(record) {
2785
3272
  tool_calls: record.tool_calls,
2786
3273
  salvaged_tool_calls: record.salvaged_tool_calls,
2787
3274
  salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
3275
+ ...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
2788
3276
  ...record.retries !== void 0 ? { retries: record.retries } : {},
2789
3277
  ...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
2790
3278
  ...record.critic_degraded === true ? { critic_degraded: true } : {},
@@ -2807,6 +3295,8 @@ function archive_iteration(config, state) {
2807
3295
  if (check.runner === "checkride") {
2808
3296
  if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
2809
3297
  for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
3298
+ if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
3299
+ if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
2810
3300
  } else {
2811
3301
  writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
2812
3302
  writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
@@ -2847,8 +3337,11 @@ function run_result_from_state(config, state, status, completed_at) {
2847
3337
  builder_cost_usd: state.builder_cost_usd,
2848
3338
  critic_cost_usd: state.critic_cost_usd,
2849
3339
  check_duration_ms: state.check_duration_ms,
3340
+ builder_max_steps_hits: state.builder_max_steps_hits,
2850
3341
  final_verdict: state.verdict,
2851
- salvaged_branch: null
3342
+ salvaged_branch: null,
3343
+ run_state_dir: run_state_dir(config),
3344
+ mode: config.mode
2852
3345
  };
2853
3346
  }
2854
3347
  //#endregion
@@ -2948,7 +3441,7 @@ function build_run_summary(config, result) {
2948
3441
  return {
2949
3442
  ...result,
2950
3443
  comparison: {
2951
- builder_transport: transport_of(config.builder_provider),
3444
+ builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
2952
3445
  critic_transport: transport_of(config.critic_provider),
2953
3446
  iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2954
3447
  wall_clock_ms,
@@ -3004,7 +3497,7 @@ function record_check_chunk(ctx, chunk) {
3004
3497
  }
3005
3498
  /** The command line the check runs, or null when no check is configured. */
3006
3499
  function check_command(config) {
3007
- if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
3500
+ if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
3008
3501
  if (config.check_resolved === "command") return config.check;
3009
3502
  return null;
3010
3503
  }
@@ -3023,7 +3516,16 @@ function report_changes(config, renderer, changes) {
3023
3516
  if (changes.gate_edits.length === 0) return;
3024
3517
  const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
3025
3518
  const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
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)." : ""));
3519
+ const [who, whose, edits] = config.mode === "critique" ? [
3520
+ "the change under review touches",
3521
+ "whether it passes",
3522
+ "the change edits"
3523
+ ] : [
3524
+ "the builder changed",
3525
+ "whether its work passes",
3526
+ "the builder can edit"
3527
+ ];
3528
+ 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)." : ""));
3027
3529
  }
3028
3530
  /** Run the configured check, checkride or a command, with the environment
3029
3531
  * the caller set for it, else volley's own. */
@@ -3077,6 +3579,33 @@ function next_iteration(s) {
3077
3579
  unmet_criteria: []
3078
3580
  };
3079
3581
  }
3582
+ /** What the builder of the iteration after `s` hears about it beyond the
3583
+ * critic's feedback: its check, and whether its builder ran out of steps. Null
3584
+ * before the first iteration. A resumed run's state carries both, read back
3585
+ * from the last archived iteration. */
3586
+ function previous_round(config, s) {
3587
+ if (s.iteration === 0) return null;
3588
+ return {
3589
+ check: s.check,
3590
+ step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
3591
+ };
3592
+ }
3593
+ /** What changed in the build root since the baseline, reported as it's
3594
+ * measured: the count, and a warning for any gate edit. A run measures the
3595
+ * builder's work this way after every build, and a critique measures the
3596
+ * change under review the same way, so the critic is shown one shape either
3597
+ * way. */
3598
+ function measure_changes(deps, root) {
3599
+ const { config, renderer } = deps;
3600
+ const changes = collect_changes({
3601
+ root,
3602
+ baseline: deps.baseline,
3603
+ gate_patterns: config.gate_paths,
3604
+ excluded_dir: state_root_within(root, config.state_dir)
3605
+ });
3606
+ report_changes(config, renderer, changes);
3607
+ return changes;
3608
+ }
3080
3609
  /** `build`: one builder session edits the build root, then volley measures what
3081
3610
  * it changed and, under `--git`, checkpoints it. */
3082
3611
  async function build_phase(deps, builder, s, ctx) {
@@ -3091,22 +3620,32 @@ async function build_phase(deps, builder, s, ctx) {
3091
3620
  builder,
3092
3621
  config,
3093
3622
  warn: renderer.warn
3094
- }, next, ctx);
3623
+ }, next, previous_round(config, s), ctx);
3095
3624
  renderer.phase_end(built.iteration, "builder", true);
3096
3625
  renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
3097
3626
  if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
3098
- const changes = collect_changes({
3099
- root: build_root(config.workspace, config.worktree),
3100
- baseline: deps.baseline,
3101
- gate_patterns: config.gate_paths
3102
- });
3103
- report_changes(config, renderer, changes);
3104
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(built.iteration)}: build`);
3627
+ const root = build_root(config.workspace, config.worktree);
3628
+ const changes = measure_changes(deps, root);
3629
+ if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
3105
3630
  return {
3106
3631
  ...built,
3107
3632
  changes
3108
3633
  };
3109
3634
  }
3635
+ /** `measure`: a critique's one round opens, and the change under review is
3636
+ * measured from the baseline, as a run measures the builder's work. */
3637
+ function measure_phase(deps, s, ctx) {
3638
+ const next = next_iteration(s);
3639
+ emit(ctx, {
3640
+ volley: "round_start",
3641
+ round: next.iteration
3642
+ });
3643
+ const root = build_root(deps.config.workspace, deps.config.worktree);
3644
+ return {
3645
+ ...next,
3646
+ changes: measure_changes(deps, root)
3647
+ };
3648
+ }
3110
3649
  /** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
3111
3650
  * as skipped and no critic is called — a doomed iteration spends nothing more.
3112
3651
  * The loop guard then halts the run on the cap. */
@@ -3220,7 +3759,10 @@ function record_phase(deps, s) {
3220
3759
  const { config } = deps;
3221
3760
  archive_iteration(config, s);
3222
3761
  write_run_summary(config, run_result_from_state(config, s, "running", null));
3223
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`);
3762
+ if (config.git_checkpoints) {
3763
+ const root = build_root(config.workspace, config.worktree);
3764
+ git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
3765
+ }
3224
3766
  return s;
3225
3767
  }
3226
3768
  //#endregion
@@ -3232,6 +3774,11 @@ function record_phase(deps, s) {
3232
3774
  * around the flow (workspace setup, the worktree lifecycle, `run`, the final
3233
3775
  * summary) in `./orchestrator.ts`.
3234
3776
  *
3777
+ * A critique (`build_critique_flow`) is one round over a change already made,
3778
+ * so it has no builder, no loop, and no guard: `measure`, which opens the
3779
+ * round and measures the change from its baseline, then the same `check`,
3780
+ * `critique`, and `record` a run's round ends with.
3781
+ *
3235
3782
  * fascicle's `describe.diagram` draws the tree below from the flow itself, and
3236
3783
  * `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
3237
3784
  * its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
@@ -3275,6 +3822,24 @@ function build_critic(deps) {
3275
3822
  project: with_fallback_cause
3276
3823
  });
3277
3824
  }
3825
+ /** The steps a run and a critique share, built one way for both. */
3826
+ function check_step(deps) {
3827
+ return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
3828
+ }
3829
+ function critique_step(deps, seat) {
3830
+ const critic = build_critic({
3831
+ ...seat,
3832
+ config: deps.config,
3833
+ on_chunk: deps.renderer.critic_chunk
3834
+ });
3835
+ return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
3836
+ description: "the read-only critic's verdict and feedback",
3837
+ arm: critic
3838
+ });
3839
+ }
3840
+ function record_step(deps) {
3841
+ return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
3842
+ }
3278
3843
  function build_flow(deps, resume_from) {
3279
3844
  const { config, renderer } = deps;
3280
3845
  const builder = make_builder_step({
@@ -3283,22 +3848,14 @@ function build_flow(deps, resume_from) {
3283
3848
  on_chunk: renderer.builder_chunk,
3284
3849
  bash_executor: deps.bash_executor
3285
3850
  });
3286
- const critic = build_critic({
3287
- ...deps.seats.critic,
3288
- config,
3289
- on_chunk: renderer.critic_chunk
3290
- });
3291
3851
  const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
3292
3852
  description: "open the iteration, run the builder, measure the change",
3293
3853
  arm: builder
3294
3854
  });
3295
3855
  const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s), { description: "record the check as skipped and call no critic" });
3296
- const check = step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
3297
- const critique = step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
3298
- description: "the read-only critic's verdict and feedback",
3299
- arm: critic
3300
- });
3301
- const record = step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
3856
+ const check = check_step(deps);
3857
+ const critique = critique_step(deps, deps.seats.critic);
3858
+ const record = record_step(deps);
3302
3859
  const verify = branch({
3303
3860
  name: "verify",
3304
3861
  description: "did the build alone cross the cost cap?",
@@ -3324,6 +3881,19 @@ function build_flow(deps, resume_from) {
3324
3881
  max_rounds: remaining_iterations(config, resume_from)
3325
3882
  });
3326
3883
  }
3884
+ /** A critique: one round over a change already made. `measure` opens it and
3885
+ * measures the change from the baseline, then the check, the critic, and the
3886
+ * record, as in a run's round. No loop and no guard: there is nothing to
3887
+ * iterate on, and the caller reads the outcome off the final state. */
3888
+ function build_critique_flow(deps) {
3889
+ const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
3890
+ return sequence([
3891
+ measure,
3892
+ check_step(deps),
3893
+ critique_step(deps, deps.critic),
3894
+ record_step(deps)
3895
+ ], { name: "critique_run" });
3896
+ }
3327
3897
  //#endregion
3328
3898
  //#region src/orchestrator.ts
3329
3899
  /**
@@ -3340,13 +3910,19 @@ function build_flow(deps, resume_from) {
3340
3910
  * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
3341
3911
  * │ └─ integrate a successful --worktree --git run only
3342
3912
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
3913
+ *
3914
+ * run_critique
3915
+ * ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
3916
+ * ├─ initialize the run's state
3917
+ * └─ run the critique flow one round on the critic's engine alone, then the final summary
3343
3918
  */
3344
3919
  async function run_volley(config, deps, resume_from = null) {
3345
3920
  const { renderer } = deps;
3346
3921
  if (resume_from === null) {
3347
3922
  initialize_run_state(config);
3348
- write_resolved_config(config);
3923
+ 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`);
3349
3924
  } else initialize_run_state(config, { preserve: true });
3925
+ renderer.info(`state: ${run_state_dir(config)}`);
3350
3926
  report_worktree_fate(config, renderer);
3351
3927
  const branch = worktree_branch(config.run_id);
3352
3928
  const fate = worktree_fate(config);
@@ -3462,59 +4038,65 @@ function finalize_failed_run(config, err) {
3462
4038
  const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
3463
4039
  try {
3464
4040
  const summary_path = state_path(config, "summary.json");
4041
+ const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
3465
4042
  write_run_summary(config, {
3466
- ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
4043
+ ...prior,
4044
+ builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
4045
+ run_state_dir: run_state_dir(config),
3467
4046
  status,
3468
4047
  completed_at: (/* @__PURE__ */ new Date()).toISOString()
3469
4048
  });
3470
4049
  } catch {}
3471
4050
  }
3472
- //#endregion
3473
- //#region src/render/format.ts
3474
4051
  /**
3475
- * Display formatting: glyph taxonomy, role colors, truncation.
3476
- * All human output goes to stderr; these helpers only build strings.
4052
+ * `volley critique`: the check and the critic, once, over a change already
4053
+ * made. No builder is constructed, and the engine serves the critic's seat
4054
+ * alone, so a critic can be measured on fixed, labeled changes without paying
4055
+ * for builder turns. It writes the state a one-round run does, with the mode
4056
+ * recorded, and ends `success` on approval over a green check, else
4057
+ * `rejected`.
3477
4058
  */
3478
- const GLYPH_TOOL = "🔧";
3479
- const GLYPH_COST = "💰";
3480
- const VERBOSE_TRUNCATE_CHARS = 4e3;
3481
- function paint(role, text, color) {
3482
- if (!color) return text;
3483
- switch (role) {
3484
- case "builder": return pc.cyan(text);
3485
- case "critic": return pc.yellow(text);
3486
- case "check": return pc.green(text);
3487
- case "cost": return pc.magenta(text);
3488
- case "error": return pc.bold(pc.red(text));
3489
- }
3490
- }
3491
- function dim(text, color) {
3492
- return color ? pc.dim(text) : text;
3493
- }
3494
- function truncate(text, max, pointer) {
3495
- if (text.length <= max) return text;
3496
- return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
3497
- }
3498
- function format_usd(value) {
3499
- return value === null ? "$?" : `$${value.toFixed(3)}`;
3500
- }
3501
- /** One-line summary of a tool input for the default streaming mode. */
3502
- function summarize_value(value, max = 120) {
3503
- let text;
4059
+ async function run_critique(config, deps) {
4060
+ if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
4061
+ const { renderer } = deps;
4062
+ const baseline = resolve_baseline(config.workspace, deps.baseline);
4063
+ initialize_run_state(config);
4064
+ write_resolved_config(config);
4065
+ renderer.info(`state: ${run_state_dir(config)}`);
4066
+ renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
4067
+ const engine = deps.engine ?? create_volley_engine({
4068
+ workspace: config.workspace,
4069
+ critic_provider: config.critic_provider,
4070
+ critic_url: config.critic_url
4071
+ });
4072
+ const flow = build_critique_flow({
4073
+ config,
4074
+ renderer,
4075
+ baseline,
4076
+ critic: {
4077
+ ...deps.critic,
4078
+ engine
4079
+ },
4080
+ ...deps.check !== void 0 ? { check: deps.check } : {}
4081
+ });
3504
4082
  try {
3505
- text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3506
- } catch {
3507
- text = String(value);
4083
+ const value = await execute_flow(config, deps, flow, initial_state());
4084
+ const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
4085
+ write_run_summary(config, result);
4086
+ return result;
4087
+ } catch (err) {
4088
+ finalize_failed_run(config, err);
4089
+ throw err;
4090
+ } finally {
4091
+ await engine.dispose();
3508
4092
  }
3509
- const flat = text.replace(/\s+/g, " ");
3510
- return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3511
4093
  }
3512
4094
  //#endregion
3513
4095
  //#region src/render/renderer.ts
3514
4096
  function create_renderer(options) {
3515
4097
  const write = options.write ?? ((text) => process.stderr.write(text));
3516
4098
  const { mode, color } = options;
3517
- const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
4099
+ const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
3518
4100
  const silent = mode === "json";
3519
4101
  const streaming = mode === "default" || mode === "verbose";
3520
4102
  let mid_line = false;
@@ -3592,6 +4174,7 @@ function create_renderer(options) {
3592
4174
  line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
3593
4175
  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));
3594
4176
  line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
4177
+ line(`state: ${result.run_state_dir}`);
3595
4178
  }
3596
4179
  };
3597
4180
  }
@@ -3613,7 +4196,7 @@ function summarize_output(output) {
3613
4196
  * volley detects it is already contained. The create/`exec`/reap/teardown
3614
4197
  * lifecycle and the `docker exec` bash executor were retired; what
3615
4198
  * remains here is the *spec* — pure, daemon-free flag-builders the blessed
3616
- * examples and the `--dry-run` preflight render into the `docker run` line,
4199
+ * examples and a parent harness render into the `docker run` line,
3617
4200
  * unit-testable without a daemon.
3618
4201
  *
3619
4202
  * Inside that container volley's `bash` is the local `host_bash_executor`
@@ -3638,6 +4221,13 @@ const SANDBOX_WORKDIR = "/workspace";
3638
4221
  * otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
3639
4222
  * brick tools that scribble there. */
3640
4223
  const SANDBOX_HOME = "/home/node";
4224
+ /** Where volley inside the container keeps each run's state when no
4225
+ * `--state-dir` names a root: the default root under the pinned `HOME`, since
4226
+ * the image sets no `XDG_STATE_HOME`. `sandbox_run_args`'s `run_state` binds one
4227
+ * run's host directory at that run's directory under it, so the state outlives
4228
+ * the `--rm` container. Exported so a harness can map a contained run's
4229
+ * `run_state_dir` back to the directory it mounted. */
4230
+ const SANDBOX_STATE_ROOT = default_state_root({}, SANDBOX_HOME);
3641
4231
  /** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
3642
4232
  * already provides it). It is both the `--add-host` target for the allowlist
3643
4233
  * bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
@@ -3669,7 +4259,7 @@ function network_run_args(network, network_name) {
3669
4259
  /**
3670
4260
  * The hardened `docker run` argv that launches volley inside its container.
3671
4261
  * Pure and exported so the flag set is unit-testable without a
3672
- * daemon and the blessed examples / `--dry-run` preflight can render it.
4262
+ * daemon and the blessed examples and a parent harness can render it.
3673
4263
  *
3674
4264
  * A one-shot, foreground `--rm` container — the whole run *is* this
3675
4265
  * container (an earlier design ran a detached `-d … sleep infinity` daemon volley
@@ -3681,6 +4271,25 @@ function network_run_args(network, network_name) {
3681
4271
  * the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
3682
4272
  * this way).
3683
4273
  *
4274
+ * `run_state` binds the run's own state directory on the host,
4275
+ * `<root>/<run_id>/`, at `<SANDBOX_STATE_ROOT>/<run_id>/`, where volley inside
4276
+ * keeps that run's state when no `--state-dir` names a root, so it outlives the
4277
+ * container at the path a run on the host would use. Only the one directory
4278
+ * is mounted, never the root: the builder inside runs arbitrary commands, so
4279
+ * it can rewrite its own run's state but can't read another run's trajectory
4280
+ * or plant a check in another run's config. Its own run's state is still the
4281
+ * builder's to rewrite, config.json included, so a host-side resume refuses
4282
+ * that config, which volley signs only outside the sandbox (`./trust.ts`), and
4283
+ * a directory holding a symlink: resume a contained run inside the container,
4284
+ * with the same mount. Create the
4285
+ * directory on the host first, empty (Docker on Linux makes a missing bind
4286
+ * source owned by root, which the container's user can't write), and pass the
4287
+ * same id to volley as `--run-id`. Keep it outside `build_root`, so the check
4288
+ * never reads it, and pass no `XDG_STATE_HOME` in `env`, which would move the
4289
+ * root off the mount. Omit it and no state is mounted: a contained run keeping
4290
+ * its state under the default root then stops before spending (exit 5),
4291
+ * since the read-only root filesystem leaves it nowhere to write.
4292
+ *
3684
4293
  * `store_volume` mounts the named volume as the pnpm store; omit it and no store
3685
4294
  * is mounted. A store is shared by every run that mounts it, so a caller that
3686
4295
  * installs dependencies in a separate container first can keep the builder's
@@ -3690,7 +4299,7 @@ function network_run_args(network, network_name) {
3690
4299
  * Never emits `--privileged` and refuses `--user 0`: volley must not hand
3691
4300
  * the sandbox root or full capabilities. Everything writable under the
3692
4301
  * `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
3693
- * is the one host-visible write path.
4302
+ * and, when one is named, the state root are the host-visible write paths.
3694
4303
  */
3695
4304
  function sandbox_run_args(options) {
3696
4305
  if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
@@ -3699,6 +4308,7 @@ function sandbox_run_args(options) {
3699
4308
  const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
3700
4309
  const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
3701
4310
  const labels = (options.labels ?? []).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
4311
+ const state = options.run_state !== void 0 && options.run_state !== null ? ["-v", run_state_mount(options.run_state)] : [];
3702
4312
  const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
3703
4313
  return [
3704
4314
  "run",
@@ -3732,6 +4342,7 @@ function sandbox_run_args(options) {
3732
4342
  ...env,
3733
4343
  "-v",
3734
4344
  `${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
4345
+ ...state,
3735
4346
  ...store,
3736
4347
  "-w",
3737
4348
  SANDBOX_WORKDIR,
@@ -3740,6 +4351,13 @@ function sandbox_run_args(options) {
3740
4351
  ...options.command ?? []
3741
4352
  ];
3742
4353
  }
4354
+ /** The `-v` value for one run's state directory. The run id becomes a path on
4355
+ * both sides, so it is held to volley's own `--run-id` rule, which keeps it to
4356
+ * one directory name. */
4357
+ function run_state_mount(run_state) {
4358
+ 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}`);
4359
+ return `${join(resolve(run_state.root), run_state.run_id)}:${SANDBOX_STATE_ROOT}/${run_state.run_id}`;
4360
+ }
3743
4361
  //#endregion
3744
4362
  //#region src/exit_codes.ts
3745
4363
  /**
@@ -3757,7 +4375,8 @@ const EXIT_INTERRUPTED = 130;
3757
4375
  function exit_code_for_status(status) {
3758
4376
  switch (status) {
3759
4377
  case "success": return 0;
3760
- case "budget_exhausted": return 2;
4378
+ case "budget_exhausted":
4379
+ case "rejected": return 2;
3761
4380
  case "cost_cap_reached": return 7;
3762
4381
  case "gate_edit_blocked": return 8;
3763
4382
  case "interrupted": return 130;
@@ -3792,6 +4411,6 @@ function exit_code_for_error(err) {
3792
4411
  return 3;
3793
4412
  }
3794
4413
  //#endregion
3795
- 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 };
4414
+ 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 };
3796
4415
 
3797
4416
  //# sourceMappingURL=index.js.map