@wrongstack/tools 0.283.0 → 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 (59) 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 +710 -259
  5. package/dist/builtin.js.map +1 -1
  6. package/dist/codebase-index/index.d.ts +11 -0
  7. package/dist/codebase-index/index.js +27 -8
  8. package/dist/codebase-index/index.js.map +1 -1
  9. package/dist/codebase-index/worker.js +25 -6
  10. package/dist/codebase-index/worker.js.map +1 -1
  11. package/dist/diff.js.map +1 -1
  12. package/dist/document.js.map +1 -1
  13. package/dist/edit.d.ts +43 -0
  14. package/dist/edit.js +386 -60
  15. package/dist/edit.js.map +1 -1
  16. package/dist/{exec-Ca3fnpUh.d.ts → exec-2OKoT6tk.d.ts} +1 -1
  17. package/dist/exec.d.ts +1 -1
  18. package/dist/exec.js +13 -14
  19. package/dist/exec.js.map +1 -1
  20. package/dist/fetch.js.map +1 -1
  21. package/dist/format.js.map +1 -1
  22. package/dist/git.js.map +1 -1
  23. package/dist/glob.js +10 -1
  24. package/dist/glob.js.map +1 -1
  25. package/dist/grep.js +4 -0
  26. package/dist/grep.js.map +1 -1
  27. package/dist/index.d.ts +13 -2
  28. package/dist/index.js +755 -274
  29. package/dist/index.js.map +1 -1
  30. package/dist/install.js.map +1 -1
  31. package/dist/json.js.map +1 -1
  32. package/dist/lint.js.map +1 -1
  33. package/dist/logs.js.map +1 -1
  34. package/dist/next-steps.d.ts +66 -0
  35. package/dist/next-steps.js +116 -0
  36. package/dist/next-steps.js.map +1 -0
  37. package/dist/outdated.js.map +1 -1
  38. package/dist/pack.js +710 -259
  39. package/dist/pack.js.map +1 -1
  40. package/dist/patch.js +43 -0
  41. package/dist/patch.js.map +1 -1
  42. package/dist/read.js +13 -4
  43. package/dist/read.js.map +1 -1
  44. package/dist/replace.js +14 -0
  45. package/dist/replace.js.map +1 -1
  46. package/dist/scaffold.js.map +1 -1
  47. package/dist/test.js.map +1 -1
  48. package/dist/tool-diff.d.ts +88 -0
  49. package/dist/tool-diff.js +229 -0
  50. package/dist/tool-diff.js.map +1 -0
  51. package/dist/tool-summary.d.ts +24 -0
  52. package/dist/tool-summary.js +208 -0
  53. package/dist/tool-summary.js.map +1 -0
  54. package/dist/tree.js.map +1 -1
  55. package/dist/typecheck.js.map +1 -1
  56. package/dist/write.d.ts +6 -0
  57. package/dist/write.js +118 -19
  58. package/dist/write.js.map +1 -1
  59. package/package.json +15 -2
package/dist/pack.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { spawn, execFileSync } from 'node:child_process';
2
2
  import * as Core from '@wrongstack/core';
