tend-cli 0.8.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-BeA71px2.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}`;
@@ -1003,12 +1024,37 @@ function thinkingEnv(findings, config) {
1003
1024
 
1004
1025
  //#endregion
1005
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-";
1006
1029
  var SandboxSetupError = class extends Error {
1007
1030
  constructor(message) {
1008
1031
  super(message);
1009
1032
  this.name = "SandboxSetupError";
1010
1033
  }
1011
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
+ }
1012
1058
  const cleanExcludes = [
1013
1059
  "node_modules",
1014
1060
  "node_modules/**",
@@ -1047,7 +1093,7 @@ function installArgs(pm) {
1047
1093
  }
1048
1094
  }
1049
1095
  var GitWorkerSandbox = class {
1050
- prepared = false;
1096
+ preparedMode = "none";
1051
1097
  constructor(cwd$1, deps) {
1052
1098
  this.cwd = cwd$1;
1053
1099
  this.deps = deps;
@@ -1065,8 +1111,37 @@ var GitWorkerSandbox = class {
1065
1111
  ...cleanExcludes.flatMap((pattern) => ["-e", pattern])
1066
1112
  ]);
1067
1113
  }
1068
- async prepare() {
1069
- 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() {
1070
1145
  const args = installArgs(this.deps.packageManager);
1071
1146
  const result = await this.deps.exec(this.deps.packageManager, args, {
1072
1147
  cwd: this.cwd,
@@ -1074,7 +1149,6 @@ var GitWorkerSandbox = class {
1074
1149
  timeout: 10 * 6e4
1075
1150
  });
1076
1151
  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
1152
  }
1079
1153
  async collectPatch(unit) {
1080
1154
  const git = createGit(this.cwd);
@@ -1145,14 +1219,14 @@ var WorkerSandboxPool = class {
1145
1219
  idle = [];
1146
1220
  sandboxes = new Set();
1147
1221
  counter = 0;
1148
- disposed = false;
1222
+ disposing;
1149
1223
  exec;
1150
1224
  constructor(deps) {
1151
1225
  this.deps = deps;
1152
1226
  this.queue = new PQueue({ concurrency: deps.maxSandboxes });
1153
1227
  this.exec = deps.exec ?? execa;
1154
1228
  }
1155
- async withSandbox(run) {
1229
+ async withSandbox(unit, run) {
1156
1230
  return this.queue.add(async () => {
1157
1231
  let sandbox;
1158
1232
  try {
@@ -1162,7 +1236,7 @@ var WorkerSandboxPool = class {
1162
1236
  }
1163
1237
  try {
1164
1238
  await sandbox.reset();
1165
- await sandbox.prepare();
1239
+ await sandbox.prepare(unit);
1166
1240
  } catch (error) {
1167
1241
  this.idle.push(sandbox);
1168
1242
  throw error instanceof SandboxSetupError ? error : new SandboxSetupError(error instanceof Error ? error.message : String(error));
@@ -1204,9 +1278,16 @@ var WorkerSandboxPool = class {
1204
1278
  return { ok: true };
1205
1279
  });
1206
1280
  }
1207
- async dispose() {
1208
- if (this.disposed) return;
1209
- 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() {
1210
1291
  await this.queue.onIdle();
1211
1292
  await Promise.all([...this.sandboxes].map((sandbox) => sandbox.dispose()));
1212
1293
  this.idle.length = 0;
@@ -1217,7 +1298,7 @@ var WorkerSandboxPool = class {
1217
1298
  if (existing) return existing;
1218
1299
  const parent = this.deps.tempRoot ?? tmpdir();
1219
1300
  mkdirSync(parent, { recursive: true });
1220
- const path = `${parent}/tend-worker-${process.pid}-${this.counter++}`;
1301
+ const path = `${parent}/${WORKTREE_PREFIX}${process.pid}-${this.counter++}`;
1221
1302
  const mainGit = createGit(this.deps.mainRoot);
1222
1303
  await mainGit.raw([
1223
1304
  "worktree",
@@ -1234,11 +1315,168 @@ var WorkerSandboxPool = class {
1234
1315
  return sandbox;
1235
1316
  }
1236
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
+ }
1237
1353
  function mapOwnerRoot(mainRoot, mainOwnerRoot, sandboxRoot) {
1238
1354
  const rel = normalizeRel(relative(mainRoot, mainOwnerRoot));
1239
1355
  return rel === "" ? sandboxRoot : `${sandboxRoot}/${rel}`;
1240
1356
  }
1241
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
+
1242
1480
  //#endregion
1243
1481
  //#region src/output/env.ts
1244
1482
  /** Truthy in the env-var sense: present and not an explicit off value. */
@@ -1361,6 +1599,7 @@ var LiveReporter = class extends BaseReporter {
1361
1599
  currentConcurrency;
1362
1600
  rules = new Map();
1363
1601
  stages = new Map();
1602
+ stageDetails = new Map();
1364
1603
  scannerStates = new Map();
1365
1604
  currentScanLoop;
1366
1605
  header;
@@ -1412,6 +1651,7 @@ var LiveReporter = class extends BaseReporter {
1412
1651
  this.currentConcurrency = event.concurrency;
1413
1652
  this.rules.clear();
1414
1653
  this.stages.clear();
1654
+ this.stageDetails.clear();
1415
1655
  this.labelWidth = Math.max(0, ...event.files.map((f) => basename(f).length));
1416
1656
  this.phases.push({
1417
1657
  kind: "fix",
@@ -1432,6 +1672,8 @@ var LiveReporter = class extends BaseReporter {
1432
1672
  case "file-stage":
1433
1673
  this.currentFile = event.file;
1434
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);
1435
1677
  this.refreshHeader();
1436
1678
  break;
1437
1679
  case "file-result":
@@ -1585,7 +1827,11 @@ var LiveReporter = class extends BaseReporter {
1585
1827
  fileTitle(file) {
1586
1828
  const rule = this.rules.get(file);
1587
1829
  const stage = this.stages.get(file);
1588
- 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(" · ");
1589
1835
  const suffix = detail ? ` ${this.theme.dim(detail)}` : "";
1590
1836
  return `${this.fileLabel(file)}${suffix}`;
1591
1837
  }
@@ -1660,6 +1906,7 @@ const cwd = process.cwd();
1660
1906
  const TEND_DIR = join(cwd, ".tend");
1661
1907
  const SNAPSHOT_PATH = join(TEND_DIR, "snapshot.json");
1662
1908
  const REPORT_PATH = join(TEND_DIR, "report.json");
1909
+ const TEND_CACHE_DIR = join(TEND_DIR, "cache");
1663
1910
  const CLAUDE_TIMEOUT_MS = 10 * 6e4;
1664
1911
  const BUILD_TIMEOUT_MS = 5 * 6e4;
1665
1912
  const TSC_TIMEOUT_MS = 5 * 6e4;
@@ -1725,7 +1972,7 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1725
1972
  const repoRoot = sandbox?.cwd ?? cwd;
1726
1973
  const gateOwnerRoot = sandbox ? mapOwnerRoot(cwd, ownerRoot, sandbox.cwd) : ownerRoot;
1727
1974
  const session = new ClaudeSession({ spawn: async (req) => {
1728
- const r = await execa("claude", [
1975
+ const child = execa("claude", [
1729
1976
  "-p",
1730
1977
  req.prompt,
1731
1978
  "--model",
@@ -1745,6 +1992,10 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1745
1992
  ...thinkingEnv(req.findings, config)
1746
1993
  }
1747
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;
1748
1999
  const exitCode = r.exitCode ?? (r.timedOut ? 143 : r.failed ? 1 : 0);
1749
2000
  return {
1750
2001
  stdout: typeof r.stdout === "string" ? r.stdout : "",
@@ -1754,17 +2005,12 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1754
2005
  return {
1755
2006
  cwd: repoRoot,
1756
2007
  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
- },
2008
+ runTsc: async () => runIncrementalTsc({
2009
+ exec: execa,
2010
+ cwd: gateOwnerRoot,
2011
+ cacheFile: tscCacheFile(TEND_CACHE_DIR, cwd, ownerRoot),
2012
+ timeoutMs: TSC_TIMEOUT_MS
2013
+ }),
1768
2014
  runBuild: buildArgs ? async () => {
1769
2015
  const r = await execa(pm, buildArgs, {
1770
2016
  cwd: gateOwnerRoot,
@@ -1803,7 +2049,7 @@ async function makeProductionFixUnit(config, baselineTargets, ownerRoot = cwd, b
1803
2049
  const fixUnit = async (unit, loop) => {
1804
2050
  if (!sandboxPool) return buildFixUnit()(unit, loop);
1805
2051
  try {
1806
- return await sandboxPool.withSandbox(async (sandbox) => {
2052
+ return await sandboxPool.withSandbox(unit, async (sandbox) => {
1807
2053
  const outcome = await buildFixUnit(sandbox)(unit, loop);
1808
2054
  if (!outcome.kept) return outcome;
1809
2055
  bus?.emit({
@@ -1962,6 +2208,7 @@ async function runRun(opts) {
1962
2208
  const scopeNote = describeScopeNote(opts.all, paths, scope);
1963
2209
  reporter.note(`${scopeNote} · ${plural(available.length, "scanner")}`);
1964
2210
  if (runner && baselineTargets.length > 0) reporter.note(`baseline: ${runner} related ${describeScopeNote(opts.all, paths, scope)} (one-time)`);
2211
+ await pruneStaleWorktrees(snapshot.repoRoot());
1965
2212
  const sandboxPool = new WorkerSandboxPool({
1966
2213
  mainRoot: snapshot.repoRoot(),
1967
2214
  snapshotSha: snapshot.commitSha(),
@@ -1972,6 +2219,9 @@ async function runRun(opts) {
1972
2219
  typescript,
1973
2220
  runner
1974
2221
  }, sandboxPool);
2222
+ const stopSignals = onTerminationSignals((signal) => {
2223
+ sandboxPool.dispose().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
2224
+ });
1975
2225
  const start = Date.now();
1976
2226
  const drawing = reporter.run();
1977
2227
  let result;
@@ -1996,6 +2246,7 @@ async function runRun(opts) {
1996
2246
  finalIntegrationResult = await finalIntegration();
1997
2247
  if (!finalIntegrationResult.ok) result.exitStatus = 1;
1998
2248
  } finally {
2249
+ stopSignals();
1999
2250
  await sandboxPool.dispose();
2000
2251
  reporter.close();
2001
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
 
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[]>;
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-BeA71px2.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.8.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: