@wrongstack/tools 0.283.1 → 0.284.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import * as fs from 'node:fs';
2
2
  import { statSync, mkdirSync, createWriteStream } from 'node:fs';
3
- import * as path3 from 'node:path';
3
+ import * as path12 from 'node:path';
4
4
  import { resolve, sep, dirname, join } from 'node:path';
5
5
  import { spawn, execFileSync } from 'node:child_process';
6
6
  import * as Core from '@wrongstack/core';
7
- import { SKILL_LIMITS, 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, stripFrontmatter, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
7
+ import { SKILL_LIMITS, 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, stripFrontmatter, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
8
8
  import * as fs2 from 'node:fs/promises';
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,7 +18,7 @@ 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/_danger-detect.ts
23
24
  var argHas = (args, value) => args.includes(value);
@@ -230,23 +231,18 @@ var RULES = [
230
231
  },
231
232
  reason: "inline script evaluation (-c / -e / --eval)"
232
233
  },
233
- // ----- pipe-to-shell (cautionwell-known exfil pattern) -----
234
+ // ----- pipe-to-shell (destructivedownload-and-run pattern) -----
234
235
  // The classic `curl https://... | sh` download-and-run vector. Detected by
235
- // looking for a known fetcher followed by a shell sink. We use a simple
236
- // substring scan; false positives are limited because both tokens must
237
- // appear in the same argv.
236
+ // looking for a known fetcher piped into a shell or expression evaluator.
237
+ // A shell command passed through `bash -c` / `pwsh -Command` arrives as one
238
+ // argv string, so scan the reconstructed argv text rather than individual
239
+ // tokens only.
238
240
  {
239
241
  id: "pipe-to-shell",
240
- level: "caution",
241
- test: (_cmd, args) => {
242
- const hasFetcher = args.some(
243
- (a) => /^(curl|wget|fetch|httpie|http)$/i.test(a) || a.startsWith("curl") || a.startsWith("wget")
244
- );
245
- const hasShellSink = args.some(
246
- (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")
247
- );
248
- return hasFetcher && hasShellSink;
249
- },
242
+ level: "destructive",
243
+ 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(
244
+ [cmd, ...args].join(" ")
245
+ ),
250
246
  reason: "network fetch piped to a shell (download-and-run pattern)"
251
247
  },
252
248
  // ----- privilege escalation (caution) -----
@@ -306,13 +302,13 @@ function levelRank(level) {
306
302
  }
307
303
  function resolveWin32Command(cmd) {
308
304
  if (process.platform !== "win32") return cmd;
309
- if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
305
+ if (cmd.includes("/") || cmd.includes("\\") || path12.extname(cmd.replace(/\//g, "\\"))) {
310
306
  return cmd;
311
307
  }
312
308
  const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
313
- const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
309
+ const pathDirs = (process.env["PATH"] ?? "").split(path12.delimiter);
314
310
  for (const dir of pathDirs) {
315
- const base = path3.join(dir, cmd);
311
+ const base = path12.join(dir, cmd);
316
312
  for (const ext of pathext) {
317
313
  const full = `${base}${ext}`;
318
314
  try {
@@ -393,7 +389,7 @@ var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
393
389
  var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
394
390
  var sweepStarted = false;
395
391
  function toolOutputDir() {
396
- return path3.join(wstackGlobalRoot(), "tool-output");
392
+ return path12.join(wstackGlobalRoot(), "tool-output");
397
393
  }
398
394
  function sweepOldSpoolFiles(dir) {
399
395
  if (sweepStarted) return;
@@ -403,7 +399,7 @@ function sweepOldSpoolFiles(dir) {
403
399
  const now2 = Date.now();
404
400
  for (const name of await fs2.readdir(dir)) {
405
401
  if (!name.endsWith(".log")) continue;
406
- const p = path3.join(dir, name);
402
+ const p = path12.join(dir, name);
407
403
  try {
408
404
  const st = await fs2.stat(p);
409
405
  if (now2 - st.mtimeMs > SPOOL_RETENTION_MS) await fs2.unlink(p);
@@ -438,7 +434,7 @@ function createOutputSpool(opts) {
438
434
  sweepOldSpoolFiles(dir);
439
435
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
440
436
  const rand = Math.random().toString(36).slice(2, 6);
441
- filePath = path3.join(dir, `${stamp}-${safeTool}-${rand}.log`);
437
+ filePath = path12.join(dir, `${stamp}-${safeTool}-${rand}.log`);
442
438
  stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
443
439
  stream.on("error", () => {
444
440
  failed = true;
@@ -1237,37 +1233,40 @@ async function* spawnStream(opts) {
1237
1233
  }
1238
1234
  }
1239
1235
  }
1236
+ function sha256hex(content) {
1237
+ return createHash("sha256").update(content, "utf8").digest("hex");
1238
+ }
1240
1239
  async function detectPackageManager(cwd) {
1241
- const { stat: stat12 } = await import('node:fs/promises');
1240
+ const { stat: stat13 } = await import('node:fs/promises');
1242
1241
  try {
1243
- await stat12(`${cwd}/pnpm-lock.yaml`);
1242
+ await stat13(`${cwd}/pnpm-lock.yaml`);
1244
1243
  return "pnpm";
1245
1244
  } catch {
1246
1245
  }
1247
1246
  try {
1248
- await stat12(`${cwd}/yarn.lock`);
1247
+ await stat13(`${cwd}/yarn.lock`);
1249
1248
  return "yarn";
1250
1249
  } catch {
1251
1250
  }
1252
1251
  return "npm";
1253
1252
  }
1254
1253
  function resolvePath(input, ctx) {
1255
- return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
1254
+ return path12.isAbsolute(input) ? path12.normalize(input) : path12.resolve(ctx.workingDir ?? ctx.cwd, input);
1256
1255
  }
1257
1256
  function allowedRoots(ctx) {
1258
- return [path3.resolve(ctx.projectRoot), path3.resolve(Core.wstackGlobalRoot())];
1257
+ return [path12.resolve(ctx.projectRoot), path12.resolve(Core.wstackGlobalRoot())];
1259
1258
  }
1260
1259
  function isInsideAny(target, roots) {
1261
1260
  return roots.some((root) => {
1262
- const rel = path3.relative(root, target);
1263
- return rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel);
1261
+ const rel = path12.relative(root, target);
1262
+ return rel === "" || !rel.startsWith("..") && !path12.isAbsolute(rel);
1264
1263
  });
1265
1264
  }
1266
1265
  function ensureInsideRoot(absPath, ctx) {
1267
- const target = path3.resolve(absPath);
1266
+ const target = path12.resolve(absPath);
1268
1267
  if (ctx.allowOutsideProjectRoot) return target;
1269
1268
  if (isInsideAny(target, allowedRoots(ctx))) return target;
1270
- throw new Error(`Path "${absPath}" is outside project root "${path3.resolve(ctx.projectRoot)}"`);
1269
+ throw new Error(`Path "${absPath}" is outside project root "${path12.resolve(ctx.projectRoot)}"`);
1271
1270
  }
1272
1271
  function safeResolve(input, ctx) {
1273
1272
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -1275,7 +1274,7 @@ function safeResolve(input, ctx) {
1275
1274
  async function assertRealInsideRoot(absPath, ctx) {
1276
1275
  if (ctx.allowOutsideProjectRoot) return;
1277
1276
  const realRoots = await Promise.all(
1278
- allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path3.resolve(r)))
1277
+ allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path12.resolve(r)))
1279
1278
  );
1280
1279
  let probe = absPath;
1281
1280
  for (; ; ) {
@@ -1284,7 +1283,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1284
1283
  real = await fs2.realpath(probe);
1285
1284
  } catch (err) {
1286
1285
  if (err.code === "ENOENT") {
1287
- const parent = path3.dirname(probe);
1286
+ const parent = path12.dirname(probe);
1288
1287
  if (parent === probe) return;
1289
1288
  probe = parent;
1290
1289
  continue;
@@ -1597,15 +1596,15 @@ var PersistentProcessRegistry = class {
1597
1596
  constructor(baseRegistry) {
1598
1597
  this.instanceId = generateInstanceId();
1599
1598
  const homeDir = os2.homedir();
1600
- this.registryPath = path3.join(homeDir, REGISTRY_FILE);
1601
- this.lockPath = path3.join(homeDir, LOCKFILE);
1599
+ this.registryPath = path12.join(homeDir, REGISTRY_FILE);
1600
+ this.lockPath = path12.join(homeDir, LOCKFILE);
1602
1601
  this.baseRegistry = baseRegistry ?? getProcessRegistry();
1603
1602
  this.ensureDirectory().catch((err) => {
1604
1603
  emitStructuredLog("warn", "process_registry.dir_create_failed", "PersistentProcessRegistry: failed to create .wrongstack directory", err);
1605
1604
  });
1606
1605
  }
1607
1606
  async ensureDirectory() {
1608
- const dir = path3.dirname(this.registryPath);
1607
+ const dir = path12.dirname(this.registryPath);
1609
1608
  try {
1610
1609
  await fs2.mkdir(dir, { recursive: true });
1611
1610
  } catch (err) {
@@ -2191,6 +2190,10 @@ var bashTool = {
2191
2190
  category: "Shell",
2192
2191
  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.",
2193
2192
  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.",
2193
+ selection: {
2194
+ doNotUseWhen: "the command is allowlisted and does not require pipes, redirection, or shell expansion.",
2195
+ useInstead: ["exec"]
2196
+ },
2194
2197
  permission: "confirm",
2195
2198
  mutating: true,
2196
2199
  riskTier: "destructive",
@@ -3055,7 +3058,7 @@ var IndexStore = class {
3055
3058
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
3056
3059
  fs.mkdirSync(this.indexDir, { recursive: true });
3057
3060
  const Database = loadDatabaseSync();
3058
- this.db = new Database(path3.join(this.indexDir, DB_FILE));
3061
+ this.db = new Database(path12.join(this.indexDir, DB_FILE));
3059
3062
  try {
3060
3063
  this.db.exec("PRAGMA journal_mode = WAL");
3061
3064
  this.db.exec("PRAGMA busy_timeout = 5000");
@@ -3634,7 +3637,7 @@ var IndexStore = class {
3634
3637
  }));
3635
3638
  }
3636
3639
  sizeBytes() {
3637
- const dbPath = path3.join(this.indexDir, DB_FILE);
3640
+ const dbPath = path12.join(this.indexDir, DB_FILE);
3638
3641
  try {
3639
3642
  return fs.statSync(dbPath).size;
3640
3643
  } catch {
@@ -4133,9 +4136,9 @@ func formatType(t ast.Expr) string {
4133
4136
  }
4134
4137
  `;
4135
4138
  async function syncGoParse(filePath, content, lang) {
4136
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), "ws-go-parse-"));
4139
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), "ws-go-parse-"));
4137
4140
  try {
4138
- const scriptPath = path3.join(tmpDir, "parse.go");
4141
+ const scriptPath = path12.join(tmpDir, "parse.go");
4139
4142
  await fs2.writeFile(scriptPath, GO_PARSE_SCRIPT, "utf8");
4140
4143
  const proc = spawn("go", ["run", scriptPath], {
4141
4144
  stdio: ["pipe", "pipe", "pipe"],
@@ -4397,9 +4400,9 @@ var _cachedScriptPath = null;
4397
4400
  async function syncPyParse(filePath, content, lang) {
4398
4401
  try {
4399
4402
  if (!_cachedScriptPath) {
4400
- const tmpDir = path3.join(os2.tmpdir(), "ws-py-parse");
4403
+ const tmpDir = path12.join(os2.tmpdir(), "ws-py-parse");
4401
4404
  await fs2.mkdir(tmpDir, { recursive: true });
4402
- _cachedScriptPath = path3.join(tmpDir, "parse.py");
4405
+ _cachedScriptPath = path12.join(tmpDir, "parse.py");
4403
4406
  await fs2.writeFile(_cachedScriptPath, PY_PARSE_SCRIPT, "utf8");
4404
4407
  }
4405
4408
  const proc = spawn("python", [_cachedScriptPath, filePath], {
@@ -4457,7 +4460,7 @@ async function parseSymbols4(opts) {
4457
4460
  function checkNativeParser() {
4458
4461
  try {
4459
4462
  execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
4460
- const toolsDir = path3.join(process.cwd(), "tools");
4463
+ const toolsDir = path12.join(process.cwd(), "tools");
4461
4464
  try {
4462
4465
  execFileSync(
4463
4466
  "cargo",
@@ -4467,7 +4470,7 @@ function checkNativeParser() {
4467
4470
  "--format-version",
4468
4471
  "1",
4469
4472
  "--manifest-path",
4470
- path3.join(toolsDir, "Cargo.toml")
4473
+ path12.join(toolsDir, "Cargo.toml")
4471
4474
  ],
4472
4475
  { stdio: "pipe", windowsHide: true }
4473
4476
  );
@@ -4481,13 +4484,13 @@ function checkNativeParser() {
4481
4484
  }
4482
4485
  async function tryNativeParse(file, content) {
4483
4486
  try {
4484
- const toolsDir = path3.join(process.cwd(), "tools");
4485
- const crateDir = path3.join(toolsDir, "syn-parser");
4486
- const tmpFile = path3.join(crateDir, "src", "input.rs");
4487
+ const toolsDir = path12.join(process.cwd(), "tools");
4488
+ const crateDir = path12.join(toolsDir, "syn-parser");
4489
+ const tmpFile = path12.join(crateDir, "src", "input.rs");
4487
4490
  await fs2.writeFile(tmpFile, content, "utf8");
4488
4491
  const proc = spawn(
4489
4492
  "cargo",
4490
- ["run", "--manifest-path", path3.join(toolsDir, "Cargo.toml")],
4493
+ ["run", "--manifest-path", path12.join(toolsDir, "Cargo.toml")],
4491
4494
  {
4492
4495
  cwd: process.cwd(),
4493
4496
  stdio: ["pipe", "pipe", "pipe"],
@@ -4537,16 +4540,16 @@ function regexParse(opts) {
4537
4540
  const { file, content, lang } = opts;
4538
4541
  const symbols = [];
4539
4542
  const lines = content.split("\n");
4540
- const lineOffsets = [0];
4543
+ const lineOffsets2 = [0];
4541
4544
  for (let i = 0; i < lines.length; i++) {
4542
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4545
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4543
4546
  }
4544
4547
  function lineFromOffset(offset) {
4545
4548
  let lo = 0;
4546
- let hi = lineOffsets.length - 1;
4549
+ let hi = lineOffsets2.length - 1;
4547
4550
  while (lo < hi) {
4548
4551
  const mid = lo + hi + 1 >>> 1;
4549
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4552
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4550
4553
  else hi = mid - 1;
4551
4554
  }
4552
4555
  return lo + 1;
@@ -4561,7 +4564,7 @@ function regexParse(opts) {
4561
4564
  const name = expectDefined(match[1]);
4562
4565
  const offset = match.index ?? 0;
4563
4566
  const line = lineFromOffset(offset);
4564
- const col = offset - (lineOffsets[line - 1] ?? 0);
4567
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4565
4568
  const lineIdx = line - 1;
4566
4569
  const signature = extractDeclaration(lineIdx);
4567
4570
  symbols.push({
@@ -4599,22 +4602,22 @@ function parseSymbols5(opts) {
4599
4602
  function regexParse2(opts) {
4600
4603
  const { file, content, lang } = opts;
4601
4604
  const symbols = [];
4602
- const basename2 = path3.basename(file).toLowerCase();
4603
- const isPackageJson = basename2 === "package.json";
4604
- const isTsconfig = basename2 === "tsconfig.json" || basename2 === "tsconfig.build.json";
4605
+ const basename4 = path12.basename(file).toLowerCase();
4606
+ const isPackageJson = basename4 === "package.json";
4607
+ const isTsconfig = basename4 === "tsconfig.json" || basename4 === "tsconfig.build.json";
4605
4608
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
4606
4609
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
4607
4610
  const lines = content.split("\n");
4608
- const lineOffsets = [0];
4611
+ const lineOffsets2 = [0];
4609
4612
  for (let i = 0; i < lines.length; i++) {
4610
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4613
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4611
4614
  }
4612
4615
  function lineFromOffset(offset) {
4613
4616
  let lo = 0;
4614
- let hi = lineOffsets.length - 1;
4617
+ let hi = lineOffsets2.length - 1;
4615
4618
  while (lo < hi) {
4616
4619
  const mid = lo + hi + 1 >>> 1;
4617
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4620
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4618
4621
  else hi = mid - 1;
4619
4622
  }
4620
4623
  return lo + 1;
@@ -4625,11 +4628,11 @@ function regexParse2(opts) {
4625
4628
  const line = lineFromOffset(offset);
4626
4629
  symbols.push(
4627
4630
  makeSymbol({
4628
- name: path3.basename(file),
4631
+ name: path12.basename(file),
4629
4632
  kind: "object",
4630
4633
  line,
4631
4634
  col: 0,
4632
- signature: `"${path3.basename(file)}" = { ... }`,
4635
+ signature: `"${path12.basename(file)}" = { ... }`,
4633
4636
  file,
4634
4637
  lang
4635
4638
  })
@@ -4640,7 +4643,7 @@ function regexParse2(opts) {
4640
4643
  const key = expectDefined(match[1]);
4641
4644
  const offset = match.index ?? 0;
4642
4645
  const line = lineFromOffset(offset);
4643
- const col = offset - (lineOffsets[line - 1] ?? 0);
4646
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4644
4647
  let kind = "property";
4645
4648
  let signature = `"${key}": ..."`;
4646
4649
  if (isPackageJson) {
@@ -4675,10 +4678,10 @@ function regexParse2(opts) {
4675
4678
  })
4676
4679
  );
4677
4680
  if (isPackageJson && key === "scripts") {
4678
- extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset);
4681
+ extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset);
4679
4682
  }
4680
4683
  if (isTsconfig && key === "compilerOptions") {
4681
- extractCompilerOptions(content, symbols, file, lang, lineOffsets, line, lineFromOffset);
4684
+ extractCompilerOptions(content, symbols, file, lang, lineOffsets2, line, lineFromOffset);
4682
4685
  }
4683
4686
  }
4684
4687
  const defsRegex = /"\$defs"\s*:|"\$defs"\s*:/g;
@@ -4691,7 +4694,7 @@ function regexParse2(opts) {
4691
4694
  name: "$defs",
4692
4695
  kind: "property",
4693
4696
  line,
4694
- col: offset - (lineOffsets[line - 1] ?? 0),
4697
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4695
4698
  signature: '"$defs": { ... }',
4696
4699
  file,
4697
4700
  lang
@@ -4715,7 +4718,7 @@ function regexParse2(opts) {
4715
4718
  name: key,
4716
4719
  kind: "property",
4717
4720
  line,
4718
- col: offset - (lineOffsets[line - 1] ?? 0),
4721
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4719
4722
  signature: `"${key}": { ... }`,
4720
4723
  file,
4721
4724
  lang
@@ -4725,7 +4728,7 @@ function regexParse2(opts) {
4725
4728
  }
4726
4729
  return { file, lang, symbols, mtimeMs: Date.now() };
4727
4730
  }
4728
- function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset) {
4731
+ function extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset) {
4729
4732
  const scriptsBlockRegex = /"scripts"\s*:\s*\{([^}]+)\}/g;
4730
4733
  for (let match = scriptsBlockRegex.exec(content); match !== null; match = scriptsBlockRegex.exec(content)) {
4731
4734
  const blockContent = expectDefined(match[0]);
@@ -4740,7 +4743,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4740
4743
  name: key,
4741
4744
  kind: "function",
4742
4745
  line,
4743
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4746
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4744
4747
  signature: `"${key}": "..."`,
4745
4748
  file,
4746
4749
  lang
@@ -4749,7 +4752,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4749
4752
  }
4750
4753
  }
4751
4754
  }
4752
- function extractCompilerOptions(content, symbols, file, lang, lineOffsets, parentLine, lineFromOffset) {
4755
+ function extractCompilerOptions(content, symbols, file, lang, lineOffsets2, parentLine, lineFromOffset) {
4753
4756
  const optsBlockRegex = /"compilerOptions"\s*:\s*\{([^}]+)\}/g;
4754
4757
  for (let match = optsBlockRegex.exec(content); match !== null; match = optsBlockRegex.exec(content)) {
4755
4758
  const blockContent = expectDefined(match[0]);
@@ -4765,7 +4768,7 @@ function extractCompilerOptions(content, symbols, file, lang, lineOffsets, paren
4765
4768
  name: key,
4766
4769
  kind: "property",
4767
4770
  line,
4768
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4771
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4769
4772
  signature: `"${key}": ...`,
4770
4773
  file,
4771
4774
  lang
@@ -4801,16 +4804,16 @@ function regexParse3(opts) {
4801
4804
  const { file, content, lang } = opts;
4802
4805
  const symbols = [];
4803
4806
  const lines = content.split("\n");
4804
- const lineOffsets = [0];
4807
+ const lineOffsets2 = [0];
4805
4808
  for (let i = 0; i < lines.length; i++) {
4806
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4809
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4807
4810
  }
4808
4811
  function lineFromOffset(offset) {
4809
4812
  let lo = 0;
4810
- let hi = lineOffsets.length - 1;
4813
+ let hi = lineOffsets2.length - 1;
4811
4814
  while (lo < hi) {
4812
4815
  const mid = lo + hi + 1 >>> 1;
4813
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4816
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4814
4817
  else hi = mid - 1;
4815
4818
  }
4816
4819
  return lo + 1;
@@ -4820,7 +4823,7 @@ function regexParse3(opts) {
4820
4823
  const name = expectDefined(match[1]);
4821
4824
  const offset = match.index ?? 0;
4822
4825
  const line = lineFromOffset(offset);
4823
- const col = offset - (lineOffsets[line - 1] ?? 0);
4826
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4824
4827
  symbols.push(
4825
4828
  makeSymbol2({
4826
4829
  name,
@@ -4838,7 +4841,7 @@ function regexParse3(opts) {
4838
4841
  const name = expectDefined(match[1]);
4839
4842
  const offset = match.index ?? 0;
4840
4843
  const line = lineFromOffset(offset);
4841
- const col = offset - (lineOffsets[line - 1] ?? 0);
4844
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4842
4845
  symbols.push(
4843
4846
  makeSymbol2({
4844
4847
  name,
@@ -4858,7 +4861,7 @@ function regexParse3(opts) {
4858
4861
  if (!key) continue;
4859
4862
  const offset = match.index ?? 0;
4860
4863
  const line = lineFromOffset(offset);
4861
- const col = offset - (lineOffsets[line - 1] ?? 0);
4864
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4862
4865
  const lineContent = lines[line - 1] ?? "";
4863
4866
  if (/^[|&>]/.test(lineContent.trim())) continue;
4864
4867
  if (key === "---" || key === "...") continue;
@@ -4873,7 +4876,7 @@ function regexParse3(opts) {
4873
4876
  const key = expectDefined(match[2]);
4874
4877
  const offset = match.index ?? 0;
4875
4878
  const line = lineFromOffset(offset);
4876
- const col = offset - (lineOffsets[line - 1] ?? 0);
4879
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4877
4880
  const value = extractValue(content, offset + match[0]?.length);
4878
4881
  const kind = isScalar(value) ? "literal" : "property";
4879
4882
  symbols.push(
@@ -4893,7 +4896,7 @@ function regexParse3(opts) {
4893
4896
  const key = expectDefined(match[2]);
4894
4897
  const offset = match.index ?? 0;
4895
4898
  const line = lineFromOffset(offset);
4896
- const col = offset - (lineOffsets[line - 1] ?? 0);
4899
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4897
4900
  symbols.push(
4898
4901
  makeSymbol2({
4899
4902
  name: key,
@@ -4979,7 +4982,7 @@ function compileGitignore(lines) {
4979
4982
  async function loadGitignoreMatcher(projectRoot) {
4980
4983
  let lines = [];
4981
4984
  try {
4982
- const raw = await fs2.readFile(path3.join(projectRoot, ".gitignore"), "utf8");
4985
+ const raw = await fs2.readFile(path12.join(projectRoot, ".gitignore"), "utf8");
4983
4986
  lines = raw.split("\n");
4984
4987
  } catch {
4985
4988
  }
@@ -5044,14 +5047,14 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
5044
5047
  dirCount++;
5045
5048
  for (const e of entries) {
5046
5049
  if (ignoreSet.has(e.name)) continue;
5047
- const full = path3.join(dir, e.name);
5048
- const rel = path3.relative(projectRoot, full).replace(/\\/g, "/");
5050
+ const full = path12.join(dir, e.name);
5051
+ const rel = path12.relative(projectRoot, full).replace(/\\/g, "/");
5049
5052
  if (e.isDirectory()) {
5050
5053
  if (isGitIgnored(rel, true)) continue;
5051
5054
  await walk2(full);
5052
5055
  } else if (e.isFile()) {
5053
5056
  if (isGitIgnored(rel, false)) continue;
5054
- const ext = path3.extname(e.name);
5057
+ const ext = path12.extname(e.name);
5055
5058
  for (const { ext: extName, pat } of globs) {
5056
5059
  if (ext === extName && (pat.test(rel) || pat.test(e.name))) {
5057
5060
  results.push(full);
@@ -5107,7 +5110,7 @@ async function runIndexerWithStore(store, opts) {
5107
5110
  let files;
5108
5111
  let discoveredFiles = null;
5109
5112
  if (opts.files && opts.files.length > 0) {
5110
- files = opts.files.map((f) => path3.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path3.relative(projectRoot, f).replace(/\\/g, "/"), false));
5113
+ files = opts.files.map((f) => path12.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path12.relative(projectRoot, f).replace(/\\/g, "/"), false));
5111
5114
  } else {
5112
5115
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
5113
5116
  discoveredFiles = new Set(files);
@@ -5135,34 +5138,34 @@ async function runIndexerWithStore(store, opts) {
5135
5138
  const statOpts = signal ? { signal } : {};
5136
5139
  const statReadParse = await Promise.allSettled(
5137
5140
  batchFiles.map(async (file) => {
5138
- let stat12;
5141
+ let stat13;
5139
5142
  try {
5140
- stat12 = await fs2.stat(file, statOpts);
5143
+ stat13 = await fs2.stat(file, statOpts);
5141
5144
  } catch (e) {
5142
5145
  if (isAbortError(e)) throw e;
5143
5146
  return { file, stat: null, lang: "", parsed: null, error: `stat error: ${e instanceof Error ? e.message : String(e)}` };
5144
5147
  }
5145
- if (!stat12.isFile()) return { file, stat: stat12, lang: "", parsed: null };
5148
+ if (!stat13.isFile()) return { file, stat: stat13, lang: "", parsed: null };
5146
5149
  const lang = detectLang(file);
5147
- if (!lang) return { file, stat: stat12, lang: "", parsed: null };
5150
+ if (!lang) return { file, stat: stat13, lang: "", parsed: null };
5148
5151
  const meta = existingMeta.get(file);
5149
- if (!force && meta && meta.mtimeMs === Math.floor(stat12.mtimeMs)) {
5150
- return { file, stat: stat12, lang, parsed: null, skippedMeta: meta };
5152
+ if (!force && meta && meta.mtimeMs === Math.floor(stat13.mtimeMs)) {
5153
+ return { file, stat: stat13, lang, parsed: null, skippedMeta: meta };
5151
5154
  }
5152
5155
  let content;
5153
5156
  try {
5154
5157
  content = await fs2.readFile(file, { encoding: "utf8", signal });
5155
5158
  } catch (e) {
5156
5159
  if (isAbortError(e)) throw e;
5157
- return { file, stat: stat12, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
5160
+ return { file, stat: stat13, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
5158
5161
  }
5159
5162
  let parsed;
5160
5163
  try {
5161
5164
  parsed = await parseFile(file, content, lang);
5162
5165
  } catch (e) {
5163
- return { file, stat: stat12, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
5166
+ return { file, stat: stat13, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
5164
5167
  }
5165
- return { file, stat: stat12, lang, parsed, content };
5168
+ return { file, stat: stat13, lang, parsed, content };
5166
5169
  })
5167
5170
  );
5168
5171
  const batchEntries = [];
@@ -5182,7 +5185,7 @@ async function runIndexerWithStore(store, opts) {
5182
5185
  if (result.error.includes("error:")) errors.push(result.error);
5183
5186
  continue;
5184
5187
  }
5185
- const { stat: stat12, lang, parsed } = result;
5188
+ const { stat: stat13, lang, parsed } = result;
5186
5189
  if (result.skippedMeta) {
5187
5190
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
5188
5191
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -5194,7 +5197,7 @@ async function runIndexerWithStore(store, opts) {
5194
5197
  store.upsertFile({
5195
5198
  file,
5196
5199
  lang,
5197
- mtimeMs: Math.floor(stat12.mtimeMs),
5200
+ mtimeMs: Math.floor(stat13.mtimeMs),
5198
5201
  symbolCount: 0,
5199
5202
  lastIndexed: Date.now()
5200
5203
  });
@@ -5206,7 +5209,7 @@ async function runIndexerWithStore(store, opts) {
5206
5209
  store.upsertFile({
5207
5210
  file,
5208
5211
  lang,
5209
- mtimeMs: Math.floor(stat12.mtimeMs),
5212
+ mtimeMs: Math.floor(stat13.mtimeMs),
5210
5213
  symbolCount: 0,
5211
5214
  lastIndexed: Date.now()
5212
5215
  });
@@ -5222,7 +5225,7 @@ async function runIndexerWithStore(store, opts) {
5222
5225
  lang,
5223
5226
  symbols: parsed.symbols,
5224
5227
  refs,
5225
- mtimeMs: Math.floor(stat12.mtimeMs),
5228
+ mtimeMs: Math.floor(stat13.mtimeMs),
5226
5229
  symbolCount: parsed.symbols.length
5227
5230
  });
5228
5231
  deleteForFiles.push(file);
@@ -5371,8 +5374,8 @@ function statsService(args) {
5371
5374
  }
5372
5375
 
5373
5376
  // src/codebase-index/background-indexer.ts
5374
- var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
5375
- var DEFAULT_INCREMENTAL_TIMEOUT_MS = 3e4;
5377
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 24e4;
5378
+ var DEFAULT_INCREMENTAL_TIMEOUT_MS = 6e4;
5376
5379
  var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
5377
5380
  var _ready = false;
5378
5381
  var _indexing = false;
@@ -5887,6 +5890,25 @@ var codebaseStatsTool = {
5887
5890
  };
5888
5891
  }
5889
5892
  };
5893
+ async function resolveReal(p) {
5894
+ const resolved = path12.resolve(p);
5895
+ let probe = resolved;
5896
+ const missing = [];
5897
+ for (; ; ) {
5898
+ try {
5899
+ return path12.resolve(await fs2.realpath(probe), ...missing);
5900
+ } catch (err) {
5901
+ if (err.code === "ENOENT") {
5902
+ const parent = path12.dirname(probe);
5903
+ if (parent === probe) return resolved;
5904
+ missing.unshift(path12.basename(probe));
5905
+ probe = parent;
5906
+ continue;
5907
+ }
5908
+ return resolved;
5909
+ }
5910
+ }
5911
+ }
5890
5912
  function normalizeOverrides(set) {
5891
5913
  const out = {};
5892
5914
  if (set && typeof set === "object") {
@@ -6044,10 +6066,12 @@ These win over kit tokens. Run \`design {action:"materialize"}\` to write them t
6044
6066
  kitId: active.kit,
6045
6067
  outPath: input.out
6046
6068
  });
6047
- const root = path3.resolve(ctx.projectRoot);
6048
- const abs = path3.resolve(path3.join(ctx.projectRoot, result.path));
6049
- const rel = path3.relative(root, abs);
6050
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
6069
+ const root = await resolveReal(ctx.projectRoot);
6070
+ const absResolved = path12.isAbsolute(result.path) ? path12.resolve(result.path) : path12.resolve(path12.join(ctx.projectRoot, result.path));
6071
+ const absParent = await resolveReal(path12.dirname(absResolved));
6072
+ const abs = path12.join(absParent, path12.basename(absResolved));
6073
+ const rel = path12.relative(root, abs);
6074
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
6051
6075
  throw new Error(
6052
6076
  `design: materialize path "${result.path}" would escape the project root`
6053
6077
  );
@@ -6071,7 +6095,7 @@ ${result.content}
6071
6095
  \`\`\``
6072
6096
  };
6073
6097
  }
6074
- await fs2.mkdir(path3.dirname(abs), { recursive: true });
6098
+ await fs2.mkdir(path12.dirname(abs), { recursive: true });
6075
6099
  await fs2.writeFile(abs, result.content);
6076
6100
  return {
6077
6101
  action,
@@ -6199,11 +6223,11 @@ function findGitDir(cwd) {
6199
6223
  let dir = cwd;
6200
6224
  for (let i = 0; i < 20; i++) {
6201
6225
  try {
6202
- const stat12 = statSync(path3.join(dir, ".git"));
6203
- if (stat12.isDirectory()) return dir;
6226
+ const stat13 = statSync(path12.join(dir, ".git"));
6227
+ if (stat13.isDirectory()) return dir;
6204
6228
  } catch {
6205
6229
  }
6206
- const parent = path3.dirname(dir);
6230
+ const parent = path12.dirname(dir);
6207
6231
  if (parent === dir) break;
6208
6232
  dir = parent;
6209
6233
  }
@@ -6244,8 +6268,8 @@ async function fileDiff(input, ctx, _signal) {
6244
6268
  const results = [];
6245
6269
  for (const file of files) {
6246
6270
  const absPath = safeResolve(file, ctx);
6247
- const stat12 = await fs2.stat(absPath).catch(() => null);
6248
- if (!stat12?.isFile()) continue;
6271
+ const stat13 = await fs2.stat(absPath).catch(() => null);
6272
+ if (!stat13?.isFile()) continue;
6249
6273
  const content = await fs2.readFile(absPath, "utf8");
6250
6274
  const lines = content.split(/\r?\n/);
6251
6275
  results.push(formatWithLineNumbers(file, lines));
@@ -6342,8 +6366,8 @@ async function resolveFiles(filesInput, cwd) {
6342
6366
  for (const f of files) {
6343
6367
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
6344
6368
  try {
6345
- const stat12 = await fs2.stat(absPath);
6346
- if (stat12.isFile()) resolved.push(absPath);
6369
+ const stat13 = await fs2.stat(absPath);
6370
+ if (stat13.isFile()) resolved.push(absPath);
6347
6371
  } catch {
6348
6372
  }
6349
6373
  }
@@ -6409,11 +6433,303 @@ function processFile(content, absPath, _style, _overwrite, target) {
6409
6433
  }
6410
6434
  return results;
6411
6435
  }
6436
+
6437
+ // src/_edit-match.ts
6438
+ var TIER_LABEL = {
6439
+ exact: "exact match",
6440
+ "trailing-whitespace": "whitespace-normalized match (trailing whitespace ignored)",
6441
+ "whitespace-normalized": "whitespace-normalized match (indentation ignored, replacement re-indented)",
6442
+ fuzzy: "fuzzy match (block anchors exact, interior \u226590% similar)"
6443
+ };
6444
+ var TIER_CONFIDENCE = {
6445
+ exact: "exact",
6446
+ "trailing-whitespace": "high",
6447
+ "whitespace-normalized": "medium",
6448
+ fuzzy: "low"
6449
+ };
6450
+ var MIN_NORMALIZED_NEEDLE_CHARS = 16;
6451
+ var FUZZY_MIN_SIMILARITY = 0.9;
6452
+ var FUZZY_AMBIGUITY_MARGIN = 0.05;
6453
+ var FUZZY_MAX_INTERIOR_CHARS = 2e3;
6454
+ function findLadderMatches(fileLf, oldLf) {
6455
+ const exact = [];
6456
+ let idx = fileLf.indexOf(oldLf);
6457
+ while (idx !== -1) {
6458
+ exact.push({ start: idx, end: idx + oldLf.length, startLine: lineAt(fileLf, idx) });
6459
+ idx = fileLf.indexOf(oldLf, idx + 1);
6460
+ }
6461
+ if (exact.length > 0) return { tier: "exact", matches: exact };
6462
+ const fileLines = fileLf.split("\n");
6463
+ const needleLines = oldLf.split("\n");
6464
+ if (needleLines.length > fileLines.length) return void 0;
6465
+ const offsets = lineOffsets(fileLines);
6466
+ const trailing = windowScan(
6467
+ fileLines,
6468
+ needleLines,
6469
+ offsets,
6470
+ (a, b) => a.trimEnd() === b.trimEnd()
6471
+ );
6472
+ if (trailing.length > 0) return { tier: "trailing-whitespace", matches: trailing };
6473
+ const normalizedLen = needleLines.reduce((n, l) => n + l.trim().length, 0);
6474
+ if (normalizedLen < MIN_NORMALIZED_NEEDLE_CHARS) return void 0;
6475
+ const normalized = windowScan(fileLines, needleLines, offsets, (a, b) => a.trim() === b.trim());
6476
+ if (normalized.length > 0) return { tier: "whitespace-normalized", matches: normalized };
6477
+ return fuzzyScan(fileLines, needleLines, offsets);
6478
+ }
6479
+ function lineAt(text, pos) {
6480
+ let line = 1;
6481
+ for (let i = 0; i < pos; i++) {
6482
+ if (text.charCodeAt(i) === 10) line++;
6483
+ }
6484
+ return line;
6485
+ }
6486
+ function lineOffsets(lines) {
6487
+ const out = new Array(lines.length);
6488
+ let pos = 0;
6489
+ for (let i = 0; i < lines.length; i++) {
6490
+ out[i] = pos;
6491
+ pos += lines[i].length + 1;
6492
+ }
6493
+ return out;
6494
+ }
6495
+ function windowToMatch(fileLines, offsets, start, windowLen) {
6496
+ const lastLine = start + windowLen - 1;
6497
+ return {
6498
+ start: offsets[start],
6499
+ end: offsets[lastLine] + fileLines[lastLine].length,
6500
+ startLine: start + 1
6501
+ };
6502
+ }
6503
+ function windowScan(fileLines, needleLines, offsets, eq) {
6504
+ const n = needleLines.length;
6505
+ const out = [];
6506
+ for (let i = 0; i + n <= fileLines.length; i++) {
6507
+ let all = true;
6508
+ for (let j = 0; j < n; j++) {
6509
+ if (!eq(fileLines[i + j], needleLines[j])) {
6510
+ all = false;
6511
+ break;
6512
+ }
6513
+ }
6514
+ if (all) {
6515
+ out.push(windowToMatch(fileLines, offsets, i, n));
6516
+ i += n - 1;
6517
+ }
6518
+ }
6519
+ return out;
6520
+ }
6521
+ function fuzzyScan(fileLines, needleLines, offsets) {
6522
+ const n = needleLines.length;
6523
+ if (n < 3) return void 0;
6524
+ const firstNeedle = needleLines[0].trim();
6525
+ const lastNeedle = needleLines[n - 1].trim();
6526
+ const needleInterior = needleLines.slice(1, -1).map((l) => l.trim()).join("\n");
6527
+ if (needleInterior.length > FUZZY_MAX_INTERIOR_CHARS) return void 0;
6528
+ const candidates = [];
6529
+ for (let i = 0; i + n <= fileLines.length; i++) {
6530
+ if (fileLines[i].trim() !== firstNeedle) continue;
6531
+ if (fileLines[i + n - 1].trim() !== lastNeedle) continue;
6532
+ const windowInterior = fileLines.slice(i + 1, i + n - 1).map((l) => l.trim()).join("\n");
6533
+ if (windowInterior.length > FUZZY_MAX_INTERIOR_CHARS) continue;
6534
+ const score = similarity(needleInterior, windowInterior);
6535
+ if (score >= FUZZY_MIN_SIMILARITY) {
6536
+ candidates.push({ match: windowToMatch(fileLines, offsets, i, n), score });
6537
+ }
6538
+ }
6539
+ if (candidates.length === 0) return void 0;
6540
+ candidates.sort((a, b) => b.score - a.score);
6541
+ const best = candidates[0];
6542
+ const runnerUp = candidates[1];
6543
+ if (runnerUp && best.score - runnerUp.score < FUZZY_AMBIGUITY_MARGIN) {
6544
+ return {
6545
+ tier: "fuzzy",
6546
+ matches: candidates.map((c) => c.match),
6547
+ score: best.score,
6548
+ ambiguous: true
6549
+ };
6550
+ }
6551
+ return { tier: "fuzzy", matches: [best.match], score: best.score };
6552
+ }
6553
+ function similarity(a, b) {
6554
+ if (a === b) return 1;
6555
+ const max = Math.max(a.length, b.length);
6556
+ if (max === 0) return 1;
6557
+ if (Math.abs(a.length - b.length) / max > 1 - FUZZY_MIN_SIMILARITY + 0.05) {
6558
+ return 1 - Math.abs(a.length - b.length) / max;
6559
+ }
6560
+ return 1 - levenshtein(a, b) / max;
6561
+ }
6562
+ function levenshtein(a, b) {
6563
+ if (a.length === 0) return b.length;
6564
+ if (b.length === 0) return a.length;
6565
+ let prev = new Array(b.length + 1);
6566
+ let cur = new Array(b.length + 1);
6567
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
6568
+ for (let i = 1; i <= a.length; i++) {
6569
+ cur[0] = i;
6570
+ const ca = a.charCodeAt(i - 1);
6571
+ for (let j = 1; j <= b.length; j++) {
6572
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
6573
+ cur[j] = Math.min(
6574
+ prev[j] + 1,
6575
+ cur[j - 1] + 1,
6576
+ prev[j - 1] + cost
6577
+ );
6578
+ }
6579
+ [prev, cur] = [cur, prev];
6580
+ }
6581
+ return prev[b.length];
6582
+ }
6583
+ function firstLineIndent(text) {
6584
+ for (const line of text.split("\n")) {
6585
+ if (line.trim() === "") continue;
6586
+ return /^[ \t]*/.exec(line)[0];
6587
+ }
6588
+ return "";
6589
+ }
6590
+ function adjustIndent(newLf, fromIndent, toIndent) {
6591
+ if (fromIndent === toIndent) return { text: newLf, adjusted: false };
6592
+ const lines = newLf.split("\n");
6593
+ if (toIndent.startsWith(fromIndent)) {
6594
+ const extra = toIndent.slice(fromIndent.length);
6595
+ return {
6596
+ text: lines.map((l) => l.trim() === "" ? l : extra + l).join("\n"),
6597
+ adjusted: true
6598
+ };
6599
+ }
6600
+ if (fromIndent.startsWith(toIndent)) {
6601
+ const remove = fromIndent.slice(toIndent.length);
6602
+ return {
6603
+ text: lines.map((l) => l.startsWith(remove) ? l.slice(remove.length) : l).join("\n"),
6604
+ adjusted: true
6605
+ };
6606
+ }
6607
+ return { text: newLf, adjusted: false };
6608
+ }
6609
+ function nearestMatchHint(fileLf, oldLf) {
6610
+ const fileLines = fileLf.split("\n");
6611
+ const needleLines = oldLf.split("\n");
6612
+ if (needleLines.length > fileLines.length) return void 0;
6613
+ const needleTrimmed = needleLines.map((l) => l.trim());
6614
+ let bestScore = 0;
6615
+ let bestStart = -1;
6616
+ for (let i = 0; i + needleLines.length <= fileLines.length; i++) {
6617
+ let sum = 0;
6618
+ for (let j = 0; j < needleLines.length; j++) {
6619
+ sum += prefixSimilarity(needleTrimmed[j], fileLines[i + j].trim());
6620
+ }
6621
+ const score = sum / needleLines.length;
6622
+ if (score > bestScore) {
6623
+ bestScore = score;
6624
+ bestStart = i;
6625
+ }
6626
+ }
6627
+ if (bestStart === -1 || bestScore < 0.5) return void 0;
6628
+ const snippet = fileLines.slice(bestStart, bestStart + Math.min(3, needleLines.length)).map((l) => l.length > 120 ? `${l.slice(0, 120)}\u2026` : l).join("\n");
6629
+ return { line: bestStart + 1, snippet };
6630
+ }
6631
+ function prefixSimilarity(a, b) {
6632
+ if (a === b) return 1;
6633
+ const max = Math.max(a.length, b.length);
6634
+ if (max === 0) return 1;
6635
+ let p = 0;
6636
+ const lim = Math.min(a.length, b.length);
6637
+ while (p < lim && a.charCodeAt(p) === b.charCodeAt(p)) p++;
6638
+ return p / max;
6639
+ }
6640
+ var TS_LIKE = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
6641
+ var MAX_CHECK_CHARS = 15e5;
6642
+ var MAX_ERRORS = 5;
6643
+ var tsLoad = null;
6644
+ function loadTypescript() {
6645
+ tsLoad ??= import('typescript').then(
6646
+ (m) => m.default ?? m,
6647
+ () => null
6648
+ );
6649
+ return tsLoad;
6650
+ }
6651
+ async function checkSyntax(filePath, content, previousContent) {
6652
+ const errors = await parseErrors(filePath, content);
6653
+ if (errors === void 0) return void 0;
6654
+ if (errors.length === 0) return { errors, preExisting: false };
6655
+ let preExisting = false;
6656
+ if (previousContent !== void 0) {
6657
+ const prevErrors = await parseErrors(filePath, previousContent);
6658
+ preExisting = prevErrors !== void 0 && prevErrors.length > 0;
6659
+ }
6660
+ return { errors, preExisting };
6661
+ }
6662
+ function isJsoncFile(filePath) {
6663
+ const base = path12.basename(filePath).toLowerCase();
6664
+ if (base.endsWith(".jsonc")) return true;
6665
+ if (/^(tsconfig|jsconfig)([.-].*)?\.json$/.test(base)) return true;
6666
+ const dir = path12.basename(path12.dirname(filePath)).toLowerCase();
6667
+ return dir === ".vscode";
6668
+ }
6669
+ async function parseErrors(filePath, content) {
6670
+ if (content.length > MAX_CHECK_CHARS) return void 0;
6671
+ const ext = path12.extname(filePath).toLowerCase();
6672
+ if (ext === ".json" || ext === ".jsonc") {
6673
+ try {
6674
+ JSON.parse(content);
6675
+ return [];
6676
+ } catch (err) {
6677
+ if (isJsoncFile(filePath)) {
6678
+ const ts3 = await loadTypescript();
6679
+ if (ts3) {
6680
+ const jsonc = ts3.parseConfigFileTextToJson(filePath, content);
6681
+ if (!jsonc.error) return [];
6682
+ return [formatDiag(ts3, jsonc.error, content)];
6683
+ }
6684
+ }
6685
+ return [`JSON parse error: ${err.message}`];
6686
+ }
6687
+ }
6688
+ if (!TS_LIKE.has(ext)) return void 0;
6689
+ const ts2 = await loadTypescript();
6690
+ if (!ts2) return void 0;
6691
+ const scriptKind = ext === ".tsx" ? ts2.ScriptKind.TSX : ext === ".ts" || ext === ".mts" || ext === ".cts" ? ts2.ScriptKind.TS : (
6692
+ // Plain JS may legitimately contain JSX; the JSX grammar is a
6693
+ // superset for untyped code, so parsing .js/.jsx as JSX avoids
6694
+ // false positives on React files.
6695
+ ts2.ScriptKind.JSX
6696
+ );
6697
+ const sourceFile = ts2.createSourceFile(
6698
+ path12.basename(filePath),
6699
+ content,
6700
+ ts2.ScriptTarget.Latest,
6701
+ /* setParentNodes */
6702
+ false,
6703
+ scriptKind
6704
+ );
6705
+ const diags = sourceFile.parseDiagnostics ?? [];
6706
+ return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts2, d, content, sourceFile));
6707
+ }
6708
+ function formatDiag(ts2, diag, content, sourceFile) {
6709
+ const message = ts2.flattenDiagnosticMessageText(diag.messageText, " ");
6710
+ if (diag.start === void 0) return message;
6711
+ let line;
6712
+ if (sourceFile) {
6713
+ line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;
6714
+ } else {
6715
+ line = 1;
6716
+ for (let i = 0; i < diag.start && i < content.length; i++) {
6717
+ if (content.charCodeAt(i) === 10) line++;
6718
+ }
6719
+ }
6720
+ return `line ${line}: ${message}`;
6721
+ }
6722
+
6723
+ // src/edit.ts
6412
6724
  var editTool = {
6413
6725
  name: "edit",
6414
6726
  category: "Filesystem",
6415
6727
  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.",
6416
- 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.",
6728
+ 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.",
6729
+ selection: {
6730
+ doNotUseWhen: "creating a new file, replacing the whole file, or applying an existing unified diff.",
6731
+ useInstead: ["write", "patch"]
6732
+ },
6417
6733
  permission: "confirm",
6418
6734
  mutating: true,
6419
6735
  capabilities: ["fs.write"],
@@ -6429,7 +6745,7 @@ var editTool = {
6429
6745
  },
6430
6746
  required: ["path", "old_string", "new_string"]
6431
6747
  },
6432
- async execute(input, ctx) {
6748
+ async execute(input, ctx, opts) {
6433
6749
  if (!input?.path) {
6434
6750
  throw new ToolValidationError({ message: "edit: path is required", field: "path" });
6435
6751
  }
@@ -6452,7 +6768,7 @@ var editTool = {
6452
6768
  });
6453
6769
  }
6454
6770
  const absPath = await safeResolveReal(input.path, ctx);
6455
- const stat12 = await fs2.stat(absPath).catch((err) => {
6771
+ const stat13 = await fs2.stat(absPath).catch((err) => {
6456
6772
  if (err.code === "ENOENT") {
6457
6773
  throw new ToolValidationError({
6458
6774
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -6462,7 +6778,7 @@ var editTool = {
6462
6778
  }
6463
6779
  throw err;
6464
6780
  });
6465
- if (!stat12.isFile()) {
6781
+ if (!stat13.isFile()) {
6466
6782
  throw new ToolValidationError({
6467
6783
  message: `edit: "${input.path}" is not a regular file`,
6468
6784
  field: "path"
@@ -6472,15 +6788,27 @@ var editTool = {
6472
6788
  const original = await fs2.readFile(absPath, "utf8");
6473
6789
  const updated = await fs2.stat(absPath);
6474
6790
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
6475
- const lastReadMtime = ctx.lastReadMtime(absPath);
6476
- if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6477
- throw new ToolValidationError({
6478
- message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6479
- field: "path",
6480
- context: { reason: "external_modification" }
6481
- });
6791
+ const originalHash = sha256hex(original);
6792
+ const lastReadHash = ctx.lastReadHash?.(absPath);
6793
+ if (lastReadHash !== void 0) {
6794
+ if (lastReadHash !== originalHash) {
6795
+ throw new ToolValidationError({
6796
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6797
+ field: "path",
6798
+ context: { reason: "external_modification" }
6799
+ });
6800
+ }
6801
+ } else {
6802
+ const lastReadMtime = ctx.lastReadMtime(absPath);
6803
+ if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6804
+ throw new ToolValidationError({
6805
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6806
+ field: "path",
6807
+ context: { reason: "external_modification" }
6808
+ });
6809
+ }
6482
6810
  }
6483
- if (autoRead && updated.mtimeMs > stat12.mtimeMs + mtimeTolerance) {
6811
+ if (autoRead && updated.mtimeMs > stat13.mtimeMs + mtimeTolerance) {
6484
6812
  throw new ToolValidationError({
6485
6813
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
6486
6814
  field: "path",
@@ -6493,42 +6821,78 @@ var editTool = {
6493
6821
  const oldLf = normalizeToLf(input.old_string);
6494
6822
  const newLf = normalizeToLf(input.new_string);
6495
6823
  if (oldLf === newLf) {
6496
- if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
6824
+ if (autoRead) ctx.recordRead(absPath, updated.mtimeMs, "user", originalHash);
6497
6825
  return {
6498
6826
  path: absPath,
6499
6827
  replacements: 0,
6500
6828
  diff: "(no-op: old and new are identical)",
6829
+ matched_by: "exact",
6501
6830
  note: autoReadNote
6502
6831
  };
6503
6832
  }
6504
- let count = 0;
6505
- let idx = fileLf.indexOf(oldLf);
6506
- const matches = [];
6507
- while (idx !== -1) {
6508
- matches.push(idx);
6509
- count++;
6510
- idx = fileLf.indexOf(oldLf, idx + 1);
6511
- }
6512
- if (count === 0) {
6513
- const hint = findSimilarity(fileLf, oldLf);
6833
+ const ladder = findLadderMatches(fileLf, oldLf);
6834
+ if (!ladder) {
6835
+ const hint = nearestMatchHint(fileLf, oldLf);
6514
6836
  throw new ToolValidationError({
6515
- message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`,
6837
+ message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint.line}:
6838
+ ${hint.snippet}
6839
+ Compare this against your old_string and retry with the file's actual text.` : ""}`,
6516
6840
  field: "old_string"
6517
6841
  });
6518
6842
  }
6843
+ const { tier, matches } = ladder;
6844
+ const count = matches.length;
6845
+ if (ladder.ambiguous) {
6846
+ const lines = matches.map((m) => m.startLine);
6847
+ throw new ToolValidationError({
6848
+ 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.`,
6849
+ field: "old_string",
6850
+ context: { occurrences: count, matchTier: tier }
6851
+ });
6852
+ }
6853
+ if (input.replace_all && tier !== "exact" && tier !== "trailing-whitespace") {
6854
+ throw new ToolValidationError({
6855
+ 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.`,
6856
+ field: "old_string",
6857
+ context: { matchTier: tier }
6858
+ });
6859
+ }
6519
6860
  if (count > 1 && !input.replace_all) {
6520
- const lines = lineNumbersFor(fileLf, matches);
6861
+ const lines = matches.map((m) => m.startLine);
6521
6862
  throw new ToolValidationError({
6522
- 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.`,
6863
+ 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.`,
6523
6864
  field: "old_string",
6524
- context: { occurrences: count }
6865
+ context: { occurrences: count, matchTier: tier }
6525
6866
  });
6526
6867
  }
6527
- const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6868
+ let newFileLf;
6869
+ let tierNote;
6870
+ if (tier === "exact") {
6871
+ newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6872
+ } else {
6873
+ let replacement = newLf;
6874
+ let indentSuffix = "";
6875
+ if (tier === "whitespace-normalized" || tier === "fuzzy") {
6876
+ const first = matches[0];
6877
+ const matchedText = fileLf.slice(first.start, first.end);
6878
+ const adjusted = adjustIndent(newLf, firstLineIndent(oldLf), firstLineIndent(matchedText));
6879
+ replacement = adjusted.text;
6880
+ if (adjusted.adjusted) indentSuffix = "; replacement re-indented to match the file";
6881
+ }
6882
+ newFileLf = fileLf;
6883
+ const applied = input.replace_all ? matches : matches.slice(0, 1);
6884
+ for (let i = applied.length - 1; i >= 0; i--) {
6885
+ const m = applied[i];
6886
+ newFileLf = newFileLf.slice(0, m.start) + replacement + newFileLf.slice(m.end);
6887
+ }
6888
+ const scoreSuffix = ladder.score !== void 0 ? `, similarity ${(ladder.score * 100).toFixed(1)}%` : "";
6889
+ 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.`;
6890
+ }
6528
6891
  const newFile = toStyle(newFileLf, style);
6892
+ opts?.signal?.throwIfAborted();
6529
6893
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
6530
6894
  const written = await fs2.stat(absPath);
6531
- ctx.recordRead(absPath, written.mtimeMs, "write");
6895
+ ctx.recordRead(absPath, written.mtimeMs, "write", sha256hex(newFile));
6532
6896
  ctx.session.recordFileChange({
6533
6897
  path: absPath,
6534
6898
  action: "modified",
@@ -6539,38 +6903,22 @@ var editTool = {
6539
6903
  fromFile: input.path,
6540
6904
  toFile: input.path
6541
6905
  });
6906
+ const syntax = await checkSyntax(absPath, newFile, original).catch(() => void 0);
6907
+ let syntaxNote;
6908
+ if (syntax && syntax.errors.length > 0) {
6909
+ 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.`;
6910
+ }
6911
+ const notes = [autoReadNote, tierNote, syntaxNote].filter(Boolean);
6542
6912
  return {
6543
6913
  path: absPath,
6544
6914
  replacements: input.replace_all ? count : 1,
6545
6915
  diff,
6546
- note: autoReadNote
6916
+ matched_by: tier,
6917
+ syntax_errors: syntax && syntax.errors.length > 0 ? syntax.errors : void 0,
6918
+ note: notes.length > 0 ? notes.join("\n") : void 0
6547
6919
  };
6548
6920
  }
6549
6921
  };
6550
- function lineNumbersFor(text, indices) {
6551
- const out = [];
6552
- let pos = 0;
6553
- let line = 1;
6554
- for (const target of indices) {
6555
- while (pos < target) {
6556
- if (text.charCodeAt(pos) === 10) line++;
6557
- pos++;
6558
- }
6559
- out.push(line);
6560
- }
6561
- return out;
6562
- }
6563
- function findSimilarity(haystack, needle) {
6564
- if (needle.length < 20) return void 0;
6565
- const probe = needle.slice(0, Math.min(40, needle.length));
6566
- const idx = haystack.indexOf(probe);
6567
- if (idx === -1) return void 0;
6568
- let line = 1;
6569
- for (let i = 0; i < idx; i++) {
6570
- if (haystack.charCodeAt(i) === 10) line++;
6571
- }
6572
- return line;
6573
- }
6574
6922
  var isWin3 = process.platform === "win32";
6575
6923
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
6576
6924
  // JS / TS toolchain
@@ -7302,6 +7650,10 @@ var execTool = {
7302
7650
  category: "Shell",
7303
7651
  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.",
7304
7652
  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.",
7653
+ selection: {
7654
+ doNotUseWhen: "the operation requires pipes, redirection, shell expansion, or a non-allowlisted command.",
7655
+ useInstead: ["bash"]
7656
+ },
7305
7657
  permission: "confirm",
7306
7658
  mutating: true,
7307
7659
  riskTier: "standard",
@@ -7965,13 +8317,13 @@ var formatTool = {
7965
8317
  }
7966
8318
  };
7967
8319
  async function detectFixer(cwd) {
7968
- const { stat: stat12 } = await import('node:fs/promises');
8320
+ const { stat: stat13 } = await import('node:fs/promises');
7969
8321
  try {
7970
- await stat12(`${cwd}/biome.json`);
8322
+ await stat13(`${cwd}/biome.json`);
7971
8323
  return "biome";
7972
8324
  } catch {
7973
8325
  try {
7974
- await stat12(`${cwd}/.prettierrc`);
8326
+ await stat13(`${cwd}/.prettierrc`);
7975
8327
  return "prettier";
7976
8328
  } catch {
7977
8329
  return "biome";
@@ -8132,8 +8484,8 @@ function findGitDir2(cwd, projectRoot) {
8132
8484
  let dir = cwd;
8133
8485
  for (let i = 0; i < 20; i++) {
8134
8486
  try {
8135
- const stat12 = statSync(`${dir}/.git`);
8136
- if (stat12.isDirectory() || stat12.isFile()) return dir;
8487
+ const stat13 = statSync(`${dir}/.git`);
8488
+ if (stat13.isDirectory() || stat13.isFile()) return dir;
8137
8489
  } catch {
8138
8490
  }
8139
8491
  if (dir === root) break;
@@ -8280,6 +8632,10 @@ var globTool = {
8280
8632
  category: "Filesystem",
8281
8633
  description: "Find files matching a glob pattern. Fast way to discover relevant files before reading, grepping, or editing them.",
8282
8634
  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.",
8635
+ selection: {
8636
+ doNotUseWhen: "you need to search inside file contents.",
8637
+ useInstead: ["grep"]
8638
+ },
8283
8639
  permission: "auto",
8284
8640
  mutating: false,
8285
8641
  capabilities: ["fs.read"],
@@ -8304,8 +8660,9 @@ var globTool = {
8304
8660
  },
8305
8661
  required: ["pattern"]
8306
8662
  },
8307
- async execute(input, ctx) {
8663
+ async execute(input, ctx, opts) {
8308
8664
  if (!input?.pattern) throw new Error("glob: pattern is required");
8665
+ const signal = opts?.signal;
8309
8666
  const base = input.path ? await safeResolveReal(input.path, ctx) : ctx.cwd;
8310
8667
  const limit = Math.max(1, Math.min(input.limit ?? 1e3, 5e3));
8311
8668
  const ignored = await readGitignore(base);
@@ -8329,6 +8686,10 @@ var globTool = {
8329
8686
  }
8330
8687
  };
8331
8688
  const walk2 = async (dir, relPrefix) => {
8689
+ if (signal?.aborted) {
8690
+ truncated = true;
8691
+ return;
8692
+ }
8332
8693
  if (results.length >= limit) {
8333
8694
  truncated = true;
8334
8695
  return;
@@ -8346,7 +8707,7 @@ var globTool = {
8346
8707
  if (DEFAULT_IGNORE2.includes(name)) continue;
8347
8708
  if (ignored.includes(name)) continue;
8348
8709
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
8349
- const full = path3.join(dir, name);
8710
+ const full = path12.join(dir, name);
8350
8711
  if (e.isDirectory()) {
8351
8712
  subdirs.push({ full, rel });
8352
8713
  } else if (e.isFile()) {
@@ -8390,7 +8751,7 @@ var globTool = {
8390
8751
  };
8391
8752
  async function readGitignore(dir) {
8392
8753
  try {
8393
- const raw = await fs2.readFile(path3.join(dir, ".gitignore"), "utf8");
8754
+ const raw = await fs2.readFile(path12.join(dir, ".gitignore"), "utf8");
8394
8755
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
8395
8756
  } catch {
8396
8757
  return [];
@@ -8452,6 +8813,10 @@ var grepTool = {
8452
8813
  category: "Search",
8453
8814
  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.",
8454
8815
  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.',
8816
+ selection: {
8817
+ doNotUseWhen: "you only need to locate files by name or path pattern.",
8818
+ useInstead: ["glob"]
8819
+ },
8455
8820
  permission: "auto",
8456
8821
  mutating: false,
8457
8822
  capabilities: ["fs.read"],
@@ -8691,8 +9056,8 @@ async function runNative(input, base, mode, limit, signal) {
8691
9056
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
8692
9057
  if (globRe) globRe.lastIndex = 0;
8693
9058
  try {
8694
- const stat12 = await fs2.stat(full);
8695
- if (!stat12.isFile() || stat12.size > maxBytes || stopped || signal.aborted) return;
9059
+ const stat13 = await fs2.stat(full);
9060
+ if (!stat13.isFile() || stat13.size > maxBytes || stopped || signal.aborted) return;
8696
9061
  const file = await fs2.open(full, "r");
8697
9062
  try {
8698
9063
  let bytesReadTotal = 0;
@@ -8782,7 +9147,7 @@ async function runNative(input, base, mode, limit, signal) {
8782
9147
  if (stopped) return;
8783
9148
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
8784
9149
  if (e.isSymbolicLink()) continue;
8785
- const full = path3.join(dir, e.name);
9150
+ const full = path12.join(dir, e.name);
8786
9151
  if (e.isDirectory()) {
8787
9152
  subdirs.push(full);
8788
9153
  } else if (e.isFile()) {
@@ -9329,56 +9694,56 @@ function jmespathSearch(data, query) {
9329
9694
  }
9330
9695
  function validateJsonSchema(data, schema) {
9331
9696
  const errors = [];
9332
- function check(value, s, path23) {
9697
+ function check(value, s, path24) {
9333
9698
  if (s["type"]) {
9334
9699
  const expectedType = s["type"];
9335
9700
  const actualType = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
9336
9701
  if (expectedType === "integer") {
9337
- if (!Number.isInteger(value)) errors.push(`${path23}: expected integer, got ${actualType}`);
9702
+ if (!Number.isInteger(value)) errors.push(`${path24}: expected integer, got ${actualType}`);
9338
9703
  } else if (expectedType !== actualType) {
9339
- errors.push(`${path23}: expected ${expectedType}, got ${actualType}`);
9704
+ errors.push(`${path24}: expected ${expectedType}, got ${actualType}`);
9340
9705
  }
9341
9706
  }
9342
9707
  if (typeof value === "string" && s["format"] === "uri" && value) {
9343
9708
  try {
9344
9709
  new URL(value);
9345
9710
  } catch {
9346
- errors.push(`${path23}: not a valid URI`);
9711
+ errors.push(`${path24}: not a valid URI`);
9347
9712
  }
9348
9713
  }
9349
9714
  if (typeof value === "string" && s["pattern"]) {
9350
9715
  const re = new RegExp(s["pattern"]);
9351
- if (!re.test(value)) errors.push(`${path23}: does not match pattern ${s["pattern"]}`);
9716
+ if (!re.test(value)) errors.push(`${path24}: does not match pattern ${s["pattern"]}`);
9352
9717
  }
9353
9718
  if (typeof value === "string" && s["minLength"] !== void 0 && value.length < s["minLength"]) {
9354
- errors.push(`${path23}: string too short (min ${s["minLength"]})`);
9719
+ errors.push(`${path24}: string too short (min ${s["minLength"]})`);
9355
9720
  }
9356
9721
  if (typeof value === "string" && s["maxLength"] !== void 0 && value.length > s["maxLength"]) {
9357
- errors.push(`${path23}: string too long (max ${s["maxLength"]})`);
9722
+ errors.push(`${path24}: string too long (max ${s["maxLength"]})`);
9358
9723
  }
9359
9724
  if (typeof value === "number" && s["minimum"] !== void 0 && value < s["minimum"]) {
9360
- errors.push(`${path23}: below minimum ${s["minimum"]}`);
9725
+ errors.push(`${path24}: below minimum ${s["minimum"]}`);
9361
9726
  }
9362
9727
  if (typeof value === "number" && s["maximum"] !== void 0 && value > s["maximum"]) {
9363
- errors.push(`${path23}: above maximum ${s["maximum"]}`);
9728
+ errors.push(`${path24}: above maximum ${s["maximum"]}`);
9364
9729
  }
9365
9730
  if (Array.isArray(value) && s["items"] && Array.isArray(s["items"])) {
9366
9731
  for (let i = 0; i < value.length; i++) {
9367
- check(value[i], s["items"], `${path23}[${i}]`);
9732
+ check(value[i], s["items"], `${path24}[${i}]`);
9368
9733
  }
9369
9734
  }
9370
9735
  if (typeof value === "object" && value !== null && !Array.isArray(value) && s["properties"]) {
9371
9736
  const props = s["properties"];
9372
9737
  for (const [k, propSchema] of Object.entries(props)) {
9373
- check(value[k], propSchema, `${path23}.${k}`);
9738
+ check(value[k], propSchema, `${path24}.${k}`);
9374
9739
  }
9375
9740
  }
9376
9741
  }
9377
9742
  check(data, schema, "$");
9378
9743
  return { valid: errors.length === 0, errors };
9379
9744
  }
9380
- function simpleQuery(data, path23) {
9381
- const parts = path23.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9745
+ function simpleQuery(data, path24) {
9746
+ const parts = path24.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9382
9747
  let current = data;
9383
9748
  for (const part of parts) {
9384
9749
  if (current === null || current === void 0) return void 0;
@@ -10303,11 +10668,11 @@ var lintTool = {
10303
10668
  }
10304
10669
  };
10305
10670
  async function detectLinter(cwd) {
10306
- const { stat: stat12 } = await import('node:fs/promises');
10671
+ const { stat: stat13 } = await import('node:fs/promises');
10307
10672
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
10308
10673
  for (const f of checks) {
10309
10674
  try {
10310
- await stat12(`${cwd}/${f}`);
10675
+ await stat13(`${cwd}/${f}`);
10311
10676
  if (f.includes("biome")) return "biome";
10312
10677
  if (f.includes("eslint")) return "eslint";
10313
10678
  if (f.includes("tslint")) return "tslint";
@@ -10449,7 +10814,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
10449
10814
  }
10450
10815
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
10451
10816
  var MAX_TAIL_LINES = 1e5;
10452
- async function fileLogs(path23, lines, filterRe, stream) {
10817
+ async function fileLogs(path24, lines, filterRe, stream) {
10453
10818
  const { createInterface } = await import('node:readline');
10454
10819
  const { createReadStream } = await import('node:fs');
10455
10820
  const entries = [];
@@ -10458,7 +10823,7 @@ async function fileLogs(path23, lines, filterRe, stream) {
10458
10823
  let writeIdx = 0;
10459
10824
  let totalLines = 0;
10460
10825
  const rl = createInterface({
10461
- input: createReadStream(path23),
10826
+ input: createReadStream(path24),
10462
10827
  crlfDelay: Number.POSITIVE_INFINITY
10463
10828
  });
10464
10829
  for await (const line of rl) {
@@ -10479,7 +10844,7 @@ async function fileLogs(path23, lines, filterRe, stream) {
10479
10844
  if (parsed) entries.push(parsed);
10480
10845
  }
10481
10846
  return {
10482
- source: path23,
10847
+ source: path24,
10483
10848
  entries,
10484
10849
  total: entries.length,
10485
10850
  truncated: totalLines > effLines,
@@ -10646,6 +11011,10 @@ var patchTool = {
10646
11011
  category: "Filesystem",
10647
11012
  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.",
10648
11013
  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.",
11014
+ selection: {
11015
+ doNotUseWhen: "you do not already have a unified diff or only need one precise replacement.",
11016
+ useInstead: ["edit"]
11017
+ },
10649
11018
  permission: "confirm",
10650
11019
  mutating: true,
10651
11020
  capabilities: ["fs.write"],
@@ -10667,12 +11036,13 @@ var patchTool = {
10667
11036
  const strip = Math.max(1, input.strip ?? 1);
10668
11037
  const dryRun = input.dry_run ?? false;
10669
11038
  const targets = extractDiffTargets(input.patch);
11039
+ const resolvedTargets = [];
10670
11040
  for (const t of targets) {
10671
11041
  const stripped = stripPathComponents(t, strip);
10672
11042
  if (!stripped) continue;
10673
- const candidate = path3.resolve(dir, stripped);
10674
- const rel = path3.relative(ctx.projectRoot, candidate);
10675
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
11043
+ const candidate = path12.resolve(dir, stripped);
11044
+ const rel = path12.relative(ctx.projectRoot, candidate);
11045
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
10676
11046
  return {
10677
11047
  applied: 0,
10678
11048
  rejected: 1,
@@ -10681,12 +11051,19 @@ var patchTool = {
10681
11051
  message: `patch refused: target "${t}" resolves outside project root`
10682
11052
  };
10683
11053
  }
11054
+ resolvedTargets.push(candidate);
10684
11055
  }
10685
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), ".wstack_patch_"));
11056
+ const beforeContents = /* @__PURE__ */ new Map();
11057
+ if (!dryRun) {
11058
+ for (const target of resolvedTargets) {
11059
+ beforeContents.set(target, await readTextForTracking(target));
11060
+ }
11061
+ }
11062
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), ".wstack_patch_"));
10686
11063
  try {
10687
11064
  await fs2.chmod(tmpDir, 448).catch(() => {
10688
11065
  });
10689
- const patchFile = path3.join(tmpDir, "in.diff");
11066
+ const patchFile = path12.join(tmpDir, "in.diff");
10690
11067
  await fs2.writeFile(patchFile, input.patch, { mode: 384 });
10691
11068
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
10692
11069
  const result = await runPatch(args, dir, opts.signal);
@@ -10700,6 +11077,21 @@ var patchTool = {
10700
11077
  };
10701
11078
  }
10702
11079
  const patched = extractPatchedFiles(result.stdout);
11080
+ if (!dryRun) {
11081
+ for (const target of resolvedTargets) {
11082
+ const before = beforeContents.get(target) ?? null;
11083
+ const after = await readTextForTracking(target);
11084
+ if (after === null || after === before) continue;
11085
+ const stat13 = await fs2.stat(target).catch(() => null);
11086
+ if (stat13) ctx.recordRead?.(target, stat13.mtimeMs, "write", sha256hex(after));
11087
+ ctx.session?.recordFileChange?.({
11088
+ path: target,
11089
+ action: before === null ? "created" : "modified",
11090
+ before,
11091
+ after
11092
+ });
11093
+ }
11094
+ }
10703
11095
  return {
10704
11096
  applied: patched.length,
10705
11097
  rejected: 0,
@@ -10713,6 +11105,18 @@ var patchTool = {
10713
11105
  }
10714
11106
  }
10715
11107
  };
11108
+ var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
11109
+ async function readTextForTracking(absPath) {
11110
+ try {
11111
+ const stat13 = await fs2.stat(absPath);
11112
+ if (!stat13.isFile() || stat13.size > MAX_TRACKING_BYTES) return null;
11113
+ const buf = await fs2.readFile(absPath);
11114
+ if (buf.includes(0)) return null;
11115
+ return buf.toString("utf8");
11116
+ } catch {
11117
+ return null;
11118
+ }
11119
+ }
10716
11120
  function extractDiffTargets(patch) {
10717
11121
  const out = [];
10718
11122
  const re = /^\+\+\+\s+([^\t\r\n]+)/gm;
@@ -11020,6 +11424,10 @@ var readTool = {
11020
11424
  category: "Filesystem",
11021
11425
  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.",
11022
11426
  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.",
11427
+ selection: {
11428
+ doNotUseWhen: "you need to search many files for matching content.",
11429
+ useInstead: ["grep"]
11430
+ },
11023
11431
  permission: "auto",
11024
11432
  mutating: false,
11025
11433
  capabilities: ["fs.read"],
@@ -11057,9 +11465,9 @@ var readTool = {
11057
11465
  });
11058
11466
  }
11059
11467
  const absPath = await safeResolveReal(input.path, ctx);
11060
- let stat12;
11468
+ let stat13;
11061
11469
  try {
11062
- stat12 = await fs2.stat(absPath);
11470
+ stat13 = await fs2.stat(absPath);
11063
11471
  } catch (err) {
11064
11472
  const code = err.code;
11065
11473
  if (code === "ENOENT") {
@@ -11078,7 +11486,7 @@ var readTool = {
11078
11486
  cause: err
11079
11487
  });
11080
11488
  }
11081
- if (!stat12.isFile()) {
11489
+ if (!stat13.isFile()) {
11082
11490
  throw new FsError({
11083
11491
  message: `read: "${input.path}" is not a regular file`,
11084
11492
  code: "FS_READ_FAILED",
@@ -11086,22 +11494,22 @@ var readTool = {
11086
11494
  context: { reason: "not-a-regular-file" }
11087
11495
  });
11088
11496
  }
11089
- if (stat12.size > MAX_BYTES2) {
11497
+ if (stat13.size > MAX_BYTES2) {
11090
11498
  throw new FsError({
11091
- message: `read: file too large (${stat12.size} bytes, limit ${MAX_BYTES2})`,
11499
+ message: `read: file too large (${stat13.size} bytes, limit ${MAX_BYTES2})`,
11092
11500
  code: "FS_READ_FAILED",
11093
11501
  path: absPath,
11094
- context: { size: stat12.size, limit: MAX_BYTES2, reason: "too-large" }
11502
+ context: { size: stat13.size, limit: MAX_BYTES2, reason: "too-large" }
11095
11503
  });
11096
11504
  }
11097
11505
  const offset = Math.max(1, input.offset ?? 1);
11098
11506
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
11099
11507
  const prior = getReadRangeRecord(ctx, absPath);
11100
11508
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
11101
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat12.mtimeMs, offset, requestedEnd)) {
11102
- ctx.recordRead(absPath, stat12.mtimeMs);
11509
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat13.mtimeMs, offset, requestedEnd)) {
11510
+ ctx.recordRead(absPath, stat13.mtimeMs);
11103
11511
  return {
11104
- 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.]`,
11512
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat13.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
11105
11513
  total_lines: prior.totalLines,
11106
11514
  encoding: "utf8",
11107
11515
  truncated: requestedEnd < prior.totalLines,
@@ -11114,13 +11522,14 @@ var readTool = {
11114
11522
  throw new Error(`read: "${input.path}" appears to be binary`);
11115
11523
  }
11116
11524
  const text = buf.toString("utf8");
11525
+ const contentHash = sha256hex(text);
11117
11526
  const allLines = text.split(/\r\n|\r|\n/);
11118
11527
  const total = allLines.length;
11119
11528
  if (input.mode === "summary") {
11120
- ctx.recordRead(absPath, stat12.mtimeMs);
11121
- rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, Math.min(total, 200));
11529
+ ctx.recordRead(absPath, stat13.mtimeMs, "user", contentHash);
11530
+ rememberReadRange(ctx, absPath, stat13.mtimeMs, total, 1, Math.min(total, 200));
11122
11531
  return {
11123
- text: summarizeFile(input.path, stat12.size, allLines),
11532
+ text: summarizeFile(input.path, stat13.size, allLines),
11124
11533
  total_lines: total,
11125
11534
  encoding: "utf8",
11126
11535
  truncated: total > 200,
@@ -11128,13 +11537,13 @@ var readTool = {
11128
11537
  };
11129
11538
  }
11130
11539
  if (limit === 0) {
11131
- ctx.recordRead(absPath, stat12.mtimeMs);
11132
- rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, 0);
11540
+ ctx.recordRead(absPath, stat13.mtimeMs, "user", contentHash);
11541
+ rememberReadRange(ctx, absPath, stat13.mtimeMs, total, 1, 0);
11133
11542
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
11134
11543
  }
11135
11544
  if (offset > total) {
11136
- ctx.recordRead(absPath, stat12.mtimeMs);
11137
- rememberReadRange(ctx, absPath, stat12.mtimeMs, total, total + 1, total + 1);
11545
+ ctx.recordRead(absPath, stat13.mtimeMs, "user", contentHash);
11546
+ rememberReadRange(ctx, absPath, stat13.mtimeMs, total, total + 1, total + 1);
11138
11547
  return {
11139
11548
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
11140
11549
  total_lines: total,
@@ -11146,8 +11555,8 @@ var readTool = {
11146
11555
  const truncated = offset - 1 + slice.length < total;
11147
11556
  const width = String(offset + slice.length - 1).length;
11148
11557
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
11149
- ctx.recordRead(absPath, stat12.mtimeMs);
11150
- rememberReadRange(ctx, absPath, stat12.mtimeMs, total, offset, offset + slice.length - 1);
11558
+ ctx.recordRead(absPath, stat13.mtimeMs, "user", contentHash);
11559
+ rememberReadRange(ctx, absPath, stat13.mtimeMs, total, offset, offset + slice.length - 1);
11151
11560
  return {
11152
11561
  text: numbered,
11153
11562
  total_lines: total,
@@ -11289,10 +11698,10 @@ var replaceTool = {
11289
11698
  } catch {
11290
11699
  continue;
11291
11700
  }
11292
- const rel = path3.relative(realRoot, realPath);
11293
- if (rel.startsWith("..") || path3.isAbsolute(rel)) continue;
11294
- const stat12 = await fs2.stat(realPath).catch(() => null);
11295
- if (!stat12?.isFile()) continue;
11701
+ const rel = path12.relative(realRoot, realPath);
11702
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) continue;
11703
+ const stat13 = await fs2.stat(realPath).catch(() => null);
11704
+ if (!stat13?.isFile()) continue;
11296
11705
  let content;
11297
11706
  try {
11298
11707
  const buf = await fs2.readFile(realPath);
@@ -11317,7 +11726,17 @@ var replaceTool = {
11317
11726
  totalReplacements += count;
11318
11727
  if (!dryRun) {
11319
11728
  const newContent = toStyle(newContentLf, style);
11320
- await atomicWrite(realPath, newContent, { mode: stat12.mode & 511 });
11729
+ await atomicWrite(realPath, newContent, { mode: stat13.mode & 511 });
11730
+ const written = await fs2.stat(realPath).catch(() => null);
11731
+ if (written) {
11732
+ ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
11733
+ }
11734
+ ctx.session?.recordFileChange?.({
11735
+ path: realPath,
11736
+ action: "modified",
11737
+ before: content,
11738
+ after: newContent
11739
+ });
11321
11740
  }
11322
11741
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
11323
11742
  fromFile: absPath,
@@ -11347,8 +11766,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
11347
11766
  const resolved = [];
11348
11767
  for (const p of parts) {
11349
11768
  const absPath = safeResolve(p, ctx);
11350
- const stat12 = await fs2.stat(absPath).catch(() => null);
11351
- if (stat12?.isFile()) {
11769
+ const stat13 = await fs2.stat(absPath).catch(() => null);
11770
+ if (stat13?.isFile()) {
11352
11771
  resolved.push(absPath);
11353
11772
  }
11354
11773
  }
@@ -11409,10 +11828,10 @@ async function globNative(pattern, base, extraGlob) {
11409
11828
  }
11410
11829
  for (const e of entries) {
11411
11830
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
11412
- const full = path3.join(dir, e.name);
11831
+ const full = path12.join(dir, e.name);
11413
11832
  try {
11414
- const stat12 = await fs2.lstat(full);
11415
- if (stat12.isSymbolicLink()) continue;
11833
+ const stat13 = await fs2.lstat(full);
11834
+ if (stat13.isSymbolicLink()) continue;
11416
11835
  } catch {
11417
11836
  continue;
11418
11837
  }
@@ -11580,16 +11999,16 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
11580
11999
  let filesCreated = 0;
11581
12000
  for (const [filePath, content] of Object.entries(templateFiles)) {
11582
12001
  const resolvedPath = substituteVars(filePath, name, vars);
11583
- const joinedPath = path3.join(cwd, resolvedPath);
11584
- const root = path3.resolve(ctx.projectRoot);
11585
- const target = path3.resolve(joinedPath);
11586
- const rel = path3.relative(root, target);
11587
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
12002
+ const joinedPath = path12.join(cwd, resolvedPath);
12003
+ const root = path12.resolve(ctx.projectRoot);
12004
+ const target = path12.resolve(joinedPath);
12005
+ const rel = path12.relative(root, target);
12006
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
11588
12007
  throw new Error(`scaffold: generated path "${resolvedPath}" would escape project root`);
11589
12008
  }
11590
12009
  const fullPath = target;
11591
12010
  if (!dryRun) {
11592
- await fs2.mkdir(path3.dirname(fullPath), { recursive: true });
12011
+ await fs2.mkdir(path12.dirname(fullPath), { recursive: true });
11593
12012
  await atomicWrite(fullPath, substituteVars(content, name, vars));
11594
12013
  }
11595
12014
  files.push(resolvedPath);
@@ -12489,11 +12908,11 @@ var testTool = {
12489
12908
  }
12490
12909
  };
12491
12910
  async function detectRunner(cwd) {
12492
- const { stat: stat12 } = await import('node:fs/promises');
12911
+ const { stat: stat13 } = await import('node:fs/promises');
12493
12912
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
12494
12913
  for (const f of candidates) {
12495
12914
  try {
12496
- await stat12(path3.join(cwd, f));
12915
+ await stat13(path12.join(cwd, f));
12497
12916
  if (f.includes("vitest")) return "vitest";
12498
12917
  if (f.includes("jest")) return "jest";
12499
12918
  if (f.includes("mocha")) return "mocha";
@@ -13121,7 +13540,7 @@ async function walkDir(dir, depth, opts) {
13121
13540
  opts.lines.push(opts.prefix + branch + displayName);
13122
13541
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
13123
13542
  const childPrefix = opts.prefix + connector;
13124
- await walkDir(path3.join(dir, entry.name), depth + 1, {
13543
+ await walkDir(path12.join(dir, entry.name), depth + 1, {
13125
13544
  ...opts,
13126
13545
  prefix: childPrefix,
13127
13546
  isLast
@@ -13207,12 +13626,12 @@ var typecheckTool = {
13207
13626
  }
13208
13627
  };
13209
13628
  async function findTsConfig(cwd) {
13210
- const { stat: stat12 } = await import('node:fs/promises');
13629
+ const { stat: stat13 } = await import('node:fs/promises');
13211
13630
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
13212
13631
  for (const f of candidates) {
13213
13632
  try {
13214
- const s = await stat12(path3.join(cwd, f));
13215
- if (s.isFile()) return path3.join(cwd, f);
13633
+ const s = await stat13(path12.join(cwd, f));
13634
+ if (s.isFile()) return path12.join(cwd, f);
13216
13635
  } catch {
13217
13636
  }
13218
13637
  }
@@ -13223,6 +13642,10 @@ var writeTool = {
13223
13642
  category: "Filesystem",
13224
13643
  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.",
13225
13644
  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.",
13645
+ selection: {
13646
+ doNotUseWhen: "making a precise change to part of an existing file.",
13647
+ useInstead: ["edit"]
13648
+ },
13226
13649
  permission: "confirm",
13227
13650
  mutating: true,
13228
13651
  timeoutMs: 5e3,
@@ -13242,10 +13665,10 @@ var writeTool = {
13242
13665
  },
13243
13666
  required: ["path", "content"]
13244
13667
  },
13245
- async execute(input, ctx) {
13246
- return writeFile7(input, ctx);
13668
+ async execute(input, ctx, opts) {
13669
+ return writeFile7(input, ctx, opts?.signal);
13247
13670
  },
13248
- async *executeStream(input, ctx) {
13671
+ async *executeStream(input, ctx, opts) {
13249
13672
  const prepared = await prepareWrite(input, ctx);
13250
13673
  if (!prepared.existed) {
13251
13674
  for (const line of input.content.split("\n")) {
@@ -13253,11 +13676,11 @@ var writeTool = {
13253
13676
  `, data: { livePreview: true } };
13254
13677
  }
13255
13678
  }
13256
- yield { type: "final", output: await finishWrite(input, ctx, prepared) };
13679
+ yield { type: "final", output: await finishWrite(input, ctx, prepared, opts?.signal) };
13257
13680
  }
13258
13681
  };
13259
- async function writeFile7(input, ctx) {
13260
- return finishWrite(input, ctx, await prepareWrite(input, ctx));
13682
+ async function writeFile7(input, ctx, signal) {
13683
+ return finishWrite(input, ctx, await prepareWrite(input, ctx), signal);
13261
13684
  }
13262
13685
  async function prepareWrite(input, ctx) {
13263
13686
  if (!input?.path) {
@@ -13276,12 +13699,12 @@ async function prepareWrite(input, ctx) {
13276
13699
  let existed = false;
13277
13700
  let prev = "";
13278
13701
  try {
13279
- const stat12 = await fs2.stat(absPath);
13280
- existed = stat12.isFile();
13702
+ const stat13 = await fs2.stat(absPath);
13703
+ existed = stat13.isFile();
13281
13704
  if (existed) {
13282
13705
  if (!ctx.hasRead(absPath)) {
13283
13706
  prev = await fs2.readFile(absPath, "utf8");
13284
- ctx.recordRead(absPath, stat12.mtimeMs, "write");
13707
+ ctx.recordRead(absPath, stat13.mtimeMs, "write", sha256hex(prev));
13285
13708
  } else {
13286
13709
  prev = await fs2.readFile(absPath, "utf8");
13287
13710
  }
@@ -13293,23 +13716,32 @@ async function prepareWrite(input, ctx) {
13293
13716
  }
13294
13717
  return { absPath, existed, prev };
13295
13718
  }
13296
- async function finishWrite(input, ctx, prepared) {
13719
+ async function finishWrite(input, ctx, prepared, signal) {
13720
+ signal?.throwIfAborted();
13297
13721
  await atomicWrite(prepared.absPath, input.content);
13298
13722
  const diff = prepared.existed ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
13299
13723
  + (new file, ${input.content.split("\n").length} lines)`;
13300
- const stat12 = await fs2.stat(prepared.absPath);
13301
- ctx.recordRead(prepared.absPath, stat12.mtimeMs, "write");
13724
+ const stat13 = await fs2.stat(prepared.absPath);
13725
+ ctx.recordRead(prepared.absPath, stat13.mtimeMs, "write", sha256hex(input.content));
13302
13726
  ctx.session.recordFileChange({
13303
13727
  path: prepared.absPath,
13304
13728
  action: prepared.existed ? "modified" : "created",
13305
13729
  before: prepared.existed ? prepared.prev : null,
13306
13730
  after: input.content
13307
13731
  });
13732
+ const syntax = await checkSyntax(
13733
+ prepared.absPath,
13734
+ input.content,
13735
+ prepared.existed ? prepared.prev : void 0
13736
+ ).catch(() => void 0);
13737
+ const hasSyntaxErrors = syntax !== void 0 && syntax.errors.length > 0;
13308
13738
  return {
13309
13739
  path: prepared.absPath,
13310
13740
  bytes_written: Buffer.byteLength(input.content, "utf8"),
13311
13741
  created: !prepared.existed,
13312
- diff
13742
+ diff,
13743
+ syntax_errors: hasSyntaxErrors ? syntax.errors : void 0,
13744
+ 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
13313
13745
  };
13314
13746
  }
13315
13747
 
@@ -13718,6 +14150,7 @@ var ProcessGuardian = class {
13718
14150
  heartbeatTimer = null;
13719
14151
  isRunning = false;
13720
14152
  instanceId;
14153
+ sigtermCount = 0;
13721
14154
  constructor(config = {}) {
13722
14155
  this.registry = getPersistentProcessRegistry();
13723
14156
  this.config = {
@@ -13725,7 +14158,8 @@ var ProcessGuardian = class {
13725
14158
  autoResurrect: config.autoResurrect ?? false,
13726
14159
  // Disabled by default
13727
14160
  maxResurrectionAttempts: config.maxResurrectionAttempts ?? 3,
13728
- protectedPatterns: config.protectedPatterns ?? ["node", "wrongstack"]
14161
+ protectedPatterns: config.protectedPatterns ?? ["node", "wrongstack"],
14162
+ sigtermThreshold: config.sigtermThreshold ?? 3
13729
14163
  };
13730
14164
  this.instanceId = this.registry.getInstanceId();
13731
14165
  }
@@ -13868,15 +14302,43 @@ var ProcessGuardian = class {
13868
14302
  this.stop();
13869
14303
  process.exit(1);
13870
14304
  });
13871
- process.on("SIGTERM", (origin) => {
14305
+ process.on("SIGTERM", () => {
14306
+ this.sigtermCount++;
14307
+ const threshold = this.config.sigtermThreshold;
14308
+ if (threshold === Infinity) {
14309
+ console.log(JSON.stringify({
14310
+ level: "warn",
14311
+ event: "process_guardian.sigterm_ignored",
14312
+ pid: process.pid,
14313
+ instanceId: this.instanceId,
14314
+ sigtermCount: this.sigtermCount,
14315
+ message: "SIGTERM received but sigtermThreshold is Infinity \u2014 ignoring (use graceful shutdown instead)"
14316
+ }));
14317
+ return;
14318
+ }
14319
+ if (this.sigtermCount < threshold) {
14320
+ console.log(JSON.stringify({
14321
+ level: "warn",
14322
+ event: "process_guardian.sigterm_deferred",
14323
+ pid: process.pid,
14324
+ instanceId: this.instanceId,
14325
+ sigtermCount: this.sigtermCount,
14326
+ threshold,
14327
+ message: `SIGTERM received (${this.sigtermCount}/${threshold}) \u2014 ignoring until threshold is reached`
14328
+ }));
14329
+ return;
14330
+ }
13872
14331
  console.log(JSON.stringify({
13873
14332
  level: "warn",
13874
- event: "process_guardian.sigterm_received",
13875
- origin,
14333
+ event: "process_guardian.sigterm_exiting",
13876
14334
  pid: process.pid,
13877
14335
  instanceId: this.instanceId,
13878
- message: "SIGTERM received but ignored - use graceful shutdown instead"
14336
+ sigtermCount: this.sigtermCount,
14337
+ threshold,
14338
+ message: `SIGTERM threshold (${threshold}) reached \u2014 shutting down gracefully`
13879
14339
  }));
14340
+ this.stop();
14341
+ process.exit(0);
13880
14342
  });
13881
14343
  process.on("SIGHUP", () => {
13882
14344
  console.log(JSON.stringify({
@@ -14284,7 +14746,7 @@ function makeSkillTool(skillLoader) {
14284
14746
  field: "name"
14285
14747
  });
14286
14748
  }
14287
- const dir = path3.dirname(manifest.path);
14749
+ const dir = path12.dirname(manifest.path);
14288
14750
  let loadedResource;
14289
14751
  if (input.resource?.trim()) {
14290
14752
  loadedResource = await loadResource(dir, input.resource.trim());
@@ -14334,15 +14796,15 @@ ${listing}`;
14334
14796
  }
14335
14797
  async function loadResource(skillDir, rel) {
14336
14798
  const norm = rel.replace(/\\/g, "/");
14337
- if (path3.isAbsolute(rel) || norm.split("/").some((seg) => seg === "..")) {
14799
+ if (path12.isAbsolute(rel) || norm.split("/").some((seg) => seg === "..")) {
14338
14800
  throw new ToolValidationError({
14339
14801
  message: `skill: invalid resource path "${rel}"`,
14340
14802
  field: "resource"
14341
14803
  });
14342
14804
  }
14343
- const absPath = path3.resolve(skillDir, rel);
14344
- const root = path3.resolve(skillDir);
14345
- if (absPath !== root && !absPath.startsWith(root + path3.sep)) {
14805
+ const absPath = path12.resolve(skillDir, rel);
14806
+ const root = path12.resolve(skillDir);
14807
+ if (absPath !== root && !absPath.startsWith(root + path12.sep)) {
14346
14808
  throw new ToolValidationError({
14347
14809
  message: `skill: resource "${rel}" escapes the skill directory`,
14348
14810
  field: "resource"
@@ -14383,7 +14845,7 @@ async function walk(root, dir, out) {
14383
14845
  }
14384
14846
  for (const e of entries) {
14385
14847
  if (out.length >= MAX_LISTED_RESOURCES) return;
14386
- const fullPath = path3.join(dir, e.name);
14848
+ const fullPath = path12.join(dir, e.name);
14387
14849
  let isDir = e.isDirectory();
14388
14850
  if (e.isSymbolicLink()) {
14389
14851
  try {
@@ -14398,9 +14860,9 @@ async function walk(root, dir, out) {
14398
14860
  } else if (e.isFile()) {
14399
14861
  if (e.name === "SKILL.md" || e.name === "SKILL.save.md") continue;
14400
14862
  try {
14401
- const stat12 = await fs2.stat(fullPath);
14402
- const rel = path3.relative(root, fullPath).split(path3.sep).join("/");
14403
- out.push({ path: rel, bytes: stat12.size });
14863
+ const stat13 = await fs2.stat(fullPath);
14864
+ const rel = path12.relative(root, fullPath).split(path12.sep).join("/");
14865
+ out.push({ path: rel, bytes: stat13.size });
14404
14866
  } catch {
14405
14867
  }
14406
14868
  }