3
- import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, ToolError, FetchError, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, deepMerge, addLinkToTask, addNoteToTask, updateCheckOnTask, addCheckToTask, updateGoalMetricOnTask, addGoalMetricToTask, addDependency, getKanbanQueueHealth, listKanbanEvents, recoverStaleTaskAssignments, heartbeatTaskAssignment, updateTaskAssignment, assignTask, releaseTaskClaim, claimReadyTask, getTaskChain, setTaskChain, removeTask, moveTask, updateTask, getTask, transferTaskToBoard, copyTaskToBoard, mergeTasks, splitTask, addTask, removeColumn, updateColumn, addColumn, getKanbanOrchestrationSnapshot, listReadyTasks, searchKanban, deserializeTaskGraph, syncBoardFromTaskGraph, exportBoardToTaskGraph, serializeTaskGraph, exportBoardAsMarkdown, generateBoardFromDescription, createBoard, parseLinesIntoTasks, getBoard, removeBoard, duplicateBoard, updateBoard, listBoards, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, FsError, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
3
+ import { buildChildEnv, getDesignKitLoader, isDesignStack, loadActiveKit, applyTokenOverrides, setActiveKit, recordKitChoice, recordOverrides, setDesignOverrides, materializeTokens, runDesignVerify, ToolValidationError, detectNewlineStyle, normalizeToLf, toStyle, atomicWrite, unifiedDiff, ToolError, FetchError, isPrivateIPv4, isPrivateIPv6, assessCommitSafety, compileGlob, expectDefined, recordPackageAction, detectPackageEcosystem, deepMerge, deserializeTaskGraph, serializeTaskGraph, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, setPlanItemStatus, mutateTasks, formatTaskList, formatPlan, FsError, toErrorMessage as toErrorMessage$2, computeTaskItemProgress, loadPlan, savePlan, loadTasks, saveTasks, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
4
4
  import * as fs from 'node:fs';
5
5
  import { statSync, mkdirSync, createWriteStream } from 'node:fs';
6
6
  import * as fs2 from 'node:fs/promises';
7
- import * as path3 from 'node:path';
7
+ import * as path12 from 'node:path';
8
8
  import { resolve, sep, dirname, join } from 'node:path';
9
9
  import * as os2 from 'node:os';
10
+ import { randomUUID, createHash } from 'node:crypto';
10
11
  import { toErrorMessage as toErrorMessage$3 } from '@wrongstack/core/utils';
11
12
  import { createRequire } from 'node:module';
12
13
  import { fileURLToPath } from 'node:url';
@@ -17,14 +18,14 @@ import * as dns from 'node:dns/promises';
17
18
  import * as net from 'node:net';
18
19
  import { Agent, fetch } from 'undici';
19
20
  import TurndownService from 'turndown';
20
- import { randomUUID } from 'node:crypto';
21
+ import { addLinkToTask, addNoteToTask, updateCheckOnTask, addCheckToTask, updateGoalMetricOnTask, addGoalMetricToTask, addDependency, getKanbanQueueHealth, listKanbanEvents, recoverStaleTaskAssignments, heartbeatTaskAssignment, updateTaskAssignment, assignTask, releaseTaskClaim, claimReadyTask, getTaskChain, setTaskChain, removeTask, moveTask, updateTask, getTask, transferTaskToBoard, copyTaskToBoard, mergeTasks, splitTask, addTask, removeColumn, updateColumn, addColumn, getKanbanOrchestrationSnapshot, listReadyTasks, searchKanban, syncBoardFromTaskGraph, exportBoardToTaskGraph, exportBoardAsMarkdown, generateBoardFromDescription, createBoard, parseLinesIntoTasks, getBoard, removeBoard, duplicateBoard, updateBoard, listBoards } from '@wrongstack/kanban';
21
22
 
22
23
  // src/_spawn-stream.ts
23
24
  var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
24
25
  var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
25
26
  var sweepStarted = false;
26
27
  function toolOutputDir() {
27
- return path3.join(wstackGlobalRoot(), "tool-output");
28
+ return path12.join(wstackGlobalRoot(), "tool-output");
28
29
  }
29
30
  function sweepOldSpoolFiles(dir) {
30
31
  if (sweepStarted) return;
@@ -34,7 +35,7 @@ function sweepOldSpoolFiles(dir) {
34
35
  const now = Date.now();
35
36
  for (const name of await fs2.readdir(dir)) {
36
37
  if (!name.endsWith(".log")) continue;
37
- const p = path3.join(dir, name);
38
+ const p = path12.join(dir, name);
38
39
  try {
39
40
  const st = await fs2.stat(p);
40
41
  if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fs2.unlink(p);
@@ -69,7 +70,7 @@ function createOutputSpool(opts) {
69
70
  sweepOldSpoolFiles(dir);
70
71
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
71
72
  const rand = Math.random().toString(36).slice(2, 6);
72
- filePath = path3.join(dir, `${stamp}-${safeTool}-${rand}.log`);
73
+ filePath = path12.join(dir, `${stamp}-${safeTool}-${rand}.log`);
73
74
  stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
74
75
  stream.on("error", () => {
75
76
  failed = true;
@@ -702,13 +703,13 @@ function getProcessRegistry() {
702
703
  }
703
704
  function resolveWin32Command(cmd) {
704
705
  if (process.platform !== "win32") return cmd;
705
- if (cmd.includes("/") || cmd.includes("\\") || path3.extname(cmd.replace(/\//g, "\\"))) {
706
+ if (cmd.includes("/") || cmd.includes("\\") || path12.extname(cmd.replace(/\//g, "\\"))) {
706
707
  return cmd;
707
708
  }
708
709
  const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
709
- const pathDirs = (process.env["PATH"] ?? "").split(path3.delimiter);
710
+ const pathDirs = (process.env["PATH"] ?? "").split(path12.delimiter);
710
711
  for (const dir of pathDirs) {
711
- const base = path3.join(dir, cmd);
712
+ const base = path12.join(dir, cmd);
712
713
  for (const ext of pathext) {
713
714
  const full = `${base}${ext}`;
714
715
  try {
@@ -922,37 +923,40 @@ async function* spawnStream(opts) {
922
923
  }
923
924
  }
924
925
  }
926
+ function sha256hex(content) {
927
+ return createHash("sha256").update(content, "utf8").digest("hex");
928
+ }
925
929
  async function detectPackageManager(cwd) {
926
- const { stat: stat11 } = await import('node:fs/promises');
930
+ const { stat: stat12 } = await import('node:fs/promises');
927
931
  try {
928
- await stat11(`${cwd}/pnpm-lock.yaml`);
932
+ await stat12(`${cwd}/pnpm-lock.yaml`);
929
933
  return "pnpm";
930
934
  } catch {
931
935
  }
932
936
  try {
933
- await stat11(`${cwd}/yarn.lock`);
937
+ await stat12(`${cwd}/yarn.lock`);
934
938
  return "yarn";
935
939
  } catch {
936
940
  }
937
941
  return "npm";
938
942
  }
939
943
  function resolvePath(input, ctx) {
940
- return path3.isAbsolute(input) ? path3.normalize(input) : path3.resolve(ctx.workingDir ?? ctx.cwd, input);
944
+ return path12.isAbsolute(input) ? path12.normalize(input) : path12.resolve(ctx.workingDir ?? ctx.cwd, input);
941
945
  }
942
946
  function allowedRoots(ctx) {
943
- return [path3.resolve(ctx.projectRoot), path3.resolve(Core.wstackGlobalRoot())];
947
+ return [path12.resolve(ctx.projectRoot), path12.resolve(Core.wstackGlobalRoot())];
944
948
  }
945
949
  function isInsideAny(target, roots) {
946
950
  return roots.some((root) => {
947
- const rel = path3.relative(root, target);
948
- return rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel);
951
+ const rel = path12.relative(root, target);
952
+ return rel === "" || !rel.startsWith("..") && !path12.isAbsolute(rel);
949
953
  });
950
954
  }
951
955
  function ensureInsideRoot(absPath, ctx) {
952
- const target = path3.resolve(absPath);
956
+ const target = path12.resolve(absPath);
953
957
  if (ctx.allowOutsideProjectRoot) return target;
954
958
  if (isInsideAny(target, allowedRoots(ctx))) return target;
955
- throw new Error(`Path "${absPath}" is outside project root "${path3.resolve(ctx.projectRoot)}"`);
959
+ throw new Error(`Path "${absPath}" is outside project root "${path12.resolve(ctx.projectRoot)}"`);
956
960
  }
957
961
  function safeResolve(input, ctx) {
958
962
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -960,7 +964,7 @@ function safeResolve(input, ctx) {
960
964
  async function assertRealInsideRoot(absPath, ctx) {
961
965
  if (ctx.allowOutsideProjectRoot) return;
962
966
  const realRoots = await Promise.all(
963
- allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path3.resolve(r)))
967
+ allowedRoots(ctx).map((r) => fs2.realpath(r).catch(() => path12.resolve(r)))
964
968
  );
965
969
  let probe = absPath;
966
970
  for (; ; ) {
@@ -969,7 +973,7 @@ async function assertRealInsideRoot(absPath, ctx) {
969
973
  real = await fs2.realpath(probe);
970
974
  } catch (err) {
971
975
  if (err.code === "ENOENT") {
972
- const parent = path3.dirname(probe);
976
+ const parent = path12.dirname(probe);
973
977
  if (parent === probe) return;
974
978
  probe = parent;
975
979
  continue;
@@ -1282,15 +1286,15 @@ var PersistentProcessRegistry = class {
1282
1286
  constructor(baseRegistry) {
1283
1287
  this.instanceId = generateInstanceId();
1284
1288
  const homeDir = os2.homedir();
1285
- this.registryPath = path3.join(homeDir, REGISTRY_FILE);
1286
- this.lockPath = path3.join(homeDir, LOCKFILE);
1289
+ this.registryPath = path12.join(homeDir, REGISTRY_FILE);
1290
+ this.lockPath = path12.join(homeDir, LOCKFILE);
1287
1291
  this.baseRegistry = baseRegistry ?? getProcessRegistry();
1288
1292
  this.ensureDirectory().catch((err) => {
1289
1293
  emitStructuredLog("warn", "process_registry.dir_create_failed", "PersistentProcessRegistry: failed to create .wrongstack directory", err);
1290
1294
  });
1291
1295
  }
1292
1296
  async ensureDirectory() {
1293
- const dir = path3.dirname(this.registryPath);
1297
+ const dir = path12.dirname(this.registryPath);
1294
1298
  try {
1295
1299
  await fs2.mkdir(dir, { recursive: true });
1296
1300
  } catch (err) {
@@ -1870,6 +1874,10 @@ var bashTool = {
1870
1874
  category: "Shell",
1871
1875
  description: "Execute an arbitrary command in the user's default shell (bash/zsh/pwsh/cmd). stdout and stderr are merged into one stream. This is the most powerful and dangerous tool \u2014 it gives the model full access to the developer's machine. Prefer specialized tools whenever possible.",
1872
1876
  usageHint: "SECURITY WARNING: This tool runs with the full privileges of the current user.\n\nBest practices for the model:\n- Strongly prefer `exec` for known safe commands (node, npm, pnpm, tsc, git, etc.).\n- Use bash only when you genuinely need shell features (pipes, redirection, complex one-liners).\n- Prefer single focused commands over huge `&&` chains.\n- Use `background: true` only for long-running processes (dev servers, watchers).\n- The working directory is the project root.\n- Output may be truncated in the middle for very large results.",
1877
+ selection: {
1878
+ doNotUseWhen: "the command is allowlisted and does not require pipes, redirection, or shell expansion.",
1879
+ useInstead: ["exec"]
1880
+ },
1873
1881
  permission: "confirm",
1874
1882
  mutating: true,
1875
1883
  riskTier: "destructive",
@@ -2675,6 +2683,24 @@ var IndexStore = class {
2675
2683
  * When false, ranked search falls back to the LIKE + in-process BM25 path.
2676
2684
  */
2677
2685
  ftsAvailable = false;
2686
+ /**
2687
+ * Cache of prepared statements keyed by their SQL text. `DatabaseSync`
2688
+ * compiles SQL on every `.prepare()` call; for the fixed-SQL methods
2689
+ * (upsertFile, getFileMeta, deleteFile, insertRefs, …) that runs thousands
2690
+ * of times during a full reindex. `StatementSync` objects are reusable
2691
+ * across calls on the same connection, so we compile each distinct SQL once
2692
+ * and reuse it. Cleared in {@link close} when the connection is torn down.
2693
+ */
2694
+ stmtCache = /* @__PURE__ */ new Map();
2695
+ /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
2696
+ stmt(sql) {
2697
+ let s = this.stmtCache.get(sql);
2698
+ if (s === void 0) {
2699
+ s = this.db.prepare(sql);
2700
+ this.stmtCache.set(sql, s);
2701
+ }
2702
+ return s;
2703
+ }
2678
2704
  /**
2679
2705
  * Execute a SQLite write operation with automatic retry on lock conflicts.
2680
2706
  *
@@ -2713,7 +2739,7 @@ var IndexStore = class {
2713
2739
  this.indexDir = resolveIndexDir(projectRoot, opts.indexDir);
2714
2740
  fs.mkdirSync(this.indexDir, { recursive: true });
2715
2741
  const Database = loadDatabaseSync();
2716
- this.db = new Database(path3.join(this.indexDir, DB_FILE));
2742
+ this.db = new Database(path12.join(this.indexDir, DB_FILE));
2717
2743
  try {
2718
2744
  this.db.exec("PRAGMA journal_mode = WAL");
2719
2745
  this.db.exec("PRAGMA busy_timeout = 5000");
@@ -2844,9 +2870,9 @@ var IndexStore = class {
2844
2870
  deleteSymbolsForFile(file) {
2845
2871
  this.runWithRetry(() => {
2846
2872
  if (this.ftsAvailable) {
2847
- this.db.prepare("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
2873
+ this.stmt("DELETE FROM symbols_fts WHERE rowid IN (SELECT id FROM symbols WHERE file_fk = ?)").run(file);
2848
2874
  }
2849
- this.db.prepare("DELETE FROM symbols WHERE file_fk = ?").run(file);
2875
+ this.stmt("DELETE FROM symbols WHERE file_fk = ?").run(file);
2850
2876
  });
2851
2877
  }
2852
2878
  /**
@@ -2858,13 +2884,13 @@ var IndexStore = class {
2858
2884
  this.runWithRetry(() => {
2859
2885
  this.deleteRefsForFile(file);
2860
2886
  this.deleteSymbolsForFile(file);
2861
- this.db.prepare("DELETE FROM files WHERE file = ?").run(file);
2887
+ this.stmt("DELETE FROM files WHERE file = ?").run(file);
2862
2888
  });
2863
2889
  }
2864
2890
  // ─── File metadata ──────────────────────────────────────────────────────────
2865
2891
  upsertFile(meta) {
2866
2892
  this.runWithRetry(() => {
2867
- this.db.prepare(
2893
+ this.stmt(
2868
2894
  `INSERT INTO files(file, lang, mtime_ms, symbol_count, last_indexed)
2869
2895
  VALUES (?, ?, ?, ?, ?)
2870
2896
  ON CONFLICT(file) DO UPDATE SET
@@ -2876,7 +2902,7 @@ var IndexStore = class {
2876
2902
  });
2877
2903
  }
2878
2904
  getFileMeta(file) {
2879
- const rows = this.db.prepare(
2905
+ const rows = this.stmt(
2880
2906
  "SELECT file, lang, mtime_ms, symbol_count, last_indexed FROM files WHERE file = ?"
2881
2907
  ).all(file);
2882
2908
  if (!rows.length) return null;
@@ -3099,7 +3125,7 @@ var IndexStore = class {
3099
3125
  */
3100
3126
  insertRefs(fromId, refs) {
3101
3127
  this.runWithRetry(() => {
3102
- this.db.prepare("DELETE FROM refs WHERE from_id = ?").run(fromId);
3128
+ this.stmt("DELETE FROM refs WHERE from_id = ?").run(fromId);
3103
3129
  if (refs.length === 0) return;
3104
3130
  const stmt = this.db.prepare(
3105
3131
  `INSERT INTO refs(from_id, to_name, to_id, call_type, line)
@@ -3292,7 +3318,7 @@ var IndexStore = class {
3292
3318
  }));
3293
3319
  }
3294
3320
  sizeBytes() {
3295
- const dbPath = path3.join(this.indexDir, DB_FILE);
3321
+ const dbPath = path12.join(this.indexDir, DB_FILE);
3296
3322
  try {
3297
3323
  return fs.statSync(dbPath).size;
3298
3324
  } catch {
@@ -3300,6 +3326,7 @@ var IndexStore = class {
3300
3326
  }
3301
3327
  }
3302
3328
  close() {
3329
+ this.stmtCache.clear();
3303
3330
  try {
3304
3331
  this.db.close();
3305
3332
  } catch {
@@ -3790,9 +3817,9 @@ func formatType(t ast.Expr) string {
3790
3817
  }
3791
3818
  `;
3792
3819
  async function syncGoParse(filePath, content, lang) {
3793
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), "ws-go-parse-"));
3820
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), "ws-go-parse-"));
3794
3821
  try {
3795
- const scriptPath = path3.join(tmpDir, "parse.go");
3822
+ const scriptPath = path12.join(tmpDir, "parse.go");
3796
3823
  await fs2.writeFile(scriptPath, GO_PARSE_SCRIPT, "utf8");
3797
3824
  const proc = spawn("go", ["run", scriptPath], {
3798
3825
  stdio: ["pipe", "pipe", "pipe"],
@@ -4054,9 +4081,9 @@ var _cachedScriptPath = null;
4054
4081
  async function syncPyParse(filePath, content, lang) {
4055
4082
  try {
4056
4083
  if (!_cachedScriptPath) {
4057
- const tmpDir = path3.join(os2.tmpdir(), "ws-py-parse");
4084
+ const tmpDir = path12.join(os2.tmpdir(), "ws-py-parse");
4058
4085
  await fs2.mkdir(tmpDir, { recursive: true });
4059
- _cachedScriptPath = path3.join(tmpDir, "parse.py");
4086
+ _cachedScriptPath = path12.join(tmpDir, "parse.py");
4060
4087
  await fs2.writeFile(_cachedScriptPath, PY_PARSE_SCRIPT, "utf8");
4061
4088
  }
4062
4089
  const proc = spawn("python", [_cachedScriptPath, filePath], {
@@ -4114,7 +4141,7 @@ async function parseSymbols4(opts) {
4114
4141
  function checkNativeParser() {
4115
4142
  try {
4116
4143
  execFileSync("rustc", ["--version"], { stdio: "pipe", windowsHide: true });
4117
- const toolsDir = path3.join(process.cwd(), "tools");
4144
+ const toolsDir = path12.join(process.cwd(), "tools");
4118
4145
  try {
4119
4146
  execFileSync(
4120
4147
  "cargo",
@@ -4124,7 +4151,7 @@ function checkNativeParser() {
4124
4151
  "--format-version",
4125
4152
  "1",
4126
4153
  "--manifest-path",
4127
- path3.join(toolsDir, "Cargo.toml")
4154
+ path12.join(toolsDir, "Cargo.toml")
4128
4155
  ],
4129
4156
  { stdio: "pipe", windowsHide: true }
4130
4157
  );
@@ -4138,13 +4165,13 @@ function checkNativeParser() {
4138
4165
  }
4139
4166
  async function tryNativeParse(file, content) {
4140
4167
  try {
4141
- const toolsDir = path3.join(process.cwd(), "tools");
4142
- const crateDir = path3.join(toolsDir, "syn-parser");
4143
- const tmpFile = path3.join(crateDir, "src", "input.rs");
4168
+ const toolsDir = path12.join(process.cwd(), "tools");
4169
+ const crateDir = path12.join(toolsDir, "syn-parser");
4170
+ const tmpFile = path12.join(crateDir, "src", "input.rs");
4144
4171
  await fs2.writeFile(tmpFile, content, "utf8");
4145
4172
  const proc = spawn(
4146
4173
  "cargo",
4147
- ["run", "--manifest-path", path3.join(toolsDir, "Cargo.toml")],
4174
+ ["run", "--manifest-path", path12.join(toolsDir, "Cargo.toml")],
4148
4175
  {
4149
4176
  cwd: process.cwd(),
4150
4177
  stdio: ["pipe", "pipe", "pipe"],
@@ -4194,16 +4221,16 @@ function regexParse(opts) {
4194
4221
  const { file, content, lang } = opts;
4195
4222
  const symbols = [];
4196
4223
  const lines = content.split("\n");
4197
- const lineOffsets = [0];
4224
+ const lineOffsets2 = [0];
4198
4225
  for (let i = 0; i < lines.length; i++) {
4199
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4226
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4200
4227
  }
4201
4228
  function lineFromOffset(offset) {
4202
4229
  let lo = 0;
4203
- let hi = lineOffsets.length - 1;
4230
+ let hi = lineOffsets2.length - 1;
4204
4231
  while (lo < hi) {
4205
4232
  const mid = lo + hi + 1 >>> 1;
4206
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4233
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4207
4234
  else hi = mid - 1;
4208
4235
  }
4209
4236
  return lo + 1;
@@ -4218,7 +4245,7 @@ function regexParse(opts) {
4218
4245
  const name = expectDefined(match[1]);
4219
4246
  const offset = match.index ?? 0;
4220
4247
  const line = lineFromOffset(offset);
4221
- const col = offset - (lineOffsets[line - 1] ?? 0);
4248
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4222
4249
  const lineIdx = line - 1;
4223
4250
  const signature = extractDeclaration(lineIdx);
4224
4251
  symbols.push({
@@ -4256,22 +4283,22 @@ function parseSymbols5(opts) {
4256
4283
  function regexParse2(opts) {
4257
4284
  const { file, content, lang } = opts;
4258
4285
  const symbols = [];
4259
- const basename2 = path3.basename(file).toLowerCase();
4260
- const isPackageJson = basename2 === "package.json";
4261
- const isTsconfig = basename2 === "tsconfig.json" || basename2 === "tsconfig.build.json";
4286
+ const basename4 = path12.basename(file).toLowerCase();
4287
+ const isPackageJson = basename4 === "package.json";
4288
+ const isTsconfig = basename4 === "tsconfig.json" || basename4 === "tsconfig.build.json";
4262
4289
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
4263
4290
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
4264
4291
  const lines = content.split("\n");
4265
- const lineOffsets = [0];
4292
+ const lineOffsets2 = [0];
4266
4293
  for (let i = 0; i < lines.length; i++) {
4267
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4294
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4268
4295
  }
4269
4296
  function lineFromOffset(offset) {
4270
4297
  let lo = 0;
4271
- let hi = lineOffsets.length - 1;
4298
+ let hi = lineOffsets2.length - 1;
4272
4299
  while (lo < hi) {
4273
4300
  const mid = lo + hi + 1 >>> 1;
4274
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4301
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4275
4302
  else hi = mid - 1;
4276
4303
  }
4277
4304
  return lo + 1;
@@ -4282,11 +4309,11 @@ function regexParse2(opts) {
4282
4309
  const line = lineFromOffset(offset);
4283
4310
  symbols.push(
4284
4311
  makeSymbol({
4285
- name: path3.basename(file),
4312
+ name: path12.basename(file),
4286
4313
  kind: "object",
4287
4314
  line,
4288
4315
  col: 0,
4289
- signature: `"${path3.basename(file)}" = { ... }`,
4316
+ signature: `"${path12.basename(file)}" = { ... }`,
4290
4317
  file,
4291
4318
  lang
4292
4319
  })
@@ -4297,7 +4324,7 @@ function regexParse2(opts) {
4297
4324
  const key = expectDefined(match[1]);
4298
4325
  const offset = match.index ?? 0;
4299
4326
  const line = lineFromOffset(offset);
4300
- const col = offset - (lineOffsets[line - 1] ?? 0);
4327
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4301
4328
  let kind = "property";
4302
4329
  let signature = `"${key}": ..."`;
4303
4330
  if (isPackageJson) {
@@ -4332,10 +4359,10 @@ function regexParse2(opts) {
4332
4359
  })
4333
4360
  );
4334
4361
  if (isPackageJson && key === "scripts") {
4335
- extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset);
4362
+ extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset);
4336
4363
  }
4337
4364
  if (isTsconfig && key === "compilerOptions") {
4338
- extractCompilerOptions(content, symbols, file, lang, lineOffsets, line, lineFromOffset);
4365
+ extractCompilerOptions(content, symbols, file, lang, lineOffsets2, line, lineFromOffset);
4339
4366
  }
4340
4367
  }
4341
4368
  const defsRegex = /"\$defs"\s*:|"\$defs"\s*:/g;
@@ -4348,7 +4375,7 @@ function regexParse2(opts) {
4348
4375
  name: "$defs",
4349
4376
  kind: "property",
4350
4377
  line,
4351
- col: offset - (lineOffsets[line - 1] ?? 0),
4378
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4352
4379
  signature: '"$defs": { ... }',
4353
4380
  file,
4354
4381
  lang
@@ -4372,7 +4399,7 @@ function regexParse2(opts) {
4372
4399
  name: key,
4373
4400
  kind: "property",
4374
4401
  line,
4375
- col: offset - (lineOffsets[line - 1] ?? 0),
4402
+ col: offset - (lineOffsets2[line - 1] ?? 0),
4376
4403
  signature: `"${key}": { ... }`,
4377
4404
  file,
4378
4405
  lang
@@ -4382,7 +4409,7 @@ function regexParse2(opts) {
4382
4409
  }
4383
4410
  return { file, lang, symbols, mtimeMs: Date.now() };
4384
4411
  }
4385
- function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFromOffset) {
4412
+ function extractPackageScripts(content, symbols, file, lang, lineOffsets2, lineFromOffset) {
4386
4413
  const scriptsBlockRegex = /"scripts"\s*:\s*\{([^}]+)\}/g;
4387
4414
  for (let match = scriptsBlockRegex.exec(content); match !== null; match = scriptsBlockRegex.exec(content)) {
4388
4415
  const blockContent = expectDefined(match[0]);
@@ -4397,7 +4424,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4397
4424
  name: key,
4398
4425
  kind: "function",
4399
4426
  line,
4400
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4427
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4401
4428
  signature: `"${key}": "..."`,
4402
4429
  file,
4403
4430
  lang
@@ -4406,7 +4433,7 @@ function extractPackageScripts(content, symbols, file, lang, lineOffsets, lineFr
4406
4433
  }
4407
4434
  }
4408
4435
  }
4409
- function extractCompilerOptions(content, symbols, file, lang, lineOffsets, parentLine, lineFromOffset) {
4436
+ function extractCompilerOptions(content, symbols, file, lang, lineOffsets2, parentLine, lineFromOffset) {
4410
4437
  const optsBlockRegex = /"compilerOptions"\s*:\s*\{([^}]+)\}/g;
4411
4438
  for (let match = optsBlockRegex.exec(content); match !== null; match = optsBlockRegex.exec(content)) {
4412
4439
  const blockContent = expectDefined(match[0]);
@@ -4422,7 +4449,7 @@ function extractCompilerOptions(content, symbols, file, lang, lineOffsets, paren
4422
4449
  name: key,
4423
4450
  kind: "property",
4424
4451
  line,
4425
- col: keyOffset - (lineOffsets[line - 1] ?? 0),
4452
+ col: keyOffset - (lineOffsets2[line - 1] ?? 0),
4426
4453
  signature: `"${key}": ...`,
4427
4454
  file,
4428
4455
  lang
@@ -4458,16 +4485,16 @@ function regexParse3(opts) {
4458
4485
  const { file, content, lang } = opts;
4459
4486
  const symbols = [];
4460
4487
  const lines = content.split("\n");
4461
- const lineOffsets = [0];
4488
+ const lineOffsets2 = [0];
4462
4489
  for (let i = 0; i < lines.length; i++) {
4463
- lineOffsets.push((lineOffsets[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4490
+ lineOffsets2.push((lineOffsets2[i] ?? 0) + (lines[i]?.length ?? 0) + 1);
4464
4491
  }
4465
4492
  function lineFromOffset(offset) {
4466
4493
  let lo = 0;
4467
- let hi = lineOffsets.length - 1;
4494
+ let hi = lineOffsets2.length - 1;
4468
4495
  while (lo < hi) {
4469
4496
  const mid = lo + hi + 1 >>> 1;
4470
- if (expectDefined(lineOffsets[mid]) <= offset) lo = mid;
4497
+ if (expectDefined(lineOffsets2[mid]) <= offset) lo = mid;
4471
4498
  else hi = mid - 1;
4472
4499
  }
4473
4500
  return lo + 1;
@@ -4477,7 +4504,7 @@ function regexParse3(opts) {
4477
4504
  const name = expectDefined(match[1]);
4478
4505
  const offset = match.index ?? 0;
4479
4506
  const line = lineFromOffset(offset);
4480
- const col = offset - (lineOffsets[line - 1] ?? 0);
4507
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4481
4508
  symbols.push(
4482
4509
  makeSymbol2({
4483
4510
  name,
@@ -4495,7 +4522,7 @@ function regexParse3(opts) {
4495
4522
  const name = expectDefined(match[1]);
4496
4523
  const offset = match.index ?? 0;
4497
4524
  const line = lineFromOffset(offset);
4498
- const col = offset - (lineOffsets[line - 1] ?? 0);
4525
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4499
4526
  symbols.push(
4500
4527
  makeSymbol2({
4501
4528
  name,
@@ -4515,7 +4542,7 @@ function regexParse3(opts) {
4515
4542
  if (!key) continue;
4516
4543
  const offset = match.index ?? 0;
4517
4544
  const line = lineFromOffset(offset);
4518
- const col = offset - (lineOffsets[line - 1] ?? 0);
4545
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4519
4546
  const lineContent = lines[line - 1] ?? "";
4520
4547
  if (/^[|&>]/.test(lineContent.trim())) continue;
4521
4548
  if (key === "---" || key === "...") continue;
@@ -4530,7 +4557,7 @@ function regexParse3(opts) {
4530
4557
  const key = expectDefined(match[2]);
4531
4558
  const offset = match.index ?? 0;
4532
4559
  const line = lineFromOffset(offset);
4533
- const col = offset - (lineOffsets[line - 1] ?? 0);
4560
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4534
4561
  const value = extractValue(content, offset + match[0]?.length);
4535
4562
  const kind = isScalar(value) ? "literal" : "property";
4536
4563
  symbols.push(
@@ -4550,7 +4577,7 @@ function regexParse3(opts) {
4550
4577
  const key = expectDefined(match[2]);
4551
4578
  const offset = match.index ?? 0;
4552
4579
  const line = lineFromOffset(offset);
4553
- const col = offset - (lineOffsets[line - 1] ?? 0);
4580
+ const col = offset - (lineOffsets2[line - 1] ?? 0);
4554
4581
  symbols.push(
4555
4582
  makeSymbol2({
4556
4583
  name: key,
@@ -4636,7 +4663,7 @@ function compileGitignore(lines) {
4636
4663
  async function loadGitignoreMatcher(projectRoot) {
4637
4664
  let lines = [];
4638
4665
  try {
4639
- const raw = await fs2.readFile(path3.join(projectRoot, ".gitignore"), "utf8");
4666
+ const raw = await fs2.readFile(path12.join(projectRoot, ".gitignore"), "utf8");
4640
4667
  lines = raw.split("\n");
4641
4668
  } catch {
4642
4669
  }
@@ -4701,14 +4728,14 @@ async function findSourceFiles(projectRoot, ignore, isGitIgnored, signal) {
4701
4728
  dirCount++;
4702
4729
  for (const e of entries) {
4703
4730
  if (ignoreSet.has(e.name)) continue;
4704
- const full = path3.join(dir, e.name);
4705
- const rel = path3.relative(projectRoot, full).replace(/\\/g, "/");
4731
+ const full = path12.join(dir, e.name);
4732
+ const rel = path12.relative(projectRoot, full).replace(/\\/g, "/");
4706
4733
  if (e.isDirectory()) {
4707
4734
  if (isGitIgnored(rel, true)) continue;
4708
4735
  await walk(full);
4709
4736
  } else if (e.isFile()) {
4710
4737
  if (isGitIgnored(rel, false)) continue;
4711
- const ext = path3.extname(e.name);
4738
+ const ext = path12.extname(e.name);
4712
4739
  for (const { ext: extName, pat } of globs) {
4713
4740
  if (ext === extName && (pat.test(rel) || pat.test(e.name))) {
4714
4741
  results.push(full);
@@ -4764,7 +4791,7 @@ async function runIndexerWithStore(store, opts) {
4764
4791
  let files;
4765
4792
  let discoveredFiles = null;
4766
4793
  if (opts.files && opts.files.length > 0) {
4767
- files = opts.files.map((f) => path3.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path3.relative(projectRoot, f).replace(/\\/g, "/"), false));
4794
+ files = opts.files.map((f) => path12.resolve(projectRoot, f)).filter((f) => !isGitIgnored(path12.relative(projectRoot, f).replace(/\\/g, "/"), false));
4768
4795
  } else {
4769
4796
  files = await findSourceFiles(projectRoot, ignore, isGitIgnored, signal);
4770
4797
  discoveredFiles = new Set(files);
@@ -4792,34 +4819,34 @@ async function runIndexerWithStore(store, opts) {
4792
4819
  const statOpts = signal ? { signal } : {};
4793
4820
  const statReadParse = await Promise.allSettled(
4794
4821
  batchFiles.map(async (file) => {
4795
- let stat11;
4822
+ let stat12;
4796
4823
  try {
4797
- stat11 = await fs2.stat(file, statOpts);
4824
+ stat12 = await fs2.stat(file, statOpts);
4798
4825
  } catch (e) {
4799
4826
  if (isAbortError(e)) throw e;
4800
4827
  return { file, stat: null, lang: "", parsed: null, error: `stat error: ${e instanceof Error ? e.message : String(e)}` };
4801
4828
  }
4802
- if (!stat11.isFile()) return { file, stat: stat11, lang: "", parsed: null };
4829
+ if (!stat12.isFile()) return { file, stat: stat12, lang: "", parsed: null };
4803
4830
  const lang = detectLang(file);
4804
- if (!lang) return { file, stat: stat11, lang: "", parsed: null };
4831
+ if (!lang) return { file, stat: stat12, lang: "", parsed: null };
4805
4832
  const meta = existingMeta.get(file);
4806
- if (!force && meta && meta.mtimeMs === Math.floor(stat11.mtimeMs)) {
4807
- return { file, stat: stat11, lang, parsed: null, skippedMeta: meta };
4833
+ if (!force && meta && meta.mtimeMs === Math.floor(stat12.mtimeMs)) {
4834
+ return { file, stat: stat12, lang, parsed: null, skippedMeta: meta };
4808
4835
  }
4809
4836
  let content;
4810
4837
  try {
4811
4838
  content = await fs2.readFile(file, { encoding: "utf8", signal });
4812
4839
  } catch (e) {
4813
4840
  if (isAbortError(e)) throw e;
4814
- return { file, stat: stat11, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
4841
+ return { file, stat: stat12, lang, parsed: null, error: `read error: ${e instanceof Error ? e.message : String(e)}` };
4815
4842
  }
4816
4843
  let parsed;
4817
4844
  try {
4818
4845
  parsed = await parseFile(file, content, lang);
4819
4846
  } catch (e) {
4820
- return { file, stat: stat11, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
4847
+ return { file, stat: stat12, lang, parsed: null, error: `parse error: ${e instanceof Error ? e.message : String(e)}` };
4821
4848
  }
4822
- return { file, stat: stat11, lang, parsed, content };
4849
+ return { file, stat: stat12, lang, parsed, content };
4823
4850
  })
4824
4851
  );
4825
4852
  const batchEntries = [];
@@ -4839,7 +4866,7 @@ async function runIndexerWithStore(store, opts) {
4839
4866
  if (result.error.includes("error:")) errors.push(result.error);
4840
4867
  continue;
4841
4868
  }
4842
- const { stat: stat11, lang, parsed } = result;
4869
+ const { stat: stat12, lang, parsed } = result;
4843
4870
  if (result.skippedMeta) {
4844
4871
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
4845
4872
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -4851,7 +4878,7 @@ async function runIndexerWithStore(store, opts) {
4851
4878
  store.upsertFile({
4852
4879
  file,
4853
4880
  lang,
4854
- mtimeMs: Math.floor(stat11.mtimeMs),
4881
+ mtimeMs: Math.floor(stat12.mtimeMs),
4855
4882
  symbolCount: 0,
4856
4883
  lastIndexed: Date.now()
4857
4884
  });
@@ -4863,7 +4890,7 @@ async function runIndexerWithStore(store, opts) {
4863
4890
  store.upsertFile({
4864
4891
  file,
4865
4892
  lang,
4866
- mtimeMs: Math.floor(stat11.mtimeMs),
4893
+ mtimeMs: Math.floor(stat12.mtimeMs),
4867
4894
  symbolCount: 0,
4868
4895
  lastIndexed: Date.now()
4869
4896
  });
@@ -4879,7 +4906,7 @@ async function runIndexerWithStore(store, opts) {
4879
4906
  lang,
4880
4907
  symbols: parsed.symbols,
4881
4908
  refs,
4882
- mtimeMs: Math.floor(stat11.mtimeMs),
4909
+ mtimeMs: Math.floor(stat12.mtimeMs),
4883
4910
  symbolCount: parsed.symbols.length
4884
4911
  });
4885
4912
  deleteForFiles.push(file);
@@ -5028,7 +5055,7 @@ function statsService(args) {
5028
5055
  }
5029
5056
 
5030
5057
  // src/codebase-index/background-indexer.ts
5031
- var DEFAULT_FULL_INDEX_TIMEOUT_MS = 12e4;
5058
+ var DEFAULT_FULL_INDEX_TIMEOUT_MS = 24e4;
5032
5059
  var DEFAULT_QUERY_TIMEOUT_MS = 8e3;
5033
5060
  var _ready = false;
5034
5061
  var _indexing = false;
@@ -5485,6 +5512,25 @@ var codebaseStatsTool = {
5485
5512
  };
5486
5513
  }
5487
5514
  };
5515
+ async function resolveReal(p) {
5516
+ const resolved = path12.resolve(p);
5517
+ let probe = resolved;
5518
+ const missing = [];
5519
+ for (; ; ) {
5520
+ try {
5521
+ return path12.resolve(await fs2.realpath(probe), ...missing);
5522
+ } catch (err) {
5523
+ if (err.code === "ENOENT") {
5524
+ const parent = path12.dirname(probe);
5525
+ if (parent === probe) return resolved;
5526
+ missing.unshift(path12.basename(probe));
5527
+ probe = parent;
5528
+ continue;
5529
+ }
5530
+ return resolved;
5531
+ }
5532
+ }
5533
+ }
5488
5534
  function normalizeOverrides(set) {
5489
5535
  const out = {};
5490
5536
  if (set && typeof set === "object") {
@@ -5642,10 +5688,12 @@ These win over kit tokens. Run \`design {action:"materialize"}\` to write them t
5642
5688
  kitId: active.kit,
5643
5689
  outPath: input.out
5644
5690
  });
5645
- const root = path3.resolve(ctx.projectRoot);
5646
- const abs = path3.resolve(path3.join(ctx.projectRoot, result.path));
5647
- const rel = path3.relative(root, abs);
5648
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
5691
+ const root = await resolveReal(ctx.projectRoot);
5692
+ const absResolved = path12.isAbsolute(result.path) ? path12.resolve(result.path) : path12.resolve(path12.join(ctx.projectRoot, result.path));
5693
+ const absParent = await resolveReal(path12.dirname(absResolved));
5694
+ const abs = path12.join(absParent, path12.basename(absResolved));
5695
+ const rel = path12.relative(root, abs);
5696
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
5649
5697
  throw new Error(
5650
5698
  `design: materialize path "${result.path}" would escape the project root`
5651
5699
  );
@@ -5669,7 +5717,7 @@ ${result.content}
5669
5717
  \`\`\``
5670
5718
  };
5671
5719
  }
5672
- await fs2.mkdir(path3.dirname(abs), { recursive: true });
5720
+ await fs2.mkdir(path12.dirname(abs), { recursive: true });
5673
5721
  await fs2.writeFile(abs, result.content);
5674
5722
  return {
5675
5723
  action,
@@ -5797,11 +5845,11 @@ function findGitDir(cwd) {
5797
5845
  let dir = cwd;
5798
5846
  for (let i = 0; i < 20; i++) {
5799
5847
  try {
5800
- const stat11 = statSync(path3.join(dir, ".git"));
5801
- if (stat11.isDirectory()) return dir;
5848
+ const stat12 = statSync(path12.join(dir, ".git"));
5849
+ if (stat12.isDirectory()) return dir;
5802
5850
  } catch {
5803
5851
  }
5804
- const parent = path3.dirname(dir);
5852
+ const parent = path12.dirname(dir);
5805
5853
  if (parent === dir) break;
5806
5854
  dir = parent;
5807
5855
  }
@@ -5842,8 +5890,8 @@ async function fileDiff(input, ctx, _signal) {
5842
5890
  const results = [];
5843
5891
  for (const file of files) {
5844
5892
  const absPath = safeResolve(file, ctx);
5845
- const stat11 = await fs2.stat(absPath).catch(() => null);
5846
- if (!stat11?.isFile()) continue;
5893
+ const stat12 = await fs2.stat(absPath).catch(() => null);
5894
+ if (!stat12?.isFile()) continue;
5847
5895
  const content = await fs2.readFile(absPath, "utf8");
5848
5896
  const lines = content.split(/\r?\n/);
5849
5897
  results.push(formatWithLineNumbers(file, lines));
@@ -5940,8 +5988,8 @@ async function resolveFiles(filesInput, cwd) {
5940
5988
  for (const f of files) {
5941
5989
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
5942
5990
  try {
5943
- const stat11 = await fs2.stat(absPath);
5944
- if (stat11.isFile()) resolved.push(absPath);
5991
+ const stat12 = await fs2.stat(absPath);
5992
+ if (stat12.isFile()) resolved.push(absPath);
5945
5993
  } catch {
5946
5994
  }
5947
5995
  }
@@ -6007,11 +6055,303 @@ function processFile(content, absPath, _style, _overwrite, target) {
6007
6055
  }
6008
6056
  return results;
6009
6057
  }
6058
+
6059
+ // src/_edit-match.ts
6060
+ var TIER_LABEL = {
6061
+ exact: "exact match",
6062
+ "trailing-whitespace": "whitespace-normalized match (trailing whitespace ignored)",
6063
+ "whitespace-normalized": "whitespace-normalized match (indentation ignored, replacement re-indented)",
6064
+ fuzzy: "fuzzy match (block anchors exact, interior \u226590% similar)"
6065
+ };
6066
+ var TIER_CONFIDENCE = {
6067
+ exact: "exact",
6068
+ "trailing-whitespace": "high",
6069
+ "whitespace-normalized": "medium",
6070
+ fuzzy: "low"
6071
+ };
6072
+ var MIN_NORMALIZED_NEEDLE_CHARS = 16;
6073
+ var FUZZY_MIN_SIMILARITY = 0.9;
6074
+ var FUZZY_AMBIGUITY_MARGIN = 0.05;
6075
+ var FUZZY_MAX_INTERIOR_CHARS = 2e3;
6076
+ function findLadderMatches(fileLf, oldLf) {
6077
+ const exact = [];
6078
+ let idx = fileLf.indexOf(oldLf);
6079
+ while (idx !== -1) {
6080
+ exact.push({ start: idx, end: idx + oldLf.length, startLine: lineAt(fileLf, idx) });
6081
+ idx = fileLf.indexOf(oldLf, idx + 1);
6082
+ }
6083
+ if (exact.length > 0) return { tier: "exact", matches: exact };
6084
+ const fileLines = fileLf.split("\n");
6085
+ const needleLines = oldLf.split("\n");
6086
+ if (needleLines.length > fileLines.length) return void 0;
6087
+ const offsets = lineOffsets(fileLines);
6088
+ const trailing = windowScan(
6089
+ fileLines,
6090
+ needleLines,
6091
+ offsets,
6092
+ (a, b) => a.trimEnd() === b.trimEnd()
6093
+ );
6094
+ if (trailing.length > 0) return { tier: "trailing-whitespace", matches: trailing };
6095
+ const normalizedLen = needleLines.reduce((n, l) => n + l.trim().length, 0);
6096
+ if (normalizedLen < MIN_NORMALIZED_NEEDLE_CHARS) return void 0;
6097
+ const normalized = windowScan(fileLines, needleLines, offsets, (a, b) => a.trim() === b.trim());
6098
+ if (normalized.length > 0) return { tier: "whitespace-normalized", matches: normalized };
6099
+ return fuzzyScan(fileLines, needleLines, offsets);
6100
+ }
6101
+ function lineAt(text, pos) {
6102
+ let line = 1;
6103
+ for (let i = 0; i < pos; i++) {
6104
+ if (text.charCodeAt(i) === 10) line++;
6105
+ }
6106
+ return line;
6107
+ }
6108
+ function lineOffsets(lines) {
6109
+ const out = new Array(lines.length);
6110
+ let pos = 0;
6111
+ for (let i = 0; i < lines.length; i++) {
6112
+ out[i] = pos;
6113
+ pos += lines[i].length + 1;
6114
+ }
6115
+ return out;
6116
+ }
6117
+ function windowToMatch(fileLines, offsets, start, windowLen) {
6118
+ const lastLine = start + windowLen - 1;
6119
+ return {
6120
+ start: offsets[start],
6121
+ end: offsets[lastLine] + fileLines[lastLine].length,
6122
+ startLine: start + 1
6123
+ };
6124
+ }
6125
+ function windowScan(fileLines, needleLines, offsets, eq) {
6126
+ const n = needleLines.length;
6127
+ const out = [];
6128
+ for (let i = 0; i + n <= fileLines.length; i++) {
6129
+ let all = true;
6130
+ for (let j = 0; j < n; j++) {
6131
+ if (!eq(fileLines[i + j], needleLines[j])) {
6132
+ all = false;
6133
+ break;
6134
+ }
6135
+ }
6136
+ if (all) {
6137
+ out.push(windowToMatch(fileLines, offsets, i, n));
6138
+ i += n - 1;
6139
+ }
6140
+ }
6141
+ return out;
6142
+ }
6143
+ function fuzzyScan(fileLines, needleLines, offsets) {
6144
+ const n = needleLines.length;
6145
+ if (n < 3) return void 0;
6146
+ const firstNeedle = needleLines[0].trim();
6147
+ const lastNeedle = needleLines[n - 1].trim();
6148
+ const needleInterior = needleLines.slice(1, -1).map((l) => l.trim()).join("\n");
6149
+ if (needleInterior.length > FUZZY_MAX_INTERIOR_CHARS) return void 0;
6150
+ const candidates = [];
6151
+ for (let i = 0; i + n <= fileLines.length; i++) {
6152
+ if (fileLines[i].trim() !== firstNeedle) continue;
6153
+ if (fileLines[i + n - 1].trim() !== lastNeedle) continue;
6154
+ const windowInterior = fileLines.slice(i + 1, i + n - 1).map((l) => l.trim()).join("\n");
6155
+ if (windowInterior.length > FUZZY_MAX_INTERIOR_CHARS) continue;
6156
+ const score = similarity(needleInterior, windowInterior);
6157
+ if (score >= FUZZY_MIN_SIMILARITY) {
6158
+ candidates.push({ match: windowToMatch(fileLines, offsets, i, n), score });
6159
+ }
6160
+ }
6161
+ if (candidates.length === 0) return void 0;
6162
+ candidates.sort((a, b) => b.score - a.score);
6163
+ const best = candidates[0];
6164
+ const runnerUp = candidates[1];
6165
+ if (runnerUp && best.score - runnerUp.score < FUZZY_AMBIGUITY_MARGIN) {
6166
+ return {
6167
+ tier: "fuzzy",
6168
+ matches: candidates.map((c) => c.match),
6169
+ score: best.score,
6170
+ ambiguous: true
6171
+ };
6172
+ }
6173
+ return { tier: "fuzzy", matches: [best.match], score: best.score };
6174
+ }
6175
+ function similarity(a, b) {
6176
+ if (a === b) return 1;
6177
+ const max = Math.max(a.length, b.length);
6178
+ if (max === 0) return 1;
6179
+ if (Math.abs(a.length - b.length) / max > 1 - FUZZY_MIN_SIMILARITY + 0.05) {
6180
+ return 1 - Math.abs(a.length - b.length) / max;
6181
+ }
6182
+ return 1 - levenshtein(a, b) / max;
6183
+ }
6184
+ function levenshtein(a, b) {
6185
+ if (a.length === 0) return b.length;
6186
+ if (b.length === 0) return a.length;
6187
+ let prev = new Array(b.length + 1);
6188
+ let cur = new Array(b.length + 1);
6189
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
6190
+ for (let i = 1; i <= a.length; i++) {
6191
+ cur[0] = i;
6192
+ const ca = a.charCodeAt(i - 1);
6193
+ for (let j = 1; j <= b.length; j++) {
6194
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
6195
+ cur[j] = Math.min(
6196
+ prev[j] + 1,
6197
+ cur[j - 1] + 1,
6198
+ prev[j - 1] + cost
6199
+ );
6200
+ }
6201
+ [prev, cur] = [cur, prev];
6202
+ }
6203
+ return prev[b.length];
6204
+ }
6205
+ function firstLineIndent(text) {
6206
+ for (const line of text.split("\n")) {
6207
+ if (line.trim() === "") continue;
6208
+ return /^[ \t]*/.exec(line)[0];
6209
+ }
6210
+ return "";
6211
+ }
6212
+ function adjustIndent(newLf, fromIndent, toIndent) {
6213
+ if (fromIndent === toIndent) return { text: newLf, adjusted: false };
6214
+ const lines = newLf.split("\n");
6215
+ if (toIndent.startsWith(fromIndent)) {
6216
+ const extra = toIndent.slice(fromIndent.length);
6217
+ return {
6218
+ text: lines.map((l) => l.trim() === "" ? l : extra + l).join("\n"),
6219
+ adjusted: true
6220
+ };
6221
+ }
6222
+ if (fromIndent.startsWith(toIndent)) {
6223
+ const remove = fromIndent.slice(toIndent.length);
6224
+ return {
6225
+ text: lines.map((l) => l.startsWith(remove) ? l.slice(remove.length) : l).join("\n"),
6226
+ adjusted: true
6227
+ };
6228
+ }
6229
+ return { text: newLf, adjusted: false };
6230
+ }
6231
+ function nearestMatchHint(fileLf, oldLf) {
6232
+ const fileLines = fileLf.split("\n");
6233
+ const needleLines = oldLf.split("\n");
6234
+ if (needleLines.length > fileLines.length) return void 0;
6235
+ const needleTrimmed = needleLines.map((l) => l.trim());
6236
+ let bestScore = 0;
6237
+ let bestStart = -1;
6238
+ for (let i = 0; i + needleLines.length <= fileLines.length; i++) {
6239
+ let sum = 0;
6240
+ for (let j = 0; j < needleLines.length; j++) {
6241
+ sum += prefixSimilarity(needleTrimmed[j], fileLines[i + j].trim());
6242
+ }
6243
+ const score = sum / needleLines.length;
6244
+ if (score > bestScore) {
6245
+ bestScore = score;
6246
+ bestStart = i;
6247
+ }
6248
+ }
6249
+ if (bestStart === -1 || bestScore < 0.5) return void 0;
6250
+ const snippet = fileLines.slice(bestStart, bestStart + Math.min(3, needleLines.length)).map((l) => l.length > 120 ? `${l.slice(0, 120)}\u2026` : l).join("\n");
6251
+ return { line: bestStart + 1, snippet };
6252
+ }
6253
+ function prefixSimilarity(a, b) {
6254
+ if (a === b) return 1;
6255
+ const max = Math.max(a.length, b.length);
6256
+ if (max === 0) return 1;
6257
+ let p = 0;
6258
+ const lim = Math.min(a.length, b.length);
6259
+ while (p < lim && a.charCodeAt(p) === b.charCodeAt(p)) p++;
6260
+ return p / max;
6261
+ }
6262
+ var TS_LIKE = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
6263
+ var MAX_CHECK_CHARS = 15e5;
6264
+ var MAX_ERRORS = 5;
6265
+ var tsLoad = null;
6266
+ function loadTypescript() {
6267
+ tsLoad ??= import('typescript').then(
6268
+ (m) => m.default ?? m,
6269
+ () => null
6270
+ );
6271
+ return tsLoad;
6272
+ }
6273
+ async function checkSyntax(filePath, content, previousContent) {
6274
+ const errors = await parseErrors(filePath, content);
6275
+ if (errors === void 0) return void 0;
6276
+ if (errors.length === 0) return { errors, preExisting: false };
6277
+ let preExisting = false;
6278
+ if (previousContent !== void 0) {
6279
+ const prevErrors = await parseErrors(filePath, previousContent);
6280
+ preExisting = prevErrors !== void 0 && prevErrors.length > 0;
6281
+ }
6282
+ return { errors, preExisting };
6283
+ }
6284
+ function isJsoncFile(filePath) {
6285
+ const base = path12.basename(filePath).toLowerCase();
6286
+ if (base.endsWith(".jsonc")) return true;
6287
+ if (/^(tsconfig|jsconfig)([.-].*)?\.json$/.test(base)) return true;
6288
+ const dir = path12.basename(path12.dirname(filePath)).toLowerCase();
6289
+ return dir === ".vscode";
6290
+ }
6291
+ async function parseErrors(filePath, content) {
6292
+ if (content.length > MAX_CHECK_CHARS) return void 0;
6293
+ const ext = path12.extname(filePath).toLowerCase();
6294
+ if (ext === ".json" || ext === ".jsonc") {
6295
+ try {
6296
+ JSON.parse(content);
6297
+ return [];
6298
+ } catch (err) {
6299
+ if (isJsoncFile(filePath)) {
6300
+ const ts3 = await loadTypescript();
6301
+ if (ts3) {
6302
+ const jsonc = ts3.parseConfigFileTextToJson(filePath, content);
6303
+ if (!jsonc.error) return [];
6304
+ return [formatDiag(ts3, jsonc.error, content)];
6305
+ }
6306
+ }
6307
+ return [`JSON parse error: ${err.message}`];
6308
+ }
6309
+ }
6310
+ if (!TS_LIKE.has(ext)) return void 0;
6311
+ const ts2 = await loadTypescript();
6312
+ if (!ts2) return void 0;
6313
+ const scriptKind = ext === ".tsx" ? ts2.ScriptKind.TSX : ext === ".ts" || ext === ".mts" || ext === ".cts" ? ts2.ScriptKind.TS : (
6314
+ // Plain JS may legitimately contain JSX; the JSX grammar is a
6315
+ // superset for untyped code, so parsing .js/.jsx as JSX avoids
6316
+ // false positives on React files.
6317
+ ts2.ScriptKind.JSX
6318
+ );
6319
+ const sourceFile = ts2.createSourceFile(
6320
+ path12.basename(filePath),
6321
+ content,
6322
+ ts2.ScriptTarget.Latest,
6323
+ /* setParentNodes */
6324
+ false,
6325
+ scriptKind
6326
+ );
6327
+ const diags = sourceFile.parseDiagnostics ?? [];
6328
+ return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts2, d, content, sourceFile));
6329
+ }
6330
+ function formatDiag(ts2, diag, content, sourceFile) {
6331
+ const message = ts2.flattenDiagnosticMessageText(diag.messageText, " ");
6332
+ if (diag.start === void 0) return message;
6333
+ let line;
6334
+ if (sourceFile) {
6335
+ line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;
6336
+ } else {
6337
+ line = 1;
6338
+ for (let i = 0; i < diag.start && i < content.length; i++) {
6339
+ if (content.charCodeAt(i) === 10) line++;
6340
+ }
6341
+ }
6342
+ return `line ${line}: ${message}`;
6343
+ }
6344
+
6345
+ // src/edit.ts
6010
6346
  var editTool = {
6011
6347
  name: "edit",
6012
6348
  category: "Filesystem",
6013
6349
  description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It works best after a prior `read`, but can auto-read the current file when the replacement is still unambiguous. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
6014
- usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.",
6350
+ usageHint: "RECOMMENDED WORKFLOW:\n1. Prefer calling `read` on the target file first when planning an edit.\n2. Use a sufficiently unique `old_string` (include surrounding lines/context if needed).\n3. If the string appears multiple times and you want to change all of them, set `replace_all: true`.\n4. `new_string` must be the exact replacement text.\n\nIf no prior read is recorded, the tool auto-reads the current file and only applies the edit after the same ambiguity checks pass.\nIf `old_string` differs from the file only in whitespace (trailing spaces, indentation), a lower-confidence fallback match is applied and reported in `matched_by`/`note` \u2014 always verify the diff when this happens.\nAfter the edit, TS/JS/JSON files are syntax-checked; if `syntax_errors` is present in the output, fix those errors immediately with a follow-up edit.",
6351
+ selection: {
6352
+ doNotUseWhen: "creating a new file, replacing the whole file, or applying an existing unified diff.",
6353
+ useInstead: ["write", "patch"]
6354
+ },
6015
6355
  permission: "confirm",
6016
6356
  mutating: true,
6017
6357
  capabilities: ["fs.write"],
@@ -6027,7 +6367,7 @@ var editTool = {
6027
6367
  },
6028
6368
  required: ["path", "old_string", "new_string"]
6029
6369
  },
6030
- async execute(input, ctx) {
6370
+ async execute(input, ctx, opts) {
6031
6371
  if (!input?.path) {
6032
6372
  throw new ToolValidationError({ message: "edit: path is required", field: "path" });
6033
6373
  }
@@ -6050,7 +6390,7 @@ var editTool = {
6050
6390
  });
6051
6391
  }
6052
6392
  const absPath = await safeResolveReal(input.path, ctx);
6053
- const stat11 = await fs2.stat(absPath).catch((err) => {
6393
+ const stat12 = await fs2.stat(absPath).catch((err) => {
6054
6394
  if (err.code === "ENOENT") {
6055
6395
  throw new ToolValidationError({
6056
6396
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -6060,7 +6400,7 @@ var editTool = {
6060
6400
  }
6061
6401
  throw err;
6062
6402
  });
6063
- if (!stat11.isFile()) {
6403
+ if (!stat12.isFile()) {
6064
6404
  throw new ToolValidationError({
6065
6405
  message: `edit: "${input.path}" is not a regular file`,
6066
6406
  field: "path"
@@ -6070,15 +6410,27 @@ var editTool = {
6070
6410
  const original = await fs2.readFile(absPath, "utf8");
6071
6411
  const updated = await fs2.stat(absPath);
6072
6412
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
6073
- const lastReadMtime = ctx.lastReadMtime(absPath);
6074
- if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6075
- throw new ToolValidationError({
6076
- message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6077
- field: "path",
6078
- context: { reason: "external_modification" }
6079
- });
6413
+ const originalHash = sha256hex(original);
6414
+ const lastReadHash = ctx.lastReadHash?.(absPath);
6415
+ if (lastReadHash !== void 0) {
6416
+ if (lastReadHash !== originalHash) {
6417
+ throw new ToolValidationError({
6418
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6419
+ field: "path",
6420
+ context: { reason: "external_modification" }
6421
+ });
6422
+ }
6423
+ } else {
6424
+ const lastReadMtime = ctx.lastReadMtime(absPath);
6425
+ if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
6426
+ throw new ToolValidationError({
6427
+ message: `edit: file "${input.path}" was modified externally. Re-read it first.`,
6428
+ field: "path",
6429
+ context: { reason: "external_modification" }
6430
+ });
6431
+ }
6080
6432
  }
6081
- if (autoRead && updated.mtimeMs > stat11.mtimeMs + mtimeTolerance) {
6433
+ if (autoRead && updated.mtimeMs > stat12.mtimeMs + mtimeTolerance) {
6082
6434
  throw new ToolValidationError({
6083
6435
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
6084
6436
  field: "path",
@@ -6091,42 +6443,78 @@ var editTool = {
6091
6443
  const oldLf = normalizeToLf(input.old_string);
6092
6444
  const newLf = normalizeToLf(input.new_string);
6093
6445
  if (oldLf === newLf) {
6094
- if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
6446
+ if (autoRead) ctx.recordRead(absPath, updated.mtimeMs, "user", originalHash);
6095
6447
  return {
6096
6448
  path: absPath,
6097
6449
  replacements: 0,
6098
6450
  diff: "(no-op: old and new are identical)",
6451
+ matched_by: "exact",
6099
6452
  note: autoReadNote
6100
6453
  };
6101
6454
  }
6102
- let count = 0;
6103
- let idx = fileLf.indexOf(oldLf);
6104
- const matches = [];
6105
- while (idx !== -1) {
6106
- matches.push(idx);
6107
- count++;
6108
- idx = fileLf.indexOf(oldLf, idx + 1);
6109
- }
6110
- if (count === 0) {
6111
- const hint = findSimilarity(fileLf, oldLf);
6455
+ const ladder = findLadderMatches(fileLf, oldLf);
6456
+ if (!ladder) {
6457
+ const hint = nearestMatchHint(fileLf, oldLf);
6112
6458
  throw new ToolValidationError({
6113
- message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint}.` : ""}`,
6459
+ message: `edit: no match for old_string in "${input.path}".${hint ? ` Nearest match near line ${hint.line}:
6460
+ ${hint.snippet}
6461
+ Compare this against your old_string and retry with the file's actual text.` : ""}`,
6114
6462
  field: "old_string"
6115
6463
  });
6116
6464
  }
6465
+ const { tier, matches } = ladder;
6466
+ const count = matches.length;
6467
+ if (ladder.ambiguous) {
6468
+ const lines = matches.map((m) => m.startLine);
6469
+ throw new ToolValidationError({
6470
+ message: `edit: old_string only matched fuzzily and ${count} candidate blocks scored too close to distinguish (lines: ${lines.join(", ")}) in "${input.path}". Re-read the file and use the exact text of the intended block.`,
6471
+ field: "old_string",
6472
+ context: { occurrences: count, matchTier: tier }
6473
+ });
6474
+ }
6475
+ if (input.replace_all && tier !== "exact" && tier !== "trailing-whitespace") {
6476
+ throw new ToolValidationError({
6477
+ message: `edit: old_string only matched via ${TIER_LABEL[tier]} in "${input.path}", but replace_all requires an exact (or trailing-whitespace) match. Re-read the file and use its exact text.`,
6478
+ field: "old_string",
6479
+ context: { matchTier: tier }
6480
+ });
6481
+ }
6117
6482
  if (count > 1 && !input.replace_all) {
6118
- const lines = lineNumbersFor(fileLf, matches);
6483
+ const lines = matches.map((m) => m.startLine);
6119
6484
  throw new ToolValidationError({
6120
- message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")}). Add more context to make it unique, or set replace_all: true.`,
6485
+ message: `edit: old_string matched ${count} times in "${input.path}" (lines: ${lines.join(", ")})${tier === "exact" ? "" : ` via ${TIER_LABEL[tier]}`}. Add more context to make it unique, or set replace_all: true.`,
6121
6486
  field: "old_string",
6122
- context: { occurrences: count }
6487
+ context: { occurrences: count, matchTier: tier }
6123
6488
  });
6124
6489
  }
6125
- const newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6490
+ let newFileLf;
6491
+ let tierNote;
6492
+ if (tier === "exact") {
6493
+ newFileLf = input.replace_all ? fileLf.split(oldLf).join(newLf) : fileLf.replace(oldLf, newLf);
6494
+ } else {
6495
+ let replacement = newLf;
6496
+ let indentSuffix = "";
6497
+ if (tier === "whitespace-normalized" || tier === "fuzzy") {
6498
+ const first = matches[0];
6499
+ const matchedText = fileLf.slice(first.start, first.end);
6500
+ const adjusted = adjustIndent(newLf, firstLineIndent(oldLf), firstLineIndent(matchedText));
6501
+ replacement = adjusted.text;
6502
+ if (adjusted.adjusted) indentSuffix = "; replacement re-indented to match the file";
6503
+ }
6504
+ newFileLf = fileLf;
6505
+ const applied = input.replace_all ? matches : matches.slice(0, 1);
6506
+ for (let i = applied.length - 1; i >= 0; i--) {
6507
+ const m = applied[i];
6508
+ newFileLf = newFileLf.slice(0, m.start) + replacement + newFileLf.slice(m.end);
6509
+ }
6510
+ const scoreSuffix = ladder.score !== void 0 ? `, similarity ${(ladder.score * 100).toFixed(1)}%` : "";
6511
+ tierNote = `old_string did not match exactly; applied via ${TIER_LABEL[tier]} at line ${matches[0].startLine} (confidence: ${TIER_CONFIDENCE[tier]}${scoreSuffix}${indentSuffix}). Review the diff to confirm the intended target was edited.`;
6512
+ }
6126
6513
  const newFile = toStyle(newFileLf, style);
6514
+ opts?.signal?.throwIfAborted();
6127
6515
  await atomicWrite(absPath, newFile, { mode: updated.mode & 511 });
6128
6516
  const written = await fs2.stat(absPath);
6129
- ctx.recordRead(absPath, written.mtimeMs, "write");
6517
+ ctx.recordRead(absPath, written.mtimeMs, "write", sha256hex(newFile));
6130
6518
  ctx.session.recordFileChange({
6131
6519
  path: absPath,
6132
6520
  action: "modified",
@@ -6137,38 +6525,22 @@ var editTool = {
6137
6525
  fromFile: input.path,
6138
6526
  toFile: input.path
6139
6527
  });
6528
+ const syntax = await checkSyntax(absPath, newFile, original).catch(() => void 0);
6529
+ let syntaxNote;
6530
+ if (syntax && syntax.errors.length > 0) {
6531
+ syntaxNote = syntax.preExisting ? `Syntax check: the file still has parse errors (they pre-date this edit) \u2014 see syntax_errors.` : `Syntax check: this edit introduced ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.`;
6532
+ }
6533
+ const notes = [autoReadNote, tierNote, syntaxNote].filter(Boolean);
6140
6534
  return {
6141
6535
  path: absPath,
6142
6536
  replacements: input.replace_all ? count : 1,
6143
6537
  diff,
6144
- note: autoReadNote
6538
+ matched_by: tier,
6539
+ syntax_errors: syntax && syntax.errors.length > 0 ? syntax.errors : void 0,
6540
+ note: notes.length > 0 ? notes.join("\n") : void 0
6145
6541
  };
6146
6542
  }
6147
6543
  };
6148
- function lineNumbersFor(text, indices) {
6149
- const out = [];
6150
- let pos = 0;
6151
- let line = 1;
6152
- for (const target of indices) {
6153
- while (pos < target) {
6154
- if (text.charCodeAt(pos) === 10) line++;
6155
- pos++;
6156
- }
6157
- out.push(line);
6158
- }
6159
- return out;
6160
- }
6161
- function findSimilarity(haystack, needle) {
6162
- if (needle.length < 20) return void 0;
6163
- const probe = needle.slice(0, Math.min(40, needle.length));
6164
- const idx = haystack.indexOf(probe);
6165
- if (idx === -1) return void 0;
6166
- let line = 1;
6167
- for (let i = 0; i < idx; i++) {
6168
- if (haystack.charCodeAt(i) === 10) line++;
6169
- }
6170
- return line;
6171
- }
6172
6544
 
6173
6545
  // src/_danger-detect.ts
6174
6546
  var argHas = (args, value) => args.includes(value);
@@ -6381,23 +6753,18 @@ var RULES = [
6381
6753
  },
6382
6754
  reason: "inline script evaluation (-c / -e / --eval)"
6383
6755
  },
6384
- // ----- pipe-to-shell (cautionwell-known exfil pattern) -----
6756
+ // ----- pipe-to-shell (destructivedownload-and-run pattern) -----
6385
6757
  // The classic `curl https://... | sh` download-and-run vector. Detected by
6386
- // looking for a known fetcher followed by a shell sink. We use a simple
6387
- // substring scan; false positives are limited because both tokens must
6388
- // appear in the same argv.
6758
+ // looking for a known fetcher piped into a shell or expression evaluator.
6759
+ // A shell command passed through `bash -c` / `pwsh -Command` arrives as one
6760
+ // argv string, so scan the reconstructed argv text rather than individual
6761
+ // tokens only.
6389
6762
  {
6390
6763
  id: "pipe-to-shell",
6391
- level: "caution",
6392
- test: (_cmd, args) => {
6393
- const hasFetcher = args.some(
6394
- (a) => /^(curl|wget|fetch|httpie|http)$/i.test(a) || a.startsWith("curl") || a.startsWith("wget")
6395
- );
6396
- const hasShellSink = args.some(
6397
- (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")
6398
- );
6399
- return hasFetcher && hasShellSink;
6400
- },
6764
+ level: "destructive",
6765
+ test: (cmd, args) => /\b(?:curl|wget|fetch|httpie|http|irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[\s\S]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|fish|pwsh|powershell|iex|Invoke-Expression)\b/i.test(
6766
+ [cmd, ...args].join(" ")
6767
+ ),
6401
6768
  reason: "network fetch piped to a shell (download-and-run pattern)"
6402
6769
  },
6403
6770
  // ----- privilege escalation (caution) -----
@@ -7159,6 +7526,10 @@ var execTool = {
7159
7526
  category: "Shell",
7160
7527
  description: "Execute a **whitelisted, restricted set of commands** with strict argument validation. This is the **preferred and safer** alternative to the `bash` tool for running development tools (node, npm, pnpm, tsc, git, tests, linters, etc.). It prevents arbitrary command injection and limits what the model can do.",
7161
7528
  usageHint: "PREFERRED SHELL TOOL for most cases.\n\nUse this instead of `bash` whenever possible.\n- `command` must be in the allowlist. Defaults cover JS (node/npm/pnpm/yarn/bun/deno/tsc/vitest/eslint/biome), Go (`go build`/`go test`), Rust (cargo), Python (python/pip), Ruby (gem/bundle), JVM (java/mvn/gradle), .NET (dotnet), native (make/cmake), and git. Users can extend it via `tools.exec.allow` in config.\n- Arguments are passed as a clean array (no shell interpretation).\n- `cwd` is validated to stay inside the project.\n- If a command is not allowlisted, the error explains how to add it; for one-off arbitrary commands, fall back to `bash` (with strong justification).\nThis tool significantly reduces the risk compared to full shell access.",
7529
+ selection: {
7530
+ doNotUseWhen: "the operation requires pipes, redirection, shell expansion, or a non-allowlisted command.",
7531
+ useInstead: ["bash"]
7532
+ },
7162
7533
  permission: "confirm",
7163
7534
  mutating: true,
7164
7535
  riskTier: "standard",
@@ -7822,13 +8193,13 @@ var formatTool = {
7822
8193
  }
7823
8194
  };
7824
8195
  async function detectFixer(cwd) {
7825
- const { stat: stat11 } = await import('node:fs/promises');
8196
+ const { stat: stat12 } = await import('node:fs/promises');
7826
8197
  try {
7827
- await stat11(`${cwd}/biome.json`);
8198
+ await stat12(`${cwd}/biome.json`);
7828
8199
  return "biome";
7829
8200
  } catch {
7830
8201
  try {
7831
- await stat11(`${cwd}/.prettierrc`);
8202
+ await stat12(`${cwd}/.prettierrc`);
7832
8203
  return "prettier";
7833
8204
  } catch {
7834
8205
  return "biome";
@@ -7989,8 +8360,8 @@ function findGitDir2(cwd, projectRoot) {
7989
8360
  let dir = cwd;
7990
8361
  for (let i = 0; i < 20; i++) {
7991
8362
  try {
7992
- const stat11 = statSync(`${dir}/.git`);
7993
- if (stat11.isDirectory() || stat11.isFile()) return dir;
8363
+ const stat12 = statSync(`${dir}/.git`);
8364
+ if (stat12.isDirectory() || stat12.isFile()) return dir;
7994
8365
  } catch {
7995
8366
  }
7996
8367
  if (dir === root) break;
@@ -8137,6 +8508,10 @@ var globTool = {
8137
8508
  category: "Filesystem",
8138
8509
  description: "Find files matching a glob pattern. Fast way to discover relevant files before reading, grepping, or editing them.",
8139
8510
  usageHint: "RECOMMENDED FOR SCOPING SEARCHES:\n\n- Use early to get a list of files you actually care about.\n- Combine with `path` and `limit`.\n- Default ignores common build/dependency directories.\nMuch more efficient than shell `find` for most use cases inside the agent.",
8511
+ selection: {
8512
+ doNotUseWhen: "you need to search inside file contents.",
8513
+ useInstead: ["grep"]
8514
+ },
8140
8515
  permission: "auto",
8141
8516
  mutating: false,
8142
8517
  capabilities: ["fs.read"],
@@ -8161,8 +8536,9 @@ var globTool = {
8161
8536
  },
8162
8537
  required: ["pattern"]
8163
8538
  },
8164
- async execute(input, ctx) {
8539
+ async execute(input, ctx, opts) {
8165
8540
  if (!input?.pattern) throw new Error("glob: pattern is required");
8541
+ const signal = opts?.signal;
8166
8542
  const base = input.path ? await safeResolveReal(input.path, ctx) : ctx.cwd;
8167
8543
  const limit = Math.max(1, Math.min(input.limit ?? 1e3, 5e3));
8168
8544
  const ignored = await readGitignore(base);
@@ -8186,6 +8562,10 @@ var globTool = {
8186
8562
  }
8187
8563
  };
8188
8564
  const walk = async (dir, relPrefix) => {
8565
+ if (signal?.aborted) {
8566
+ truncated = true;
8567
+ return;
8568
+ }
8189
8569
  if (results.length >= limit) {
8190
8570
  truncated = true;
8191
8571
  return;
@@ -8203,7 +8583,7 @@ var globTool = {
8203
8583
  if (DEFAULT_IGNORE2.includes(name)) continue;
8204
8584
  if (ignored.includes(name)) continue;
8205
8585
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
8206
- const full = path3.join(dir, name);
8586
+ const full = path12.join(dir, name);
8207
8587
  if (e.isDirectory()) {
8208
8588
  subdirs.push({ full, rel });
8209
8589
  } else if (e.isFile()) {
@@ -8247,7 +8627,7 @@ var globTool = {
8247
8627
  };
8248
8628
  async function readGitignore(dir) {
8249
8629
  try {
8250
- const raw = await fs2.readFile(path3.join(dir, ".gitignore"), "utf8");
8630
+ const raw = await fs2.readFile(path12.join(dir, ".gitignore"), "utf8");
8251
8631
  return raw.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
8252
8632
  } catch {
8253
8633
  return [];
@@ -8309,6 +8689,10 @@ var grepTool = {
8309
8689
  category: "Search",
8310
8690
  description: "Search across files using a regular expression. This is one of the primary code search tools. Prefers ripgrep for speed and features when available.",
8311
8691
  usageHint: 'POWERFUL CODE SEARCH TOOL:\n\n- `pattern` is a regular expression.\n- Use `output_mode: "content"` (default) to get matching lines with context.\n- Use `"files_with_matches"` when you only need the list of files.\n- Use `"count"` for quick statistics.\n- `glob` and `path` let you narrow the search scope significantly.\n- Always prefer this over `bash grep` when searching code.',
8692
+ selection: {
8693
+ doNotUseWhen: "you only need to locate files by name or path pattern.",
8694
+ useInstead: ["glob"]
8695
+ },
8312
8696
  permission: "auto",
8313
8697
  mutating: false,
8314
8698
  capabilities: ["fs.read"],
@@ -8548,8 +8932,8 @@ async function runNative(input, base, mode, limit, signal) {
8548
8932
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
8549
8933
  if (globRe) globRe.lastIndex = 0;
8550
8934
  try {
8551
- const stat11 = await fs2.stat(full);
8552
- if (!stat11.isFile() || stat11.size > maxBytes || stopped || signal.aborted) return;
8935
+ const stat12 = await fs2.stat(full);
8936
+ if (!stat12.isFile() || stat12.size > maxBytes || stopped || signal.aborted) return;
8553
8937
  const file = await fs2.open(full, "r");
8554
8938
  try {
8555
8939
  let bytesReadTotal = 0;
@@ -8639,7 +9023,7 @@ async function runNative(input, base, mode, limit, signal) {
8639
9023
  if (stopped) return;
8640
9024
  if (DEFAULT_IGNORE3.includes(e.name)) continue;
8641
9025
  if (e.isSymbolicLink()) continue;
8642
- const full = path3.join(dir, e.name);
9026
+ const full = path12.join(dir, e.name);
8643
9027
  if (e.isDirectory()) {
8644
9028
  subdirs.push(full);
8645
9029
  } else if (e.isFile()) {
@@ -9186,56 +9570,56 @@ function jmespathSearch(data, query) {
9186
9570
  }
9187
9571
  function validateJsonSchema(data, schema) {
9188
9572
  const errors = [];
9189
- function check(value, s, path22) {
9573
+ function check(value, s, path23) {
9190
9574
  if (s["type"]) {
9191
9575
  const expectedType = s["type"];
9192
9576
  const actualType = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
9193
9577
  if (expectedType === "integer") {
9194
- if (!Number.isInteger(value)) errors.push(`${path22}: expected integer, got ${actualType}`);
9578
+ if (!Number.isInteger(value)) errors.push(`${path23}: expected integer, got ${actualType}`);
9195
9579
  } else if (expectedType !== actualType) {
9196
- errors.push(`${path22}: expected ${expectedType}, got ${actualType}`);
9580
+ errors.push(`${path23}: expected ${expectedType}, got ${actualType}`);
9197
9581
  }
9198
9582
  }
9199
9583
  if (typeof value === "string" && s["format"] === "uri" && value) {
9200
9584
  try {
9201
9585
  new URL(value);
9202
9586
  } catch {
9203
- errors.push(`${path22}: not a valid URI`);
9587
+ errors.push(`${path23}: not a valid URI`);
9204
9588
  }
9205
9589
  }
9206
9590
  if (typeof value === "string" && s["pattern"]) {
9207
9591
  const re = new RegExp(s["pattern"]);
9208
- if (!re.test(value)) errors.push(`${path22}: does not match pattern ${s["pattern"]}`);
9592
+ if (!re.test(value)) errors.push(`${path23}: does not match pattern ${s["pattern"]}`);
9209
9593
  }
9210
9594
  if (typeof value === "string" && s["minLength"] !== void 0 && value.length < s["minLength"]) {
9211
- errors.push(`${path22}: string too short (min ${s["minLength"]})`);
9595
+ errors.push(`${path23}: string too short (min ${s["minLength"]})`);
9212
9596
  }
9213
9597
  if (typeof value === "string" && s["maxLength"] !== void 0 && value.length > s["maxLength"]) {
9214
- errors.push(`${path22}: string too long (max ${s["maxLength"]})`);
9598
+ errors.push(`${path23}: string too long (max ${s["maxLength"]})`);
9215
9599
  }
9216
9600
  if (typeof value === "number" && s["minimum"] !== void 0 && value < s["minimum"]) {
9217
- errors.push(`${path22}: below minimum ${s["minimum"]}`);
9601
+ errors.push(`${path23}: below minimum ${s["minimum"]}`);
9218
9602
  }
9219
9603
  if (typeof value === "number" && s["maximum"] !== void 0 && value > s["maximum"]) {
9220
- errors.push(`${path22}: above maximum ${s["maximum"]}`);
9604
+ errors.push(`${path23}: above maximum ${s["maximum"]}`);
9221
9605
  }
9222
9606
  if (Array.isArray(value) && s["items"] && Array.isArray(s["items"])) {
9223
9607
  for (let i = 0; i < value.length; i++) {
9224
- check(value[i], s["items"], `${path22}[${i}]`);
9608
+ check(value[i], s["items"], `${path23}[${i}]`);
9225
9609
  }
9226
9610
  }
9227
9611
  if (typeof value === "object" && value !== null && !Array.isArray(value) && s["properties"]) {
9228
9612
  const props = s["properties"];
9229
9613
  for (const [k, propSchema] of Object.entries(props)) {
9230
- check(value[k], propSchema, `${path22}.${k}`);
9614
+ check(value[k], propSchema, `${path23}.${k}`);
9231
9615
  }
9232
9616
  }
9233
9617
  }
9234
9618
  check(data, schema, "$");
9235
9619
  return { valid: errors.length === 0, errors };
9236
9620
  }
9237
- function simpleQuery(data, path22) {
9238
- const parts = path22.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9621
+ function simpleQuery(data, path23) {
9622
+ const parts = path23.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
9239
9623
  let current = data;
9240
9624
  for (const part of parts) {
9241
9625
  if (current === null || current === void 0) return void 0;
@@ -10160,11 +10544,11 @@ var lintTool = {
10160
10544
  }
10161
10545
  };
10162
10546
  async function detectLinter(cwd) {
10163
- const { stat: stat11 } = await import('node:fs/promises');
10547
+ const { stat: stat12 } = await import('node:fs/promises');
10164
10548
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
10165
10549
  for (const f of checks) {
10166
10550
  try {
10167
- await stat11(`${cwd}/${f}`);
10551
+ await stat12(`${cwd}/${f}`);
10168
10552
  if (f.includes("biome")) return "biome";
10169
10553
  if (f.includes("eslint")) return "eslint";
10170
10554
  if (f.includes("tslint")) return "tslint";
@@ -10306,7 +10690,7 @@ async function dockerLogs(service, lines, filterRe, cwd, signal, since) {
10306
10690
  }
10307
10691
  var DOCKER_LOGS_TIMEOUT_MS = 3e3;
10308
10692
  var MAX_TAIL_LINES = 1e5;
10309
- async function fileLogs(path22, lines, filterRe, stream) {
10693
+ async function fileLogs(path23, lines, filterRe, stream) {
10310
10694
  const { createInterface } = await import('node:readline');
10311
10695
  const { createReadStream } = await import('node:fs');
10312
10696
  const entries = [];
@@ -10315,7 +10699,7 @@ async function fileLogs(path22, lines, filterRe, stream) {
10315
10699
  let writeIdx = 0;
10316
10700
  let totalLines = 0;
10317
10701
  const rl = createInterface({
10318
- input: createReadStream(path22),
10702
+ input: createReadStream(path23),
10319
10703
  crlfDelay: Number.POSITIVE_INFINITY
10320
10704
  });
10321
10705
  for await (const line of rl) {
@@ -10336,7 +10720,7 @@ async function fileLogs(path22, lines, filterRe, stream) {
10336
10720
  if (parsed) entries.push(parsed);
10337
10721
  }
10338
10722
  return {
10339
- source: path22,
10723
+ source: path23,
10340
10724
  entries,
10341
10725
  total: entries.length,
10342
10726
  truncated: totalLines > effLines,
@@ -10503,6 +10887,10 @@ var patchTool = {
10503
10887
  category: "Filesystem",
10504
10888
  description: "Apply a unified diff (patch) to the project. This is the correct tool when you have a diff that needs to be applied precisely, including handling of rejects.",
10505
10889
  usageHint: "Best used when you already have a diff (from generation, external source, or previous step).\n- Use `dry_run: true` to see what would happen without modifying files.\n- On failure it creates .rej and .orig files for manual review.\nOften cleaner than many small `edit` operations for larger changes.",
10890
+ selection: {
10891
+ doNotUseWhen: "you do not already have a unified diff or only need one precise replacement.",
10892
+ useInstead: ["edit"]
10893
+ },
10506
10894
  permission: "confirm",
10507
10895
  mutating: true,
10508
10896
  capabilities: ["fs.write"],
@@ -10524,12 +10912,13 @@ var patchTool = {
10524
10912
  const strip = Math.max(1, input.strip ?? 1);
10525
10913
  const dryRun = input.dry_run ?? false;
10526
10914
  const targets = extractDiffTargets(input.patch);
10915
+ const resolvedTargets = [];
10527
10916
  for (const t of targets) {
10528
10917
  const stripped = stripPathComponents(t, strip);
10529
10918
  if (!stripped) continue;
10530
- const candidate = path3.resolve(dir, stripped);
10531
- const rel = path3.relative(ctx.projectRoot, candidate);
10532
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
10919
+ const candidate = path12.resolve(dir, stripped);
10920
+ const rel = path12.relative(ctx.projectRoot, candidate);
10921
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
10533
10922
  return {
10534
10923
  applied: 0,
10535
10924
  rejected: 1,
@@ -10538,12 +10927,19 @@ var patchTool = {
10538
10927
  message: `patch refused: target "${t}" resolves outside project root`
10539
10928
  };
10540
10929
  }
10930
+ resolvedTargets.push(candidate);
10931
+ }
10932
+ const beforeContents = /* @__PURE__ */ new Map();
10933
+ if (!dryRun) {
10934
+ for (const target of resolvedTargets) {
10935
+ beforeContents.set(target, await readTextForTracking(target));
10936
+ }
10541
10937
  }
10542
- const tmpDir = await fs2.mkdtemp(path3.join(os2.tmpdir(), ".wstack_patch_"));
10938
+ const tmpDir = await fs2.mkdtemp(path12.join(os2.tmpdir(), ".wstack_patch_"));
10543
10939
  try {
10544
10940
  await fs2.chmod(tmpDir, 448).catch(() => {
10545
10941
  });
10546
- const patchFile = path3.join(tmpDir, "in.diff");
10942
+ const patchFile = path12.join(tmpDir, "in.diff");
10547
10943
  await fs2.writeFile(patchFile, input.patch, { mode: 384 });
10548
10944
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
10549
10945
  const result = await runPatch(args, dir, opts.signal);
@@ -10557,6 +10953,21 @@ var patchTool = {
10557
10953
  };
10558
10954
  }
10559
10955
  const patched = extractPatchedFiles(result.stdout);
10956
+ if (!dryRun) {
10957
+ for (const target of resolvedTargets) {
10958
+ const before = beforeContents.get(target) ?? null;
10959
+ const after = await readTextForTracking(target);
10960
+ if (after === null || after === before) continue;
10961
+ const stat12 = await fs2.stat(target).catch(() => null);
10962
+ if (stat12) ctx.recordRead?.(target, stat12.mtimeMs, "write", sha256hex(after));
10963
+ ctx.session?.recordFileChange?.({
10964
+ path: target,
10965
+ action: before === null ? "created" : "modified",
10966
+ before,
10967
+ after
10968
+ });
10969
+ }
10970
+ }
10560
10971
  return {
10561
10972
  applied: patched.length,
10562
10973
  rejected: 0,
@@ -10570,6 +10981,18 @@ var patchTool = {
10570
10981
  }
10571
10982
  }
10572
10983
  };
10984
+ var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
10985
+ async function readTextForTracking(absPath) {
10986
+ try {
10987
+ const stat12 = await fs2.stat(absPath);
10988
+ if (!stat12.isFile() || stat12.size > MAX_TRACKING_BYTES) return null;
10989
+ const buf = await fs2.readFile(absPath);
10990
+ if (buf.includes(0)) return null;
10991
+ return buf.toString("utf8");
10992
+ } catch {
10993
+ return null;
10994
+ }
10995
+ }
10573
10996
  function extractDiffTargets(patch) {
10574
10997
  const out = [];
10575
10998
  const re = /^\+\+\+\s+([^\t\r\n]+)/gm;
@@ -10877,6 +11300,10 @@ var readTool = {
10877
11300
  category: "Filesystem",
10878
11301
  description: "Read the contents of a file with line numbers. This is the primary way to inspect source code, configuration, or any text file before making changes. Lines are returned 1-indexed with a ` N| ` prefix for easy reference in edits.",
10879
11302
  usageHint: "FOUNDATIONAL TOOL \u2014 call this before almost any edit operation.\n\nBest practices:\n- Always read a file before using `edit`, `replace`, or `write` on it (the system often requires it for safety).\n- Use `offset` + `limit` for very large files instead of reading everything at once.\n- Default limit is generous (2000 lines) but can be increased.\n- The output format is designed to be directly usable as context for `edit` operations.",
11303
+ selection: {
11304
+ doNotUseWhen: "you need to search many files for matching content.",
11305
+ useInstead: ["grep"]
11306
+ },
10880
11307
  permission: "auto",
10881
11308
  mutating: false,
10882
11309
  capabilities: ["fs.read"],
@@ -10914,9 +11341,9 @@ var readTool = {
10914
11341
  });
10915
11342
  }
10916
11343
  const absPath = await safeResolveReal(input.path, ctx);
10917
- let stat11;
11344
+ let stat12;
10918
11345
  try {
10919
- stat11 = await fs2.stat(absPath);
11346
+ stat12 = await fs2.stat(absPath);
10920
11347
  } catch (err) {
10921
11348
  const code = err.code;
10922
11349
  if (code === "ENOENT") {
@@ -10935,7 +11362,7 @@ var readTool = {
10935
11362
  cause: err
10936
11363
  });
10937
11364
  }
10938
- if (!stat11.isFile()) {
11365
+ if (!stat12.isFile()) {
10939
11366
  throw new FsError({
10940
11367
  message: `read: "${input.path}" is not a regular file`,
10941
11368
  code: "FS_READ_FAILED",
@@ -10943,22 +11370,22 @@ var readTool = {
10943
11370
  context: { reason: "not-a-regular-file" }
10944
11371
  });
10945
11372
  }
10946
- if (stat11.size > MAX_BYTES2) {
11373
+ if (stat12.size > MAX_BYTES2) {
10947
11374
  throw new FsError({
10948
- message: `read: file too large (${stat11.size} bytes, limit ${MAX_BYTES2})`,
11375
+ message: `read: file too large (${stat12.size} bytes, limit ${MAX_BYTES2})`,
10949
11376
  code: "FS_READ_FAILED",
10950
11377
  path: absPath,
10951
- context: { size: stat11.size, limit: MAX_BYTES2, reason: "too-large" }
11378
+ context: { size: stat12.size, limit: MAX_BYTES2, reason: "too-large" }
10952
11379
  });
10953
11380
  }
10954
11381
  const offset = Math.max(1, input.offset ?? 1);
10955
11382
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
10956
11383
  const prior = getReadRangeRecord(ctx, absPath);
10957
11384
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
10958
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat11.mtimeMs, offset, requestedEnd)) {
10959
- ctx.recordRead(absPath, stat11.mtimeMs);
11385
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat12.mtimeMs, offset, requestedEnd)) {
11386
+ ctx.recordRead(absPath, stat12.mtimeMs);
10960
11387
  return {
10961
- text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat11.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
11388
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat12.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
10962
11389
  total_lines: prior.totalLines,
10963
11390
  encoding: "utf8",
10964
11391
  truncated: requestedEnd < prior.totalLines,
@@ -10971,13 +11398,14 @@ var readTool = {
10971
11398
  throw new Error(`read: "${input.path}" appears to be binary`);
10972
11399
  }
10973
11400
  const text = buf.toString("utf8");
11401
+ const contentHash = sha256hex(text);
10974
11402
  const allLines = text.split(/\r\n|\r|\n/);
10975
11403
  const total = allLines.length;
10976
11404
  if (input.mode === "summary") {
10977
- ctx.recordRead(absPath, stat11.mtimeMs);
10978
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, Math.min(total, 200));
11405
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11406
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, Math.min(total, 200));
10979
11407
  return {
10980
- text: summarizeFile(input.path, stat11.size, allLines),
11408
+ text: summarizeFile(input.path, stat12.size, allLines),
10981
11409
  total_lines: total,
10982
11410
  encoding: "utf8",
10983
11411
  truncated: total > 200,
@@ -10985,13 +11413,13 @@ var readTool = {
10985
11413
  };
10986
11414
  }
10987
11415
  if (limit === 0) {
10988
- ctx.recordRead(absPath, stat11.mtimeMs);
10989
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, 0);
11416
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11417
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, 1, 0);
10990
11418
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
10991
11419
  }
10992
11420
  if (offset > total) {
10993
- ctx.recordRead(absPath, stat11.mtimeMs);
10994
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, total + 1, total + 1);
11421
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11422
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, total + 1, total + 1);
10995
11423
  return {
10996
11424
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
10997
11425
  total_lines: total,
@@ -11003,8 +11431,8 @@ var readTool = {
11003
11431
  const truncated = offset - 1 + slice.length < total;
11004
11432
  const width = String(offset + slice.length - 1).length;
11005
11433
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
11006
- ctx.recordRead(absPath, stat11.mtimeMs);
11007
- rememberReadRange(ctx, absPath, stat11.mtimeMs, total, offset, offset + slice.length - 1);
11434
+ ctx.recordRead(absPath, stat12.mtimeMs, "user", contentHash);
11435
+ rememberReadRange(ctx, absPath, stat12.mtimeMs, total, offset, offset + slice.length - 1);
11008
11436
  return {
11009
11437
  text: numbered,
11010
11438
  total_lines: total,
@@ -11146,10 +11574,10 @@ var replaceTool = {
11146
11574
  } catch {
11147
11575
  continue;
11148
11576
  }
11149
- const rel = path3.relative(realRoot, realPath);
11150
- if (rel.startsWith("..") || path3.isAbsolute(rel)) continue;
11151
- const stat11 = await fs2.stat(realPath).catch(() => null);
11152
- if (!stat11?.isFile()) continue;
11577
+ const rel = path12.relative(realRoot, realPath);
11578
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) continue;
11579
+ const stat12 = await fs2.stat(realPath).catch(() => null);
11580
+ if (!stat12?.isFile()) continue;
11153
11581
  let content;
11154
11582
  try {
11155
11583
  const buf = await fs2.readFile(realPath);
@@ -11174,7 +11602,17 @@ var replaceTool = {
11174
11602
  totalReplacements += count;
11175
11603
  if (!dryRun) {
11176
11604
  const newContent = toStyle(newContentLf, style);
11177
- await atomicWrite(realPath, newContent, { mode: stat11.mode & 511 });
11605
+ await atomicWrite(realPath, newContent, { mode: stat12.mode & 511 });
11606
+ const written = await fs2.stat(realPath).catch(() => null);
11607
+ if (written) {
11608
+ ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
11609
+ }
11610
+ ctx.session?.recordFileChange?.({
11611
+ path: realPath,
11612
+ action: "modified",
11613
+ before: content,
11614
+ after: newContent
11615
+ });
11178
11616
  }
11179
11617
  const diff = dryRun || matches.length > 0 ? unifiedDiff(content, toStyle(newContentLf, style), {
11180
11618
  fromFile: absPath,
@@ -11204,8 +11642,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
11204
11642
  const resolved = [];
11205
11643
  for (const p of parts) {
11206
11644
  const absPath = safeResolve(p, ctx);
11207
- const stat11 = await fs2.stat(absPath).catch(() => null);
11208
- if (stat11?.isFile()) {
11645
+ const stat12 = await fs2.stat(absPath).catch(() => null);
11646
+ if (stat12?.isFile()) {
11209
11647
  resolved.push(absPath);
11210
11648
  }
11211
11649
  }
@@ -11266,10 +11704,10 @@ async function globNative(pattern, base, extraGlob) {
11266
11704
  }
11267
11705
  for (const e of entries) {
11268
11706
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
11269
- const full = path3.join(dir, e.name);
11707
+ const full = path12.join(dir, e.name);
11270
11708
  try {
11271
- const stat11 = await fs2.lstat(full);
11272
- if (stat11.isSymbolicLink()) continue;
11709
+ const stat12 = await fs2.lstat(full);
11710
+ if (stat12.isSymbolicLink()) continue;
11273
11711
  } catch {
11274
11712
  continue;
11275
11713
  }
@@ -11437,16 +11875,16 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
11437
11875
  let filesCreated = 0;
11438
11876
  for (const [filePath, content] of Object.entries(templateFiles)) {
11439
11877
  const resolvedPath = substituteVars(filePath, name, vars);
11440
- const joinedPath = path3.join(cwd, resolvedPath);
11441
- const root = path3.resolve(ctx.projectRoot);
11442
- const target = path3.resolve(joinedPath);
11443
- const rel = path3.relative(root, target);
11444
- if (rel.startsWith("..") || path3.isAbsolute(rel)) {
11878
+ const joinedPath = path12.join(cwd, resolvedPath);
11879
+ const root = path12.resolve(ctx.projectRoot);
11880
+ const target = path12.resolve(joinedPath);
11881
+ const rel = path12.relative(root, target);
11882
+ if (rel.startsWith("..") || path12.isAbsolute(rel)) {
11445
11883
  throw new Error(`scaffold: generated path "${resolvedPath}" would escape project root`);
11446
11884
  }
11447
11885
  const fullPath = target;
11448
11886
  if (!dryRun) {
11449
- await fs2.mkdir(path3.dirname(fullPath), { recursive: true });
11887
+ await fs2.mkdir(path12.dirname(fullPath), { recursive: true });
11450
11888
  await atomicWrite(fullPath, substituteVars(content, name, vars));
11451
11889
  }
11452
11890
  files.push(resolvedPath);
@@ -12346,11 +12784,11 @@ var testTool = {
12346
12784
  }
12347
12785
  };
12348
12786
  async function detectRunner(cwd) {
12349
- const { stat: stat11 } = await import('node:fs/promises');
12787
+ const { stat: stat12 } = await import('node:fs/promises');
12350
12788
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
12351
12789
  for (const f of candidates) {
12352
12790
  try {
12353
- await stat11(path3.join(cwd, f));
12791
+ await stat12(path12.join(cwd, f));
12354
12792
  if (f.includes("vitest")) return "vitest";
12355
12793
  if (f.includes("jest")) return "jest";
12356
12794
  if (f.includes("mocha")) return "mocha";
@@ -12978,7 +13416,7 @@ async function walkDir(dir, depth, opts) {
12978
13416
  opts.lines.push(opts.prefix + branch + displayName);
12979
13417
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
12980
13418
  const childPrefix = opts.prefix + connector;
12981
- await walkDir(path3.join(dir, entry.name), depth + 1, {
13419
+ await walkDir(path12.join(dir, entry.name), depth + 1, {
12982
13420
  ...opts,
12983
13421
  prefix: childPrefix,
12984
13422
  isLast
@@ -13064,12 +13502,12 @@ var typecheckTool = {
13064
13502
  }
13065
13503
  };
13066
13504
  async function findTsConfig(cwd) {
13067
- const { stat: stat11 } = await import('node:fs/promises');
13505
+ const { stat: stat12 } = await import('node:fs/promises');
13068
13506
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
13069
13507
  for (const f of candidates) {
13070
13508
  try {
13071
- const s = await stat11(path3.join(cwd, f));
13072
- if (s.isFile()) return path3.join(cwd, f);
13509
+ const s = await stat12(path12.join(cwd, f));
13510
+ if (s.isFile()) return path12.join(cwd, f);
13073
13511
  } catch {
13074
13512
  }
13075
13513
  }
@@ -13080,6 +13518,10 @@ var writeTool = {
13080
13518
  category: "Filesystem",
13081
13519
  description: "Write or completely overwrite a file on disk. This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, because `edit` is safer and works on the last-read version of the file.",
13082
13520
  usageHint: "RULES FOR CORRECT USAGE:\n- Use `write` primarily for **new files** or when you want to replace the entire content.\n- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\n- You MUST have called `read` on the file earlier in the conversation before using `write` on an existing path (the system enforces this for safety).\n- The path is resolved relative to the project root and protected against escaping the workspace.",
13521
+ selection: {
13522
+ doNotUseWhen: "making a precise change to part of an existing file.",
13523
+ useInstead: ["edit"]
13524
+ },
13083
13525
  permission: "confirm",
13084
13526
  mutating: true,
13085
13527
  timeoutMs: 5e3,
@@ -13099,10 +13541,10 @@ var writeTool = {
13099
13541
  },
13100
13542
  required: ["path", "content"]
13101
13543
  },
13102
- async execute(input, ctx) {
13103
- return writeFile7(input, ctx);
13544
+ async execute(input, ctx, opts) {
13545
+ return writeFile7(input, ctx, opts?.signal);
13104
13546
  },
13105
- async *executeStream(input, ctx) {
13547
+ async *executeStream(input, ctx, opts) {
13106
13548
  const prepared = await prepareWrite(input, ctx);
13107
13549
  if (!prepared.existed) {
13108
13550
  for (const line of input.content.split("\n")) {
@@ -13110,11 +13552,11 @@ var writeTool = {
13110
13552
  `, data: { livePreview: true } };
13111
13553
  }
13112
13554
  }
13113
- yield { type: "final", output: await finishWrite(input, ctx, prepared) };
13555
+ yield { type: "final", output: await finishWrite(input, ctx, prepared, opts?.signal) };
13114
13556
  }
13115
13557
  };
13116
- async function writeFile7(input, ctx) {
13117
- return finishWrite(input, ctx, await prepareWrite(input, ctx));
13558
+ async function writeFile7(input, ctx, signal) {
13559
+ return finishWrite(input, ctx, await prepareWrite(input, ctx), signal);
13118
13560
  }
13119
13561
  async function prepareWrite(input, ctx) {
13120
13562
  if (!input?.path) {
@@ -13133,12 +13575,12 @@ async function prepareWrite(input, ctx) {
13133
13575
  let existed = false;
13134
13576
  let prev = "";
13135
13577
  try {
13136
- const stat11 = await fs2.stat(absPath);
13137
- existed = stat11.isFile();
13578
+ const stat12 = await fs2.stat(absPath);
13579
+ existed = stat12.isFile();
13138
13580
  if (existed) {
13139
13581
  if (!ctx.hasRead(absPath)) {
13140
13582
  prev = await fs2.readFile(absPath, "utf8");
13141
- ctx.recordRead(absPath, stat11.mtimeMs, "write");
13583
+ ctx.recordRead(absPath, stat12.mtimeMs, "write", sha256hex(prev));
13142
13584
  } else {
13143
13585
  prev = await fs2.readFile(absPath, "utf8");
13144
13586
  }
@@ -13150,23 +13592,32 @@ async function prepareWrite(input, ctx) {
13150
13592
  }
13151
13593
  return { absPath, existed, prev };
13152
13594
  }
13153
- async function finishWrite(input, ctx, prepared) {
13595
+ async function finishWrite(input, ctx, prepared, signal) {
13596
+ signal?.throwIfAborted();
13154
13597
  await atomicWrite(prepared.absPath, input.content);
13155
13598
  const diff = prepared.existed ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
13156
13599
  + (new file, ${input.content.split("\n").length} lines)`;
13157
- const stat11 = await fs2.stat(prepared.absPath);
13158
- ctx.recordRead(prepared.absPath, stat11.mtimeMs, "write");
13600
+ const stat12 = await fs2.stat(prepared.absPath);
13601
+ ctx.recordRead(prepared.absPath, stat12.mtimeMs, "write", sha256hex(input.content));
13159
13602
  ctx.session.recordFileChange({
13160
13603
  path: prepared.absPath,
13161
13604
  action: prepared.existed ? "modified" : "created",
13162
13605
  before: prepared.existed ? prepared.prev : null,
13163
13606
  after: input.content
13164
13607
  });
13608
+ const syntax = await checkSyntax(
13609
+ prepared.absPath,
13610
+ input.content,
13611
+ prepared.existed ? prepared.prev : void 0
13612
+ ).catch(() => void 0);
13613
+ const hasSyntaxErrors = syntax !== void 0 && syntax.errors.length > 0;
13165
13614
  return {
13166
13615
  path: prepared.absPath,
13167
13616
  bytes_written: Buffer.byteLength(input.content, "utf8"),
13168
13617
  created: !prepared.existed,
13169
- diff
13618
+ diff,
13619
+ syntax_errors: hasSyntaxErrors ? syntax.errors : void 0,
13620
+ note: hasSyntaxErrors ? syntax.preExisting ? "Syntax check: the file still has parse errors (they pre-date this write) \u2014 see syntax_errors." : `Syntax check: the written content has ${syntax.errors.length} parse error(s) \u2014 fix them now, see syntax_errors.` : void 0
13170
13621
  };
13171
13622
  }
13172
13623