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