@robmclarty/volley 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,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,6 +561,264 @@ 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
+ critic_model: config.critic_model,
726
+ critic_provider: config.critic_provider,
727
+ critic_url: config.critic_url,
728
+ builder_permission_mode: config.builder_permission_mode,
729
+ critic_preset: config.critic_preset,
730
+ critic_prompt_path: config.critic_prompt_path,
731
+ max_iterations: config.max_iterations,
732
+ max_cost_usd: config.max_cost_usd,
733
+ git_checkpoints: config.git_checkpoints,
734
+ worktree: config.worktree,
735
+ discard_worktree: config.discard_worktree,
736
+ gate_paths: config.gate_paths,
737
+ fail_on_gate_edit: config.fail_on_gate_edit,
738
+ sandbox_image: config.sandbox_image,
739
+ state_dir: config.state_dir,
740
+ workspace: config.workspace
741
+ };
742
+ const text = `${JSON.stringify(record, null, 2)}\n`;
743
+ writeFileSync(state_path(config, "config.json"), text);
744
+ const signature = sign_config(text, env);
745
+ if (signature === null) {
746
+ rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
747
+ return false;
748
+ }
749
+ writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
750
+ return true;
751
+ }
752
+ /**
753
+ * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
754
+ * here via the spawn `cwd`). `root` is the build root (`build_root`): the
755
+ * workspace on the default path, or the per-run worktree under `--worktree`, so
756
+ * checkpoints land on the worktree branch rather than the workspace's.
757
+ * A checkpoint with nothing to commit is fine; a missing git binary or repo is
758
+ * not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
759
+ * tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
760
+ * bare `git add -A` is, and `literal` reads the path as written, wildcards and
761
+ * all.
762
+ */
763
+ function git_checkpoint(root, message, excluded_dir = null) {
764
+ if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
765
+ const pathspec = excluded_dir === null ? [] : [
766
+ "--",
767
+ ":/",
768
+ `:(exclude,literal)${excluded_dir}`
769
+ ];
770
+ const add = spawnSync("git", [
771
+ "add",
772
+ "-A",
773
+ ...pathspec
774
+ ], { cwd: root });
775
+ if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
776
+ const commit = spawnSync("git", [
777
+ "commit",
778
+ "-m",
779
+ message,
780
+ "--no-verify"
781
+ ], { cwd: root });
782
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
783
+ const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
784
+ if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
785
+ }
786
+ /**
787
+ * Integration — bring a successful `--worktree --git` run's effects onto the
788
+ * workspace branch. The per-phase checkpoints on `branch` are the raw
789
+ * audit/replay trail; here they collapse into a single squash commit
790
+ * (`git merge --squash` then `commit`) on the workspace's current branch. The
791
+ * shared object store makes this a local, fetch-free merge, and — because the
792
+ * workspace branch never moved during the run (checkpoints went to the phase
793
+ * branch) — it is conflict-free by construction. A run whose phase branch has no
794
+ * new commits integrates to a no-op. Only successful runs integrate; an
795
+ * abandoned run's branch is discarded wholesale by the teardown trio instead.
796
+ */
797
+ function integrate_worktree(workspace, branch, message) {
798
+ if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
799
+ const squash = spawnSync("git", [
800
+ "merge",
801
+ "--squash",
802
+ branch
803
+ ], {
804
+ cwd: workspace,
805
+ encoding: "utf8"
806
+ });
807
+ if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
808
+ if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
809
+ const commit = spawnSync("git", [
810
+ "commit",
811
+ "-m",
812
+ message,
813
+ "--no-verify"
814
+ ], {
815
+ cwd: workspace,
816
+ encoding: "utf8"
817
+ });
818
+ if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
819
+ const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
820
+ if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
821
+ }
519
822
  const DEFAULT_CRITIC_MODEL = "opus";
520
823
  const DEFAULT_CRITIC = "reviewer";
521
824
  const DEFAULT_BUILDER_PROVIDER = "claude_cli";
@@ -598,17 +901,49 @@ function resolve_critic(critic, cwd) {
598
901
  critic_prompt_path: path
599
902
  };
