@wrongstack/tools 0.283.1 → 0.284.1

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.
Files changed (56) hide show
  1. package/dist/audit.js.map +1 -1
  2. package/dist/bash.js +4 -0
  3. package/dist/bash.js.map +1 -1
  4. package/dist/builtin.js +685 -253
  5. package/dist/builtin.js.map +1 -1
  6. package/dist/codebase-index/index.js +2 -2
  7. package/dist/codebase-index/index.js.map +1 -1
  8. package/dist/diff.js.map +1 -1
  9. package/dist/document.js.map +1 -1
  10. package/dist/edit.d.ts +43 -0
  11. package/dist/edit.js +386 -60
  12. package/dist/edit.js.map +1 -1
  13. package/dist/{exec-Ca3fnpUh.d.ts → exec-2OKoT6tk.d.ts} +1 -1
  14. package/dist/exec.d.ts +1 -1
  15. package/dist/exec.js +13 -14
  16. package/dist/exec.js.map +1 -1
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/format.js.map +1 -1
  19. package/dist/git.js.map +1 -1
  20. package/dist/glob.js +10 -1
  21. package/dist/glob.js.map +1 -1
  22. package/dist/grep.js +4 -0
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +13 -2
  25. package/dist/index.js +730 -268
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js.map +1 -1
  28. package/dist/json.js.map +1 -1
  29. package/dist/lint.js.map +1 -1
  30. package/dist/logs.js.map +1 -1
  31. package/dist/next-steps.d.ts +66 -0
  32. package/dist/next-steps.js +116 -0
  33. package/dist/next-steps.js.map +1 -0
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +685 -253
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +43 -0
  38. package/dist/patch.js.map +1 -1
  39. package/dist/read.js +13 -4
  40. package/dist/read.js.map +1 -1
  41. package/dist/replace.js +14 -0
  42. package/dist/replace.js.map +1 -1
  43. package/dist/scaffold.js.map +1 -1
  44. package/dist/test.js.map +1 -1
  45. package/dist/tool-diff.d.ts +88 -0
  46. package/dist/tool-diff.js +229 -0
  47. package/dist/tool-diff.js.map +1 -0
  48. package/dist/tool-summary.d.ts +24 -0
  49. package/dist/tool-summary.js +208 -0
  50. package/dist/tool-summary.js.map +1 -0
  51. package/dist/tree.js.map +1 -1
  52. package/dist/typecheck.js.map +1 -1
  53. package/dist/write.d.ts +6 -0
  54. package/dist/write.js +118 -19
  55. package/dist/write.js.map +1 -1
  56. package/package.json +15 -2
package/dist/pack.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, ToolError, FetchError, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, deepMerge, addLinkToTask, addNoteToTask, updateCheckOnTask, addCheckToTask, updateGoalMetricOnTask, addGoalMetricToTask, addDependency, getKanbanQueueHealth, listKanbanEvents, recoverStaleTaskAssignments, heartbeatTaskAssignment, updateTaskAssignment, assignTask, releaseTaskClaim, claimReadyTask, getTaskChain, setTaskChain, removeTask, moveTask, updateTask, getTask, transferTaskToBoard, copyTaskToBoard, mergeTasks, splitTask, addTask, removeColumn, updateColumn, addColumn, getKanbanOrchestrationSnapshot, listReadyTasks, searchKanban, deserializeTaskGraph, syncBoardFromTaskGraph, exportBoardToTaskGraph, serializeTaskGraph, exportBoardAsMarkdown, generateBoardFromDescription, createBoard, parseLinesIntoTasks, getBoard, removeBoard, duplicateBoard, updateBoard, listBoards, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, FsError, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
3
+ import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, ToolError, FetchError, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, deepMerge, deserializeTaskGraph, serializeTaskGraph, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, FsError, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, mkdirSync, createWriteStream } from 'node:fs';
6
6
  import * as fs2 from 'node:fs/promises';
7
- import * as path3 from 'node:path';
7
+ import * as path12 from 'node:path';
8
8
  import { resolve, sep, dirname, join } from 'node:path';
9
9
  import * as os2 from 'node:os';
10
+ import { randomUUID, createHash } from 'node:crypto';
10
11
  import { toErrorMessage as toErrorMessage$3 } from '@wrongstack/core/utils';
11
12
  import { createRequire } from 'node:module';
12
13
  import { fileURLToPath } from 'node:url';
@@ -17,14 +18,14 @@ import * as dns from 'node:dns/promises';
17
18
  import * as net from 'node:net';
18
19
  import { Agent, fetch } from 'undici';
19
20
  import TurndownService from 'turndown';
20
- import { randomUUID } from 'node:crypto';
21
+ import { addLinkToTask, addNoteToTask, updateCheckOnTask, addCheckToTask, updateGoalMetricOnTask, addGoalMetricToTask, addDependency, getKanbanQueueHealth, listKanbanEvents, recoverStaleTaskAssignments, heartbeatTaskAssignment, updateTaskAssignment, assignTask, releaseTaskClaim, claimReadyTask, getTaskChain, setTaskChain, removeTask, moveTask, updateTask, getTask, transferTaskToBoard, copyTaskToBoard, mergeTasks, splitTask, addTask, removeColumn, updateColumn, addColumn, getKanbanOrchestrationSnapshot, listReadyTasks, searchKanban, syncBoardFromTaskGraph, exportBoardToTaskGraph, exportBoardAsMarkdown, generateBoardFromDescription, createBoard, parseLinesIntoTasks, getBoard, removeBoard, duplicateBoard, updateBoard, listBoards } from '@wrongstack/kanban';
21
22
 
22
23
  // src/_spawn-stream.ts
23
24
  var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
24
25
  var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
25
26
  var sweepStarted = false;
26
27
  function toolOutputDir() {
27
- return path3.join(wstackGlobalRoot(), "tool-output");
28
+ return path12.join(wstackGlobalRoot(), "tool-output");
28
29
  }
