@robmclarty/volley 0.9.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 +22 -0
- package/README.md +57 -16
- package/dist/cli.js +562 -270
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.js +441 -242
- 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 +3 -2
package/dist/index.js
CHANGED
|
@@ -722,6 +722,7 @@ function write_resolved_config(config, env = process.env) {
|
|
|
722
722
|
builder_url: config.builder_url,
|
|
723
723
|
builder_max_steps: config.builder_max_steps,
|
|
724
724
|
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
725
|
+
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
725
726
|
critic_model: config.critic_model,
|
|
726
727
|
critic_provider: config.critic_provider,
|
|
727
728
|
critic_url: config.critic_url,
|
|
@@ -749,6 +750,29 @@ function write_resolved_config(config, env = process.env) {
|
|
|
749
750
|
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
750
751
|
return true;
|
|
751
752
|
}
|
|
753
|
+
/** The identity volley's own commits fall back to. git won't commit without a
|
|
754
|
+
* name and an email, and the sandbox image carries no git config, so inside
|
|
755
|
+
* it a checkpoint or a worktree's salvage commit failed outright; a machine
|
|
756
|
+
* never set up for git can be the same. `.invalid` is reserved, so the email
|
|
757
|
+
* can't reach anyone. */
|
|
758
|
+
const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
|
|
759
|
+
/** The `-c` flags that give a commit in `cwd` the fallback identity for each
|
|
760
|
+
* part git has no value for. One the repo or the user configured wins, and so
|
|
761
|
+
* does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
|
|
762
|
+
* any config. Put them ahead of the subcommand. */
|
|
763
|
+
function commit_identity(cwd) {
|
|
764
|
+
return FALLBACK_IDENTITY.flatMap(([key, value]) => {
|
|
765
|
+
const set = spawnSync("git", [
|
|
766
|
+
"config",
|
|
767
|
+
"--get",
|
|
768
|
+
key
|
|
769
|
+
], {
|
|
770
|
+
cwd,
|
|
771
|
+
encoding: "utf8"
|
|
772
|
+
});
|
|
773
|
+
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
774
|
+
});
|
|
775
|
+
}
|
|
752
776
|
/**
|
|
753
777
|
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
754
778
|
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
@@ -774,6 +798,7 @@ function git_checkpoint(root, message, excluded_dir = null) {
|
|
|
774
798
|
], { cwd: root });
|
|
775
799
|
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
776
800
|
const commit = spawnSync("git", [
|
|
801
|
+
...commit_identity(root),
|
|
777
802
|
"commit",
|
|
778
803
|
"-m",
|
|
779
804
|
message,
|
|
@@ -807,6 +832,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
807
832
|
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
808
833
|
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
809
834
|
const commit = spawnSync("git", [
|
|
835
|
+
...commit_identity(workspace),
|
|
810
836
|
"commit",
|
|
811
837
|
"-m",
|
|
812
838
|
message,
|
|
@@ -820,6 +846,7 @@ function integrate_worktree(workspace, branch, message) {
|
|
|
820
846
|
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
821
847
|
}
|
|
822
848
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
849
|
+
const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
|
|
823
850
|
const DEFAULT_CRITIC = "reviewer";
|
|
824
851
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
825
852
|
const DEFAULT_CRITIC_PROVIDER = "claude_cli";
|
|
@@ -910,6 +937,7 @@ const CRITIQUE_REFUSED = [
|
|
|
910
937
|
["builder_url", "--builder-url"],
|
|
911
938
|
["builder_max_steps", "--builder-max-steps"],
|
|
912
939
|
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
940
|
+
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
913
941
|
["builder_permission_mode", "--builder-permission-mode"],
|
|
914
942
|
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
915
943
|
["sandbox_image", "--sandbox-image"],
|
|
@@ -933,6 +961,22 @@ const BUILDER_MAX_TOOL_CALLS = {
|
|
|
933
961
|
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
934
962
|
fallback: 1
|
|
935
963
|
};
|
|
964
|
+
/** The builders whose turns are capped when nothing sets a cap: the local
|
|
965
|
+
* ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
|
|
966
|
+
* `num_predict` run to the model's trained context less the prompt, which is
|
|
967
|
+
* how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
|
|
968
|
+
* OpenRouter has its own output limit, and a request for more than it allows
|
|
969
|
+
* can be refused, so it's capped only when asked. */
|
|
970
|
+
const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
|
|
971
|
+
/** The output cap's flag and env var, and its default for `provider`: 16k
|
|
972
|
+
* tokens on a local builder, else the provider's own limit (null). */
|
|
973
|
+
function builder_max_output_tokens_count(provider) {
|
|
974
|
+
return {
|
|
975
|
+
flag: "--builder-max-output-tokens",
|
|
976
|
+
env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
|
|
977
|
+
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
978
|
+
};
|
|
979
|
+
}
|
|
936
980
|
/** A builder count's flag or config value wins over its env var, which wins
|
|
937
981
|
* over the default, and either must be a positive integer. Local builder
|
|
938
982
|
* only; ignored by the `claude_cli` arm. */
|
|
@@ -1066,6 +1110,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1066
1110
|
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, builder_env)) throw config_error(`builder-provider '${builder_provider}' runs volley's tool loop with a real bash (write + exec) — refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run …, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`);
|
|
1067
1111
|
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1068
1112
|
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
1113
|
+
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
1069
1114
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
1070
1115
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
1071
1116
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
@@ -1103,6 +1148,7 @@ function resolve_config(raw, options = {}) {
|
|
|
1103
1148
|
builder_url,
|
|
1104
1149
|
builder_max_steps,
|
|
1105
1150
|
builder_max_tool_calls,
|
|
1151
|
+
builder_max_output_tokens,
|
|
1106
1152
|
allow_unsandboxed_builder,
|
|
1107
1153
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
1108
1154
|
critic_provider,
|
|
@@ -1182,6 +1228,34 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
1182
1228
|
if (warning !== null) warn(warning);
|
|
1183
1229
|
}
|
|
1184
1230
|
//#endregion
|
|
1231
|
+
//#region src/builder/countdown.ts
|
|
1232
|
+
/** How many of a session's last steps carry the countdown: five, or a tenth
|
|
1233
|
+
* of a long budget, but never more than half of it, so a short session still
|
|
1234
|
+
* starts without one. */
|
|
1235
|
+
function countdown_window(max_steps) {
|
|
1236
|
+
return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
|
|
1237
|
+
}
|
|
1238
|
+
/** The note for a turn with `steps_left` steps left, this one included.
|
|
1239
|
+
* `check` names how to run the check. On the last step fascicle runs only a
|
|
1240
|
+
* call that ends the session, which for the builder is `finish`. */
|
|
1241
|
+
function countdown_note(steps_left, check) {
|
|
1242
|
+
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.";
|
|
1243
|
+
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.`;
|
|
1244
|
+
}
|
|
1245
|
+
/** The `prepare_step` hook that adds the countdown to a session of
|
|
1246
|
+
* `max_steps` steps. */
|
|
1247
|
+
function step_countdown(max_steps, check) {
|
|
1248
|
+
const window = countdown_window(max_steps);
|
|
1249
|
+
return ({ step_index, messages }) => {
|
|
1250
|
+
const steps_left = max_steps - step_index;
|
|
1251
|
+
if (steps_left > window) return void 0;
|
|
1252
|
+
return { messages: [...messages, {
|
|
1253
|
+
role: "user",
|
|
1254
|
+
content: countdown_note(steps_left, check)
|
|
1255
|
+
}] };
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
1185
1259
|
//#region src/workspace_tools.ts
|
|
1186
1260
|
/**
|
|
1187
1261
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -1292,6 +1366,7 @@ function read_only_tools(workspace) {
|
|
|
1292
1366
|
return [
|
|
1293
1367
|
{
|
|
1294
1368
|
name: "read_file",
|
|
1369
|
+
read_only: true,
|
|
1295
1370
|
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
1296
1371
|
input_schema: read_file_input,
|
|
1297
1372
|
execute: (raw) => {
|
|
@@ -1302,6 +1377,7 @@ function read_only_tools(workspace) {
|
|
|
1302
1377
|
},
|
|
1303
1378
|
{
|
|
1304
1379
|
name: "search_files",
|
|
1380
|
+
read_only: true,
|
|
1305
1381
|
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
|
|
1306
1382
|
input_schema: search_files_input,
|
|
1307
1383
|
execute: (raw) => {
|
|
@@ -1331,6 +1407,7 @@ function read_only_tools(workspace) {
|
|
|
1331
1407
|
},
|
|
1332
1408
|
{
|
|
1333
1409
|
name: "list_files",
|
|
1410
|
+
read_only: true,
|
|
1334
1411
|
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
1335
1412
|
input_schema: list_files_input,
|
|
1336
1413
|
execute: (raw) => {
|
|
@@ -1751,6 +1828,248 @@ function finish_tool() {
|
|
|
1751
1828
|
};
|
|
1752
1829
|
}
|
|
1753
1830
|
//#endregion
|
|
1831
|
+
//#region src/check/checkride.ts
|
|
1832
|
+
/**
|
|
1833
|
+
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
1834
|
+
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
1835
|
+
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
1836
|
+
* actually said, not a normalized digest. The digest checkride writes on a red
|
|
1837
|
+
* run is kept beside them for the next builder, which needs a short list of
|
|
1838
|
+
* what to fix more than the whole of every slot's output.
|
|
1839
|
+
*/
|
|
1840
|
+
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
1841
|
+
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
1842
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
1843
|
+
* something that escaped its process group still holds it, before the reads
|
|
1844
|
+
* stop so the check can finish. */
|
|
1845
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
1846
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
1847
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
1848
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
1849
|
+
* groups to signal, so there only the shell goes. */
|
|
1850
|
+
function kill_group(proc) {
|
|
1851
|
+
if (proc.pid === void 0) return;
|
|
1852
|
+
try {
|
|
1853
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
1854
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
1855
|
+
} catch {}
|
|
1856
|
+
}
|
|
1857
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
1858
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
1859
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
1860
|
+
* no test workers or servers running. */
|
|
1861
|
+
function spawn_capture(cmd, args, options) {
|
|
1862
|
+
const { abort } = options;
|
|
1863
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
1864
|
+
return new Promise((resolve_promise, reject) => {
|
|
1865
|
+
const proc = spawn(cmd, args, {
|
|
1866
|
+
cwd: options.cwd,
|
|
1867
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
1868
|
+
detached: process.platform !== "win32",
|
|
1869
|
+
stdio: [
|
|
1870
|
+
"ignore",
|
|
1871
|
+
"pipe",
|
|
1872
|
+
"pipe"
|
|
1873
|
+
]
|
|
1874
|
+
});
|
|
1875
|
+
let stdout = "";
|
|
1876
|
+
let stderr = "";
|
|
1877
|
+
let grace;
|
|
1878
|
+
const on_abort = () => {
|
|
1879
|
+
kill_group(proc);
|
|
1880
|
+
reject(new aborted_error("check aborted"));
|
|
1881
|
+
};
|
|
1882
|
+
const finish = () => {
|
|
1883
|
+
clearTimeout(grace);
|
|
1884
|
+
abort?.removeEventListener("abort", on_abort);
|
|
1885
|
+
};
|
|
1886
|
+
proc.stdout.on("data", (chunk) => {
|
|
1887
|
+
stdout += chunk.toString();
|
|
1888
|
+
});
|
|
1889
|
+
proc.stderr.on("data", (chunk) => {
|
|
1890
|
+
stderr += chunk.toString();
|
|
1891
|
+
});
|
|
1892
|
+
proc.on("error", (err) => {
|
|
1893
|
+
finish();
|
|
1894
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
1895
|
+
});
|
|
1896
|
+
proc.on("exit", () => {
|
|
1897
|
+
kill_group(proc);
|
|
1898
|
+
grace = setTimeout(() => {
|
|
1899
|
+
proc.stdout.destroy();
|
|
1900
|
+
proc.stderr.destroy();
|
|
1901
|
+
}, OUTPUT_GRACE_MS);
|
|
1902
|
+
});
|
|
1903
|
+
proc.on("close", (code) => {
|
|
1904
|
+
finish();
|
|
1905
|
+
resolve_promise({
|
|
1906
|
+
exit_code: code,
|
|
1907
|
+
stdout,
|
|
1908
|
+
stderr
|
|
1909
|
+
});
|
|
1910
|
+
});
|
|
1911
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
1915
|
+
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
1916
|
+
* install line ahead of the JSON), so the object is extracted between the
|
|
1917
|
+
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
1918
|
+
* schema_version warns and still parses; no JSON object at all is a harness
|
|
1919
|
+
* error (exit 4). */
|
|
1920
|
+
function parse_checkride_summary(stdout) {
|
|
1921
|
+
const start = stdout.indexOf("{");
|
|
1922
|
+
const end = stdout.lastIndexOf("}");
|
|
1923
|
+
let raw;
|
|
1924
|
+
try {
|
|
1925
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
1926
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
1927
|
+
} catch {
|
|
1928
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
1929
|
+
}
|
|
1930
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
1931
|
+
const summary = raw;
|
|
1932
|
+
return {
|
|
1933
|
+
summary,
|
|
1934
|
+
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
1938
|
+
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
1939
|
+
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
1940
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
1941
|
+
try {
|
|
1942
|
+
return parse_checkride_summary(stdout);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
const file = join(workspace, ".check", "summary.json");
|
|
1945
|
+
if (!existsSync(file)) throw err;
|
|
1946
|
+
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
function failing_checks(summary) {
|
|
1950
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
1951
|
+
}
|
|
1952
|
+
/** Whether `path` sits under `dir`. */
|
|
1953
|
+
function within(dir, path) {
|
|
1954
|
+
const from_dir = relative(dir, path);
|
|
1955
|
+
return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
|
|
1956
|
+
}
|
|
1957
|
+
/** Per failing slot: the raw artifact (`<output_file>` when present, else
|
|
1958
|
+
* `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
|
|
1959
|
+
* iteration's copy of it, truncated with a pointer to the file. The names come
|
|
1960
|
+
* from a summary the builder could have written, so a path that leaves
|
|
1961
|
+
* `check_dir` has no artifact, rather than putting a file from elsewhere in
|
|
1962
|
+
* front of a model. */
|
|
1963
|
+
function read_failing_artifacts(check_dir, failing) {
|
|
1964
|
+
return failing.map((check) => {
|
|
1965
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
1966
|
+
const path = join(check_dir, file);
|
|
1967
|
+
if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
|
|
1968
|
+
slot: check.name,
|
|
1969
|
+
path: null,
|
|
1970
|
+
content: "(no artifact found)",
|
|
1971
|
+
truncated: false
|
|
1972
|
+
};
|
|
1973
|
+
const raw = readFileSync(path, "utf8");
|
|
1974
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
1975
|
+
return {
|
|
1976
|
+
slot: check.name,
|
|
1977
|
+
path,
|
|
1978
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
1979
|
+
truncated
|
|
1980
|
+
};
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
/** checkride's digest of a red run (`.check/digest.md`, written under
|
|
1984
|
+
* `--digest` and removed on a green one), or undefined when there is none. It
|
|
1985
|
+
* is capped by checkride itself, at 8 kB. */
|
|
1986
|
+
function read_digest(workspace) {
|
|
1987
|
+
const path = join(workspace, ".check", "digest.md");
|
|
1988
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
1989
|
+
}
|
|
1990
|
+
async function run_checkride(opts) {
|
|
1991
|
+
const { workspace, ...spawn_options } = opts;
|
|
1992
|
+
const proc = await spawn_capture("pnpm", [
|
|
1993
|
+
"exec",
|
|
1994
|
+
"checkride",
|
|
1995
|
+
"--json",
|
|
1996
|
+
"--digest"
|
|
1997
|
+
], {
|
|
1998
|
+
...spawn_options,
|
|
1999
|
+
cwd: workspace
|
|
2000
|
+
});
|
|
2001
|
+
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2002
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2003
|
+
const failing = failing_checks(summary);
|
|
2004
|
+
const digest = summary.ok ? void 0 : read_digest(opts.workspace);
|
|
2005
|
+
return {
|
|
2006
|
+
ran: true,
|
|
2007
|
+
runner: "checkride",
|
|
2008
|
+
ok: summary.ok,
|
|
2009
|
+
exit_code: proc.exit_code,
|
|
2010
|
+
duration_ms: summary.total_duration_ms,
|
|
2011
|
+
failing_slots: failing.map((c) => c.name),
|
|
2012
|
+
detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
|
|
2013
|
+
summary,
|
|
2014
|
+
...digest !== void 0 ? { digest } : {}
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/check/command.ts
|
|
2019
|
+
/**
|
|
2020
|
+
* Generic command runner: `sh -c <command>`, combined
|
|
2021
|
+
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2022
|
+
* (exit 4), not an iterable failure.
|
|
2023
|
+
*/
|
|
2024
|
+
async function run_command_check(opts) {
|
|
2025
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
2026
|
+
const started = Date.now();
|
|
2027
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2028
|
+
...spawn_options,
|
|
2029
|
+
cwd: workspace
|
|
2030
|
+
});
|
|
2031
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2032
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
2033
|
+
return {
|
|
2034
|
+
ran: true,
|
|
2035
|
+
runner: "command",
|
|
2036
|
+
ok: proc.exit_code === 0,
|
|
2037
|
+
exit_code: proc.exit_code,
|
|
2038
|
+
duration_ms: Date.now() - started,
|
|
2039
|
+
failing_slots: [],
|
|
2040
|
+
detail: [],
|
|
2041
|
+
log
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
function skipped_check(reason) {
|
|
2045
|
+
return {
|
|
2046
|
+
ran: false,
|
|
2047
|
+
runner: "none",
|
|
2048
|
+
ok: reason === "none",
|
|
2049
|
+
exit_code: null,
|
|
2050
|
+
duration_ms: 0,
|
|
2051
|
+
failing_slots: [],
|
|
2052
|
+
detail: []
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
/** What a rejected check says in place of its output. */
|
|
2056
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
2057
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
2058
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
2059
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
2060
|
+
function rejected_check(runner) {
|
|
2061
|
+
return {
|
|
2062
|
+
ran: true,
|
|
2063
|
+
runner,
|
|
2064
|
+
ok: false,
|
|
2065
|
+
exit_code: null,
|
|
2066
|
+
duration_ms: 0,
|
|
2067
|
+
failing_slots: [],
|
|
2068
|
+
detail: [],
|
|
2069
|
+
log: CHECK_REJECTED
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
1754
2073
|
//#region src/cost.ts
|
|
1755
2074
|
/**
|
|
1756
2075
|
* Cost and usage accumulation. All pricing knowledge lives in
|
|
@@ -1790,6 +2109,7 @@ function cost_source_of(result) {
|
|
|
1790
2109
|
* its whole degradation ladder. */
|
|
1791
2110
|
function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
|
|
1792
2111
|
const rate = throughput(result);
|
|
2112
|
+
const truncated = result.steps.filter((step) => step.truncated === true).length;
|
|
1793
2113
|
return {
|
|
1794
2114
|
provider: result.model_resolved.provider,
|
|
1795
2115
|
model,
|
|
@@ -1801,6 +2121,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
1801
2121
|
finish_reason: result.finish_reason,
|
|
1802
2122
|
tool_calls: result.tool_calls.length,
|
|
1803
2123
|
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
2124
|
+
...truncated > 0 ? { truncated_turns: truncated } : {},
|
|
1804
2125
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
1805
2126
|
};
|
|
1806
2127
|
}
|
|
@@ -2120,6 +2441,7 @@ function salvage_worktree(repo, path, branch) {
|
|
|
2120
2441
|
if (existsSync(path)) {
|
|
2121
2442
|
git_quiet(path, ["add", "-A"]);
|
|
2122
2443
|
git_quiet(path, [
|
|
2444
|
+
...commit_identity(path),
|
|
2123
2445
|
"commit",
|
|
2124
2446
|
"-m",
|
|
2125
2447
|
`volley: salvage unintegrated worktree (${branch})`,
|
|
@@ -2248,14 +2570,9 @@ const HARNESS_APPENDS = {
|
|
|
2248
2570
|
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
2249
2571
|
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
2250
2572
|
* turn's calls in order, so a read can't race an edit, but each change or
|
|
2251
|
-
* command is still worth reading before the next.
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
`2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
|
|
2255
|
-
" make one change or command per turn, and read its result before",
|
|
2256
|
-
" deciding the next call."
|
|
2257
|
-
].join("\n");
|
|
2258
|
-
}
|
|
2573
|
+
* command is still worth reading before the next. Reads don't count toward
|
|
2574
|
+
* the cap, so the step names no number. */
|
|
2575
|
+
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");
|
|
2259
2576
|
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
2260
2577
|
const identity = [
|
|
2261
2578
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
@@ -2263,7 +2580,7 @@ function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
|
2263
2580
|
"criteria."
|
|
2264
2581
|
].join("\n");
|
|
2265
2582
|
const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
|
|
2266
|
-
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN,
|
|
2583
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
|
|
2267
2584
|
}
|
|
2268
2585
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
2269
2586
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -2274,15 +2591,25 @@ function resolve_builder_system(config, tool_set) {
|
|
|
2274
2591
|
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
2275
2592
|
}
|
|
2276
2593
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
2594
|
+
/** How the step countdown tells the builder to run the check: the command
|
|
2595
|
+
* volley gates on, with the digest and without `--json`, the configured
|
|
2596
|
+
* command, or the project's own tests when no check gates the run. A
|
|
2597
|
+
* workspace can hold checkride with no `check` script, so not `pnpm check`. */
|
|
2598
|
+
function check_hint(config) {
|
|
2599
|
+
if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
|
|
2600
|
+
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2601
|
+
return "the project's tests";
|
|
2602
|
+
}
|
|
2277
2603
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2278
2604
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2279
2605
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
2280
2606
|
* from v2, and takes no injected tools or approval handler. Every other
|
|
2281
2607
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2282
2608
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2283
|
-
* the handler that answers its approvals)
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
2609
|
+
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2610
|
+
* countdown (`./builder/countdown.ts`), the output cap when one is set, and
|
|
2611
|
+
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2612
|
+
* over engine defaults. */
|
|
2286
2613
|
function builder_tool_options(deps) {
|
|
2287
2614
|
const { config } = deps;
|
|
2288
2615
|
const bash_executor = deps.bash_executor ?? null;
|
|
@@ -2296,9 +2623,75 @@ function builder_tool_options(deps) {
|
|
|
2296
2623
|
max_steps: config.builder_max_steps,
|
|
2297
2624
|
tool_error_policy: "feed_back",
|
|
2298
2625
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2299
|
-
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
2626
|
+
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2627
|
+
prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
|
|
2628
|
+
...config.builder_max_output_tokens !== null ? {
|
|
2629
|
+
max_tokens: config.builder_max_output_tokens,
|
|
2630
|
+
truncated_turn_policy: "continue"
|
|
2631
|
+
} : {}
|
|
2300
2632
|
};
|
|
2301
2633
|
}
|
|
2634
|
+
/** How much of the last check's output the builder's prompt carries: a few KB,
|
|
2635
|
+
* whatever the runner. checkride's digest already fits in 8 kB, and the builder
|
|
2636
|
+
* can run the check again for the rest. */
|
|
2637
|
+
const PREVIOUS_CHECK_MAX_CHARS = 6e3;
|
|
2638
|
+
/** The first `max` characters of `text`, saying where it was cut. */
|
|
2639
|
+
function head(text, max, rest) {
|
|
2640
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
|
|
2641
|
+
}
|
|
2642
|
+
/** The last `max` characters of `text`, saying what came before was cut. */
|
|
2643
|
+
function tail(text, max) {
|
|
2644
|
+
return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
|
|
2645
|
+
}
|
|
2646
|
+
/** The part of a failed check the builder reads first: checkride's digest,
|
|
2647
|
+
* else the start of each failing slot's raw output, else the end of a
|
|
2648
|
+
* command's output, where test runners and compilers sum up. */
|
|
2649
|
+
function check_excerpt(check) {
|
|
2650
|
+
const max = PREVIOUS_CHECK_MAX_CHARS;
|
|
2651
|
+
if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
|
|
2652
|
+
if (check.detail.length > 0) return head(check.detail.map((artifact) => {
|
|
2653
|
+
const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
|
|
2654
|
+
return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
|
|
2655
|
+
}).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
|
|
2656
|
+
return tail((check.log ?? "").trimEnd(), max);
|
|
2657
|
+
}
|
|
2658
|
+
/** The check section of a later iteration's prompt: what the deterministic
|
|
2659
|
+
* gate itself said about the last session's work, so the builder starts from
|
|
2660
|
+
* its words rather than the critic's account of them, or re-running it to
|
|
2661
|
+
* find out. Empty unless that check ran and failed. A check the user rejected
|
|
2662
|
+
* when it asked to run is recorded as failed but said nothing, and the
|
|
2663
|
+
* section says so rather than call the work failed. */
|
|
2664
|
+
function previous_check_section(check) {
|
|
2665
|
+
if (check === null || !check.ran || check.ok) return [];
|
|
2666
|
+
const heading = [
|
|
2667
|
+
"",
|
|
2668
|
+
"PREVIOUS CHECK RESULT",
|
|
2669
|
+
"---------------------"
|
|
2670
|
+
];
|
|
2671
|
+
if (check.log === "the check was rejected when it asked to run, so it never ran") return [
|
|
2672
|
+
...heading,
|
|
2673
|
+
"The check never ran on the work your last session left: it was rejected when it",
|
|
2674
|
+
"asked to run, so nothing has verified that work yet."
|
|
2675
|
+
];
|
|
2676
|
+
const runner = check.runner === "checkride" ? "checkride, " : "";
|
|
2677
|
+
const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
|
|
2678
|
+
const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
|
|
2679
|
+
if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
|
|
2680
|
+
const excerpt = check_excerpt(check);
|
|
2681
|
+
if (excerpt.length > 0) lines.push("", excerpt);
|
|
2682
|
+
return lines;
|
|
2683
|
+
}
|
|
2684
|
+
/** The line a later iteration's prompt carries when the last session ran out
|
|
2685
|
+
* of steps: it never got to verify, so this one should check first. */
|
|
2686
|
+
function step_limit_lines(step_limit_hit) {
|
|
2687
|
+
if (step_limit_hit === null) return [];
|
|
2688
|
+
return [
|
|
2689
|
+
"",
|
|
2690
|
+
`Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
|
|
2691
|
+
"it may never have verified its work. Run the check first this time, and keep",
|
|
2692
|
+
"enough steps in hand to verify again before you finish."
|
|
2693
|
+
];
|
|
2694
|
+
}
|
|
2302
2695
|
function compose_builder_prompt(input) {
|
|
2303
2696
|
const parts = [
|
|
2304
2697
|
"TASK",
|
|
@@ -2312,7 +2705,8 @@ function compose_builder_prompt(input) {
|
|
|
2312
2705
|
`ITERATION: ${input.iteration}`
|
|
2313
2706
|
];
|
|
2314
2707
|
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.");
|
|
2315
|
-
if (input.
|
|
2708
|
+
if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
|
|
2709
|
+
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.");
|
|
2316
2710
|
return parts.join("\n");
|
|
2317
2711
|
}
|
|
2318
2712
|
function builder_max_steps_warning(max_steps) {
|
|
@@ -2334,7 +2728,9 @@ function make_builder_step(deps) {
|
|
|
2334
2728
|
...builder_tool_options(deps)
|
|
2335
2729
|
});
|
|
2336
2730
|
}
|
|
2337
|
-
|
|
2731
|
+
/** Run one builder session on the opened iteration `state`, told what the
|
|
2732
|
+
* iteration before it left behind (`previous`, null on the first). */
|
|
2733
|
+
async function run_builder(deps, state, previous, ctx) {
|
|
2338
2734
|
const { config } = deps;
|
|
2339
2735
|
try {
|
|
2340
2736
|
if (config.builder_provider === "ollama") {
|
|
@@ -2347,10 +2743,16 @@ async function run_builder(deps, state, ctx) {
|
|
|
2347
2743
|
criteria: config.criteria,
|
|
2348
2744
|
feedback: state.feedback,
|
|
2349
2745
|
iteration: state.iteration,
|
|
2350
|
-
checkride: config.check_resolved === "checkride"
|
|
2746
|
+
checkride: config.check_resolved === "checkride",
|
|
2747
|
+
previous
|
|
2351
2748
|
}));
|
|
2352
|
-
|
|
2353
|
-
|
|
2749
|
+
const built = accumulate(state, "builder", result, config.builder_model);
|
|
2750
|
+
if (result.finish_reason !== "max_steps") return built;
|
|
2751
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
2752
|
+
return {
|
|
2753
|
+
...built,
|
|
2754
|
+
builder_max_steps_hits: built.builder_max_steps_hits + 1
|
|
2755
|
+
};
|
|
2354
2756
|
} catch (err) {
|
|
2355
2757
|
throw phase_error("builder", state.iteration, err);
|
|
2356
2758
|
}
|
|
@@ -2759,6 +3161,7 @@ function initial_state() {
|
|
|
2759
3161
|
builder_cost_usd: 0,
|
|
2760
3162
|
critic_cost_usd: 0,
|
|
2761
3163
|
check_duration_ms: 0,
|
|
3164
|
+
builder_max_steps_hits: 0,
|
|
2762
3165
|
iteration_cost_usd: 0,
|
|
2763
3166
|
halt: null,
|
|
2764
3167
|
cost_warned: false
|
|
@@ -2802,227 +3205,6 @@ function status_of(value, converged) {
|
|
|
2802
3205
|
return converged ? "success" : "budget_exhausted";
|
|
2803
3206
|
}
|
|
2804
3207
|
//#endregion
|
|
2805
|
-
//#region src/check/checkride.ts
|
|
2806
|
-
/**
|
|
2807
|
-
* checkride runner: spawn `pnpm exec checkride --json`, gate on the
|
|
2808
|
-
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
2809
|
-
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
2810
|
-
* actually said, not a normalized digest.
|
|
2811
|
-
*/
|
|
2812
|
-
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2813
|
-
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2814
|
-
/** How long a check's output may stay open after its shell exits, while
|
|
2815
|
-
* something that escaped its process group still holds it, before the reads
|
|
2816
|
-
* stop so the check can finish. */
|
|
2817
|
-
const OUTPUT_GRACE_MS = 2e3;
|
|
2818
|
-
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2819
|
-
* and anything the check started in the background, sit in the shell's group,
|
|
2820
|
-
* so killing the shell alone would leave them running. Windows has no process
|
|
2821
|
-
* groups to signal, so there only the shell goes. */
|
|
2822
|
-
function kill_group(proc) {
|
|
2823
|
-
if (proc.pid === void 0) return;
|
|
2824
|
-
try {
|
|
2825
|
-
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2826
|
-
else process.kill(-proc.pid, "SIGKILL");
|
|
2827
|
-
} catch {}
|
|
2828
|
-
}
|
|
2829
|
-
/** Run a check to its end in a process group of its own, capturing its
|
|
2830
|
-
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2831
|
-
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2832
|
-
* no test workers or servers running. */
|
|
2833
|
-
function spawn_capture(cmd, args, options) {
|
|
2834
|
-
const { abort } = options;
|
|
2835
|
-
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2836
|
-
return new Promise((resolve_promise, reject) => {
|
|
2837
|
-
const proc = spawn(cmd, args, {
|
|
2838
|
-
cwd: options.cwd,
|
|
2839
|
-
...options.env !== void 0 ? { env: options.env } : {},
|
|
2840
|
-
detached: process.platform !== "win32",
|
|
2841
|
-
stdio: [
|
|
2842
|
-
"ignore",
|
|
2843
|
-
"pipe",
|
|
2844
|
-
"pipe"
|
|
2845
|
-
]
|
|
2846
|
-
});
|
|
2847
|
-
let stdout = "";
|
|
2848
|
-
let stderr = "";
|
|
2849
|
-
let grace;
|
|
2850
|
-
const on_abort = () => {
|
|
2851
|
-
kill_group(proc);
|
|
2852
|
-
reject(new aborted_error("check aborted"));
|
|
2853
|
-
};
|
|
2854
|
-
const finish = () => {
|
|
2855
|
-
clearTimeout(grace);
|
|
2856
|
-
abort?.removeEventListener("abort", on_abort);
|
|
2857
|
-
};
|
|
2858
|
-
proc.stdout.on("data", (chunk) => {
|
|
2859
|
-
stdout += chunk.toString();
|
|
2860
|
-
});
|
|
2861
|
-
proc.stderr.on("data", (chunk) => {
|
|
2862
|
-
stderr += chunk.toString();
|
|
2863
|
-
});
|
|
2864
|
-
proc.on("error", (err) => {
|
|
2865
|
-
finish();
|
|
2866
|
-
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2867
|
-
});
|
|
2868
|
-
proc.on("exit", () => {
|
|
2869
|
-
kill_group(proc);
|
|
2870
|
-
grace = setTimeout(() => {
|
|
2871
|
-
proc.stdout.destroy();
|
|
2872
|
-
proc.stderr.destroy();
|
|
2873
|
-
}, OUTPUT_GRACE_MS);
|
|
2874
|
-
});
|
|
2875
|
-
proc.on("close", (code) => {
|
|
2876
|
-
finish();
|
|
2877
|
-
resolve_promise({
|
|
2878
|
-
exit_code: code,
|
|
2879
|
-
stdout,
|
|
2880
|
-
stderr
|
|
2881
|
-
});
|
|
2882
|
-
});
|
|
2883
|
-
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2884
|
-
});
|
|
2885
|
-
}
|
|
2886
|
-
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
2887
|
-
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
2888
|
-
* install line ahead of the JSON), so the object is extracted between the
|
|
2889
|
-
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
2890
|
-
* schema_version warns and still parses; no JSON object at all is a harness
|
|
2891
|
-
* error (exit 4). */
|
|
2892
|
-
function parse_checkride_summary(stdout) {
|
|
2893
|
-
const start = stdout.indexOf("{");
|
|
2894
|
-
const end = stdout.lastIndexOf("}");
|
|
2895
|
-
let raw;
|
|
2896
|
-
try {
|
|
2897
|
-
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
2898
|
-
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
2899
|
-
} catch {
|
|
2900
|
-
throw check_error("checkride --json produced unparseable stdout");
|
|
2901
|
-
}
|
|
2902
|
-
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
2903
|
-
const summary = raw;
|
|
2904
|
-
return {
|
|
2905
|
-
summary,
|
|
2906
|
-
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
2907
|
-
};
|
|
2908
|
-
}
|
|
2909
|
-
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
2910
|
-
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
2911
|
-
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
2912
|
-
function parse_summary_with_fallback(stdout, workspace) {
|
|
2913
|
-
try {
|
|
2914
|
-
return parse_checkride_summary(stdout);
|
|
2915
|
-
} catch (err) {
|
|
2916
|
-
const file = join(workspace, ".check", "summary.json");
|
|
2917
|
-
if (!existsSync(file)) throw err;
|
|
2918
|
-
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
function failing_checks(summary) {
|
|
2922
|
-
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
2923
|
-
}
|
|
2924
|
-
/** Per failing slot: the raw artifact (`.check/<output_file>` when present,
|
|
2925
|
-
* else `.check/<slot>.stdout.txt`), truncated with a pointer to the file. */
|
|
2926
|
-
function read_failing_artifacts(workspace, failing) {
|
|
2927
|
-
return failing.map((check) => {
|
|
2928
|
-
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
2929
|
-
const path = join(workspace, ".check", file);
|
|
2930
|
-
if (!existsSync(path)) return {
|
|
2931
|
-
slot: check.name,
|
|
2932
|
-
path: null,
|
|
2933
|
-
content: "(no artifact found)",
|
|
2934
|
-
truncated: false
|
|
2935
|
-
};
|
|
2936
|
-
const raw = readFileSync(path, "utf8");
|
|
2937
|
-
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
2938
|
-
return {
|
|
2939
|
-
slot: check.name,
|
|
2940
|
-
path,
|
|
2941
|
-
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
2942
|
-
truncated
|
|
2943
|
-
};
|
|
2944
|
-
});
|
|
2945
|
-
}
|
|
2946
|
-
async function run_checkride(opts) {
|
|
2947
|
-
const { workspace, ...spawn_options } = opts;
|
|
2948
|
-
const proc = await spawn_capture("pnpm", [
|
|
2949
|
-
"exec",
|
|
2950
|
-
"checkride",
|
|
2951
|
-
"--json"
|
|
2952
|
-
], {
|
|
2953
|
-
...spawn_options,
|
|
2954
|
-
cwd: workspace
|
|
2955
|
-
});
|
|
2956
|
-
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2957
|
-
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2958
|
-
const failing = failing_checks(summary);
|
|
2959
|
-
return {
|
|
2960
|
-
ran: true,
|
|
2961
|
-
runner: "checkride",
|
|
2962
|
-
ok: summary.ok,
|
|
2963
|
-
exit_code: proc.exit_code,
|
|
2964
|
-
duration_ms: summary.total_duration_ms,
|
|
2965
|
-
failing_slots: failing.map((c) => c.name),
|
|
2966
|
-
detail: read_failing_artifacts(opts.workspace, failing),
|
|
2967
|
-
summary
|
|
2968
|
-
};
|
|
2969
|
-
}
|
|
2970
|
-
//#endregion
|
|
2971
|
-
//#region src/check/command.ts
|
|
2972
|
-
/**
|
|
2973
|
-
* Generic command runner: `sh -c <command>`, combined
|
|
2974
|
-
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2975
|
-
* (exit 4), not an iterable failure.
|
|
2976
|
-
*/
|
|
2977
|
-
async function run_command_check(opts) {
|
|
2978
|
-
const { command, workspace, ...spawn_options } = opts;
|
|
2979
|
-
const started = Date.now();
|
|
2980
|
-
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2981
|
-
...spawn_options,
|
|
2982
|
-
cwd: workspace
|
|
2983
|
-
});
|
|
2984
|
-
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2985
|
-
const log = `${proc.stdout}${proc.stderr}`;
|
|
2986
|
-
return {
|
|
2987
|
-
ran: true,
|
|
2988
|
-
runner: "command",
|
|
2989
|
-
ok: proc.exit_code === 0,
|
|
2990
|
-
exit_code: proc.exit_code,
|
|
2991
|
-
duration_ms: Date.now() - started,
|
|
2992
|
-
failing_slots: [],
|
|
2993
|
-
detail: [],
|
|
2994
|
-
log
|
|
2995
|
-
};
|
|
2996
|
-
}
|
|
2997
|
-
function skipped_check(reason) {
|
|
2998
|
-
return {
|
|
2999
|
-
ran: false,
|
|
3000
|
-
runner: "none",
|
|
3001
|
-
ok: reason === "none",
|
|
3002
|
-
exit_code: null,
|
|
3003
|
-
duration_ms: 0,
|
|
3004
|
-
failing_slots: [],
|
|
3005
|
-
detail: []
|
|
3006
|
-
};
|
|
3007
|
-
}
|
|
3008
|
-
/** What a rejected check says in place of its output. */
|
|
3009
|
-
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
3010
|
-
/** A check the user rejected when it asked to run: recorded as a failed
|
|
3011
|
-
* check, with the refusal as its output, so a round can't converge on work
|
|
3012
|
-
* nothing checked, and the critic hears why there's no result. */
|
|
3013
|
-
function rejected_check(runner) {
|
|
3014
|
-
return {
|
|
3015
|
-
ran: true,
|
|
3016
|
-
runner,
|
|
3017
|
-
ok: false,
|
|
3018
|
-
exit_code: null,
|
|
3019
|
-
duration_ms: 0,
|
|
3020
|
-
failing_slots: [],
|
|
3021
|
-
detail: [],
|
|
3022
|
-
log: CHECK_REJECTED
|
|
3023
|
-
};
|
|
3024
|
-
}
|
|
3025
|
-
//#endregion
|
|
3026
3208
|
//#region src/render/format.ts
|
|
3027
3209
|
/**
|
|
3028
3210
|
* Display formatting: glyph taxonomy, role colors, truncation.
|
|
@@ -3090,6 +3272,7 @@ function phase_summary(record) {
|
|
|
3090
3272
|
tool_calls: record.tool_calls,
|
|
3091
3273
|
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
3092
3274
|
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
3275
|
+
...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
|
|
3093
3276
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
3094
3277
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
3095
3278
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
@@ -3112,6 +3295,8 @@ function archive_iteration(config, state) {
|
|
|
3112
3295
|
if (check.runner === "checkride") {
|
|
3113
3296
|
if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
|
|
3114
3297
|
for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
|
|
3298
|
+
if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
|
|
3299
|
+
if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
|
|
3115
3300
|
} else {
|
|
3116
3301
|
writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
|
|
3117
3302
|
writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
|
|
@@ -3152,6 +3337,7 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
3152
3337
|
builder_cost_usd: state.builder_cost_usd,
|
|
3153
3338
|
critic_cost_usd: state.critic_cost_usd,
|
|
3154
3339
|
check_duration_ms: state.check_duration_ms,
|
|
3340
|
+
builder_max_steps_hits: state.builder_max_steps_hits,
|
|
3155
3341
|
final_verdict: state.verdict,
|
|
3156
3342
|
salvaged_branch: null,
|
|
3157
3343
|
run_state_dir: run_state_dir(config),
|
|
@@ -3311,7 +3497,7 @@ function record_check_chunk(ctx, chunk) {
|
|
|
3311
3497
|
}
|
|
3312
3498
|
/** The command line the check runs, or null when no check is configured. */
|
|
3313
3499
|
function check_command(config) {
|
|
3314
|
-
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3500
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
|
|
3315
3501
|
if (config.check_resolved === "command") return config.check;
|
|
3316
3502
|
return null;
|
|
3317
3503
|
}
|
|
@@ -3393,6 +3579,17 @@ function next_iteration(s) {
|
|
|
3393
3579
|
unmet_criteria: []
|
|
3394
3580
|
};
|
|
3395
3581
|
}
|
|
3582
|
+
/** What the builder of the iteration after `s` hears about it beyond the
|
|
3583
|
+
* critic's feedback: its check, and whether its builder ran out of steps. Null
|
|
3584
|
+
* before the first iteration. A resumed run's state carries both, read back
|
|
3585
|
+
* from the last archived iteration. */
|
|
3586
|
+
function previous_round(config, s) {
|
|
3587
|
+
if (s.iteration === 0) return null;
|
|
3588
|
+
return {
|
|
3589
|
+
check: s.check,
|
|
3590
|
+
step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
|
|
3591
|
+
};
|
|
3592
|
+
}
|
|
3396
3593
|
/** What changed in the build root since the baseline, reported as it's
|
|
3397
3594
|
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3398
3595
|
* builder's work this way after every build, and a critique measures the
|
|
@@ -3423,7 +3620,7 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3423
3620
|
builder,
|
|
3424
3621
|
config,
|
|
3425
3622
|
warn: renderer.warn
|
|
3426
|
-
}, next, ctx);
|
|
3623
|
+
}, next, previous_round(config, s), ctx);
|
|
3427
3624
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3428
3625
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3429
3626
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
@@ -3841,8 +4038,10 @@ function finalize_failed_run(config, err) {
|
|
|
3841
4038
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
3842
4039
|
try {
|
|
3843
4040
|
const summary_path = state_path(config, "summary.json");
|
|
4041
|
+
const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
3844
4042
|
write_run_summary(config, {
|
|
3845
|
-
...
|
|
4043
|
+
...prior,
|
|
4044
|
+
builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
|
|
3846
4045
|
run_state_dir: run_state_dir(config),
|
|
3847
4046
|
status,
|
|
3848
4047
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|