600
903
  }
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)}`);
904
+ /** What only a builder, or the loop around it, uses, with the flag that sets
905
+ * each: a critique has no use for any of them, so naming one is an error
906
+ * rather than something quietly ignored. */
907
+ const CRITIQUE_REFUSED = [
908
+ ["builder_model", "--builder-model"],
909
+ ["builder_provider", "--builder-provider"],
910
+ ["builder_url", "--builder-url"],
911
+ ["builder_max_steps", "--builder-max-steps"],
912
+ ["builder_max_tool_calls", "--builder-max-tool-calls"],
913
+ ["builder_permission_mode", "--builder-permission-mode"],
914
+ ["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
915
+ ["sandbox_image", "--sandbox-image"],
916
+ ["max_iterations", "--max-iterations"],
917
+ ["max_cost_usd", "--max-cost-usd"],
918
+ ["git_checkpoints", "--git"],
919
+ ["worktree", "--worktree"],
920
+ ["discard_worktree", "--discard-worktree"],
921
+ ["fail_on_gate_edit", "--fail-on-gate-edit"]
922
+ ];
923
+ function refuse_builder_settings(raw) {
924
+ for (const [field, flag] of CRITIQUE_REFUSED) if (raw[field] !== void 0 && raw[field] !== false) throw config_error(`volley critique refuses ${flag} (${field}): a critique runs no builder and no loop`);
925
+ }
926
+ const BUILDER_MAX_STEPS = {
927
+ flag: "--builder-max-steps",
928
+ env_var: "VOLLEY_BUILDER_MAX_STEPS",
929
+ fallback: 50
930
+ };
931
+ const BUILDER_MAX_TOOL_CALLS = {
932
+ flag: "--builder-max-tool-calls",
933
+ env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
934
+ fallback: 1
935
+ };
936
+ /** A builder count's flag or config value wins over its env var, which wins
937
+ * over the default, and either must be a positive integer. Local builder
938
+ * only; ignored by the `claude_cli` arm. */
939
+ function resolve_builder_count(value, env, count) {
940
+ if (value !== void 0) return positive_count(value, value, count);
941
+ const env_value = env[count.env_var];
942
+ if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
943
+ return count.fallback;
944
+ }
945
+ function positive_count(value, original, count) {
946
+ if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
612
947
  return value;
613
948
  }
614
949
  /** `--sandbox-image` (a merged flag/config value) wins over the
@@ -658,25 +993,36 @@ function resolve_seat_url(seat, value, provider) {
658
993
  }
659
994
  /** `--state-dir` (`state_dir`): the root each run's state sits under at
660
995
  * `<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;
996
+ * place. Unset, or empty as a seat URL may be, it's the default root,
997
+ * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
998
+ * workspace, where no check that reads every file can trip over the run's own
999
+ * feedback. */
1000
+ function resolve_state_dir(value, cwd, env) {
1001
+ if (value === void 0 || value === null) return default_state_root(env);
665
1002
  if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
666
1003
  const path = value.trim();
667
- return path === "" ? null : resolve(cwd, path);
1004
+ return path === "" ? default_state_root(env) : resolve(cwd, path);
668
1005
  }
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";
1006
+ function is_writable_dir(path) {
1007
+ try {
1008
+ if (!statSync(path).isDirectory()) return false;
1009
+ accessSync(path, constants.W_OK);
1010
+ return true;
1011
+ } catch {
1012
+ return false;
1013
+ }
1014
+ }
1015
+ /** The run's state directory must be one volley can write: the directory
1016
+ * itself when it's there, as a contained run's mount is, else the nearest
1017
+ * directory above it, where volley creates it. Checked before any spend,
1018
+ * because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
1019
+ * the sandbox the root filesystem is read-only, so that is what an unmounted
1020
+ * run directory looks like, and the message says what to mount. */
1021
+ function validate_run_state_dir(dir, contained) {
1022
+ let existing = dir;
1023
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
1024
+ if (is_writable_dir(existing)) return;
1025
+ throw config_error(contained ? `run state directory not writable: ${dir}. Inside the sandbox, mount the run's own host directory there (sandbox_run_args run_state, or -v <root>/<run id>:${dir}), created on the host first, and pass volley the same --run-id; or point --state-dir at a writable mount` : `run state directory not writable: ${dir}. Point --state-dir at a directory volley can write`);
680
1026
  }
