tend-cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { ClaudeSession, EFFORT_LEVELS, EventBus, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildDiff, buildProgram, changedVsHead, createGit, detectBuildCommand, detectPackageManager, filesUnder, filterToChanged, formatClock, gateUnitChanges, loadConfig, makeDeterministicFixUnit, makeTheme, orchestrate, planRepair, planWorkFromRepairs, reasonLabel, renderSummary, resolveRetryTarget, restoreSnapshot, retryCommand, runEslintSonarjs, runScanner, scannerStatus, showCommand, snapshotUnitFiles, snapshotUnitNow, toRepoRelative, unitChanged, zeroUsage } from "./config-Dz4byqjU.js";
2
+ import { ClaudeSession, EFFORT_LEVELS, EventBus, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, assertGitRepo, buildDiff, buildProgram, changedVsHead, createGit, detectBuildCommand, detectPackageManager, filesUnder, filterToChanged, formatClock, gateUnitChanges, loadConfig, makeDeterministicFixUnit, makeTheme, orchestrate, planRepair, planWorkFromRepairs, reasonLabel, renderSummary, resolveRetryTarget, restoreSnapshot, retryCommand, runEslintSonarjs, runScanner, scannerStatus, showCommand, snapshotUnitFiles, snapshotUnitNow, toRepoRelative, unitChanged, zeroUsage } from "./config-BeA71px2.js";
3
3
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { execa } from "execa";
6
+ import PQueue from "p-queue";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { tmpdir } from "node:os";
8
9
  import { createRequire } from "node:module";
@@ -390,13 +391,51 @@ async function runScanners(deps, files, loop) {
390
391
  files,
391
392
  loop
392
393
  };
393
- const spawned = await Promise.all(SPAWN_SCANNERS.map((scanner) => runScanner(scanner, ctx, {
394
- which: deps.which,
395
- spawn: deps.spawn,
396
- timeout: deps.timeoutMs
397
- })));
398
- const eslint = await runEslintSonarjs(ctx);
399
- const results = [...spawned, eslint];
394
+ const requested = deps.tools ? new Set(deps.tools) : void 0;
395
+ const shouldRun = (tool) => requested === void 0 || requested.has(tool);
396
+ const runWithEvents = async (scanner) => {
397
+ deps.bus?.emit({
398
+ type: "scanner-start",
399
+ loop,
400
+ tool: scanner.tool
401
+ });
402
+ const result = await runScanner(scanner, ctx, {
403
+ which: deps.which,
404
+ spawn: deps.spawn,
405
+ timeout: deps.timeoutMs
406
+ });
407
+ const status = scannerStatus(result);
408
+ deps.bus?.emit({
409
+ type: "scanner-result",
410
+ loop,
411
+ tool: scanner.tool,
412
+ status: status.status,
413
+ findings: result.findings.length,
414
+ reason: status.reason
415
+ });
416
+ return result;
417
+ };
418
+ const spawnedPromise = Promise.all(SPAWN_SCANNERS.filter((scanner) => shouldRun(scanner.tool)).map(runWithEvents));
419
+ const eslintPromise = shouldRun("sonarjs") ? (async () => {
420
+ deps.bus?.emit({
421
+ type: "scanner-start",
422
+ loop,
423
+ tool: "sonarjs"
424
+ });
425
+ const result = await runEslintSonarjs(ctx);
426
+ const status = scannerStatus(result);
427
+ deps.bus?.emit({
428
+ type: "scanner-result",
429
+ loop,
430
+ tool: "sonarjs",
431
+ status: status.status,
432
+ findings: result.findings.length,
433
+ reason: status.reason
434
+ });
435
+ return result;
436
+ })() : Promise.resolve(void 0);
437
+ const [spawned, eslint] = await Promise.all([spawnedPromise, eslintPromise]);
438
+ const results = eslint ? [...spawned, eslint] : spawned;
400
439
  return {
401
440
  results,
402
441
  scannerStatuses: results.map(scannerStatus)
@@ -729,8 +768,8 @@ function renderPrompt(unit) {
729
768
  });
730
769
  }
731
770
  function firstRelevantLines(output, max = 20) {
732
- const lines = output.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
733
- return lines.slice(0, max).join("\n") || "(none)";
771
+ const lines$1 = output.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
772
+ return lines$1.slice(0, max).join("\n") || "(none)";
734
773
  }
