tend-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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-DPlVYfKX.js";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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-BPLYzcro.js";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
5
  import { execa } from "execa";
6
+ import { createHash } from "node:crypto";
6
7
  import PQueue from "p-queue";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { tmpdir } from "node:os";
@@ -753,18 +754,28 @@ function templateForStrategy(strategy) {
753
754
  function renderFileList(files) {
754
755
  return files.map((file) => `- ${file}`).join("\n");
755
756
  }
757
+ /**
758
+ * Render the editable files' current source as labelled fenced blocks, so the model can
759
+ * edit without a preliminary Read (Fix 6). Templates that don't reference `{{fileContents}}`
760
+ * are unaffected; an empty map renders a neutral note so the placeholder is never left raw.
761
+ */
762
+ function renderFileContents(contents) {
763
+ if (contents.size === 0) return "(file content not supplied — read the file before editing)";
764
+ return [...contents].map(([file, source]) => `### ${file}\n\n\`\`\`\n${source}\n\`\`\``).join("\n\n");
765
+ }
756
766
  function renderCommonTemplate(input) {
757
- return replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(input.template, "{{strategyName}}", input.strategyName), "{{findings}}", input.findings), "{{editableFiles}}", renderFileList(input.editableFiles)), "{{verificationTargets}}", renderFileList(input.verificationTargets)).trim();
767
+ return replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(input.template, "{{strategyName}}", input.strategyName), "{{findings}}", input.findings), "{{editableFiles}}", renderFileList(input.editableFiles)), "{{verificationTargets}}", renderFileList(input.verificationTargets)), "{{fileContents}}", renderFileContents(input.fileContents ?? new Map())).trim();
758
768
  }
759
- /** Render the fix prompt for a unit's findings. */
760
- function renderPrompt(unit) {
769
+ /** Render the fix prompt for a unit's findings. `fileContents` supplies on-disk source. */
770
+ function renderPrompt(unit, fileContents) {
761
771
  const strategyName = promptStrategyFor(unit);
762
772
  return renderCommonTemplate({
763
773
  template: templateForStrategy(strategyName),
764
774
  strategyName,
765
775
  findings: renderFindingsJson(unit.findings),
766
776
  editableFiles: unit.files,
767
- verificationTargets: unit.verificationTargets ?? unit.files
777
+ verificationTargets: unit.verificationTargets ?? unit.files,
778
+ fileContents
768
779
  });
769
780
  }
770
781
  function firstRelevantLines(output, max = 20) {
@@ -800,8 +811,8 @@ function renderRegressionRepairPrompt(input) {
800
811
  });
801
812
  return replaceAllLiteral(replaceAllLiteral(replaceAllLiteral(prompt, "{{rejectedDiff}}", firstRelevantLines(input.rejectedDiff, 80)), "{{newFindings}}", renderFindingsJson(input.newFindings)), "{{gateDetails}}", [`Reason: ${input.gateReason}`, firstRelevantLines(input.gateOutput)].join("\n")).trim();
802
813
  }
803
- function renderNoEditRetryPrompt(unit) {
804
- return `${renderPrompt(unit)}
814
+ function renderNoEditRetryPrompt(unit, fileContents) {
815
+ return `${renderPrompt(unit, fileContents)}
805
816
 
806
817
  The previous session completed without changing any owned file. Retry once with a smaller, concrete edit:
807
818
  - Make the minimal behavior-preserving code change that clears the finding.
@@ -841,12 +852,19 @@ function makeFixUnit(deps) {
841
852
  const before = snapshotUnitFiles(deps.cwd, snapshotFiles);
842
853
  const restore = () => restoreSnapshot(deps.cwd, before);
843
854
  const changedOnDisk = () => unitChanged(deps.cwd, unit.files, before);
855
+ const fileContents = new Map();
856
+ for (const file of unit.files) {
857
+ const source = before.get(file);
858
+ if (typeof source === "string") fileContents.set(file, source);
859
+ }
844
860
  let usage = zeroUsage();
861
+ const activity = (stage) => (detail) => progress(stage, detail);
845
862
  progress("ai-edit");
846
863
  const res = await deps.session.run({
847
864
  file: unit.file,
848
865
  findings: unit.findings,
849
- prompt: renderPrompt(unit)
866
+ prompt: renderPrompt(unit, fileContents),
867
+ onActivity: activity("ai-edit")
850
868
  });
851
869
  if (res.usage) usage = addUsage(usage, res.usage);
852
870
  if (!res.ok) {
@@ -864,7 +882,8 @@ function makeFixUnit(deps) {
864
882
  const retry = await deps.session.run({
865
883
  file: unit.file,
866
884
  findings: unit.findings,
867
- prompt: renderNoEditRetryPrompt(unit)
885
+ prompt: renderNoEditRetryPrompt(unit, fileContents),
886
+ onActivity: activity("ai-no-edit-retry")
868
887
  });
869
888
  if (retry.usage) usage = addUsage(usage, retry.usage);
870
889
  if (!retry.ok) {
@@ -906,7 +925,8 @@ function makeFixUnit(deps) {
906
925
  newFindings: outcome$1.reason === "regression" ? await scanNewFindings() : [],
907
926
  gateReason: outcome$1.reason,
908
927
  gateOutput: outcome$1.detail ?? ""
909
- })
928
+ }),
929
+ onActivity: activity("regression-repair")
910
930
  });