681
1027
  /** Whether a tool-loop builder's commands would reach the host with nothing in
682
1028
  * between: volley isn't contained, and the operator hasn't opted out with
@@ -684,10 +1030,7 @@ function detect_containment(env) {
684
1030
  function unguarded_builder(raw, env) {
685
1031
  return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
686
1032
  }
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_-]*$/;
1033
+ /** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
691
1034
  function resolve_run_id(run_id) {
692
1035
  if (run_id === void 0) return randomUUID();
693
1036
  if (!RUN_ID_PATTERN.test(run_id)) throw config_error(`--run-id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_id}`);
@@ -699,13 +1042,17 @@ function resolve_run_id(run_id) {
699
1042
  function resolve_config(raw, options = {}) {
700
1043
  const cwd = options.cwd ?? process.cwd();
701
1044
  const env = options.env ?? process.env;
702
- if (typeof raw.prompt !== "string" || raw.prompt.length === 0) throw config_error("missing required option: --prompt");
1045
+ const critique = options.mode === "critique";
1046
+ if (critique) refuse_builder_settings(raw);
1047
+ const builder_env = critique ? {} : env;
1048
+ const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
1049
+ if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
703
1050
  if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
704
1051
  if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
705
1052
  const workspace = validate_workspace(resolve(cwd, raw.workspace));
706
- const prompt = expand_at_file(raw.prompt, cwd);
1053
+ const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
707
1054
  const criteria = expand_at_file(raw.criteria, cwd);
708
- const max_iterations = raw.max_iterations ?? 10;
1055
+ const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
709
1056
  if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
710
1057
  const max_cost_usd = raw.max_cost_usd ?? null;
711
1058
  if (max_cost_usd !== null && !(typeof max_cost_usd === "number" && max_cost_usd > 0)) throw config_error(`--max-cost-usd must be a positive number; got: ${String(raw.max_cost_usd)}`);
@@ -714,17 +1061,18 @@ function resolve_config(raw, options = {}) {
714
1061
  const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
715
1062
  const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
716
1063
  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);
1064
+ const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
718
1065
  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);
1066
+ if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, builder_env)) throw config_error(`builder-provider '${builder_provider}' runs volley's tool loop with a real bash (write + exec) — refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run …, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`);
1067
+ const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
1068
+ const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
721
1069
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
722
1070
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
723
1071
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
724
1072
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
725
1073
  if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
726
1074
  if (contained) {
727
- const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
1075
+ const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
728
1076
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
729
1077
  if (claude_roles.length > 0 && auth_mode !== "api_key") throw config_error(`claude_cli (${claude_roles.join(", ")}) cannot use its subscription/OAuth token inside a container — it is mangled there. Set VOLLEY_AUTH_MODE=api_key (with ANTHROPIC_API_KEY) to drive Claude contained, or run claude_cli on the host uncontained.`);
730
1078
  }
@@ -735,14 +1083,17 @@ function resolve_config(raw, options = {}) {
735
1083
  if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
736
1084
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
737
1085
  if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) throw config_error("gate_paths must be an array of glob strings");
738
- const sandbox_image = resolve_sandbox_image(raw, env);
739
- const state_dir = resolve_state_dir(raw.state_dir, cwd);
1086
+ const sandbox_image = resolve_sandbox_image(raw, builder_env);
1087
+ const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
1088
+ if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
740
1089
  const run_id = resolve_run_id(options.run_id);
1090
+ if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
741
1091
  const check = raw.check ?? "auto";
742
1092
  return {
743
1093
  version: 2,
744
1094
  run_id,
745
1095
  started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1096
+ mode: critique ? "critique" : "run",
746
1097
  prompt,
747
1098
  criteria,
748
1099
  check,
@@ -751,6 +1102,7 @@ function resolve_config(raw, options = {}) {
751
1102
  builder_provider,
752
1103
  builder_url,
753
1104
  builder_max_steps,
1105
+ builder_max_tool_calls,
754
1106
  allow_unsandboxed_builder,
755
1107
  critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
756
1108
  critic_provider,
@@ -860,7 +1212,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
860
1212
  * escape. Returns the absolute path; throws on containment violation. The
861
1213
  * `workspace` root passed here is the build root, which is the per-run git
862
1214
  * worktree under `--worktree` (`build_root`), not necessarily the
863
- * control-plane workspace that holds `.volley/`. */
1215
+ * workspace the run was started on. */
864
1216
  function contain(workspace, rel) {
865
1217
  const root = resolve(workspace);
866
1218
  const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
@@ -899,6 +1251,25 @@ function walk(root, start, limit) {
899
1251
  }
900
1252
  return out;
901
1253
  }
