@robmclarty/volley 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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;
@@ -103,7 +103,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
103
103
  * escape. Returns the absolute path; throws on containment violation. The
104
104
  * `workspace` root passed here is the build root, which is the per-run git
105
105
  * worktree under `--worktree` (`build_root`), not necessarily the
106
- * control-plane workspace that holds `.volley/`. */
106
+ * workspace the run was started on. */
107
107
  function contain(workspace, rel) {
108
108
  const root = resolve(workspace);
109
109
  const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
@@ -142,6 +142,25 @@ function walk(root, start, limit) {
142
142
  }
143
143
  return out;
144
144
  }
145
+ /** The files a read tool's optional `path` covers: the whole tree, the tree
146
+ * under a directory, or the one file it names. A path that doesn't exist
147
+ * throws, so the seat reads why rather than an empty result that looks like
148
+ * "nothing matched". */
149
+ function scoped_files(workspace, path, limit) {
150
+ const root = resolve(workspace);
151
+ if (path === void 0) return walk(root, root, limit);
152
+ const start = contain(workspace, path);
153
+ let is_dir;
154
+ try {
155
+ is_dir = statSync(start).isDirectory();
156
+ } catch (err) {
157
+ throw new Error(`no such file or directory: ${path}`, { cause: err });
158
+ }
159
+ return is_dir ? walk(root, start, limit) : [{
160
+ rel: relative(root, start),
161
+ abs: start
162
+ }];
163
+ }
145
164
  function build_regex(pattern, flags) {
146
165
  try {
147
166
  return new RegExp(pattern, flags);
@@ -153,11 +172,11 @@ function build_regex(pattern, flags) {
153
172
  const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
154
173
  const search_files_input = z.object({
155
174
  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."),
175
+ path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
157
176
  ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
158
177
  });
159
178
  const list_files_input = z.object({
160
- path: z.string().optional().describe("Optional workspace-relative subdirectory. Default: the workspace root."),
179
+ path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
161
180
  contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
162
181
  });
163
182
  function read_only_tools(workspace) {
@@ -178,9 +197,8 @@ function read_only_tools(workspace) {
178
197
  input_schema: search_files_input,
179
198
  execute: (raw) => {
180
199
  const input = search_files_input.parse(raw);
181
- const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
182
200
  const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
183
- const files = walk(resolve(workspace), start, LIST_MAX_ENTRIES);
201
+ const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
184
202
  const hits = [];
185
203
  for (const file of files) {
186
204
  if (hits.length >= 200) break;
@@ -208,8 +226,7 @@ function read_only_tools(workspace) {
208
226
  input_schema: list_files_input,
209
227
  execute: (raw) => {
210
228
  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);
229
+ let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
213
230
  if (input.contains !== void 0) {
214
231
  const needle = input.contains;
215
232
  files = files.filter((f) => f.includes(needle));
@@ -986,14 +1003,130 @@ async function prewarm_ollama_model(base_url, model, abort) {
986
1003
  } catch {}
987
1004
  }
988
1005
  //#endregion
1006
+ //#region src/containment.ts
1007
+ /**
1008
+ * Whether volley is running inside its sandbox container. A leaf, so the
1009
+ * config resolution, the preflight, and the run-config signing can all ask
1010
+ * without importing one another.
1011
+ */
1012
+ /** volley *detects* it is running inside its sandbox container rather than
1013
+ * starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
1014
+ * volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
1015
+ * flags> <image> volley run …` is recognized as contained out of the box. Kept
1016
+ * env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
1017
+ * and testable, and so a CI job that itself runs in an unrelated container does
1018
+ * not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
1019
+ * the operator uses `--allow-unsandboxed-builder`). */
1020
+ function detect_containment(env) {
1021
+ const value = env["VOLLEY_CONTAINED"];
1022
+ return value === "1" || value === "true";
1023
+ }
1024
+ //#endregion
1025
+ //#region src/trust.ts
1026
+ /**
1027
+ * Which run configs a host-side resume may trust. A resume runs what a run's
1028
+ * `config.json` says: its check, a shell command, and its seats, permission
1029
+ * mode and prompt included. Inside the sandbox the builder runs arbitrary
1030
+ * commands with write access to its own run's state, so a contained run's
1031
+ * config is the builder's to rewrite, and resuming it on the host would run
1032
+ * whatever the builder left there.
1033
+ *
1034
+ * So volley on the host signs each config it writes, an HMAC under a key of
1035
+ * this machine's that never enters the container, and a host-side resume
1036
+ * trusts only a config whose signature holds. Inside the sandbox nothing is
1037
+ * signed, since the key isn't there and one made there would be in the
1038
+ * builder's reach, so a contained run's config never carries a signature the
1039
+ * host accepts, and the builder can't forge one without the key.
1040
+ */
1041
+ /** Prefixed to what is signed, so a signature over a run config can't pass for
1042
+ * one over anything else under the same key. */
1043
+ const DOMAIN = "volley run config v1\n";
1044
+ /** Where this machine's key lives: `$XDG_CONFIG_HOME/volley/run-key`, else
1045
+ * `~/.config/volley/run-key`, outside every state root, so a container a run
1046
+ * root is mounted into still can't read it. A relative `XDG_CONFIG_HOME` is
1047
+ * ignored, as the XDG spec says. */
1048
+ function trust_key_path(env, home = homedir()) {
1049
+ const xdg = env["XDG_CONFIG_HOME"];
1050
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".config");
1051
+ return join(base, "volley", "run-key");
1052
+ }
1053
+ function error_code(err) {
1054
+ return err.code;
1055
+ }
1056
+ /** The key as stored, or null when there is none or it isn't one. */
1057
+ function read_key(path) {
1058
+ try {
1059
+ const hex = readFileSync(path, "utf8").trim();
1060
+ return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null;
1061
+ } catch {
1062
+ return null;
1063
+ }
1064
+ }
1065
+ /** The key, created on first use: 32 random bytes in a file only its user can
1066
+ * read, in a directory only its user can enter. Two runs that start together
1067
+ * race to create it; the loser reads the winner's. */
1068
+ function machine_key(env) {
1069
+ const path = trust_key_path(env);
1070
+ const existing = read_key(path);
1071
+ if (existing !== null) return existing;
1072
+ try {
1073
+ mkdirSync(dirname(path), {
1074
+ recursive: true,
1075
+ mode: 448
1076
+ });
1077
+ writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, {
1078
+ flag: "wx",
1079
+ mode: 384
1080
+ });
1081
+ } catch (err) {
1082
+ if (error_code(err) !== "EEXIST") return null;
1083
+ }
1084
+ return read_key(path);
1085
+ }
1086
+ function hmac(key, config_text) {
1087
+ return createHmac("sha256", key).update(DOMAIN).update(config_text).digest("hex");
1088
+ }
1089
+ /** This machine's signature over a run's `config.json` text, or null where
1090
+ * there's none to give: inside the sandbox, or when the key can't be read or
1091
+ * created. */
1092
+ function sign_config(config_text, env = process.env) {
1093
+ if (detect_containment(env)) return null;
1094
+ const key = machine_key(env);
1095
+ return key === null ? null : hmac(key, config_text);
1096
+ }
1097
+ /** Does `signature` hold for `config_text` under this machine's key? Never
1098
+ * creates the key: with none there's nothing it could hold under. */
1099
+ function config_signed(config_text, signature, env = process.env) {
1100
+ if (signature === null) return false;
1101
+ const key = read_key(trust_key_path(env));
1102
+ if (key === null) return false;
1103
+ const expected = Buffer.from(hmac(key, config_text), "hex");
1104
+ const given = Buffer.from(signature.trim(), "hex");
1105
+ return given.length === expected.length && timingSafeEqual(given, expected);
1106
+ }
1107
+ //#endregion
989
1108
  //#region src/workspace.ts
990
1109
  /**
991
1110
  * Workspace lifecycle: run-state initialization, stale-run
992
1111
  * backup rotation, path helpers, and optional git checkpoints.
993
1112
  */
1113
+ /** A run id names the run's `volley/<run id>` branch and its state directory,
1114
+ * so it is held to what both take without quoting: letters, digits, `-`, and
1115
+ * `_`, starting with a letter or digit, so it never reads as an option and
1116
+ * never climbs out of the directory it names. */
1117
+ const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
1118
+ /** The root each run's state sits under when `--state-dir` names none:
1119
+ * `$XDG_STATE_HOME/volley`, else `<home>/.local/state/volley`. A relative
1120
+ * `XDG_STATE_HOME` is ignored, as the XDG spec says. `home` is the host's by
1121
+ * default; the sandbox passes its container's to spell the same root inside. */
1122
+ function default_state_root(env, home = homedir()) {
1123
+ const xdg = env["XDG_STATE_HOME"];
1124
+ const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".local", "state");
1125
+ return join(base, "volley");
1126
+ }
994
1127
  /** 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/`. */
1128
+ * iterations: `<state_dir>/<run_id>/` under its state root, or, in the layout
1129
+ * older runs used (`state_dir` null), `<workspace>/.volley/`. */
997
1130
  function run_state_dir(run) {
998
1131
  return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
999
1132
  }
@@ -1003,6 +1136,17 @@ function state_path(run, ...parts) {
1003
1136
  function iteration_dir(run, iteration) {
1004
1137
  return state_path(run, "iterations", String(iteration).padStart(3, "0"));
1005
1138
  }
1139
+ /** A state root that sits inside `root` (the build root), as the path from
1140
+ * there in git's `/`-separated vocabulary, or null when it sits outside. A
1141
+ * root inside the tree would otherwise pass for the builder's work: listed in
1142
+ * the changes the critic reads, and committed by every `--git` checkpoint.
1143
+ * The legacy `.volley/` (`state_dir` null) is excluded by name already. */
1144
+ function state_root_within(root, state_dir) {
1145
+ if (state_dir === null) return null;
1146
+ const path = relative(resolve(root), resolve(state_dir));
1147
+ if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return null;
1148
+ return path.split(sep).join("/");
1149
+ }
1006
1150
  /** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
1007
1151
  * run-state rotation here and the worktree rotation so both preserve
1008
1152
  * prior state under the same never-destroy naming. */
@@ -1012,18 +1156,33 @@ function backup_stamp(date = /* @__PURE__ */ new Date()) {
1012
1156
  /** Fresh-run initialization: rotate a stale state directory aside (a prior
1013
1157
  * `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
1014
1158
  * 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`). */
1159
+ * `--run-id` ever finds one to rotate. An empty one is used as it is: that is
1160
+ * what a contained run is handed when its run directory is the mount, which
1161
+ * can't be renamed. Resume runs skip rotation (`preserve: true`). */
1017
1162
  function initialize_run_state(run, options = {}) {
1018
1163
  const dir = run_state_dir(run);
1019
- if (existsSync(dir) && options.preserve !== true) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
1164
+ if (existsSync(dir) && options.preserve !== true && readdirSync(dir).length > 0) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
1165
+ if (run.state_dir !== null && !existsSync(run.state_dir)) mkdirSync(run.state_dir, {
1166
+ recursive: true,
1167
+ mode: 448
1168
+ });
1020
1169
  mkdirSync(join(dir, "iterations"), { recursive: true });
1021
1170
  }
1022
- function write_resolved_config(config) {
1171
+ /** Beside a run's `config.json`: this machine's signature over it
1172
+ * (`./trust.ts`), which a host-side resume checks before it trusts the
1173
+ * config. */
1174
+ const CONFIG_SIGNATURE = "config.sig";
1175
+ /** Record the run's config, signed when volley runs on the host. Returns
1176
+ * whether it was signed: inside the sandbox it never is, and on the host only
1177
+ * when the machine's key can't be read or created. A signature a reused run
1178
+ * directory held from before is removed rather than left to vouch for this
1179
+ * config. */
1180
+ function write_resolved_config(config, env = process.env) {
1023
1181
  const record = {
1024
1182
  version: config.version,
1025
1183
  run_id: config.run_id,
1026
1184
  started_at: config.started_at,
1185
+ mode: config.mode,
1027
1186
  prompt: config.prompt,
1028
1187
  criteria: config.criteria,
1029
1188
  check: config.check,
@@ -1032,6 +1191,7 @@ function write_resolved_config(config) {
1032
1191
  builder_provider: config.builder_provider,
1033
1192
  builder_url: config.builder_url,
1034
1193
  builder_max_steps: config.builder_max_steps,
1194
+ builder_max_tool_calls: config.builder_max_tool_calls,
1035
1195
  critic_model: config.critic_model,
1036
1196
  critic_provider: config.critic_provider,
1037
1197
  critic_url: config.critic_url,
@@ -1049,7 +1209,15 @@ function write_resolved_config(config) {
1049
1209
  state_dir: config.state_dir,
1050
1210
  workspace: config.workspace
1051
1211
  };
1052
- writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
1212
+ const text = `${JSON.stringify(record, null, 2)}\n`;
1213
+ writeFileSync(state_path(config, "config.json"), text);
1214
+ const signature = sign_config(text, env);
1215
+ if (signature === null) {
1216
+ rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
1217
+ return false;
1218
+ }
1219
+ writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
1220
+ return true;
1053
1221
  }
1054
1222
  /**
1055
1223
  * `--git`: commit everything under `root` after a phase (`git -C <root>`, run
@@ -1057,11 +1225,23 @@ function write_resolved_config(config) {
1057
1225
  * workspace on the default path, or the per-run worktree under `--worktree`, so
1058
1226
  * checkpoints land on the worktree branch rather than the workspace's.
1059
1227
  * A checkpoint with nothing to commit is fine; a missing git binary or repo is
1060
- * not.
1228
+ * not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
1229
+ * tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
1230
+ * bare `git add -A` is, and `literal` reads the path as written, wildcards and
1231
+ * all.
1061
1232
  */
1062
- function git_checkpoint(root, message) {
1233
+ function git_checkpoint(root, message, excluded_dir = null) {
1063
1234
  if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
1064
- const add = spawnSync("git", ["add", "-A"], { cwd: root });
1235
+ const pathspec = excluded_dir === null ? [] : [
1236
+ "--",
1237
+ ":/",
1238
+ `:(exclude,literal)${excluded_dir}`
1239
+ ];
1240
+ const add = spawnSync("git", [
1241
+ "add",
1242
+ "-A",
1243
+ ...pathspec
1244
+ ], { cwd: root });
1065
1245
  if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
1066
1246
  const commit = spawnSync("git", [
1067
1247
  "commit",
@@ -1071,7 +1251,7 @@ function git_checkpoint(root, message) {
1071
1251
  ], { cwd: root });
1072
1252
  if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
1073
1253
  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()}`);
1254
+ if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
1075
1255
  }
1076
1256
  /**
1077
1257
  * Integration — bring a successful `--worktree --git` run's effects onto the
@@ -1142,9 +1322,8 @@ function worktree_branch(run_id) {
1142
1322
  * when `--worktree` is on, else the workspace itself. This is the containment
1143
1323
  * root the builder/critic file tools resolve through `contain()` and the
1144
1324
  * `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.
1325
+ * disturbing the workspace. The check, the change report, and the checkpoints
1326
+ * follow it there; the run's state stays wherever `state_dir` puts it.
1148
1327
  */
1149
1328
  function build_root(workspace, worktree) {
1150
1329
  return worktree ? worktree_path(workspace) : workspace;
@@ -1485,12 +1664,27 @@ const HARNESS_APPENDS = {
1485
1664
  volley: "harness_append_local.md",
1486
1665
  acp: "harness_append_acp.md"
1487
1666
  };
1488
- function compose_builder_system_local(tool_set = "volley") {
1489
- return `${[
1667
+ /** Step 2 of "Work this way", as both harness appends word it for a builder
1668
+ * held to one tool call a turn. */
1669
+ const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
1670
+ /** Step 2 for a builder that may make several calls a turn: fascicle runs a
1671
+ * turn's calls in order, so a read can't race an edit, but each change or
1672
+ * command is still worth reading before the next. */
1673
+ function batched_calls(max_tool_calls) {
1674
+ return [
1675
+ `2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
1676
+ " make one change or command per turn, and read its result before",
1677
+ " deciding the next call."
1678
+ ].join("\n");
1679
+ }
1680
+ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
1681
+ const identity = [
1490
1682
  "You are the builder inside the volley harness: an autonomous agent",
1491
1683
  "iterating on a workspace until it satisfies a task and its acceptance",
1492
1684
  "criteria."
1493
- ].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
1685
+ ].join("\n");
1686
+ const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
1687
+ return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, batched_calls(max_tool_calls)) : append).trimEnd()}`;
1494
1688
  }
1495
1689
  /** The builder system prompt for the active provider: the `claude_cli`
1496
1690
  * prompt (implicit built-in tools, "the current working directory") for the
@@ -1498,7 +1692,7 @@ function compose_builder_system_local(tool_set = "volley") {
1498
1692
  * the seat's tool set for every provider that runs volley's tool loop,
1499
1693
  * `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
1500
1694
  function resolve_builder_system(config, tool_set) {
1501
- return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
1695
+ return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
1502
1696
  }
1503
1697
  const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
1504
1698
  /** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
@@ -1523,7 +1717,7 @@ function builder_tool_options(deps) {
1523
1717
  max_steps: config.builder_max_steps,
1524
1718
  tool_error_policy: "feed_back",
1525
1719
  tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
1526
- max_tool_calls_per_step: 1
1720
+ max_tool_calls_per_step: config.builder_max_tool_calls
1527
1721
  };
1528
1722
  }
1529
1723
  function compose_builder_prompt(input) {
@@ -1687,6 +1881,29 @@ function capture_baseline(root) {
1687
1881
  const sha = head.stdout.trim();
1688
1882
  return sha.length > 0 ? sha : null;
1689
1883
  }
1884
+ /**
1885
+ * The commit a critique's change is measured from: `rev` (`--baseline`)
1886
+ * resolved in `root`, so the change is everything that differs from it,
1887
+ * committed or not, untracked files included. With no revision named it's
1888
+ * HEAD, so the uncommitted and untracked edits are the change, and, as for a
1889
+ * run, null where there's no repository or no commit yet. A named revision
1890
+ * that isn't a commit there, or one named where there's no repository, is a
1891
+ * config error.
1892
+ */
1893
+ function resolve_baseline(root, rev) {
1894
+ if (rev === void 0) return capture_baseline(root);
1895
+ if (!in_git_repo(root)) throw config_error(`--baseline needs the workspace to be a git repository: ${root}`);
1896
+ const resolved = git(root, [
1897
+ "rev-parse",
1898
+ "--verify",
1899
+ "--quiet",
1900
+ "--end-of-options",
1901
+ `${rev}^{commit}`
1902
+ ]);
1903
+ const sha = resolved.stdout.trim();
1904
+ if (resolved.status !== 0 || sha.length === 0) throw config_error(`--baseline names no commit in ${root}: ${rev}`);
1905
+ return sha;
1906
+ }
1690
1907
  /** git's status letters, folded onto the four cases worth naming. Rename and
1691
1908
  * copy both record the destination path — where the content lives now. */
1692
1909
  function change_status(code) {
@@ -1723,7 +1940,8 @@ function parse_name_status(stdout) {
1723
1940
  function parse_paths(stdout) {
1724
1941
  return stdout.split("\0").filter((path) => path.length > 0);
1725
1942
  }
1726
- function excluded(path) {
1943
+ function excluded(path, excluded_dir) {
1944
+ if (excluded_dir !== null && path.startsWith(`${excluded_dir}/`)) return true;
1727
1945
  return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix));
1728
1946
  }
1729
1947
  /**
@@ -1803,10 +2021,11 @@ function collect_changes(options) {
1803
2021
  path,
1804
2022
  status: "added"
1805
2023
  }));
2024
+ const excluded_dir = options.excluded_dir ?? null;
1806
2025
  const seen = /* @__PURE__ */ new Set();
1807
2026
  const files = [];
1808
2027
  for (const change of [...tracked, ...untracked]) {
1809
- if (excluded(change.path) || seen.has(change.path)) continue;
2028
+ if (excluded(change.path, excluded_dir) || seen.has(change.path)) continue;
1810
2029
  seen.add(change.path);
1811
2030
  files.push({
1812
2031
  ...change,
@@ -2169,7 +2388,7 @@ function compose_critic_prompt(input) {
2169
2388
  * Critic invocation: a read-only session (`claude_cli`, or a local or
2170
2389
  * `openrouter` model with volley's read tools) returning a schema-validated
2171
2390
  * structured verdict. The
2172
- * harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
2391
+ * harness — not the critic — writes the run's `feedback.md` and `verdict`.
2173
2392
  */
2174
2393
  const CRITIC_ALLOWED_TOOLS = [
2175
2394
  "Read",
@@ -2177,10 +2396,16 @@ const CRITIC_ALLOWED_TOOLS = [
2177
2396
  "Glob"
2178
2397
  ];
2179
2398
  const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2399
+ /** A critic that holds tools isn't constrain-decoded, so nothing stops it
2400
+ * answering null for the feedback or the unmet list, as GLM did on approving.
2401
+ * Both fields allow null and read it as empty, so that answer is a verdict
2402
+ * rather than a schema failure that ends the run. fascicle sends the schema's
2403
+ * input side, so the model sees each field as its type or null, still
2404
+ * required; `.describe()` comes last so the description stays on the field. */
2180
2405
  const verdict_schema = z.object({
2181
2406
  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.")
2407
+ 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."),
2408
+ unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
2184
2409
  });
2185
2410
  /** Read-only tool wiring per provider. The claude_cli critic is confined at
2186
2411
  * the CLI permission layer (allowlist + explicit disallow); every other critic,
@@ -2483,6 +2708,62 @@ async function run_critic(deps, state, ctx) {
2483
2708
  }
2484
2709
  }
2485
2710
  //#endregion
2711
+ //#region src/render/format.ts
2712
+ /**
2713
+ * Display formatting: glyph taxonomy, role colors, truncation.
2714
+ * All human output goes to stderr; these helpers only build strings.
2715
+ */
2716
+ const GLYPH_TOOL = "🔧";
2717
+ const GLYPH_COST = "💰";
2718
+ const VERBOSE_TRUNCATE_CHARS = 4e3;
2719
+ function colors_enabled(env = process.env, is_tty = process.stderr.isTTY === true) {
2720
+ if (env["NO_COLOR"] !== void 0 || env["VOLLEY_NO_COLOR"] !== void 0) return false;
2721
+ return is_tty;
2722
+ }
2723
+ function paint(role, text, color) {
2724
+ if (!color) return text;
2725
+ switch (role) {
2726
+ case "builder": return pc.cyan(text);
2727
+ case "critic": return pc.yellow(text);
2728
+ case "check": return pc.green(text);
2729
+ case "cost": return pc.magenta(text);
2730
+ case "error": return pc.bold(pc.red(text));
2731
+ }
2732
+ }
2733
+ function dim(text, color) {
2734
+ return color ? pc.dim(text) : text;
2735
+ }
2736
+ function truncate(text, max, pointer) {
2737
+ if (text.length <= max) return text;
2738
+ return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
2739
+ }
2740
+ /** Controls, format characters (bidi overrides, zero-widths), and line and
2741
+ * paragraph separators: what can move the cursor, hide text, or reorder a line
2742
+ * in a terminal. */
2743
+ const UNSAFE_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
2744
+ /** A value from a file volley can't vouch for, quoted for a person to judge,
2745
+ * with every character that could redraw or reorder the line escaped, so a
2746
+ * rewritten config can't make the warning about it say something else. JSON
2747
+ * quoting escapes the C0 controls, the quote, and the backslash; the rest are
2748
+ * written as `\u{…}`. */
2749
+ function shown(value) {
2750
+ return JSON.stringify(value).replace(UNSAFE_CHARACTERS, (character) => `\\u{${(character.codePointAt(0) ?? 0).toString(16)}}`);
2751
+ }
2752
+ function format_usd(value) {
2753
+ return value === null ? "$?" : `$${value.toFixed(3)}`;
2754
+ }
2755
+ /** One-line summary of a tool input for the default streaming mode. */
2756
+ function summarize_value(value, max = 120) {
2757
+ let text;
2758
+ try {
2759
+ text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
2760
+ } catch {
2761
+ text = String(value);
2762
+ }
2763
+ const flat = text.replace(/\s+/g, " ");
2764
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
2765
+ }
2766
+ //#endregion
2486
2767
  //#region src/iteration.ts
2487
2768
  /**
2488
2769
  * Iteration archiving, run-level summary, and resume-state recovery.
@@ -2573,23 +2854,86 @@ function run_result_from_state(config, state, status, completed_at) {
2573
2854
  critic_cost_usd: state.critic_cost_usd,
2574
2855
  check_duration_ms: state.check_duration_ms,
2575
2856
  final_verdict: state.verdict,
2576
- salvaged_branch: null
2857
+ salvaged_branch: null,
2858
+ run_state_dir: run_state_dir(config),
2859
+ mode: config.mode
2577
2860
  };
2578
2861
  }
2862
+ /** Why a host-side resume won't run a config this machine didn't sign. */
2863
+ function untrusted_config(run_id, config_path) {
2864
+ 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.`);
2865
+ }
2866
+ /** Whether a host-side resume may run the config in `text`: signed by this
2867
+ * machine, or trusted by the caller. Inside the sandbox nothing is checked: a
2868
+ * resume there runs only within the container, as the builder already does. */
2869
+ function check_config_trust(text, run, options) {
2870
+ const env = options.env ?? process.env;
2871
+ if (detect_containment(env)) return null;
2872
+ const signature_path = state_path(run, CONFIG_SIGNATURE);
2873
+ const signed = config_signed(text, existsSync(signature_path) ? readFileSync(signature_path, "utf8") : null, env);
2874
+ if (!signed && options.trust_config !== true) throw untrusted_config(run.run_id, state_path(run, "config.json"));
2875
+ return signed;
2876
+ }
2877
+ /** The run id an older volley's `.volley/config.json` records, or null when
2878
+ * there is none to read. */
2879
+ function legacy_run_id(workspace) {
2880
+ try {
2881
+ const recorded = JSON.parse(readFileSync(join(workspace, ".volley", "config.json"), "utf8"));
2882
+ return typeof recorded.run_id === "string" ? recorded.run_id : null;
2883
+ } catch {
2884
+ return null;
2885
+ }
2886
+ }
2887
+ /** Where `volley resume <run_id>` finds a run: under `root` (`--state-dir`,
2888
+ * else the default root) at `<root>/<run_id>/`, returned as that root, or, for
2889
+ * a run an older volley kept in the workspace, `.volley/`, returned as null so
2890
+ * the resumed run stays there. Finding neither is a config error naming both
2891
+ * places it looked. The id becomes a path, so it must be one a run could have
2892
+ * had. */
2893
+ function find_run_state(workspace, run_id, root) {
2894
+ 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)`);
2895
+ if (existsSync(join(root, run_id, "config.json"))) return root;
2896
+ const legacy = legacy_run_id(workspace);
2897
+ if (legacy === run_id) return null;
2898
+ const holds = legacy === null ? "" : ` (it holds run ${shown(legacy)})`;
2899
+ 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/`);
2900
+ }
2901
+ /** A resumed run writes into its state directory, and a contained run's
2902
+ * builder could have written there first, so a symlink anywhere in it would
2903
+ * aim the resume's writes, or its pruning of a half-finished iteration, at a
2904
+ * file of the host's. volley never makes one, so any is refused. The walk
2905
+ * reads each entry's own type and never descends a link, which Node's
2906
+ * recursive `readdirSync` would. */
2907
+ function refuse_symlinks(dir) {
2908
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2909
+ const path = join(dir, entry.name);
2910
+ if (entry.isSymbolicLink()) throw config_error(`refusing to resume: ${path} is a symbolic link, which volley never writes`);
2911
+ if (entry.isDirectory()) refuse_symlinks(path);
2912
+ }
2913
+ }
2579
2914
  /** Recover a resumable run from its state directory (`.volley/`, or
2580
2915
  * `<state_dir>/<run_id>/` under a state root): settings from config.json,
2581
2916
  * 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) {
2917
+ * without a summary.json was interrupted mid-phase and is discarded. A
2918
+ * directory with a symlink in it is refused before anything is read. */
2919
+ function load_resume_state(workspace, run_id, state_dir = null, options = {}) {
2584
2920
  const run = {
2585
2921
  workspace,
2586
2922
  state_dir,
2587
2923
  run_id
2588
2924
  };
2925
+ const run_dir = run_state_dir(run);
2926
+ if (existsSync(run_dir)) {
2927
+ if (lstatSync(run_dir).isSymbolicLink()) throw config_error(`refusing to resume: ${run_dir} is a symbolic link, which volley never writes`);
2928
+ refuse_symlinks(run_dir);
2929
+ }
2589
2930
  const config_path = state_path(run, "config.json");
2590
2931
  if (!existsSync(config_path)) throw config_error(`no resumable run found: ${config_path} missing`);
2591
- const recorded = JSON.parse(readFileSync(config_path, "utf8"));
2932
+ const config_text = readFileSync(config_path, "utf8");
2933
+ const signed = check_config_trust(config_text, run, options);
2934
+ const recorded = JSON.parse(config_text);
2592
2935
  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"])})`);
2936
+ 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
2937
  const iterations_root = state_path(run, "iterations");
2594
2938
  const dirs = existsSync(iterations_root) ? readdirSync(iterations_root).filter((d) => /^\d{3}$/.test(d)).toSorted() : [];
2595
2939
  let completed = 0;
@@ -2614,6 +2958,7 @@ function load_resume_state(workspace, run_id, state_dir = null) {
2614
2958
  builder_provider: recorded["builder_provider"] ?? "claude_cli",
2615
2959
  ...typeof recorded["builder_url"] === "string" ? { builder_url: recorded["builder_url"] } : {},
2616
2960
  ...recorded["builder_max_steps"] === void 0 ? {} : { builder_max_steps: Number(recorded["builder_max_steps"]) },
2961
+ ...recorded["builder_max_tool_calls"] === void 0 ? {} : { builder_max_tool_calls: Number(recorded["builder_max_tool_calls"]) },
2617
2962
  critic_model: String(recorded["critic_model"]),
2618
2963
  critic_provider: recorded["critic_provider"] ?? "claude_cli",
2619
2964
  ...typeof recorded["critic_url"] === "string" ? { critic_url: recorded["critic_url"] } : {},
@@ -2653,7 +2998,8 @@ function load_resume_state(workspace, run_id, state_dir = null) {
2653
2998
  run_id,
2654
2999
  started_at: String(recorded["started_at"]),
2655
3000
  state,
2656
- iterations_completed: completed
3001
+ iterations_completed: completed,
3002
+ config_signed: signed
2657
3003
  };
2658
3004
  }
2659
3005
  //#endregion
@@ -2753,7 +3099,7 @@ function build_run_summary(config, result) {
2753
3099
  return {
2754
3100
  ...result,
2755
3101
  comparison: {
2756
- builder_transport: transport_of(config.builder_provider),
3102
+ builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
2757
3103
  critic_transport: transport_of(config.critic_provider),
2758
3104
  iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2759
3105
  wall_clock_ms,
@@ -2828,7 +3174,16 @@ function report_changes(config, renderer, changes) {
2828
3174
  if (changes.gate_edits.length === 0) return;
2829
3175
  const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
2830
3176
  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)." : ""));
3177
+ const [who, whose, edits] = config.mode === "critique" ? [
3178
+ "the change under review touches",
3179
+ "whether it passes",
3180
+ "the change edits"
3181
+ ] : [
3182
+ "the builder changed",
3183
+ "whether its work passes",
3184
+ "the builder can edit"
3185
+ ];
3186
+ 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
3187
  }
2833
3188
  /** Run the configured check, checkride or a command, with the environment
2834
3189
  * the caller set for it, else volley's own. */
@@ -2882,6 +3237,22 @@ function next_iteration(s) {
2882
3237
  unmet_criteria: []
2883
3238
  };
2884
3239
  }
3240
+ /** What changed in the build root since the baseline, reported as it's
3241
+ * measured: the count, and a warning for any gate edit. A run measures the
3242
+ * builder's work this way after every build, and a critique measures the
3243
+ * change under review the same way, so the critic is shown one shape either
3244
+ * way. */
3245
+ function measure_changes(deps, root) {
3246
+ const { config, renderer } = deps;
3247
+ const changes = collect_changes({
3248
+ root,
3249
+ baseline: deps.baseline,
3250
+ gate_patterns: config.gate_paths,
3251
+ excluded_dir: state_root_within(root, config.state_dir)
3252
+ });
3253
+ report_changes(config, renderer, changes);
3254
+ return changes;
3255
+ }
2885
3256
  /** `build`: one builder session edits the build root, then volley measures what
2886
3257
  * it changed and, under `--git`, checkpoints it. */
2887
3258
  async function build_phase(deps, builder, s, ctx) {
@@ -2900,18 +3271,28 @@ async function build_phase(deps, builder, s, ctx) {
2900
3271
  renderer.phase_end(built.iteration, "builder", true);
2901
3272
  renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
2902
3273
  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`);
3274
+ const root = build_root(config.workspace, config.worktree);
3275
+ const changes = measure_changes(deps, root);
3276
+ if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
2910
3277
  return {
2911
3278
  ...built,
2912
3279
  changes
2913
3280
  };
2914
3281
  }
3282
+ /** `measure`: a critique's one round opens, and the change under review is
3283
+ * measured from the baseline, as a run measures the builder's work. */
3284
+ function measure_phase(deps, s, ctx) {
3285
+ const next = next_iteration(s);
3286
+ emit(ctx, {
3287
+ volley: "round_start",
3288
+ round: next.iteration
3289
+ });
3290
+ const root = build_root(deps.config.workspace, deps.config.worktree);
3291
+ return {
3292
+ ...next,
3293
+ changes: measure_changes(deps, root)
3294
+ };
3295
+ }
2915
3296
  /** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
2916
3297
  * as skipped and no critic is called — a doomed iteration spends nothing more.
2917
3298
  * The loop guard then halts the run on the cap. */
@@ -3025,7 +3406,10 @@ function record_phase(deps, s) {
3025
3406
  const { config } = deps;
3026
3407
  archive_iteration(config, s);
3027
3408
  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"})`);
3409
+ if (config.git_checkpoints) {
3410
+ const root = build_root(config.workspace, config.worktree);
3411
+ git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
3412
+ }
3029
3413
  return s;
3030
3414
  }
3031
3415
  //#endregion
@@ -3488,17 +3872,49 @@ function resolve_critic(critic, cwd) {
3488
3872
  critic_prompt_path: path
3489
3873
  };
3490
3874
  }
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)}`);
3875
+ /** What only a builder, or the loop around it, uses, with the flag that sets
3876
+ * each: a critique has no use for any of them, so naming one is an error
3877
+ * rather than something quietly ignored. */
3878
+ const CRITIQUE_REFUSED = [
3879
+ ["builder_model", "--builder-model"],
3880
+ ["builder_provider", "--builder-provider"],
3881
+ ["builder_url", "--builder-url"],
3882
+ ["builder_max_steps", "--builder-max-steps"],
3883
+ ["builder_max_tool_calls", "--builder-max-tool-calls"],
3884
+ ["builder_permission_mode", "--builder-permission-mode"],
3885
+ ["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
3886
+ ["sandbox_image", "--sandbox-image"],
3887
+ ["max_iterations", "--max-iterations"],
3888
+ ["max_cost_usd", "--max-cost-usd"],
3889
+ ["git_checkpoints", "--git"],
3890
+ ["worktree", "--worktree"],
3891
+ ["discard_worktree", "--discard-worktree"],
3892
+ ["fail_on_gate_edit", "--fail-on-gate-edit"]
3893
+ ];
3894
+ function refuse_builder_settings(raw) {
3895
+ 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`);
3896
+ }
3897
+ const BUILDER_MAX_STEPS = {
3898
+ flag: "--builder-max-steps",
3899
+ env_var: "VOLLEY_BUILDER_MAX_STEPS",
3900
+ fallback: 50
3901
+ };
3902
+ const BUILDER_MAX_TOOL_CALLS = {
3903
+ flag: "--builder-max-tool-calls",
3904
+ env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
3905
+ fallback: 1
3906
+ };
3907
+ /** A builder count's flag or config value wins over its env var, which wins
3908
+ * over the default, and either must be a positive integer. Local builder
3909
+ * only; ignored by the `claude_cli` arm. */
3910
+ function resolve_builder_count(value, env, count) {
3911
+ if (value !== void 0) return positive_count(value, value, count);
3912
+ const env_value = env[count.env_var];
3913
+ if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
3914
+ return count.fallback;
3915
+ }
3916
+ function positive_count(value, original, count) {
3917
+ if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
3502
3918
  return value;
3503
3919
  }
3504
3920
  /** `--sandbox-image` (a merged flag/config value) wins over the
@@ -3548,25 +3964,36 @@ function resolve_seat_url(seat, value, provider) {
3548
3964
  }
3549
3965
  /** `--state-dir` (`state_dir`): the root each run's state sits under at
3550
3966
  * `<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;
3967
+ * place. Unset, or empty as a seat URL may be, it's the default root,
3968
+ * `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
3969
+ * workspace, where no check that reads every file can trip over the run's own
3970
+ * feedback. */
3971
+ function resolve_state_dir(value, cwd, env) {
3972
+ if (value === void 0 || value === null) return default_state_root(env);
3555
3973
  if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
3556
3974
  const path = value.trim();
3557
- return path === "" ? null : resolve(cwd, path);
3975
+ return path === "" ? default_state_root(env) : resolve(cwd, path);
3558
3976
  }
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";
3977
+ function is_writable_dir(path) {
3978
+ try {
3979
+ if (!statSync(path).isDirectory()) return false;
3980
+ accessSync(path, constants.W_OK);
3981
+ return true;
3982
+ } catch {
3983
+ return false;
3984
+ }
3985
+ }
3986
+ /** The run's state directory must be one volley can write: the directory
3987
+ * itself when it's there, as a contained run's mount is, else the nearest
3988
+ * directory above it, where volley creates it. Checked before any spend,
3989
+ * because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
3990
+ * the sandbox the root filesystem is read-only, so that is what an unmounted
3991
+ * run directory looks like, and the message says what to mount. */
3992
+ function validate_run_state_dir(dir, contained) {
3993
+ let existing = dir;
3994
+ while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
3995
+ if (is_writable_dir(existing)) return;
3996
+ 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
3997
  }
3571
3998
  /** Whether a tool-loop builder's commands would reach the host with nothing in
3572
3999
  * between: volley isn't contained, and the operator hasn't opted out with
@@ -3574,10 +4001,7 @@ function detect_containment(env) {
3574
4001
  function unguarded_builder(raw, env) {
3575
4002
  return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
3576
4003
  }
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_-]*$/;
4004
+ /** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
3581
4005
  function resolve_run_id(run_id) {
3582
4006
  if (run_id === void 0) return randomUUID();
3583
4007
  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 +4013,17 @@ function resolve_run_id(run_id) {
3589
4013
  function resolve_config(raw, options = {}) {
3590
4014
  const cwd = options.cwd ?? process.cwd();
3591
4015
  const env = options.env ?? process.env;
3592
- if (typeof raw.prompt !== "string" || raw.prompt.length === 0) throw config_error("missing required option: --prompt");
4016
+ const critique = options.mode === "critique";
4017
+ if (critique) refuse_builder_settings(raw);
4018
+ const builder_env = critique ? {} : env;
4019
+ const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
4020
+ if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
3593
4021
  if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
3594
4022
  if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
3595
4023
  const workspace = validate_workspace(resolve(cwd, raw.workspace));
3596
- const prompt = expand_at_file(raw.prompt, cwd);
4024
+ const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
3597
4025
  const criteria = expand_at_file(raw.criteria, cwd);
3598
- const max_iterations = raw.max_iterations ?? 10;
4026
+ const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
3599
4027
  if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
3600
4028
  const max_cost_usd = raw.max_cost_usd ?? null;
3601
4029
  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 +4032,18 @@ function resolve_config(raw, options = {}) {
3604
4032
  const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
3605
4033
  const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
3606
4034
  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);
4035
+ const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
3608
4036
  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);
4037
+ 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.`);
4038
+ const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
4039
+ const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
3611
4040
  const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
3612
4041
  if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
3613
4042
  const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
3614
4043
  const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
3615
4044
  if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
3616
4045
  if (contained) {
3617
- const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
4046
+ const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
3618
4047
  const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
3619
4048
  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
4049
  }
@@ -3625,14 +4054,17 @@ function resolve_config(raw, options = {}) {
3625
4054
  if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
3626
4055
  const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
3627
4056
  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);
4057
+ const sandbox_image = resolve_sandbox_image(raw, builder_env);
4058
+ const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
4059
+ if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
3630
4060
  const run_id = resolve_run_id(options.run_id);
4061
+ if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
3631
4062
  const check = raw.check ?? "auto";
3632
4063
  return {
3633
4064
  version: 2,
3634
4065
  run_id,
3635
4066
  started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
4067
+ mode: critique ? "critique" : "run",
3636
4068
  prompt,
3637
4069
  criteria,
3638
4070
  check,
@@ -3641,6 +4073,7 @@ function resolve_config(raw, options = {}) {
3641
4073
  builder_provider,
3642
4074
  builder_url,
3643
4075
  builder_max_steps,
4076
+ builder_max_tool_calls,
3644
4077
  allow_unsandboxed_builder,
3645
4078
  critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
3646
4079
  critic_provider,
@@ -3739,6 +4172,11 @@ function status_of(value, converged) {
3739
4172
  * around the flow (workspace setup, the worktree lifecycle, `run`, the final
3740
4173
  * summary) in `./orchestrator.ts`.
3741
4174
  *
4175
+ * A critique (`build_critique_flow`) is one round over a change already made,
4176
+ * so it has no builder, no loop, and no guard: `measure`, which opens the
4177
+ * round and measures the change from its baseline, then the same `check`,
4178
+ * `critique`, and `record` a run's round ends with.
4179
+ *
3742
4180
  * fascicle's `describe.diagram` draws the tree below from the flow itself, and
3743
4181
  * `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
3744
4182
  * its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
@@ -3782,6 +4220,24 @@ function build_critic(deps) {
3782
4220
  project: with_fallback_cause
3783
4221
  });
3784
4222
  }
4223
+ /** The steps a run and a critique share, built one way for both. */
4224
+ function check_step(deps) {
4225
+ return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
4226
+ }
4227
+ function critique_step(deps, seat) {
4228
+ const critic = build_critic({
4229
+ ...seat,
4230
+ config: deps.config,
4231
+ on_chunk: deps.renderer.critic_chunk
4232
+ });
4233
+ return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
4234
+ description: "the read-only critic's verdict and feedback",
4235
+ arm: critic
4236
+ });
4237
+ }
4238
+ function record_step(deps) {
4239
+ return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
4240
+ }
3785
4241
  function build_flow(deps, resume_from) {
3786
4242
  const { config, renderer } = deps;
3787
4243
  const builder = make_builder_step({
@@ -3790,22 +4246,14 @@ function build_flow(deps, resume_from) {
3790
4246
  on_chunk: renderer.builder_chunk,
3791
4247
  bash_executor: deps.bash_executor
3792
4248
  });
3793
- const critic = build_critic({
3794
- ...deps.seats.critic,
3795
- config,
3796
- on_chunk: renderer.critic_chunk
3797
- });
3798
4249
  const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
3799
4250
  description: "open the iteration, run the builder, measure the change",
3800
4251
  arm: builder
3801
4252
  });
3802
4253
  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" });
4254
+ const check = check_step(deps);
4255
+ const critique = critique_step(deps, deps.seats.critic);
4256
+ const record = record_step(deps);
3809
4257
  const verify = branch({
3810
4258
  name: "verify",
3811
4259
  description: "did the build alone cross the cost cap?",
@@ -3831,6 +4279,19 @@ function build_flow(deps, resume_from) {
3831
4279
  max_rounds: remaining_iterations(config, resume_from)
3832
4280
  });
3833
4281
  }
4282
+ /** A critique: one round over a change already made. `measure` opens it and
4283
+ * measures the change from the baseline, then the check, the critic, and the
4284
+ * record, as in a run's round. No loop and no guard: there is nothing to
4285
+ * iterate on, and the caller reads the outcome off the final state. */
4286
+ function build_critique_flow(deps) {
4287
+ const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
4288
+ return sequence([
4289
+ measure,
4290
+ check_step(deps),
4291
+ critique_step(deps, deps.critic),
4292
+ record_step(deps)
4293
+ ], { name: "critique_run" });
4294
+ }
3834
4295
  //#endregion
3835
4296
  //#region src/orchestrator.ts
3836
4297
  /**
@@ -3847,13 +4308,19 @@ function build_flow(deps, resume_from) {
3847
4308
  * │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
3848
4309
  * │ └─ integrate a successful --worktree --git run only
3849
4310
  * └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
4311
+ *
4312
+ * run_critique
4313
+ * ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
4314
+ * ├─ initialize the run's state
4315
+ * └─ run the critique flow one round on the critic's engine alone, then the final summary
3850
4316
  */
3851
4317
  async function run_volley(config, deps, resume_from = null) {
3852
4318
  const { renderer } = deps;
3853
4319
  if (resume_from === null) {
3854
4320
  initialize_run_state(config);
3855
- write_resolved_config(config);
4321
+ 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
4322
  } else initialize_run_state(config, { preserve: true });
4323
+ renderer.info(`state: ${run_state_dir(config)}`);
3857
4324
  report_worktree_fate(config, renderer);
3858
4325
  const branch = worktree_branch(config.run_id);
3859
4326
  const fate = worktree_fate(config);
@@ -3971,61 +4438,61 @@ function finalize_failed_run(config, err) {
3971
4438
  const summary_path = state_path(config, "summary.json");
3972
4439
  write_run_summary(config, {
3973
4440
  ...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
4441
+ run_state_dir: run_state_dir(config),
3974
4442
  status,
3975
4443
  completed_at: (/* @__PURE__ */ new Date()).toISOString()
3976
4444
  });
3977
4445
  } catch {}
3978
4446
  }
3979
- //#endregion
3980
- //#region src/render/format.ts
3981
4447
  /**
3982
- * Display formatting: glyph taxonomy, role colors, truncation.
3983
- * All human output goes to stderr; these helpers only build strings.
4448
+ * `volley critique`: the check and the critic, once, over a change already
4449
+ * made. No builder is constructed, and the engine serves the critic's seat
4450
+ * alone, so a critic can be measured on fixed, labeled changes without paying
4451
+ * for builder turns. It writes the state a one-round run does, with the mode
4452
+ * recorded, and ends `success` on approval over a green check, else
4453
+ * `rejected`.
3984
4454
  */
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;
4455
+ async function run_critique(config, deps) {
4456
+ if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
4457
+ const { renderer } = deps;
4458
+ const baseline = resolve_baseline(config.workspace, deps.baseline);
4459
+ initialize_run_state(config);
4460
+ write_resolved_config(config);
4461
+ renderer.info(`state: ${run_state_dir(config)}`);
4462
+ renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
4463
+ const engine = deps.engine ?? create_volley_engine({
4464
+ workspace: config.workspace,
4465
+ critic_provider: config.critic_provider,
4466
+ critic_url: config.critic_url
4467
+ });
4468
+ const flow = build_critique_flow({
4469
+ config,
4470
+ renderer,
4471
+ baseline,
4472
+ critic: {
4473
+ ...deps.critic,
4474
+ engine
4475
+ },
4476
+ ...deps.check !== void 0 ? { check: deps.check } : {}
4477
+ });
4015
4478
  try {
4016
- text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
4017
- } catch {
4018
- text = String(value);
4479
+ const value = await execute_flow(config, deps, flow, initial_state());
4480
+ const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
4481
+ write_run_summary(config, result);
4482
+ return result;
4483
+ } catch (err) {
4484
+ finalize_failed_run(config, err);
4485
+ throw err;
4486
+ } finally {
4487
+ await engine.dispose();
4019
4488
  }
4020
- const flat = text.replace(/\s+/g, " ");
4021
- return flat.length > max ? `${flat.slice(0, max)}…` : flat;
4022
4489
  }
4023
4490
  //#endregion
4024
4491
  //#region src/render/renderer.ts
4025
4492
  function create_renderer(options) {
4026
4493
  const write = options.write ?? ((text) => process.stderr.write(text));
4027
4494
  const { mode, color } = options;
4028
- const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
4495
+ const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
4029
4496
  const silent = mode === "json";
4030
4497
  const streaming = mode === "default" || mode === "verbose";
4031
4498
  let mid_line = false;
@@ -4103,6 +4570,7 @@ function create_renderer(options) {
4103
4570
  line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
4104
4571
  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
4572
  line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
4573
+ line(`state: ${result.run_state_dir}`);
4106
4574
  }
4107
4575
  };
4108
4576
  }
@@ -4146,14 +4614,6 @@ const REFUSED_FLAGS = [
4146
4614
  /** What the startup check resolves in place of a prompt, which no session has
4147
4615
  * sent yet. */
4148
4616
  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
4617
  /** Unset, or set to nothing but whitespace. Any other value goes on to
4158
4618
  * `resolve_config`, which says what's wrong with it. */
4159
4619
  function is_blank(value) {
@@ -4166,17 +4626,18 @@ function refuse_unservable(raw) {
4166
4626
  if (missing.length > 0) throw config_error(`volley acp requires every seat flag, and these are missing: ${missing.join(", ")}`);
4167
4627
  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
4628
  }
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. */
4629
+ /** The flags `volley acp` was started with, with acp's default filled in:
4630
+ * three rounds. Run state goes under the default root, outside the workspace,
4631
+ * unless `--state-dir` names one, as it does for any run. Each refusal, a
4632
+ * missing seat flag, and any other config error, an unwritable state root
4633
+ * among them, throws here, before serving: the whole config resolves once
4634
+ * against the system temp directory with a stand-in task, since no session has
4635
+ * named a workspace yet. */
4174
4636
  function acp_base_config(raw, env = process.env) {
4175
4637
  refuse_unservable(raw);
4176
4638
  const base = {
4177
4639
  ...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
4640
+ max_iterations: raw.max_iterations ?? 3
4180
4641
  };
4181
4642
  resolve_config({
4182
4643
  ...base,
@@ -4290,7 +4751,8 @@ function acp_serve_config(base, options) {
4290
4751
  function exit_code_for_status(status) {
4291
4752
  switch (status) {
4292
4753
  case "success": return 0;
4293
- case "budget_exhausted": return 2;
4754
+ case "budget_exhausted":
4755
+ case "rejected": return 2;
4294
4756
  case "cost_cap_reached": return 7;
4295
4757
  case "gate_edit_blocked": return 8;
4296
4758
  case "interrupted": return 130;
@@ -4537,10 +4999,10 @@ function combo_dir(matrix_dir, combo) {
4537
4999
  function slug(model) {
4538
5000
  return model.replace(/[^a-zA-Z0-9._-]/g, "_");
4539
5001
  }
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. */
5002
+ /** One attempt's run summary, copied out of the run's own state into the
5003
+ * sweep, so the sweep keeps its evidence beside its table. Every attempt gets
5004
+ * its own `run-NN/` so a repeated seat keeps all of its evidence, not just the
5005
+ * last run's. */
4544
5006
  function persist_attempt(matrix_dir, combo, attempt, summary) {
4545
5007
  const dir = join(combo_dir(matrix_dir, combo), `run-${String(attempt).padStart(2, "0")}`);
4546
5008
  mkdirSync(dir, { recursive: true });
@@ -4854,7 +5316,8 @@ function report_gate_posture(config, renderer) {
4854
5316
  return;
4855
5317
  }
4856
5318
  if (config.gate_paths.length === 0) {
4857
- renderer.info("gate: no gate paths configured; builder edits to tests and check config are not flagged.");
5319
+ const edits = config.mode === "critique" ? "changes" : "builder edits";
5320
+ renderer.info(`gate: no gate paths configured; ${edits} to tests and check config are not flagged.`);
4858
5321
  return;
4859
5322
  }
4860
5323
  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 +5346,16 @@ async function preflight(config, renderer, env, probes = {}) {
4883
5346
  const tool_loop_builder = is_tool_loop_builder(config.builder_provider);
4884
5347
  renderer.info(`dry run: config valid (run ${config.run_id})`);
4885
5348
  renderer.info(`workspace: ${config.workspace}`);
5349
+ renderer.info(config.state_dir === null ? `state: ${run_state_dir(config)}` : `state root: ${config.state_dir}`);
4886
5350
  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}`);
5351
+ if (config.mode === "critique") renderer.info("builder: none (a critique checks and critiques the change once)");
5352
+ else {
5353
+ renderer.info(seat_line("builder", config.builder_model, config.builder_provider, config.builder_url, env));
5354
+ renderer.info(`builder max steps: ${config.builder_max_steps}`);
5355
+ renderer.info(`builder tool calls per turn: ${config.builder_max_tool_calls}`);
5356
+ }
4889
5357
  if (tool_loop_builder) {
4890
- const contained = env["VOLLEY_CONTAINED"] === "1" || env["VOLLEY_CONTAINED"] === "true";
5358
+ const contained = detect_containment(env);
4891
5359
  renderer.info(contained ? "containment: inside the sandbox container (VOLLEY_CONTAINED)" : "containment: uncontained on the host (--allow-unsandboxed-builder)");
4892
5360
  renderer.info(`sandbox image: ${config.sandbox_image}`);
4893
5361
  }
@@ -4942,6 +5410,8 @@ async function preflight(config, renderer, env, probes = {}) {
4942
5410
  * CLI entry point: cac argv parsing, dispatch, exit-code mapping.
4943
5411
  * Human progress goes to stderr; stdout carries machine output only.
4944
5412
  */
5413
+ /** One help string for every command that takes a state root. */
5414
+ const STATE_DIR_HELP = "Keep each run's state under <path>/<run id>/ (default: $XDG_STATE_HOME/volley, else ~/.local/state/volley)";
4945
5415
  /**
4946
5416
  * volley's own version for `--version`, read from the manifest rather than
4947
5417
  * repeated here — a literal drifts silently, since `pnpm version` bumps only
@@ -4976,19 +5446,18 @@ function render_mode(config) {
4976
5446
  if (config.verbose) return "verbose";
4977
5447
  return "default";
4978
5448
  }
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. */
5449
+ /** `run_id` names the run whose trajectory the verbose hint points at; a
5450
+ * sweep passes a placeholder, since each seat has an id of its own. */
4982
5451
  function make_renderer(config, run_id = config.run_id) {
4983
5452
  return create_renderer({
4984
5453
  mode: render_mode(config),
4985
5454
  show_thinking: config.show_thinking,
4986
5455
  color: colors_enabled(),
4987
5456
  max_cost_usd: config.max_cost_usd,
4988
- ...config.state_dir === null ? {} : { trajectory_path: state_path({
5457
+ trajectory_path: state_path({
4989
5458
  ...config,
4990
5459
  run_id
4991
- }, "trajectory.jsonl") }
5460
+ }, "trajectory.jsonl")
4992
5461
  });
4993
5462
  }
4994
5463
  function warn_api_key_meter(renderer) {
@@ -5001,6 +5470,13 @@ function warn_api_key_meter(renderer) {
5001
5470
  function warn_unsandboxed_builder(config, renderer) {
5002
5471
  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
5472
  }
5473
+ /** The critic seat's options: its model, provider, and URL. `required` marks
5474
+ * the model and provider as having no default, as under `acp`. */
5475
+ function with_critic_options(command, required = false) {
5476
+ const model_note = required ? " (required)" : "";
5477
+ const provider_note = required ? "required" : "default: claude_cli";
5478
+ 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)");
5479
+ }
5004
5480
  /** The seat options, declared once for every command that runs seats: each
5005
5481
  * seat's model, provider, and URL, the tool-loop builder's step cap, and the
5006
5482
  * unsandboxed opt-out. `required` marks each seat's model and provider as
@@ -5008,8 +5484,28 @@ function warn_unsandboxed_builder(config, renderer) {
5008
5484
  function with_seat_options(command, required = false) {
5009
5485
  const model_note = required ? " (required)" : "";
5010
5486
  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
- }
5487
+ 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, any provider but claude_cli (default: 1)").option("--allow-unsandboxed-builder", "Permit a builder other than claude_cli to run without a sandbox (real host bash + write)"), required);
5488
+ }
5489
+ /** What a critique declares only so that it can refuse each by name, with
5490
+ * exit 5: cac would answer an undeclared one with a bare "Unknown option".
5491
+ * `resolve_config` refuses all but `--config`, which the command does. */
5492
+ const CRITIQUE_REFUSED_OPTIONS = [
5493
+ "--builder-model <model>",
5494
+ "--builder-provider <name>",
5495
+ "--builder-url <url>",
5496
+ "--builder-max-steps <n>",
5497
+ "--builder-max-tool-calls <n>",
5498
+ "--builder-permission-mode <mode>",
5499
+ "--allow-unsandboxed-builder",
5500
+ "--sandbox-image <tag>",
5501
+ "--max-iterations <n>",
5502
+ "--max-cost-usd <usd>",
5503
+ "--git",
5504
+ "--worktree",
5505
+ "--discard-worktree",
5506
+ "--fail-on-gate-edit",
5507
+ "--config <path>"
5508
+ ];
5013
5509
  function merge_flags(base, flags) {
5014
5510
  return {
5015
5511
  ...base,
@@ -5021,6 +5517,7 @@ function merge_flags(base, flags) {
5021
5517
  ...flags.builderProvider !== void 0 ? { builder_provider: flags.builderProvider } : {},
5022
5518
  ...flags.builderUrl !== void 0 ? { builder_url: flags.builderUrl } : {},
5023
5519
  ...flags.builderMaxSteps !== void 0 ? { builder_max_steps: Number(flags.builderMaxSteps) } : {},
5520
+ ...flags.builderMaxToolCalls !== void 0 ? { builder_max_tool_calls: Number(flags.builderMaxToolCalls) } : {},
5024
5521
  ...flags.allowUnsandboxedBuilder === true ? { allow_unsandboxed_builder: true } : {},
5025
5522
  ...flags.criticModel !== void 0 ? { critic_model: flags.criticModel } : {},
5026
5523
  ...flags.criticProvider !== void 0 ? { critic_provider: flags.criticProvider } : {},
@@ -5043,6 +5540,20 @@ function merge_flags(base, flags) {
5043
5540
  ...flags.thinking === false ? { show_thinking: false } : {}
5044
5541
  };
5045
5542
  }
5543
+ /** What a resume is about to run on a config this machine didn't sign, said
5544
+ * once before it runs, so `--trust-config` is never a blind yes. Every value
5545
+ * came from that config, so each is quoted with its control characters
5546
+ * escaped, and can't redraw the line it's warned on. */
5547
+ function warn_trusted_config(config, renderer) {
5548
+ 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)}`);
5549
+ }
5550
+ /** A flag's value as typed. cac reads an all-digit value as a number, which
5551
+ * drops leading zeros, so a coerced id or revision would silently name another
5552
+ * run or commit: refused rather than guessed at. */
5553
+ function not_a_bare_number(name, value) {
5554
+ if (typeof value === "number") throw config_error(`${name} must not be a bare number (read as ${String(value)})`);
5555
+ return value;
5556
+ }
5046
5557
  function emit_result(config, result, renderer) {
5047
5558
  renderer.final_summary(result);
5048
5559
  if (config.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -5060,10 +5571,10 @@ function report_error(err) {
5060
5571
  async function main(argv) {
5061
5572
  const cli = cac("volley");
5062
5573
  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) => {
5574
+ 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
5575
  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 } : {});
5576
+ const run_id = not_a_bare_number("--run-id", flags.runId);
5577
+ const config = resolve_config(merged, run_id !== void 0 ? { run_id } : {});
5067
5578
  const renderer = make_renderer(config);
5068
5579
  warn_api_key_meter(renderer);
5069
5580
  warn_unsandboxed_builder(config, renderer);
@@ -5073,24 +5584,48 @@ async function main(argv) {
5073
5584
  }
5074
5585
  exit_code = emit_result(config, await run_volley(config, { renderer }), renderer);
5075
5586
  });
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
- }), {
5587
+ 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) => {
5588
+ const { workspace = ".", stateDir, trustConfig, ...output } = flags;
5589
+ const state_dir = find_run_state(workspace, run_id, stateDir === void 0 ? default_state_root(process.env) : resolve(stateDir));
5590
+ const resume = load_resume_state(workspace, run_id, state_dir, { trust_config: trustConfig === true });
5591
+ const config = resolve_config(merge_flags(resume.raw_config, output), {
5084
5592
  run_id: resume.run_id,
5085
- started_at: resume.started_at
5593
+ started_at: resume.started_at,
5594
+ state_dir
5086
5595
  });
5087
5596
  if (resume.iterations_completed >= config.max_iterations) throw config_error(`run already completed ${String(resume.iterations_completed)} of ${String(config.max_iterations)} iterations`);
5088
5597
  const renderer = make_renderer(config);
5089
5598
  warn_api_key_meter(renderer);
5090
5599
  warn_unsandboxed_builder(config, renderer);
5600
+ if (resume.config_signed === false) warn_trusted_config(config, renderer);
5091
5601
  renderer.info(`resuming run ${run_id} at iteration ${String(resume.iterations_completed + 1)}`);
5092
5602
  exit_code = emit_result(config, await run_volley(config, { renderer }, resume.state), renderer);
5093
5603
  });
5604
+ 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})`);
5605
+ 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)");
5606
+ for (const refused of CRITIQUE_REFUSED_OPTIONS) critique_command.option(refused, "Refused: a critique runs no builder and no loop");
5607
+ critique_command.action(async (flags) => {
5608
+ 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");
5609
+ const run_id = not_a_bare_number("--run-id", flags.runId);
5610
+ const baseline = not_a_bare_number("--baseline", flags.baseline);
5611
+ const merged = merge_flags({}, flags);
5612
+ const config = resolve_config(merged, {
5613
+ mode: "critique",
5614
+ ...run_id !== void 0 ? { run_id } : {}
5615
+ });
5616
+ const renderer = make_renderer(config);
5617
+ warn_api_key_meter(renderer);
5618
+ if (merged.dry_run === true) {
5619
+ const sha = resolve_baseline(config.workspace, baseline);
5620
+ renderer.info(`baseline: ${sha ?? "none (not a git repository, or no commit yet)"}`);
5621
+ exit_code = await preflight(config, renderer, process.env);
5622
+ return;
5623
+ }
5624
+ exit_code = emit_result(config, await run_critique(config, {
5625
+ renderer,
5626
+ ...baseline !== void 0 ? { baseline } : {}
5627
+ }), renderer);
5628
+ });
5094
5629
  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
5630
  if (flags.config === void 0) throw config_error("volley matrix requires --config <path> (the base task to sweep)");
5096
5631
  const builders = parse_model_list(flags.builders, "--builders");
@@ -5121,7 +5656,7 @@ async function main(argv) {
5121
5656
  if (base_config.json) process.stdout.write(`${JSON.stringify({ combos: outcome.rows }, null, 2)}\n`);
5122
5657
  exit_code = outcome.ok ? 0 : 1;
5123
5658
  });
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) => {
5659
+ 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
5660
  const base = acp_base_config(merge_flags({}, flags));
5126
5661
  await serve_acp(acp_serve_config(base, { version: package_version() }));
5127
5662
  });