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 +288 -37
- package/dist/{config-BeA71px2.js → config-BPLYzcro.js} +13 -4
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/prompts/single-file-ai-edit.md +8 -0
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-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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}
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
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-
|
|
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
|
@@ -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:
|