29
30
  function sweepOldSpoolFiles(dir) {
30
31
  if (sweepStarted) return;
@@ -34,7 +35,7 @@ function sweepOldSpoolFiles(dir) {
34
35
  const now = Date.now();
35
36
  for (const name of await fs2.readdir(dir)) {
36
37
  if (!name.endsWith(".log")) continue;
37
- const p = path3.join(dir, name);
38
+ const p = path12.join(dir, name);
38
39
  try {
39
40
  const st = await fs2.stat(p);
40
41
  if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fs2.unlink(p);
@@ -69,7 +70,7 @@ function createOutputSpool(opts) {
69
70
  sweepOldSpoolFiles(dir);
70
71
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
71
72
  const rand = Math.random().toString(36).slice(2, 6);
72
- filePath = path3.join(dir, `${stamp}-${safeTool}-${rand}.log`);
73
+ filePath = path12.join(dir, `${stamp}-${safeTool}-${rand}.log`);
73
74
  stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
74
75
  stream.on("error", () => {
75
76
  failed = true;
@@ -702,13 +703,13 @@ function getProcessRegistry() {
702
703
  }
703
704
  function resolveWin32Command(cmd) {
704
705
  if (process.platform !== "win32") return cmd;
705
- if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
706
+ if (cmd.includes("/") || cmd.includes("\\") || path12.extname(cmd.replace(/\//g, "\\"))) {
706
707
  return cmd;
707
708
  }
708
709
  const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
709
- const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
710
+ const pathDirs = (process.env["PATH"] ?? "").split(path12.delimiter);
710
711
  for (const dir of pathDirs) {
711
- const base = path3.join(dir, cmd);
712
+ const base = path12.join(dir, cmd);
712
713
  for (const ext of pathext) {
713
714
  const full = `${base}${ext}`;
714
715
  try {
@@ -922,37 +923,40 @@ async function* spawnStream(opts) {
922
923
  }
923
924
  }
924
925
  }
926
+ function sha256hex(content) {
927
+ return createHash("sha256").update(content, "utf8").digest("hex");
928
+ }
925
929
  async function detectPackageManager(cwd) {
926
- const { stat: stat11 } = await import('node:fs/promises');
930
+ const { stat: stat12 } = await import('node:fs/promises');
927
931
  try {
928
- await stat11(`${cwd}/pnpm-lock.yaml`);
932
+ await stat12(`${cwd}/pnpm-lock.yaml`);
929
933
  return "pnpm";
930
934
  } catch {
931
935
  }
932
936
  try {
933
- await stat11(`${cwd}/yarn.lock`);
937
+ await stat12(`${cwd}/yarn.lock`);
934
938
  return "yarn";
935
939
  } catch {
936
940
  }
937
941
  return "npm";
938
942
  }
939
943
  function resolvePath(input, ctx) {
940
- return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
944
+ return path12.isAbsolute(input) ? path12.normalize(input) : path12.resolve(ctx.workingDir ?? ctx.cwd, input);
941
945
  }
942
946
  function allowedRoots(ctx) {
943
- return [path3.resolve(ctx.projectRoot), path3.resolve(Core.wstackGlobalRoot())];
947
+ return [path12.resolve(ctx.projectRoot), path12.resolve(Core.wstackGlobalRoot())];
944
948
  }
945
949
  function isInsideAny(target, roots) {
946
950
  return roots.some((root) => {
947
- const rel = path3.relative(root, target);
948
- return rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel);
951
+ const rel = path12.relative(root, target);
952
+ return rel === "" || !rel.startsWith("..") && !path12.isAbsolute(rel);
949
953
  });
950
954
  }
951
955
  function ensureInsideRoot(absPath, ctx) {
952
- const target = path3.resolve(absPath);
956
+ const target = path12.resolve(absPath);
953
957
  if (ctx.allowOutsideProjectRoot) return target;
954
958
  if (isInsideAny(target, allowedRoots(ctx))) return target;
955
- throw new Error(`Path "${absPath}" is outside project root "${path3.resolve(ctx.projectRoot)}"`);
959
+ throw new Error(`Path "${absPath}" is outside project root "${path12.resolve(ctx.projectRoot)}"`);
956
960
  }
957
961
  function safeResolve(input, ctx) {
958
962
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -960,7 +964,7 @@ function safeResolve(input, ctx) {
960
964
  async function assertRealInsideRoot(absPath, ctx) {
961
965
  if (ctx.allowOutsideProjectRoot) return;
962
966
  const realRoots = await Promise.all(
963
- allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path3.resolve(r)))
967
+ allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path12.resolve(r)))
964
968
  );
965
969
  let probe = absPath;
966
970
  for (; ; ) {
@@ -969,7 +973,7 @@ async function assertRealInsideRoot(absPath, ctx) {
969
973
  real = await fs2.realpath(probe);
970
974
  } catch (err) {
971
975
  if (err.code === "ENOENT") {
972
- const parent = path3.dirname(probe);
976
+ const parent = path12.dirname(probe);
973
977
  if (parent === probe) return;
974
978
  probe = parent;
975
979
  continue;
@@ -1282,15 +1286,15 @@ var PersistentProcessRegistry = class {
1282
1286
  constructor(baseRegistry) {
1283
1287
  this.instanceId = generateInstanceId();
1284
1288
  const homeDir = os2.homedir();
1285
- this.registryPath = path3.join(homeDir, REGISTRY_FILE);
1286
- this.lockPath = path3.join(homeDir, LOCKFILE);
1289
+ this.registryPath = path12.join(homeDir, REGISTRY_FILE);
1290
+ this.lockPath = path12.join(homeDir, LOCKFILE);
1287
1291
  this.baseRegistry = baseRegistry ?? getProcessRegistry();
1288
1292
  this.ensureDirectory().catch((err) => {
1289
1293
  emitStructuredLog("warn", "process_registry.dir_create_failed", "PersistentProcessRegistry: failed to create .wrongstack directory", err);
1290
1294
  });
1291
1295
  }
1292
1296
  async ensureDirectory() {
1293
- const dir = path3.dirname(this.registryPath);
1297
+ const dir = path12.dirname(this.registryPath);
1294
1298
  try {
1295
1299
  await fs2.mkdir(dir, { recursive: true });
1296
1300
  } catch (err) {
@@ -1870,6 +1874,10 @@ var bashTool = {
1870
1874
  category: "Shell",
1871
1875
  description: "Execute an arbitrary command in the user's default shell (bash/zsh/pwsh/cmd). stdout and stderr are merged into one stream. This is the most powerful and dangerous tool \u2014 it gives the model full access to the developer's machine. Prefer specialized tools whenever possible.",
1872
1876
  usageHint: "SECURITY WARNING: This tool runs with the full privileges of the current user.\n\nBest practices for the model:\n- Strongly prefer `exec` for known safe commands (node, npm, pnpm, tsc, git, etc.).\n- Use bash only when you genuinely need shell features (pipes, redirection, complex one-liners).\n- Prefer single focused commands over huge `&&` chains.\n- Use `background: true` only for long-running processes (dev servers, watchers).\n- The working directory is the project root.\n- Output may be truncated in the middle for very large results.",
1877
+ selection: {
1878
+ doNotUseWhen: "the command is allowlisted and does not require pipes, redirection, or shell expansion.",
1879
+ useInstead: ["exec"]
1880
+ },
1873
1881
  permission: "confirm",
1874
1882
  mutating: true,
1875
1883
  riskTier: "destructive",
@@ -2731,7 +2739,7 @@ var IndexStore = class {
2731
2739
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
2732
2740
  fs.mkdirSync(this.indexDir, { recursive: true });
2733
2741
  const Database = loadDatabaseSync();
2734
- this.db = new Database(path3.join(this.indexDir, DB_FILE));
2742
+ this.db = new Database(path12.join(this.indexDir, DB_FILE));
2735
2743
  try {
2736
2744
  this.db.exec("PRAGMA journal_mode = WAL");
2737
2745
  this.db.exec("PRAGMA busy_timeout = 5000");
@@ -3310,7 +3318,7 @@ var IndexStore = class {
3310
3318
  }));
3311
3319
  }
3312
3320
  sizeBytes() {
3313
- const dbPath = path3.join(this.indexDir, DB_FILE);
3321
+ const dbPath = path12.join(this.indexDir, DB_FILE);
3314
3322
  try {
3315
3323
  return fs.statSync(dbPath).size;
3316
3324
  } catch {
@@ -3809,9 +3817,9 @@ func formatType(t ast.Expr) string {
3809
3817
  }
3810
3818
  `;
3811
3819
  async function syncGoParse(filePath, content, lang) {
3812
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), "ws-go-parse-"));
3820
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), "ws-go-parse-"));
3813
3821
  try {
3814
- const scriptPath = path3.join(tmpDir, "parse.go");
3822
+ const scriptPath = path12.join(tmpDir, "parse.go");
3815
3823
  await fs2.writeFile(scriptPath, GO_PARSE_SCRIPT, "utf8");
3816
3824
  const proc = spawn("go", ["run", scriptPath], {
3817
3825
  stdio: ["pipe", "pipe", "pipe"],
@@ -4073,9 +4081,9 @@ var _cachedScriptPath = null;
4073
4081
  async function syncPyParse(filePath, content, lang) {
4074
4082
  try {
4075
4083
  if (!_cachedScriptPath) {
4076
- const tmpDir = path3.join(os2.tmpdir(), "ws-py-parse");
4084
+ const tmpDir = path12.join(os2.tmpdir(), "ws-py-parse");
4077
4085
  await fs2.mkdir(tmpDir, { recursive: true });
4078
- _cachedScriptPath = path3.join(tmpDir, "parse.py");
4086
+ _cachedScriptPath = path12.join(tmpDir, "parse.py");
4079
4087
  await fs2.writeFile(_cachedScriptPath, PY_PARSE_SCRIPT, "utf8");
4080
4088
  }
4081
4089
  const proc = spawn("python", [_cachedScriptPath, filePath], {
@@ -4133,7 +4141,7 @@ async function parseSymbols4(opts) {
4133
4141
  function checkNativeParser() {
4134
4142
  try {
4135
4143
  execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
4136
- const toolsDir = path3.join(process.cwd(), "tools");
4144
+ const toolsDir = path12.join(process.cwd(), "tools");
4137
4145
  try {
4138
4146
  execFileSync(
4139
4147
  "cargo",
@@ -4143,7 +4151,7 @@ function checkNativeParser() {
4143
4151
  "--format-version",
4144
4152
  "1",
4145
4153
  "--manifest-path",
4146
- path3.join(toolsDir, "Cargo.toml")
4154
+ path12.join(toolsDir, "Cargo.toml")
4147
4155
  ],
4148
4156
  { stdio: "pipe", windowsHide: true }
4149
4157
  );
@@ -4157,13 +4165,13 @@ function checkNativeParser() {
4157
4165
  }
4158
4166
  async function tryNativeParse(file, content) {
4159
4167
  try {
4160
- const toolsDir = path3.join(process.cwd(), "tools");
4161
- const crateDir = path3.join(toolsDir, "syn-parser");
4162
- const tmpFile = path3.join(crateDir, "src", "input.rs");
4168
+ const toolsDir = path12.join(process.cwd(), "tools");
4169
+ const crateDir = path12.join(toolsDir, "syn-parser");
4170
+ const tmpFile = path12.join(crateDir, "src", "input.rs");
4163
4171
  await fs2.writeFile(tmpFile, content, "utf8");
4164
4172
  const proc = spawn(
4165
4173
  "cargo",
4166
- ["run", "--manifest-path", path3.join(toolsDir, "Cargo.toml")],
4174
+ ["run", "--manifest-path", path12.join(toolsDir, "Cargo.toml")],
4167
4175
  {
4168
4176
  cwd: process.cwd(),
4169
4177
  stdio: ["pipe", "pipe", "pipe"],
@@ -4213,16 +4221,16 @@ function regexParse(opts) {
4213
4221
  const { file, content, lang } = opts;
4214
4222
  const symbols = [];
4215
4223
  const lines = content.split("\n");
4216
- const lineOffsets = [0];
4224
+ const lineOffsets2 = [0];
4217
4225
  for (let i = 0; i < lines.length; i++) {
4218
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4226
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4219
4227
  }
4220
4228
  function lineFromOffset(offset) {
4221
4229
  let lo = 0;
4222
- let hi = lineOffsets.length - 1;
4230
+ let hi = lineOffsets2.length - 1;
4223
4231
  while (lo < hi) {
4224
4232
  const mid = lo + hi + 1 >>> 1;
4225
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4233
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4226
4234
  else hi = mid - 1;
4227
4235
  }
4228
4236
  return lo + 1;
@@ -4237,7 +4245,7 @@ function regexParse(opts) {
4237
4245
  const name = expectDefined(match[1]);
4238
4246
  const offset = match.index ?? 0;
4239
4247
  const line = lineFromOffset(offset);
4240
- const col = offset - (lineOffsets[line - 1] ?? 0);
4248
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4241
4249
  const lineIdx = line - 1;
4242
4250
  const signature = extractDeclaration(lineIdx);
4243
4251
  symbols.push({
@@ -4275,22 +4283,22 @@ function parseSymbols5(opts) {
4275
4283
  function regexParse2(opts) {
4276
4284
  const { file, content, lang } = opts;
4277
4285
  const symbols = [];
4278
- const basename2 = path3.basename(file).toLowerCase();
4279
- const isPackageJson = basename2 === "package.json";
4280
- const isTsconfig = basename2 === "tsconfig.json" || basename2 === "tsconfig.build.json";
4286
+ const basename4 = path12.basename(file).toLowerCase();
4287
+ const isPackageJson = basename4 === "package.json";
4288
+ const isTsconfig = basename4 === "tsconfig.json" || basename4 === "tsconfig.build.json";
4281
4289
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
4282
4290
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
4283
4291
  const lines = content.split("\n");
4284
- const lineOffsets = [0];
4292
+ const lineOffsets2 = [0];
4285
4293
  for (let i = 0; i < lines.length; i++) {
4286
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4294
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4287
4295
  }
4288
4296
  function lineFromOffset(offset) {
4289
4297
  let lo = 0;
4290
- let hi = lineOffsets.length - 1;
4298
+ let hi = lineOffsets2.length - 1;
4291
4299
  while (lo < hi) {
4292
4300
  const mid = lo + hi + 1 >>> 1;
4293
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4301
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4294
4302
  else hi = mid - 1;
4295
4303
  }
4296
4304
  return lo + 1;
@@ -4301,11 +4309,11 @@ function regexParse2(opts) {
4301
4309
  const line = lineFromOffset(offset);
4302
4310
  symbols.push(
4303
4311
  makeSymbol({
4304
- name: path3.basename(file),
4312
+ name: path12.basename(file),
4305
4313
  kind: "object",
4306
4314
  line,
4307
4315
  col: 0,
4308
- signature: `"${path3.basename(file)}" = { ... }`,
4316
+ signature: `"${path12.basename(file)}" = { ... }`,
4309
4317
  file,
4310
4318
  lang
4311
4319
  })
@@ -4316,7 +4324,7 @@ function regexParse2(opts) {
4316
4324
  const key = expectDefined(match[1]);
4317
4325
  const offset = match.index ?? 0;
4318
4326
  const line = lineFromOffset(offset);
4319
- const col = offset - (lineOffsets[line - 1] ?? 0);
4327
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4320
4328
  let kind = "property";
4321
4329
  let signature = `"${key}": ..."`;
4322
4330
  if (isPackageJson) {
@@ -4351,10 +4359,10 @@ function regexParse2(opts) {
4351
4359
  })
4352
4360
  );
4353
4361
  if (isPackageJson && key === "scripts") {
4354
- extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset);
4362
+ extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset);
4355
4363
  }
4356
4364
  if (isTsconfig && key === "compilerOptions") {
4357
- extractCompilerOptions(content, symbols, file, lang, lineOffsets, line, lineFromOffset);
4365
+ extractCompilerOptions(content, symbols, file, lang, lineOffsets2, line, lineFromOffset);
4358
4366
  }
4359
4367
  }
4360
4368
  const defsRegex = /"\$defs"\s*:|"\$defs"\s*:/g;
@@ -4367,7 +4375,7 @@ function regexParse2(opts) {
4367
4375
  name: "$defs",
4368
4376
  kind: "property",
4369
4377
  line,
4370
- col: offset - (lineOffsets[line - 1] ?? 0),
4378
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4371
4379
  signature: '"$defs": { ... }',
4372
4380
  file,
4373
4381
  lang
@@ -4391,7 +4399,7 @@ function regexParse2(opts) {
4391
4399
  name: key,
4392
4400
  kind: "property",
4393
4401
  line,
4394
- col: offset - (lineOffsets[line - 1] ?? 0),
4402
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4395
4403
  signature: `"${key}": { ... }`,
4396
4404
  file,
4397
4405
  lang
@@ -4401,7 +4409,7 @@ function regexParse2(opts) {
4401
4409
  }
4402
4410
  return { file, lang, symbols, mtimeMs: Date.now() };
4403
4411
  }
4404
- function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset) {
4412
+ function extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset) {
4405
4413
  const scriptsBlockRegex = /"scripts"\s*:\s*\{([^}]+)\}/g;
4406
4414
  for (let match = scriptsBlockRegex.exec(content); match !== null; match = scriptsBlockRegex.exec(content)) {
4407
4415
  const blockContent = expectDefined(match[0]);
@@ -4416,7 +4424,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4416
4424
  name: key,
4417
4425
  kind: "function",
4418
4426
  line,
4419
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4427
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4420
4428
  signature: `"${key}": "..."`,
4421
4429
  file,
4422
4430
  lang
@@ -4425,7 +4433,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4425
4433
  }
4426
4434
  }
4427
4435
  }
4428
- function extractCompilerOptions(content, symbols, file, lang, lineOffsets, parentLine, lineFromOffset) {
4436
+ function extractCompilerOptions(content, symbols, file, lang, lineOffsets2, parentLine, lineFromOffset) {
4429
4437
  const optsBlockRegex = /"compilerOptions"\s*:\s*\{([^}]+)\}/g;
4430
4438
  for (let match = optsBlockRegex.exec(content); match !== null; match = optsBlockRegex.exec(content)) {
4431
4439
  const blockContent = expectDefined(match[0]);
@@ -4441,7 +4449,7 @@ function extractCompilerOptions(content, symbols, file, lang, lineOffsets, paren
4441
4449
  name: key,
4442
4450
  kind: "property",
4443
4451
  line,
4444
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4452
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4445
4453
  signature: `"${key}": ...`,
4446
4454
  file,
4447
4455
  lang
@@ -4477,16 +4485,16 @@ function regexParse3(opts) {
4477
4485
  const { file, content, lang } = opts;
4478
4486
  const symbols = [];
4479
4487
  const lines = content.split("\n");
4480
- const lineOffsets = [0];
4488
+ const lineOffsets2 = [0];
4481
4489
  for (let i = 0; i < lines.length; i++) {
4482
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4490
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4483
4491
  }
4484
4492
  function lineFromOffset(offset) {
4485
4493
  let lo = 0;
4486
- let hi = lineOffsets.length - 1;
4494
+ let hi = lineOffsets2.length - 1;
4487
4495
  while (lo < hi) {
4488
4496
  const mid = lo + hi + 1 >>> 1;
4489
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4497
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4490
4498
  else hi = mid - 1;
4491
4499
  }
4492
4500
  return lo + 1;
@@ -4496,7 +4504,7 @@ function regexParse3(opts) {
4496
4504
  const name = expectDefined(match[1]);
4497
4505
  const offset = match.index ?? 0;
4498
4506
  const line = lineFromOffset(offset);
4499
- const col = offset - (lineOffsets[line - 1] ?? 0);
4507
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4500
4508
  symbols.push(
4501
4509
  makeSymbol2({
4502
4510
  name,
@@ -4514,7 +4522,7 @@ function regexParse3(opts) {
4514
4522
  const name = expectDefined(match[1]);
4515
4523
  const offset = match.index ?? 0;
4516
4524
  const line = lineFromOffset(offset);
4517
- const col = offset - (lineOffsets[line - 1] ?? 0);
4525
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4518
4526
  symbols.push(
4519
4527
  makeSymbol2({
4520
4528
  name,
@@ -4534,7 +4542,7 @@ function regexParse3(opts) {
4534
4542
  if (!key) continue;
4535
4543
  const offset = match.index ?? 0;
4536
4544
  const line = lineFromOffset(offset);
4537
- const col = offset - (lineOffsets[line - 1] ?? 0);
4545
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4538
4546
  const lineContent = lines[line - 1] ?? "";
4539
4547
  if (/^[|&>]/.test(lineContent.trim())) continue;
4540
4548
  if (key === "---" || key === "...") continue;
@@ -4549,7 +4557,7 @@ function regexParse3(opts) {
4549
4557
  const key = expectDefined(match[2]);
4550
4558
  const offset = match.index ?? 0;
4551
4559
  const line = lineFromOffset(offset);
4552
- const col = offset - (lineOffsets[line - 1] ?? 0);
4560
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4553
4561
  const value = extractValue(content, offset + match[0]?.length);
4554
4562
  const kind = isScalar(value) ? "literal" : "property";
4555
4563
  symbols.push(
@@ -4569,7 +4577,7 @@ function regexParse3(opts) {
4569
4577
  const key = expectDefined(match[2]);
4570
4578
  const offset = match.index ?? 0;
4571
4579
  const line = lineFromOffset(offset);
4572
- const col = offset - (lineOffsets[line - 1] ?? 0);
4580
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4573
4581
  symbols.push(
4574
4582
  makeSymbol2({
4575
4583
  name: key,
@@ -4655,7 +4663,7 @@ function compileGitignore(lines) {
4655
4663
  async function loadGitignoreMatcher(projectRoot) {
4656
4664
  let lines = [];
4657
4665
  try {
4658
- const raw = await fs2.readFile(path3.join(projectRoot, ".gitignore"), "utf8");
4666
+ const raw = await fs2.readFile(path12.join(projectRoot, ".gitignore"), "utf8");
4659
4667
  lines = raw.split("\n");
4660
4668
  } catch {
4661
4669
  }
@@ -4720,14 +4728,14 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
4720
4728
  dirCount++;
4721
4729
  for (const e of entries) {
4722
4730
  if (ignoreSet.has(e.name)) continue;
4723
- const full = path3.join(dir, e.name);
4724
- const rel = path3.relative(projectRoot, full).replace(/\\/g, "/");
4731
+ const full = path12.join(dir, e.name);
4732
+ const rel = path12.relative(projectRoot, full).replace(/\\/g, "/");
4725
4733
  if (e.isDirectory()) {
4726
4734
  if (isGitIgnored(rel, true)) continue;
4727
4735
  await walk(full);
4728
4736
  } else if (e.isFile()) {
4729
4737
  if (isGitIgnored(rel, false)) continue;
4730
- const ext = path3.extname(e.name);
4738
+ const ext = path12.extname(e.name);
4731
4739
  for (const { ext: extName, pat } of globs) {
4732
4740
  if (ext === extName && (pat.test(rel) || pat.test(e.name))) {
4733
4741
  results.push(full);
@@ -4783,7 +4791,7 @@ async function runIndexerWithStore(store, opts) {
4783
4791
  let files;
4784
4792
  let discoveredFiles = null;
4785
4793
  if (opts.files && opts.files.length > 0) {
4786
- files = opts.files.map((f) => path3.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path3.relative(projectRoot, f).replace(/\\/g, "/"), false));
4794
+ files = opts.files.map((f) => path12.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path12.relative(projectRoot, f).replace(/\\/g, "/"), false));
4787
4795
  } else {
4788
4796
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
4789
4797
  discoveredFiles = new Set(files);
@@ -4811,34 +4819,34 @@ async function runIndexerWithStore(store, opts) {
4811
4819
  const statOpts = signal ? { signal } : {};
4812
4820
  const statReadParse = await Promise.allSettled(
4813
4821
  batchFiles.map(async (file) => {
4814
- let stat11;
4822
+ let stat12;
4815
4823
  try {
4816
- stat11 = await fs2.stat(file, statOpts);
4824
+ stat12 = await fs2.stat(file, statOpts);
4817
4825
  } catch (e) {
4818
4826
  if (isAbortError(e)) throw e;
4819
4827
  return { file, stat: null, lang: "", parsed: null, error: `stat error: ${e instanceof Error ? e.message : String(e)}` };
4820
4828
  }
4821
- if (!stat11.isFile()) return { file, stat: stat11, lang: "", parsed: null };
4829
+ if (!stat12.isFile()) return { file, stat: stat12, lang: "", parsed: null };
4822
4830
  const lang = detectLang(file);
4823
- if (!lang) return { file, stat: stat11, lang: "", parsed: null };
4831
+ if (!lang) return { file, stat: stat12, lang: "", parsed: null };
4824
4832
  const meta = existingMeta.get(file);
4825
- if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
4826
- return { file, stat: stat11, lang, parsed: null, skippedMeta: meta };
4833
+ if (!force && meta && meta.mtimeMs === Math.floor(stat12.mtimeMs)) {
4834
+ return { file, stat: stat12, lang, parsed: null, skippedMeta: meta };
4827
4835
  }
4828
4836
  let content;
4829
4837
  try {
4830
4838
  content = await fs2.readFile(file, { encoding: "utf8", signal });
4831
4839
  } catch (e) {
4832
4840
  if (isAbortError(e)) throw e;
4833
- return { file, stat: stat11, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
4841
+ return { file, stat: stat12, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
4834
4842
  }
4835
4843
  let parsed;
4836
4844
  try {
4837
4845
  parsed = await parseFile(file, content, lang);
4838
4846
  } catch (e) {
4839
- return { file, stat: stat11, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
4847
+ return { file, stat: stat12, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
4840
4848
  }
4841
- return { file, stat: stat11, lang, parsed, content };
4849
+ return { file, stat: stat12, lang, parsed, content };
4842
4850
  })
4843
4851
  );
4844
4852
  const batchEntries = [];
@@ -4858,7 +4866,7 @@ async function runIndexerWithStore(store, opts) {
4858
4866
  if (result.error.includes("error:")) errors.push(result.error);
4859
4867
  continue;
4860
4868
  }
4861
- const { stat: stat11, lang, parsed } = result;
4869
+ const { stat: stat12, lang, parsed } = result;
4862
4870
  if (result.skippedMeta) {
4863
4871
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
4864
4872
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -4870,7 +4878,7 @@ async function runIndexerWithStore(store, opts) {
4870
4878
  store.upsertFile({
4871
4879
  file,
4872
4880
  lang,
4873
- mtimeMs: Math.floor(stat11.mtimeMs),
4881
+ mtimeMs: Math.floor(stat12.mtimeMs),
4874
4882
  symbolCount: 0,
4875
4883
  lastIndexed: Date.now()
4876
4884
  });
@@ -4882,7 +4890,7 @@ async function runIndexerWithStore(store, opts) {
4882
4890
  store.upsertFile({
4883
4891
  file,
4884
4892
  lang,
4885
- mtimeMs: Math.floor(stat11.mtimeMs),
4893
+ mtimeMs: Math.floor(stat12.mtimeMs),
4886
4894
  symbolCount: 0,
4887
4895
  lastIndexed: Date.now()
4888
4896
  });
@@ -4898,7 +4906,7 @@ async function runIndexerWithStore(store, opts) {
4898
4906
  lang,
4899
4907
  symbols: parsed.symbols,
4900
4908
  refs,
4901
- mtimeMs: Math.floor(stat11.mtimeMs),
4909
+ mtimeMs: Math.floor(stat12.mtimeMs),
4902
4910
  symbolCount: parsed.symbols.length
4903
4911
  });
4904
4912
  deleteForFiles.push(file);
@@ -5047,7 +5055,7 @@ function statsService(args) {
5047
5055
  }
5048
5056
 
5049
5057
  // src/codebase-index/background-indexer.ts
5050
- var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
5058
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 24e4;
5051
5059
  var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
5052
5060
  var _ready = false;
5053
5061
  var _indexing = false;
@@ -5504,6 +5512,25 @@ var codebaseStatsTool = {
5504
5512
  };
5505
5513
  }
5506
5514
  };
5515
+ async function resolveReal(p) {
5516
+ const resolved = path12.resolve(p);
5517
+ let probe = resolved;
5518
+ const missing = [];
5519
+ for (; ; ) {
5520
+ try {
5521
+ return path12.resolve(await fs2.realpath(probe), ...missing);
5522
+ } catch (err) {
5523
+ if (err.code === "ENOENT") {
5524
+ const parent = path12.dirname(probe);
5525
+ if (parent === probe) return resolved;
5526
+ missing.unshift(path12.basename(probe));
5527
+ probe = parent;
5528
+ continue;
5529
+ }
5530
+ return resolved;
5531
+ }
5532
+ }
5533
+ }
5507
5534
  function normalizeOverrides(set) {
5508
5535
  const out = {};
5509
5536
  if (set && typeof set === "object") {
@@ -5661,10 +5688,12 @@ These win over kit tokens. Run \`design {action:"materialize"}\` to write them t
5661
5688
  kitId: active.kit,
5662
5689
  outPath: input.out
5663
5690
  });
5664
- const root = path3.resolve(ctx.projectRoot);
5665
- const abs = path3.resolve(path3.join(ctx.projectRoot, result.path));
5666
- const rel = path3.relative(root, abs);
5667
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
5691
+ const root = await resolveReal(ctx.projectRoot);
5692
+ const absResolved = path12.isAbsolute(result.path) ? path12.resolve(result.path) : path12.resolve(path12.join(ctx.projectRoot, result.path));
5693
+ const absParent = await resolveReal(path12.dirname(absResolved));
5694
+ const abs = path12.join(absParent, path12.basename(absResolved));
5695
+ const rel = path12.relative(root, abs);
5696
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
5668
5697
  throw new Error(
5669
5698
  `design: materialize path "${result.path}" would escape the project root`
5670
5699
  );
@@ -5688,7 +5717,7 @@ ${result.content}
5688
5717
  \`\`\``
5689
5718
  };
5690
5719
  }
5691
- await fs2.mkdir(path3.dirname(abs), { recursive: true });
5720
+ await fs2.mkdir(path12.dirname(abs), { recursive: true });
5692
5721
  await fs2.writeFile(abs, result.content);
5693
5722
  return {
5694
5723
  action,
@@ -5816,11 +5845,11 @@ function findGitDir(cwd) {
5816
5845
  let dir = cwd;
5817
5846
  for (let i = 0; i < 20; i++) {
5818
5847
  try {
5819
- const stat11 = statSync(path3.join(dir, ".git"));
5820
- if (stat11.isDirectory()) return dir;
5848
+ const stat12 = statSync(path12.join(dir, ".git"));
5849
+ if (stat12.isDirectory()) return dir;
5821
5850
  } catch {
5822
5851
  }
5823
- const parent = path3.dirname(dir);
5852
+ const parent = path12.dirname(dir);
5824
5853
  if (parent === dir) break;
5825
5854
  dir = parent;
5826
5855
  }
@@ -5861,8 +5890,8 @@ async function fileDiff(input, ctx, _signal) {
5861
5890
  const results = [];
5862
5891
  for (const file of files) {
5863
5892
  const absPath = safeResolve(file, ctx);
5864
- const stat11 = await fs2.stat(absPath).catch(() => null);
5865
- if (!stat11?.isFile()) continue;
5893
+ const stat12 = await fs2.stat(absPath).catch(() => null);
5894
+ if (!stat12?.isFile()) continue;
5866
5895
  const content = await fs2.readFile(absPath, "utf8");
5867
5896
  const lines = content.split(/\r?\n/);
5868
5897
  results.push(formatWithLineNumbers(file, lines));
@@ -5959,8 +5988,8 @@ async function resolveFiles(filesInput, cwd) {
5959
5988
  for (const f of files) {
5960
5989
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
5961
5990
  try {
5962
- const stat11 = await fs2.stat(absPath);
5963
- if (stat11.isFile()) resolved.push(absPath);
5991
+ const stat12 = await fs2.stat(absPath);
5992
+ if (stat12.isFile()) resolved.push(absPath);
5964
5993
  } catch {
5965
5994
  }
5966
5995
  }
@@ -6026,11 +6055,303 @@ function processFile(content, absPath, _style, _overwrite, target) {
6026
6055
  }
6027
6056
  return results;
6028
6057
  }
6058
+
6059
+ // src/_edit-match.ts
6060
+ var TIER_LABEL = {
6061
+ exact: "exact match",
6062
+ "trailing-whitespace": "whitespace-normalized match (trailing whitespace ignored)",
6063
+ "whitespace-normalized": "whitespace-normalized match (indentation ignored, replacement re-indented)",
6064
+ fuzzy: "fuzzy match (block anchors exact, interior \u226590% similar)"
6065
+ };
6066
+ var TIER_CONFIDENCE = {
6067
+ exact: "exact",
6068
+ "trailing-whitespace": "high",
6069
+ "whitespace-normalized": "medium",
6070
+ fuzzy: "low"
6071
+ };
6072
+ var MIN_NORMALIZED_NEEDLE_CHARS = 16;
6073
+ var FUZZY_MIN_SIMILARITY = 0.9;
6074
+ var FUZZY_AMBIGUITY_MARGIN = 0.05;
6075
+ var FUZZY_MAX_INTERIOR_CHARS = 2e3;
6076
+ function findLadderMatches(fileLf, oldLf) {
6077
+ const exact = [];
6078
+ let idx = fileLf.indexOf(oldLf);
6079
+ while (idx !== -1) {
6080
+ exact.push({ start: idx, end: idx + oldLf.length, startLine: lineAt(fileLf, idx) });
6081
+ idx = fileLf.indexOf(oldLf, idx + 1);
6082
+ }
6083
+ if (exact.length > 0) return { tier: "exact", matches: exact };
6084
+ const fileLines = fileLf.split("\n");
6085
+ const needleLines = oldLf.split("\n");
6086
+ if (needleLines.length > fileLines.length) return void 0;
6087
+ const offsets = lineOffsets(fileLines);
6088
+ const trailing = windowScan(
6089
+ fileLines,
6090
+ needleLines,
6091
+ offsets,
6092
+ (a, b) => a.trimEnd() === b.trimEnd()
6093
+ );
6094
+ if (trailing.length > 0) return { tier: "trailing-whitespace", matches: trailing };
6095
+ const normalizedLen = needleLines.reduce((n, l) => n + l.trim().length, 0);
6096
+ if (normalizedLen < MIN_NORMALIZED_NEEDLE_CHARS) return void 0;
6097
+ const normalized = windowScan(fileLines, needleLines, offsets, (a, b) => a.trim() === b.trim());
6098
+ if (normalized.length > 0) return { tier: "whitespace-normalized", matches: normalized };
6099
+ return fuzzyScan(fileLines, needleLines, offsets);
6100
+ }
6101
+ function lineAt(text, pos) {
6102
+ let line = 1;
6103
+ for (let i = 0; i < pos; i++) {
6104
+ if (text.charCodeAt(i) === 10) line++;
6105
+ }
6106
+ return line;
6107
+ }
6108
+ function lineOffsets(lines) {
6109
+ const out = new Array(lines.length);
6110
+ let pos = 0;
6111
+ for (let i = 0; i < lines.length; i++) {
6112
+ out[i] = pos;
6113
+ pos += lines[i].length + 1;
6114
+ }
6115
+ return out;
6116
+ }
6117
+ function windowToMatch(fileLines, offsets, start, windowLen) {
6118
+ const lastLine = start + windowLen - 1;
6119
+ return {
6120
+ start: offsets[start],
6121
+ end: offsets[lastLine] + fileLines[lastLine].length,
6122
+ startLine: start + 1
6123
+ };
6124
+ }
6125
+ function windowScan(fileLines, needleLines, offsets, eq) {
6126
+ const n = needleLines.length;
6127
+ const out = [];
6128
+ for (let i = 0; i + n <= fileLines.length; i++) {
6129
+ let all = true;
6130
+ for (let j = 0; j < n; j++) {
6131
+ if (!eq(fileLines[i + j], needleLines[j])) {
6132
+ all = false;
6133
+ break;
6134
+ }
6135
+ }
6136
+ if (all) {
6137
+ out.push(windowToMatch(fileLines, offsets, i, n));
6138
+ i += n - 1;
6139
+ }
6140
+ }
6141
+ return out;
6142
+ }
6143
+ function fuzzyScan(fileLines, needleLines, offsets) {
6144
+ const n = needleLines.length;
6145
+ if (n < 3) return void 0;
6146
+ const firstNeedle = needleLines[0].trim();
6147
+ const lastNeedle = needleLines[n - 1].trim();
6148
+ const needleInterior = needleLines.slice(1, -1).map((l) => l.trim()).join("\n");
6149
+ if (needleInterior.length > FUZZY_MAX_INTERIOR_CHARS) return void 0;
6150
+ const candidates = [];
6151
+ for (let i = 0; i + n <= fileLines.length; i++) {
6152
+ if (fileLines[i].trim() !== firstNeedle) continue;
6153
+ if (fileLines[i + n - 1].trim() !== lastNeedle) continue;
6154
+ const windowInterior = fileLines.slice(i + 1, i + n - 1).map((l) => l.trim()).join("\n");
6155
+ if (windowInterior.length > FUZZY_MAX_INTERIOR_CHARS) continue;
6156
+ const score = similarity(needleInterior, windowInterior);
6157
+ if (score >= FUZZY_MIN_SIMILARITY) {
6158
+ candidates.push({ match: windowToMatch(fileLines, offsets, i, n), score });
6159
+ }
6160
+ }
6161
+ if (candidates.length === 0) return void 0;
6162
+ candidates.sort((a, b) => b.score - a.score);
6163
+ const best = candidates[0];
6164
+ const runnerUp = candidates[1];
6165
+ if (runnerUp && best.score - runnerUp.score < FUZZY_AMBIGUITY_MARGIN) {
6166
+ return {
6167
+ tier: "fuzzy",
6168
+ matches: candidates.map((c) => c.match),
6169
+ score: best.score,
6170
+ ambiguous: true
6171
+ };
6172
+ }
6173
+ return { tier: "fuzzy", matches: [best.match], score: best.score };
6174
+ }
6175
+ function similarity(a, b) {
6176
+ if (a === b) return 1;
6177
+ const max = Math.max(a.length, b.length);
6178
+ if (max === 0) return 1;
6179
+ if (Math.abs(a.length - b.length) / max > 1 - FUZZY_MIN_SIMILARITY + 0.05) {
6180
+ return 1 - Math.abs(a.length - b.length) / max;
6181
+ }
6182
+ return 1 - levenshtein(a, b) / max;
6183
+ }
6184
+ function levenshtein(a, b) {
6185
+ if (a.length === 0) return b.length;
6186
+ if (b.length === 0) return a.length;
6187
+ let prev = new Array(b.length + 1);
6188
+ let cur = new Array(b.length + 1);
6189
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
6190
+ for (let i = 1; i <= a.length; i++) {
6191
+ cur[0] = i;
6192
+ const ca = a.charCodeAt(i - 1);
6193
+ for (let j = 1; j <= b.length; j++) {
6194
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
6195
+ cur[j] = Math.min(
6196
+ prev[j] + 1,
6197
+ cur[j - 1] + 1,
6198
+ prev[j - 1] + cost
6199
+ );
6200
+ }
6201
+ [prev, cur] = [cur, prev];
6202
+ }
6203
+ return prev[b.length];
6204
+ }
6205
+ function firstLineIndent(text) {
6206
+ for (const line of text.split("\n")) {
6207
+ if (line.trim() === "") continue;
6208
+ return /^[ \t]*/.exec(line)[0];
6209
+ }
6210
+ return "";
6211
+ }
6212
+ function adjustIndent(newLf, fromIndent, toIndent) {
6213
+ if (fromIndent === toIndent) return { text: newLf, adjusted: false };
6214
+ const lines = newLf.split("\n");
6215
+ if (toIndent.startsWith(fromIndent)) {
6216
+ const extra = toIndent.slice(fromIndent.length);
6217
+ return {
6218
+ text: lines.map((l) => l.trim() === "" ? l : extra + l).join("\n"),
6219
+ adjusted: true
6220
+ };
6221
+ }
6222
+ if (fromIndent.startsWith(toIndent)) {
6223
+ const remove = fromIndent.slice(toIndent.length);
6224
+ return {
6225
+ text: lines.map((l) => l.startsWith(remove) ? l.slice(remove.length) : l).join("\n"),
6226
+ adjusted: true
6227
+ };
6228
+ }
6229
+ return { text: newLf, adjusted: false };
6230
+ }
6231
+ function nearestMatchHint(fileLf, oldLf) {
6232
+ const fileLines = fileLf.split("\n");
6233
+ const needleLines = oldLf.split("\n");
6234
+ if (needleLines.length > fileLines.length) return void 0;
6235
+ const needleTrimmed = needleLines.map((l) => l.trim());
6236
+ let bestScore = 0;
6237
+ let bestStart = -1;
6238
+ for (let i = 0; i + needleLines.length <= fileLines.length; i++) {
6239
+ let sum = 0;
6240
+ for (let j = 0; j < needleLines.length; j++) {
6241
+ sum += prefixSimilarity(needleTrimmed[j], fileLines[i + j].trim());
6242
+ }
6243
+ const score = sum / needleLines.length;
6244
+ if (score > bestScore) {
6245
+ bestScore = score;
6246
+ bestStart = i;
6247
+ }
6248
+ }
6249
+ if (bestStart === -1 || bestScore < 0.5) return void 0;
6250
+ const snippet = fileLines.slice(bestStart, bestStart + Math.min(3, needleLines.length)).map((l) => l.length > 120 ? `${l.slice(0, 120)}\u2026` : l).join("\n");
6251
+ return { line: bestStart + 1, snippet };
6252
+ }
6253
+ function prefixSimilarity(a, b) {
6254
+ if (a === b) return 1;
6255
+ const max = Math.max(a.length, b.length);
6256
+ if (max === 0) return 1;
6257
+ let p = 0;
6258
+ const lim = Math.min(a.length, b.length);
6259
+ while (p < lim && a.charCodeAt(p) === b.charCodeAt(p)) p++;
6260
+ return p / max;
6261
+ }
6262
+ var TS_LIKE = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
6263
+ var MAX_CHECK_CHARS = 15e5;
6264
+ var MAX_ERRORS = 5;
6265
+ var tsLoad = null;
6266
+ function loadTypescript() {
6267
+ tsLoad ??= import('typescript').then(
6268
+ (m) => m.default ?? m,
6269
+ () => null
6270
+ );
6271
+ return tsLoad;
6272
+ }
6273
+ async function checkSyntax(filePath, content, previousContent) {
6274
+ const errors = await parseErrors(filePath, content);
6275
+ if (errors === void 0) return void 0;
6276
+ if (errors.length === 0) return { errors, preExisting: false };
6277
+ let preExisting = false;
6278
+ if (previousContent !== void 0) {
6279
+ const prevErrors = await parseErrors(filePath, previousContent);
6280
+ preExisting = prevErrors !== void 0 && prevErrors.length > 0;
6281
+ }
6282
+ return { errors, preExisting };
6283
+ }
6284
+ function isJsoncFile(filePath) {
6285
+ const base = path12.basename(filePath).toLowerCase();
6286
+ if (base.endsWith(".jsonc")) return true;
6287
+ if (/^(tsconfig|jsconfig)([.-].*)?\.json$/.test(base)) return true;
6288
+ const dir = path12.basename(path12.dirname(filePath)).toLowerCase();
6289
+ return dir === ".vscode";
6290
+ }
6291
+ async function parseErrors(filePath, content) {
6292
+ if (content.length > MAX_CHECK_CHARS) return void 0;
6293
+ const ext = path12.extname(filePath).toLowerCase();
6294
+ if (ext === ".json" || ext === ".jsonc") {
6295
+ try {
6296
+ JSON.parse(content);
6297
+ return [];
6298
+ } catch (err) {
6299
+ if (isJsoncFile(filePath)) {
6300
+ const ts3 = await loadTypescript();
6301
+ if (ts3) {
6302
+ const jsonc = ts3.parseConfigFileTextToJson(filePath, content);
6303
+ if (!jsonc.error) return [];
6304
+ return [formatDiag(ts3, jsonc.error, content)];
6305
+ }
6306
+ }
6307
+ return [`JSON parse error: ${err.message}`];
6308
+ }
6309
+ }
6310
+ if (!TS_LIKE.has(ext)) return void 0;
6311
+ const ts2 = await loadTypescript();
6312
+ if (!ts2) return void 0;
6313
+ const scriptKind = ext === ".tsx" ? ts2.ScriptKind.TSX : ext === ".ts" || ext === ".mts" || ext === ".cts" ? ts2.ScriptKind.TS : (
6314
+ // Plain JS may legitimately contain JSX; the JSX grammar is a
6315
+ // superset for untyped code, so parsing .js/.jsx as JSX avoids
6316
+ // false positives on React files.
6317
+ ts2.ScriptKind.JSX
6318
+ );
6319
+ const sourceFile = ts2.createSourceFile(
6320
+ path12.basename(filePath),
6321
+ content,
6322
+ ts2.ScriptTarget.Latest,
6323
+ /* setParentNodes */
6324
+ false,
6325
+ scriptKind
6326
+ );
6327
+ const diags = sourceFile.parseDiagnostics ?? [];
6328
+ return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts2, d, content, sourceFile));
6329
+ }
6330
+ function formatDiag(ts2, diag, content, sourceFile) {
6331
+ const message = ts2.flattenDiagnosticMessageText(diag.messageText, " ");
6332
+ if (diag.start === void 0) return message;
6333
+ let line;
6334
+ if (sourceFile) {
6335
+ line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;
6336
+ } else {
6337
+ line = 1;
6338
+ for (let i = 0; i < diag.start && i < content.length; i++) {
6339
+ if (content.charCodeAt(i) === 10) line++;
6340
+ }
6341
+ }
6342
+ return `line ${line}: ${message}`;
6343
+ }
6344
+
6345
+ // src/edit.ts
6029
6346
  var editTool = {
6030
6347
  name: "edit",
6031
6348
  category: "Filesystem",
6032
6349
  description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
6033
- usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.",
6350
+ usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.\nIf `old_string` differs from the file only in whitespace (trailing spaces, indentation), a lower-confidence fallback match is applied and reported in `matched_by`/`note` \u2014 always verify the diff when this happens.\nAfter the edit, TS/JS/JSON files are syntax-checked; if `syntax_errors` is present in the output, fix those errors immediately with a follow-up edit.",
6351
+ selection: {
6352
+ doNotUseWhen: "creating a new file, replacing the whole file, or applying an existing unified diff.",
6353
+ useInstead: ["write", "patch"]
6354
+ },
6034
6355
  permission: "confirm",
6035
6356
  mutating: true,
6036
6357
  capabilities: ["fs.write"],
@@ -6046,7 +6367,7 @@ var editTool = {
6046
6367
  },
6047
6368
  required: ["path", "old_string", "new_string"]
6048
6369
  },
6049
- async execute(input, ctx) {
6370
+ async execute(input, ctx, opts) {
6050
6371
  if (!input?.path) {
6051
6372
  throw new ToolValidationError({ message: "edit: path is required", field: "path" });
6052
6373
  }
@@ -6069,7 +6390,7 @@ var editTool = {
6069
6390
  });
6070
6391
  }
6071
6392
  const absPath = await safeResolveReal(input.path, ctx);
6072
- const stat11 = await fs2.stat(absPath).catch((err) => {
6393
+ const stat12 = await fs2.stat(absPath).catch((err) => {
6073
6394
  if (err.code === "ENOENT") {
6074
6395
  throw new ToolValidationError({
6075
6396
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -6079,7 +6400,7 @@ var editTool = {
6079
6400
  }
6080
6401
  throw err;
6081
6402
  });
6082
- if (!stat11.isFile()) {
6403
+ if (!stat12.isFile()) {
6083
6404
  throw new ToolValidationError({
6084
6405
  message: `edit: "${input.path}" is not a regular file`,
6085
6406
  field: "path"
@@ -6089,15 +6410,27 @@ var editTool = {
6089
6410
  const original = await fs2.readFile(absPath, "utf8");
6090
6411
  const updated = await fs2.stat(absPath);
6091
6412
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
6092
- const lastReadMtime = ctx.lastReadMtime(absPath);
6093
- if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6094
- throw new ToolValidationError({
6095
- message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6096
- field: "path",
6097
- context: { reason: "external_modification" }
6098
- });
6413
+ const originalHash = sha256hex(original);
6414
+ const lastReadHash = ctx.lastReadHash?.(absPath);
6415
+ if (lastReadHash !== void 0) {
6416
+ if (lastReadHash !== originalHash) {
6417
+ throw new ToolValidationError({
6418
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6419
+ field: "path",
6420
+ context: { reason: "external_modification" }
6421
+ });
6422
+ }
6423
+ } else {
6424
+ const lastReadMtime = ctx.lastReadMtime(absPath);
6425
+ if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6426
+ throw new ToolValidationError({
6427
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6428
+ field: "path",
6429
+ context: { reason: "external_modification" }
6430
+ });
6431
+ }
6099
6432
  }
6100
- if (autoRead && updated.mtimeMs > stat11.mtimeMs + mtimeTolerance) {
6433
+ if (autoRead && updated.mtimeMs > stat12.mtimeMs + mtimeTolerance) {
6101
6434
  throw new ToolValidationError({
6102
6435
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
6103
6436
  field: "path",
@@ -6110,42 +6443,78 @@ var editTool = {
6110
6443
  const oldLf = normalizeToLf(input.old_string);
6111
6444
  const newLf = normalizeToLf(input.new_string);
6112
6445
  if (oldLf === newLf) {
6113
- if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
6446
+ if (autoRead) ctx.recordRead(absPath, updated.mtimeMs, "user", originalHash);
6114
6447
  return {
6115
6448
  path: absPath,
6116
6449
  replacements: 0,
6117
6450
  diff: "(no-op: old and new are identical)",
6451
+ matched_by: "exact",
6118
6452
  note: autoReadNote
6119
6453
  };
6120
6454
  }
6121
- let count = 0;
6122
- let idx = fileLf.indexOf(oldLf);
6123
- const matches = [];
6124
- while (idx !== -1) {
6125
- matches.push(idx);
6126
- count++;
6127
- idx = fileLf.indexOf(oldLf, idx + 1);
6128
- }
6129
- if (count === 0) {
6130
- const hint = findSimilarity(fileLf, oldLf);
6455
+ const ladder = findLadderMatches(fileLf, oldLf);
6456
+ if (!ladder) {
6457
+ const hint = nearestMatchHint(fileLf, oldLf);
6131
6458
  throw new ToolValidationError({
6132
- message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`,
6459
+ message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint.line}:
6460
+ ${hint.snippet}
6461
+ Compare this against your old_string and retry with the file's actual text.` : ""}`,
6133
6462
  field: "old_string"
6134
6463
  });
6135
6464
  }
6465
+ const { tier, matches } = ladder;
6466
+ const count = matches.length;
6467
+ if (ladder.ambiguous) {
6468
+ const lines = matches.map((m) => m.startLine);
6469
+ throw new ToolValidationError({
6470
+ message: `edit: old_string only matched fuzzily and ${count} candidate blocks scored too close to distinguish (lines: ${lines.join(", ")}) in "${input.path}". Re-read the file and use the exact text of the intended block.`,
6471
+ field: "old_string",
6472
+ context: { occurrences: count, matchTier: tier }
6473
+ });
6474
+ }
6475
+ if (input.replace_all && tier !== "exact" && tier !== "trailing-whitespace") {
6476
+ throw new ToolValidationError({
6477
+ message: `edit: old_string only matched via ${TIER_LABEL[tier]} in "${input.path}", but replace_all requires an exact (or trailing-whitespace) match. Re-read the file and use its exact text.`,
6478
+ field: "old_string",
6479
+ context: { matchTier: tier }
6480
+ });
6481
+ }
6136
6482
  if (count > 1 && !input.replace_all) {
6137
- const lines = lineNumbersFor(fileLf, matches);
6483
+ const lines = matches.map((m) => m.startLine);
6138
6484
  throw new ToolValidationError({
6139
- message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true.`,
6485
+ message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")})${tier === "exact" ? "" : ` via ${TIER_LABEL[tier]}`}. Add more context to make it unique, or set replace_all: true.`,
6140
6486
  field: "old_string",
6141
- context: { occurrences: count }
6487
+ context: { occurrences: count, matchTier: tier }
6142
6488
  });
6143
6489
  }
6144
- const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6490
+ let newFileLf;
6491
+ let tierNote;
6492
+ if (tier === "exact") {
6493
+ newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6494
+ } else {
6495
+ let replacement = newLf;
6496
+ let indentSuffix = "";
6497
+ if (tier === "whitespace-normalized" || tier === "fuzzy") {
6498
+ const first = matches[0];
6499
+ const matchedText = fileLf.slice(first.start, first.end);
6500
+ const adjusted = adjustIndent(newLf, firstLineIndent(oldLf), firstLineIndent(matchedText));
6501
+ replacement = adjusted.text;
6502
+ if (adjusted.adjusted) indentSuffix = "; replacement re-indented to match the file";
6503
+ }
6504
+ newFileLf = fileLf;
6505
+ const applied = input.replace_all ? matches : matches.slice(0, 1);
6506
+ for (let i = applied.length - 1; i >= 0; i--) {
6507
+ const m = applied[i];
6508
+ newFileLf = newFileLf.slice(0, m.start) + replacement + newFileLf.slice(m.end);
6509
+ }
6510
+ const scoreSuffix = ladder.score !== void 0 ? `, similarity ${(ladder.score * 100).toFixed(1)}%` : "";
6511
+ tierNote = `old_string did not match exactly; applied via ${TIER_LABEL[tier]} at line ${matches[0].startLine} (confidence: ${TIER_CONFIDENCE[tier]}${scoreSuffix}${indentSuffix}). Review the diff to confirm the intended target was edited.`;
6512
+ }
6145
6513
  const newFile = toStyle(newFileLf, style);
6514
+ opts?.signal?.throwIfAborted();
6146
6515
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
6147
6516
  const written = await fs2.stat(absPath);
6148
- ctx.recordRead(absPath, written.mtimeMs, "write");
6517
+ ctx.recordRead(absPath, written.mtimeMs, "write", sha256hex(newFile));
6149
6518
  ctx.session.recordFileChange({
6150
6519
  path: absPath,
6151
6520
  action: "modified",
@@ -6156,38 +6525,22 @@ var editTool = {
6156
6525
  fromFile: input.path,
6157
6526
  toFile: input.path
6158
6527
  });
6528
+ const syntax = await checkSyntax(absPath, newFile, original).catch(() => void 0);
6529
+ let syntaxNote;
6530
+ if (syntax && syntax.errors.length > 0) {
6531
+ syntaxNote = syntax.preExisting ? `Syntax check: the file still has parse errors (they pre-date this edit) \u2014 see syntax_errors.` : `Syntax check: this edit introduced ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.`;
6532
+ }
6533
+ const notes = [autoReadNote, tierNote, syntaxNote].filter(Boolean);
6159
6534
  return {
6160
6535
  path: absPath,
6161
6536
  replacements: input.replace_all ? count : 1,
6162
6537
  diff,
6163
- note: autoReadNote
6538
+ matched_by: tier,
6539
+ syntax_errors: syntax && syntax.errors.length > 0 ? syntax.errors : void 0,
6540
+ note: notes.length > 0 ? notes.join("\n") : void 0
6164
6541
  };
6165
6542
  }
6166
6543
  };
6167
- function lineNumbersFor(text, indices) {
6168
- const out = [];
6169
- let pos = 0;
6170
- let line = 1;
6171
- for (const target of indices) {
6172
- while (pos < target) {
6173
- if (text.charCodeAt(pos) === 10) line++;
6174
- pos++;
6175
- }
6176
- out.push(line);
6177
- }
6178
- return out;
6179
- }
6180
- function findSimilarity(haystack, needle) {
6181
- if (needle.length < 20) return void 0;
6182
- const probe = needle.slice(0, Math.min(40, needle.length));
6183
- const idx = haystack.indexOf(probe);
6184
- if (idx === -1) return void 0;
6185
- let line = 1;
6186
- for (let i = 0; i < idx; i++) {
6187
- if (haystack.charCodeAt(i) === 10) line++;
6188
- }
6189
- return line;
6190
- }
6191
6544
 
6192
6545
  // src/_danger-detect.ts
6193
6546
  var argHas = (args, value) => args.includes(value);
@@ -6400,23 +6753,18 @@ var RULES = [
6400
6753
  },
6401
6754
  reason: "inline script evaluation (-c / -e / --eval)"
6402
6755
  },
6403
- // ----- pipe-to-shell (cautionwell-known exfil pattern) -----
6756
+ // ----- pipe-to-shell (destructivedownload-and-run pattern) -----
6404
6757
  // The classic `curl https://... | sh` download-and-run vector. Detected by
6405
- // looking for a known fetcher followed by a shell sink. We use a simple
6406
- // substring scan; false positives are limited because both tokens must
6407
- // appear in the same argv.
6758
+ // looking for a known fetcher piped into a shell or expression evaluator.
6759
+ // A shell command passed through `bash -c` / `pwsh -Command` arrives as one
6760
+ // argv string, so scan the reconstructed argv text rather than individual
6761
+ // tokens only.
6408
6762
  {
6409
6763
  id: "pipe-to-shell",
6410
- level: "caution",
6411
- test: (_cmd, args) => {
6412
- const hasFetcher = args.some(
6413
- (a) => /^(curl|wget|fetch|httpie|http)$/i.test(a) || a.startsWith("curl") || a.startsWith("wget")
6414
- );
6415
- const hasShellSink = args.some(
6416
- (a) => a === "sh" || a === "bash" || a === "zsh" || a === "fish" || a === "pwsh" || a === "powershell" || a.endsWith("/sh") || a.endsWith("/bash") || a.endsWith("/zsh") || a.endsWith("/pwsh")
6417
- );
6418
- return hasFetcher && hasShellSink;
6419
- },
6764
+ level: "destructive",
6765
+ test: (cmd, args) => /\b(?:curl|wget|fetch|httpie|http|irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[\s\S]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|fish|pwsh|powershell|iex|Invoke-Expression)\b/i.test(
6766
+ [cmd, ...args].join(" ")
6767
+ ),
6420
6768
  reason: "network fetch piped to a shell (download-and-run pattern)"
6421
6769
  },
6422
6770
  // ----- privilege escalation (caution) -----
@@ -7178,6 +7526,10 @@ var execTool = {
7178
7526
  category: "Shell",
7179
7527
  description: "Execute a **whitelisted, restricted set of commands** with strict argument validation. This is the **preferred and safer** alternative to the `bash` tool for running development tools (node, npm, pnpm, tsc, git, tests, linters, etc.). It prevents arbitrary command injection and limits what the model can do.",
7180
7528
  usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be in the allowlist. Defaults cover JS (node/npm/pnpm/yarn/bun/deno/tsc/vitest/eslint/biome), Go (`go build`/`go test`), Rust (cargo), Python (python/pip), Ruby (gem/bundle), JVM (java/mvn/gradle), .NET (dotnet), native (make/cmake), and git. Users can extend it via `tools.exec.allow` in config.\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- If a command is not allowlisted, the error explains how to add it; for one-off arbitrary commands, fall back to `bash` (with strong justification).\nThis tool significantly reduces the risk compared to full shell access.",
7529
+ selection: {
7530
+ doNotUseWhen: "the operation requires pipes, redirection, shell expansion, or a non-allowlisted command.",
7531
+ useInstead: ["bash"]
7532
+ },
7181
7533
  permission: "confirm",
7182
7534
  mutating: true,
7183
7535
  riskTier: "standard",
@@ -7841,13 +8193,13 @@ var formatTool = {
7841
8193
  }
7842
8194
  };
7843
8195
  async function detectFixer(cwd) {
7844
- const { stat: stat11 } = await import('node:fs/promises');
8196
+ const { stat: stat12 } = await import('node:fs/promises');
7845
8197
  try {
7846
- await stat11(`${cwd}/biome.json`);
8198
+ await stat12(`${cwd}/biome.json`);
7847
8199
  return "biome";
7848
8200
  } catch {
7849
8201
  try {
7850
- await stat11(`${cwd}/.prettierrc`);
8202
+ await stat12(`${cwd}/.prettierrc`);
7851
8203
  return "prettier";
7852
8204
  } catch {
7853
8205
  return "biome";
@@ -8008,8 +8360,8 @@ function findGitDir2(cwd, projectRoot) {
8008
8360
  let dir = cwd;
8009
8361
  for (let i = 0; i < 20; i++) {
8010
8362
  try {
8011
- const stat11 = statSync(`${dir}/.git`);
8012
- if (stat11.isDirectory() || stat11.isFile()) return dir;
8363
+ const stat12 = statSync(`${dir}/.git`);
8364
+ if (stat12.isDirectory() || stat12.isFile()) return dir;
8013
8365
  } catch {
8014
8366
  }
8015
8367
  if (dir === root) break;
@@ -8156,6 +8508,10 @@ var globTool = {
8156
8508
  category: "Filesystem",
8157
8509
  description: "Find files matching a glob pattern. Fast way to discover relevant files before reading, grepping, or editing them.",
8158
8510
  usageHint: "RECOMMENDED FOR SCOPING SEARCHES:\n\n- Use early to get a list of files you actually care about.\n- Combine with `path` and `limit`.\n- Default ignores common build/dependency directories.\nMuch more efficient than shell `find` for most use cases inside the agent.",
8511
+ selection: {
8512
+ doNotUseWhen: "you need to search inside file contents.",
8513
+ useInstead: ["grep"]
8514
+ },
8159
8515
  permission: "auto",
8160
8516
  mutating: false,
8161
8517
  capabilities: ["fs.read"],
@@ -8180,8 +8536,9 @@ var globTool = {
8180
8536
  },
8181
8537
  required: ["pattern"]
8182
8538
  },
8183
- async execute(input, ctx) {
8539
+ async execute(input, ctx, opts) {
8184
8540
  if (!input?.pattern) throw new Error("glob: pattern is required");
8541
+ const signal = opts?.signal;
8185
8542
  const base = input.path ? await safeResolveReal(input.path, ctx) : ctx.cwd;
8186
8543
  const limit = Math.max(1, Math.min(input.limit ?? 1e3, 5e3));
8187
8544
  const ignored = await readGitignore(base);
@@ -8205,6 +8562,10 @@ var globTool = {
8205
8562
  }
8206
8563
  };
8207
8564
  const walk = async (dir, relPrefix) => {
8565
+ if (signal?.aborted) {
8566
+ truncated = true;
8567
+ return;
8568
+ }
8208
8569
  if (results.length >= limit) {
8209
8570
  truncated = true;
8210
8571
  return;
@@ -8222,7 +8583,7 @@ var globTool = {
8222
8583
  if (DEFAULT_IGNORE2.includes(name)) continue;
8223
8584
  if (ignored.includes(name)) continue;
8224
8585
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
8225
- const full = path3.join(dir, name);
8586
+ const full = path12.join(dir, name);
8226
8587
  if (e.isDirectory()) {
8227
8588
  subdirs.push({ full, rel });
8228
8589
  } else if (e.isFile()) {
@@ -8266,7 +8627,7 @@ var globTool = {
8266
8627
  };
8267
8628
  async function readGitignore(dir) {
8268
8629
  try {
8269
- const raw = await fs2.readFile(path3.join(dir, ".gitignore"), "utf8");
8630
+ const raw = await fs2.readFile(path12.join(dir, ".gitignore"), "utf8");
8270
8631
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
8271
8632
  } catch {
8272
8633
  return [];
@@ -8328,6 +8689,10 @@ var grepTool = {
8328
8689
  category: "Search",
8329
8690
  description: "Search across files using a regular expression. This is one of the primary code search tools. Prefers ripgrep for speed and features when available.",
8330
8691
  usageHint: 'POWERFUL CODE SEARCH TOOL:\n\n- `pattern` is a regular expression.\n- Use `output_mode: "content"` (default) to get matching lines with context.\n- Use `"files_with_matches"` when you only need the list of files.\n- Use `"count"` for quick statistics.\n- `glob` and `path` let you narrow the search scope significantly.\n- Always prefer this over `bash grep` when searching code.',
8692
+ selection: {
8693
+ doNotUseWhen: "you only need to locate files by name or path pattern.",
8694
+ useInstead: ["glob"]
8695
+ },
8331
8696
  permission: "auto",
8332
8697
  mutating: false,
8333
8698
  capabilities: ["fs.read"],
@@ -8567,8 +8932,8 @@ async function runNative(input, base, mode, limit, signal) {
8567
8932
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
8568
8933
  if (globRe) globRe.lastIndex = 0;
8569
8934
  try {
8570
- const stat11 = await fs2.stat(full);
8571
- if (!stat11.isFile() || stat11.size > maxBytes || stopped || signal.aborted) return;
8935
+ const stat12 = await fs2.stat(full);
8936
+ if (!stat12.isFile() || stat12.size > maxBytes || stopped || signal.aborted) return;
8572
8937
  const file = await fs2.open(full, "r");
8573
8938
  try {
8574
8939
  let bytesReadTotal = 0;
@@ -8658,7 +9023,7 @@ async function runNative(input, base, mode, limit, signal) {
8658
9023
  if (stopped) return;
8659
9024
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
8660
9025
  if (e.isSymbolicLink()) continue;
8661
- const full = path3.join(dir, e.name);
9026
+ const full = path12.join(dir, e.name);
8662
9027
  if (e.isDirectory()) {
8663
9028
  subdirs.push(full);
8664
9029
  } else if (e.isFile()) {
@@ -9205,56 +9570,56 @@ function jmespathSearch(data, query) {
9205
9570
  }
9206
9571
  function validateJsonSchema(data, schema) {
9207
9572
  const errors = [];
9208
- function check(value, s, path22) {
9573
+ function check(value, s, path23) {
9209
9574
  if (s["type"]) {
9210
9575
  const expectedType = s["type"];
9211
9576
  const actualType = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
9212
9577
  if (expectedType === "integer") {
9213
- if (!Number.isInteger(value)) errors.push(`${path22}: expected integer, got ${actualType}`);
9578
+ if (!Number.isInteger(value)) errors.push(`${path23}: expected integer, got ${actualType}`);
9214
9579
  } else if (expectedType !== actualType) {
9215
- errors.push(`${path22}: expected ${expectedType}, got ${actualType}`);
9580
+ errors.push(`${path23}: expected ${expectedType}, got ${actualType}`);
9216
9581
  }
9217
9582
  }
9218
9583
  if (typeof value === "string" && s["format"] === "uri" && value) {
9219
9584
  try {
9220
9585
  new URL(value);
9221
9586
  } catch {
9222
- errors.push(`${path22}: not a valid URI`);
9587
+ errors.push(`${path23}: not a valid URI`);
9223
9588
  }
9224
9589
  }
9225
9590
  if (typeof value === "string" && s["pattern"]) {
9226
9591
  const re = new RegExp(s["pattern"]);
9227
- if (!re.test(value)) errors.push(`${path22}: does not match pattern ${s["pattern"]}`);
9592
+ if (!re.test(value)) errors.push(`${path23}: does not match pattern ${s["pattern"]}`);
9228
9593
  }
9229
9594
  if (typeof value === "string" && s["minLength"] !== void 0 && value.length < s["minLength"]) {
9230
- errors.push(`${path22}: string too short (min ${s["minLength"]})`);
9595
+ errors.push(`${path23}: string too short (min ${s["minLength"]})`);
9231
9596
  }
9232
9597
  if (typeof value === "string" && s["maxLength"] !== void 0 && value.length > s["maxLength"]) {
9233
- errors.push(`${path22}: string too long (max ${s["maxLength"]})`);
9598
+ errors.push(`${path23}: string too long (max ${s["maxLength"]})`);
9234
9599
  }
9235
9600
  if (typeof value === "number" && s["minimum"] !== void 0 && value < s["minimum"]) {
9236
- errors.push(`${path22}: below minimum ${s["minimum"]}`);
9601
+ errors.push(`${path23}: below minimum ${s["minimum"]}`);
9237
9602
  }
9238
9603
  if (typeof value === "number" && s["maximum"] !== void 0 && value > s["maximum"]) {
9239
- errors.push(`${path22}: above maximum ${s["maximum"]}`);
9604
+ errors.push(`${path23}: above maximum ${s["maximum"]}`);
9240
9605
  }
9241
9606
  if (Array.isArray(value) && s["items"] && Array.isArray(s["items"])) {
9242
9607
  for (let i = 0; i < value.length; i++) {
9243
- check(value[i], s["items"], `${path22}[${i}]`);
9608
+ check(value[i], s["items"], `${path23}[${i}]`);
9244
9609
  }
9245
9610
  }
9246
9611
  if (typeof value === "object" && value !== null && !Array.isArray(value) && s["properties"]) {
9247
9612
  const props = s["properties"];
9248
9613
  for (const [k, propSchema] of Object.entries(props)) {
9249
- check(value[k], propSchema, `${path22}.${k}`);
9614
+ check(value[k], propSchema, `${path23}.${k}`);
9250
9615
  }
9251
9616
  }
9252
9617
  }
9253
9618
  check(data, schema, "$");
9254
9619
  return { valid: errors.length === 0, errors };
9255
9620
  }
9256
- function simpleQuery(data, path22) {
9257
- const parts = path22.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9621
+ function simpleQuery(data, path23) {
9622
+ const parts = path23.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9258
9623
  let current = data;
9259
9624
  for (const part of parts) {
9260
9625
  if (current === null || current === void 0) return void 0;
@@ -10179,11 +10544,11 @@ var lintTool = {
10179
10544
  }
10180
10545
  };
10181
10546
  async function detectLinter(cwd) {
10182
- const { stat: stat11 } = await import('node:fs/promises');
10547
+ const { stat: stat12 } = await import('node:fs/promises');
10183
10548
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
10184
10549
  for (const f of checks) {
10185
10550
  try {
10186
- await stat11(`${cwd}/${f}`);
10551
+ await stat12(`${cwd}/${f}`);
10187
10552
  if (f.includes("biome")) return "biome";
10188
10553
  if (f.includes("eslint")) return "eslint";
10189
10554
  if (f.includes("tslint")) return "tslint";
@@ -10325,7 +10690,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
10325
10690
  }
10326
10691
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
10327
10692
  var MAX_TAIL_LINES = 1e5;
10328
- async function fileLogs(path22, lines, filterRe, stream) {
10693
+ async function fileLogs(path23, lines, filterRe, stream) {
10329
10694
  const { createInterface } = await import('node:readline');
10330
10695
  const { createReadStream } = await import('node:fs');
10331
10696
  const entries = [];
@@ -10334,7 +10699,7 @@ async function fileLogs(path22, lines, filterRe, stream) {
10334
10699
  let writeIdx = 0;
10335
10700
  let totalLines = 0;
10336
10701
  const rl = createInterface({
10337
- input: createReadStream(path22),
10702
+ input: createReadStream(path23),
10338
10703
  crlfDelay: Number.POSITIVE_INFINITY
10339
10704
  });
10340
10705
  for await (const line of rl) {
@@ -10355,7 +10720,7 @@ async function fileLogs(path22, lines, filterRe, stream) {
10355
10720
  if (parsed) entries.push(parsed);
10356
10721
  }
10357
10722
  return {
10358
- source: path22,
10723
+ source: path23,
10359
10724
  entries,
10360
10725
  total: entries.length,
10361
10726
  truncated: totalLines > effLines,
@@ -10522,6 +10887,10 @@ var patchTool = {
10522
10887
  category: "Filesystem",
10523
10888
  description: "Apply a unified diff (patch) to the project. This is the correct tool when you have a diff that needs to be applied precisely, including handling of rejects.",
10524
10889
  usageHint: "Best used when you already have a diff (from generation, external source, or previous step).\n- Use `dry_run: true` to see what would happen without modifying files.\n- On failure it creates .rej and .orig files for manual review.\nOften cleaner than many small `edit` operations for larger changes.",
10890
+ selection: {
10891
+ doNotUseWhen: "you do not already have a unified diff or only need one precise replacement.",
10892
+ useInstead: ["edit"]
10893
+ },
10525
10894
  permission: "confirm",
10526
10895
  mutating: true,
10527
10896
  capabilities: ["fs.write"],
@@ -10543,12 +10912,13 @@ var patchTool = {
10543
10912
  const strip = Math.max(1, input.strip ?? 1);
10544
10913
  const dryRun = input.dry_run ?? false;
10545
10914
  const targets = extractDiffTargets(input.patch);
10915
+ const resolvedTargets = [];
10546
10916
  for (const t of targets) {
10547
10917
  const stripped = stripPathComponents(t, strip);
10548
10918
  if (!stripped) continue;
10549
- const candidate = path3.resolve(dir, stripped);
10550
- const rel = path3.relative(ctx.projectRoot, candidate);
10551
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
10919
+ const candidate = path12.resolve(dir, stripped);
10920
+ const rel = path12.relative(ctx.projectRoot, candidate);
10921
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
10552
10922
  return {
10553
10923
  applied: 0,
10554
10924
  rejected: 1,
@@ -10557,12 +10927,19 @@ var patchTool = {
10557
10927
  message: `patch refused: target "${t}" resolves outside project root`
10558
10928
  };
10559
10929
  }
10930
+ resolvedTargets.push(candidate);
10560
10931
  }
10561
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), ".wstack_patch_"));
10932
+ const beforeContents = /* @__PURE__ */ new Map();
10933
+ if (!dryRun) {
10934
+ for (const target of resolvedTargets) {
10935
+ beforeContents.set(target, await readTextForTracking(target));
10936
+ }
10937
+ }
10938
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), ".wstack_patch_"));
10562
10939
  try {
10563
10940
  await fs2.chmod(tmpDir, 448).catch(() => {
10564
10941
  });
10565
- const patchFile = path3.join(tmpDir, "in.diff");
10942
+ const patchFile = path12.join(tmpDir, "in.diff");
10566
10943
  await fs2.writeFile(patchFile, input.patch, { mode: 384 });
10567
10944
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
10568
10945
  const result = await runPatch(args, dir, opts.signal);
@@ -10576,6 +10953,21 @@ var patchTool = {
10576
10953
  };
10577
10954
  }
10578
10955
  const patched = extractPatchedFiles(result.stdout);
10956
+ if (!dryRun) {
10957
+ for (const target of resolvedTargets) {
10958
+ const before = beforeContents.get(target) ?? null;
10959
+ const after = await readTextForTracking(target);
10960
+ if (after === null || after === before) continue;
10961
+ const stat12 = await fs2.stat(target).catch(() => null);
10962
+ if (stat12) ctx.recordRead?.(target, stat12.mtimeMs, "write", sha256hex(after));
10963
+ ctx.session?.recordFileChange?.({
10964
+ path: target,
10965
+ action: before === null ? "created" : "modified",
10966
+ before,
10967
+ after
10968
+ });
10969
+ }
10970
+ }
10579
10971
  return {
10580
10972
  applied: patched.length,
10581
10973
  rejected: 0,
@@ -10589,6 +10981,18 @@ var patchTool = {
10589
10981
  }
10590
10982
  }
10591
10983
  };
10984
+ var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
10985
+ async function readTextForTracking(absPath) {
10986
+ try {
10987
+ const stat12 = await fs2.stat(absPath);
10988
+ if (!stat12.isFile() || stat12.size > MAX_TRACKING_BYTES) return null;
10989
+ const buf = await fs2.readFile(absPath);
10990
+ if (buf.includes(0)) return null;
10991
+ return buf.toString("utf8");
10992
+ } catch {
10993
+ return null;
10994
+ }
10995
+ }
10592
10996
  function extractDiffTargets(patch) {
10593
10997
  const out = [];
10594
10998
  const re = /^\+\+\+\s+([^\t\r\n]+)/gm;
@@ -10896,6 +11300,10 @@ var readTool = {
10896
11300
  category: "Filesystem",
10897
11301
  description: "Read the contents of a file with line numbers. This is the primary way to inspect source code, configuration, or any text file before making changes. Lines are returned 1-indexed with a ` N| ` prefix for easy reference in edits.",
10898
11302
  usageHint: "FOUNDATIONAL TOOL \u2014 call this before almost any edit operation.\n\nBest practices:\n- Always read a file before using `edit`, `replace`, or `write` on it (the system often requires it for safety).\n- Use `offset` + `limit` for very large files instead of reading everything at once.\n- Default limit is generous (2000 lines) but can be increased.\n- The output format is designed to be directly usable as context for `edit` operations.",
11303
+ selection: {
11304
+ doNotUseWhen: "you need to search many files for matching content.",
11305
+ useInstead: ["grep"]
11306
+ },
10899
11307
  permission: "auto",
10900
11308
  mutating: false,
10901
11309
  capabilities: ["fs.read"],
@@ -10933,9 +11341,9 @@ var readTool = {
10933
11341
  });
10934
11342
  }
10935
11343
  const absPath = await safeResolveReal(input.path, ctx);
10936
- let stat11;
11344
+ let stat12;
10937
11345
  try {
10938
- stat11 = await fs2.stat(absPath);
11346
+ stat12 = await fs2.stat(absPath);
10939
11347
  } catch (err) {
10940
11348
  const code = err.code;
10941
11349
  if (code === "ENOENT") {
@@ -10954,7 +11362,7 @@ var readTool = {
10954
11362
  cause: err
10955
11363
  });
10956
11364
  }
10957
- if (!stat11.isFile()) {
11365
+ if (!stat12.isFile()) {
10958
11366
  throw new FsError({
10959
11367
  message: `read: "${input.path}" is not a regular file`,
10960
11368
  code: "FS_READ_FAILED",
@@ -10962,22 +11370,22 @@ var readTool = {
10962
11370
  context: { reason: "not-a-regular-file" }
10963
11371
  });
10964
11372
  }
10965
- if (stat11.size > MAX_BYTES2) {
11373
+ if (stat12.size > MAX_BYTES2) {
10966
11374
  throw new FsError({
10967
- message: `read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`,
11375
+ message: `read: file too large (${stat12.size} bytes, limit ${MAX_BYTES2})`,
10968
11376
  code: "FS_READ_FAILED",
10969
11377
  path: absPath,
10970
- context: { size: stat11.size, limit: MAX_BYTES2, reason: "too-large" }
11378
+ context: { size: stat12.size, limit: MAX_BYTES2, reason: "too-large" }
10971
11379
  });
10972
11380
  }
10973
11381
  const offset = Math.max(1, input.offset ?? 1);
10974
11382
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
10975
11383
  const prior = getReadRangeRecord(ctx, absPath);
10976
11384
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
10977
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat11.mtimeMs, offset, requestedEnd)) {
10978
- ctx.recordRead(absPath, stat11.mtimeMs);
11385
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat12.mtimeMs, offset, requestedEnd)) {
11386
+ ctx.recordRead(absPath, stat12.mtimeMs);
10979
11387
  return {
10980
- text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat11.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
11388
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat12.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
10981
11389
  total_lines: prior.totalLines,
10982
11390
  encoding: "utf8",
10983
11391
  truncated: requestedEnd < prior.totalLines,
@@ -10990,13 +11398,14 @@ var readTool = {
10990
11398
  throw new Error(`read: "${input.path}" appears to be binary`);
10991
11399
  }
10992
11400
  const text = buf.toString("utf8");
11401
+ const contentHash = sha256hex(text);
10993
11402
  const allLines = text.split(/\r\n|\r|\n/);
10994
11403
  const total = allLines.length;
10995
11404
  if (input.mode === "summary") {
10996
- ctx.recordRead(absPath, stat11.mtimeMs);
10997
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, Math.min(total, 200));
11405
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11406
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, Math.min(total, 200));
10998
11407
  return {
10999
- text: summarizeFile(input.path, stat11.size, allLines),
11408
+ text: summarizeFile(input.path, stat12.size, allLines),
11000
11409
  total_lines: total,
11001
11410
  encoding: "utf8",
11002
11411
  truncated: total > 200,
@@ -11004,13 +11413,13 @@ var readTool = {
11004
11413
  };
11005
11414
  }
11006
11415
  if (limit === 0) {
11007
- ctx.recordRead(absPath, stat11.mtimeMs);
11008
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, 0);
11416
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11417
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, 0);
11009
11418
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
11010
11419
  }
11011
11420
  if (offset > total) {
11012
- ctx.recordRead(absPath, stat11.mtimeMs);
11013
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, total + 1, total + 1);
11421
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11422
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, total + 1, total + 1);
11014
11423
  return {
11015
11424
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
11016
11425
  total_lines: total,
@@ -11022,8 +11431,8 @@ var readTool = {
11022
11431
  const truncated = offset - 1 + slice.length < total;
11023
11432
  const width = String(offset + slice.length - 1).length;
11024
11433
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
11025
- ctx.recordRead(absPath, stat11.mtimeMs);
11026
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, offset, offset + slice.length - 1);
11434
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11435
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, offset, offset + slice.length - 1);
11027
11436
  return {
11028
11437
  text: numbered,
11029
11438
  total_lines: total,
@@ -11165,10 +11574,10 @@ var replaceTool = {
11165
11574
  } catch {
11166
11575
  continue;
11167
11576
  }
11168
- const rel = path3.relative(realRoot, realPath);
11169
- if (rel.startsWith("..") || path3.isAbsolute(rel)) continue;
11170
- const stat11 = await fs2.stat(realPath).catch(() => null);
11171
- if (!stat11?.isFile()) continue;
11577
+ const rel = path12.relative(realRoot, realPath);
11578
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) continue;
11579
+ const stat12 = await fs2.stat(realPath).catch(() => null);
11580
+ if (!stat12?.isFile()) continue;
11172
11581
  let content;
11173
11582
  try {
11174
11583
  const buf = await fs2.readFile(realPath);
@@ -11193,7 +11602,17 @@ var replaceTool = {
11193
11602
  totalReplacements += count;
11194
11603
  if (!dryRun) {
11195
11604
  const newContent = toStyle(newContentLf, style);
11196
- await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
11605
+ await atomicWrite(realPath, newContent, { mode: stat12.mode & 511 });
11606
+ const written = await fs2.stat(realPath).catch(() => null);
11607
+ if (written) {
11608
+ ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
11609
+ }
11610
+ ctx.session?.recordFileChange?.({
11611
+ path: realPath,
11612
+ action: "modified",
11613
+ before: content,
11614
+ after: newContent
11615
+ });
11197
11616
  }
11198
11617
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
11199
11618
  fromFile: absPath,
@@ -11223,8 +11642,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
11223
11642
  const resolved = [];
11224
11643
  for (const p of parts) {
11225
11644
  const absPath = safeResolve(p, ctx);
11226
- const stat11 = await fs2.stat(absPath).catch(() => null);
11227
- if (stat11?.isFile()) {
11645
+ const stat12 = await fs2.stat(absPath).catch(() => null);
11646
+ if (stat12?.isFile()) {
11228
11647
  resolved.push(absPath);
11229
11648
  }
11230
11649
  }
@@ -11285,10 +11704,10 @@ async function globNative(pattern, base, extraGlob) {
11285
11704
  }
11286
11705
  for (const e of entries) {
11287
11706
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
11288
- const full = path3.join(dir, e.name);
11707
+ const full = path12.join(dir, e.name);
11289
11708
  try {
11290
- const stat11 = await fs2.lstat(full);
11291
- if (stat11.isSymbolicLink()) continue;
11709
+ const stat12 = await fs2.lstat(full);
11710
+ if (stat12.isSymbolicLink()) continue;
11292
11711
  } catch {
11293
11712
  continue;
11294
11713
  }
@@ -11456,16 +11875,16 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
11456
11875
  let filesCreated = 0;
11457
11876
  for (const [filePath, content] of Object.entries(templateFiles)) {
11458
11877
  const resolvedPath = substituteVars(filePath, name, vars);
11459
- const joinedPath = path3.join(cwd, resolvedPath);
11460
- const root = path3.resolve(ctx.projectRoot);
11461
- const target = path3.resolve(joinedPath);
11462
- const rel = path3.relative(root, target);
11463
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
11878
+ const joinedPath = path12.join(cwd, resolvedPath);
11879
+ const root = path12.resolve(ctx.projectRoot);
11880
+ const target = path12.resolve(joinedPath);
11881
+ const rel = path12.relative(root, target);
11882
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
11464
11883
  throw new Error(`scaffold: generated path "${resolvedPath}" would escape project root`);
11465
11884
  }
11466
11885
  const fullPath = target;
11467
11886
  if (!dryRun) {
11468
- await fs2.mkdir(path3.dirname(fullPath), { recursive: true });
11887
+ await fs2.mkdir(path12.dirname(fullPath), { recursive: true });
11469
11888
  await atomicWrite(fullPath, substituteVars(content, name, vars));
11470
11889
  }
11471
11890
  files.push(resolvedPath);
@@ -12365,11 +12784,11 @@ var testTool = {
12365
12784
  }
12366
12785
  };
12367
12786
  async function detectRunner(cwd) {
12368
- const { stat: stat11 } = await import('node:fs/promises');
12787
+ const { stat: stat12 } = await import('node:fs/promises');
12369
12788
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
12370
12789
  for (const f of candidates) {
12371
12790
  try {
12372
- await stat11(path3.join(cwd, f));
12791
+ await stat12(path12.join(cwd, f));
12373
12792
  if (f.includes("vitest")) return "vitest";
12374
12793
  if (f.includes("jest")) return "jest";
12375
12794
  if (f.includes("mocha")) return "mocha";
@@ -12997,7 +13416,7 @@ async function walkDir(dir, depth, opts) {
12997
13416
  opts.lines.push(opts.prefix + branch + displayName);
12998
13417
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
12999
13418
  const childPrefix = opts.prefix + connector;
13000
- await walkDir(path3.join(dir, entry.name), depth + 1, {
13419
+ await walkDir(path12.join(dir, entry.name), depth + 1, {
13001
13420
  ...opts,
13002
13421
  prefix: childPrefix,
13003
13422
  isLast
@@ -13083,12 +13502,12 @@ var typecheckTool = {
13083
13502
  }
13084
13503
  };
13085
13504
  async function findTsConfig(cwd) {
13086
- const { stat: stat11 } = await import('node:fs/promises');
13505
+ const { stat: stat12 } = await import('node:fs/promises');
13087
13506
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
13088
13507
  for (const f of candidates) {
13089
13508
  try {
13090
- const s = await stat11(path3.join(cwd, f));
13091
- if (s.isFile()) return path3.join(cwd, f);
13509
+ const s = await stat12(path12.join(cwd, f));
13510
+ if (s.isFile()) return path12.join(cwd, f);
13092
13511
  } catch {
13093
13512
  }
13094
13513
  }
@@ -13099,6 +13518,10 @@ var writeTool = {
13099
13518
  category: "Filesystem",
13100
13519
  description: "Write or completely overwrite a file on disk. This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, because `edit` is safer and works on the last-read version of the file.",
13101
13520
  usageHint: "RULES FOR CORRECT USAGE:\n- Use `write` primarily for **new files** or when you want to replace the entire content.\n- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\n- You MUST have called `read` on the file earlier in the conversation before using `write` on an existing path (the system enforces this for safety).\n- The path is resolved relative to the project root and protected against escaping the workspace.",
13521
+ selection: {
13522
+ doNotUseWhen: "making a precise change to part of an existing file.",
13523
+ useInstead: ["edit"]
13524
+ },
13102
13525
  permission: "confirm",
13103
13526
  mutating: true,
13104
13527
  timeoutMs: 5e3,
@@ -13118,10 +13541,10 @@ var writeTool = {
13118
13541
  },
13119
13542
  required: ["path", "content"]
13120
13543
  },
13121
- async execute(input, ctx) {
13122
- return writeFile7(input, ctx);
13544
+ async execute(input, ctx, opts) {
13545
+ return writeFile7(input, ctx, opts?.signal);
13123
13546
  },
13124
- async *executeStream(input, ctx) {
13547
+ async *executeStream(input, ctx, opts) {
13125
13548
  const prepared = await prepareWrite(input, ctx);
13126
13549
  if (!prepared.existed) {
13127
13550
  for (const line of input.content.split("\n")) {
@@ -13129,11 +13552,11 @@ var writeTool = {
13129
13552
  `, data: { livePreview: true } };
13130
13553
  }
13131
13554
  }
13132
- yield { type: "final", output: await finishWrite(input, ctx, prepared) };
13555
+ yield { type: "final", output: await finishWrite(input, ctx, prepared, opts?.signal) };
13133
13556
  }
13134
13557
  };
13135
- async function writeFile7(input, ctx) {
13136
- return finishWrite(input, ctx, await prepareWrite(input, ctx));
13558
+ async function writeFile7(input, ctx, signal) {
13559
+ return finishWrite(input, ctx, await prepareWrite(input, ctx), signal);
13137
13560
  }
13138
13561
  async function prepareWrite(input, ctx) {
13139
13562
  if (!input?.path) {
@@ -13152,12 +13575,12 @@ async function prepareWrite(input, ctx) {
13152
13575
  let existed = false;
13153
13576
  let prev = "";
13154
13577
  try {
13155
- const stat11 = await fs2.stat(absPath);
13156
- existed = stat11.isFile();
13578
+ const stat12 = await fs2.stat(absPath);
13579
+ existed = stat12.isFile();
13157
13580
  if (existed) {
13158
13581
  if (!ctx.hasRead(absPath)) {
13159
13582
  prev = await fs2.readFile(absPath, "utf8");
13160
- ctx.recordRead(absPath, stat11.mtimeMs, "write");
13583
+ ctx.recordRead(absPath, stat12.mtimeMs, "write", sha256hex(prev));
13161
13584
  } else {
13162
13585
  prev = await fs2.readFile(absPath, "utf8");
13163
13586
  }
@@ -13169,23 +13592,32 @@ async function prepareWrite(input, ctx) {
13169
13592
  }
13170
13593
  return { absPath, existed, prev };
13171
13594
  }
13172
- async function finishWrite(input, ctx, prepared) {
13595
+ async function finishWrite(input, ctx, prepared, signal) {
13596
+ signal?.throwIfAborted();
13173
13597
  await atomicWrite(prepared.absPath, input.content);
13174
13598
  const diff = prepared.existed ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
13175
13599
  + (new file, ${input.content.split("\n").length} lines)`;
13176
- const stat11 = await fs2.stat(prepared.absPath);
13177
- ctx.recordRead(prepared.absPath, stat11.mtimeMs, "write");
13600
+ const stat12 = await fs2.stat(prepared.absPath);
13601
+ ctx.recordRead(prepared.absPath, stat12.mtimeMs, "write", sha256hex(input.content));
13178
13602
  ctx.session.recordFileChange({
13179
13603
  path: prepared.absPath,
13180
13604
  action: prepared.existed ? "modified" : "created",
13181
13605
  before: prepared.existed ? prepared.prev : null,
13182
13606
  after: input.content
13183
13607
  });
13608
+ const syntax = await checkSyntax(
13609
+ prepared.absPath,
13610
+ input.content,
13611
+ prepared.existed ? prepared.prev : void 0
13612
+ ).catch(() => void 0);
13613
+ const hasSyntaxErrors = syntax !== void 0 && syntax.errors.length > 0;
13184
13614
  return {
13185
13615
  path: prepared.absPath,
13186
13616
  bytes_written: Buffer.byteLength(input.content, "utf8"),
13187
13617
  created: !prepared.existed,
13188
- diff
13618
+ diff,
13619
+ syntax_errors: hasSyntaxErrors ? syntax.errors : void 0,
13620
+ note: hasSyntaxErrors ? syntax.preExisting ? "Syntax check: the file still has parse errors (they pre-date this write) \u2014 see syntax_errors." : `Syntax check: the written content has ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.` : void 0
13189
13621
  };
13190
13622
  }
13191
13623