911
931
  if (repair.usage) usage = addUsage(usage, repair.usage);
912
932
  if (!repair.ok) {
@@ -931,7 +951,8 @@ function makeFixUnit(deps) {
931
951
  newFindings: [],
932
952
  gateReason: "broke-test",
933
953
  gateOutput: `Fix left previously-green test(s) red:\n${regressed.map((test) => test.name).join("\n")}`
934
- })
954
+ }),
955
+ onActivity: activity("test-repair")
935
956
  });
936
957
  if (repair.usage) usage = addUsage(usage, repair.usage);
937
958
  if (!repair.ok) repairFailureDetail = `Repair session failed: ${repair.error}`;
@@ -957,14 +978,83 @@ function makeFixUnit(deps) {
957
978
  };
958
979
  }
959
980
 
981
+ //#endregion
982
+ //#region src/fixing/thinking-budget.ts
983
+ /** Thinking disabled — mechanical fixes don't need reasoning tokens. */
984
+ const THINKING_OFF = 0;
985
+ /**
986
+ * Upper bound on extended-thinking tokens per fix session. Unbounded thinking
987
+ * measured ~7000 tokens per finding regardless of difficulty; capping it keeps
988
+ * reasoning fixes correct (gate stays green) at a fraction of the latency.
989
+ */
990
+ const THINKING_BUDGET_CAP = 4096;
991
+ function isMechanical(finding) {
992
+ return finding.category === "dead-code" || finding.autofixable === true;
993
+ }
994
+ /**
995
+ * Decide the extended-thinking token budget for one finding's fix session.
996
+ * Mechanical findings (dead-code removal, autofixable rules) get thinking off;
997
+ * everything else — reasoning findings and any unrecognized category — gets the
998
+ * bounded cap (the safe default: never spend more than the cap, never starve a
999
+ * finding that might need reasoning). A configured `thinkingBudget` overrides
1000
+ * the policy outright, with 0 meaning thinking off.
1001
+ */
1002
+ function thinkingBudgetFor(finding, config) {
1003
+ if (config?.thinkingBudget !== void 0) return config.thinkingBudget;
1004
+ return isMechanical(finding) ? THINKING_OFF : THINKING_BUDGET_CAP;
1005
+ }
1006
+ /**
1007
+ * Budget for a whole work unit (one file's findings). Takes the most-conservative
1008
+ * (largest) per-finding budget so a reasoning finding is never starved of thinking
1009
+ * just because it shares the file with a mechanical one. An empty unit and any
1010
+ * unrecognized category fall back to the cap. A configured budget overrides all.
1011
+ */
1012
+ function thinkingBudgetForUnit(findings, config) {
1013
+ if (config?.thinkingBudget !== void 0) return config.thinkingBudget;
1014
+ if (findings.length === 0) return THINKING_BUDGET_CAP;
1015
+ return Math.max(...findings.map((finding) => thinkingBudgetFor(finding)));
1016
+ }
1017
+ /**
1018
+ * Delivery to the `claude -p` session boundary: the env overlay that pins the
1019
+ * session's extended-thinking budget. Spread onto the child process env.
1020
+ */
1021
+ function thinkingEnv(findings, config) {
1022
+ return { MAX_THINKING_TOKENS: String(thinkingBudgetForUnit(findings, config)) };
1023
+ }
1024
+
960
1025
  //#endregion
961
1026
  //#region src/fixing/worker-sandbox.ts
1027
+ /** Prefix every tend sandbox worktree directory carries, so we can recognize our own. */
1028
+ const WORKTREE_PREFIX = "tend-worker-";
962
1029
  var SandboxSetupError = class extends Error {
963
1030
  constructor(message) {
964
1031
  super(message);
965
1032
  this.name = "SandboxSetupError";
966
1033
  }
967
1034
  };
1035
+ /**
1036
+ * Files whose presence in a unit means the install graph may have changed, so a sandbox
1037
+ * must reinstall rather than reuse the main checkout's node_modules. Matched by basename,
1038
+ * so nested-package manifests (e.g. `packages/app/package.json`) count too.
1039
+ */
1040
+ const DEPENDENCY_MANIFESTS = new Set([
1041
+ "package.json",
1042
+ "package-lock.json",
1043
+ "npm-shrinkwrap.json",
1044
+ "pnpm-lock.yaml",
1045
+ "pnpm-workspace.yaml",
1046
+ "yarn.lock",
1047
+ "bun.lockb",
1048
+ "bun.lock"
1049
+ ]);
1050
+ /**
1051
+ * The reuse decision (Fix 4): a sandbox can reuse the main repo's installed deps unless
1052
+ * the unit edits a dependency manifest, in which case it must reinstall. Package-manager-
1053
+ * agnostic — driven entirely by which files the unit may change.
1054
+ */
1055
+ function shouldReinstall(unit) {
1056
+ return allowedPatchFiles(unit).some((file) => DEPENDENCY_MANIFESTS.has(basename(normalizeRel(file))));
1057
+ }
968
1058
  const cleanExcludes = [
969
1059
  "node_modules",
970
1060
  "node_modules/**",
@@ -1003,7 +1093,7 @@ function installArgs(pm) {
1003
1093
  }
1004
1094
  }