735
774
  function renderFindingsJson(findings) {
736
775
  const data = findings.map((f) => ({
@@ -789,12 +828,21 @@ function classFromOutcome(reason, fallback) {
789
828
  * session error reverts the files to the snapshot. Nothing changed → not a fix.
790
829
  */
791
830
  function makeFixUnit(deps) {
792
- return async (unit) => {
831
+ return async (unit, loop = 0) => {
832
+ const progress = (stage, detail) => {
833
+ deps.onProgress?.({
834
+ loop,
835
+ file: unit.file,
836
+ stage,
837
+ detail
838
+ });
839
+ };
793
840
  const snapshotFiles = unit.strategy === "generated-source-repair" ? [...new Set([...unit.files, ...unit.verificationTargets ?? []])] : unit.files;
794
841
  const before = snapshotUnitFiles(deps.cwd, snapshotFiles);
795
842
  const restore = () => restoreSnapshot(deps.cwd, before);
796
843
  const changedOnDisk = () => unitChanged(deps.cwd, unit.files, before);
797
844
  let usage = zeroUsage();
845
+ progress("ai-edit");
798
846
  const res = await deps.session.run({
799
847
  file: unit.file,
800
848
  findings: unit.findings,
@@ -812,6 +860,7 @@ function makeFixUnit(deps) {
812
860
  };
813
861
  }
814
862
  if (!changedOnDisk()) {
863
+ progress("ai-no-edit-retry");
815
864
  const retry = await deps.session.run({
816
865
  file: unit.file,
817
866
  findings: unit.findings,
@@ -837,14 +886,17 @@ function makeFixUnit(deps) {
837
886
  };
838
887
  }
839
888
  async function scanNewFindings() {
889
+ progress("rescan");
840
890
  const verificationTargets = unit.verificationTargets ?? unit.files;
841
- const afterFindings = await deps.scanFindings(verificationTargets);
891
+ const scannerTools = [...new Set(unit.findings.map((finding) => finding.tool))];
892
+ const afterFindings = await deps.scanFindings(verificationTargets, scannerTools);
842
893
  const originalIds = new Set(unit.findings.map((f) => f.id));
843
894
  return afterFindings.filter((f) => !originalIds.has(f.id));
844
895
  }
845
896
  async function runRegressionRepair(outcome$1) {
846
897
  if (outcome$1.reason !== "regression" && outcome$1.reason !== "typecheck") return false;
847
898
  const after = snapshotUnitNow(deps.cwd, snapshotFiles);
899
+ progress("regression-repair");
848
900
  const repair = await deps.session.run({
849
901
  file: unit.file,
850
902
  findings: unit.findings,
@@ -867,6 +919,7 @@ function makeFixUnit(deps) {
867
919
  async function gateCurrent() {
868
920
  return gateUnitChanges(unit, before, deps, {
869
921
  usage,
922
+ onProgress: progress,
870
923
  repair: async (_attempt, regressed) => {
871
924
  const after = snapshotUnitNow(deps.cwd, snapshotFiles);
872
925
  const repair = await deps.session.run({
@@ -904,6 +957,288 @@ function makeFixUnit(deps) {
904
957
  };
905
958
  }
906
959
 
960
+ //#endregion
961
+ //#region src/fixing/thinking-budget.ts
962
+ /** Thinking disabled — mechanical fixes don't need reasoning tokens. */
963
+ const THINKING_OFF = 0;
964
+ /**
965
+ * Upper bound on extended-thinking tokens per fix session. Unbounded thinking
966
+ * measured ~7000 tokens per finding regardless of difficulty; capping it keeps
967
+ * reasoning fixes correct (gate stays green) at a fraction of the latency.
968
+ */
969
+ const THINKING_BUDGET_CAP = 4096;
970
+ function isMechanical(finding) {
971
+ return finding.category === "dead-code" || finding.autofixable === true;
972
+ }
973
+ /**
974
+ * Decide the extended-thinking token budget for one finding's fix session.
975
+ * Mechanical findings (dead-code removal, autofixable rules) get thinking off;
976
+ * everything else — reasoning findings and any unrecognized category — gets the
977
+ * bounded cap (the safe default: never spend more than the cap, never starve a
978
+ * finding that might need reasoning). A configured `thinkingBudget` overrides
979
+ * the policy outright, with 0 meaning thinking off.
980
+ */
981
+ function thinkingBudgetFor(finding, config) {
982
+ if (config?.thinkingBudget !== void 0) return config.thinkingBudget;
983
+ return isMechanical(finding) ? THINKING_OFF : THINKING_BUDGET_CAP;
984
+ }
985
+ /**
986
+ * Budget for a whole work unit (one file's findings). Takes the most-conservative
987
+ * (largest) per-finding budget so a reasoning finding is never starved of thinking
988
+ * just because it shares the file with a mechanical one. An empty unit and any
989
+ * unrecognized category fall back to the cap. A configured budget overrides all.
990
+ */
991
+ function thinkingBudgetForUnit(findings, config) {
992
+ if (config?.thinkingBudget !== void 0) return config.thinkingBudget;
993
+ if (findings.length === 0) return THINKING_BUDGET_CAP;
994
+ return Math.max(...findings.map((finding) => thinkingBudgetFor(finding)));
995
+ }
996
+ /**
997
+ * Delivery to the `claude -p` session boundary: the env overlay that pins the
998
+ * session's extended-thinking budget. Spread onto the child process env.
999
+ */
1000
+ function thinkingEnv(findings, config) {
1001
+ return { MAX_THINKING_TOKENS: String(thinkingBudgetForUnit(findings, config)) };
1002
+ }
1003
+
1004
+ //#endregion
1005
+ //#region src/fixing/worker-sandbox.ts
1006
+ var SandboxSetupError = class extends Error {
1007
+ constructor(message) {
1008
+ super(message);
1009
+ this.name = "SandboxSetupError";
1010
+ }
1011
+ };
1012
+ const cleanExcludes = [
1013
+ "node_modules",
1014
+ "node_modules/**",
1015
+ "**/node_modules",
1016
+ "**/node_modules/**"
1017
+ ];
1018
+ function normalizeRel(path) {
1019
+ return path.replaceAll("\\", "/").replace(/^\.?\//, "");
1020
+ }
1021
+ function lines(raw) {
1022
+ return raw.split("\n").map((line) => normalizeRel(line.trim())).filter(Boolean);
1023
+ }
1024
+ function unique(values) {
1025
+ return [...new Set(values.map(normalizeRel))];
1026
+ }
1027
+ function isGeneratedRepair(unit) {
1028
+ return unit.strategy === "generated-source-repair" || unit.strategies?.includes("generated-source-repair") === true;
1029
+ }
1030
+ function allowedPatchFiles(unit) {
1031
+ return unique([...unit.files, ...isGeneratedRepair(unit) ? unit.verificationTargets ?? [] : []]);
1032
+ }
1033
+ function installArgs(pm) {
1034
+ switch (pm) {
1035
+ case "pnpm": return [
1036
+ "install",
1037
+ "--frozen-lockfile",
1038
+ "--prefer-offline"
1039
+ ];
1040
+ case "yarn": return [
1041
+ "install",
1042
+ "--frozen-lockfile",
1043
+ "--prefer-offline"
1044
+ ];
1045
+ case "bun": return ["install", "--frozen-lockfile"];
1046
+ case "npm": return ["ci", "--prefer-offline"];
1047
+ }
1048
+ }
1049
+ var GitWorkerSandbox = class {
1050
+ prepared = false;
1051
+ constructor(cwd$1, deps) {
1052
+ this.cwd = cwd$1;
1053
+ this.deps = deps;
1054
+ }
1055
+ async reset() {
1056
+ const git = createGit(this.cwd);
1057
+ await git.raw([
1058
+ "reset",
1059
+ "--hard",
1060
+ this.deps.snapshotSha
1061
+ ]);
1062
+ await git.raw([
1063
+ "clean",
1064
+ "-ffdx",
1065
+ ...cleanExcludes.flatMap((pattern) => ["-e", pattern])
1066
+ ]);
1067
+ }
1068
+ async prepare() {
1069
+ if (this.prepared || this.deps.prepareDependencies === false) return;
1070
+ const args = installArgs(this.deps.packageManager);
1071
+ const result = await this.deps.exec(this.deps.packageManager, args, {
1072
+ cwd: this.cwd,
1073
+ reject: false,
1074
+ timeout: 10 * 6e4
1075
+ });
1076
+ if ((result.exitCode ?? 1) !== 0) throw new SandboxSetupError(`sandbox dependency install failed: ${result.stderr || result.stdout || `exit ${result.exitCode ?? 1}`}`);
1077
+ this.prepared = true;
1078
+ }
1079
+ async collectPatch(unit) {
1080
+ const git = createGit(this.cwd);
1081
+ const tracked = lines(await git.raw([
1082
+ "diff",
1083
+ "--name-only",
1084
+ this.deps.snapshotSha
1085
+ ]));
1086
+ const untracked = lines(await git.raw([
1087
+ "ls-files",
1088
+ "--others",
1089
+ "--exclude-standard"
1090
+ ]));
1091
+ const changedFiles = unique([...tracked, ...untracked]).sort();
1092
+ const allowed = new Set(allowedPatchFiles(unit));
1093
+ const unowned = changedFiles.filter((file) => !allowed.has(file));
1094
+ if (unowned.length > 0) return {
1095
+ ok: false,
1096
+ reason: "unowned-patch",
1097
+ detail: `Worker modified unowned files: ${unowned.join(", ")}`,
1098
+ changedFiles
1099
+ };
1100
+ const allowedFiles = [...allowed].sort();
1101
+ if (allowedFiles.length === 0 || changedFiles.length === 0) return {
1102
+ ok: true,
1103
+ patch: "",
1104
+ changedFiles
1105
+ };
1106
+ if (untracked.length > 0) await git.raw([
1107
+ "add",
1108
+ "-N",
1109
+ "--",
1110
+ ...untracked.filter((file) => allowed.has(file))
1111
+ ]);
1112
+ const patch = await git.raw([
1113
+ "diff",
1114
+ "--binary",
1115
+ this.deps.snapshotSha,
1116
+ "--",
1117
+ ...allowedFiles
1118
+ ]);
1119
+ return {
1120
+ ok: true,
1121
+ patch,
1122
+ changedFiles
1123
+ };
1124
+ }
1125
+ async dispose() {
1126
+ const git = createGit(this.deps.mainRoot);
1127
+ try {
1128
+ await git.raw([
1129
+ "worktree",
1130
+ "remove",
1131
+ "--force",
1132
+ this.cwd
1133
+ ]);
1134
+ } finally {
1135
+ rmSync(this.cwd, {
1136
+ recursive: true,
1137
+ force: true
1138
+ });
1139
+ }
1140
+ }
1141
+ };
1142
+ var WorkerSandboxPool = class {
1143
+ queue;
1144
+ applyQueue = new PQueue({ concurrency: 1 });
1145
+ idle = [];
1146
+ sandboxes = new Set();
1147
+ counter = 0;
1148
+ disposed = false;
1149
+ exec;
1150
+ constructor(deps) {
1151
+ this.deps = deps;
1152
+ this.queue = new PQueue({ concurrency: deps.maxSandboxes });
1153
+ this.exec = deps.exec ?? execa;
1154
+ }
1155
+ async withSandbox(run) {
1156
+ return this.queue.add(async () => {
1157
+ let sandbox;
1158
+ try {
1159
+ sandbox = await this.acquire();
1160
+ } catch (error) {
1161
+ throw new SandboxSetupError(error instanceof Error ? error.message : String(error));
1162
+ }
1163
+ try {
1164
+ await sandbox.reset();
1165
+ await sandbox.prepare();
1166
+ } catch (error) {
1167
+ this.idle.push(sandbox);
1168
+ throw error instanceof SandboxSetupError ? error : new SandboxSetupError(error instanceof Error ? error.message : String(error));
1169
+ }
1170
+ try {
1171
+ return await run(sandbox);
1172
+ } finally {
1173
+ this.idle.push(sandbox);
1174
+ }
1175
+ });
1176
+ }
1177
+ async applyPatchToMain(patch) {
1178
+ return this.applyQueue.add(async () => {
1179
+ if (patch.trim() === "") return { ok: true };
1180
+ const check = await this.exec("git", [
1181
+ "apply",
1182
+ "--check",
1183
+ "--3way"
1184
+ ], {
1185
+ cwd: this.deps.mainRoot,
1186
+ input: patch,
1187
+ reject: false
1188
+ });
1189
+ if ((check.exitCode ?? 1) !== 0) return {
1190
+ ok: false,
1191
+ reason: "patch-conflict",
1192
+ detail: check.stderr || check.stdout || "git apply --check --3way failed"
1193
+ };
1194
+ const applied = await this.exec("git", ["apply", "--3way"], {
1195
+ cwd: this.deps.mainRoot,
1196
+ input: patch,
1197
+ reject: false
1198
+ });
1199
+ if ((applied.exitCode ?? 1) !== 0) return {
1200
+ ok: false,
1201
+ reason: "patch-conflict",
1202
+ detail: applied.stderr || applied.stdout || "git apply --3way failed"
1203
+ };
1204
+ return { ok: true };
1205
+ });
1206
+ }
1207
+ async dispose() {
1208
+ if (this.disposed) return;
1209
+ this.disposed = true;
1210
+ await this.queue.onIdle();
1211
+ await Promise.all([...this.sandboxes].map((sandbox) => sandbox.dispose()));
1212
+ this.idle.length = 0;
1213
+ this.sandboxes.clear();
1214
+ }
1215
+ async acquire() {
1216
+ const existing = this.idle.pop();
1217
+ if (existing) return existing;
1218
+ const parent = this.deps.tempRoot ?? tmpdir();
1219
+ mkdirSync(parent, { recursive: true });
1220
+ const path = `${parent}/tend-worker-${process.pid}-${this.counter++}`;
1221
+ const mainGit = createGit(this.deps.mainRoot);
1222
+ await mainGit.raw([
1223
+ "worktree",
1224
+ "add",
1225
+ "--detach",
1226
+ path,
1227
+ this.deps.snapshotSha
1228
+ ]);
1229
+ const sandbox = new GitWorkerSandbox(path, {
1230
+ ...this.deps,
1231
+ exec: this.exec
1232
+ });
1233
+ this.sandboxes.add(sandbox);
1234
+ return sandbox;
1235
+ }
1236
+ };
1237
+ function mapOwnerRoot(mainRoot, mainOwnerRoot, sandboxRoot) {
1238
+ const rel = normalizeRel(relative(mainRoot, mainOwnerRoot));
1239
+ return rel === "" ? sandboxRoot : `${sandboxRoot}/${rel}`;
1240
+ }
1241
+
907
1242
  //#endregion
908
1243
  //#region src/output/env.ts
909
1244
  /** Truthy in the env-var sense: present and not an explicit off value. */
@@ -939,6 +1274,28 @@ function detectOutputEnv(input = {}) {
939
1274
  };
940
1275
  }
941
1276
 
1277
+ //#endregion
1278
+ //#region src/fixing/progress.ts
1279
+ const LABELS = {
1280
+ "ai-edit": "AI edit",
1281
+ "ai-no-edit-retry": "AI retry",
1282
+ "anti-suppression": "suppression check",
1283
+ typecheck: "typecheck",
1284
+ build: "build",
1285
+ "related-tests": "related tests",
1286
+ "test-repair": "test repair",
1287
+ rescan: "rescan",
1288
+ "regression-check": "regression check",
1289
+ "regression-repair": "regression repair",
1290
+ "patch-apply": "patch apply",
1291
+ "patch-conflict": "patch conflict",
1292
+ "sandbox-setup": "sandbox setup",
1293
+ "final-integration": "final integration"
1294
+ };
1295
+ function fixStageLabel(stage) {
1296
+ return LABELS[stage];
1297
+ }
1298
+
942
1299
  //#endregion
943
1300
  //#region src/output/base-reporter.ts
944
1301
  var BaseReporter = class {
@@ -987,6 +1344,7 @@ var LiveReporter = class extends BaseReporter {
987
1344
  audits = new Channel();
988
1345
  phases = new Channel();
989
1346
  fixTicks = new Channel();
1347
+ loopCompletions = new Channel();
990
1348
  closed = false;
991
1349
  resolveClosed;
992
1350
  closedSignal = new Promise((resolve$1) => {
@@ -1002,7 +1360,11 @@ var LiveReporter = class extends BaseReporter {
1002
1360
  currentFile;
1003
1361
  currentConcurrency;
1004
1362
  rules = new Map();
1363
+ stages = new Map();
1364
+ scannerStates = new Map();
1365
+ currentScanLoop;
1005
1366
  header;
1367
+ scanHeader;
1006
1368
  labelWidth = 0;
1007
1369
  constructor(deps) {
1008
1370
  super(deps);
@@ -1018,6 +1380,26 @@ var LiveReporter = class extends BaseReporter {
1018
1380
  scanned: event.scanned
1019
1381
  });
1020
1382
  break;
1383
+ case "scan-start":
1384
+ this.currentScanLoop = event.loop;
1385
+ this.scannerStates.clear();
1386
+ this.scanStarts.push(event.loop);
1387
+ this.refreshScanHeader();
1388
+ break;
1389
+ case "scanner-start":
1390
+ if (event.loop !== this.currentScanLoop) break;
1391
+ this.scannerStates.set(event.tool, { status: "running" });
1392
+ this.refreshScanHeader();
1393
+ break;
1394
+ case "scanner-result":
1395
+ if (event.loop !== this.currentScanLoop) break;
1396
+ this.scannerStates.set(event.tool, {
1397
+ status: event.status,
1398
+ findings: event.findings,
1399
+ reason: event.reason
1400
+ });
1401
+ this.refreshScanHeader();
1402
+ break;
1021
1403
  case "loop-start":
1022
1404
  this.currentLoop = event.loop;
1023
1405
  this.fixTotal = event.files.length;
@@ -1029,6 +1411,7 @@ var LiveReporter = class extends BaseReporter {
1029
1411
  this.currentFile = void 0;
1030
1412
  this.currentConcurrency = event.concurrency;
1031
1413
  this.rules.clear();
1414
+ this.stages.clear();
1032
1415
  this.labelWidth = Math.max(0, ...event.files.map((f) => basename(f).length));
1033
1416
  this.phases.push({
1034
1417
  kind: "fix",
@@ -1041,12 +1424,19 @@ var LiveReporter = class extends BaseReporter {
1041
1424
  break;
1042
1425
  case "file-start":
1043
1426
  this.started += 1;
1427
+ this.fixTotal = Math.max(this.fixTotal, this.started);
1044
1428
  this.currentFile = event.file;
1045
1429
  if (event.rule) this.rules.set(event.file, event.rule);
1046
1430
  this.refreshHeader();
1047
1431
  break;
1432
+ case "file-stage":
1433
+ this.currentFile = event.file;
1434
+ this.stages.set(event.file, event.stage);
1435
+ this.refreshHeader();
1436
+ break;
1048
1437
  case "file-result":
1049
1438
  this.finished += 1;
1439
+ this.fixTotal = Math.max(this.fixTotal, this.started, this.finished);
1050
1440
  if (event.outcome === "fixed") this.fixed += 1;
1051
1441
  else if (event.outcome === "reverted") this.reverted += 1;
1052
1442
  else this.notAttempted += 1;
@@ -1054,15 +1444,15 @@ var LiveReporter = class extends BaseReporter {
1054
1444
  this.refreshHeader();
1055
1445
  this.fixTicks.push();
1056
1446
  break;
1447
+ case "loop-complete":
1448
+ this.loopCompletions.push(event.loop);
1449
+ this.refreshHeader();
1450
+ break;
1057
1451
  case "done":
1058
1452
  this.phases.push({ kind: "done" });
1059
1453
  break;
1060
- case "scan-start":
1061
- this.scanStarts.push(event.loop);
1062
- break;
1063
1454
  case "snapshot":
1064
- case "detected":
1065
- case "loop-complete": break;
1455
+ case "detected": break;
1066
1456
  }
1067
1457
  }
1068
1458
  close() {
@@ -1090,6 +1480,7 @@ var LiveReporter = class extends BaseReporter {
1090
1480
  const list = new Listr([{
1091
1481
  title: this.theme.dim("scanning…"),
1092
1482
  task: async (_ctx, task) => {
1483
+ this.scanHeader = task;
1093
1484
  const loop = await this.race(this.scanStarts.take());
1094
1485
  if (loop === CLOSED) {
1095
1486
  live = false;
@@ -1102,6 +1493,7 @@ var LiveReporter = class extends BaseReporter {
1102
1493
  return;
1103
1494
  }
1104
1495
  task.title = this.scannedTitle(audit);
1496
+ this.scanHeader = void 0;
1105
1497
  }
1106
1498
  }], this.listrOptions());
1107
1499
  await list.run();
@@ -1116,10 +1508,11 @@ var LiveReporter = class extends BaseReporter {
1116
1508
  this.currentLoop = info.loop;
1117
1509
  this.currentConcurrency = info.concurrency;
1118
1510
  task.title = this.headerTitle();
1119
- while (this.finished < this.fixTotal) {
1120
- const tick = await this.race(this.fixTicks.take());
1121
- if (tick === CLOSED) return;
1511
+ while (true) {
1512
+ const tickOrComplete = await this.race(Promise.race([this.fixTicks.take().then(() => "tick"), this.loopCompletions.take().then(() => "complete")]));
1513
+ if (tickOrComplete === CLOSED) return;
1122
1514
  task.title = this.headerTitle();
1515
+ if (tickOrComplete === "complete") break;
1123
1516
  }
1124
1517
  }
1125
1518
  }], this.listrOptions());
@@ -1158,7 +1551,17 @@ var LiveReporter = class extends BaseReporter {
1158
1551
  return meta;
1159
1552
  }
1160
1553
  scanTitle(loop) {
1161
- return this.theme.dim(loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${loop - 1}: scanning…`);
1554
+ const detail = this.scannerDetail();
1555
+ return this.theme.dim(loop === 1 ? `initial audit: scanning…${detail}` : `re-audit after fix pass ${loop - 1}: scanning…${detail}`);
1556
+ }
1557
+ scannerDetail() {
1558
+ const entries = [...this.scannerStates.entries()];
1559
+ if (entries.length === 0) return "";
1560
+ const running = entries.filter(([, info]) => info.status === "running");
1561
+ const done = entries.length - running.length;
1562
+ if (running.length === 0) return ` ${this.theme.glyph.bullet} scanners ${done}/${entries.length} done`;
1563
+ const runningTools = running.map(([tool]) => tool).join(", ");
1564
+ return ` ${this.theme.glyph.bullet} running ${runningTools} ${this.theme.glyph.bullet} ${done}/${entries.length} done`;
1162
1565
  }
1163
1566
  headerTitle() {
1164
1567
  const running = Math.max(0, this.started - this.finished);
@@ -1173,12 +1576,17 @@ var LiveReporter = class extends BaseReporter {
1173
1576
  refreshHeader() {
1174
1577
  if (this.header) this.header.title = this.headerTitle();
1175
1578
  }
1579
+ refreshScanHeader() {
1580
+ if (this.scanHeader && this.currentScanLoop !== void 0) this.scanHeader.title = this.scanTitle(this.currentScanLoop);
1581
+ }
1176
1582
  fileLabel(file) {
1177
1583
  return basename(file).padEnd(this.labelWidth);
1178
1584
  }
1179
1585
  fileTitle(file) {
1180
1586
  const rule = this.rules.get(file);
1181
- const suffix = rule ? ` ${this.theme.dim(rule)}` : "";
1587
+ const stage = this.stages.get(file);
1588
+ const detail = [rule, stage ? fixStageLabel(stage) : void 0].filter(Boolean).join(" · ");
1589
+ const suffix = detail ? ` ${this.theme.dim(detail)}` : "";
1182
1590
  return `${this.fileLabel(file)}${suffix}`;
1183
1591
  }
1184
1592
  };
@@ -1200,6 +1608,15 @@ var PlainReporter = class extends BaseReporter {
1200
1608
  case "scan-start":
1201
1609
  this.write(event.loop === 1 ? "initial audit: scanning…" : `re-audit after fix pass ${event.loop - 1}: scanning…`);
1202
1610
  break;
1611
+ case "scanner-start":
1612
+ this.write(`scanner ${event.tool}: running`);
1613
+ break;
1614
+ case "scanner-result": {
1615
+ const count = event.status === "ran" ? ` ${event.findings} findings` : "";
1616
+ const reason = event.reason ? ` — ${event.reason}` : "";
1617
+ this.write(`scanner ${event.tool}: ${event.status}${count}${reason}`);
1618
+ break;
1619
+ }
1203
1620
  case "audit": {
1204
1621
  const scope = event.scanned != null ? `${event.scanned} files eligible for fixes` : "whole repo";
1205
1622
  const phase = event.loop === 1 ? "initial audit" : `re-audit after fix pass ${event.loop - 1}`;
@@ -1214,6 +1631,9 @@ var PlainReporter = class extends BaseReporter {
1214
1631
  else if (event.outcome === "reverted") this.write(`${glyph.reverted} reverted ${event.file} — ${reasonLabel(event.reason)}`);
1215
1632
  else this.write(`${glyph.left} not attempted ${event.file}`);
1216
1633
  break;
1634
+ case "file-stage":
1635
+ this.write(`progress ${event.file}: ${fixStageLabel(event.stage)}${event.detail ? ` (${event.detail})` : ""}`);
1636
+ break;
1217
1637
  case "snapshot":
1218
1638
  case "detected":
1219
1639
  case "file-start":
@@ -1264,8 +1684,8 @@ function loadReport() {
1264
1684
  * executes in). Files are re-based onto `root` so `vitest related` / `jest
1265
1685
  * --findRelatedTests` resolve them inside the owning package, not the repo root.
1266
1686
  */
1267
- async function runTests(runner, files, root) {
1268
- const targets = toOwnerRelative(files, cwd, root);
1687
+ async function runTests(runner, files, root, repoRoot = cwd) {
1688
+ const targets = toOwnerRelative(files, repoRoot, root);
1269
1689
  const args = runner === "vitest" ? [
1270
1690
  "vitest",
1271
1691
  "related",
@@ -1295,79 +1715,185 @@ async function runTests(runner, files, root) {
1295
1715
  return [];
1296
1716
  }
1297
1717
  }
1298
- async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd) {
1299
- const typescript = detectTypeScript(ownerRoot);
1300
- const runner = detectTestRunner(ownerRoot) ?? null;
1718
+ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, bus, detected, sandboxPool) {
1719
+ const typescript = detected?.typescript ?? detectTypeScript(ownerRoot);
1720
+ const runner = detected?.runner ?? detectTestRunner(ownerRoot) ?? null;
1301
1721
  const buildArgs = detectBuildCommand(ownerRoot);
1302
1722
  const pm = detectPackageManager(ownerRoot);
1303
- const baseline = new Set(runner && baselineTargets.length > 0 ? (await runTests(runner, baselineTargets, ownerRoot)).filter((t) => t.status === "pass").map((t) => t.name) : []);
1304
- const session = new ClaudeSession({ spawn: async (req) => {
1305
- const r = await execa("claude", [
1306
- "-p",
1307
- req.prompt,
1308
- "--model",
1309
- config.model,
1310
- ...config.effort ? ["--effort", config.effort] : [],
1311
- "--output-format",
1312
- "stream-json",
1313
- "--verbose",
1314
- "--allowedTools",
1315
- "Read,Write,Edit"
1316
- ], {
1317
- cwd,
1318
- reject: false,
1319
- timeout: CLAUDE_TIMEOUT_MS
1320
- });
1321
- const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1322
- return {
1323
- stdout: typeof r.stdout === "string" ? r.stdout : "",
1324
- exitCode
1325
- };
1326
- } });
1327
- const gateDeps = {
1328
- cwd,
1329
- typescript,
1330
- runTsc: async () => {
1331
- const r = await execa("npx", ["tsc", "--noEmit"], {
1332
- cwd: ownerRoot,
1723
+ const baseline = new Set(runner && baselineTargets.length > 0 ? (await runTests(runner, baselineTargets, ownerRoot, cwd)).filter((t) => t.status === "pass").map((t) => t.name) : []);
1724
+ const makeGateDeps = (sandbox) => {
1725
+ const repoRoot = sandbox?.cwd ?? cwd;
1726
+ const gateOwnerRoot = sandbox ? mapOwnerRoot(cwd, ownerRoot, sandbox.cwd) : ownerRoot;
1727
+ const session = new ClaudeSession({ spawn: async (req) => {
1728
+ const r = await execa("claude", [
1729
+ "-p",
1730
+ req.prompt,
1731
+ "--model",
1732
+ config.model,
1733
+ ...config.effort ? ["--effort", config.effort] : [],
1734
+ "--output-format",
1735
+ "stream-json",
1736
+ "--verbose",
1737
+ "--allowedTools",
1738
+ "Read,Write,Edit"
1739
+ ], {
1740
+ cwd: repoRoot,
1333
1741
  reject: false,
1334
- timeout: TSC_TIMEOUT_MS
1742
+ timeout: CLAUDE_TIMEOUT_MS,
1743
+ env: {
1744
+ ...process.env,
1745
+ ...thinkingEnv(req.findings, config)
1746
+ }
1335
1747
  });
1748
+ const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1336
1749
  return {
1337
- exitCode: r.exitCode ?? 1,
1338
- output: `${r.stdout}\n${r.stderr}`
1750
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
1751
+ exitCode
1339
1752
  };
1340
- },
1341
- runBuild: buildArgs ? async () => {
1342
- const r = await execa(pm, buildArgs, {
1343
- cwd: ownerRoot,
1344
- reject: false,
1345
- timeout: BUILD_TIMEOUT_MS
1753
+ } });
1754
+ return {
1755
+ cwd: repoRoot,
1756
+ typescript,
1757
+ runTsc: async () => {
1758
+ const r = await execa("npx", ["tsc", "--noEmit"], {
1759
+ cwd: gateOwnerRoot,
1760
+ reject: false,
1761
+ timeout: TSC_TIMEOUT_MS
1762
+ });
1763
+ return {
1764
+ exitCode: r.exitCode ?? 1,
1765
+ output: `${r.stdout}\n${r.stderr}`
1766
+ };
1767
+ },
1768
+ runBuild: buildArgs ? async () => {
1769
+ const r = await execa(pm, buildArgs, {
1770
+ cwd: gateOwnerRoot,
1771
+ reject: false,
1772
+ timeout: BUILD_TIMEOUT_MS
1773
+ });
1774
+ return {
1775
+ exitCode: r.exitCode ?? 1,
1776
+ output: `${r.stdout}\n${r.stderr}`
1777
+ };
1778
+ } : void 0,
1779
+ hasTestRunner: Boolean(runner),
1780
+ runRelated: (files) => runner ? runTests(runner, files, gateOwnerRoot, repoRoot) : Promise.resolve([]),
1781
+ scanFindings: async (files, tools) => (await scanFiles({
1782
+ cwd: repoRoot,
1783
+ which: realWhich,
1784
+ spawn: realSpawn,
1785
+ timeoutMs: 12e4,
1786
+ tools
1787
+ }, files, 0)).findings,
1788
+ baseline,
1789
+ session
1790
+ };
1791
+ };
1792
+ const mainGateDeps = makeGateDeps();
1793
+ const acceptedFiles = new Set();
1794
+ const acceptedTools = new Set();
1795
+ const buildFixUnit = (sandbox) => makeFixUnit({
1796
+ ...makeGateDeps(sandbox),
1797
+ maxRepairs: 3,
1798
+ onProgress: (event) => bus?.emit({
1799
+ type: "file-stage",
1800
+ ...event
1801
+ })
1802
+ });
1803
+ const fixUnit = async (unit, loop) => {
1804
+ if (!sandboxPool) return buildFixUnit()(unit, loop);
1805
+ try {
1806
+ return await sandboxPool.withSandbox(async (sandbox) => {
1807
+ const outcome = await buildFixUnit(sandbox)(unit, loop);
1808
+ if (!outcome.kept) return outcome;
1809
+ bus?.emit({
1810
+ type: "file-stage",
1811
+ loop,
1812
+ file: unit.file,
1813
+ stage: "patch-apply"
1814
+ });
1815
+ const patch = await sandbox.collectPatch(unit);
1816
+ if (!patch.ok) return {
1817
+ kept: false,
1818
+ reason: "unowned-patch",
1819
+ detail: patch.detail,
1820
+ failureClass: "unowned-patch",
1821
+ usage: outcome.usage
1822
+ };
1823
+ const applied = await sandboxPool.applyPatchToMain(patch.patch);
1824
+ if (!applied.ok) {
1825
+ bus?.emit({
1826
+ type: "file-stage",
1827
+ loop,
1828
+ file: unit.file,
1829
+ stage: "patch-conflict"
1830
+ });
1831
+ return {
1832
+ kept: false,
1833
+ reason: "patch-conflict",
1834
+ detail: applied.detail,
1835
+ failureClass: "patch-conflict",
1836
+ usage: outcome.usage
1837
+ };
1838
+ }
1839
+ for (const file of patch.changedFiles) acceptedFiles.add(file);
1840
+ for (const finding of unit.findings) acceptedTools.add(finding.tool);
1841
+ return outcome;
1346
1842
  });
1843
+ } catch (error) {
1844
+ const detail = error instanceof Error ? error.message : String(error);
1845
+ const setupFailed = error instanceof SandboxSetupError;
1347
1846
  return {
1348
- exitCode: r.exitCode ?? 1,
1349
- output: `${r.stdout}\n${r.stderr}`
1847
+ kept: false,
1848
+ reason: setupFailed ? "sandbox-setup-failed" : "session-error",
1849
+ detail,
1850
+ failureClass: setupFailed ? "sandbox-setup-failed" : "model-tool-failure",
1851
+ usage: zeroUsage()
1350
1852
  };
1351
- } : void 0,
1352
- hasTestRunner: Boolean(runner),
1353
- runRelated: (files) => runner ? runTests(runner, files, ownerRoot) : Promise.resolve([]),
1354
- scanFindings: async (files) => (await scanFiles({
1355
- cwd,
1356
- which: realWhich,
1357
- spawn: realSpawn,
1358
- timeoutMs: 12e4
1359
- }, files, 0)).findings,
1360
- baseline
1853
+ }
1361
1854
  };
1362
1855
  return {
1363
1856
  typescript,
1364
1857
  runner,
1365
- fixUnit: makeFixUnit({
1366
- ...gateDeps,
1367
- session,
1368
- maxRepairs: 3
1369
- }),
1370
- deterministicFixUnit: makeDeterministicFixUnit(gateDeps)
1858
+ fixUnit,
1859
+ deterministicFixUnit: makeDeterministicFixUnit(mainGateDeps),
1860
+ finalIntegration: async () => {
1861
+ const files = [...acceptedFiles].sort();
1862
+ if (files.length === 0) return {
1863
+ ok: true,
1864
+ files
1865
+ };
1866
+ if (typescript) {
1867
+ const tc = await mainGateDeps.runTsc();
1868
+ if (tc.exitCode !== 0) return {
1869
+ ok: false,
1870
+ files,
1871
+ detail: `final integration typecheck failed: ${tc.output}`
1872
+ };
1873
+ }
1874
+ if (runner) {
1875
+ const tests = await mainGateDeps.runRelated(files);
1876
+ const failed = tests.filter((test) => test.status === "fail");
1877
+ if (failed.length > 0) return {
1878
+ ok: false,
1879
+ files,
1880
+ detail: `final integration related tests failed: ${failed.map((test) => test.name).join(", ")}`
1881
+ };
1882
+ }
1883
+ const tools = [...acceptedTools];
1884
+ if (tools.length > 0) {
1885
+ const findings = await mainGateDeps.scanFindings(files, tools);
1886
+ if (findings.length > 0) return {
1887
+ ok: false,
1888
+ files,
1889
+ detail: `final integration scanner rescan found ${findings.length} finding${findings.length === 1 ? "" : "s"}`
1890
+ };
1891
+ }
1892
+ return {
1893
+ ok: true,
1894
+ files
1895
+ };
1896
+ }
1371
1897
  };
1372
1898
  }
1373
1899
  function describeScopeNote(all, paths, scope) {
@@ -1389,6 +1915,8 @@ async function runRun(opts) {
1389
1915
  write: out
1390
1916
  });
1391
1917
  reporter.start();
1918
+ const bus = new EventBus();
1919
+ bus.on((e) => reporter.onEvent(e));
1392
1920
  const git = createGit(cwd);
1393
1921
  await assertGitRepo(git);
1394
1922
  if (opts.effort && !EFFORT_LEVELS.includes(opts.effort)) {
@@ -1427,16 +1955,27 @@ async function runRun(opts) {
1427
1955
  } else scope = await changedVsHead(git);
1428
1956
  const baselineTargets = scope ?? ["."];
1429
1957
  const ownerRoot = scope ? resolveOwnerRoot(cwd, scope) : cwd;
1430
- const { fixUnit, deterministicFixUnit, runner, typescript } = await makeProductionFixUnit(config, baselineTargets, ownerRoot);
1958
+ const typescript = detectTypeScript(ownerRoot);
1959
+ const runner = detectTestRunner(ownerRoot) ?? null;
1431
1960
  const pm = detectPackageManager(cwd);
1432
1961
  reporter.note(`${pm} · ${typescript ? "TypeScript" : "JavaScript"} · ${runner ?? "no test runner"} · ${modelLabel}`);
1433
1962
  const scopeNote = describeScopeNote(opts.all, paths, scope);
1434
1963
  reporter.note(`${scopeNote} · ${plural(available.length, "scanner")}`);
1435
- const bus = new EventBus();
1436
- bus.on((e) => reporter.onEvent(e));
1964
+ if (runner && baselineTargets.length > 0) reporter.note(`baseline: ${runner} related ${describeScopeNote(opts.all, paths, scope)} (one-time)`);
1965
+ const sandboxPool = new WorkerSandboxPool({
1966
+ mainRoot: snapshot.repoRoot(),
1967
+ snapshotSha: snapshot.commitSha(),
1968
+ maxSandboxes: config.maxSessions,
1969
+ packageManager: pm
1970
+ });
1971
+ const { fixUnit, deterministicFixUnit, finalIntegration } = await makeProductionFixUnit(config, baselineTargets, ownerRoot, bus, {
1972
+ typescript,
1973
+ runner
1974
+ }, sandboxPool);
1437
1975
  const start = Date.now();
1438
1976
  const drawing = reporter.run();
1439
1977
  let result;
1978
+ let finalIntegrationResult;
1440
1979
  try {
1441
1980
  result = await orchestrate({
1442
1981
  cwd,
@@ -1445,7 +1984,8 @@ async function runRun(opts) {
1445
1984
  which: realWhich,
1446
1985
  spawn: realSpawn,
1447
1986
  scope,
1448
- timeoutMs: 12e4
1987
+ timeoutMs: 12e4,
1988
+ bus
1449
1989
  }),
1450
1990
  fixUnit,
1451
1991
  deterministicFixUnit,
@@ -1453,7 +1993,10 @@ async function runRun(opts) {
1453
1993
  inScope: scope ? (fs) => filterToChanged(fs, scope) : void 0,
1454
1994
  bus
1455
1995
  });
1996
+ finalIntegrationResult = await finalIntegration();
1997
+ if (!finalIntegrationResult.ok) result.exitStatus = 1;
1456
1998
  } finally {
1999
+ await sandboxPool.dispose();
1457
2000
  reporter.close();
1458
2001
  }
1459
2002
  await drawing;
@@ -1473,7 +2016,8 @@ async function runRun(opts) {
1473
2016
  exclude: config.fix.exclude,
1474
2017
  includeGenerated: config.fix.includeGenerated,
1475
2018
  includeFixtures: config.fix.includeFixtures
1476
- }
2019
+ },
2020
+ finalIntegration: finalIntegrationResult
1477
2021
  });
1478
2022
  persist(REPORT_PATH, report);
1479
2023
  out("");
@@ -1496,6 +2040,7 @@ async function runRetry(id) {
1496
2040
  }
1497
2041
  const config = await loadConfig(cwd);
1498
2042
  let snapshotSaved = false;
2043
+ let retrySandboxPool;
1499
2044
  const result = await retryCommand(id, {
1500
2045
  report,
1501
2046
  baseBudget: config.perIssueBudget,
@@ -1517,12 +2062,20 @@ async function runRetry(id) {
1517
2062
  if (!snapshotSaved) {
1518
2063
  const snapshot = await Snapshot.capture(git, cwd);
1519
2064
  persist(SNAPSHOT_PATH, snapshot.toJSON());
2065
+ retrySandboxPool = new WorkerSandboxPool({
2066
+ mainRoot: snapshot.repoRoot(),
2067
+ snapshotSha: snapshot.commitSha(),
2068
+ maxSandboxes: config.maxSessions,
2069
+ packageManager: detectPackageManager(cwd)
2070
+ });
1520
2071
  snapshotSaved = true;
1521
2072
  }
1522
2073
  const ownerRoot = resolveOwnerRoot(cwd, unit.files);
1523
- const { fixUnit } = await makeProductionFixUnit(config, unit.files, ownerRoot);
2074
+ const { fixUnit } = await makeProductionFixUnit(config, unit.files, ownerRoot, void 0, void 0, retrySandboxPool);
1524
2075
  return fixUnit(unit, 1);
1525
2076
  }
2077
+ }).finally(async () => {
2078
+ await retrySandboxPool?.dispose();
1526
2079
  });
1527
2080
  if ("error" in result) {
1528
2081
  err(`✖ ${result.error}`);