@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/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { cac } from "cac";
@@ -15,9 +15,9 @@ import { parseHTML } from "linkedom";
15
15
  import TurndownService from "turndown";
16
16
  import { Agent, buildConnector, interceptors, request } from "undici";
17
17
  import { z } from "zod";
18
- import { randomUUID } from "node:crypto";
19
- import { filesystem_logger, tee_logger } from "fascicle/adapters";
18
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
20
19
  import pc from "picocolors";
20
+ import { filesystem_logger, tee_logger } from "fascicle/adapters";
21
21
  //#region src/builder/context_check.ts
22
22
  /** Below this, tool schemas start getting truncated for volley's tool set. */
23
23
  const MIN_RECOMMENDED_NUM_CTX = 16384;
@@ -73,6 +73,34 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
73
73
  if (warning !== null) warn(warning);
74
74
  }
75
75
  //#endregion
76
+ //#region src/builder/countdown.ts
77
+ /** How many of a session's last steps carry the countdown: five, or a tenth
78
+ * of a long budget, but never more than half of it, so a short session still
79
+ * starts without one. */
80
+ function countdown_window(max_steps) {
81
+ return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
82
+ }
83
+ /** The note for a turn with `steps_left` steps left, this one included.
84
+ * `check` names how to run the check. On the last step fascicle runs only a
85
+ * call that ends the session, which for the builder is `finish`. */
86
+ function countdown_note(steps_left, check) {
87
+ 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.";
88
+ 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.`;
89
+ }
90
+ /** The `prepare_step` hook that adds the countdown to a session of
91
+ * `max_steps` steps. */
92
+ function step_countdown(max_steps, check) {
93
+ const window = countdown_window(max_steps);
94
+ return ({ step_index, messages }) => {
95
+ const steps_left = max_steps - step_index;
96
+ if (steps_left > window) return void 0;
97
+ return { messages: [...messages, {
98
+ role: "user",
99
+ content: countdown_note(steps_left, check)
100
+ }] };
101
+ };
102
+ }
103
+ //#endregion
76
104
  //#region src/workspace_tools.ts
77
105
  /**
78
106
  * Shared workspace tools: `contain()` path confinement plus the read-only
@@ -103,7 +131,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
103
131
  * escape. Returns the absolute path; throws on containment violation. The
104
132
  * `workspace` root passed here is the build root, which is the per-run git
105
133
  * worktree under `--worktree` (`build_root`), not necessarily the
106
- * control-plane workspace that holds `.volley/`. */
134
+ * workspace the run was started on. */
107
135
  function contain(workspace, rel) {
108
136
  const root = resolve(workspace);
109
137
  const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
@@ -142,6 +170,25 @@ function walk(root, start, limit) {
142
170
  }
143
171
  return out;
144
172
  }
173
+ /** The files a read tool's optional `path` covers: the whole tree, the tree
174
+ * under a directory, or the one file it names. A path that doesn't exist
175
+ * throws, so the seat reads why rather than an empty result that looks like
176
+ * "nothing matched". */
177
+ function scoped_files(workspace, path, limit) {
178
+ const root = resolve(workspace);
179
+ if (path === void 0) return walk(root, root, limit);
180
+ const start = contain(workspace, path);
181
+ let is_dir;
182
+ try {
183
+ is_dir = statSync(start).isDirectory();
184
+ } catch (err) {
185
+ throw new Error(`no such file or directory: ${path}`, { cause: err });
186
+ }
187
+ return is_dir ? walk(root, start, limit) : [{
188
+ rel: relative(root, start),
189
+ abs: start
190
+ }];
191
+ }
145
192
  function build_regex(pattern, flags) {
146
193
  try {
147
194
  return new RegExp(pattern, flags);
@@ -153,17 +200,18 @@ function build_regex(pattern, flags) {
153
200
  const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
154
201
  const search_files_input = z.object({
155
202
  pattern: z.string().describe("JavaScript regular expression to match against each line."),
156
- path: z.string().optional().describe("Optional workspace-relative subdirectory to limit the search to."),
203
+ path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
157
204
  ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
158
205
  });
159
206
  const list_files_input = z.object({
160
- path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
207
+ path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
161
208
  contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
162
209
  });
163
210
  function read_only_tools(workspace) {
164
211
  return [
165
212
  {
166
213
  name: "read_file",
214
+ read_only: true,
167
215
  description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
168
216
  input_schema: read_file_input,
169
217
  execute: (raw) => {
@@ -174,13 +222,13 @@ function read_only_tools(workspace) {
174
222
  },
175
223
  {
176
224
  name: "search_files",
225
+ read_only: true,
177
226
  description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
178
227
  input_schema: search_files_input,
179
228
  execute: (raw) => {
180
229
  const input = search_files_input.parse(raw);
181
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
182
230
  const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
183
- const files = walk(resolve(workspace), start, LIST_MAX_ENTRIES);
231
+ const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
184
232
  const hits = [];
185
233
  for (const file of files) {
186
234
  if (hits.length >= 200) break;
@@ -204,12 +252,12 @@ function read_only_tools(workspace) {
204
252
  },
205
253
  {
206
254
  name: "list_files",
255
+ read_only: true,
207
256
  description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
208
257
  input_schema: list_files_input,
209
258
  execute: (raw) => {
210
259
  const input = list_files_input.parse(raw);
211
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
212
- let files = walk(resolve(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
260
+ let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
213
261
  if (input.contains !== void 0) {
214
262
  const needle = input.contains;
215
263
  files = files.filter((f) => f.includes(needle));
@@ -625,6 +673,273 @@ function finish_tool() {
625
673
  };
626
674
  }
627
675
  //#endregion
676
+ //#region src/types.ts
677
+ function config_error(message) {
678
+ return Object.assign(new Error(message), { kind: "config_error" });
679
+ }
680
+ function check_error(message) {
681
+ return Object.assign(new Error(message), { kind: "check_error" });
682
+ }
683
+ /** Wraps a provider/schema failure with the phase and iteration it hit. */
684
+ function phase_error(phase, iteration, cause) {
685
+ const detail = cause instanceof Error ? cause.message : String(cause);
686
+ return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
687
+ kind: "phase_error",
688
+ phase,
689
+ iteration,
690
+ cause
691
+ });
692
+ }
693
+ function error_kind(err) {
694
+ if (typeof err === "object" && err !== null && "kind" in err) {
695
+ const kind = err.kind;
696
+ return typeof kind === "string" ? kind : null;
697
+ }
698
+ return null;
699
+ }
700
+ //#endregion
701
+ //#region src/check/checkride.ts
702
+ /**
703
+ * checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
704
+ * `.check/summary.json` contract (`schema_version: 1`), and collect raw
705
+ * failing-slot artifacts for the critic — the critic reads what the tool
706
+ * actually said, not a normalized digest. The digest checkride writes on a red
707
+ * run is kept beside them for the next builder, which needs a short list of
708
+ * what to fix more than the whole of every slot's output.
709
+ */
710
+ const CHECK_ARTIFACT_MAX_CHARS = 8e3;
711
+ const CHECKRIDE_SCHEMA_VERSION = 1;
712
+ /** How long a check's output may stay open after its shell exits, while
713
+ * something that escaped its process group still holds it, before the reads
714
+ * stop so the check can finish. */
715
+ const OUTPUT_GRACE_MS = 2e3;
716
+ /** Kill a check's whole process group with SIGKILL. A test runner's workers,
717
+ * and anything the check started in the background, sit in the shell's group,
718
+ * so killing the shell alone would leave them running. Windows has no process
719
+ * groups to signal, so there only the shell goes. */
720
+ function kill_group(proc) {
721
+ if (proc.pid === void 0) return;
722
+ try {
723
+ if (process.platform === "win32") proc.kill("SIGKILL");
724
+ else process.kill(-proc.pid, "SIGKILL");
725
+ } catch {}
726
+ }
727
+ /** Run a check to its end in a process group of its own, capturing its
728
+ * output. The abort kills the whole group and rejects with `aborted_error`,
729
+ * and the shell's exit kills whatever it left behind, so a stopped run leaves
730
+ * no test workers or servers running. */
731
+ function spawn_capture(cmd, args, options) {
732
+ const { abort } = options;
733
+ if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
734
+ return new Promise((resolve_promise, reject) => {
735
+ const proc = spawn(cmd, args, {
736
+ cwd: options.cwd,
737
+ ...options.env !== void 0 ? { env: options.env } : {},
738
+ detached: process.platform !== "win32",
739
+ stdio: [
740
+ "ignore",
741
+ "pipe",
742
+ "pipe"
743
+ ]
744
+ });
745
+ let stdout = "";
746
+ let stderr = "";
747
+ let grace;
748
+ const on_abort = () => {
749
+ kill_group(proc);
750
+ reject(new aborted_error("check aborted"));
751
+ };
752
+ const finish = () => {
753
+ clearTimeout(grace);
754
+ abort?.removeEventListener("abort", on_abort);
755
+ };
756
+ proc.stdout.on("data", (chunk) => {
757
+ stdout += chunk.toString();
758
+ });
759
+ proc.stderr.on("data", (chunk) => {
760
+ stderr += chunk.toString();
761
+ });
762
+ proc.on("error", (err) => {
763
+ finish();
764
+ reject(check_error(`check command failed to start: ${err.message}`));
765
+ });
766
+ proc.on("exit", () => {
767
+ kill_group(proc);
768
+ grace = setTimeout(() => {
769
+ proc.stdout.destroy();
770
+ proc.stderr.destroy();
771
+ }, OUTPUT_GRACE_MS);
772
+ });
773
+ proc.on("close", (code) => {
774
+ finish();
775
+ resolve_promise({
776
+ exit_code: code,
777
+ stdout,
778
+ stderr
779
+ });
780
+ });
781
+ abort?.addEventListener("abort", on_abort, { once: true });
782
+ });
783
+ }
784
+ /** Best-effort parse of the summary contract. `pnpm exec` talks over the
785
+ * command's stdout (pnpm 11's dep-verify prints an "Already up to date"
786
+ * install line ahead of the JSON), so the object is extracted between the
787
+ * first `{` and the last `}` rather than parsed from byte 0. An unexpected
788
+ * schema_version warns and still parses; no JSON object at all is a harness
789
+ * error (exit 4). */
790
+ function parse_checkride_summary(stdout) {
791
+ const start = stdout.indexOf("{");
792
+ const end = stdout.lastIndexOf("}");
793
+ let raw;
794
+ try {
795
+ if (start === -1 || end < start) throw new Error("no JSON object in stdout");
796
+ raw = JSON.parse(stdout.slice(start, end + 1));
797
+ } catch {
798
+ throw check_error("checkride --json produced unparseable stdout");
799
+ }
800
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
801
+ const summary = raw;
802
+ return {
803
+ summary,
804
+ warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
805
+ };
806
+ }
807
+ /** Parse the summary from stdout, falling back to the on-disk contract:
808
+ * `.check/summary.json` is what checkride documents; stdout is its mirror and
809
+ * wrappers may talk over it beyond what the tolerant slice can recover. */
810
+ function parse_summary_with_fallback(stdout, workspace) {
811
+ try {
812
+ return parse_checkride_summary(stdout);
813
+ } catch (err) {
814
+ const file = join(workspace, ".check", "summary.json");
815
+ if (!existsSync(file)) throw err;
816
+ return parse_checkride_summary(readFileSync(file, "utf8"));
817
+ }
818
+ }
819
+ function failing_checks(summary) {
820
+ return summary.checks.filter((c) => !c.ok && c.skipped !== true);
821
+ }
822
+ /** Whether `path` sits under `dir`. */
823
+ function within(dir, path) {
824
+ const from_dir = relative(dir, path);
825
+ return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
826
+ }
827
+ /** Per failing slot: the raw artifact (`<output_file>` when present, else
828
+ * `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
829
+ * iteration's copy of it, truncated with a pointer to the file. The names come
830
+ * from a summary the builder could have written, so a path that leaves
831
+ * `check_dir` has no artifact, rather than putting a file from elsewhere in
832
+ * front of a model. */
833
+ function read_failing_artifacts(check_dir, failing) {
834
+ return failing.map((check) => {
835
+ const file = check.output_file ?? `${check.name}.stdout.txt`;
836
+ const path = join(check_dir, file);
837
+ if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
838
+ slot: check.name,
839
+ path: null,
840
+ content: "(no artifact found)",
841
+ truncated: false
842
+ };
843
+ const raw = readFileSync(path, "utf8");
844
+ const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
845
+ return {
846
+ slot: check.name,
847
+ path,
848
+ content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
849
+ truncated
850
+ };
851
+ });
852
+ }
853
+ /** checkride's digest of a red run (`.check/digest.md`, written under
854
+ * `--digest` and removed on a green one), or undefined when there is none. It
855
+ * is capped by checkride itself, at 8 kB. */
856
+ function read_digest(workspace) {
857
+ const path = join(workspace, ".check", "digest.md");
858
+ return existsSync(path) ? readFileSync(path, "utf8") : void 0;
859
+ }
860
+ async function run_checkride(opts) {
861
+ const { workspace, ...spawn_options } = opts;
862
+ const proc = await spawn_capture("pnpm", [
863
+ "exec",
864
+ "checkride",
865
+ "--json",
866
+ "--digest"
867
+ ], {
868
+ ...spawn_options,
869
+ cwd: workspace
870
+ });
871
+ if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
872
+ const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
873
+ const failing = failing_checks(summary);
874
+ const digest = summary.ok ? void 0 : read_digest(opts.workspace);
875
+ return {
876
+ ran: true,
877
+ runner: "checkride",
878
+ ok: summary.ok,
879
+ exit_code: proc.exit_code,
880
+ duration_ms: summary.total_duration_ms,
881
+ failing_slots: failing.map((c) => c.name),
882
+ detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
883
+ summary,
884
+ ...digest !== void 0 ? { digest } : {}
885
+ };
886
+ }
887
+ //#endregion
888
+ //#region src/check/command.ts
889
+ /**
890
+ * Generic command runner: `sh -c <command>`, combined
891
+ * output captured, exit-0 semantics. Command-not-found is an operator error
892
+ * (exit 4), not an iterable failure.
893
+ */
894
+ async function run_command_check(opts) {
895
+ const { command, workspace, ...spawn_options } = opts;
896
+ const started = Date.now();
897
+ const proc = await spawn_capture("sh", ["-c", command], {
898
+ ...spawn_options,
899
+ cwd: workspace
900
+ });
901
+ if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
902
+ const log = `${proc.stdout}${proc.stderr}`;
903
+ return {
904
+ ran: true,
905
+ runner: "command",
906
+ ok: proc.exit_code === 0,
907
+ exit_code: proc.exit_code,
908
+ duration_ms: Date.now() - started,
909
+ failing_slots: [],
910
+ detail: [],
911
+ log
912
+ };
913
+ }
914
+ function skipped_check(reason) {
915
+ return {
916
+ ran: false,
917
+ runner: "none",
918
+ ok: reason === "none",
919
+ exit_code: null,
920
+ duration_ms: 0,
921
+ failing_slots: [],
922
+ detail: []
923
+ };
924
+ }
925
+ /** What a rejected check says in place of its output. */
926
+ const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
927
+ /** A check the user rejected when it asked to run: recorded as a failed
928
+ * check, with the refusal as its output, so a round can't converge on work
929
+ * nothing checked, and the critic hears why there's no result. */
930
+ function rejected_check(runner) {
931
+ return {
932
+ ran: true,
933
+ runner,
934
+ ok: false,
935
+ exit_code: null,
936
+ duration_ms: 0,
937
+ failing_slots: [],
938
+ detail: [],
939
+ log: CHECK_REJECTED
940
+ };
941
+ }
942
+ //#endregion
628
943
  //#region src/cost.ts