1254
+ /** The files a read tool's optional `path` covers: the whole tree, the tree
1255
+ * under a directory, or the one file it names. A path that doesn't exist
1256
+ * throws, so the seat reads why rather than an empty result that looks like
1257
+ * "nothing matched". */
1258
+ function scoped_files(workspace, path, limit) {
1259
+ const root = resolve(workspace);
1260
+ if (path === void 0) return walk(root, root, limit);
1261
+ const start = contain(workspace, path);
1262
+ let is_dir;
1263
+ try {
1264
+ is_dir = statSync(start).isDirectory();
1265
+ } catch (err) {
1266
+ throw new Error(`no such file or directory: ${path}`, { cause: err });
1267
+ }
1268
+ return is_dir ? walk(root, start, limit) : [{
1269
+ rel: relative(root, start),
1270
+ abs: start
1271
+ }];
1272
+ }
902
1273
  function build_regex(pattern, flags) {
903
1274
  try {
904
1275
  return new RegExp(pattern, flags);
@@ -910,11 +1281,11 @@ function build_regex(pattern, flags) {
910
1281
  const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
911
1282
  const search_files_input = z.object({
912
1283
  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."),
1284
+ path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
914
1285
  ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
915
1286
  });
916
1287
  const list_files_input = z.object({
917
- path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
1288
+ path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
918
1289
  contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
919
1290
  });
920
1291
  function read_only_tools(workspace) {
@@ -935,9 +1306,8 @@ function read_only_tools(workspace) {
935
1306
  input_schema: search_files_input,
936
1307
  execute: (raw) => {
937
1308
  const input = search_files_input.parse(raw);
938
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
939
1309
  const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
940
- const files = walk(resolve(workspace), start, LIST_MAX_ENTRIES);
1310
+ const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
941
1311
  const hits = [];
942
1312
  for (const file of files) {
943
1313
  if (hits.length >= 200) break;
@@ -965,8 +1335,7 @@ function read_only_tools(workspace) {
965
1335
  input_schema: list_files_input,
966
1336
  execute: (raw) => {
967
1337
  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);
1338
+ let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
970
1339
  if (input.contains !== void 0) {
971
1340
  const needle = input.contains;
972
1341
  files = files.filter((f) => f.includes(needle));
@@ -1500,130 +1869,6 @@ async function prewarm_ollama_model(base_url, model, abort) {
1500
1869
  } catch {}
1501
1870
  }
1502
1871
  //#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
1872
  //#region src/worktree.ts
1628
1873
  /**
1629
1874
  * Git-worktree orchestration: isolate a builder run's
@@ -1656,9 +1901,8 @@ function worktree_branch(run_id) {
1656
1901
  * when `--worktree` is on, else the workspace itself. This is the containment
1657
1902
  * root the builder/critic file tools resolve through `contain()` and the
1658
1903
  * `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.
1904
+ * disturbing the workspace. The check, the change report, and the checkpoints
1905
+ * follow it there; the run's state stays wherever `state_dir` puts it.
1662
1906
  */
1663
1907
  function build_root(workspace, worktree) {
1664
1908
  return worktree ? worktree_path(workspace) : workspace;
@@ -1999,12 +2243,27 @@ const HARNESS_APPENDS = {
1999
2243
  volley: "harness_append_local.md",
2000
2244
  acp: "harness_append_acp.md"
2001
2245
  };
2002
- function compose_builder_system_local(tool_set = "volley") {
2003
- return `${[
2246
+ /** Step 2 of "Work this way", as both harness appends word it for a builder
2247
+ * held to one tool call a turn. */
2248
+ const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
2249
+ /** Step 2 for a builder that may make several calls a turn: fascicle runs a
2250
+ * turn's calls in order, so a read can't race an edit, but each change or
2251
+ * command is still worth reading before the next. */
2252
+ function batched_calls(max_tool_calls) {
2253
+ return [
2254
+ `2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
2255
+ " make one change or command per turn, and read its result before",
2256
+ " deciding the next call."
2257
+ ].join("\n");
2258
+ }
2259
+ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
2260
+ const identity = [
2004
2261
  "You are the builder inside the volley harness: an autonomous agent",
2005
2262
  "iterating on a workspace until it satisfies a task and its acceptance",
2006
2263
  "criteria."
2007
- ].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
2264
+ ].join("\n");
2265
+ const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
2266
+ return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, batched_calls(max_tool_calls)) : append).trimEnd()}`;
2008
2267
  }
2009
2268
  /** The builder system prompt for the active provider: the `claude_cli`
2010
2269
  * prompt (implicit built-in tools, "the current working directory") for the
@@ -2012,7 +2271,7 @@ function compose_builder_system_local(tool_set = "volley") {
2012
2271
  * the seat's tool set for every provider that runs volley's tool loop,
2013
2272
  * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
2014
2273
  function resolve_builder_system(config, tool_set) {
2015
- return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
2274
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
2016
2275
  }
2017
2276
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
2018
2277
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
@@ -2037,7 +2296,7 @@ function builder_tool_options(deps) {
2037
2296
  max_steps: config.builder_max_steps,
2038
2297
  tool_error_policy: "feed_back",
2039
2298
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
2040
- max_tool_calls_per_step: 1
2299
+ max_tool_calls_per_step: config.builder_max_tool_calls
2041
2300
  };
2042
2301
  }
2043
2302
  function compose_builder_prompt(input) {
@@ -2222,7 +2481,7 @@ function compose_critic_prompt(input) {
2222
2481
  * Critic invocation: a read-only session (`claude_cli`, or a local or
2223
2482
  * `openrouter` model with volley's read tools) returning a schema-validated
2224
2483
  * structured verdict. The
2225
- * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2484
+ * harness — not the critic — writes the run's `feedback.md` and `verdict`.
2226
2485
  */
2227
2486
  const CRITIC_ALLOWED_TOOLS = [
2228
2487
  "Read",
@@ -2230,10 +2489,16 @@ const CRITIC_ALLOWED_TOOLS = [
2230
2489
  "Glob"
2231
2490
  ];
2232
2491
  const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2492
+ /** A critic that holds tools isn't constrain-decoded, so nothing stops it
2493
+ * answering null for the feedback or the unmet list, as GLM did on approving.
2494
+ * Both fields allow null and read it as empty, so that answer is a verdict
2495
+ * rather than a schema failure that ends the run. fascicle sends the schema's
2496
+ * input side, so the model sees each field as its type or null, still
2497
+ * required; `.describe()` comes last so the description stays on the field. */
2233
2498
  const verdict_schema = z.object({
2234
2499
  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.")
2500
+ feedback: z.string().nullable().transform((feedback) => feedback ?? "").describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
2501
+ unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2237
2502
  });
2238
2503
  /** Read-only tool wiring per provider. The claude_cli critic is confined at
2239
2504
  * the CLI permission layer (allowlist + explicit disallow); every other critic,
@@ -2758,6 +3023,46 @@ function rejected_check(runner) {
2758
3023
  };
2759
3024
  }
2760
3025
  //#endregion
3026
+ //#region src/render/format.ts
3027
+ /**
3028
+ * Display formatting: glyph taxonomy, role colors, truncation.
3029
+ * All human output goes to stderr; these helpers only build strings.
3030
+ */
3031
+ const GLYPH_TOOL = "🔧";
3032
+ const GLYPH_COST = "💰";
3033
+ const VERBOSE_TRUNCATE_CHARS = 4e3;
3034
+ function paint(role, text, color) {
3035
+ if (!color) return text;
3036
+ switch (role) {
3037
+ case "builder": return pc.cyan(text);
3038
+ case "critic": return pc.yellow(text);
3039
+ case "check": return pc.green(text);
3040
+ case "cost": return pc.magenta(text);
3041
+ case "error": return pc.bold(pc.red(text));
3042
+ }
3043
+ }
3044
+ function dim(text, color) {
3045
+ return color ? pc.dim(text) : text;
3046
+ }
3047
+ function truncate(text, max, pointer) {
3048
+ if (text.length <= max) return text;
3049
+ return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
3050
+ }
3051
+ function format_usd(value) {
3052
+ return value === null ? "$?" : `$${value.toFixed(3)}`;
3053
+ }
3054
+ /** One-line summary of a tool input for the default streaming mode. */
3055
+ function summarize_value(value, max = 120) {
3056
+ let text;
3057
+ try {
3058
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3059
+ } catch {
3060
+ text = String(value);
3061
+ }
3062
+ const flat = text.replace(/\s+/g, " ");
3063
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3064
+ }
3065
+ //#endregion
2761
3066
  //#region src/iteration.ts
2762
3067
  /**
2763
3068
  * Iteration archiving, run-level summary, and resume-state recovery.
@@ -2848,7 +3153,9 @@ function run_result_from_state(config, state, status, completed_at) {
2848
3153
  critic_cost_usd: state.critic_cost_usd,
2849
3154
  check_duration_ms: state.check_duration_ms,
2850
3155
  final_verdict: state.verdict,
2851
- salvaged_branch: null
3156
+ salvaged_branch: null,
3157
+ run_state_dir: run_state_dir(config),
3158
+ mode: config.mode
2852
3159
  };
2853
3160
  }
2854
3161
  //#endregion
@@ -2948,7 +3255,7 @@ function build_run_summary(config, result) {
2948
3255
  return {
2949
3256
  ...result,
2950
3257
  comparison: {
2951
- builder_transport: transport_of(config.builder_provider),
3258
+ builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
2952
3259
  critic_transport: transport_of(config.critic_provider),
2953
3260
  iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2954
3261
  wall_clock_ms,
@@ -3023,7 +3330,16 @@ function report_changes(config, renderer, changes) {
3023
3330
  if (changes.gate_edits.length === 0) return;
3024
3331
  const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
3025
3332
  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)." : ""));
3333
+ const [who, whose, edits] = config.mode === "critique" ? [
3334
+ "the change under review touches",
3335
+ "whether it passes",
3336
+ "the change edits"
3337
+ ] : [
3338
+ "the builder changed",
3339
+ "whether its work passes",
3340
+ "the builder can edit"
3341
+ ];
3342
+ renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
3027
3343
  }
3028
3344
  /** Run the configured check, checkride or a command, with the environment
3029
3345
  * the caller set for it, else volley's own. */
@@ -3077,6 +3393,22 @@ function next_iteration(s) {
3077
3393
  unmet_criteria: []
3078
3394
  };
3079
3395
  }
3396
+ /** What changed in the build root since the baseline, reported as it's
3397
+ * measured: the count, and a warning for any gate edit. A run measures the
3398
+ * builder's work this way after every build, and a critique measures the
3399
+ * change under review the same way, so the critic is shown one shape either
3400
+ * way. */
3401
+ function measure_changes(deps, root) {
3402
+ const { config, renderer } = deps;
3403
+ const changes = collect_changes({
3404
+ root,
3405
+ baseline: deps.baseline,
3406
+ gate_patterns: config.gate_paths,
3407
+ excluded_dir: state_root_within(root, config.state_dir)
3408
+ });
3409
+ report_changes(config, renderer, changes);
3410
+ return changes;
3411
+ }
3080
3412
  /** `build`: one builder session edits the build root, then volley measures what
3081
3413
  * it changed and, under `--git`, checkpoints it. */
3082
3414
  async function build_phase(deps, builder, s, ctx) {
@@ -3095,18 +3427,28 @@ async function build_phase(deps, builder, s, ctx) {
3095
3427
  renderer.phase_end(built.iteration, "builder", true);
3096
3428
  renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
3097
3429
  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`);
3430
+ const root = build_root(config.workspace, config.worktree);
3431
+ const changes = measure_changes(deps, root);
3432
+ if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
3105
3433
  return {
3106
3434
  ...built,
3107
3435
  changes
3108
3436
  };
3109
3437
  }
3438
+ /** `measure`: a critique's one round opens, and the change under review is
3439
+ * measured from the baseline, as a run measures the builder's work. */
3440
+ function measure_phase(deps, s, ctx) {
3441
+ const next = next_iteration(s);
3442
+ emit(ctx, {
3443
+ volley: "round_start",
3444
+ round: next.iteration
3445
+ });
3446
+ const root = build_root(deps.config.workspace, deps.config.worktree);
3447
+ return {
3448
+ ...next,
3449
+ changes: measure_changes(deps, root)
3450
+ };
3451
+ }
3110
3452
  /** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
3111
3453
  * as skipped and no critic is called — a doomed iteration spends nothing more.
3112
3454
  * The loop guard then halts the run on the cap. */
@@ -3220,7 +3562,10 @@ function record_phase(deps, s) {
3220
3562
  const { config } = deps;
3221
3563
  archive_iteration(config, s);
3222
3564
  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"})`);
3565
+ if (config.git_checkpoints) {
3566
+ const root = build_root(config.workspace, config.worktree);
3567
+ git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
3568
+ }
3224
3569
  return s;
3225
3570
  }
3226
3571
  //#endregion
@@ -3232,6 +3577,11 @@ function record_phase(deps, s) {
3232
3577
  * around the flow (workspace setup, the worktree lifecycle, `run`, the final
3233
3578
  * summary) in `./orchestrator.ts`.
3234
3579
  *
3580
+ * A critique (`build_critique_flow`) is one round over a change already made,
3581
+ * so it has no builder, no loop, and no guard: `measure`, which opens the
3582
+ * round and measures the change from its baseline, then the same `check`,
3583
+ * `critique`, and `record` a run's round ends with.
3584
+ *
3235
3585
  * fascicle's `describe.diagram` draws the tree below from the flow itself, and
3236
3586
  * `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
3237
3587
  * its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
@@ -3275,6 +3625,24 @@ function build_critic(deps) {
3275
3625
  project: with_fallback_cause
3276
3626
  });
3277
3627
  }
3628
+ /** The steps a run and a critique share, built one way for both. */
3629
+ function check_step(deps) {
3630
+ return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
3631
+ }
3632
+ function critique_step(deps, seat) {
3633
+ const critic = build_critic({
3634
+ ...seat,
3635
+ config: deps.config,
3636
+ on_chunk: deps.renderer.critic_chunk
3637
+ });
3638
+ return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
3639
+ description: "the read-only critic's verdict and feedback",
3640
+ arm: critic
3641
+ });
3642
+ }
3643
+ function record_step(deps) {
3644
+ return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
3645
+ }
3278
3646
  function build_flow(deps, resume_from) {
3279
3647
  const { config, renderer } = deps;
3280
3648
  const builder = make_builder_step({
@@ -3283,22 +3651,14 @@ function build_flow(deps, resume_from) {
3283
3651
  on_chunk: renderer.builder_chunk,
3284
3652
  bash_executor: deps.bash_executor
3285
3653
  });
3286
- const critic = build_critic({
3287
- ...deps.seats.critic,
3288
- config,
3289
- on_chunk: renderer.critic_chunk
3290
- });
3291
3654
  const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
3292
3655
  description: "open the iteration, run the builder, measure the change",
3293
3656
  arm: builder
3294
3657
  });
3295
3658
  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" });
3659
+ const check = check_step(deps);
3660
+ const critique = critique_step(deps, deps.seats.critic);
3661
+ const record = record_step(deps);
3302
3662
  const verify = branch({
3303
3663
  name: "verify",
3304
3664
  description: "did the build alone cross the cost cap?",
@@ -3324,6 +3684,19 @@ function build_flow(deps, resume_from) {
3324
3684
  max_rounds: remaining_iterations(config, resume_from)
3325
3685
  });
3326
3686
  }
3687
+ /** A critique: one round over a change already made. `measure` opens it and
3688
+ * measures the change from the baseline, then the check, the critic, and the
3689
+ * record, as in a run's round. No loop and no guard: there is nothing to
3690
+ * iterate on, and the caller reads the outcome off the final state. */
3691
+ function build_critique_flow(deps) {
3692
+ const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
3693
+ return sequence([
3694
+ measure,
3695
+ check_step(deps),
3696
+ critique_step(deps, deps.critic),
3697
+ record_step(deps)
3698
+ ], { name: "critique_run" });
3699
+ }
3327
3700
  //#endregion
3328
3701
  //#region src/orchestrator.ts
3329
3702
  /**
@@ -3340,13 +3713,19 @@ function build_flow(deps, resume_from) {
3340
3713
  * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
3341
3714
  * │ └─ integrate a successful --worktree --git run only
3342
3715
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
3716
+ *
3717
+ * run_critique
3718
+ * ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
3719
+ * ├─ initialize the run's state
3720
+ * └─ run the critique flow one round on the critic's engine alone, then the final summary
3343
3721
  */
3344
3722
  async function run_volley(config, deps, resume_from = null) {
3345
3723
  const { renderer } = deps;
3346
3724
  if (resume_from === null) {
3347
3725
  initialize_run_state(config);
3348
- write_resolved_config(config);
3726
+ if (!write_resolved_config(config) && !detect_containment(process.env)) renderer.warn(`could not sign this run's config with this machine's key (${trust_key_path(process.env)}), so resuming it here will need --trust-config`);
3349
3727
  } else initialize_run_state(config, { preserve: true });
3728
+ renderer.info(`state: ${run_state_dir(config)}`);
3350
3729
  report_worktree_fate(config, renderer);
3351
3730
  const branch = worktree_branch(config.run_id);
3352
3731
  const fate = worktree_fate(config);
@@ -3464,57 +3843,61 @@ function finalize_failed_run(config, err) {
3464
3843
  const summary_path = state_path(config, "summary.json");
3465
3844
  write_run_summary(config, {
3466
3845
  ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
3846
+ run_state_dir: run_state_dir(config),
3467
3847
  status,
3468
3848
  completed_at: (/* @__PURE__ */ new Date()).toISOString()
3469
3849
  });
3470
3850
  } catch {}
3471
3851
  }
3472
- //#endregion
3473
- //#region src/render/format.ts
3474
3852
  /**
3475
- * Display formatting: glyph taxonomy, role colors, truncation.
3476
- * All human output goes to stderr; these helpers only build strings.
3853
+ * `volley critique`: the check and the critic, once, over a change already
3854
+ * made. No builder is constructed, and the engine serves the critic's seat
3855
+ * alone, so a critic can be measured on fixed, labeled changes without paying
3856
+ * for builder turns. It writes the state a one-round run does, with the mode
3857
+ * recorded, and ends `success` on approval over a green check, else
3858
+ * `rejected`.
3477
3859
  */
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;
3860
+ async function run_critique(config, deps) {
3861
+ if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
3862
+ const { renderer } = deps;
3863
+ const baseline = resolve_baseline(config.workspace, deps.baseline);
3864
+ initialize_run_state(config);
3865
+ write_resolved_config(config);
3866
+ renderer.info(`state: ${run_state_dir(config)}`);
3867
+ renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
3868
+ const engine = deps.engine ?? create_volley_engine({
3869
+ workspace: config.workspace,
3870
+ critic_provider: config.critic_provider,
3871
+ critic_url: config.critic_url
3872
+ });
3873
+ const flow = build_critique_flow({
3874
+ config,
3875
+ renderer,
3876
+ baseline,
3877
+ critic: {
3878
+ ...deps.critic,
3879
+ engine
3880
+ },
3881
+ ...deps.check !== void 0 ? { check: deps.check } : {}
3882
+ });
3504
3883
  try {
3505
- text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
3506
- } catch {
3507
- text = String(value);
3884
+ const value = await execute_flow(config, deps, flow, initial_state());
3885
+ const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
3886
+ write_run_summary(config, result);
3887
+ return result;
3888
+ } catch (err) {
3889
+ finalize_failed_run(config, err);
3890
+ throw err;
3891
+ } finally {
3892
+ await engine.dispose();
3508
3893
  }
3509
- const flat = text.replace(/\s+/g, " ");
3510
- return flat.length > max ? `${flat.slice(0, max)}…` : flat;
3511
3894
  }
3512
3895
  //#endregion
3513
3896
  //#region src/render/renderer.ts
3514
3897
  function create_renderer(options) {
3515
3898
  const write = options.write ?? ((text) => process.stderr.write(text));
3516
3899
  const { mode, color } = options;
3517
- const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
3900
+ const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
3518
3901
  const silent = mode === "json";
3519
3902
  const streaming = mode === "default" || mode === "verbose";
3520
3903
  let mid_line = false;
@@ -3592,6 +3975,7 @@ function create_renderer(options) {
3592
3975
  line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
3593
3976
  line(paint("cost", `${GLYPH_COST} total: ${format_usd(result.total_cost_usd)} (builder ${format_usd(result.builder_cost_usd)}, critic ${format_usd(result.critic_cost_usd)})`, color));
3594
3977
  line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
3978
+ line(`state: ${result.run_state_dir}`);
3595
3979
  }
3596
3980
  };
3597
3981
  }
@@ -3613,7 +3997,7 @@ function summarize_output(output) {
3613
3997
  * volley detects it is already contained. The create/`exec`/reap/teardown
3614
3998
  * lifecycle and the `docker exec` bash executor were retired; what
3615
3999
  * 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,
4000
+ * examples and a parent harness render into the `docker run` line,
3617
4001
  * unit-testable without a daemon.
3618
4002
  *
3619
4003
  * Inside that container volley's `bash` is the local `host_bash_executor`
@@ -3638,6 +4022,13 @@ const SANDBOX_WORKDIR = "/workspace";
3638
4022
  * otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
3639
4023
  * brick tools that scribble there. */
3640
4024
  const SANDBOX_HOME = "/home/node";
4025
+ /** Where volley inside the container keeps each run's state when no
4026
+ * `--state-dir` names a root: the default root under the pinned `HOME`, since
4027
+ * the image sets no `XDG_STATE_HOME`. `sandbox_run_args`'s `run_state` binds one
4028
+ * run's host directory at that run's directory under it, so the state outlives
4029
+ * the `--rm` container. Exported so a harness can map a contained run's
4030
+ * `run_state_dir` back to the directory it mounted. */
4031
+ const SANDBOX_STATE_ROOT = default_state_root({}, SANDBOX_HOME);
3641
4032
  /** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
3642
4033
  * already provides it). It is both the `--add-host` target for the allowlist
3643
4034
  * bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
@@ -3669,7 +4060,7 @@ function network_run_args(network, network_name) {
3669
4060
  /**
3670
4061
  * The hardened `docker run` argv that launches volley inside its container.
3671
4062
  * Pure and exported so the flag set is unit-testable without a
3672
- * daemon and the blessed examples / `--dry-run` preflight can render it.
4063
+ * daemon and the blessed examples and a parent harness can render it.
3673
4064
  *
3674
4065
  * A one-shot, foreground `--rm` container — the whole run *is* this
3675
4066
  * container (an earlier design ran a detached `-d … sleep infinity` daemon volley
@@ -3681,6 +4072,25 @@ function network_run_args(network, network_name) {
3681
4072
  * the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
3682
4073
  * this way).
3683
4074
  *
4075
+ * `run_state` binds the run's own state directory on the host,
4076
+ * `<root>/<run_id>/`, at `<SANDBOX_STATE_ROOT>/<run_id>/`, where volley inside
4077
+ * keeps that run's state when no `--state-dir` names a root, so it outlives the
4078
+ * container at the path a run on the host would use. Only the one directory
4079
+ * is mounted, never the root: the builder inside runs arbitrary commands, so
4080
+ * it can rewrite its own run's state but can't read another run's trajectory
4081
+ * or plant a check in another run's config. Its own run's state is still the
4082
+ * builder's to rewrite, config.json included, so a host-side resume refuses
4083
+ * that config, which volley signs only outside the sandbox (`./trust.ts`), and
4084
+ * a directory holding a symlink: resume a contained run inside the container,
4085
+ * with the same mount. Create the
4086
+ * directory on the host first, empty (Docker on Linux makes a missing bind
4087
+ * source owned by root, which the container's user can't write), and pass the
4088
+ * same id to volley as `--run-id`. Keep it outside `build_root`, so the check
4089
+ * never reads it, and pass no `XDG_STATE_HOME` in `env`, which would move the
4090
+ * root off the mount. Omit it and no state is mounted: a contained run keeping
4091
+ * its state under the default root then stops before spending (exit 5),
4092
+ * since the read-only root filesystem leaves it nowhere to write.
4093
+ *
3684
4094
  * `store_volume` mounts the named volume as the pnpm store; omit it and no store
3685
4095
  * is mounted. A store is shared by every run that mounts it, so a caller that
3686
4096
  * installs dependencies in a separate container first can keep the builder's
@@ -3690,7 +4100,7 @@ function network_run_args(network, network_name) {
3690
4100
  * Never emits `--privileged` and refuses `--user 0`: volley must not hand
3691
4101
  * the sandbox root or full capabilities. Everything writable under the
3692
4102
  * `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
3693
- * is the one host-visible write path.
4103
+ * and, when one is named, the state root are the host-visible write paths.
3694
4104
  */
3695
4105
  function sandbox_run_args(options) {
3696
4106
  if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
@@ -3699,6 +4109,7 @@ function sandbox_run_args(options) {
3699
4109
  const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
3700
4110
  const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
3701
4111
  const labels = (options.labels ?? []).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
4112
+ const state = options.run_state !== void 0 && options.run_state !== null ? ["-v", run_state_mount(options.run_state)] : [];
3702
4113
  const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
3703
4114
  return [
3704
4115
  "run",
@@ -3732,6 +4143,7 @@ function sandbox_run_args(options) {
3732
4143
  ...env,
3733
4144
  "-v",
3734
4145
  `${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
4146
+ ...state,
3735
4147
  ...store,
3736
4148
  "-w",
3737
4149
  SANDBOX_WORKDIR,
@@ -3740,6 +4152,13 @@ function sandbox_run_args(options) {
3740
4152
  ...options.command ?? []
3741
4153
  ];
3742
4154
  }
4155
+ /** The `-v` value for one run's state directory. The run id becomes a path on
4156
+ * both sides, so it is held to volley's own `--run-id` rule, which keeps it to
4157
+ * one directory name. */
4158
+ function run_state_mount(run_state) {
4159
+ if (!RUN_ID_PATTERN.test(run_state.run_id)) throw config_error(`run_state.run_id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_state.run_id}`);
4160
+ return `${join(resolve(run_state.root), run_state.run_id)}:${SANDBOX_STATE_ROOT}/${run_state.run_id}`;
4161
+ }
3743
4162
  //#endregion
3744
4163
  //#region src/exit_codes.ts
3745
4164
  /**
@@ -3757,7 +4176,8 @@ const EXIT_INTERRUPTED = 130;
3757
4176
  function exit_code_for_status(status) {
3758
4177
  switch (status) {
3759
4178
  case "success": return 0;
3760
- case "budget_exhausted": return 2;
4179
+ case "budget_exhausted":
4180
+ case "rejected": return 2;
3761
4181
  case "cost_cap_reached": return 7;
3762
4182
  case "gate_edit_blocked": return 8;
3763
4183
  case "interrupted": return 130;
@@ -3792,6 +4212,6 @@ function exit_code_for_error(err) {
3792
4212
  return 3;
3793
4213
  }
3794
4214
  //#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 };
4215
+ export { EXIT_BUDGET_EXHAUSTED, EXIT_BUILDER_ERROR, EXIT_CHECK_ERROR, EXIT_CONFIG_ERROR, EXIT_COST_CAP, EXIT_CRITIC_ERROR, EXIT_INTERRUPTED, EXIT_SUCCESS, SANDBOX_NETWORK_NAME, SANDBOX_NETWORK_SUBNET, SANDBOX_STATE_ROOT, create_renderer, default_state_root, exit_code_for_error, exit_code_for_status, expand_at_file, gate, initial_state, load_config_file, network_run_args, resolve_config, run_critique, run_volley, sandbox_run_args, status_of, verdict_schema };
3796
4216
 
3797
4217
  //# sourceMappingURL=index.js.map