1005
1095
  var GitWorkerSandbox = class {
1006
- prepared = false;
1096
+ preparedMode = "none";
1007
1097
  constructor(cwd$1, deps) {
1008
1098
  this.cwd = cwd$1;
1009
1099
  this.deps = deps;
@@ -1021,8 +1111,37 @@ var GitWorkerSandbox = class {
1021
1111
  ...cleanExcludes.flatMap((pattern) => ["-e", pattern])
1022
1112
  ]);
1023
1113
  }
1024
- async prepare() {
1025
- if (this.prepared || this.deps.prepareDependencies === false) return;
1114
+ async prepare(unit) {
1115
+ if (this.deps.prepareDependencies === false) return;
1116
+ if (shouldReinstall(unit)) {
1117
+ if (this.preparedMode === "install") return;
1118
+ this.removeNodeModulesLink();
1119
+ await this.install();
1120
+ this.preparedMode = "install";
1121
+ return;
1122
+ }
1123
+ if (this.preparedMode !== "none") return;
1124
+ if (this.tryReuseMainDeps()) {
1125
+ this.preparedMode = "reuse";
1126
+ return;
1127
+ }
1128
+ await this.install();
1129
+ this.preparedMode = "install";
1130
+ }
1131
+ /** Symlink the main repo's node_modules into this worktree. Returns false if main has none. */
1132
+ tryReuseMainDeps() {
1133
+ const mainModules = join(this.deps.mainRoot, "node_modules");
1134
+ if (!existsSync(mainModules)) return false;
1135
+ const link = join(this.cwd, "node_modules");
1136
+ if (existsSync(link)) return true;
1137
+ symlinkSync(mainModules, link, "junction");
1138
+ return true;
1139
+ }
1140
+ removeNodeModulesLink() {
1141
+ const link = join(this.cwd, "node_modules");
1142
+ rmSync(link, { force: true });
1143
+ }
1144
+ async install() {
1026
1145
  const args = installArgs(this.deps.packageManager);
1027
1146
  const result = await this.deps.exec(this.deps.packageManager, args, {
1028
1147
  cwd: this.cwd,
@@ -1030,7 +1149,6 @@ var GitWorkerSandbox = class {
1030
1149
  timeout: 10 * 6e4
1031
1150
  });
1032
1151
  if ((result.exitCode ?? 1) !== 0) throw new SandboxSetupError(`sandbox dependency install failed: ${result.stderr || result.stdout || `exit ${result.exitCode ?? 1}`}`);
1033
- this.prepared = true;
1034
1152
  }
1035
1153
  async collectPatch(unit) {
1036
1154
  const git = createGit(this.cwd);
@@ -1101,14 +1219,14 @@ var WorkerSandboxPool = class {
1101
1219
  idle = [];
1102
1220
  sandboxes = new Set();
1103
1221
  counter = 0;
1104
- disposed = false;
1222
+ disposing;
1105
1223
  exec;
1106
1224
  constructor(deps) {
1107
1225
  this.deps = deps;
1108
1226
  this.queue = new PQueue({ concurrency: deps.maxSandboxes });
1109
1227
  this.exec = deps.exec ?? execa;
1110
1228
  }
1111
- async withSandbox(run) {
1229
+ async withSandbox(unit, run) {
1112
1230
  return this.queue.add(async () => {
1113
1231
  let sandbox;
1114
1232
  try {
@@ -1118,7 +1236,7 @@ var WorkerSandboxPool = class {
1118
1236
  }
1119
1237
  try {
1120
1238
  await sandbox.reset();
1121
- await sandbox.prepare();
1239
+ await sandbox.prepare(unit);
1122
1240
  } catch (error) {
1123
1241
  this.idle.push(sandbox);
1124
1242
  throw error instanceof SandboxSetupError ? error : new SandboxSetupError(error instanceof Error ? error.message : String(error));
@@ -1160,9 +1278,16 @@ var WorkerSandboxPool = class {
1160
1278
  return { ok: true };
1161
1279
  });
1162
1280
  }
1163
- async dispose() {
1164
- if (this.disposed) return;
1165
- this.disposed = true;
1281
+ /**
1282
+ * Tear down every sandbox (remove worktrees). Idempotent and concurrency-safe: the SIGINT
1283
+ * handler and the run's finally path may both call this at once, so all callers share — and
1284
+ * await — the same in-flight teardown. Without this, a second caller returning early would let
1285
+ * the signal handler's process.exit() race ahead and leak a worktree.
1286
+ */
1287
+ dispose() {
1288
+ return this.disposing ??= this.runDispose();
1289
+ }
1290
+ async runDispose() {
1166
1291
  await this.queue.onIdle();
1167
1292
  await Promise.all([...this.sandboxes].map((sandbox) => sandbox.dispose()));
1168
1293
  this.idle.length = 0;
@@ -1173,7 +1298,7 @@ var WorkerSandboxPool = class {
1173
1298
  if (existing) return existing;
1174
1299
  const parent = this.deps.tempRoot ?? tmpdir();
1175
1300
  mkdirSync(parent, { recursive: true });
1176
- const path = `${parent}/tend-worker-${process.pid}-${this.counter++}`;
1301
+ const path = `${parent}/${WORKTREE_PREFIX}${process.pid}-${this.counter++}`;
1177
1302
  const mainGit = createGit(this.deps.mainRoot);
1178
1303
  await mainGit.raw([
1179
1304
  "worktree",
@@ -1190,11 +1315,168 @@ var WorkerSandboxPool = class {
1190
1315
  return sandbox;
1191
1316
  }
1192
1317
  };
1318
+ /** Paths of every worktree currently registered for the repo at `mainRoot`. */
1319
+ async function registeredWorktrees(mainRoot) {
1320
+ const raw = await createGit(mainRoot).raw([
1321
+ "worktree",
1322
+ "list",
1323
+ "--porcelain"
1324
+ ]);
1325
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9).trim());
1326
+ }
1327
+ /**
1328
+ * Self-heal after a crashed or cancelled prior run: force-remove any leftover `tend-worker-*`
1329
+ * worktrees still registered for `mainRoot`, then `git worktree prune` to clear stale admin
1330
+ * records. Best-effort and idempotent — never throws (a run must start even if cleanup hiccups).
1331
+ */
1332
+ async function pruneStaleWorktrees(mainRoot) {
1333
+ const git = createGit(mainRoot);
1334
+ try {
1335
+ for (const path of await registeredWorktrees(mainRoot)) {
1336
+ if (!basename(path).startsWith(WORKTREE_PREFIX)) continue;
1337
+ try {
1338
+ await git.raw([
1339
+ "worktree",
1340
+ "remove",
1341
+ "--force",
1342
+ path
1343
+ ]);
1344
+ } catch {}
1345
+ rmSync(path, {
1346
+ recursive: true,
1347
+ force: true
1348
+ });
1349
+ }
1350
+ await git.raw(["worktree", "prune"]);
1351
+ } catch {}
1352
+ }
1193
1353
  function mapOwnerRoot(mainRoot, mainOwnerRoot, sandboxRoot) {
1194
1354
  const rel = normalizeRel(relative(mainRoot, mainOwnerRoot));
1195
1355
  return rel === "" ? sandboxRoot : `${sandboxRoot}/${rel}`;
1196
1356
  }
1197
1357
 
1358
+ //#endregion
1359
+ //#region src/process/signals.ts
1360
+ const SIGNALS = ["SIGINT", "SIGTERM"];
1361
+ /**
1362
+ * Run `handler` once when the process is asked to terminate (Ctrl-C / SIGTERM), so a run can
1363
+ * tear down its sandboxes before exiting instead of leaking worktrees. Each signal fires the
1364
+ * handler at most once (the run then exits). Returns an unregister function for the normal
1365
+ * completion path. The emitter is injectable so tests don't have to raise real signals.
1366
+ */
1367
+ function onTerminationSignals(handler, emitter = process) {
1368
+ const wrappers = new Map();
1369
+ for (const signal of SIGNALS) {
1370
+ const wrapper = () => handler(signal);
1371
+ emitter.once(signal, wrapper);
1372
+ wrappers.set(signal, wrapper);
1373
+ }
1374
+ return () => {
1375
+ for (const [signal, wrapper] of wrappers) emitter.removeListener(signal, wrapper);
1376
+ };
1377
+ }
1378
+
1379
+ //#endregion
1380
+ //#region src/session/stream-activity.ts
1381
+ /** A short human label for one stream event's activity, or null for non-activity events. */
1382
+ function activityLabels(event) {
1383
+ if (event.type !== "assistant") return [];
1384
+ const labels = [];
1385
+ for (const block of event.message?.content ?? []) if (block.type === "tool_use" && typeof block.name === "string") {
1386
+ const path = block.input?.["file_path"];
1387
+ labels.push(typeof path === "string" ? `${block.name} ${path}` : block.name);
1388
+ } else if (block.type === "text") labels.push("thinking");
1389
+ return labels;
1390
+ }
1391
+ /**
1392
+ * Incremental consumer for Claude Code `--output-format stream-json` stdout. Buffers
1393
+ * partial lines across chunks and reports a short activity label per assistant event
1394
+ * (tool uses with their target file, text turns as "thinking"). Malformed or truncated
1395
+ * lines are skipped — this is progress decoration only and must never throw; the
1396
+ * authoritative outcome is still judged from the disk after the session ends.
1397
+ */
1398
+ function createStreamActivityScanner(onActivity) {
1399
+ let buffer = "";
1400
+ let last;
1401
+ const consume = (line) => {
1402
+ const trimmed = line.trim();
1403
+ if (!trimmed) return;
1404
+ let event;
1405
+ try {
1406
+ event = JSON.parse(trimmed);
1407
+ } catch {
1408
+ return;
1409
+ }
1410
+ for (const label of activityLabels(event)) {
1411
+ if (label === last) continue;
1412
+ last = label;
1413
+ onActivity(label);
1414
+ }
1415
+ };
1416
+ return {
1417
+ push(chunk) {
1418
+ buffer += chunk;
1419
+ const lines$1 = buffer.split("\n");
1420
+ buffer = lines$1.pop() ?? "";
1421
+ for (const line of lines$1) consume(line);
1422
+ },
1423
+ end() {
1424
+ consume(buffer);
1425
+ buffer = "";
1426
+ }
1427
+ };
1428
+ }
1429
+
1430
+ //#endregion
1431
+ //#region src/fixing/typecheck-cache.ts
1432
+ const TSC_TIMEOUT_MS$1 = 5 * 6e4;
1433
+ /**
1434
+ * tsc arguments for an incremental, cached `--noEmit` typecheck. **Only** caching/speed
1435
+ * flags are added (`--incremental`, `--tsBuildInfoFile`) — never a correctness or
1436
+ * tsconfig-semantic flag (`--skipLibCheck`, `--strict`, `--target`, …). The owning
1437
+ * package's tsconfig is still resolved from the cwd, so its semantics are unchanged.
1438
+ */
1439
+ function incrementalTscArgs(cacheFile) {
1440
+ return [
1441
+ "tsc",
1442
+ "--noEmit",
1443
+ "--incremental",
1444
+ "--tsBuildInfoFile",
1445
+ cacheFile
1446
+ ];
1447
+ }
1448
+ /**
1449
+ * A stable, tend-owned build-info cache path for the package rooted at `ownerRoot`.
1450
+ *
1451
+ * The caller roots `cacheDir` at the **main** repo's `.tend/cache` — outside any sandbox
1452
+ * worktree — so the file survives `reset()`/`git clean` between iterations and is reused.
1453
+ * The filename encodes the owner's repo-relative path (slug + hash) so monorepo packages
1454
+ * get distinct caches that don't collide.
1455
+ */
1456
+ function tscCacheFile(cacheDir, mainRoot, ownerRoot) {
1457
+ const rel = relative(mainRoot, ownerRoot).replaceAll("\\", "/") || ".";
1458
+ const slug = rel === "." ? "root" : rel.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "pkg";
1459
+ const hash = createHash("sha1").update(rel).digest("hex").slice(0, 8);
1460
+ return join(cacheDir, `${slug}-${hash}.tsbuildinfo`);
1461
+ }
1462
+ /**
1463
+ * Run `tsc --noEmit` with an incremental cache. Ensures the cache directory exists, then
1464
+ * runs tsc. tsc itself tolerates a missing or corrupt build-info file (it rebuilds from
1465
+ * scratch and overwrites), so correctness is unaffected — only cold runs are slower.
1466
+ */
1467
+ async function runIncrementalTsc(deps) {
1468
+ mkdirSync(dirname(deps.cacheFile), { recursive: true });
1469
+ const r = await deps.exec("npx", incrementalTscArgs(deps.cacheFile), {
1470
+ cwd: deps.cwd,
1471
+ reject: false,
1472
+ timeout: deps.timeoutMs ?? TSC_TIMEOUT_MS$1
1473
+ });
1474
+ return {
1475
+ exitCode: r.exitCode ?? 1,
1476
+ output: `${r.stdout ?? ""}\n${r.stderr ?? ""}`
1477
+ };
1478
+ }
1479
+
1198
1480
  //#endregion
1199
1481
  //#region src/output/env.ts
1200
1482
  /** Truthy in the env-var sense: present and not an explicit off value. */
@@ -1317,6 +1599,7 @@ var LiveReporter = class extends BaseReporter {
1317
1599
  currentConcurrency;
1318
1600
  rules = new Map();
1319
1601
  stages = new Map();
1602
+ stageDetails = new Map();
1320
1603
  scannerStates = new Map();
1321
1604
  currentScanLoop;
1322
1605
  header;
@@ -1368,6 +1651,7 @@ var LiveReporter = class extends BaseReporter {
1368
1651
  this.currentConcurrency = event.concurrency;
1369
1652
  this.rules.clear();
1370
1653
  this.stages.clear();
1654
+ this.stageDetails.clear();
1371
1655
  this.labelWidth = Math.max(0, ...event.files.map((f) => basename(f).length));
1372
1656
  this.phases.push({
1373
1657
  kind: "fix",
@@ -1388,6 +1672,8 @@ var LiveReporter = class extends BaseReporter {
1388
1672
  case "file-stage":
1389
1673
  this.currentFile = event.file;
1390
1674
  this.stages.set(event.file, event.stage);
1675
+ if (event.detail) this.stageDetails.set(event.file, event.detail);
1676
+ else this.stageDetails.delete(event.file);
1391
1677
  this.refreshHeader();
1392
1678
  break;
1393
1679
  case "file-result":
@@ -1541,7 +1827,11 @@ var LiveReporter = class extends BaseReporter {
1541
1827
  fileTitle(file) {
1542
1828
  const rule = this.rules.get(file);
1543
1829
  const stage = this.stages.get(file);
1544
- const detail = [rule, stage ? fixStageLabel(stage) : void 0].filter(Boolean).join(" · ");
1830
+ const detail = [
1831
+ rule,
1832
+ stage ? fixStageLabel(stage) : void 0,
1833
+ this.stageDetails.get(file)
1834
+ ].filter(Boolean).join(" · ");
1545
1835
  const suffix = detail ? ` ${this.theme.dim(detail)}` : "";
1546
1836
  return `${this.fileLabel(file)}${suffix}`;
1547
1837
  }
@@ -1616,6 +1906,7 @@ const cwd = process.cwd();
1616
1906
  const TEND_DIR = join(cwd, ".tend");
1617
1907
  const SNAPSHOT_PATH = join(TEND_DIR, "snapshot.json");
1618
1908
  const REPORT_PATH = join(TEND_DIR, "report.json");
1909
+ const TEND_CACHE_DIR = join(TEND_DIR, "cache");
1619
1910
  const CLAUDE_TIMEOUT_MS = 10 * 6e4;
1620
1911
  const BUILD_TIMEOUT_MS = 5 * 6e4;
1621
1912
  const TSC_TIMEOUT_MS = 5 * 6e4;
@@ -1681,7 +1972,7 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1681
1972
  const repoRoot = sandbox?.cwd ?? cwd;
1682
1973
  const gateOwnerRoot = sandbox ? mapOwnerRoot(cwd, ownerRoot, sandbox.cwd) : ownerRoot;
1683
1974
  const session = new ClaudeSession({ spawn: async (req) => {
1684
- const r = await execa("claude", [
1975
+ const child = execa("claude", [
1685
1976
  "-p",
1686
1977
  req.prompt,
1687
1978
  "--model",
@@ -1695,8 +1986,16 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1695
1986
  ], {
1696
1987
  cwd: repoRoot,
1697
1988
  reject: false,
1698
- timeout: CLAUDE_TIMEOUT_MS
1989
+ timeout: CLAUDE_TIMEOUT_MS,
1990
+ env: {
1991
+ ...process.env,
1992
+ ...thinkingEnv(req.findings, config)
1993
+ }
1699
1994
  });
1995
+ const scanner = createStreamActivityScanner((activity) => req.onActivity?.(activity));
1996
+ child.stdout?.on("data", (chunk) => scanner.push(chunk.toString()));
1997
+ child.stdout?.on("end", () => scanner.end());
1998
+ const r = await child;
1700
1999
  const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1701
2000
  return {
1702
2001
  stdout: typeof r.stdout === "string" ? r.stdout : "",
@@ -1706,17 +2005,12 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1706
2005
  return {
1707
2006
  cwd: repoRoot,
1708
2007
  typescript,
1709
- runTsc: async () => {
1710
- const r = await execa("npx", ["tsc", "--noEmit"], {
1711
- cwd: gateOwnerRoot,
1712
- reject: false,
1713
- timeout: TSC_TIMEOUT_MS
1714
- });
1715
- return {
1716
- exitCode: r.exitCode ?? 1,
1717
- output: `${r.stdout}\n${r.stderr}`
1718
- };
1719
- },
2008
+ runTsc: async () => runIncrementalTsc({
2009
+ exec: execa,
2010
+ cwd: gateOwnerRoot,
2011
+ cacheFile: tscCacheFile(TEND_CACHE_DIR, cwd, ownerRoot),
2012
+ timeoutMs: TSC_TIMEOUT_MS
2013
+ }),
1720
2014
  runBuild: buildArgs ? async () => {
1721
2015
  const r = await execa(pm, buildArgs, {
1722
2016
  cwd: gateOwnerRoot,
@@ -1755,7 +2049,7 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1755
2049
  const fixUnit = async (unit, loop) => {
1756
2050
  if (!sandboxPool) return buildFixUnit()(unit, loop);
1757
2051
  try {
1758
- return await sandboxPool.withSandbox(async (sandbox) => {
2052
+ return await sandboxPool.withSandbox(unit, async (sandbox) => {
1759
2053
  const outcome = await buildFixUnit(sandbox)(unit, loop);
1760
2054
  if (!outcome.kept) return outcome;
1761
2055
  bus?.emit({
@@ -1914,6 +2208,7 @@ async function runRun(opts) {
1914
2208
  const scopeNote = describeScopeNote(opts.all, paths, scope);
1915
2209
  reporter.note(`${scopeNote} · ${plural(available.length, "scanner")}`);
1916
2210
  if (runner && baselineTargets.length > 0) reporter.note(`baseline: ${runner} related ${describeScopeNote(opts.all, paths, scope)} (one-time)`);
2211
+ await pruneStaleWorktrees(snapshot.repoRoot());
1917
2212
  const sandboxPool = new WorkerSandboxPool({
1918
2213
  mainRoot: snapshot.repoRoot(),
1919
2214
  snapshotSha: snapshot.commitSha(),
@@ -1924,6 +2219,9 @@ async function runRun(opts) {
1924
2219
  typescript,
1925
2220
  runner
1926
2221
  }, sandboxPool);
2222
+ const stopSignals = onTerminationSignals((signal) => {
2223
+ sandboxPool.dispose().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
2224
+ });
1927
2225
  const start = Date.now();
1928
2226
  const drawing = reporter.run();
1929
2227
  let result;
@@ -1948,6 +2246,7 @@ async function runRun(opts) {
1948
2246
  finalIntegrationResult = await finalIntegration();
1949
2247
  if (!finalIntegrationResult.ok) result.exitStatus = 1;
1950
2248
  } finally {
2249
+ stopSignals();
1951
2250
  await sandboxPool.dispose();
1952
2251
  reporter.close();
1953
2252
  }
@@ -1350,10 +1350,11 @@ async function currentFiles(root) {
1350
1350
  * so the on-disk record is a 40-char id rather than a copy of every file.
1351
1351
  */
1352
1352
  var Snapshot = class Snapshot {
1353
- constructor(cwd, root, sha) {
1353
+ constructor(cwd, root, sha, indexTree = null) {
1354
1354
  this.cwd = cwd;
1355
1355
  this.root = root;
1356
1356
  this.sha = sha;
1357
+ this.indexTree = indexTree;
1357
1358
  }
1358
1359
  static async capture(_git, cwd) {
1359
1360
  const git = createGit(cwd);
@@ -1362,6 +1363,12 @@ var Snapshot = class Snapshot {
1362
1363
  ensureTendIgnored(gitDir);
1363
1364
  const rg = createGit(root);
1364
1365
  const tree = await writeWorkingTree(root);
1366
+ let indexTree = null;
1367
+ try {
1368
+ indexTree = (await rg.raw(["write-tree"])).trim();
1369
+ } catch {
1370
+ indexTree = null;
1371
+ }
1365
1372
  let parent = null;
1366
1373
  try {
1367
1374
  parent = (await rg.revparse(["HEAD"])).trim();
@@ -1387,14 +1394,15 @@ var Snapshot = class Snapshot {
1387
1394
  SNAP_REF,
1388
1395
  sha
1389
1396
  ]);
1390
- return new Snapshot(cwd, root, sha);
1397
+ return new Snapshot(cwd, root, sha, indexTree);
1391
1398
  }
1392
1399
  /** Serialize to a tiny object for `.tend/snapshot.json` (powers `undo` across invocations). */
1393
1400
  toJSON() {
1394
1401
  return {
1395
1402
  cwd: this.cwd,
1396
1403
  root: this.root,
1397
- sha: this.sha
1404
+ sha: this.sha,
1405
+ indexTree: this.indexTree
1398
1406
  };
1399
1407
  }
1400
1408
  commitSha() {
@@ -1404,7 +1412,7 @@ var Snapshot = class Snapshot {
1404
1412
  return this.root;
1405
1413
  }
1406
1414
  static fromJSON(data) {
1407
- return new Snapshot(data.cwd, data.root, data.sha);
1415
+ return new Snapshot(data.cwd, data.root, data.sha, data.indexTree ?? null);
1408
1416
  }
1409
1417
  /** Files whose contents differ from the snapshot, or that are new/deleted since it (sorted). */
1410
1418
  async changedSince(_git) {
@@ -1446,6 +1454,7 @@ var Snapshot = class Snapshot {
1446
1454
  this.sha
1447
1455
  ])));
1448
1456
  for (const rel of await currentFiles(this.root)) if (!inSnapshot.has(rel)) rmSync(join(this.root, rel), { force: true });
1457
+ if (this.indexTree) await rg.raw(["read-tree", this.indexTree]);
1449
1458
  }
1450
1459
  };
1451
1460
 
@@ -3636,6 +3645,7 @@ const ConfigSchema = z.object({
3636
3645
  includeTests: z.boolean().default(false),
3637
3646
  model: z.string().default("sonnet"),
3638
3647
  effort: z.enum(EFFORT_LEVELS).optional(),
3648
+ thinkingBudget: z.number().int().nonnegative().optional(),
3639
3649
  fix: FixScopeConfigSchema,
3640
3650
  tools: z.record(z.string(), ToolConfigSchema).default({})
3641
3651
  });
package/dist/index.d.ts CHANGED
@@ -236,6 +236,12 @@ type SessionRequest = {
236
236
  findings: Finding[];
237
237
  /** The fully-rendered prompt for the AI. */
238
238
  prompt: string;
239
+ /**
240
+ * Live progress hook: called with a short label (e.g. "Edit src/a.ts") as activity
241
+ * streams from the running session. Decoration only — outcomes are still judged
242
+ * from the disk after the session ends.
243
+ */
244
+ onActivity?: (activity: string) => void;
239
245
  };
240
246
  type FailureClass = "tool-timeout" | "rate-limit" | "model-tool-failure" | "sandbox-setup-failed" | "patch-conflict" | "unowned-patch" | "final-integration-failed" | "no-edit" | "no-op" | "regression" | "typecheck" | "broke-test" | "suppression" | "needs-lockfile-update";
241
247
  /**
@@ -1800,6 +1806,7 @@ declare class Snapshot {
1800
1806
  private readonly cwd;
1801
1807
  private readonly root;
1802
1808
  private readonly sha;
1809
+ private readonly indexTree;
1803
1810
  private constructor();
1804
1811
  static capture(_git: SimpleGit, cwd: string): Promise<Snapshot>;
1805
1812
  /** Serialize to a tiny object for `.tend/snapshot.json` (powers `undo` across invocations). */
@@ -1807,6 +1814,7 @@ declare class Snapshot {
1807
1814
  cwd: string;
1808
1815
  root: string;
1809
1816
  sha: string;
1817
+ indexTree: string | null;
1810
1818
  };
1811
1819
  commitSha(): string;
1812
1820
  repoRoot(): string;
@@ -1814,6 +1822,7 @@ declare class Snapshot {
1814
1822
  cwd: string;
1815
1823
  root: string;
1816
1824
  sha: string;
1825
+ indexTree?: string | null;
1817
1826
  }): Snapshot;
1818
1827
  /** Files whose contents differ from the snapshot, or that are new/deleted since it (sorted). */
1819
1828
  changedSince(_git: SimpleGit): Promise<string[]>;
@@ -1863,6 +1872,8 @@ declare const ConfigSchema: z.ZodObject<{
1863
1872
  model: z.ZodDefault<z.ZodString>;
1864
1873
  /** Reasoning effort for fixes; unset → claude's own default. */
1865
1874
  effort: z.ZodOptional<z.ZodEnum<["low", "medium", "high", "xhigh", "max"]>>;
1875
+ /** Extended-thinking token budget per fix session; unset → per-finding policy decides. */
1876
+ thinkingBudget: z.ZodOptional<z.ZodNumber>;
1866
1877
  /** Report/fix scope policy. Reports stay broad; fixes default away from generated/tooling paths. */
1867
1878
  fix: z.ZodDefault<z.ZodObject<{
1868
1879
  include: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -1909,6 +1920,7 @@ declare const ConfigSchema: z.ZodObject<{
1909
1920
  }>;
1910
1921
  test?: string | undefined;
1911
1922
  effort?: "low" | "medium" | "high" | "xhigh" | "max" | undefined;
1923
+ thinkingBudget?: number | undefined;
1912
1924
  }, {
1913
1925
  maxSessions?: number | undefined;
1914
1926
  maxLoops?: number | undefined;
@@ -1918,6 +1930,7 @@ declare const ConfigSchema: z.ZodObject<{
1918
1930
  includeTests?: boolean | undefined;
1919
1931
  model?: string | undefined;
1920
1932
  effort?: "low" | "medium" | "high" | "xhigh" | "max" | undefined;
1933
+ thinkingBudget?: number | undefined;
1921
1934
  fix?: {
1922
1935
  include?: string[] | undefined;
1923
1936
  exclude?: string[] | undefined;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ClaudeSession, ConfigSchema, EventBus, FindingSchema, FindingStore, REPAIR_STRATEGIES, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, applyRepairPlanToFinding, assertGitRepo, buildProgram, changedFiles, changedVsHead, detectPackageManager, dispatch, filterToChanged, fingerprint, groupRemaining, isAiDispatchStrategy, isAvailable, loadConfig, makeDeterministicFixUnit, makeDeterministicFixer, normalize, orchestrate, planRepair, planWork, planWorkFromRepairs, renderSummary, retryCommand, revertFile, route, runScanner, scopeFindings, showCommand, trackForTool, zeroUsage } from "./config-DPlVYfKX.js";
1
+ import { ClaudeSession, ConfigSchema, EventBus, FindingSchema, FindingStore, REPAIR_STRATEGIES, ReportBuilder, ReportSchema, Snapshot, addUsage, applyCliOverrides, applyRepairPlanToFinding, assertGitRepo, buildProgram, changedFiles, changedVsHead, detectPackageManager, dispatch, filterToChanged, fingerprint, groupRemaining, isAiDispatchStrategy, isAvailable, loadConfig, makeDeterministicFixUnit, makeDeterministicFixer, normalize, orchestrate, planRepair, planWork, planWorkFromRepairs, renderSummary, retryCommand, revertFile, route, runScanner, scopeFindings, showCommand, trackForTool, zeroUsage } from "./config-BPLYzcro.js";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tend-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Audit a JS/TS repo with established scanners, then fix the findings with parallel AI sessions in a safe scan-fix-rescan loop.",
5
5
  "keywords": [
6
6
  "lint",
@@ -20,6 +20,14 @@ Only edit these repo-relative files:
20
20
  Do not edit any other file. If the correct fix requires another file, leave the
21
21
  files unchanged.
22
22
 
23
+ ## Current file content
24
+
25
+ The current on-disk content of the editable file(s) follows, so you can edit
26
+ without reading first. Treat it as the source of truth; if it differs from what
27
+ you expect, re-read before editing.
28
+
29
+ {{fileContents}}
30
+
23
31
  ## Verification targets
24
32
 
25
33
  The gate will verify these repo-relative files: