@robmclarty/volley 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -400,13 +400,93 @@ function resolve_config(raw, options = {}) {
400
400
 
401
401
  // src/orchestrator.ts
402
402
  import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
403
- import { loop, run, sequence, step } from "fascicle";
403
+ import { run } from "fascicle";
404
404
  import { filesystem_logger } from "fascicle/adapters";
405
405
 
406
+ // src/engine.ts
407
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
408
+ import { create_engine } from "fascicle";
409
+ var DEFAULT_OLLAMA_URL = "http://localhost:11434";
410
+ var DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
411
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
412
+ function cross_to_host_gateway(base_url, env) {
413
+ const host = env["VOLLEY_MODEL_HOST"];
414
+ if (host === void 0 || host === "") return base_url;
415
+ let parsed;
416
+ try {
417
+ parsed = new URL(base_url);
418
+ } catch {
419
+ return base_url;
420
+ }
421
+ if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
422
+ parsed.hostname = host;
423
+ return parsed.toString().replace(/\/$/, "");
424
+ }
425
+ function resolve_ollama_base_url(env) {
426
+ const raw = env["VOLLEY_OLLAMA_URL"] ?? DEFAULT_OLLAMA_URL;
427
+ const normalized = raw.replace(/\/+$/, "").replace(/\/api$/, "");
428
+ return cross_to_host_gateway(normalized, env);
429
+ }
430
+ function load_pricing_overrides(path) {
431
+ if (!existsSync2(path)) {
432
+ throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
433
+ }
434
+ try {
435
+ return JSON.parse(readFileSync2(path, "utf8"));
436
+ } catch (err) {
437
+ const detail = err instanceof Error ? err.message : String(err);
438
+ throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${detail}`);
439
+ }
440
+ }
441
+ function is_local_provider(provider) {
442
+ return provider === "ollama" || provider === "lmstudio";
443
+ }
444
+ function local_provider_config(provider, env) {
445
+ if (provider === "ollama") {
446
+ return { ollama: { base_url: resolve_ollama_base_url(env) } };
447
+ }
448
+ const lmstudio_url = env["VOLLEY_LMSTUDIO_URL"] ?? DEFAULT_LMSTUDIO_URL;
449
+ return { lmstudio: { base_url: cross_to_host_gateway(lmstudio_url, env) } };
450
+ }
451
+ function create_volley_engine(options) {
452
+ const env = options.env ?? process.env;
453
+ const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
454
+ if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") {
455
+ throw config_error(
456
+ `VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`
457
+ );
458
+ }
459
+ const binary = env["VOLLEY_CLAUDE_BIN"];
460
+ const pricing_path = env["VOLLEY_PRICING_PATH"];
461
+ const local_providers = /* @__PURE__ */ new Set();
462
+ if (is_local_provider(options.builder_provider)) local_providers.add(options.builder_provider);
463
+ if (is_local_provider(options.critic_provider)) local_providers.add(options.critic_provider);
464
+ const providers = {
465
+ claude_cli: {
466
+ default_cwd: options.workspace,
467
+ setting_sources: ["project", "local"],
468
+ auth_mode,
469
+ ...binary !== void 0 && binary.length > 0 ? { binary } : {},
470
+ ...auth_mode === "api_key" && env["ANTHROPIC_API_KEY"] !== void 0 ? { api_key: env["ANTHROPIC_API_KEY"] } : {}
471
+ }
472
+ };
473
+ for (const provider of local_providers) {
474
+ Object.assign(providers, local_provider_config(provider, env));
475
+ }
476
+ return create_engine({
477
+ providers,
478
+ ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
479
+ });
480
+ }
481
+
482
+ // src/flow.ts
483
+ import { branch, fallback, loop, pipe, retry, sequence, step } from "fascicle";
484
+
406
485
  // src/builder.ts
407
486
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
408
487
  import { dirname as dirname2, join as join4 } from "path";
409
488
  import { fileURLToPath } from "url";
489
+ import { model_call } from "fascicle";
410
490
 
411
491
  // src/builder/context_check.ts
412
492
  var MIN_RECOMMENDED_NUM_CTX = 16384;
@@ -448,7 +528,7 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
448
528
  // src/builder/tools.ts
449
529
  import { spawnSync as spawnSync2 } from "child_process";
450
530
  import { lookup as dns_lookup } from "dns";
451
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
531
+ import { mkdirSync, readFileSync as readFileSync4, writeFileSync } from "fs";
452
532
  import { dirname } from "path";
453
533
  import { Readability } from "@mozilla/readability";
454
534
  import ipaddr from "ipaddr.js";
@@ -458,7 +538,7 @@ import { Agent, buildConnector, interceptors, request } from "undici";
458
538
  import { z as z2 } from "zod";
459
539
 
460
540
  // src/workspace_tools.ts
461
- import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
541
+ import { readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "fs";
462
542
  import { isAbsolute as isAbsolute2, join, relative, resolve as resolve2, sep } from "path";
463
543
  import { z } from "zod";
464
544
  var READ_FILE_MAX_BYTES = 2e5;
@@ -538,7 +618,7 @@ function read_only_tools(workspace) {
538
618
  execute: (raw) => {
539
619
  const input = read_file_input.parse(raw);
540
620
  const abs = contain(workspace, input.path);
541
- const content = readFileSync2(abs, "utf8");
621
+ const content = readFileSync3(abs, "utf8");
542
622
  return content.length > READ_FILE_MAX_BYTES ? `${content.slice(0, READ_FILE_MAX_BYTES)}
543
623
  \u2026 [truncated at ${String(READ_FILE_MAX_BYTES)} bytes]` : content;
544
624
  }
@@ -557,7 +637,7 @@ function read_only_tools(workspace) {
557
637
  if (hits.length >= SEARCH_MAX_MATCHES) break;
558
638
  let content;
559
639
  try {
560
- content = readFileSync2(file.abs, "utf8");
640
+ content = readFileSync3(file.abs, "utf8");
561
641
  } catch {
562
642
  continue;
563
643
  }
@@ -758,13 +838,13 @@ function extract_markdown(html) {
758
838
  body_markdown = "";
759
839
  }
760
840
  if (body_markdown.length < FETCH_MIN_EXTRACT_CHARS) {
761
- let fallback = "";
841
+ let fallback2 = "";
762
842
  try {
763
- fallback = turndown.turndown(html).trim();
843
+ fallback2 = turndown.turndown(html).trim();
764
844
  } catch {
765
- fallback = "";
845
+ fallback2 = "";
766
846
  }
767
- if (fallback.length > body_markdown.length) body_markdown = fallback;
847
+ if (fallback2.length > body_markdown.length) body_markdown = fallback2;
768
848
  }
769
849
  const prefix = title.length > 0 && !body_markdown.startsWith(`# ${title}`) ? `# ${title}
770
850
 
@@ -900,7 +980,7 @@ function builder_tools(workspace, options = {}) {
900
980
  execute: (raw) => {
901
981
  const input = edit_file_input.parse(raw);
902
982
  const abs = contain(workspace, input.path);
903
- const content = readFileSync3(abs, "utf8");
983
+ const content = readFileSync4(abs, "utf8");
904
984
  const matches = count_occurrences(content, input.old_str);
905
985
  if (matches === 0) {
906
986
  return `error: old_str not found in ${input.path} (0 matches). No change made. Read the file and retry with an exact excerpt of its current content.`;
@@ -971,6 +1051,7 @@ ${note}` : note;
971
1051
  }
972
1052
 
973
1053
  // src/cost.ts
1054
+ import { claude_cli_reported, throughput } from "fascicle";
974
1055
  var EMPTY_USAGE = { input_tokens: 0, output_tokens: 0 };
975
1056
  function add_usage(a, b) {
976
1057
  const sum = {
@@ -988,30 +1069,24 @@ function cost_source_of(result) {
988
1069
  if (result.cost === void 0) return "unknown";
989
1070
  return result.model_resolved.provider === "claude_cli" ? "provider_reported" : "engine_derived";
990
1071
  }
991
- function session_of(result) {
992
- const reported = result.provider_reported?.["claude_cli"];
993
- return {
994
- session_id: typeof reported?.session_id === "string" ? reported.session_id : null,
995
- duration_ms: typeof reported?.duration_ms === "number" ? reported.duration_ms : 0
996
- };
997
- }
998
- function phase_record(result, model) {
999
- const { session_id, duration_ms } = session_of(result);
1072
+ function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
1073
+ const rate = throughput(result);
1000
1074
  return {
1001
1075
  provider: result.model_resolved.provider,
1002
1076
  model,
1003
- session_id,
1077
+ session_id: claude_cli_reported(result)?.session_id ?? null,
1004
1078
  duration_ms,
1005
1079
  usage: result.usage,
1006
1080
  cost_usd: result.cost?.total_usd ?? null,
1007
1081
  cost_source: cost_source_of(result),
1008
1082
  finish_reason: result.finish_reason,
1009
1083
  tool_calls: result.tool_calls.length,
1010
- salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length
1084
+ salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
1085
+ ...rate !== void 0 ? { throughput: rate } : {}
1011
1086
  };
1012
1087
  }
1013
- function accumulate(state, role, result, model) {
1014
- const record = phase_record(result, model);
1088
+ function accumulate(state, role, result, model, duration_ms) {
1089
+ const record = phase_record(result, model, duration_ms);
1015
1090
  const cost = record.cost_usd ?? 0;
1016
1091
  return {
1017
1092
  ...state,
@@ -1028,82 +1103,6 @@ function cost_cap_hit(config, state) {
1028
1103
  return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
1029
1104
  }
1030
1105
 
1031
- // src/engine.ts
1032
- import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
1033
- import { create_engine } from "fascicle";
1034
- var DEFAULT_OLLAMA_URL = "http://localhost:11434";
1035
- var DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
1036
- var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
1037
- function cross_to_host_gateway(base_url, env) {
1038
- const host = env["VOLLEY_MODEL_HOST"];
1039
- if (host === void 0 || host === "") return base_url;
1040
- let parsed;
1041
- try {
1042
- parsed = new URL(base_url);
1043
- } catch {
1044
- return base_url;
1045
- }
1046
- if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) return base_url;
1047
- parsed.hostname = host;
1048
- return parsed.toString().replace(/\/$/, "");
1049
- }
1050
- function resolve_ollama_base_url(env) {
1051
- const raw = env["VOLLEY_OLLAMA_URL"] ?? DEFAULT_OLLAMA_URL;
1052
- const normalized = raw.replace(/\/+$/, "").replace(/\/api$/, "");
1053
- return cross_to_host_gateway(normalized, env);
1054
- }
1055
- function load_pricing_overrides(path) {
1056
- if (!existsSync2(path)) {
1057
- throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
1058
- }
1059
- try {
1060
- return JSON.parse(readFileSync4(path, "utf8"));
1061
- } catch (err) {
1062
- const detail = err instanceof Error ? err.message : String(err);
1063
- throw config_error(`VOLLEY_PRICING_PATH is not valid JSON: ${detail}`);
1064
- }
1065
- }
1066
- function is_local_provider(provider) {
1067
- return provider === "ollama" || provider === "lmstudio";
1068
- }
1069
- function local_provider_config(provider, env) {
1070
- if (provider === "ollama") {
1071
- return { ollama: { base_url: resolve_ollama_base_url(env) } };
1072
- }
1073
- const lmstudio_url = env["VOLLEY_LMSTUDIO_URL"] ?? DEFAULT_LMSTUDIO_URL;
1074
- return { lmstudio: { base_url: cross_to_host_gateway(lmstudio_url, env) } };
1075
- }
1076
- function create_volley_engine(options) {
1077
- const env = options.env ?? process.env;
1078
- const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
1079
- if (auth_mode !== "auto" && auth_mode !== "oauth" && auth_mode !== "api_key") {
1080
- throw config_error(
1081
- `VOLLEY_AUTH_MODE must be auto, oauth, or api_key; got: ${auth_mode}`
1082
- );
1083
- }
1084
- const binary = env["VOLLEY_CLAUDE_BIN"];
1085
- const pricing_path = env["VOLLEY_PRICING_PATH"];
1086
- const local_providers = /* @__PURE__ */ new Set();
1087
- if (is_local_provider(options.builder_provider)) local_providers.add(options.builder_provider);
1088
- if (is_local_provider(options.critic_provider)) local_providers.add(options.critic_provider);
1089
- const providers = {
1090
- claude_cli: {
1091
- default_cwd: options.workspace,
1092
- setting_sources: ["project", "local"],
1093
- auth_mode,
1094
- ...binary !== void 0 && binary.length > 0 ? { binary } : {},
1095
- ...auth_mode === "api_key" && env["ANTHROPIC_API_KEY"] !== void 0 ? { api_key: env["ANTHROPIC_API_KEY"] } : {}
1096
- }
1097
- };
1098
- for (const provider of local_providers) {
1099
- Object.assign(providers, local_provider_config(provider, env));
1100
- }
1101
- return create_engine({
1102
- providers,
1103
- ...pricing_path !== void 0 && pricing_path.length > 0 ? { pricing: load_pricing_overrides(pricing_path) } : {}
1104
- });
1105
- }
1106
-
1107
1106
  // src/prewarm.ts
1108
1107
  var PREWARM_TIMEOUT_MS = 3e5;
1109
1108
  async function prewarm_ollama_model(base_url, model, abort) {
@@ -1197,11 +1196,11 @@ function git_checkpoint(root, message) {
1197
1196
  throw check_error(`git commit failed: ${out.trim()}`);
1198
1197
  }
1199
1198
  }
1200
- function integrate_worktree(workspace, branch, message) {
1199
+ function integrate_worktree(workspace, branch2, message) {
1201
1200
  if (!existsSync3(join2(workspace, ".git"))) {
1202
1201
  throw config_error(`--worktree integration requires a git repository: ${workspace}`);
1203
1202
  }
1204
- const squash = spawnSync3("git", ["merge", "--squash", branch], {
1203
+ const squash = spawnSync3("git", ["merge", "--squash", branch2], {
1205
1204
  cwd: workspace,
1206
1205
  encoding: "utf8"
1207
1206
  });
@@ -1210,7 +1209,7 @@ function integrate_worktree(workspace, branch, message) {
1210
1209
  }
1211
1210
  if (squash.status !== 0) {
1212
1211
  throw check_error(
1213
- `git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`
1212
+ `git merge --squash ${branch2} failed: ${(squash.stderr || squash.stdout).trim()}`
1214
1213
  );
1215
1214
  }
1216
1215
  const commit = spawnSync3("git", ["commit", "-m", message, "--no-verify"], {
@@ -1295,8 +1294,8 @@ function git_quiet(cwd, args) {
1295
1294
  return { status: -1, stdout: "", stderr: "" };
1296
1295
  }
1297
1296
  }
1298
- function branch_exists(repo, branch) {
1299
- return git2(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
1297
+ function branch_exists(repo, branch2) {
1298
+ return git2(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch2}`]).status === 0;
1300
1299
  }
1301
1300
  function create_worktree(options) {
1302
1301
  const repo = resolve3(options.workspace);
@@ -1344,25 +1343,25 @@ function teardown_worktree(options) {
1344
1343
  }
1345
1344
  return outcome;
1346
1345
  }
1347
- function salvage_worktree(repo, path, branch) {
1346
+ function salvage_worktree(repo, path, branch2) {
1348
1347
  if (existsSync4(path)) {
1349
1348
  git_quiet(path, ["add", "-A"]);
1350
1349
  git_quiet(path, [
1351
1350
  "commit",
1352
1351
  "-m",
1353
- `volley: salvage unintegrated worktree (${branch})`,
1352
+ `volley: salvage unintegrated worktree (${branch2})`,
1354
1353
  "--no-verify"
1355
1354
  ]);
1356
1355
  if (worktree_dirty(path)) return { salvaged_branch: null, kept_path: path };
1357
1356
  }
1358
- return { salvaged_branch: branch_ahead(repo, branch) ? branch : null, kept_path: null };
1357
+ return { salvaged_branch: branch_ahead(repo, branch2) ? branch2 : null, kept_path: null };
1359
1358
  }
1360
1359
  function worktree_dirty(path) {
1361
1360
  const status = git_quiet(path, ["status", "--porcelain"]);
1362
1361
  return status.status === 0 && status.stdout.trim().length > 0;
1363
1362
  }
1364
- function branch_ahead(repo, branch) {
1365
- const ahead = git_quiet(repo, ["rev-list", "--count", `HEAD..${branch}`]);
1363
+ function branch_ahead(repo, branch2) {
1364
+ const ahead = git_quiet(repo, ["rev-list", "--count", `HEAD..${branch2}`]);
1366
1365
  return ahead.status === 0 && Number(ahead.stdout.trim()) > 0;
1367
1366
  }
1368
1367
  async function with_worktree(options, body) {
@@ -1502,8 +1501,20 @@ function compose_builder_prompt(input) {
1502
1501
  function builder_max_steps_warning(max_steps) {
1503
1502
  return `builder hit the ${String(max_steps)}-step limit without calling finish; handing the partial workspace to check + critic (raise --builder-max-steps if this recurs)`;
1504
1503
  }
1504
+ function make_builder_step(deps) {
1505
+ const { config } = deps;
1506
+ return model_call({
1507
+ engine: deps.engine,
1508
+ id: "builder",
1509
+ provider: config.builder_provider,
1510
+ model: config.builder_model,
1511
+ system: resolve_builder_system(config),
1512
+ on_chunk: deps.on_chunk,
1513
+ ...builder_tool_options(config, deps.bash_executor ?? null)
1514
+ });
1515
+ }
1505
1516
  async function run_builder(deps, state, ctx) {
1506
- const { engine, config } = deps;
1517
+ const { config } = deps;
1507
1518
  try {
1508
1519
  if (config.builder_provider === "ollama") {
1509
1520
  const base_url = resolve_ollama_base_url(process.env);
@@ -1516,22 +1527,16 @@ async function run_builder(deps, state, ctx) {
1516
1527
  );
1517
1528
  await prewarm_ollama_model(base_url, config.builder_model, ctx.abort);
1518
1529
  }
1519
- const result = await engine.generate({
1520
- provider: config.builder_provider,
1521
- model: config.builder_model,
1522
- system: resolve_builder_system(config),
1523
- prompt: compose_builder_prompt({
1530
+ const result = await ctx.call(
1531
+ deps.builder,
1532
+ compose_builder_prompt({
1524
1533
  task: config.prompt,
1525
1534
  criteria: config.criteria,
1526
1535
  feedback: state.feedback,
1527
1536
  iteration: state.iteration,
1528
1537
  checkride: config.check_resolved === "checkride"
1529
- }),
1530
- abort: ctx.abort,
1531
- trajectory: ctx.trajectory,
1532
- on_chunk: deps.on_chunk,
1533
- ...builder_tool_options(config, deps.bash_executor ?? null)
1534
- });
1538
+ })
1539
+ );
1535
1540
  if (result.finish_reason === "max_steps") {
1536
1541
  deps.warn(builder_max_steps_warning(config.builder_max_steps));
1537
1542
  }
@@ -1541,91 +1546,407 @@ async function run_builder(deps, state, ctx) {
1541
1546
  }
1542
1547
  }
1543
1548
 
1544
- // src/check/checkride.ts
1549
+ // src/critic/run.ts
1550
+ import { z as z3 } from "zod";
1551
+ import { model_call as model_call2 } from "fascicle";
1552
+
1553
+ // src/critic/prompt.ts
1545
1554
  import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
1546
- import { join as join5 } from "path";
1547
- import { spawn } from "child_process";
1548
- import { aborted_error } from "fascicle";
1549
- var CHECK_ARTIFACT_MAX_CHARS = 8e3;
1550
- var CHECKRIDE_SCHEMA_VERSION = 1;
1551
- function spawn_capture(cmd, args, options) {
1552
- return new Promise((resolve_promise, reject) => {
1553
- const proc = spawn(cmd, args, {
1554
- cwd: options.cwd,
1555
- ...options.abort !== void 0 ? { signal: options.abort } : {}
1556
- });
1557
- let stdout = "";
1558
- let stderr = "";
1559
- proc.stdout.on("data", (chunk) => {
1560
- stdout += chunk.toString();
1561
- });
1562
- proc.stderr.on("data", (chunk) => {
1563
- stderr += chunk.toString();
1564
- });
1565
- proc.on("error", (err) => {
1566
- if (err.name === "AbortError") {
1567
- reject(new aborted_error("check aborted"));
1568
- return;
1569
- }
1570
- reject(check_error(`check command failed to start: ${err.message}`));
1571
- });
1572
- proc.on("close", (code) => {
1573
- resolve_promise({ exit_code: code, stdout, stderr });
1574
- });
1575
- });
1576
- }
1577
- function parse_checkride_summary(stdout) {
1578
- const start = stdout.indexOf("{");
1579
- const end = stdout.lastIndexOf("}");
1580
- let raw;
1581
- try {
1582
- if (start === -1 || end < start) throw new Error("no JSON object in stdout");
1583
- raw = JSON.parse(stdout.slice(start, end + 1));
1584
- } catch {
1585
- throw check_error("checkride --json produced unparseable stdout");
1586
- }
1587
- if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) {
1588
- throw check_error("checkride summary missing checks array");
1589
- }
1590
- const summary = raw;
1591
- const warning = summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`;
1592
- return { summary, warning };
1593
- }
1594
- function parse_summary_with_fallback(stdout, workspace) {
1595
- try {
1596
- return parse_checkride_summary(stdout);
1597
- } catch (err) {
1598
- const file = join5(workspace, ".check", "summary.json");
1599
- if (!existsSync6(file)) throw err;
1600
- return parse_checkride_summary(readFileSync6(file, "utf8"));
1555
+ import { dirname as dirname3, join as join5 } from "path";
1556
+ import { fileURLToPath as fileURLToPath2 } from "url";
1557
+ function presets_dir2() {
1558
+ let dir = dirname3(fileURLToPath2(import.meta.url));
1559
+ for (let depth = 0; depth < 6; depth += 1) {
1560
+ const candidate = join5(dir, "src", "critic", "presets");
1561
+ if (existsSync6(candidate)) return candidate;
1562
+ const local = join5(dir, "presets");
1563
+ if (existsSync6(local)) return local;
1564
+ dir = dirname3(dir);
1601
1565
  }
1566
+ throw config_error("critic presets directory not found");
1602
1567
  }
1603
- function failing_checks(summary) {
1604
- return summary.checks.filter((c) => !c.ok && c.skipped !== true);
1568
+ function resolve_critic_prompt(config) {
1569
+ const base = config.critic_prompt_path !== null ? readFileSync6(config.critic_prompt_path, "utf8") : readFileSync6(join5(presets_dir2(), `${config.critic_preset}.md`), "utf8");
1570
+ const append_file = config.critic_provider === "claude_cli" ? "harness_append.md" : "harness_append_local.md";
1571
+ const append = readFileSync6(join5(presets_dir2(), append_file), "utf8");
1572
+ return `${base.trimEnd()}
1573
+
1574
+ ${append.trimEnd()}`;
1605
1575
  }
1606
- function read_failing_artifacts(workspace, failing) {
1607
- return failing.map((check) => {
1608
- const file = check.output_file ?? `${check.name}.stdout.txt`;
1609
- const path = join5(workspace, ".check", file);
1610
- if (!existsSync6(path)) {
1611
- return { slot: check.name, path: null, content: "(no artifact found)", truncated: false };
1612
- }
1613
- const raw = readFileSync6(path, "utf8");
1614
- const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
1615
- return {
1616
- slot: check.name,
1617
- path,
1618
- content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
1619
- truncated
1620
- };
1621
- });
1576
+ var STATUS_MARKS = {
1577
+ added: "A",
1578
+ modified: "M",
1579
+ deleted: "D",
1580
+ renamed: "R",
1581
+ unknown: "?"
1582
+ };
1583
+ function format_change(file) {
1584
+ return ` ${STATUS_MARKS[file.status]} ${file.path}`;
1622
1585
  }
1623
- async function run_checkride(opts) {
1624
- const proc = await spawn_capture("pnpm", ["exec", "checkride", "--json"], {
1625
- cwd: opts.workspace,
1626
- ...opts.abort !== void 0 ? { abort: opts.abort } : {}
1627
- });
1628
- if (proc.exit_code === 2) {
1586
+ function format_changes_section(changes) {
1587
+ if (changes === null) return null;
1588
+ const scope = changes.baseline === null ? "this run" : `baseline ${changes.baseline.slice(0, 8)}`;
1589
+ if (changes.total === 0) {
1590
+ return [
1591
+ `BUILDER CHANGES (since ${scope})`,
1592
+ "------------------------------",
1593
+ "The builder changed no files. Unless the acceptance criteria were already",
1594
+ "met before this run, that is itself grounds for changes_requested."
1595
+ ].join("\n");
1596
+ }
1597
+ const lines = [
1598
+ `BUILDER CHANGES (since ${scope})`,
1599
+ "------------------------------",
1600
+ "Review the work as a change, not just as a finished tree. Read the files",
1601
+ "below with your tools as needed.",
1602
+ "",
1603
+ ...changes.files.map(format_change)
1604
+ ];
1605
+ if (changes.truncated) {
1606
+ lines.push(
1607
+ ` \u2026 showing ${String(changes.files.length)} of ${String(changes.total)} changed paths`
1608
+ );
1609
+ }
1610
+ if (changes.gate_edits.length > 0) {
1611
+ lines.push(
1612
+ "",
1613
+ `GATE EDITS (${String(changes.gate_edits.length)})`,
1614
+ "-----------",
1615
+ "These changed paths are part of the gate \u2014 the tests, fixtures, and check",
1616
+ "configuration that decide whether this work passes. A check the builder can",
1617
+ "edit proves less than one it cannot.",
1618
+ "",
1619
+ ...changes.gate_edits.map((path) => ` ! ${path}`),
1620
+ "",
1621
+ "Judge whether each of these edits was necessary and legitimate for the task",
1622
+ "as specified. If any of them weakened the check, deleted a case, or moved a",
1623
+ "target rather than meeting it, the work does not meet the criteria: return",
1624
+ "changes_requested, name the file, and say what was weakened."
1625
+ );
1626
+ }
1627
+ return lines.join("\n");
1628
+ }
1629
+ function format_check_section(check) {
1630
+ if (!check.ran) {
1631
+ return "No deterministic check ran for this iteration. Base your verdict on workspace inspection alone.";
1632
+ }
1633
+ const lines = [
1634
+ `DETERMINISTIC CHECK (${check.runner})`,
1635
+ "------------------------------------",
1636
+ `result: ${check.ok ? "PASSED" : "FAILED"} (exit ${String(check.exit_code)})`
1637
+ ];
1638
+ if (check.failing_slots.length > 0) {
1639
+ lines.push(`failing slots: ${check.failing_slots.join(", ")}`);
1640
+ }
1641
+ if (check.summary !== void 0) {
1642
+ lines.push("", "summary.json:", "```json", JSON.stringify(check.summary, null, 2), "```");
1643
+ }
1644
+ for (const artifact of check.detail) {
1645
+ lines.push(
1646
+ "",
1647
+ `--- raw output for failing slot "${artifact.slot}"${artifact.truncated ? ` (truncated; full output archived at ${artifact.path ?? "n/a"})` : ""} ---`,
1648
+ artifact.content
1649
+ );
1650
+ }
1651
+ if (check.log !== void 0 && check.log.length > 0) {
1652
+ lines.push("", "check output:", "```", check.log, "```");
1653
+ }
1654
+ return lines.join("\n");
1655
+ }
1656
+ function compose_critic_prompt(input) {
1657
+ const changes = format_changes_section(input.changes);
1658
+ return [
1659
+ "ACCEPTANCE CRITERIA",
1660
+ "-------------------",
1661
+ input.criteria,
1662
+ "",
1663
+ `ITERATION: ${input.iteration}`,
1664
+ "",
1665
+ format_check_section(input.check),
1666
+ ...changes === null ? [] : ["", changes],
1667
+ "",
1668
+ "Review the workspace against the acceptance criteria and return your",
1669
+ "structured verdict."
1670
+ ].join("\n");
1671
+ }
1672
+
1673
+ // src/critic/run.ts
1674
+ var CRITIC_ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
1675
+ var CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
1676
+ var verdict_schema = z3.object({
1677
+ verdict: z3.enum(["approved", "changes_requested"]),
1678
+ feedback: z3.string().describe(
1679
+ "Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."
1680
+ ),
1681
+ unmet_criteria: z3.array(z3.string()).describe(
1682
+ "The specific acceptance criteria judged unmet, verbatim. Empty when approved."
1683
+ )
1684
+ });
1685
+ function critic_tool_options(config) {
1686
+ if (config.critic_provider === "claude_cli") {
1687
+ return {
1688
+ provider_options: {
1689
+ claude_cli: {
1690
+ allowed_tools: [...CRITIC_ALLOWED_TOOLS],
1691
+ extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
1692
+ }
1693
+ }
1694
+ };
1695
+ }
1696
+ return { tools: read_only_tools(build_root(config.workspace, config.worktree)) };
1697
+ }
1698
+ var MAX_CRITIC_RETRIES = 1;
1699
+ function is_retryable_critic_error(config, err) {
1700
+ if (config.critic_provider === "claude_cli") return false;
1701
+ return error_kind(err) === "provider_error";
1702
+ }
1703
+ function retry_cause_kind_of(err) {
1704
+ const cause = err.cause_kind;
1705
+ return cause === "provider_5xx" || cause === "network" ? cause : "unknown";
1706
+ }
1707
+ function finalize_critic(state, result, model, mark, duration_ms) {
1708
+ const next = accumulate(state, "critic", result, model, duration_ms);
1709
+ return {
1710
+ ...next,
1711
+ critic: next.critic === null ? null : {
1712
+ ...next.critic,
1713
+ retries: mark.retries,
1714
+ ...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
1715
+ ...mark.critic_degraded === true ? { critic_degraded: true } : {}
1716
+ },
1717
+ verdict: result.content.verdict,
1718
+ feedback: result.content.feedback,
1719
+ unmet_criteria: result.content.unmet_criteria
1720
+ };
1721
+ }
1722
+ function toolless_critic_prompt(config, tool_prompt) {
1723
+ return [
1724
+ tool_prompt,
1725
+ "",
1726
+ "NOTE: file-read tools are unavailable for this review. Judge from the",
1727
+ "acceptance criteria, the deterministic check output above, and the workspace",
1728
+ "file inventory below (paths and sizes only \u2014 file contents are not shown).",
1729
+ "",
1730
+ "WORKSPACE FILE INVENTORY",
1731
+ "------------------------",
1732
+ workspace_inventory(build_root(config.workspace, config.worktree))
1733
+ ].join("\n");
1734
+ }
1735
+ function critic_call_config(deps) {
1736
+ const { config } = deps;
1737
+ return {
1738
+ engine: deps.engine,
1739
+ provider: config.critic_provider,
1740
+ model: config.critic_model,
1741
+ system: resolve_critic_prompt(config),
1742
+ schema: verdict_schema,
1743
+ on_chunk: deps.on_chunk
1744
+ };
1745
+ }
1746
+ function make_critic_tools_step(deps) {
1747
+ return model_call2({
1748
+ ...critic_call_config(deps),
1749
+ id: "critic_tools",
1750
+ ...critic_tool_options(deps.config)
1751
+ });
1752
+ }
1753
+ function make_critic_toolless_step(deps) {
1754
+ return model_call2({ ...critic_call_config(deps), id: "critic_toolless" });
1755
+ }
1756
+ function critic_retryable(config) {
1757
+ return (err) => is_retryable_critic_error(config, err);
1758
+ }
1759
+ function with_retries(outcome) {
1760
+ const last = outcome.errors.at(-1);
1761
+ return {
1762
+ result: outcome.value,
1763
+ mark: {
1764
+ retries: outcome.attempts - 1,
1765
+ retry_cause_kind: last === void 0 ? void 0 : retry_cause_kind_of(last)
1766
+ }
1767
+ };
1768
+ }
1769
+ function as_degraded(result) {
1770
+ return {
1771
+ result,
1772
+ mark: { retries: MAX_CRITIC_RETRIES, retry_cause_kind: void 0, critic_degraded: true }
1773
+ };
1774
+ }
1775
+ function with_fallback_cause(outcome) {
1776
+ if (outcome.source === "primary") return outcome.value;
1777
+ return {
1778
+ ...outcome.value,
1779
+ mark: { ...outcome.value.mark, retry_cause_kind: retry_cause_kind_of(outcome.primary_error) }
1780
+ };
1781
+ }
1782
+ var CANARY_PROMPT = [
1783
+ "CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
1784
+ 'First, call the read_file tool on the path "package.json".',
1785
+ "Whatever the tool returns (content or an error), immediately answer with",
1786
+ 'verdict "approved", feedback exactly "canary", and empty unmet_criteria.'
1787
+ ].join("\n");
1788
+ var CANARY_TOOLLESS_PROMPT = [
1789
+ "CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
1790
+ 'Answer with verdict "approved", feedback exactly "canary", and empty',
1791
+ "unmet_criteria."
1792
+ ].join("\n");
1793
+ async function run_critic(deps, state, ctx) {
1794
+ const { config } = deps;
1795
+ if (state.check === null) {
1796
+ throw phase_error("critic", state.iteration, new Error("check phase did not run"));
1797
+ }
1798
+ const check = state.check;
1799
+ try {
1800
+ if (config.critic_provider === "ollama") {
1801
+ await prewarm_ollama_model(
1802
+ resolve_ollama_base_url(process.env),
1803
+ config.critic_model,
1804
+ ctx.abort
1805
+ );
1806
+ }
1807
+ const prompt = compose_critic_prompt({
1808
+ criteria: config.criteria,
1809
+ iteration: state.iteration,
1810
+ check,
1811
+ changes: state.changes
1812
+ });
1813
+ const started = Date.now();
1814
+ const { result, mark } = await ctx.call(deps.critic, prompt);
1815
+ return finalize_critic(state, result, config.critic_model, mark, Date.now() - started);
1816
+ } catch (err) {
1817
+ throw phase_error("critic", state.iteration, err);
1818
+ }
1819
+ }
1820
+
1821
+ // src/loop_state.ts
1822
+ function initial_state() {
1823
+ return {
1824
+ iteration: 0,
1825
+ iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
1826
+ feedback: null,
1827
+ verdict: null,
1828
+ unmet_criteria: [],
1829
+ check: null,
1830
+ changes: null,
1831
+ builder: null,
1832
+ critic: null,
1833
+ total_usage: EMPTY_USAGE,
1834
+ total_cost_usd: 0,
1835
+ builder_cost_usd: 0,
1836
+ critic_cost_usd: 0,
1837
+ check_duration_ms: 0,
1838
+ iteration_cost_usd: 0,
1839
+ halt: null,
1840
+ cost_warned: false
1841
+ };
1842
+ }
1843
+ function remaining_iterations(config, resume_from) {
1844
+ return config.max_iterations - (resume_from?.iteration ?? 0);
1845
+ }
1846
+ function gate_edit_halt(config, state) {
1847
+ return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
1848
+ }
1849
+ function gate(config, state) {
1850
+ const gate_edit = gate_edit_halt(config, state);
1851
+ const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
1852
+ const cap = cost_cap_hit(config, state);
1853
+ const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
1854
+ return {
1855
+ stop: success || cap || gate_edit,
1856
+ state: { ...state, halt }
1857
+ };
1858
+ }
1859
+ function status_of(value, converged) {
1860
+ if (value.halt === "gate_edit") return "gate_edit_blocked";
1861
+ if (value.halt === "cost_cap") return "cost_cap_reached";
1862
+ return converged ? "success" : "budget_exhausted";
1863
+ }
1864
+
1865
+ // src/check/checkride.ts
1866
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
1867
+ import { join as join6 } from "path";
1868
+ import { spawn } from "child_process";
1869
+ import { aborted_error } from "fascicle";
1870
+ var CHECK_ARTIFACT_MAX_CHARS = 8e3;
1871
+ var CHECKRIDE_SCHEMA_VERSION = 1;
1872
+ function spawn_capture(cmd, args, options) {
1873
+ return new Promise((resolve_promise, reject) => {
1874
+ const proc = spawn(cmd, args, {
1875
+ cwd: options.cwd,
1876
+ ...options.abort !== void 0 ? { signal: options.abort } : {}
1877
+ });
1878
+ let stdout = "";
1879
+ let stderr = "";
1880
+ proc.stdout.on("data", (chunk) => {
1881
+ stdout += chunk.toString();
1882
+ });
1883
+ proc.stderr.on("data", (chunk) => {
1884
+ stderr += chunk.toString();
1885
+ });
1886
+ proc.on("error", (err) => {
1887
+ if (err.name === "AbortError") {
1888
+ reject(new aborted_error("check aborted"));
1889
+ return;
1890
+ }
1891
+ reject(check_error(`check command failed to start: ${err.message}`));
1892
+ });
1893
+ proc.on("close", (code) => {
1894
+ resolve_promise({ exit_code: code, stdout, stderr });
1895
+ });
1896
+ });
1897
+ }
1898
+ function parse_checkride_summary(stdout) {
1899
+ const start = stdout.indexOf("{");
1900
+ const end = stdout.lastIndexOf("}");
1901
+ let raw;
1902
+ try {
1903
+ if (start === -1 || end < start) throw new Error("no JSON object in stdout");
1904
+ raw = JSON.parse(stdout.slice(start, end + 1));
1905
+ } catch {
1906
+ throw check_error("checkride --json produced unparseable stdout");
1907
+ }
1908
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) {
1909
+ throw check_error("checkride summary missing checks array");
1910
+ }
1911
+ const summary = raw;
1912
+ const warning = summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`;
1913
+ return { summary, warning };
1914
+ }
1915
+ function parse_summary_with_fallback(stdout, workspace) {
1916
+ try {
1917
+ return parse_checkride_summary(stdout);
1918
+ } catch (err) {
1919
+ const file = join6(workspace, ".check", "summary.json");
1920
+ if (!existsSync7(file)) throw err;
1921
+ return parse_checkride_summary(readFileSync7(file, "utf8"));
1922
+ }
1923
+ }
1924
+ function failing_checks(summary) {
1925
+ return summary.checks.filter((c) => !c.ok && c.skipped !== true);
1926
+ }
1927
+ function read_failing_artifacts(workspace, failing) {
1928
+ return failing.map((check) => {
1929
+ const file = check.output_file ?? `${check.name}.stdout.txt`;
1930
+ const path = join6(workspace, ".check", file);
1931
+ if (!existsSync7(path)) {
1932
+ return { slot: check.name, path: null, content: "(no artifact found)", truncated: false };
1933
+ }
1934
+ const raw = readFileSync7(path, "utf8");
1935
+ const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
1936
+ return {
1937
+ slot: check.name,
1938
+ path,
1939
+ content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
1940
+ truncated
1941
+ };
1942
+ });
1943
+ }
1944
+ async function run_checkride(opts) {
1945
+ const proc = await spawn_capture("pnpm", ["exec", "checkride", "--json"], {
1946
+ cwd: opts.workspace,
1947
+ ...opts.abort !== void 0 ? { abort: opts.abort } : {}
1948
+ });
1949
+ if (proc.exit_code === 2) {
1629
1950
  throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
1630
1951
  }
1631
1952
  const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
@@ -1682,14 +2003,14 @@ function skipped_check(reason) {
1682
2003
 
1683
2004
  // src/iteration.ts
1684
2005
  import {
1685
- existsSync as existsSync7,
2006
+ existsSync as existsSync8,
1686
2007
  mkdirSync as mkdirSync3,
1687
- readFileSync as readFileSync7,
2008
+ readFileSync as readFileSync8,
1688
2009
  readdirSync as readdirSync2,
1689
2010
  rmSync as rmSync2,
1690
2011
  writeFileSync as writeFileSync3
1691
2012
  } from "fs";
1692
- import { basename, join as join6 } from "path";
2013
+ import { basename, join as join7 } from "path";
1693
2014
  function write_json(path, value) {
1694
2015
  writeFileSync3(path, `${JSON.stringify(value, null, 2)}
1695
2016
  `);
@@ -1725,37 +2046,40 @@ function phase_summary(record) {
1725
2046
  // half of the contract — the comparison block reads it back from here.
1726
2047
  ...record.retries !== void 0 ? { retries: record.retries } : {},
1727
2048
  ...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
1728
- ...record.critic_degraded === true ? { critic_degraded: true } : {}
2049
+ ...record.critic_degraded === true ? { critic_degraded: true } : {},
2050
+ // Model speed, where fascicle could time the turns (never `claude_cli`).
2051
+ // Emitted only when measured. Additive.
2052
+ ...record.throughput !== void 0 ? { throughput: record.throughput } : {}
1729
2053
  };
1730
2054
  }
1731
2055
  function archive_iteration(config, state) {
1732
2056
  const dir = iteration_dir(config.workspace, state.iteration);
1733
- mkdirSync3(join6(dir, "check"), { recursive: true });
2057
+ mkdirSync3(join7(dir, "check"), { recursive: true });
1734
2058
  if (state.verdict !== null && state.feedback !== null) {
1735
2059
  persist_verdict_files(config.workspace, state.feedback, state.verdict);
1736
- writeFileSync3(join6(dir, "feedback.md"), readFileSync7(volley_path(config.workspace, "feedback.md")));
1737
- writeFileSync3(join6(dir, "verdict"), `${state.verdict}
2060
+ writeFileSync3(join7(dir, "feedback.md"), readFileSync8(volley_path(config.workspace, "feedback.md")));
2061
+ writeFileSync3(join7(dir, "verdict"), `${state.verdict}
1738
2062
  `);
1739
2063
  }
1740
2064
  const check = state.check;
1741
2065
  if (check !== null && check.ran) {
1742
2066
  if (check.runner === "checkride") {
1743
2067
  if (check.summary !== void 0) {
1744
- write_json(join6(dir, "check", "summary.json"), check.summary);
2068
+ write_json(join7(dir, "check", "summary.json"), check.summary);
1745
2069
  }
1746
2070
  for (const artifact of check.detail) {
1747
- if (artifact.path !== null && existsSync7(artifact.path)) {
1748
- writeFileSync3(join6(dir, "check", basename(artifact.path)), readFileSync7(artifact.path));
2071
+ if (artifact.path !== null && existsSync8(artifact.path)) {
2072
+ writeFileSync3(join7(dir, "check", basename(artifact.path)), readFileSync8(artifact.path));
1749
2073
  }
1750
2074
  }
1751
2075
  } else {
1752
- writeFileSync3(join6(dir, "check", "check.log"), check.log ?? "");
1753
- writeFileSync3(join6(dir, "check", "check.exit"), `${String(check.exit_code)}
2076
+ writeFileSync3(join7(dir, "check", "check.log"), check.log ?? "");
2077
+ writeFileSync3(join7(dir, "check", "check.exit"), `${String(check.exit_code)}
1754
2078
  `);
1755
2079
  }
1756
2080
  }
1757
2081
  const completed_at = (/* @__PURE__ */ new Date()).toISOString();
1758
- write_json(join6(dir, "summary.json"), {
2082
+ write_json(join7(dir, "summary.json"), {
1759
2083
  iteration: state.iteration,
1760
2084
  started_at: state.iteration_started_at,
1761
2085
  completed_at,
@@ -1780,408 +2104,112 @@ function archive_iteration(config, state) {
1780
2104
  // reads the last iteration's list, so a non-converged run says what it was
1781
2105
  // still missing when it stopped instead of only that it stopped. Additive.
1782
2106
  unmet_criteria: state.unmet_criteria,
1783
- iteration_cost_usd: state.iteration_cost_usd,
1784
- iteration_total_cost_usd: state.total_cost_usd
1785
- });
1786
- }
1787
- function run_result_from_state(config, state, status, completed_at) {
1788
- return {
1789
- run_id: config.run_id,
1790
- status,
1791
- started_at: config.started_at,
1792
- completed_at,
1793
- iterations_completed: state.iteration,
1794
- total_usage: state.total_usage,
1795
- total_cost_usd: state.total_cost_usd,
1796
- builder_cost_usd: state.builder_cost_usd,
1797
- critic_cost_usd: state.critic_cost_usd,
1798
- check_duration_ms: state.check_duration_ms,
1799
- final_verdict: state.verdict,
1800
- // The loop itself never salvages: the orchestrator re-stamps this after
1801
- // teardown when a converged `--worktree` run's work was kept on its branch,
1802
- // which is the only moment the branch's survival is known.
1803
- salvaged_branch: null
1804
- };
1805
- }
1806
-
1807
- // src/summary.ts
1808
- import { existsSync as existsSync8, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
1809
- function transport_of(provider) {
1810
- return provider === "claude_cli" ? "claude_cli" : "ai_sdk";
1811
- }
1812
- function read_iteration_archives(workspace) {
1813
- const root = volley_path(workspace, "iterations");
1814
- if (!existsSync8(root)) return [];
1815
- const dirs = readdirSync3(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
1816
- const archives = [];
1817
- for (const dir of dirs) {
1818
- const path = volley_path(workspace, "iterations", dir, "summary.json");
1819
- if (!existsSync8(path)) continue;
1820
- try {
1821
- archives.push(JSON.parse(readFileSync8(path, "utf8")));
1822
- } catch {
1823
- }
1824
- }
1825
- return archives;
1826
- }
1827
- function check_trajectory(archives) {
1828
- return archives.map((archive, index) => ({
1829
- iteration: typeof archive.iteration === "number" ? archive.iteration : index + 1,
1830
- ran: archive.check?.ran === true,
1831
- ok: archive.check?.ok === true,
1832
- failing_slots: archive.check?.failing_slots ?? []
1833
- }));
1834
- }
1835
- function any_critic_degraded(archives) {
1836
- return archives.some((archive) => archive.critic?.critic_degraded === true);
1837
- }
1838
- function all_gate_edits(archives) {
1839
- const edits = /* @__PURE__ */ new Set();
1840
- for (const archive of archives) {
1841
- for (const path of archive.changes?.gate_edits ?? []) edits.add(path);
1842
- }
1843
- return [...edits].toSorted();
1844
- }
1845
- function salvage_stats(archives) {
1846
- let tool_calls = 0;
1847
- let salvaged_tool_calls = 0;
1848
- for (const archive of archives) {
1849
- tool_calls += archive.builder?.tool_calls ?? 0;
1850
- salvaged_tool_calls += archive.builder?.salvaged_tool_calls ?? 0;
1851
- }
1852
- return {
1853
- tool_calls,
1854
- salvaged_tool_calls,
1855
- rate: tool_calls === 0 ? 0 : salvaged_tool_calls / tool_calls
1856
- };
1857
- }
1858
- function build_run_summary(config, result) {
1859
- const archives = read_iteration_archives(config.workspace);
1860
- const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
1861
- return {
1862
- ...result,
1863
- comparison: {
1864
- builder_transport: transport_of(config.builder_provider),
1865
- critic_transport: transport_of(config.critic_provider),
1866
- iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
1867
- wall_clock_ms,
1868
- total_cost_usd: result.total_cost_usd,
1869
- builder_cost_usd: result.builder_cost_usd,
1870
- critic_cost_usd: result.critic_cost_usd,
1871
- final_verdict: result.final_verdict,
1872
- check_trajectory: check_trajectory(archives),
1873
- gate_edits: all_gate_edits(archives),
1874
- unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
1875
- local_salvage: salvage_stats(archives),
1876
- critic_degraded: any_critic_degraded(archives)
1877
- }
1878
- };
1879
- }
1880
- function write_run_summary(config, result) {
1881
- writeFileSync4(
1882
- volley_path(config.workspace, "summary.json"),
1883
- `${JSON.stringify(build_run_summary(config, result), null, 2)}
1884
- `
1885
- );
1886
- }
1887
-
1888
- // src/critic/run.ts
1889
- import { z as z3 } from "zod";
1890
-
1891
- // src/critic/prompt.ts
1892
- import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
1893
- import { dirname as dirname3, join as join7 } from "path";
1894
- import { fileURLToPath as fileURLToPath2 } from "url";
1895
- function presets_dir2() {
1896
- let dir = dirname3(fileURLToPath2(import.meta.url));
1897
- for (let depth = 0; depth < 6; depth += 1) {
1898
- const candidate = join7(dir, "src", "critic", "presets");
1899
- if (existsSync9(candidate)) return candidate;
1900
- const local = join7(dir, "presets");
1901
- if (existsSync9(local)) return local;
1902
- dir = dirname3(dir);
1903
- }
1904
- throw config_error("critic presets directory not found");
1905
- }
1906
- function resolve_critic_prompt(config) {
1907
- const base = config.critic_prompt_path !== null ? readFileSync9(config.critic_prompt_path, "utf8") : readFileSync9(join7(presets_dir2(), `${config.critic_preset}.md`), "utf8");
1908
- const append_file = config.critic_provider === "claude_cli" ? "harness_append.md" : "harness_append_local.md";
1909
- const append = readFileSync9(join7(presets_dir2(), append_file), "utf8");
1910
- return `${base.trimEnd()}
1911
-
1912
- ${append.trimEnd()}`;
1913
- }
1914
- var STATUS_MARKS = {
1915
- added: "A",
1916
- modified: "M",
1917
- deleted: "D",
1918
- renamed: "R",
1919
- unknown: "?"
1920
- };
1921
- function format_change(file) {
1922
- return ` ${STATUS_MARKS[file.status]} ${file.path}`;
1923
- }
1924
- function format_changes_section(changes) {
1925
- if (changes === null) return null;
1926
- const scope = changes.baseline === null ? "this run" : `baseline ${changes.baseline.slice(0, 8)}`;
1927
- if (changes.total === 0) {
1928
- return [
1929
- `BUILDER CHANGES (since ${scope})`,
1930
- "------------------------------",
1931
- "The builder changed no files. Unless the acceptance criteria were already",
1932
- "met before this run, that is itself grounds for changes_requested."
1933
- ].join("\n");
1934
- }
1935
- const lines = [
1936
- `BUILDER CHANGES (since ${scope})`,
1937
- "------------------------------",
1938
- "Review the work as a change, not just as a finished tree. Read the files",
1939
- "below with your tools as needed.",
1940
- "",
1941
- ...changes.files.map(format_change)
1942
- ];
1943
- if (changes.truncated) {
1944
- lines.push(
1945
- ` \u2026 showing ${String(changes.files.length)} of ${String(changes.total)} changed paths`
1946
- );
1947
- }
1948
- if (changes.gate_edits.length > 0) {
1949
- lines.push(
1950
- "",
1951
- `GATE EDITS (${String(changes.gate_edits.length)})`,
1952
- "-----------",
1953
- "These changed paths are part of the gate \u2014 the tests, fixtures, and check",
1954
- "configuration that decide whether this work passes. A check the builder can",
1955
- "edit proves less than one it cannot.",
1956
- "",
1957
- ...changes.gate_edits.map((path) => ` ! ${path}`),
1958
- "",
1959
- "Judge whether each of these edits was necessary and legitimate for the task",
1960
- "as specified. If any of them weakened the check, deleted a case, or moved a",
1961
- "target rather than meeting it, the work does not meet the criteria: return",
1962
- "changes_requested, name the file, and say what was weakened."
1963
- );
1964
- }
1965
- return lines.join("\n");
1966
- }
1967
- function format_check_section(check) {
1968
- if (!check.ran) {
1969
- return "No deterministic check ran for this iteration. Base your verdict on workspace inspection alone.";
1970
- }
1971
- const lines = [
1972
- `DETERMINISTIC CHECK (${check.runner})`,
1973
- "------------------------------------",
1974
- `result: ${check.ok ? "PASSED" : "FAILED"} (exit ${String(check.exit_code)})`
1975
- ];
1976
- if (check.failing_slots.length > 0) {
1977
- lines.push(`failing slots: ${check.failing_slots.join(", ")}`);
1978
- }
1979
- if (check.summary !== void 0) {
1980
- lines.push("", "summary.json:", "```json", JSON.stringify(check.summary, null, 2), "```");
1981
- }
1982
- for (const artifact of check.detail) {
1983
- lines.push(
1984
- "",
1985
- `--- raw output for failing slot "${artifact.slot}"${artifact.truncated ? ` (truncated; full output archived at ${artifact.path ?? "n/a"})` : ""} ---`,
1986
- artifact.content
1987
- );
1988
- }
1989
- if (check.log !== void 0 && check.log.length > 0) {
1990
- lines.push("", "check output:", "```", check.log, "```");
1991
- }
1992
- return lines.join("\n");
1993
- }
1994
- function compose_critic_prompt(input) {
1995
- const changes = format_changes_section(input.changes);
1996
- return [
1997
- "ACCEPTANCE CRITERIA",
1998
- "-------------------",
1999
- input.criteria,
2000
- "",
2001
- `ITERATION: ${input.iteration}`,
2002
- "",
2003
- format_check_section(input.check),
2004
- ...changes === null ? [] : ["", changes],
2005
- "",
2006
- "Review the workspace against the acceptance criteria and return your",
2007
- "structured verdict."
2008
- ].join("\n");
2009
- }
2010
-
2011
- // src/critic/run.ts
2012
- var CRITIC_ALLOWED_TOOLS = ["Read", "Grep", "Glob"];
2013
- var CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
2014
- var verdict_schema = z3.object({
2015
- verdict: z3.enum(["approved", "changes_requested"]),
2016
- feedback: z3.string().describe(
2017
- "Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."
2018
- ),
2019
- unmet_criteria: z3.array(z3.string()).describe(
2020
- "The specific acceptance criteria judged unmet, verbatim. Empty when approved."
2021
- )
2022
- });
2023
- function critic_tool_options(config) {
2024
- if (config.critic_provider === "claude_cli") {
2025
- return {
2026
- provider_options: {
2027
- claude_cli: {
2028
- allowed_tools: [...CRITIC_ALLOWED_TOOLS],
2029
- extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
2030
- }
2031
- }
2032
- };
2033
- }
2034
- return { tools: read_only_tools(build_root(config.workspace, config.worktree)) };
2035
- }
2036
- var MAX_CRITIC_RETRIES = 1;
2037
- function is_retryable_critic_error(config, ctx, err) {
2038
- if (config.critic_provider === "claude_cli") return false;
2039
- if (ctx.abort.aborted) return false;
2040
- return error_kind(err) === "provider_error";
2041
- }
2042
- function retry_cause_kind_of(err) {
2043
- const cause = err.cause_kind;
2044
- return cause === "provider_5xx" || cause === "network" ? cause : "unknown";
2107
+ iteration_cost_usd: state.iteration_cost_usd,
2108
+ iteration_total_cost_usd: state.total_cost_usd
2109
+ });
2045
2110
  }
2046
- function finalize_critic(state, result, model, mark) {
2047
- const next = accumulate(state, "critic", result, model);
2111
+ function run_result_from_state(config, state, status, completed_at) {
2048
2112
  return {
2049
- ...next,
2050
- critic: next.critic === null ? null : {
2051
- ...next.critic,
2052
- retries: mark.retries,
2053
- ...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
2054
- ...mark.critic_degraded === true ? { critic_degraded: true } : {}
2055
- },
2056
- verdict: result.content.verdict,
2057
- feedback: result.content.feedback,
2058
- unmet_criteria: result.content.unmet_criteria
2113
+ run_id: config.run_id,
2114
+ status,
2115
+ started_at: config.started_at,
2116
+ completed_at,
2117
+ iterations_completed: state.iteration,
2118
+ total_usage: state.total_usage,
2119
+ total_cost_usd: state.total_cost_usd,
2120
+ builder_cost_usd: state.builder_cost_usd,
2121
+ critic_cost_usd: state.critic_cost_usd,
2122
+ check_duration_ms: state.check_duration_ms,
2123
+ final_verdict: state.verdict,
2124
+ // The loop itself never salvages: the orchestrator re-stamps this after
2125
+ // teardown when a converged `--worktree` run's work was kept on its branch,
2126
+ // which is the only moment the branch's survival is known.
2127
+ salvaged_branch: null
2059
2128
  };
2060
2129
  }
2061
- function toolless_critic_prompt(config, tool_prompt) {
2062
- return [
2063
- tool_prompt,
2064
- "",
2065
- "NOTE: file-read tools are unavailable for this review. Judge from the",
2066
- "acceptance criteria, the deterministic check output above, and the workspace",
2067
- "file inventory below (paths and sizes only \u2014 file contents are not shown).",
2068
- "",
2069
- "WORKSPACE FILE INVENTORY",
2070
- "------------------------",
2071
- workspace_inventory(build_root(config.workspace, config.worktree))
2072
- ].join("\n");
2130
+
2131
+ // src/summary.ts
2132
+ import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync3, writeFileSync as writeFileSync4 } from "fs";
2133
+ function transport_of(provider) {
2134
+ return provider === "claude_cli" ? "claude_cli" : "ai_sdk";
2073
2135
  }
2074
- var CANARY_PROMPT = [
2075
- "CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
2076
- 'First, call the read_file tool on the path "package.json".',
2077
- "Whatever the tool returns (content or an error), immediately answer with",
2078
- 'verdict "approved", feedback exactly "canary", and empty unmet_criteria.'
2079
- ].join("\n");
2080
- var CANARY_TOOLLESS_PROMPT = [
2081
- "CANARY CHECK \u2014 a tiny wiring probe, not a real review.",
2082
- 'Answer with verdict "approved", feedback exactly "canary", and empty',
2083
- "unmet_criteria."
2084
- ].join("\n");
2085
- async function run_critic(deps, state, ctx) {
2086
- const { engine, config } = deps;
2087
- if (state.check === null) {
2088
- throw phase_error("critic", state.iteration, new Error("check phase did not run"));
2089
- }
2090
- const check = state.check;
2091
- try {
2092
- if (config.critic_provider === "ollama") {
2093
- await prewarm_ollama_model(
2094
- resolve_ollama_base_url(process.env),
2095
- config.critic_model,
2096
- ctx.abort
2097
- );
2098
- }
2099
- const base = {
2100
- provider: config.critic_provider,
2101
- model: config.critic_model,
2102
- system: resolve_critic_prompt(config),
2103
- schema: verdict_schema,
2104
- abort: ctx.abort,
2105
- trajectory: ctx.trajectory,
2106
- on_chunk: deps.on_chunk
2107
- };
2108
- const tool_prompt = compose_critic_prompt({
2109
- criteria: config.criteria,
2110
- iteration: state.iteration,
2111
- check,
2112
- changes: state.changes
2113
- });
2114
- let retry_cause_kind;
2115
- for (let attempt = 0; attempt <= MAX_CRITIC_RETRIES; attempt += 1) {
2116
- try {
2117
- const result2 = await engine.generate({
2118
- ...base,
2119
- prompt: tool_prompt,
2120
- ...critic_tool_options(config)
2121
- });
2122
- return finalize_critic(state, result2, config.critic_model, {
2123
- retries: attempt,
2124
- retry_cause_kind
2125
- });
2126
- } catch (err) {
2127
- if (!is_retryable_critic_error(config, ctx, err)) throw err;
2128
- retry_cause_kind = retry_cause_kind_of(err);
2129
- }
2136
+ function read_iteration_archives(workspace) {
2137
+ const root = volley_path(workspace, "iterations");
2138
+ if (!existsSync9(root)) return [];
2139
+ const dirs = readdirSync3(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
2140
+ const archives = [];
2141
+ for (const dir of dirs) {
2142
+ const path = volley_path(workspace, "iterations", dir, "summary.json");
2143
+ if (!existsSync9(path)) continue;
2144
+ try {
2145
+ archives.push(JSON.parse(readFileSync9(path, "utf8")));
2146
+ } catch {
2130
2147
  }
2131
- const result = await engine.generate({
2132
- ...base,
2133
- prompt: toolless_critic_prompt(config, tool_prompt)
2134
- });
2135
- return finalize_critic(state, result, config.critic_model, {
2136
- retries: MAX_CRITIC_RETRIES,
2137
- retry_cause_kind,
2138
- critic_degraded: true
2139
- });
2140
- } catch (err) {
2141
- throw phase_error("critic", state.iteration, err);
2142
2148
  }
2149
+ return archives;
2143
2150
  }
2144
-
2145
- // src/orchestrator.ts
2146
- function initial_state() {
2151
+ function check_trajectory(archives) {
2152
+ return archives.map((archive, index) => ({
2153
+ iteration: typeof archive.iteration === "number" ? archive.iteration : index + 1,
2154
+ ran: archive.check?.ran === true,
2155
+ ok: archive.check?.ok === true,
2156
+ failing_slots: archive.check?.failing_slots ?? []
2157
+ }));
2158
+ }
2159
+ function any_critic_degraded(archives) {
2160
+ return archives.some((archive) => archive.critic?.critic_degraded === true);
2161
+ }
2162
+ function all_gate_edits(archives) {
2163
+ const edits = /* @__PURE__ */ new Set();
2164
+ for (const archive of archives) {
2165
+ for (const path of archive.changes?.gate_edits ?? []) edits.add(path);
2166
+ }
2167
+ return [...edits].toSorted();
2168
+ }
2169
+ function salvage_stats(archives) {
2170
+ let tool_calls = 0;
2171
+ let salvaged_tool_calls = 0;
2172
+ for (const archive of archives) {
2173
+ tool_calls += archive.builder?.tool_calls ?? 0;
2174
+ salvaged_tool_calls += archive.builder?.salvaged_tool_calls ?? 0;
2175
+ }
2147
2176
  return {
2148
- iteration: 0,
2149
- iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
2150
- feedback: null,
2151
- verdict: null,
2152
- unmet_criteria: [],
2153
- check: null,
2154
- changes: null,
2155
- builder: null,
2156
- critic: null,
2157
- total_usage: EMPTY_USAGE,
2158
- total_cost_usd: 0,
2159
- builder_cost_usd: 0,
2160
- critic_cost_usd: 0,
2161
- check_duration_ms: 0,
2162
- iteration_cost_usd: 0,
2163
- halt: null,
2164
- cost_warned: false
2177
+ tool_calls,
2178
+ salvaged_tool_calls,
2179
+ rate: tool_calls === 0 ? 0 : salvaged_tool_calls / tool_calls
2165
2180
  };
2166
2181
  }
2167
- function gate_edit_halt(config, state) {
2168
- return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
2169
- }
2170
- function gate(config, state) {
2171
- const gate_edit = gate_edit_halt(config, state);
2172
- const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
2173
- const cap = cost_cap_hit(config, state);
2174
- const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
2182
+ function build_run_summary(config, result) {
2183
+ const archives = read_iteration_archives(config.workspace);
2184
+ const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
2175
2185
  return {
2176
- stop: success || cap || gate_edit,
2177
- state: { ...state, halt }
2186
+ ...result,
2187
+ comparison: {
2188
+ builder_transport: transport_of(config.builder_provider),
2189
+ critic_transport: transport_of(config.critic_provider),
2190
+ iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
2191
+ wall_clock_ms,
2192
+ total_cost_usd: result.total_cost_usd,
2193
+ builder_cost_usd: result.builder_cost_usd,
2194
+ critic_cost_usd: result.critic_cost_usd,
2195
+ final_verdict: result.final_verdict,
2196
+ check_trajectory: check_trajectory(archives),
2197
+ gate_edits: all_gate_edits(archives),
2198
+ unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
2199
+ local_salvage: salvage_stats(archives),
2200
+ critic_degraded: any_critic_degraded(archives)
2201
+ }
2178
2202
  };
2179
2203
  }
2180
- function status_of(value, converged) {
2181
- if (value.halt === "gate_edit") return "gate_edit_blocked";
2182
- if (value.halt === "cost_cap") return "cost_cap_reached";
2183
- return converged ? "success" : "budget_exhausted";
2204
+ function write_run_summary(config, result) {
2205
+ writeFileSync4(
2206
+ volley_path(config.workspace, "summary.json"),
2207
+ `${JSON.stringify(build_run_summary(config, result), null, 2)}
2208
+ `
2209
+ );
2184
2210
  }
2211
+
2212
+ // src/phases.ts
2185
2213
  var LISTED_GATE_EDITS = 5;
2186
2214
  function report_changes(config, renderer, changes) {
2187
2215
  if (changes === null) return;
@@ -2207,6 +2235,164 @@ async function execute_check(config, abort) {
2207
2235
  }
2208
2236
  return skipped_check("none");
2209
2237
  }
2238
+ function next_iteration(s) {
2239
+ return {
2240
+ ...s,
2241
+ iteration: s.iteration + 1,
2242
+ iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
2243
+ iteration_cost_usd: 0,
2244
+ builder: null,
2245
+ critic: null,
2246
+ check: null,
2247
+ verdict: null,
2248
+ unmet_criteria: []
2249
+ };
2250
+ }
2251
+ async function build_phase(deps, builder, s, ctx) {
2252
+ const { config, renderer } = deps;
2253
+ const next = next_iteration(s);
2254
+ renderer.phase_start(next.iteration, "builder");
2255
+ const built = await run_builder({ builder, config, warn: renderer.warn }, next, ctx);
2256
+ renderer.phase_end(built.iteration, "builder", true);
2257
+ renderer.cost_line(
2258
+ built.iteration,
2259
+ "builder",
2260
+ built.builder?.cost_usd ?? null,
2261
+ built.total_cost_usd
2262
+ );
2263
+ if (built.cost_warned && !s.cost_warned) {
2264
+ renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
2265
+ }
2266
+ const changes = collect_changes({
2267
+ root: build_root(config.workspace, config.worktree),
2268
+ baseline: deps.baseline,
2269
+ gate_patterns: config.gate_paths
2270
+ });
2271
+ report_changes(config, renderer, changes);
2272
+ if (config.git_checkpoints) {
2273
+ git_checkpoint(
2274
+ build_root(config.workspace, config.worktree),
2275
+ `volley iter ${String(built.iteration)}: build`
2276
+ );
2277
+ }
2278
+ return { ...built, changes };
2279
+ }
2280
+ function skip_verify_phase(deps, s) {
2281
+ deps.renderer.warn("cost cap crossed during build; skipping check and critic");
2282
+ return { ...s, check: skipped_check("cost_cap") };
2283
+ }
2284
+ async function check_phase(deps, s, ctx) {
2285
+ const { config, renderer } = deps;
2286
+ renderer.phase_start(s.iteration, "check");
2287
+ try {
2288
+ const result = await execute_check(config, ctx.abort);
2289
+ if (result.ran) {
2290
+ renderer.phase_end(
2291
+ s.iteration,
2292
+ "check",
2293
+ result.ok,
2294
+ result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0
2295
+ );
2296
+ } else {
2297
+ renderer.info("no deterministic check configured; loop is critic-gated only");
2298
+ }
2299
+ return {
2300
+ ...s,
2301
+ check: result,
2302
+ check_duration_ms: s.check_duration_ms + result.duration_ms
2303
+ };
2304
+ } catch (err) {
2305
+ if (error_kind(err) === "check_error") throw err;
2306
+ throw phase_error("check", s.iteration, err);
2307
+ }
2308
+ }
2309
+ async function critique_phase(deps, critic, s, ctx) {
2310
+ const { renderer } = deps;
2311
+ renderer.phase_start(s.iteration, "critic");
2312
+ const critiqued = await run_critic({ critic, config: deps.config }, s, ctx);
2313
+ renderer.phase_end(
2314
+ critiqued.iteration,
2315
+ "critic",
2316
+ critiqued.verdict === "approved",
2317
+ critiqued.verdict ?? void 0
2318
+ );
2319
+ renderer.cost_line(
2320
+ critiqued.iteration,
2321
+ "critic",
2322
+ critiqued.critic?.cost_usd ?? null,
2323
+ critiqued.total_cost_usd
2324
+ );
2325
+ return critiqued;
2326
+ }
2327
+ function record_phase(deps, s) {
2328
+ const { config } = deps;
2329
+ archive_iteration(config, s);
2330
+ write_run_summary(config, run_result_from_state(config, s, "running", null));
2331
+ if (config.git_checkpoints) {
2332
+ git_checkpoint(
2333
+ build_root(config.workspace, config.worktree),
2334
+ `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`
2335
+ );
2336
+ }
2337
+ return s;
2338
+ }
2339
+
2340
+ // src/flow.ts
2341
+ function build_critic(deps) {
2342
+ const retryable = critic_retryable(deps.config);
2343
+ return fallback(
2344
+ retry(make_critic_tools_step(deps), {
2345
+ max_attempts: MAX_CRITIC_RETRIES + 1,
2346
+ backoff_ms: 0,
2347
+ when: retryable,
2348
+ project: with_retries
2349
+ }),
2350
+ pipe(make_critic_toolless_step(deps), as_degraded),
2351
+ {
2352
+ name: "critic",
2353
+ when: retryable,
2354
+ handoff: (prompt) => toolless_critic_prompt(deps.config, prompt),
2355
+ project: with_fallback_cause
2356
+ }
2357
+ );
2358
+ }
2359
+ function build_flow(deps, resume_from) {
2360
+ const { config, renderer } = deps;
2361
+ const builder = make_builder_step({
2362
+ engine: deps.engine,
2363
+ config,
2364
+ on_chunk: renderer.builder_chunk,
2365
+ bash_executor: deps.bash_executor
2366
+ });
2367
+ const critic = build_critic({ engine: deps.engine, config, on_chunk: renderer.critic_chunk });
2368
+ const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx));
2369
+ const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s));
2370
+ const check = step("check", (s, ctx) => check_phase(deps, s, ctx));
2371
+ const critique = step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx));
2372
+ const record = step("record", (s) => record_phase(deps, s));
2373
+ const verify = branch({
2374
+ name: "verify",
2375
+ when: (s) => cost_cap_hit(config, s),
2376
+ // fascicle names this arm `then`. It holds a Step object, never a function,
2377
+ // so nothing can mistake the config for a thenable.
2378
+ // oxlint-disable-next-line unicorn/no-thenable
2379
+ then: skip_verify,
2380
+ otherwise: sequence([check, critique])
2381
+ });
2382
+ const iteration = sequence([build, verify, record], {
2383
+ name: "iteration"
2384
+ });
2385
+ return loop({
2386
+ name: "volley",
2387
+ init: (input) => input.resume_from ?? initial_state(),
2388
+ body: iteration,
2389
+ guard: step("gate", (s) => gate(config, s)),
2390
+ finish: (s, { converged }) => ({ value: s, converged }),
2391
+ max_rounds: remaining_iterations(config, resume_from)
2392
+ });
2393
+ }
2394
+
2395
+ // src/orchestrator.ts
2210
2396
  async function run_volley(config, deps, resume_from = null) {
2211
2397
  const { renderer } = deps;
2212
2398
  if (resume_from === null) {
@@ -2216,13 +2402,13 @@ async function run_volley(config, deps, resume_from = null) {
2216
2402
  initialize_workspace(config.workspace, { preserve: true });
2217
2403
  }
2218
2404
  report_worktree_fate(config, renderer);
2219
- const branch = worktree_branch(config.run_id);
2405
+ const branch2 = worktree_branch(config.run_id);
2220
2406
  const fate = worktree_fate(config);
2221
2407
  const outcome = await with_worktree(
2222
2408
  {
2223
2409
  enabled: config.worktree,
2224
2410
  workspace: config.workspace,
2225
- branch,
2411
+ branch: branch2,
2226
2412
  log: (message) => renderer.info(message),
2227
2413
  // The one case teardown must keep rather than force-delete: a converged run
2228
2414
  // that integrated nothing, whose only copy of its work is the worktree.
@@ -2232,15 +2418,15 @@ async function run_volley(config, deps, resume_from = null) {
2232
2418
  const baseline = capture_baseline(build_root(config.workspace, config.worktree));
2233
2419
  const result = await run_loop(config, deps, resume_from, null, baseline);
2234
2420
  if (fate === "integrate" && result.status === "success") {
2235
- integrate_worktree(config.workspace, branch, `volley run ${config.run_id}: integrate worktree (squash)`);
2236
- renderer.info(`worktree: squash-merged ${branch} onto the workspace branch`);
2421
+ integrate_worktree(config.workspace, branch2, `volley run ${config.run_id}: integrate worktree (squash)`);
2422
+ renderer.info(`worktree: squash-merged ${branch2} onto the workspace branch`);
2237
2423
  }
2238
2424
  return result;
2239
2425
  }
2240
2426
  );
2241
2427
  if (outcome.kept_path !== null) {
2242
2428
  renderer.warn(
2243
- `worktree: could not commit this run's work onto ${branch}, so the checkout at ${outcome.kept_path} was left standing rather than deleted \u2014 its files are intact, and the next --worktree run rotates the directory aside instead of destroying it.`
2429
+ `worktree: could not commit this run's work onto ${branch2}, so the checkout at ${outcome.kept_path} was left standing rather than deleted \u2014 its files are intact, and the next --worktree run rotates the directory aside instead of destroying it.`
2244
2430
  );
2245
2431
  }
2246
2432
  if (outcome.salvaged_branch === null) return outcome.value;
@@ -2258,119 +2444,7 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
2258
2444
  builder_provider: config.builder_provider,
2259
2445
  critic_provider: config.critic_provider
2260
2446
  });
2261
- const build = step("build", async (s, ctx) => {
2262
- const next = {
2263
- ...s,
2264
- iteration: s.iteration + 1,
2265
- iteration_started_at: (/* @__PURE__ */ new Date()).toISOString(),
2266
- iteration_cost_usd: 0,
2267
- builder: null,
2268
- critic: null,
2269
- check: null,
2270
- verdict: null,
2271
- unmet_criteria: []
2272
- };
2273
- renderer.phase_start(next.iteration, "builder");
2274
- const built = await run_builder(
2275
- { engine, config, on_chunk: renderer.builder_chunk, warn: renderer.warn, bash_executor },
2276
- next,
2277
- ctx
2278
- );
2279
- renderer.phase_end(built.iteration, "builder", true);
2280
- renderer.cost_line(
2281
- built.iteration,
2282
- "builder",
2283
- built.builder?.cost_usd ?? null,
2284
- built.total_cost_usd
2285
- );
2286
- if (built.cost_warned && !s.cost_warned) {
2287
- renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
2288
- }
2289
- const changes = collect_changes({
2290
- root: build_root(config.workspace, config.worktree),
2291
- baseline,
2292
- gate_patterns: config.gate_paths
2293
- });
2294
- report_changes(config, renderer, changes);
2295
- if (config.git_checkpoints) {
2296
- git_checkpoint(
2297
- build_root(config.workspace, config.worktree),
2298
- `volley iter ${String(built.iteration)}: build`
2299
- );
2300
- }
2301
- return { ...built, changes };
2302
- });
2303
- const check = step("check", async (s, ctx) => {
2304
- if (cost_cap_hit(config, s)) {
2305
- renderer.warn("cost cap crossed during build; skipping check and critic");
2306
- return { ...s, check: skipped_check("cost_cap") };
2307
- }
2308
- renderer.phase_start(s.iteration, "check");
2309
- try {
2310
- const result = await execute_check(config, ctx.abort);
2311
- if (result.ran) {
2312
- renderer.phase_end(
2313
- s.iteration,
2314
- "check",
2315
- result.ok,
2316
- result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0
2317
- );
2318
- } else {
2319
- renderer.info("no deterministic check configured; loop is critic-gated only");
2320
- }
2321
- return {
2322
- ...s,
2323
- check: result,
2324
- check_duration_ms: s.check_duration_ms + result.duration_ms
2325
- };
2326
- } catch (err) {
2327
- if (error_kind(err) === "check_error") throw err;
2328
- throw phase_error("check", s.iteration, err);
2329
- }
2330
- });
2331
- const critique = step("critique", async (s, ctx) => {
2332
- if (s.check !== null && !s.check.ran && cost_cap_hit(config, s)) {
2333
- return s;
2334
- }
2335
- renderer.phase_start(s.iteration, "critic");
2336
- const critiqued = await run_critic(
2337
- { engine, config, on_chunk: renderer.critic_chunk },
2338
- s,
2339
- ctx
2340
- );
2341
- renderer.phase_end(
2342
- critiqued.iteration,
2343
- "critic",
2344
- critiqued.verdict === "approved",
2345
- critiqued.verdict ?? void 0
2346
- );
2347
- renderer.cost_line(
2348
- critiqued.iteration,
2349
- "critic",
2350
- critiqued.critic?.cost_usd ?? null,
2351
- critiqued.total_cost_usd
2352
- );
2353
- return critiqued;
2354
- });
2355
- const record = step("record", (s) => {
2356
- archive_iteration(config, s);
2357
- write_run_summary(config, run_result_from_state(config, s, "running", null));
2358
- if (config.git_checkpoints) {
2359
- git_checkpoint(
2360
- build_root(config.workspace, config.worktree),
2361
- `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`
2362
- );
2363
- }
2364
- return s;
2365
- });
2366
- const flow = loop({
2367
- name: "volley",
2368
- init: (input) => input.resume_from ?? initial_state(),
2369
- body: sequence([build, check, critique, record]),
2370
- guard: step("gate", (s) => gate(config, s)),
2371
- finish: (s) => s,
2372
- max_rounds: config.max_iterations - (resume_from?.iteration ?? 0)
2373
- });
2447
+ const flow = build_flow({ engine, config, renderer, bash_executor, baseline }, resume_from);
2374
2448
  try {
2375
2449
  const { value, converged } = await run(
2376
2450
  flow,