629
944
  /**
630
945
  * Cost and usage accumulation. All pricing knowledge lives in
@@ -664,6 +979,7 @@ function cost_source_of(result) {
664
979
  * its whole degradation ladder. */
665
980
  function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
666
981
  const rate = throughput(result);
982
+ const truncated = result.steps.filter((step) => step.truncated === true).length;
667
983
  return {
668
984
  provider: result.model_resolved.provider,
669
985
  model,
@@ -675,6 +991,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
675
991
  finish_reason: result.finish_reason,
676
992
  tool_calls: result.tool_calls.length,
677
993
  salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
994
+ ...truncated > 0 ? { truncated_turns: truncated } : {},
678
995
  ...rate !== void 0 ? { throughput: rate } : {}
679
996
  };
680
997
  }
@@ -698,31 +1015,6 @@ function cost_cap_hit(config, state) {
698
1015
  return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
699
1016
  }
700
1017
  //#endregion
701
- //#region src/types.ts
702
- function config_error(message) {
703
- return Object.assign(new Error(message), { kind: "config_error" });
704
- }
705
- function check_error(message) {
706
- return Object.assign(new Error(message), { kind: "check_error" });
707
- }
708
- /** Wraps a provider/schema failure with the phase and iteration it hit. */
709
- function phase_error(phase, iteration, cause) {
710
- const detail = cause instanceof Error ? cause.message : String(cause);
711
- return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
712
- kind: "phase_error",
713
- phase,
714
- iteration,
715
- cause
716
- });
717
- }
718
- function error_kind(err) {
719
- if (typeof err === "object" && err !== null && "kind" in err) {
720
- const kind = err.kind;
721
- return typeof kind === "string" ? kind : null;
722
- }
723
- return null;
724
- }
725
- //#endregion
726
1018
  //#region src/engine.ts
727
1019
  /**
728
1020
  * The ONLY module that calls fascicle's `create_engine`. Every
@@ -986,14 +1278,130 @@ async function prewarm_ollama_model(base_url, model, abort) {
986
1278
  } catch {}
987
1279
  }
988
1280
  //#endregion
1281
+ //#region src/containment.ts
1282
+ /**
1283
+ * Whether volley is running inside its sandbox container. A leaf, so the
1284
+ * config resolution, the preflight, and the run-config signing can all ask
1285
+ * without importing one another.
1286
+ */
1287
+ /** volley *detects* it is running inside its sandbox container rather than
1288
+ * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
1289
+ * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
1290
+ * flags> <image> volley run …` is recognized as contained out of the box. Kept
1291
+ * env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
1292
+ * and testable, and so a CI job that itself runs in an unrelated container does
1293
+ * not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
1294
+ * the operator uses `--allow-unsandboxed-builder`). */
1295
+ function detect_containment(env) {
1296
+ const value = env["VOLLEY_CONTAINED"];
1297
+ return value === "1" || value === "true";
1298
+ }
1299
+ //#endregion
1300
+ //#region src/trust.ts
1301
+ /**
1302
+ * Which run configs a host-side resume may trust. A resume runs what a run's
1303
+ * `config.json` says: its check, a shell command, and its seats, permission
1304
+ * mode and prompt included. Inside the sandbox the builder runs arbitrary
1305
+ * commands with write access to its own run's state, so a contained run's
1306
+ * config is the builder's to rewrite, and resuming it on the host would run
1307
+ * whatever the builder left there.
1308
+ *
1309
+ * So volley on the host signs each config it writes, an HMAC under a key of
1310
+ * this machine's that never enters the container, and a host-side resume
1311
+ * trusts only a config whose signature holds. Inside the sandbox nothing is
1312
+ * signed, since the key isn't there and one made there would be in the
1313
+ * builder's reach, so a contained run's config never carries a signature the
1314
+ * host accepts, and the builder can't forge one without the key.
1315
+ */
1316
+ /** Prefixed to what is signed, so a signature over a run config can't pass for
1317
+ * one over anything else under the same key. */
1318
+ const DOMAIN = "volley run config v1\n";
1319
+ /** Where this machine's key lives: `$XDG_CONFIG_HOME/volley/run-key`, else
1320
+ * `~/.config/volley/run-key`, outside every state root, so a container a run
1321
+ * root is mounted into still can't read it. A relative `XDG_CONFIG_HOME` is
1322
+ * ignored, as the XDG spec says. */
1323
+ function trust_key_path(env, home = homedir()) {
1324
+ const xdg = env["XDG_CONFIG_HOME"];
1325
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".config");
1326
+ return join(base, "volley", "run-key");
1327
+ }
1328
+ function error_code(err) {
1329
+ return err.code;
1330
+ }
1331
+ /** The key as stored, or null when there is none or it isn't one. */
1332
+ function read_key(path) {
1333
+ try {
1334
+ const hex = readFileSync(path, "utf8").trim();
1335
+ return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null;
1336
+ } catch {
1337
+ return null;
1338
+ }
1339
+ }
1340
+ /** The key, created on first use: 32 random bytes in a file only its user can
1341
+ * read, in a directory only its user can enter. Two runs that start together
1342
+ * race to create it; the loser reads the winner's. */
1343
+ function machine_key(env) {
1344
+ const path = trust_key_path(env);
1345
+ const existing = read_key(path);
1346
+ if (existing !== null) return existing;
1347
+ try {
1348
+ mkdirSync(dirname(path), {
1349
+ recursive: true,
1350
+ mode: 448
1351
+ });
1352
+ writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, {
1353
+ flag: "wx",
1354
+ mode: 384
1355
+ });
1356
+ } catch (err) {
1357
+ if (error_code(err) !== "EEXIST") return null;
1358
+ }
1359
+ return read_key(path);
1360
+ }
1361
+ function hmac(key, config_text) {
1362
+ return createHmac("sha256", key).update(DOMAIN).update(config_text).digest("hex");
1363
+ }
1364
+ /** This machine's signature over a run's `config.json` text, or null where
1365
+ * there's none to give: inside the sandbox, or when the key can't be read or
1366
+ * created. */
1367
+ function sign_config(config_text, env = process.env) {
1368
+ if (detect_containment(env)) return null;
1369
+ const key = machine_key(env);
1370
+ return key === null ? null : hmac(key, config_text);
1371
+ }
1372
+ /** Does `signature` hold for `config_text` under this machine's key? Never
1373
+ * creates the key: with none there's nothing it could hold under. */
1374
+ function config_signed(config_text, signature, env = process.env) {
1375
+ if (signature === null) return false;
1376
+ const key = read_key(trust_key_path(env));
1377
+ if (key === null) return false;
1378
+ const expected = Buffer.from(hmac(key, config_text), "hex");
1379
+ const given = Buffer.from(signature.trim(), "hex");
1380
+ return given.length === expected.length && timingSafeEqual(given, expected);
1381
+ }
1382
+ //#endregion
989
1383
  //#region src/workspace.ts
990
1384
  /**
991
1385
  * Workspace lifecycle: run-state initialization, stale-run
992
1386
  * backup rotation, path helpers, and optional git checkpoints.
993
1387
  */
1388
+ /** A run id names the run's `volley/<run id>` branch and its state directory,
1389
+ * so it is held to what both take without quoting: letters, digits, `-`, and
1390
+ * `_`, starting with a letter or digit, so it never reads as an option and
1391
+ * never climbs out of the directory it names. */
1392
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
1393
+ /** The root each run's state sits under when `--state-dir` names none:
1394
+ * `$XDG_STATE_HOME/volley`, else `<home>/.local/state/volley`. A relative
1395
+ * `XDG_STATE_HOME` is ignored, as the XDG spec says. `home` is the host's by
1396
+ * default; the sandbox passes its container's to spell the same root inside. */
1397
+ function default_state_root(env, home = homedir()) {
1398
+ const xdg = env["XDG_STATE_HOME"];
1399
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".local", "state");
1400
+ return join(base, "volley");
1401
+ }
994
1402
  /** Where a run keeps its config, summary, trajectory, feedback, verdict, and
995
- * iterations: `<state_dir>/<run_id>/` under a state root, else
996
- * `<workspace>/.volley/`. */
1403
+ * iterations: `<state_dir>/<run_id>/` under its state root, or, in the layout
1404
+ * older runs used (`state_dir` null), `<workspace>/.volley/`. */
997
1405
  function run_state_dir(run) {
998
1406
  return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
999
1407
  }
