@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/CHANGELOG.md +22 -0
- package/README.md +145 -37
- package/dist/cli.js +720 -185
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +109 -12
- package/dist/index.js +692 -272
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/builder/presets/harness_append_local.md +2 -1
- package/src/critic/presets/harness_append_local.md +2 -1
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
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
1016
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
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.
|
|
1146
|
-
*
|
|
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
|
-
|
|
1489
|
-
|
|
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")
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2904
|
-
|
|
2905
|
-
|
|
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)
|
|
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
|
-
/**
|
|
3492
|
-
*
|
|
3493
|
-
*
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
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
|
|
3552
|
-
*
|
|
3553
|
-
|
|
3554
|
-
|
|
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 === "" ?
|
|
3975
|
+
return path === "" ? default_state_root(env) : resolve(cwd, path);
|
|
3558
3976
|
}
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
3610
|
-
const builder_max_steps =
|
|
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,
|
|
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 =
|
|
3804
|
-
const critique =
|
|
3805
|
-
|
|
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
|
-
*
|
|
3983
|
-
*
|
|
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
|
-
|
|
3986
|
-
|
|
3987
|
-
const
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
}
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
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
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
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 ?? "
|
|
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
|
|
4170
|
-
* three rounds
|
|
4171
|
-
*
|
|
4172
|
-
*
|
|
4173
|
-
*
|
|
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":
|
|
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
|
|
4541
|
-
*
|
|
4542
|
-
* `run-NN/` so a repeated seat keeps all of its evidence, not just the
|
|
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
|
-
|
|
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(
|
|
4888
|
-
|
|
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
|
|
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
|
|
4980
|
-
*
|
|
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
|
-
|
|
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("--
|
|
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>",
|
|
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
|
-
|
|
5066
|
-
const config = resolve_config(merged,
|
|
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>", "
|
|
5077
|
-
const { workspace = ".", stateDir, ...output } = flags;
|
|
5078
|
-
const state_dir = stateDir === void 0 ?
|
|
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>",
|
|
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
|
});
|