@@ -1003,6 +1411,17 @@ function state_path(run, ...parts) {
1003
1411
  function iteration_dir(run, iteration) {
1004
1412
  return state_path(run, "iterations", String(iteration).padStart(3, "0"));
1005
1413
  }
1414
+ /** A state root that sits inside `root` (the build root), as the path from
1415
+ * there in git's `/`-separated vocabulary, or null when it sits outside. A
1416
+ * root inside the tree would otherwise pass for the builder's work: listed in
1417
+ * the changes the critic reads, and committed by every `--git` checkpoint.
1418
+ * The legacy `.volley/` (`state_dir` null) is excluded by name already. */
1419
+ function state_root_within(root, state_dir) {
1420
+ if (state_dir === null) return null;
1421
+ const path = relative(resolve(root), resolve(state_dir));
1422
+ if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return null;
1423
+ return path.split(sep).join("/");
1424
+ }
1006
1425
  /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
1007
1426
  * run-state rotation here and the worktree rotation so both preserve
1008
1427
  * prior state under the same never-destroy naming. */
@@ -1012,18 +1431,33 @@ function backup_stamp(date = /* @__PURE__ */ new Date()) {
1012
1431
  /** Fresh-run initialization: rotate a stale state directory aside (a prior
1013
1432
  * `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
1014
1433
  * Under a state root each run has a directory of its own, so only a reused
1015
- * `--run-id` ever finds one to rotate. Resume runs skip rotation
1016
- * (`preserve: true`). */
1434
+ * `--run-id` ever finds one to rotate. An empty one is used as it is: that is
1435
+ * what a contained run is handed when its run directory is the mount, which
1436
+ * can't be renamed. Resume runs skip rotation (`preserve: true`). */
1017
1437
  function initialize_run_state(run, options = {}) {
1018
1438
  const dir = run_state_dir(run);
1019
- if (existsSync(dir) && options.preserve !== true) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
1439
+ if (existsSync(dir) && options.preserve !== true && readdirSync(dir).length > 0) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
1440
+ if (run.state_dir !== null && !existsSync(run.state_dir)) mkdirSync(run.state_dir, {
1441
+ recursive: true,
1442
+ mode: 448
1443
+ });
1020
1444
  mkdirSync(join(dir, "iterations"), { recursive: true });
1021
1445
  }
1022
- function write_resolved_config(config) {
1446
+ /** Beside a run's `config.json`: this machine's signature over it
1447
+ * (`./trust.ts`), which a host-side resume checks before it trusts the
1448
+ * config. */
1449
+ const CONFIG_SIGNATURE = "config.sig";
1450
+ /** Record the run's config, signed when volley runs on the host. Returns
1451
+ * whether it was signed: inside the sandbox it never is, and on the host only
1452
+ * when the machine's key can't be read or created. A signature a reused run
1453
+ * directory held from before is removed rather than left to vouch for this
1454
+ * config. */
1455
+ function write_resolved_config(config, env = process.env) {
1023
1456
  const record = {
1024
1457
  version: config.version,
1025
1458
  run_id: config.run_id,
1026
1459
  started_at: config.started_at,
1460
+ mode: config.mode,
1027
1461
  prompt: config.prompt,
1028
1462
  criteria: config.criteria,
1029
1463
  check: config.check,
@@ -1032,6 +1466,8 @@ function write_resolved_config(config) {
1032
1466
  builder_provider: config.builder_provider,
1033
1467
  builder_url: config.builder_url,
1034
1468
  builder_max_steps: config.builder_max_steps,
1469
+ builder_max_tool_calls: config.builder_max_tool_calls,
1470
+ builder_max_output_tokens: config.builder_max_output_tokens,
1035
1471
  critic_model: config.critic_model,
1036
1472
  critic_provider: config.critic_provider,
1037
1473
  critic_url: config.critic_url,
@@ -1049,7 +1485,38 @@ function write_resolved_config(config) {
1049
1485
  state_dir: config.state_dir,
1050
1486
  workspace: config.workspace
1051
1487
  };
1052
- writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1488
+ const text = `${JSON.stringify(record, null, 2)}\n`;
1489
+ writeFileSync(state_path(config, "config.json"), text);
1490
+ const signature = sign_config(text, env);
1491
+ if (signature === null) {
1492
+ rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
1493
+ return false;
1494
+ }
1495
+ writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
1496
+ return true;
1497
+ }
1498
+ /** The identity volley's own commits fall back to. git won't commit without a
1499
+ * name and an email, and the sandbox image carries no git config, so inside
1500
+ * it a checkpoint or a worktree's salvage commit failed outright; a machine
1501
+ * never set up for git can be the same. `.invalid` is reserved, so the email
1502
+ * can't reach anyone. */
1503
+ const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
1504
+ /** The `-c` flags that give a commit in `cwd` the fallback identity for each
1505
+ * part git has no value for. One the repo or the user configured wins, and so
1506
+ * does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
1507
+ * any config. Put them ahead of the subcommand. */
1508
+ function commit_identity(cwd) {
1509
+ return FALLBACK_IDENTITY.flatMap(([key, value]) => {
1510
+ const set = spawnSync("git", [
1511
+ "config",
1512
+ "--get",
1513
+ key
1514
+ ], {
1515
+ cwd,
1516
+ encoding: "utf8"
1517
+ });
1518
+ return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
1519
+ });
1053
1520
  }
1054
1521
  /**
1055
1522
  * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
@@ -1057,13 +1524,26 @@ function write_resolved_config(config) {
1057
1524
  * workspace on the default path, or the per-run worktree under `--worktree`, so
1058
1525
  * checkpoints land on the worktree branch rather than the workspace's.
1059
1526
  * A checkpoint with nothing to commit is fine; a missing git binary or repo is
1060
- * not.
1527
+ * not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
1528
+ * tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
1529
+ * bare `git add -A` is, and `literal` reads the path as written, wildcards and
1530
+ * all.
1061
1531
  */
1062
- function git_checkpoint(root, message) {
1532
+ function git_checkpoint(root, message, excluded_dir = null) {
1063
1533
  if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
1064
- const add = spawnSync("git", ["add", "-A"], { cwd: root });
1534
+ const pathspec = excluded_dir === null ? [] : [
1535
+ "--",
1536
+ ":/",
1537
+ `:(exclude,literal)${excluded_dir}`
1538
+ ];
1539
+ const add = spawnSync("git", [
1540
+ "add",
1541
+ "-A",
1542
+ ...pathspec
1543
+ ], { cwd: root });
1065
1544
  if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
1066
1545
  const commit = spawnSync("git", [
1546
+ ...commit_identity(root),
1067
1547
  "commit",
1068
1548
  "-m",
1069
1549
  message,
@@ -1071,7 +1551,7 @@ function git_checkpoint(root, message) {
1071
1551
  ], { cwd: root });
1072
1552
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1073
1553
  const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
1074
- if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
1554
+ if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
1075
1555
  }
1076
1556
  /**
1077
1557
  * Integration — bring a successful `--worktree --git` run's effects onto the
@@ -1097,6 +1577,7 @@ function integrate_worktree(workspace, branch, message) {
1097
1577
  if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
1098
1578
  if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
1099
1579
  const commit = spawnSync("git", [
1580
+ ...commit_identity(workspace),
1100
1581
  "commit",
1101
1582
  "-m",
1102
1583
  message,
@@ -1142,9 +1623,8 @@ function worktree_branch(run_id) {
1142
1623
  * when `--worktree` is on, else the workspace itself. This is the containment
1143
1624
  * root the builder/critic file tools resolve through `contain()` and the
1144
1625
  * `bash` tool's cwd — re-pointing it moves writes into the worktree without
1145
- * disturbing the workspace. `.volley/` state, the resolved config, and the
1146
- * deterministic check stay under the workspace (the control plane), so only the
1147
- * model-driven effect surface moves.
1626
+ * disturbing the workspace. The check, the change report, and the checkpoints
1627
+ * follow it there; the run's state stays wherever `state_dir` puts it.
1148
1628
  */
1149
1629
  function build_root(workspace, worktree) {
1150
1630
  return worktree ? worktree_path(workspace) : workspace;
@@ -1362,6 +1842,7 @@ function salvage_worktree(repo, path, branch) {
1362
1842
  if (existsSync(path)) {
1363
1843
  git_quiet(path, ["add", "-A"]);
1364
1844
  git_quiet(path, [
1845
+ ...commit_identity(path),
1365
1846
  "commit",
1366
1847
  "-m",
1367
1848
  `volley: salvage unintegrated worktree (${branch})`,
@@ -1485,12 +1966,22 @@ const HARNESS_APPENDS = {
1485
1966
  volley: "harness_append_local.md",
1486
1967
  acp: "harness_append_acp.md"
1487
1968
  };
1488
- function compose_builder_system_local(tool_set = "volley") {
1489
- return `${[
1969
+ /** Step 2 of "Work this way", as both harness appends word it for a builder
1970
+ * held to one tool call a turn. */
1971
+ const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
1972
+ /** Step 2 for a builder that may make several calls a turn: fascicle runs a
1973
+ * turn's calls in order, so a read can't race an edit, but each change or
1974
+ * command is still worth reading before the next. Reads don't count toward
1975
+ * the cap, so the step names no number. */
1976
+ 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");
1977
+ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
1978
+ const identity = [
1490
1979
  "You are the builder inside the volley harness: an autonomous agent",
1491
1980
  "iterating on a workspace until it satisfies a task and its acceptance",
1492
1981
  "criteria."
1493
- ].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
1982
+ ].join("\n");
1983
+ const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
1984
+ return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
1494
1985
  }
1495
1986
  /** The builder system prompt for the active provider: the `claude_cli`
1496
1987
  * prompt (implicit built-in tools, "the current working directory") for the
@@ -1498,18 +1989,28 @@ function compose_builder_system_local(tool_set = "volley") {
1498
1989
  * the seat's tool set for every provider that runs volley's tool loop,
1499
1990
  * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
1500
1991
  function resolve_builder_system(config, tool_set) {
1501
- return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
1992
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
1502
1993
  }
1503
1994
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
1995
+ /** How the step countdown tells the builder to run the check: the command
1996
+ * volley gates on, with the digest and without `--json`, the configured
1997
+ * command, or the project's own tests when no check gates the run. A
1998
+ * workspace can hold checkride with no `check` script, so not `pnpm check`. */
1999
+ function check_hint(config) {
2000
+ if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
2001
+ if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
2002
+ return "the project's tests";
2003
+ }
1504
2004
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
1505
2005
  * The `claude_cli` builder is confined at the CLI permission layer (allowlist +
1506
2006
  * permission mode) and brings its own built-in tools — this arm is unchanged
1507
2007
  * from v2, and takes no injected tools or approval handler. Every other
1508
2008
  * builder, local or `openrouter`, brings none, so volley supplies its whole
1509
2009
  * workspace tool surface (or the set the caller injected in its place, with
1510
- * the handler that answers its approvals) plus the five per-call loop knobs
1511
- * and **no** schema (it produces a workspace, not a verdict); per-call values
1512
- * win over engine defaults. */
2010
+ * the handler that answers its approvals), the per-call loop knobs, the step
2011
+ * countdown (`./builder/countdown.ts`), the output cap when one is set, and
2012
+ * **no** schema (it produces a workspace, not a verdict); per-call values win
2013
+ * over engine defaults. */
1513
2014
  function builder_tool_options(deps) {
1514
2015
  const { config } = deps;
1515
2016
  const bash_executor = deps.bash_executor ?? null;
@@ -1523,9 +2024,75 @@ function builder_tool_options(deps) {
1523
2024
  max_steps: config.builder_max_steps,
1524
2025
  tool_error_policy: "feed_back",
1525
2026
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
1526
- max_tool_calls_per_step: 1
2027
+ max_tool_calls_per_step: config.builder_max_tool_calls,
2028
+ prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
2029
+ ...config.builder_max_output_tokens !== null ? {
2030
+ max_tokens: config.builder_max_output_tokens,
2031
+ truncated_turn_policy: "continue"
2032
+ } : {}
1527
2033
  };
1528
2034
  }
2035
+ /** How much of the last check's output the builder's prompt carries: a few KB,
2036
+ * whatever the runner. checkride's digest already fits in 8 kB, and the builder
2037
+ * can run the check again for the rest. */
2038
+ const PREVIOUS_CHECK_MAX_CHARS = 6e3;
2039
+ /** The first `max` characters of `text`, saying where it was cut. */
2040
+ function head(text, max, rest) {
2041
+ return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
2042
+ }
2043
+ /** The last `max` characters of `text`, saying what came before was cut. */
2044
+ function tail(text, max) {
2045
+ return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
2046
+ }
2047
+ /** The part of a failed check the builder reads first: checkride's digest,
2048
+ * else the start of each failing slot's raw output, else the end of a
2049
+ * command's output, where test runners and compilers sum up. */
2050
+ function check_excerpt(check) {
2051
+ const max = PREVIOUS_CHECK_MAX_CHARS;
2052
+ if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
2053
+ if (check.detail.length > 0) return head(check.detail.map((artifact) => {
2054
+ const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
2055
+ return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
2056
+ }).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
2057
+ return tail((check.log ?? "").trimEnd(), max);
2058
+ }
2059
+ /** The check section of a later iteration's prompt: what the deterministic
2060
+ * gate itself said about the last session's work, so the builder starts from
2061
+ * its words rather than the critic's account of them, or re-running it to
2062
+ * find out. Empty unless that check ran and failed. A check the user rejected
2063
+ * when it asked to run is recorded as failed but said nothing, and the
2064
+ * section says so rather than call the work failed. */
2065
+ function previous_check_section(check) {
2066
+ if (check === null || !check.ran || check.ok) return [];
2067
+ const heading = [
2068
+ "",
2069
+ "PREVIOUS CHECK RESULT",
2070
+ "---------------------"
2071
+ ];
2072
+ if (check.log === "the check was rejected when it asked to run, so it never ran") return [
2073
+ ...heading,
2074
+ "The check never ran on the work your last session left: it was rejected when it",
2075
+ "asked to run, so nothing has verified that work yet."
2076
+ ];
2077
+ const runner = check.runner === "checkride" ? "checkride, " : "";
2078
+ const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
2079
+ const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
2080
+ if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
2081
+ const excerpt = check_excerpt(check);
2082
+ if (excerpt.length > 0) lines.push("", excerpt);
2083
+ return lines;
2084
+ }
2085
+ /** The line a later iteration's prompt carries when the last session ran out
2086
+ * of steps: it never got to verify, so this one should check first. */
2087
+ function step_limit_lines(step_limit_hit) {
2088
+ if (step_limit_hit === null) return [];
2089
+ return [
2090
+ "",
2091
+ `Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
2092
+ "it may never have verified its work. Run the check first this time, and keep",
2093
+ "enough steps in hand to verify again before you finish."
2094
+ ];
2095
+ }
1529
2096
  function compose_builder_prompt(input) {
1530
2097
  const parts = [
1531
2098
  "TASK",
@@ -1539,7 +2106,8 @@ function compose_builder_prompt(input) {
1539
2106
  `ITERATION: ${input.iteration}`
1540
2107
  ];
1541
2108
  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.");
1542
- 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.");
2109
+ if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
2110
+ 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.");
1543
2111
  return parts.join("\n");
1544
2112
  }
1545
2113
  function builder_max_steps_warning(max_steps) {
@@ -1561,7 +2129,9 @@ function make_builder_step(deps) {
1561
2129
  ...builder_tool_options(deps)
1562
2130
  });
1563
2131
  }
1564
- async function run_builder(deps, state, ctx) {
2132
+ /** Run one builder session on the opened iteration `state`, told what the
2133
+ * iteration before it left behind (`previous`, null on the first). */
2134
+ async function run_builder(deps, state, previous, ctx) {
1565
2135
  const { config } = deps;
1566
2136
  try {
1567
2137
  if (config.builder_provider === "ollama") {
@@ -1574,10 +2144,16 @@ async function run_builder(deps, state, ctx) {
1574
2144
  criteria: config.criteria,
1575
2145
  feedback: state.feedback,
1576
2146
  iteration: state.iteration,
1577
- checkride: config.check_resolved === "checkride"
2147
+ checkride: config.check_resolved === "checkride",
2148
+ previous
1578
2149
  }));
1579
- if (result.finish_reason === "max_steps") deps.warn(builder_max_steps_warning(config.builder_max_steps));
1580
- return accumulate(state, "builder", result, config.builder_model);
2150
+ const built = accumulate(state, "builder", result, config.builder_model);
2151
+ if (result.finish_reason !== "max_steps") return built;
2152
+ deps.warn(builder_max_steps_warning(config.builder_max_steps));
2153
+ return {
2154
+ ...built,
2155
+ builder_max_steps_hits: built.builder_max_steps_hits + 1
2156
+ };
1581
2157
  } catch (err) {
1582
2158
  throw phase_error("builder", state.iteration, err);
1583
2159
  }
@@ -1687,6 +2263,29 @@ function capture_baseline(root) {
1687
2263
  const sha = head.stdout.trim();
1688
2264
  return sha.length > 0 ? sha : null;
1689
2265
  }
2266
+ /**
2267
+ * The commit a critique's change is measured from: `rev` (`--baseline`)
2268
+ * resolved in `root`, so the change is everything that differs from it,
2269
+ * committed or not, untracked files included. With no revision named it's
2270
+ * HEAD, so the uncommitted and untracked edits are the change, and, as for a
2271
+ * run, null where there's no repository or no commit yet. A named revision
2272
+ * that isn't a commit there, or one named where there's no repository, is a
2273
+ * config error.
2274
+ */
2275
+ function resolve_baseline(root, rev) {
2276
+ if (rev === void 0) return capture_baseline(root);
2277
+ if (!in_git_repo(root)) throw config_error(`--baseline needs the workspace to be a git repository: ${root}`);
2278
+ const resolved = git(root, [
2279
+ "rev-parse",
2280
+ "--verify",
2281
+ "--quiet",
2282
+ "--end-of-options",
2283
+ `${rev}^{commit}`
2284
+ ]);
2285
+ const sha = resolved.stdout.trim();
2286
+ if (resolved.status !== 0 || sha.length === 0) throw config_error(`--baseline names no commit in ${root}: ${rev}`);
2287
+ return sha;
2288
+ }
1690
2289
  /** git's status letters, folded onto the four cases worth naming. Rename and
1691
2290
  * copy both record the destination path — where the content lives now. */
1692
2291
  function change_status(code) {
@@ -1723,7 +2322,8 @@ function parse_name_status(stdout) {
1723
2322
  function parse_paths(stdout) {
1724
2323
  return stdout.split("\0").filter((path) => path.length > 0);
1725
2324
  }
1726
- function excluded(path) {
2325
+ function excluded(path, excluded_dir) {
2326
+ if (excluded_dir !== null && path.startsWith(`${excluded_dir}/`)) return true;
1727
2327
  return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix));
1728
2328
  }
1729
2329
  /**
@@ -1803,244 +2403,24 @@ function collect_changes(options) {
1803
2403
  path,
1804
2404
  status: "added"
1805
2405
  }));
2406
+ const excluded_dir = options.excluded_dir ?? null;
1806
2407
  const seen = /* @__PURE__ */ new Set();
1807
2408
  const files = [];
1808
- for (const change of [...tracked, ...untracked]) {
1809
- if (excluded(change.path) || seen.has(change.path)) continue;
1810
- seen.add(change.path);
1811
- files.push({
1812
- ...change,
1813
- gate: matches_any(change.path, options.gate_patterns)
1814
- });
1815
- }
1816
- const sorted = files.toSorted(by_path);
1817
- return {
1818
- baseline,
1819
- gate_edits: sorted.filter((file) => file.gate).map((file) => file.path),
1820
- total: sorted.length,
1821
- files: sorted.slice(0, limit),
1822
- truncated: sorted.length > limit
1823
- };
1824
- }
1825
- //#endregion
1826
- //#region src/check/checkride.ts
1827
- /**
1828
- * checkride runner: spawn `pnpm exec checkride --json`, gate on the
1829
- * `.check/summary.json` contract (`schema_version: 1`), and collect raw
1830
- * failing-slot artifacts for the critic — the critic reads what the tool
1831
- * actually said, not a normalized digest.
1832
- */
1833
- const CHECK_ARTIFACT_MAX_CHARS = 8e3;
1834
- const CHECKRIDE_SCHEMA_VERSION = 1;
1835
- /** How long a check's output may stay open after its shell exits, while
1836
- * something that escaped its process group still holds it, before the reads
1837
- * stop so the check can finish. */
1838
- const OUTPUT_GRACE_MS = 2e3;
1839
- /** Kill a check's whole process group with SIGKILL. A test runner's workers,
1840
- * and anything the check started in the background, sit in the shell's group,
1841
- * so killing the shell alone would leave them running. Windows has no process
1842
- * groups to signal, so there only the shell goes. */
1843
- function kill_group(proc) {
1844
- if (proc.pid === void 0) return;
1845
- try {
1846
- if (process.platform === "win32") proc.kill("SIGKILL");
1847
- else process.kill(-proc.pid, "SIGKILL");
1848
- } catch {}
1849
- }
1850
- /** Run a check to its end in a process group of its own, capturing its
1851
- * output. The abort kills the whole group and rejects with `aborted_error`,
1852
- * and the shell's exit kills whatever it left behind, so a stopped run leaves
1853
- * no test workers or servers running. */
1854
- function spawn_capture(cmd, args, options) {
1855
- const { abort } = options;
1856
- if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
1857
- return new Promise((resolve_promise, reject) => {
1858
- const proc = spawn(cmd, args, {
1859
- cwd: options.cwd,
1860
- ...options.env !== void 0 ? { env: options.env } : {},
1861
- detached: process.platform !== "win32",
1862
- stdio: [
1863
- "ignore",
1864
- "pipe",
1865
- "pipe"
1866
- ]
1867
- });
1868
- let stdout = "";
1869
- let stderr = "";
1870
- let grace;
1871
- const on_abort = () => {
1872
- kill_group(proc);
1873
- reject(new aborted_error("check aborted"));
1874
- };
1875
- const finish = () => {
1876
- clearTimeout(grace);
1877
- abort?.removeEventListener("abort", on_abort);
1878
- };
1879
- proc.stdout.on("data", (chunk) => {
1880
- stdout += chunk.toString();
1881
- });
1882
- proc.stderr.on("data", (chunk) => {
1883
- stderr += chunk.toString();
1884
- });
1885
- proc.on("error", (err) => {
1886
- finish();
1887
- reject(check_error(`check command failed to start: ${err.message}`));
1888
- });
1889
- proc.on("exit", () => {
1890
- kill_group(proc);
1891
- grace = setTimeout(() => {
1892
- proc.stdout.destroy();
1893
- proc.stderr.destroy();
1894
- }, OUTPUT_GRACE_MS);
1895
- });
1896
- proc.on("close", (code) => {
1897
- finish();
1898
- resolve_promise({
1899
- exit_code: code,
1900
- stdout,
1901
- stderr
1902
- });
1903
- });
1904
- abort?.addEventListener("abort", on_abort, { once: true });
1905
- });
1906
- }
1907
- /** Best-effort parse of the summary contract. `pnpm exec` talks over the
1908
- * command's stdout (pnpm 11's dep-verify prints an "Already up to date"
1909
- * install line ahead of the JSON), so the object is extracted between the
1910
- * first `{` and the last `}` rather than parsed from byte 0. An unexpected
1911
- * schema_version warns and still parses; no JSON object at all is a harness
1912
- * error (exit 4). */
1913
- function parse_checkride_summary(stdout) {
1914
- const start = stdout.indexOf("{");
1915
- const end = stdout.lastIndexOf("}");
1916
- let raw;
1917
- try {
1918
- if (start === -1 || end < start) throw new Error("no JSON object in stdout");
1919
- raw = JSON.parse(stdout.slice(start, end + 1));
1920
- } catch {
1921
- throw check_error("checkride --json produced unparseable stdout");
1922
- }
1923
- if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
1924
- const summary = raw;
1925
- return {
1926
- summary,
1927
- warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
1928
- };
1929
- }
1930
- /** Parse the summary from stdout, falling back to the on-disk contract:
1931
- * `.check/summary.json` is what checkride documents; stdout is its mirror and
1932
- * wrappers may talk over it beyond what the tolerant slice can recover. */
1933
- function parse_summary_with_fallback(stdout, workspace) {
1934
- try {
1935
- return parse_checkride_summary(stdout);
1936
- } catch (err) {
1937
- const file = join(workspace, ".check", "summary.json");
1938
- if (!existsSync(file)) throw err;
1939
- return parse_checkride_summary(readFileSync(file, "utf8"));
1940
- }
1941
- }
1942
- function failing_checks(summary) {
1943
- return summary.checks.filter((c) => !c.ok && c.skipped !== true);
1944
- }
1945
- /** Per failing slot: the raw artifact (`.check/<output_file>` when present,
1946
- * else `.check/<slot>.stdout.txt`), truncated with a pointer to the file. */
1947
- function read_failing_artifacts(workspace, failing) {
1948
- return failing.map((check) => {
1949
- const file = check.output_file ?? `${check.name}.stdout.txt`;
1950
- const path = join(workspace, ".check", file);
1951
- if (!existsSync(path)) return {
1952
- slot: check.name,
1953
- path: null,
1954
- content: "(no artifact found)",
1955
- truncated: false
1956
- };
1957
- const raw = readFileSync(path, "utf8");
1958
- const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
1959
- return {
1960
- slot: check.name,
1961
- path,
1962
- content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
1963
- truncated
1964
- };
1965
- });
1966
- }
1967
- async function run_checkride(opts) {
1968
- const { workspace, ...spawn_options } = opts;
1969
- const proc = await spawn_capture("pnpm", [
1970
- "exec",
1971
- "checkride",
1972
- "--json"
1973
- ], {
1974
- ...spawn_options,
1975
- cwd: workspace
1976
- });
1977
- if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
1978
- const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
1979
- const failing = failing_checks(summary);
1980
- return {
1981
- ran: true,
1982
- runner: "checkride",
1983
- ok: summary.ok,
1984
- exit_code: proc.exit_code,
1985
- duration_ms: summary.total_duration_ms,
1986
- failing_slots: failing.map((c) => c.name),
1987
- detail: read_failing_artifacts(opts.workspace, failing),
1988
- summary
1989
- };
1990
- }
1991
- //#endregion
1992
- //#region src/check/command.ts
1993
- /**
1994
- * Generic command runner: `sh -c <command>`, combined
1995
- * output captured, exit-0 semantics. Command-not-found is an operator error
1996
- * (exit 4), not an iterable failure.
1997
- */
1998
- async function run_command_check(opts) {
1999
- const { command, workspace, ...spawn_options } = opts;
2000
- const started = Date.now();
2001
- const proc = await spawn_capture("sh", ["-c", command], {
2002
- ...spawn_options,
2003
- cwd: workspace
2004
- });
2005
- if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
2006
- const log = `${proc.stdout}${proc.stderr}`;
2007
- return {
2008
- ran: true,
2009
- runner: "command",
2010
- ok: proc.exit_code === 0,
2011
- exit_code: proc.exit_code,
2012
- duration_ms: Date.now() - started,
2013
- failing_slots: [],
2014
- detail: [],
2015
- log
2016
- };
2017
- }
2018
- function skipped_check(reason) {
2019
- return {
2020
- ran: false,
2021
- runner: "none",
2022
- ok: reason === "none",
2023
- exit_code: null,
2024
- duration_ms: 0,
2025
- failing_slots: [],
2026
- detail: []
2027
- };
2028
- }
2029
- /** What a rejected check says in place of its output. */
2030
- const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
2031
- /** A check the user rejected when it asked to run: recorded as a failed
2032
- * check, with the refusal as its output, so a round can't converge on work
2033
- * nothing checked, and the critic hears why there's no result. */
2034
- function rejected_check(runner) {
2409
+ for (const change of [...tracked, ...untracked]) {
2410
+ if (excluded(change.path, excluded_dir) || seen.has(change.path)) continue;
2411
+ seen.add(change.path);
2412
+ files.push({
2413
+ ...change,
2414
+ gate: matches_any(change.path, options.gate_patterns)
2415
+ });
2416
+ }
2417
+ const sorted = files.toSorted(by_path);
2035
2418
  return {
2036
- ran: true,
2037
- runner,
2038
- ok: false,
2039
- exit_code: null,
2040
- duration_ms: 0,
2041
- failing_slots: [],
2042
- detail: [],
2043
- log: CHECK_REJECTED
2419
+ baseline,
2420
+ gate_edits: sorted.filter((file) => file.gate).map((file) => file.path),
2421
+ total: sorted.length,
2422
+ files: sorted.slice(0, limit),
2423
+ truncated: sorted.length > limit
2044
2424
  };
2045
2425
  }
2046
2426
  //#endregion
@@ -2169,7 +2549,7 @@ function compose_critic_prompt(input) {
2169
2549
  * Critic invocation: a read-only session (`claude_cli`, or a local or
2170
2550
  * `openrouter` model with volley's read tools) returning a schema-validated
2171
2551
  * structured verdict. The
2172
- * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2552
+ * harness — not the critic — writes the run's `feedback.md` and `verdict`.
2173
2553
  */
2174
2554
  const CRITIC_ALLOWED_TOOLS = [
2175
2555
  "Read",
@@ -2177,10 +2557,16 @@ const CRITIC_ALLOWED_TOOLS = [
2177
2557
  "Glob"
2178
2558
  ];
2179
2559
  const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2560
+ /** A critic that holds tools isn't constrain-decoded, so nothing stops it
2561
+ * answering null for the feedback or the unmet list, as GLM did on approving.
2562
+ * Both fields allow null and read it as empty, so that answer is a verdict
2563
+ * rather than a schema failure that ends the run. fascicle sends the schema's
2564
+ * input side, so the model sees each field as its type or null, still
2565
+ * required; `.describe()` comes last so the description stays on the field. */
2180
2566
  const verdict_schema = z.object({
2181
2567
  verdict: z.enum(["approved", "changes_requested"]),
2182
- feedback: z.string().describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
2183
- unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2568
+ 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."),
2569
+ unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2184
2570
  });
2185
2571
  /** Read-only tool wiring per provider. The claude_cli critic is confined at
2186
2572
  * the CLI permission layer (allowlist + explicit disallow); every other critic,
@@ -2483,6 +2869,62 @@ async function run_critic(deps, state, ctx) {
2483
2869
  }
2484
2870
  }
2485
2871
  //#endregion
2872
+ //#region src/render/format.ts
2873
+ /**
2874
+ * Display formatting: glyph taxonomy, role colors, truncation.
2875
+ * All human output goes to stderr; these helpers only build strings.
2876
+ */
2877
+ const GLYPH_TOOL = "🔧";
2878
+ const GLYPH_COST = "💰";
2879
+ const VERBOSE_TRUNCATE_CHARS = 4e3;
2880
+ function colors_enabled(env = process.env, is_tty = process.stderr.isTTY === true) {
2881
+ if (env["NO_COLOR"] !== void 0 || env["VOLLEY_NO_COLOR"] !== void 0) return false;
2882
+ return is_tty;
2883
+ }
2884
+ function paint(role, text, color) {
2885
+ if (!color) return text;
2886
+ switch (role) {
2887
+ case "builder": return pc.cyan(text);
2888
+ case "critic": return pc.yellow(text);
2889
+ case "check": return pc.green(text);
2890
+ case "cost": return pc.magenta(text);
2891
+ case "error": return pc.bold(pc.red(text));
2892
+ }
2893
+ }
2894
+ function dim(text, color) {
2895
+ return color ? pc.dim(text) : text;
2896
+ }
2897
+ function truncate(text, max, pointer) {
2898
+ if (text.length <= max) return text;
2899
+ return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
2900
+ }
2901
+ /** Controls, format characters (bidi overrides, zero-widths), and line and
2902
+ * paragraph separators: what can move the cursor, hide text, or reorder a line
2903
+ * in a terminal. */
2904
+ const UNSAFE_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
2905
+ /** A value from a file volley can't vouch for, quoted for a person to judge,
2906
+ * with every character that could redraw or reorder the line escaped, so a
2907
+ * rewritten config can't make the warning about it say something else. JSON
2908
+ * quoting escapes the C0 controls, the quote, and the backslash; the rest are
2909
+ * written as `\u{…}`. */
2910
+ function shown(value) {
2911
+ return JSON.stringify(value).replace(UNSAFE_CHARACTERS, (character) => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
2912
+ }
2913
+ function format_usd(value) {
2914
+ return value === null ? "$?" : `$${value.toFixed(3)}`;
2915
+ }
2916
+ /** One-line summary of a tool input for the default streaming mode. */
2917
+ function summarize_value(value, max = 120) {
2918
+ let text;
2919
+ try {
2920
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
2921
+ } catch {
2922
+ text = String(value);
2923
+ }
2924
+ const flat = text.replace(/\s+/g, " ");
2925
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
2926
+ }
2927
+ //#endregion
2486
2928
  //#region src/iteration.ts
2487
2929
  /**
2488
2930
  * Iteration archiving, run-level summary, and resume-state recovery.
@@ -2510,6 +2952,7 @@ function phase_summary(record) {
2510
2952
  tool_calls: record.tool_calls,
2511
2953
  salvaged_tool_calls: record.salvaged_tool_calls,
2512
2954
  salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
2955
+ ...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
2513
2956
  ...record.retries !== void 0 ? { retries: record.retries } : {},
2514
2957
  ...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
2515
2958
  ...record.critic_degraded === true ? { critic_degraded: true } : {},
@@ -2532,6 +2975,8 @@ function archive_iteration(config, state) {
2532
2975
  if (check.runner === "checkride") {
2533
2976
  if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
2534
2977
  for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
2978
+ if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
2979
+ if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
2535
2980
  } else {
2536
2981
  writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
2537
2982
  writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
@@ -2572,24 +3017,177 @@ function run_result_from_state(config, state, status, completed_at) {
2572
3017
  builder_cost_usd: state.builder_cost_usd,
2573
3018
  critic_cost_usd: state.critic_cost_usd,
2574
3019
  check_duration_ms: state.check_duration_ms,
3020
+ builder_max_steps_hits: state.builder_max_steps_hits,
2575
3021
  final_verdict: state.verdict,
2576
- salvaged_branch: null
3022
+ salvaged_branch: null,
3023
+ run_state_dir: run_state_dir(config),
3024
+ mode: config.mode
3025
+ };
3026
+ }
3027
+ /** Why a host-side resume won't run a config this machine didn't sign. */
3028
+ function untrusted_config(run_id, config_path) {
3029
+ return config_error(`run ${run_id}'s config.json has no signature from volley on this machine, so a resume here won't run it: inside the sandbox a run's builder can rewrite its config, and a resume runs the check and the seats it names. Resume a contained run inside its container, with the same mount. If ${config_path} is another machine's, an older volley's, or one you edited, read it, then pass --trust-config.`);
3030
+ }
3031
+ /** Whether a host-side resume may run the config in `text`: signed by this
3032
+ * machine, or trusted by the caller. Inside the sandbox nothing is checked: a
3033
+ * resume there runs only within the container, as the builder already does. */
3034
+ function check_config_trust(text, run, options) {
3035
+ const env = options.env ?? process.env;
3036
+ if (detect_containment(env)) return null;
3037
+ const signature_path = state_path(run, CONFIG_SIGNATURE);
3038
+ const signed = config_signed(text, existsSync(signature_path) ? readFileSync(signature_path, "utf8") : null, env);
3039
+ if (!signed && options.trust_config !== true) throw untrusted_config(run.run_id, state_path(run, "config.json"));
3040
+ return signed;
3041
+ }
3042
+ /** The run id an older volley's `.volley/config.json` records, or null when
3043
+ * there is none to read. */
3044
+ function legacy_run_id(workspace) {
3045
+ try {
3046
+ const recorded = JSON.parse(readFileSync(join(workspace, ".volley", "config.json"), "utf8"));
3047
+ return typeof recorded.run_id === "string" ? recorded.run_id : null;
3048
+ } catch {
3049
+ return null;
3050
+ }
3051
+ }
3052
+ /** Where `volley resume <run_id>` finds a run: under `root` (`--state-dir`,
3053
+ * else the default root) at `<root>/<run_id>/`, returned as that root, or, for
3054
+ * a run an older volley kept in the workspace, `.volley/`, returned as null so
3055
+ * the resumed run stays there. Finding neither is a config error naming both
3056
+ * places it looked. The id becomes a path, so it must be one a run could have
3057
+ * had. */
3058
+ function find_run_state(workspace, run_id, root) {
3059
+ if (!RUN_ID_PATTERN.test(run_id)) throw config_error(`not a run id: ${run_id} (letters, digits, '-', and '_', starting with a letter or digit)`);
3060
+ if (existsSync(join(root, run_id, "config.json"))) return root;
3061
+ const legacy = legacy_run_id(workspace);
3062
+ if (legacy === run_id) return null;
3063
+ const holds = legacy === null ? "" : ` (it holds run ${shown(legacy)})`;
3064
+ throw config_error(`run ${run_id} not found in ${join(root, run_id)} or in ${join(resolve(workspace), ".volley")}${holds}; pass --state-dir for the root it ran under, or --workspace for a run kept in .volley/`);
3065
+ }
3066
+ /** A resumed run writes into its state directory, and a contained run's
3067
+ * builder could have written there first, so a symlink anywhere in it would
3068
+ * aim the resume's writes, or its pruning of a half-finished iteration, at a
3069
+ * file of the host's. volley never makes one, so any is refused. The walk
3070
+ * reads each entry's own type and never descends a link, which Node's
3071
+ * recursive `readdirSync` would. */
3072
+ function refuse_symlinks(dir) {
3073
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
3074
+ const path = join(dir, entry.name);
3075
+ if (entry.isSymbolicLink()) throw config_error(`refusing to resume: ${path} is a symbolic link, which volley never writes`);
3076
+ if (entry.isDirectory()) refuse_symlinks(path);
3077
+ }
3078
+ }
3079
+ /** An archived file's text, or undefined when there's no file to read there. */
3080
+ function read_text(path) {
3081
+ try {
3082
+ return readFileSync(path, "utf8");
3083
+ } catch {
3084
+ return;
3085
+ }
3086
+ }
3087
+ function read_json(path) {
3088
+ const text = read_text(path);
3089
+ if (text === void 0) return null;
3090
+ try {
3091
+ return JSON.parse(text);
3092
+ } catch {
3093
+ return null;
3094
+ }
3095
+ }
3096
+ /** The failing slots' raw artifacts from an archived iteration's `check/`,
3097
+ * which holds each under its base name, found through the archived checkride
3098
+ * summary. */
3099
+ function archived_artifacts(check_dir, failing_slots) {
3100
+ const summary = read_json(join(check_dir, "summary.json"));
3101
+ const checks = Array.isArray(summary?.checks) ? summary.checks : [];
3102
+ return read_failing_artifacts(check_dir, failing_slots.map((name) => {
3103
+ const output_file = checks.find((check) => check.name === name)?.output_file;
3104
+ return {
3105
+ name,
3106
+ adapter: null,
3107
+ ok: false,
3108
+ exit_code: null,
3109
+ duration_ms: 0,
3110
+ output_file: typeof output_file === "string" ? basename(output_file) : null
3111
+ };
3112
+ }));
3113
+ }
3114
+ /** The check an archived iteration ran, rebuilt from its directory with the
3115
+ * output its failure is shown by: the digest and the failing slots' artifacts
3116
+ * for checkride, the log for a command or a rejected check. Null when it ran
3117
+ * none. */
3118
+ function archived_check(dir, recorded) {
3119
+ if (recorded?.ran !== true || typeof recorded.ok !== "boolean") return null;
3120
+ const runner = recorded.runner === "checkride" ? "checkride" : "command";
3121
+ const failing_slots = Array.isArray(recorded.failing_slots) ? recorded.failing_slots.filter((slot) => typeof slot === "string") : [];
3122
+ const check = {
3123
+ ran: true,
3124
+ runner,
3125
+ ok: recorded.ok,
3126
+ exit_code: typeof recorded.exit_code === "number" ? recorded.exit_code : null,
3127
+ duration_ms: typeof recorded.duration_ms === "number" ? recorded.duration_ms : 0,
3128
+ failing_slots,
3129
+ detail: []
3130
+ };
3131
+ if (check.ok) return check;
3132
+ const check_dir = join(dir, "check");
3133
+ const log = read_text(join(check_dir, "check.log"));
3134
+ const logged = log === void 0 ? check : {
3135
+ ...check,
3136
+ log
3137
+ };
3138
+ if (runner === "command") return logged;
3139
+ const digest = read_text(join(check_dir, "digest.md"));
3140
+ return {
3141
+ ...logged,
3142
+ detail: archived_artifacts(check_dir, failing_slots),
3143
+ ...digest !== void 0 ? { digest } : {}
3144
+ };
3145
+ }
3146
+ /** An archived builder record, as `archive_iteration` wrote it from the
3147
+ * phase's own, or null when there is none with a finish reason. */
3148
+ function archived_builder(recorded) {
3149
+ if (typeof recorded !== "object" || recorded === null) return null;
3150
+ return typeof recorded.finish_reason === "string" ? recorded : null;
3151
+ }
3152
+ /** What the last completed iteration left for the next build: its check and
3153
+ * its builder's record, which tell the resumed run's first builder what the
3154
+ * gate said and whether the last session ran out of steps, as a run that never
3155
+ * stopped would have. Null for both before the first iteration. */
3156
+ function archived_round(run, completed) {
3157
+ if (completed === 0) return {
3158
+ check: null,
3159
+ builder: null
3160
+ };
3161
+ const dir = iteration_dir(run, completed);
3162
+ const recorded = read_json(join(dir, "summary.json"));
3163
+ return {
3164
+ check: archived_check(dir, recorded?.check),
3165
+ builder: archived_builder(recorded?.builder)
2577
3166
  };
2578
3167
  }
2579
3168
  /** Recover a resumable run from its state directory (`.volley/`, or
2580
3169
  * `<state_dir>/<run_id>/` under a state root): settings from config.json,
2581
3170
  * totals from summary.json, feedback from feedback.md. An iteration directory
2582
- * without a summary.json was interrupted mid-phase and is discarded. */
2583
- function load_resume_state(workspace, run_id, state_dir = null) {
3171
+ * without a summary.json was interrupted mid-phase and is discarded. A
3172
+ * directory with a symlink in it is refused before anything is read. */
3173
+ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
2584
3174
  const run = {
2585
3175
  workspace,
2586
3176
  state_dir,
2587
3177
  run_id
2588
3178
  };
3179
+ const run_dir = run_state_dir(run);
3180
+ if (existsSync(run_dir)) {
3181
+ if (lstatSync(run_dir).isSymbolicLink()) throw config_error(`refusing to resume: ${run_dir} is a symbolic link, which volley never writes`);
3182
+ refuse_symlinks(run_dir);
3183
+ }
2589
3184
  const config_path = state_path(run, "config.json");
2590
3185
  if (!existsSync(config_path)) throw config_error(`no resumable run found: ${config_path} missing`);
2591
- const recorded = JSON.parse(readFileSync(config_path, "utf8"));
3186
+ const config_text = readFileSync(config_path, "utf8");
3187
+ const signed = check_config_trust(config_text, run, options);
3188
+ const recorded = JSON.parse(config_text);
2592
3189
  if (recorded["run_id"] !== run_id) throw config_error(`run ${run_id} not found in ${run_state_dir(run)} (recorded run: ${String(recorded["run_id"])})`);
3190
+ if (recorded["mode"] === "critique") throw config_error(`run ${run_id} is a critique, which has one round and nothing to resume; run volley critique again`);
2593
3191
  const iterations_root = state_path(run, "iterations");
2594
3192
  const dirs = existsSync(iterations_root) ? readdirSync(iterations_root).filter((d) => /^\d{3}$/.test(d)).toSorted() : [];
2595
3193
  let completed = 0;
@@ -2614,6 +3212,8 @@ function load_resume_state(workspace, run_id, state_dir = null) {
2614
3212
  builder_provider: recorded["builder_provider"] ?? "claude_cli",
2615
3213
  ...typeof recorded["builder_url"] === "string" ? { builder_url: recorded["builder_url"] } : {},
2616
3214
  ...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
3215
+ ...recorded["builder_max_tool_calls"] === void 0 ? {} : { builder_max_tool_calls: Number(recorded["builder_max_tool_calls"]) },
3216
+ ...typeof recorded["builder_max_output_tokens"] === "number" ? { builder_max_output_tokens: recorded["builder_max_output_tokens"] } : {},
2617
3217
  critic_model: String(recorded["critic_model"]),
2618
3218
  critic_provider: recorded["critic_provider"] ?? "claude_cli",
2619
3219
  ...typeof recorded["critic_url"] === "string" ? { critic_url: recorded["critic_url"] } : {},
@@ -2635,15 +3235,15 @@ function load_resume_state(workspace, run_id, state_dir = null) {
2635
3235
  feedback,
2636
3236
  verdict: null,
2637
3237
  unmet_criteria: [],
2638
- check: null,
3238
+ ...archived_round(run, completed),
2639
3239
  changes: null,
2640
- builder: null,
2641
3240
  critic: null,
2642
3241
  total_usage: summary?.total_usage ?? EMPTY_USAGE,
2643
3242
  total_cost_usd: summary?.total_cost_usd ?? 0,
2644
3243
  builder_cost_usd: summary?.builder_cost_usd ?? 0,
2645
3244
  critic_cost_usd: summary?.critic_cost_usd ?? 0,
2646
3245
  check_duration_ms: summary?.check_duration_ms ?? 0,
3246
+ builder_max_steps_hits: summary?.builder_max_steps_hits ?? 0,
2647
3247
  iteration_cost_usd: 0,
2648
3248
  halt: null,
2649
3249
  cost_warned: false
@@ -2653,7 +3253,8 @@ function load_resume_state(workspace, run_id, state_dir = null) {
2653
3253
  run_id,
2654
3254
  started_at: String(recorded["started_at"]),
2655
3255
  state,
2656
- iterations_completed: completed
3256
+ iterations_completed: completed,
3257
+ config_signed: signed
2657
3258
  };
2658
3259
  }
2659
3260
  //#endregion
@@ -2753,7 +3354,7 @@ function build_run_summary(config, result) {
2753
3354
  return {
2754
3355
  ...result,
2755
3356
  comparison: {
2756
- builder_transport: transport_of(config.builder_provider),
3357
+ builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
2757
3358
  critic_transport: transport_of(config.critic_provider),
2758
3359
  iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2759
3360
  wall_clock_ms,
@@ -2809,7 +3410,7 @@ function record_check_chunk(ctx, chunk) {
2809
3410
  }
2810
3411
  /** The command line the check runs, or null when no check is configured. */
2811
3412
  function check_command(config) {
2812
- if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
3413
+ if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
2813
3414
  if (config.check_resolved === "command") return config.check;
2814
3415
  return null;
2815
3416
  }
@@ -2828,7 +3429,16 @@ function report_changes(config, renderer, changes) {
2828
3429
  if (changes.gate_edits.length === 0) return;
2829
3430
  const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
2830
3431
  const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
2831
- 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)." : ""));
3432
+ const [who, whose, edits] = config.mode === "critique" ? [
3433
+ "the change under review touches",
3434
+ "whether it passes",
3435
+ "the change edits"
3436
+ ] : [
3437
+ "the builder changed",
3438
+ "whether its work passes",
3439
+ "the builder can edit"
3440
+ ];
3441
+ 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)." : ""));
2832
3442
  }
2833
3443
  /** Run the configured check, checkride or a command, with the environment
2834
3444
  * the caller set for it, else volley's own. */
@@ -2882,6 +3492,33 @@ function next_iteration(s) {
2882
3492
  unmet_criteria: []
2883
3493
  };
2884
3494
  }
3495
+ /** What the builder of the iteration after `s` hears about it beyond the
3496
+ * critic's feedback: its check, and whether its builder ran out of steps. Null
3497
+ * before the first iteration. A resumed run's state carries both, read back
3498
+ * from the last archived iteration. */
3499
+ function previous_round(config, s) {
3500
+ if (s.iteration === 0) return null;
3501
+ return {
3502
+ check: s.check,
3503
+ step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
3504
+ };
3505
+ }
3506
+ /** What changed in the build root since the baseline, reported as it's
3507
+ * measured: the count, and a warning for any gate edit. A run measures the
3508
+ * builder's work this way after every build, and a critique measures the
3509
+ * change under review the same way, so the critic is shown one shape either
3510
+ * way. */
3511
+ function measure_changes(deps, root) {
3512
+ const { config, renderer } = deps;
3513
+ const changes = collect_changes({
3514
+ root,
3515
+ baseline: deps.baseline,
3516
+ gate_patterns: config.gate_paths,
3517
+ excluded_dir: state_root_within(root, config.state_dir)
3518
+ });
3519
+ report_changes(config, renderer, changes);
3520
+ return changes;
3521
+ }
2885
3522
  /** `build`: one builder session edits the build root, then volley measures what
2886
3523
  * it changed and, under `--git`, checkpoints it. */
2887
3524
  async function build_phase(deps, builder, s, ctx) {
@@ -2896,22 +3533,32 @@ async function build_phase(deps, builder, s, ctx) {
2896
3533
  builder,
2897
3534
  config,
2898
3535
  warn: renderer.warn
2899
- }, next, ctx);
3536
+ }, next, previous_round(config, s), ctx);
2900
3537
  renderer.phase_end(built.iteration, "builder", true);
2901
3538
  renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
2902
3539
  if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
2903
- const changes = collect_changes({
2904
- root: build_root(config.workspace, config.worktree),
2905
- baseline: deps.baseline,
2906
- gate_patterns: config.gate_paths
2907
- });
2908
- report_changes(config, renderer, changes);
2909
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(built.iteration)}: build`);
3540
+ const root = build_root(config.workspace, config.worktree);
3541
+ const changes = measure_changes(deps, root);
3542
+ if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
2910
3543
  return {
2911
3544
  ...built,
2912
3545
  changes
2913
3546
  };
2914
3547
  }
3548
+ /** `measure`: a critique's one round opens, and the change under review is
3549
+ * measured from the baseline, as a run measures the builder's work. */
3550
+ function measure_phase(deps, s, ctx) {
3551
+ const next = next_iteration(s);
3552
+ emit(ctx, {
3553
+ volley: "round_start",
3554
+ round: next.iteration
3555
+ });
3556
+ const root = build_root(deps.config.workspace, deps.config.worktree);
3557
+ return {
3558
+ ...next,
3559
+ changes: measure_changes(deps, root)
3560
+ };
3561
+ }
2915
3562
  /** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
2916
3563
  * as skipped and no critic is called — a doomed iteration spends nothing more.
2917
3564
  * The loop guard then halts the run on the cap. */
@@ -3025,7 +3672,10 @@ function record_phase(deps, s) {
3025
3672
  const { config } = deps;
3026
3673
  archive_iteration(config, s);
3027
3674
  write_run_summary(config, run_result_from_state(config, s, "running", null));
3028
- if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`);
3675
+ if (config.git_checkpoints) {
3676
+ const root = build_root(config.workspace, config.worktree);
3677
+ git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
3678
+ }
3029
3679
  return s;
3030
3680
  }
3031
3681
  //#endregion
@@ -3406,6 +4056,7 @@ function resolve_check_runner(check, workspace) {
3406
4056
  return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
3407
4057
  }
3408
4058
  const DEFAULT_CRITIC_MODEL = "opus";
4059
+ const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
3409
4060
  const DEFAULT_CHECK = "auto";
3410
4061
  const DEFAULT_CRITIC = "reviewer";
3411
4062
  const DEFAULT_BUILDER_PROVIDER = "claude_cli";
@@ -3488,17 +4139,66 @@ function resolve_critic(critic, cwd) {
3488
4139
  critic_prompt_path: path
3489
4140
  };
3490
4141
  }
3491
- /** `--builder-max-steps` (a merged flag/config value) wins over the
3492
- * `VOLLEY_BUILDER_MAX_STEPS` env var, which wins over the default. Local
3493
- * builder only; ignored by the `claude_cli` arm. */
3494
- function resolve_builder_max_steps(raw, env) {
3495
- if (raw.builder_max_steps !== void 0) return validate_builder_max_steps(raw.builder_max_steps, raw.builder_max_steps);
3496
- const env_value = env["VOLLEY_BUILDER_MAX_STEPS"];
3497
- if (env_value !== void 0 && env_value !== "") return validate_builder_max_steps(Number(env_value), env_value);
3498
- return 50;
3499
- }
3500
- function validate_builder_max_steps(value, original) {
3501
- if (!Number.isInteger(value) || value < 1) throw config_error(`--builder-max-steps / VOLLEY_BUILDER_MAX_STEPS must be a positive integer; got: ${String(original)}`);
4142
+ /** What only a builder, or the loop around it, uses, with the flag that sets
4143
+ * each: a critique has no use for any of them, so naming one is an error
4144
+ * rather than something quietly ignored. */
4145
+ const CRITIQUE_REFUSED = [
4146
+ ["builder_model", "--builder-model"],
4147
+ ["builder_provider", "--builder-provider"],
4148
+ ["builder_url", "--builder-url"],
4149
+ ["builder_max_steps", "--builder-max-steps"],
4150
+ ["builder_max_tool_calls", "--builder-max-tool-calls"],
4151
+ ["builder_max_output_tokens", "--builder-max-output-tokens"],
4152
+ ["builder_permission_mode", "--builder-permission-mode"],
4153
+ ["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
4154
+ ["sandbox_image", "--sandbox-image"],
4155
+ ["max_iterations", "--max-iterations"],
4156
+ ["max_cost_usd", "--max-cost-usd"],
4157
+ ["git_checkpoints", "--git"],
4158
+ ["worktree", "--worktree"],
4159
+ ["discard_worktree", "--discard-worktree"],
4160
+ ["fail_on_gate_edit", "--fail-on-gate-edit"]
4161
+ ];
4162
+ function refuse_builder_settings(raw) {
4163
+ 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`);
4164
+ }
4165
+ const BUILDER_MAX_STEPS = {
4166
+ flag: "--builder-max-steps",
4167
+ env_var: "VOLLEY_BUILDER_MAX_STEPS",
4168
+ fallback: 50
4169
+ };
4170
+ const BUILDER_MAX_TOOL_CALLS = {
4171
+ flag: "--builder-max-tool-calls",
4172
+ env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
4173
+ fallback: 1
4174
+ };
4175
+ /** The builders whose turns are capped when nothing sets a cap: the local
4176
+ * ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
4177
+ * `num_predict` run to the model's trained context less the prompt, which is
4178
+ * how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
4179
+ * OpenRouter has its own output limit, and a request for more than it allows
4180
+ * can be refused, so it's capped only when asked. */
4181
+ const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
4182
+ /** The output cap's flag and env var, and its default for `provider`: 16k
4183
+ * tokens on a local builder, else the provider's own limit (null). */
4184
+ function builder_max_output_tokens_count(provider) {
4185
+ return {
4186
+ flag: "--builder-max-output-tokens",
4187
+ env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
4188
+ fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
4189
+ };
4190
+ }
4191
+ /** A builder count's flag or config value wins over its env var, which wins
4192
+ * over the default, and either must be a positive integer. Local builder
4193
+ * only; ignored by the `claude_cli` arm. */
4194
+ function resolve_builder_count(value, env, count) {
4195
+ if (value !== void 0) return positive_count(value, value, count);
4196
+ const env_value = env[count.env_var];
4197
+ if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
4198
+ return count.fallback;
4199
+ }
4200
+ function positive_count(value, original, count) {
4201
+ if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
3502
4202
  return value;
3503
4203
  }
3504
4204
  /** `--sandbox-image` (a merged flag/config value) wins over the
@@ -3548,25 +4248,36 @@ function resolve_seat_url(seat, value, provider) {
3548
4248
  }
3549
4249
  /** `--state-dir` (`state_dir`): the root each run's state sits under at
3550
4250
  * `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
3551
- * place, or null to keep `<workspace>/.volley/`. An empty value counts as unset,
3552
- * as it does for a seat URL. */
3553
- function resolve_state_dir(value, cwd) {
3554
- if (value === void 0 || value === null) return null;
4251
+ * place. Unset, or empty as a seat URL may be, it's the default root,
4252
+ * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
4253
+ * workspace, where no check that reads every file can trip over the run's own
4254
+ * feedback. */
4255
+ function resolve_state_dir(value, cwd, env) {
4256
+ if (value === void 0 || value === null) return default_state_root(env);
3555
4257
  if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
3556
4258
  const path = value.trim();
3557
- return path === "" ? null : resolve(cwd, path);
4259
+ return path === "" ? default_state_root(env) : resolve(cwd, path);
3558
4260
  }
3559
- /** volley *detects* it is running inside its sandbox container rather than
3560
- * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
3561
- * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
3562
- * flags> <image> volley run …` is recognized as contained out of the box. Kept
3563
- * env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
3564
- * and testable, and so a CI job that itself runs in an unrelated container does
3565
- * not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
3566
- * the operator uses `--allow-unsandboxed-builder`). */
3567
- function detect_containment(env) {
3568
- const value = env["VOLLEY_CONTAINED"];
3569
- return value === "1" || value === "true";
4261
+ function is_writable_dir(path) {
4262
+ try {
4263
+ if (!statSync(path).isDirectory()) return false;
4264
+ accessSync(path, constants.W_OK);
4265
+ return true;
4266
+ } catch {
4267
+ return false;
4268
+ }
4269
+ }
4270
+ /** The run's state directory must be one volley can write: the directory
4271
+ * itself when it's there, as a contained run's mount is, else the nearest
4272
+ * directory above it, where volley creates it. Checked before any spend,
4273
+ * because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
4274
+ * the sandbox the root filesystem is read-only, so that is what an unmounted
4275
+ * run directory looks like, and the message says what to mount. */
4276
+ function validate_run_state_dir(dir, contained) {
4277
+ let existing = dir;
4278
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
4279
+ if (is_writable_dir(existing)) return;
4280
+ 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`);
3570
4281
  }
3571
4282
  /** Whether a tool-loop builder's commands would reach the host with nothing in
3572
4283
  * between: volley isn't contained, and the operator hasn't opted out with
@@ -3574,10 +4285,7 @@ function detect_containment(env) {
3574
4285
  function unguarded_builder(raw, env) {
3575
4286
  return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
3576
4287
  }
3577
- /** A caller's run id names the run's `volley/<run id>` branch, so it is held to
3578
- * what a branch name accepts without quoting: letters, digits, `-`, and `_`,
3579
- * starting with a letter or digit so it never reads as an option. */
3580
- const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
4288
+ /** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
3581
4289
  function resolve_run_id(run_id) {
3582
4290
  if (run_id === void 0) return randomUUID();
3583
4291
  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}`);
@@ -3589,13 +4297,17 @@ function resolve_run_id(run_id) {
3589
4297
  function resolve_config(raw, options = {}) {
3590
4298
  const cwd = options.cwd ?? process.cwd();
3591
4299
  const env = options.env ?? process.env;
3592
- if (typeof raw.prompt !== "string" || raw.prompt.length === 0) throw config_error("missing required option: --prompt");
4300
+ const critique = options.mode === "critique";
4301
+ if (critique) refuse_builder_settings(raw);
4302
+ const builder_env = critique ? {} : env;
4303
+ const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
4304
+ if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
3593
4305
  if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
3594
4306
  if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
3595
4307
  const workspace = validate_workspace(resolve(cwd, raw.workspace));
3596
- const prompt = expand_at_file(raw.prompt, cwd);
4308
+ const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
3597
4309
  const criteria = expand_at_file(raw.criteria, cwd);
3598
- const max_iterations = raw.max_iterations ?? 10;
4310
+ const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
3599
4311
  if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
3600
4312
  const max_cost_usd = raw.max_cost_usd ?? null;
3601
4313
  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)}`);
@@ -3604,17 +4316,19 @@ function resolve_config(raw, options = {}) {
3604
4316
  const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
3605
4317
  const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
3606
4318
  if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
3607
- const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
4319
+ const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
3608
4320
  const contained = detect_containment(env);
3609
- 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.`);
3610
- const builder_max_steps = resolve_builder_max_steps(raw, env);
4321
+ 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.`);
4322
+ const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
4323
+ const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
4324
+ const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
3611
4325
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
3612
4326
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
3613
4327
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
3614
4328
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
3615
4329
  if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
3616
4330
  if (contained) {
3617
- const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
4331
+ const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
3618
4332
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
3619
4333
  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.`);
3620
4334
  }
@@ -3625,14 +4339,17 @@ function resolve_config(raw, options = {}) {
3625
4339
  if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
3626
4340
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
3627
4341
  if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) throw config_error("gate_paths must be an array of glob strings");
3628
- const sandbox_image = resolve_sandbox_image(raw, env);
3629
- const state_dir = resolve_state_dir(raw.state_dir, cwd);
4342
+ const sandbox_image = resolve_sandbox_image(raw, builder_env);
4343
+ const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
4344
+ if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
3630
4345
  const run_id = resolve_run_id(options.run_id);
4346
+ if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
3631
4347
  const check = raw.check ?? "auto";
3632
4348
  return {
3633
4349
  version: 2,
3634
4350
  run_id,
3635
4351
  started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
4352
+ mode: critique ? "critique" : "run",
3636
4353
  prompt,
3637
4354
  criteria,
3638
4355
  check,
@@ -3641,6 +4358,8 @@ function resolve_config(raw, options = {}) {
3641
4358
  builder_provider,
3642
4359
  builder_url,
3643
4360
  builder_max_steps,
4361
+ builder_max_tool_calls,
4362
+ builder_max_output_tokens,
3644
4363
  allow_unsandboxed_builder,
3645
4364
  critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
3646
4365
  critic_provider,
@@ -3688,6 +4407,7 @@ function initial_state() {
3688
4407
  builder_cost_usd: 0,
3689
4408
  critic_cost_usd: 0,
3690
4409
  check_duration_ms: 0,
4410
+ builder_max_steps_hits: 0,
3691
4411
  iteration_cost_usd: 0,
3692
4412
  halt: null,
3693
4413
  cost_warned: false
@@ -3739,6 +4459,11 @@ function status_of(value, converged) {
3739
4459
  * around the flow (workspace setup, the worktree lifecycle, `run`, the final
3740
4460
  * summary) in `./orchestrator.ts`.
3741
4461
  *
4462
+ * A critique (`build_critique_flow`) is one round over a change already made,
4463
+ * so it has no builder, no loop, and no guard: `measure`, which opens the
4464
+ * round and measures the change from its baseline, then the same `check`,
4465
+ * `critique`, and `record` a run's round ends with.
4466
+ *
3742
4467
  * fascicle's `describe.diagram` draws the tree below from the flow itself, and
3743
4468
  * `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
3744
4469
  * its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
@@ -3782,6 +4507,24 @@ function build_critic(deps) {
3782
4507
  project: with_fallback_cause
3783
4508
  });
3784
4509
  }
4510
+ /** The steps a run and a critique share, built one way for both. */
4511
+ function check_step(deps) {
4512
+ return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
4513
+ }
4514
+ function critique_step(deps, seat) {
4515
+ const critic = build_critic({
4516
+ ...seat,
4517
+ config: deps.config,
4518
+ on_chunk: deps.renderer.critic_chunk
4519
+ });
4520
+ return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
4521
+ description: "the read-only critic's verdict and feedback",
4522
+ arm: critic
4523
+ });
4524
+ }
4525
+ function record_step(deps) {
4526
+ return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
4527
+ }
3785
4528
  function build_flow(deps, resume_from) {
3786
4529
  const { config, renderer } = deps;
3787
4530
  const builder = make_builder_step({
@@ -3790,22 +4533,14 @@ function build_flow(deps, resume_from) {
3790
4533
  on_chunk: renderer.builder_chunk,
3791
4534
  bash_executor: deps.bash_executor
3792
4535
  });
3793
- const critic = build_critic({
3794
- ...deps.seats.critic,
3795
- config,
3796
- on_chunk: renderer.critic_chunk
3797
- });
3798
4536
  const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
3799
4537
  description: "open the iteration, run the builder, measure the change",
3800
4538
  arm: builder
3801
4539
  });
3802
4540
  const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s), { description: "record the check as skipped and call no critic" });
3803
- const check = step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
3804
- const critique = step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
3805
- description: "the read-only critic's verdict and feedback",
3806
- arm: critic
3807
- });
3808
- const record = step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
4541
+ const check = check_step(deps);
4542
+ const critique = critique_step(deps, deps.seats.critic);
4543
+ const record = record_step(deps);
3809
4544
  const verify = branch({
3810
4545
  name: "verify",
3811
4546
  description: "did the build alone cross the cost cap?",
@@ -3831,6 +4566,19 @@ function build_flow(deps, resume_from) {
3831
4566
  max_rounds: remaining_iterations(config, resume_from)
3832
4567
  });
3833
4568
  }
4569
+ /** A critique: one round over a change already made. `measure` opens it and
4570
+ * measures the change from the baseline, then the check, the critic, and the
4571
+ * record, as in a run's round. No loop and no guard: there is nothing to
4572
+ * iterate on, and the caller reads the outcome off the final state. */
4573
+ function build_critique_flow(deps) {
4574
+ const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
4575
+ return sequence([
4576
+ measure,
4577
+ check_step(deps),
4578
+ critique_step(deps, deps.critic),
4579
+ record_step(deps)
4580
+ ], { name: "critique_run" });
4581
+ }
3834
4582
  //#endregion
3835
4583
  //#region src/orchestrator.ts
3836
4584
  /**
@@ -3847,13 +4595,19 @@ function build_flow(deps, resume_from) {
3847
4595
  * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
3848
4596
  * │ └─ integrate a successful --worktree --git run only
3849
4597
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
4598
+ *
4599
+ * run_critique
4600
+ * ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
4601
+ * ├─ initialize the run's state
4602
+ * └─ run the critique flow one round on the critic's engine alone, then the final summary
3850
4603
  */
3851
4604
  async function run_volley(config, deps, resume_from = null) {
3852
4605
  const { renderer } = deps;
3853
4606
  if (resume_from === null) {
3854
4607
  initialize_run_state(config);
3855
- write_resolved_config(config);
4608
+ 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`);
3856
4609
  } else initialize_run_state(config, { preserve: true });
4610
+ renderer.info(`state: ${run_state_dir(config)}`);
3857
4611
  report_worktree_fate(config, renderer);
3858
4612
  const branch = worktree_branch(config.run_id);
3859
4613
  const fate = worktree_fate(config);
@@ -3969,63 +4723,65 @@ function finalize_failed_run(config, err) {
3969
4723
  const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
3970
4724
  try {
3971
4725
  const summary_path = state_path(config, "summary.json");
4726
+ const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
3972
4727
  write_run_summary(config, {
3973
- ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
4728
+ ...prior,
4729
+ builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
4730
+ run_state_dir: run_state_dir(config),
3974
4731
  status,
3975
4732
  completed_at: (/* @__PURE__ */ new Date()).toISOString()
3976
4733
  });
3977
4734
  } catch {}
3978
4735
  }
3979
- //#endregion
3980
- //#region src/render/format.ts
3981
4736
  /**
3982
- * Display formatting: glyph taxonomy, role colors, truncation.
3983
- * All human output goes to stderr; these helpers only build strings.
4737
+ * `volley critique`: the check and the critic, once, over a change already
4738
+ * made. No builder is constructed, and the engine serves the critic's seat
4739
+ * alone, so a critic can be measured on fixed, labeled changes without paying
4740
+ * for builder turns. It writes the state a one-round run does, with the mode
4741
+ * recorded, and ends `success` on approval over a green check, else
4742
+ * `rejected`.
3984
4743
  */
3985
- const GLYPH_TOOL = "🔧";
3986
- const GLYPH_COST = "💰";
3987
- const VERBOSE_TRUNCATE_CHARS = 4e3;
3988
- function colors_enabled(env = process.env, is_tty = process.stderr.isTTY === true) {
3989
- if (env["NO_COLOR"] !== void 0 || env["VOLLEY_NO_COLOR"] !== void 0) return false;
3990
- return is_tty;
3991
- }
3992
- function paint(role, text, color) {
3993
- if (!color) return text;
3994
- switch (role) {
3995
- case "builder": return pc.cyan(text);
3996
- case "critic": return pc.yellow(text);
3997
- case "check": return pc.green(text);
3998
- case "cost": return pc.magenta(text);
3999
- case "error": return pc.bold(pc.red(text));
4000
- }
4001
- }
4002
- function dim(text, color) {
4003
- return color ? pc.dim(text) : text;
4004
- }
4005
- function truncate(text, max, pointer) {
4006
- if (text.length <= max) return text;
4007
- return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
4008
- }
4009
- function format_usd(value) {
4010
- return value === null ? "$?" : `$${value.toFixed(3)}`;
4011
- }
4012
- /** One-line summary of a tool input for the default streaming mode. */
4013
- function summarize_value(value, max = 120) {
4014
- let text;
4744
+ async function run_critique(config, deps) {
4745
+ if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
4746
+ const { renderer } = deps;
4747
+ const baseline = resolve_baseline(config.workspace, deps.baseline);
4748
+ initialize_run_state(config);
4749
+ write_resolved_config(config);
4750
+ renderer.info(`state: ${run_state_dir(config)}`);
4751
+ renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
4752
+ const engine = deps.engine ?? create_volley_engine({
4753
+ workspace: config.workspace,
4754
+ critic_provider: config.critic_provider,
4755
+ critic_url: config.critic_url
4756
+ });
4757
+ const flow = build_critique_flow({
4758
+ config,
4759
+ renderer,
4760
+ baseline,
4761
+ critic: {
4762
+ ...deps.critic,
4763
+ engine
4764
+ },
4765
+ ...deps.check !== void 0 ? { check: deps.check } : {}
4766
+ });
4015
4767
  try {
4016
- text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
4017
- } catch {
4018
- text = String(value);
4768
+ const value = await execute_flow(config, deps, flow, initial_state());
4769
+ const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
4770
+ write_run_summary(config, result);
4771
+ return result;
4772
+ } catch (err) {
4773
+ finalize_failed_run(config, err);
4774
+ throw err;
4775
+ } finally {
4776
+ await engine.dispose();
4019
4777
  }
4020
- const flat = text.replace(/\s+/g, " ");
4021
- return flat.length > max ? `${flat.slice(0, max)}…` : flat;
4022
4778
  }
4023
4779
  //#endregion
4024
4780
  //#region src/render/renderer.ts
4025
4781
  function create_renderer(options) {
4026
4782
  const write = options.write ?? ((text) => process.stderr.write(text));
4027
4783
  const { mode, color } = options;
4028
- const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
4784
+ const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
4029
4785
  const silent = mode === "json";
4030
4786
  const streaming = mode === "default" || mode === "verbose";
4031
4787
  let mid_line = false;
@@ -4103,6 +4859,7 @@ function create_renderer(options) {
4103
4859
  line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
4104
4860
  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));
4105
4861
  line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
4862
+ line(`state: ${result.run_state_dir}`);
4106
4863
  }
4107
4864
  };
4108
4865
  }
@@ -4146,14 +4903,6 @@ const REFUSED_FLAGS = [
4146
4903
  /** What the startup check resolves in place of a prompt, which no session has
4147
4904
  * sent yet. */
4148
4905
  const STAND_IN_TASK = "volley acp: startup check";
4149
- /** The root each run's state sits under when `--state-dir` names none:
4150
- * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`. A relative
4151
- * `XDG_STATE_HOME` is ignored, as the XDG spec says. */
4152
- function acp_state_root(env) {
4153
- const xdg = env["XDG_STATE_HOME"];
4154
- const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "state");
4155
- return join(base, "volley");
4156
- }
4157
4906
  /** Unset, or set to nothing but whitespace. Any other value goes on to
4158
4907
  * `resolve_config`, which says what's wrong with it. */
4159
4908
  function is_blank(value) {
@@ -4166,17 +4915,18 @@ function refuse_unservable(raw) {
4166
4915
  if (missing.length > 0) throw config_error(`volley acp requires every seat flag, and these are missing: ${missing.join(", ")}`);
4167
4916
  if (raw.builder_provider === "claude_cli") throw config_error("volley acp refuses a claude_cli builder: the Claude CLI runs its own tools under its own permission model, so its edits would never reach the editor's prompts");
4168
4917
  }
4169
- /** The flags `volley acp` was started with, with acp's defaults filled in:
4170
- * three rounds, and state outside the workspace. Each refusal, a missing seat
4171
- * flag, and any other config error throws here, before serving: the whole
4172
- * config resolves once against the system temp directory with a stand-in
4173
- * task, since no session has named a workspace yet. */
4918
+ /** The flags `volley acp` was started with, with acp's default filled in:
4919
+ * three rounds. Run state goes under the default root, outside the workspace,
4920
+ * unless `--state-dir` names one, as it does for any run. Each refusal, a
4921
+ * missing seat flag, and any other config error, an unwritable state root
4922
+ * among them, throws here, before serving: the whole config resolves once
4923
+ * against the system temp directory with a stand-in task, since no session has
4924
+ * named a workspace yet. */
4174
4925
  function acp_base_config(raw, env = process.env) {
4175
4926
  refuse_unservable(raw);
4176
4927
  const base = {
4177
4928
  ...raw,
4178
- max_iterations: raw.max_iterations ?? 3,
4179
- state_dir: raw.state_dir === void 0 || is_blank(raw.state_dir) ? acp_state_root(env) : raw.state_dir
4929
+ max_iterations: raw.max_iterations ?? 3
4180
4930
  };
4181
4931
  resolve_config({
4182
4932
  ...base,
@@ -4290,7 +5040,8 @@ function acp_serve_config(base, options) {
4290
5040
  function exit_code_for_status(status) {
4291
5041
  switch (status) {
4292
5042
  case "success": return 0;
4293
- case "budget_exhausted": return 2;
5043
+ case "budget_exhausted":
5044
+ case "rejected": return 2;
4294
5045
  case "cost_cap_reached": return 7;
4295
5046
  case "gate_edit_blocked": return 8;
4296
5047
  case "interrupted": return 130;
@@ -4537,10 +5288,10 @@ function combo_dir(matrix_dir, combo) {
4537
5288
  function slug(model) {
4538
5289
  return model.replace(/[^a-zA-Z0-9._-]/g, "_");
4539
5290
  }
4540
- /** One attempt's run summary, copied out of the run's own state before the
4541
- * next attempt rotates a shared `.volley/` aside. Every attempt gets its own
4542
- * `run-NN/` so a repeated seat keeps all of its evidence, not just the last
4543
- * run's. */
5291
+ /** One attempt's run summary, copied out of the run's own state into the
5292
+ * sweep, so the sweep keeps its evidence beside its table. Every attempt gets
5293
+ * its own `run-NN/` so a repeated seat keeps all of its evidence, not just the
5294
+ * last run's. */
4544
5295
  function persist_attempt(matrix_dir, combo, attempt, summary) {
4545
5296
  const dir = join(combo_dir(matrix_dir, combo), `run-${String(attempt).padStart(2, "0")}`);
4546
5297
  mkdirSync(dir, { recursive: true });
@@ -4854,7 +5605,8 @@ function report_gate_posture(config, renderer) {
4854
5605
  return;
4855
5606
  }
4856
5607
  if (config.gate_paths.length === 0) {
4857
- renderer.info("gate: no gate paths configured; builder edits to tests and check config are not flagged.");
5608
+ const edits = config.mode === "critique" ? "changes" : "builder edits";
5609
+ renderer.info(`gate: no gate paths configured; ${edits} to tests and check config are not flagged.`);
4858
5610
  return;
4859
5611
  }
4860
5612
  renderer.info(`gate: watching ${String(config.gate_paths.length)} path pattern(s)` + (config.fail_on_gate_edit ? " — a builder edit to any of them halts the run (--fail-on-gate-edit, exit 8)." : " — edits are reported to the critic and recorded in the summary, not refused."));
@@ -4883,11 +5635,17 @@ async function preflight(config, renderer, env, probes = {}) {
4883
5635
  const tool_loop_builder = is_tool_loop_builder(config.builder_provider);
4884
5636
  renderer.info(`dry run: config valid (run ${config.run_id})`);
4885
5637
  renderer.info(`workspace: ${config.workspace}`);
5638
+ renderer.info(config.state_dir === null ? `state: ${run_state_dir(config)}` : `state root: ${config.state_dir}`);
4886
5639
  renderer.info(`check: ${config.check} (resolved: ${config.check_resolved})`);
4887
- renderer.info(seat_line("builder", config.builder_model, config.builder_provider, config.builder_url, env));
4888
- renderer.info(`builder max steps: ${config.builder_max_steps}`);
5640
+ if (config.mode === "critique") renderer.info("builder: none (a critique checks and critiques the change once)");
5641
+ else {
5642
+ renderer.info(seat_line("builder", config.builder_model, config.builder_provider, config.builder_url, env));
5643
+ renderer.info(`builder max steps: ${config.builder_max_steps}`);
5644
+ renderer.info(`builder tool calls per turn: ${config.builder_max_tool_calls}`);
5645
+ renderer.info(`builder output tokens per turn: ${config.builder_max_output_tokens ?? "the provider's limit"}`);
5646
+ }
4889
5647
  if (tool_loop_builder) {
4890
- const contained = env["VOLLEY_CONTAINED"] === "1" || env["VOLLEY_CONTAINED"] === "true";
5648
+ const contained = detect_containment(env);
4891
5649
  renderer.info(contained ? "containment: inside the sandbox container (VOLLEY_CONTAINED)" : "containment: uncontained on the host (--allow-unsandboxed-builder)");
4892
5650
  renderer.info(`sandbox image: ${config.sandbox_image}`);
4893
5651
  }
@@ -4942,6 +5700,8 @@ async function preflight(config, renderer, env, probes = {}) {
4942
5700
  * CLI entry point: cac argv parsing, dispatch, exit-code mapping.
4943
5701
  * Human progress goes to stderr; stdout carries machine output only.
4944
5702
  */
5703
+ /** One help string for every command that takes a state root. */
5704
+ const STATE_DIR_HELP = "Keep each run's state under <path>/<run id>/ (default: $XDG_STATE_HOME/volley, else ~/.local/state/volley)";
4945
5705
  /**
4946
5706
  * volley's own version for `--version`, read from the manifest rather than
4947
5707
  * repeated here — a literal drifts silently, since `pnpm version` bumps only
@@ -4976,19 +5736,18 @@ function render_mode(config) {
4976
5736
  if (config.verbose) return "verbose";
4977
5737
  return "default";
4978
5738
  }
4979
- /** `run_id` names the run whose trajectory the verbose hint points at under a
4980
- * state root; a sweep passes a placeholder, since each seat has an id of its
4981
- * own. */
5739
+ /** `run_id` names the run whose trajectory the verbose hint points at; a
5740
+ * sweep passes a placeholder, since each seat has an id of its own. */
4982
5741
  function make_renderer(config, run_id = config.run_id) {
4983
5742
  return create_renderer({
4984
5743
  mode: render_mode(config),
4985
5744
  show_thinking: config.show_thinking,
4986
5745
  color: colors_enabled(),
4987
5746
  max_cost_usd: config.max_cost_usd,
4988
- ...config.state_dir === null ? {} : { trajectory_path: state_path({
5747
+ trajectory_path: state_path({
4989
5748
  ...config,
4990
5749
  run_id
4991
- }, "trajectory.jsonl") }
5750
+ }, "trajectory.jsonl")
4992
5751
  });
4993
5752
  }
4994
5753
  function warn_api_key_meter(renderer) {
@@ -5001,6 +5760,13 @@ function warn_api_key_meter(renderer) {
5001
5760
  function warn_unsandboxed_builder(config, renderer) {
5002
5761
  if (config.builder_provider !== "claude_cli" && config.allow_unsandboxed_builder) renderer.warn(`UNSANDBOXED BUILDER: '${config.builder_provider}' runs volley's tool loop with a real bash (write + exec) directly on this machine in ${config.workspace} — no container isolation. Proceeding because --allow-unsandboxed-builder / VOLLEY_ALLOW_UNSANDBOXED_BUILDER is set; run volley inside its sandbox container for blast-radius containment instead.`);
5003
5762
  }
5763
+ /** The critic seat's options: its model, provider, and URL. `required` marks
5764
+ * the model and provider as having no default, as under `acp`. */
5765
+ function with_critic_options(command, required = false) {
5766
+ const model_note = required ? " (required)" : "";
5767
+ const provider_note = required ? "required" : "default: claude_cli";
5768
+ return command.option("--critic-model <model>", `Critic model${model_note}`).option("--critic-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--critic-url <url>", "Critic's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)");
5769
+ }
5004
5770
  /** The seat options, declared once for every command that runs seats: each
5005
5771
  * seat's model, provider, and URL, the tool-loop builder's step cap, and the
5006
5772
  * unsandboxed opt-out. `required` marks each seat's model and provider as
@@ -5008,8 +5774,29 @@ function warn_unsandboxed_builder(config, renderer) {
5008
5774
  function with_seat_options(command, required = false) {
5009
5775
  const model_note = required ? " (required)" : "";
5010
5776
  const provider_note = required ? "required" : "default: claude_cli";
5011
- return command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)").option("--critic-model <model>", `Critic model${model_note}`).option("--critic-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--critic-url <url>", "Critic's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)");
5012
- }
5777
+ return with_critic_options(command.option("--builder-model <model>", `Builder model${model_note}`).option("--builder-provider <name>", `claude_cli | ollama | lmstudio | openrouter (${provider_note})`).option("--builder-url <url>", "Builder's model server base URL, ollama, lmstudio, or openrouter only (default: VOLLEY_OLLAMA_URL / VOLLEY_LMSTUDIO_URL / VOLLEY_OPENROUTER_URL)").option("--builder-max-steps <n>", "Tool-loop builder step cap per iteration, any provider but claude_cli (default: 50)").option("--builder-max-tool-calls <n>", "Tool calls a tool-loop builder may make per turn, reads aside, any provider but claude_cli (default: 1)").option("--builder-max-output-tokens <n>", "Output tokens a tool-loop builder may generate per turn, reasoning included, any provider but claude_cli (default: 16384 on ollama and lmstudio, the provider's on openrouter)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
5778
+ }
5779
+ /** What a critique declares only so that it can refuse each by name, with
5780
+ * exit 5: cac would answer an undeclared one with a bare "Unknown option".
5781
+ * `resolve_config` refuses all but `--config`, which the command does. */
5782
+ const CRITIQUE_REFUSED_OPTIONS = [
5783
+ "--builder-model <model>",
5784
+ "--builder-provider <name>",
5785
+ "--builder-url <url>",
5786
+ "--builder-max-steps <n>",
5787
+ "--builder-max-tool-calls <n>",
5788
+ "--builder-max-output-tokens <n>",
5789
+ "--builder-permission-mode <mode>",
5790
+ "--allow-unsandboxed-builder",
5791
+ "--sandbox-image <tag>",
5792
+ "--max-iterations <n>",
5793
+ "--max-cost-usd <usd>",
5794
+ "--git",
5795
+ "--worktree",
5796
+ "--discard-worktree",
5797
+ "--fail-on-gate-edit",
5798
+ "--config <path>"
5799
+ ];
5013
5800
  function merge_flags(base, flags) {
5014
5801
  return {
5015
5802
  ...base,
@@ -5021,6 +5808,8 @@ function merge_flags(base, flags) {
5021
5808
  ...flags.builderProvider !== void 0 ? { builder_provider: flags.builderProvider } : {},
5022
5809
  ...flags.builderUrl !== void 0 ? { builder_url: flags.builderUrl } : {},
5023
5810
  ...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
5811
+ ...flags.builderMaxToolCalls !== void 0 ? { builder_max_tool_calls: Number(flags.builderMaxToolCalls) } : {},
5812
+ ...flags.builderMaxOutputTokens !== void 0 ? { builder_max_output_tokens: Number(flags.builderMaxOutputTokens) } : {},
5024
5813
  ...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
5025
5814
  ...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
5026
5815
  ...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
@@ -5043,6 +5832,20 @@ function merge_flags(base, flags) {
5043
5832
  ...flags.thinking === false ? { show_thinking: false } : {}
5044
5833
  };
5045
5834
  }
5835
+ /** What a resume is about to run on a config this machine didn't sign, said
5836
+ * once before it runs, so `--trust-config` is never a blind yes. Every value
5837
+ * came from that config, so each is quoted with its control characters
5838
+ * escaped, and can't redraw the line it's warned on. */
5839
+ function warn_trusted_config(config, renderer) {
5840
+ renderer.warn(`resuming on a config volley on this machine didn't sign (--trust-config): check ${shown(config.check)} (resolved: ${config.check_resolved}), builder ${config.builder_provider} ${shown(config.builder_model)} (${config.builder_permission_mode}), critic ${config.critic_provider} ${shown(config.critic_model)}, workspace ${shown(config.workspace)}`);
5841
+ }
5842
+ /** A flag's value as typed. cac reads an all-digit value as a number, which
5843
+ * drops leading zeros, so a coerced id or revision would silently name another
5844
+ * run or commit: refused rather than guessed at. */
5845
+ function not_a_bare_number(name, value) {
5846
+ if (typeof value === "number") throw config_error(`${name} must not be a bare number (read as ${String(value)})`);
5847
+ return value;
5848
+ }
5046
5849
  function emit_result(config, result, renderer) {
5047
5850
  renderer.final_summary(result);
5048
5851
  if (config.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -5060,10 +5863,10 @@ function report_error(err) {
5060
5863
  async function main(argv) {
5061
5864
  const cli = cac("volley");
5062
5865
  let exit_code = 0;
5063
- with_seat_options(cli.command("", "Run the builder/critic loop").option("--prompt <prompt>", "Task prompt (string or @file)").option("--workspace <path>", "Workspace directory (must exist and be writable)").option("--criteria <criteria>", "Acceptance criteria (string or @file)").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`)).option("--builder-permission-mode <mode>", "acceptEdits | bypassPermissions").option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--max-iterations <n>", "Iteration cap").option("--max-cost-usd <usd>", "Hard USD ceiling, enforced in the loop guard").option("--git", "Auto-commit after each phase (workspace must be a git repo)").option("--worktree", "Isolate the builder run in a per-run git worktree (workspace must be a git repo)").option("--discard-worktree", "Throw a --worktree run's effects away at teardown instead of keeping them on the run branch").option("--gate-paths <globs>", "Comma-separated globs naming the gate (tests, fixtures, check config); replaces the defaults").option("--fail-on-gate-edit", "Halt the run if the builder edits a gate path (exit 8) instead of reporting it").option("--sandbox-image <tag>", `Container image for the local-builder sandbox (default: ${DEFAULT_SANDBOX_IMAGE})`).option("--state-dir <path>", "Keep each run's state under <path>/<run id>/ instead of the workspace's .volley/").option("--run-id <id>", "The run's id, naming its volley/<id> branch (default: a fresh UUID)").option("--dry-run", "Validate config (and checkride doctor) without running").option("--config <path>", "TypeScript config file exporting a VolleyConfig").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs (truncated at 4000 chars)").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks (still recorded in the trajectory)").action(async (flags) => {
5866
+ with_seat_options(cli.command("", "Run the builder/critic loop").option("--prompt <prompt>", "Task prompt (string or @file)").option("--workspace <path>", "Workspace directory (must exist and be writable)").option("--criteria <criteria>", "Acceptance criteria (string or @file)").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`)).option("--builder-permission-mode <mode>", "acceptEdits | bypassPermissions").option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--max-iterations <n>", "Iteration cap").option("--max-cost-usd <usd>", "Hard USD ceiling, enforced in the loop guard").option("--git", "Auto-commit after each phase (workspace must be a git repo)").option("--worktree", "Isolate the builder run in a per-run git worktree (workspace must be a git repo)").option("--discard-worktree", "Throw a --worktree run's effects away at teardown instead of keeping them on the run branch").option("--gate-paths <globs>", "Comma-separated globs naming the gate (tests, fixtures, check config); replaces the defaults").option("--fail-on-gate-edit", "Halt the run if the builder edits a gate path (exit 8) instead of reporting it").option("--sandbox-image <tag>", `Container image for the local-builder sandbox (default: ${DEFAULT_SANDBOX_IMAGE})`).option("--state-dir <path>", STATE_DIR_HELP).option("--run-id <id>", "The run's id, naming its volley/<id> branch (default: a fresh UUID)").option("--dry-run", "Validate config (and checkride doctor) without running").option("--config <path>", "TypeScript config file exporting a VolleyConfig").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs (truncated at 4000 chars)").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks (still recorded in the trajectory)").action(async (flags) => {
5064
5867
  const merged = merge_flags(flags.config !== void 0 ? await load_config_file(flags.config) : {}, flags);
5065
- if (typeof flags.runId === "number") throw config_error(`--run-id must not be a bare number (read as ${String(flags.runId)})`);
5066
- const config = resolve_config(merged, flags.runId !== void 0 ? { run_id: flags.runId } : {});
5868
+ const run_id = not_a_bare_number("--run-id", flags.runId);
5869
+ const config = resolve_config(merged, run_id !== void 0 ? { run_id } : {});
5067
5870
  const renderer = make_renderer(config);
5068
5871
  warn_api_key_meter(renderer);
5069
5872
  warn_unsandboxed_builder(config, renderer);
@@ -5073,24 +5876,48 @@ async function main(argv) {
5073
5876
  }
5074
5877
  exit_code = emit_result(config, await run_volley(config, { renderer }), renderer);
5075
5878
  });
5076
- cli.command("resume <run_id>", "Resume a run from its last completed iteration").option("--workspace <path>", "Workspace containing the .volley run state", { default: "." }).option("--state-dir <path>", "State root the run was started under; finds it at <path>/<run_id>/ instead of in the workspace").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks").action(async (run_id, flags) => {
5077
- const { workspace = ".", stateDir, ...output } = flags;
5078
- const state_dir = stateDir === void 0 ? null : resolve(stateDir);
5079
- const resume = load_resume_state(workspace, run_id, state_dir);
5080
- const config = resolve_config(merge_flags(resume.raw_config, {
5081
- ...output,
5082
- ...state_dir !== null ? { stateDir: state_dir } : {}
5083
- }), {
5879
+ cli.command("resume <run_id>", "Resume a run from its last completed iteration").option("--workspace <path>", "Where a run an older volley started keeps its .volley/ state", { default: "." }).option("--state-dir <path>", "State root the run was started under (default: $XDG_STATE_HOME/volley, else ~/.local/state/volley)").option("--trust-config", "Resume a config volley on this machine didn't sign: another machine's, an older volley's, or one you edited. Read it first").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks").action(async (run_id, flags) => {
5880
+ const { workspace = ".", stateDir, trustConfig, ...output } = flags;
5881
+ const state_dir = find_run_state(workspace, run_id, stateDir === void 0 ? default_state_root(process.env) : resolve(stateDir));
5882
+ const resume = load_resume_state(workspace, run_id, state_dir, { trust_config: trustConfig === true });
5883
+ const config = resolve_config(merge_flags(resume.raw_config, output), {
5084
5884
  run_id: resume.run_id,
5085
- started_at: resume.started_at
5885
+ started_at: resume.started_at,
5886
+ state_dir
5086
5887
  });
5087
5888
  if (resume.iterations_completed >= config.max_iterations) throw config_error(`run already completed ${String(resume.iterations_completed)} of ${String(config.max_iterations)} iterations`);
5088
5889
  const renderer = make_renderer(config);
5089
5890
  warn_api_key_meter(renderer);
5090
5891
  warn_unsandboxed_builder(config, renderer);
5892
+ if (resume.config_signed === false) warn_trusted_config(config, renderer);
5091
5893
  renderer.info(`resuming run ${run_id} at iteration ${String(resume.iterations_completed + 1)}`);
5092
5894
  exit_code = emit_result(config, await run_volley(config, { renderer }, resume.state), renderer);
5093
5895
  });
5896
+ const critique_command = cli.command("critique", "Check and critique a change already made, once, with no builder").option("--workspace <path>", "The repository whose change is reviewed", { default: "." }).option("--baseline <rev>", "Review everything that differs from this revision, committed or not (default: HEAD)").option("--criteria <criteria>", "Acceptance criteria (string or @file)").option("--prompt <prompt>", "The task the change answers, recorded with the run; the critic judges against --criteria").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`);
5897
+ with_critic_options(critique_command).option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--gate-paths <globs>", "Comma-separated globs naming the gate (tests, fixtures, check config); replaces the defaults").option("--state-dir <path>", STATE_DIR_HELP).option("--run-id <id>", "The run's id, naming its state directory (default: a fresh UUID)").option("--dry-run", "Validate config and the baseline, run checkride doctor, and probe the critic seat, then exit").option("--json", "Machine mode: final summary JSON on stdout, no streaming").option("--verbose", "Show full tool inputs/outputs (truncated at 4000 chars)").option("--quiet", "Only phase transitions, cost updates, and the final summary").option("--no-thinking", "Hide reasoning chunks (still recorded in the trajectory)");
5898
+ for (const refused of CRITIQUE_REFUSED_OPTIONS) critique_command.option(refused, "Refused: a critique runs no builder and no loop");
5899
+ critique_command.action(async (flags) => {
5900
+ if (flags.config !== void 0) throw config_error("volley critique refuses --config: a run's config file sets a builder; pass the critique's settings as flags");
5901
+ const run_id = not_a_bare_number("--run-id", flags.runId);
5902
+ const baseline = not_a_bare_number("--baseline", flags.baseline);
5903
+ const merged = merge_flags({}, flags);
5904
+ const config = resolve_config(merged, {
5905
+ mode: "critique",
5906
+ ...run_id !== void 0 ? { run_id } : {}
5907
+ });
5908
+ const renderer = make_renderer(config);
5909
+ warn_api_key_meter(renderer);
5910
+ if (merged.dry_run === true) {
5911
+ const sha = resolve_baseline(config.workspace, baseline);
5912
+ renderer.info(`baseline: ${sha ?? "none (not a git repository, or no commit yet)"}`);
5913
+ exit_code = await preflight(config, renderer, process.env);
5914
+ return;
5915
+ }
5916
+ exit_code = emit_result(config, await run_critique(config, {
5917
+ renderer,
5918
+ ...baseline !== void 0 ? { baseline } : {}
5919
+ }), renderer);
5920
+ });
5094
5921
  cli.command("matrix", "Sweep builder×critic model combos serially over one config").option("--config <path>", "Base VolleyConfig (TS/JS): the task, workspace, providers, and caps held fixed").option("--builders <models>", "Comma-separated builder models to sweep").option("--critics <models>", "Comma-separated critic models to sweep").option("--workspace <path>", "Override the base config workspace").option("--repeat <n>", "Attempts per seat (default: 1); a pass rate needs more than one").option("--json", "Machine mode: aggregate JSON on stdout, no table").option("--verbose", "Show full builder/critic streams per combo").option("--quiet", "Per-combo phase transitions and the final table only").action(async (flags) => {
5095
5922
  if (flags.config === void 0) throw config_error("volley matrix requires --config <path> (the base task to sweep)");
5096
5923
  const builders = parse_model_list(flags.builders, "--builders");
@@ -5121,7 +5948,7 @@ async function main(argv) {
5121
5948
  if (base_config.json) process.stdout.write(`${JSON.stringify({ combos: outcome.rows }, null, 2)}\n`);
5122
5949
  exit_code = outcome.ok ? 0 : 1;
5123
5950
  });
5124
- with_seat_options(cli.command("acp", "Serve volley to an editor as an ACP agent over stdio"), true).option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`).option("--max-iterations <n>", `Iteration cap per prompt (default: ${String(3)})`).option("--max-cost-usd <usd>", "Hard USD ceiling per prompt, enforced in the loop guard").option("--state-dir <path>", "Keep each run's state under <path>/<run id>/ (default: $XDG_STATE_HOME/volley, else ~/.local/state/volley)").option("--worktree", "Refused: a sibling worktree sits outside the project the editor has open").option("--git", "Refused: phase commits would bypass the permission prompts").option("--json", "Refused: stdout carries the protocol").action(async (flags) => {
5951
+ with_seat_options(cli.command("acp", "Serve volley to an editor as an ACP agent over stdio"), true).option("--critic <critic>", "reviewer | optimizer | researcher | path to prompt file").option("--check <check>", `auto | none | shell command (default: ${DEFAULT_CHECK})`).option("--max-iterations <n>", `Iteration cap per prompt (default: ${String(3)})`).option("--max-cost-usd <usd>", "Hard USD ceiling per prompt, enforced in the loop guard").option("--state-dir <path>", STATE_DIR_HELP).option("--worktree", "Refused: a sibling worktree sits outside the project the editor has open").option("--git", "Refused: phase commits would bypass the permission prompts").option("--json", "Refused: stdout carries the protocol").action(async (flags) => {
5125
5952
  const base = acp_base_config(merge_flags({}, flags));
5126
5953
  await serve_acp(acp_serve_config(base, { version: package_version() }));
5127
5954
  });