@wrongstack/tools 0.283.1 → 0.284.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/audit.js.map +1 -1
  2. package/dist/bash.js +4 -0
  3. package/dist/bash.js.map +1 -1
  4. package/dist/builtin.js +685 -253
  5. package/dist/builtin.js.map +1 -1
  6. package/dist/codebase-index/index.js +2 -2
  7. package/dist/codebase-index/index.js.map +1 -1
  8. package/dist/diff.js.map +1 -1
  9. package/dist/document.js.map +1 -1
  10. package/dist/edit.d.ts +43 -0
  11. package/dist/edit.js +386 -60
  12. package/dist/edit.js.map +1 -1
  13. package/dist/{exec-Ca3fnpUh.d.ts → exec-2OKoT6tk.d.ts} +1 -1
  14. package/dist/exec.d.ts +1 -1
  15. package/dist/exec.js +13 -14
  16. package/dist/exec.js.map +1 -1
  17. package/dist/fetch.js.map +1 -1
  18. package/dist/format.js.map +1 -1
  19. package/dist/git.js.map +1 -1
  20. package/dist/glob.js +10 -1
  21. package/dist/glob.js.map +1 -1
  22. package/dist/grep.js +4 -0
  23. package/dist/grep.js.map +1 -1
  24. package/dist/index.d.ts +13 -2
  25. package/dist/index.js +730 -268
  26. package/dist/index.js.map +1 -1
  27. package/dist/install.js.map +1 -1
  28. package/dist/json.js.map +1 -1
  29. package/dist/lint.js.map +1 -1
  30. package/dist/logs.js.map +1 -1
  31. package/dist/next-steps.d.ts +66 -0
  32. package/dist/next-steps.js +116 -0
  33. package/dist/next-steps.js.map +1 -0
  34. package/dist/outdated.js.map +1 -1
  35. package/dist/pack.js +685 -253
  36. package/dist/pack.js.map +1 -1
  37. package/dist/patch.js +43 -0
  38. package/dist/patch.js.map +1 -1
  39. package/dist/read.js +13 -4
  40. package/dist/read.js.map +1 -1
  41. package/dist/replace.js +14 -0
  42. package/dist/replace.js.map +1 -1
  43. package/dist/scaffold.js.map +1 -1
  44. package/dist/test.js.map +1 -1
  45. package/dist/tool-diff.d.ts +88 -0
  46. package/dist/tool-diff.js +229 -0
  47. package/dist/tool-diff.js.map +1 -0
  48. package/dist/tool-summary.d.ts +24 -0
  49. package/dist/tool-summary.js +208 -0
  50. package/dist/tool-summary.js.map +1 -0
  51. package/dist/tree.js.map +1 -1
  52. package/dist/typecheck.js.map +1 -1
  53. package/dist/write.d.ts +6 -0
  54. package/dist/write.js +118 -19
  55. package/dist/write.js.map +1 -1
  56. package/package.json +15 -2
package/dist/write.js CHANGED
@@ -1,26 +1,112 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as Core from '@wrongstack/core';
3
3
  import { ToolValidationError, atomicWrite, unifiedDiff } from '@wrongstack/core';
4
- import * as path from 'node:path';
4
+ import * as path2 from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
 
6
7
  // src/write.ts
8
+ var TS_LIKE = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
9
+ var MAX_CHECK_CHARS = 15e5;
10
+ var MAX_ERRORS = 5;
11
+ var tsLoad = null;
12
+ function loadTypescript() {
13
+ tsLoad ??= import('typescript').then(
14
+ (m) => m.default ?? m,
15
+ () => null
16
+ );
17
+ return tsLoad;
18
+ }
19
+ async function checkSyntax(filePath, content, previousContent) {
20
+ const errors = await parseErrors(filePath, content);
21
+ if (errors === void 0) return void 0;
22
+ if (errors.length === 0) return { errors, preExisting: false };
23
+ let preExisting = false;
24
+ if (previousContent !== void 0) {
25
+ const prevErrors = await parseErrors(filePath, previousContent);
26
+ preExisting = prevErrors !== void 0 && prevErrors.length > 0;
27
+ }
28
+ return { errors, preExisting };
29
+ }
30
+ function isJsoncFile(filePath) {
31
+ const base = path2.basename(filePath).toLowerCase();
32
+ if (base.endsWith(".jsonc")) return true;
33
+ if (/^(tsconfig|jsconfig)([.-].*)?\.json$/.test(base)) return true;
34
+ const dir = path2.basename(path2.dirname(filePath)).toLowerCase();
35
+ return dir === ".vscode";
36
+ }
37
+ async function parseErrors(filePath, content) {
38
+ if (content.length > MAX_CHECK_CHARS) return void 0;
39
+ const ext = path2.extname(filePath).toLowerCase();
40
+ if (ext === ".json" || ext === ".jsonc") {
41
+ try {
42
+ JSON.parse(content);
43
+ return [];
44
+ } catch (err) {
45
+ if (isJsoncFile(filePath)) {
46
+ const ts2 = await loadTypescript();
47
+ if (ts2) {
48
+ const jsonc = ts2.parseConfigFileTextToJson(filePath, content);
49
+ if (!jsonc.error) return [];
50
+ return [formatDiag(ts2, jsonc.error, content)];
51
+ }
52
+ }
53
+ return [`JSON parse error: ${err.message}`];
54
+ }
55
+ }
56
+ if (!TS_LIKE.has(ext)) return void 0;
57
+ const ts = await loadTypescript();
58
+ if (!ts) return void 0;
59
+ const scriptKind = ext === ".tsx" ? ts.ScriptKind.TSX : ext === ".ts" || ext === ".mts" || ext === ".cts" ? ts.ScriptKind.TS : (
60
+ // Plain JS may legitimately contain JSX; the JSX grammar is a
61
+ // superset for untyped code, so parsing .js/.jsx as JSX avoids
62
+ // false positives on React files.
63
+ ts.ScriptKind.JSX
64
+ );
65
+ const sourceFile = ts.createSourceFile(
66
+ path2.basename(filePath),
67
+ content,
68
+ ts.ScriptTarget.Latest,
69
+ /* setParentNodes */
70
+ false,
71
+ scriptKind
72
+ );
73
+ const diags = sourceFile.parseDiagnostics ?? [];
74
+ return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts, d, content, sourceFile));
75
+ }
76
+ function formatDiag(ts, diag, content, sourceFile) {
77
+ const message = ts.flattenDiagnosticMessageText(diag.messageText, " ");
78
+ if (diag.start === void 0) return message;
79
+ let line;
80
+ if (sourceFile) {
81
+ line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;
82
+ } else {
83
+ line = 1;
84
+ for (let i = 0; i < diag.start && i < content.length; i++) {
85
+ if (content.charCodeAt(i) === 10) line++;
86
+ }
87
+ }
88
+ return `line ${line}: ${message}`;
89
+ }
90
+ function sha256hex(content) {
91
+ return createHash("sha256").update(content, "utf8").digest("hex");
92
+ }
7
93
  function resolvePath(input, ctx) {
8
- return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);
94
+ return path2.isAbsolute(input) ? path2.normalize(input) : path2.resolve(ctx.workingDir ?? ctx.cwd, input);
9
95
  }
10
96
  function allowedRoots(ctx) {
11
- return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];
97
+ return [path2.resolve(ctx.projectRoot), path2.resolve(Core.wstackGlobalRoot())];
12
98
  }
13
99
  function isInsideAny(target, roots) {
14
100
  return roots.some((root) => {
15
- const rel = path.relative(root, target);
16
- return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
101
+ const rel = path2.relative(root, target);
102
+ return rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel);
17
103
  });
18
104
  }
19
105
  function ensureInsideRoot(absPath, ctx) {
20
- const target = path.resolve(absPath);
106
+ const target = path2.resolve(absPath);
21
107
  if (ctx.allowOutsideProjectRoot) return target;
22
108
  if (isInsideAny(target, allowedRoots(ctx))) return target;
23
- throw new Error(`Path "${absPath}" is outside project root "${path.resolve(ctx.projectRoot)}"`);
109
+ throw new Error(`Path "${absPath}" is outside project root "${path2.resolve(ctx.projectRoot)}"`);
24
110
  }
25
111
  function safeResolve(input, ctx) {
26
112
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
@@ -28,7 +114,7 @@ function safeResolve(input, ctx) {
28
114
  async function assertRealInsideRoot(absPath, ctx) {
29
115
  if (ctx.allowOutsideProjectRoot) return;
30
116
  const realRoots = await Promise.all(
31
- allowedRoots(ctx).map((r) => fs.realpath(r).catch(() => path.resolve(r)))
117
+ allowedRoots(ctx).map((r) => fs.realpath(r).catch(() => path2.resolve(r)))
32
118
  );
33
119
  let probe = absPath;
34
120
  for (; ; ) {
@@ -37,7 +123,7 @@ async function assertRealInsideRoot(absPath, ctx) {
37
123
  real = await fs.realpath(probe);
38
124
  } catch (err) {
39
125
  if (err.code === "ENOENT") {
40
- const parent = path.dirname(probe);
126
+ const parent = path2.dirname(probe);
41
127
  if (parent === probe) return;
42
128
  probe = parent;
43
129
  continue;
@@ -62,6 +148,10 @@ var writeTool = {
62
148
  category: "Filesystem",
63
149
  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.",
64
150
  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.",
151
+ selection: {
152
+ doNotUseWhen: "making a precise change to part of an existing file.",
153
+ useInstead: ["edit"]
154
+ },
65
155
  permission: "confirm",
66
156
  mutating: true,
67
157
  timeoutMs: 5e3,
@@ -81,10 +171,10 @@ var writeTool = {
81
171
  },
82
172
  required: ["path", "content"]
83
173
  },
84
- async execute(input, ctx) {
85
- return writeFile(input, ctx);
174
+ async execute(input, ctx, opts) {
175
+ return writeFile(input, ctx, opts?.signal);
86
176
  },
87
- async *executeStream(input, ctx) {
177
+ async *executeStream(input, ctx, opts) {
88
178
  const prepared = await prepareWrite(input, ctx);
89
179
  if (!prepared.existed) {
90
180
  for (const line of input.content.split("\n")) {
@@ -92,11 +182,11 @@ var writeTool = {
92
182
  `, data: { livePreview: true } };
93
183
  }
94
184
  }
95
- yield { type: "final", output: await finishWrite(input, ctx, prepared) };
185
+ yield { type: "final", output: await finishWrite(input, ctx, prepared, opts?.signal) };
96
186
  }
97
187
  };
98
- async function writeFile(input, ctx) {
99
- return finishWrite(input, ctx, await prepareWrite(input, ctx));
188
+ async function writeFile(input, ctx, signal) {
189
+ return finishWrite(input, ctx, await prepareWrite(input, ctx), signal);
100
190
  }
101
191
  async function prepareWrite(input, ctx) {
102
192
  if (!input?.path) {
@@ -120,7 +210,7 @@ async function prepareWrite(input, ctx) {
120
210
  if (existed) {
121
211
  if (!ctx.hasRead(absPath)) {
122
212
  prev = await fs.readFile(absPath, "utf8");
123
- ctx.recordRead(absPath, stat2.mtimeMs, "write");
213
+ ctx.recordRead(absPath, stat2.mtimeMs, "write", sha256hex(prev));
124
214
  } else {
125
215
  prev = await fs.readFile(absPath, "utf8");
126
216
  }
@@ -132,23 +222,32 @@ async function prepareWrite(input, ctx) {
132
222
  }
133
223
  return { absPath, existed, prev };
134
224
  }
135
- async function finishWrite(input, ctx, prepared) {
225
+ async function finishWrite(input, ctx, prepared, signal) {
226
+ signal?.throwIfAborted();
136
227
  await atomicWrite(prepared.absPath, input.content);
137
228
  const diff = prepared.existed ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
138
229
  + (new file, ${input.content.split("\n").length} lines)`;
139
230
  const stat2 = await fs.stat(prepared.absPath);
140
- ctx.recordRead(prepared.absPath, stat2.mtimeMs, "write");
231
+ ctx.recordRead(prepared.absPath, stat2.mtimeMs, "write", sha256hex(input.content));
141
232
  ctx.session.recordFileChange({
142
233
  path: prepared.absPath,
143
234
  action: prepared.existed ? "modified" : "created",
144
235
  before: prepared.existed ? prepared.prev : null,
145
236
  after: input.content
146
237
  });
238
+ const syntax = await checkSyntax(
239
+ prepared.absPath,
240
+ input.content,
241
+ prepared.existed ? prepared.prev : void 0
242
+ ).catch(() => void 0);
243
+ const hasSyntaxErrors = syntax !== void 0 && syntax.errors.length > 0;
147
244
  return {
148
245
  path: prepared.absPath,
149
246
  bytes_written: Buffer.byteLength(input.content, "utf8"),
150
247
  created: !prepared.existed,
151
- diff
248
+ diff,
249
+ syntax_errors: hasSyntaxErrors ? syntax.errors : void 0,
250
+ 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
152
251
  };
153
252
  }
154
253
 
package/dist/write.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/_util.ts","../src/write.ts"],"names":["fsp","stat"],"mappings":"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","file":"write.js","sourcesContent":["import * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport { atomicWrite, ToolValidationError, unifiedDiff } from '@wrongstack/core';\nimport type { Context, Tool } from '@wrongstack/core';\nimport { safeResolveReal } from './_util.js';\n\ninterface WriteInput {\n path: string;\n content: string;\n}\n\ninterface WriteOutput {\n path: string;\n bytes_written: number;\n created: boolean;\n diff?: string | undefined;\n}\n\nexport const writeTool: Tool<WriteInput, WriteOutput> = {\n name: 'write',\n category: 'Filesystem',\n description:\n 'Write or completely overwrite a file on disk. ' +\n 'This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, ' +\n 'because `edit` is safer and works on the last-read version of the file.',\n usageHint:\n 'RULES FOR CORRECT USAGE:\\n' +\n '- Use `write` primarily for **new files** or when you want to replace the entire content.\\n' +\n '- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\\n' +\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' +\n '- The path is resolved relative to the project root and protected against escaping the workspace.',\n permission: 'confirm',\n mutating: true,\n timeoutMs: 5_000,\n capabilities: ['fs.write'],\n icon: 'file',\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Relative path from project root. Must not escape the project.',\n },\n content: {\n type: 'string',\n description: 'The complete new content of the file.',\n },\n },\n required: ['path', 'content'],\n },\n async execute(input, ctx) {\n return writeFile(input, ctx);\n },\n async *executeStream(input, ctx) {\n const prepared = await prepareWrite(input, ctx);\n if (!prepared.existed) {\n for (const line of input.content.split('\\n')) {\n yield { type: 'partial_output', text: `${line}\\n`, data: { livePreview: true } };\n }\n }\n yield { type: 'final', output: await finishWrite(input, ctx, prepared) };\n },\n};\n\ntype PreparedWrite = {\n absPath: string;\n existed: boolean;\n prev: string;\n};\n\nasync function writeFile(input: WriteInput, ctx: Context): Promise<WriteOutput> {\n return finishWrite(input, ctx, await prepareWrite(input, ctx));\n}\n\nasync function prepareWrite(input: WriteInput, ctx: Context): Promise<PreparedWrite> {\n if (!input?.path) {\n throw new ToolValidationError({\n message: 'write: path is required',\n field: 'path',\n });\n }\n if (input.content === undefined) {\n throw new ToolValidationError({\n message: 'write: content is required',\n field: 'content',\n });\n }\n const absPath = await safeResolveReal(input.path, ctx);\n\n let existed = false;\n let prev = '';\n try {\n const stat = await fs.stat(absPath);\n existed = stat.isFile();\n if (existed) {\n if (!ctx.hasRead(absPath)) {\n // User approved this write (confirm → yes/always) but ctx has no\n // read record. The model may call write without a prior explicit\n // read. Read the file now so we can compute the diff and honor\n // the user's intent to overwrite. Tag as 'write' (NOT 'user') so\n // this internal read-for-diff does not widen the permission bypass\n // — the user never saw the old content (P1 #1).\n prev = await fs.readFile(absPath, 'utf8');\n ctx.recordRead(absPath, stat.mtimeMs, 'write');\n } else {\n prev = await fs.readFile(absPath, 'utf8');\n }\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n return { absPath, existed, prev };\n}\n\nasync function finishWrite(\n input: WriteInput,\n ctx: Context,\n prepared: PreparedWrite,\n): Promise<WriteOutput> {\n await atomicWrite(prepared.absPath, input.content);\n\n const diff = prepared.existed\n ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path })\n : `+++ ${input.path}\\n+ (new file, ${input.content.split('\\n').length} lines)`;\n\n const stat = await fs.stat(prepared.absPath);\n // Tag as 'write' so the permission bypass does not auto-approve a later\n // write to this path — the user approved THIS write, not future ones\n // (P1 #1).\n ctx.recordRead(prepared.absPath, stat.mtimeMs, 'write');\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: prepared.absPath,\n action: prepared.existed ? 'modified' : 'created',\n before: prepared.existed ? prepared.prev : null,\n after: input.content,\n });\n\n return {\n path: prepared.absPath,\n bytes_written: Buffer.byteLength(input.content, 'utf8'),\n created: !prepared.existed,\n diff,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/_syntax-check.ts","../src/_util.ts","../src/write.ts"],"names":["path","ts","fsp","stat"],"mappings":"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","file":"write.js","sourcesContent":["/**\n * Post-edit syntax validation for `edit` and `write`.\n *\n * After a mutation lands on disk, the new content is parsed (TS/JS via the\n * TypeScript compiler's parser, JSON via JSON.parse with a JSONC fallback)\n * and any parse errors are surfaced in the tool output. The edit is NOT\n * rolled back — reverting would desynchronize the model's picture of the\n * file — but the errors come back in the same turn so the model fixes the\n * breakage before the user ever opens a broken file.\n *\n * The check is purely syntactic (no type checking, no project program), so\n * it costs single-digit milliseconds per file. Semantic type errors remain\n * the `type-gate` plugin's job.\n */\nimport * as path from 'node:path';\n\nconst TS_LIKE = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']);\n/** Skip pathologically large files — the model never writes these in one go. */\nconst MAX_CHECK_CHARS = 1_500_000;\n/** Cap reported errors so a mangled file doesn't flood the context. */\nconst MAX_ERRORS = 5;\n\ntype Ts = typeof import('typescript');\n\nlet tsLoad: Promise<Ts | null> | null = null;\n/** Lazy-load the TypeScript compiler (already a dependency via the codebase\n * indexer) so tools that never touch TS files don't pay its import cost. */\nfunction loadTypescript(): Promise<Ts | null> {\n tsLoad ??= import('typescript').then(\n (m) => ((m as unknown as { default?: Ts }).default ?? m) as Ts,\n () => null,\n );\n return tsLoad;\n}\n\nexport interface SyntaxCheckResult {\n /** Parse errors in the new content (capped at {@link MAX_ERRORS}). */\n errors: string[];\n /** True when the pre-edit content already failed to parse — the edit did\n * not introduce the breakage (though it didn't fix it either). */\n preExisting: boolean;\n}\n\n/**\n * Check `content` for syntax errors based on the file extension.\n * Returns `undefined` when the file type is not checkable (or the checker\n * is unavailable) and a result with an empty `errors` array when clean.\n * Pass `previousContent` to distinguish newly-introduced errors from\n * pre-existing ones.\n */\nexport async function checkSyntax(\n filePath: string,\n content: string,\n previousContent?: string,\n): Promise<SyntaxCheckResult | undefined> {\n const errors = await parseErrors(filePath, content);\n if (errors === undefined) return undefined;\n if (errors.length === 0) return { errors, preExisting: false };\n let preExisting = false;\n if (previousContent !== undefined) {\n const prevErrors = await parseErrors(filePath, previousContent);\n preExisting = prevErrors !== undefined && prevErrors.length > 0;\n }\n return { errors, preExisting };\n}\n\n/** Files that are conventionally JSONC (comments + trailing commas allowed). */\nfunction isJsoncFile(filePath: string): boolean {\n const base = path.basename(filePath).toLowerCase();\n if (base.endsWith('.jsonc')) return true;\n if (/^(tsconfig|jsconfig)([.-].*)?\\.json$/.test(base)) return true;\n const dir = path.basename(path.dirname(filePath)).toLowerCase();\n return dir === '.vscode';\n}\n\nasync function parseErrors(filePath: string, content: string): Promise<string[] | undefined> {\n if (content.length > MAX_CHECK_CHARS) return undefined;\n const ext = path.extname(filePath).toLowerCase();\n\n if (ext === '.json' || ext === '.jsonc') {\n try {\n JSON.parse(content);\n return [];\n } catch (err) {\n // Known-JSONC files (tsconfig.json, VS Code settings, *.jsonc) accept\n // comments and trailing commas — re-parse with the TS config parser\n // before declaring them broken. Plain .json stays strict: a trailing\n // comma there IS an error the model should fix.\n if (isJsoncFile(filePath)) {\n const ts = await loadTypescript();\n if (ts) {\n const jsonc = ts.parseConfigFileTextToJson(filePath, content);\n if (!jsonc.error) return [];\n return [formatDiag(ts, jsonc.error, content)];\n }\n }\n return [`JSON parse error: ${(err as Error).message}`];\n }\n }\n\n if (!TS_LIKE.has(ext)) return undefined;\n const ts = await loadTypescript();\n if (!ts) return undefined;\n\n const scriptKind =\n ext === '.tsx'\n ? ts.ScriptKind.TSX\n : ext === '.ts' || ext === '.mts' || ext === '.cts'\n ? ts.ScriptKind.TS\n : // Plain JS may legitimately contain JSX; the JSX grammar is a\n // superset for untyped code, so parsing .js/.jsx as JSX avoids\n // false positives on React files.\n ts.ScriptKind.JSX;\n\n const sourceFile = ts.createSourceFile(\n path.basename(filePath),\n content,\n ts.ScriptTarget.Latest,\n /* setParentNodes */ false,\n scriptKind,\n );\n // parseDiagnostics is not in the public .d.ts but has been the stable\n // home of the parser's syntactic diagnostics for a decade (the public\n // alternative, program.getSyntacticDiagnostics, needs a full Program).\n const diags =\n (sourceFile as unknown as { parseDiagnostics?: import('typescript').Diagnostic[] })\n .parseDiagnostics ?? [];\n return diags.slice(0, MAX_ERRORS).map((d) => formatDiag(ts, d, content, sourceFile));\n}\n\nfunction formatDiag(\n ts: Ts,\n diag: import('typescript').Diagnostic,\n content: string,\n sourceFile?: import('typescript').SourceFile,\n): string {\n const message = ts.flattenDiagnosticMessageText(diag.messageText, ' ');\n if (diag.start === undefined) return message;\n let line: number;\n if (sourceFile) {\n line = sourceFile.getLineAndCharacterOfPosition(diag.start).line + 1;\n } else {\n line = 1;\n for (let i = 0; i < diag.start && i < content.length; i++) {\n if (content.charCodeAt(i) === 0x0a) line++;\n }\n }\n return `line ${line}: ${message}`;\n}\n","import { createHash } from 'node:crypto';\nimport * as fsp from 'node:fs/promises';\nimport * as path from 'node:path';\nimport * as Core from '@wrongstack/core';\nimport type { Context } from '@wrongstack/core';\n\n/**\n * sha-256 hex of a UTF-8 string. Used by the file tools to record a content\n * hash alongside the mtime in `ctx.recordRead` — the hash is the authoritative\n * staleness arbiter for `edit` (mtime has a 2 s tolerance window on Windows).\n */\nexport function sha256hex(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n/** Detected package manager for a project directory. */\nexport type PackageManager = 'pnpm' | 'yarn' | 'npm';\n\n/**\n * Detect the project's package manager by inspecting lockfiles in `cwd`.\n * Order: pnpm → yarn → npm (default). Missing or unreadable directories fall\n * back to `npm` rather than throwing, so a `safeResolve`-checked cwd that\n * happens to be empty never aborts the tool.\n */\nexport async function detectPackageManager(cwd: string): Promise<PackageManager> {\n const { stat } = await import('node:fs/promises');\n try {\n await stat(`${cwd}/pnpm-lock.yaml`);\n return 'pnpm';\n } catch {\n /* not pnpm */\n }\n try {\n await stat(`${cwd}/yarn.lock`);\n return 'yarn';\n } catch {\n /* not yarn */\n }\n return 'npm';\n}\n\nexport function resolvePath(input: string, ctx: Context): string {\n return path.isAbsolute(input) ? path.normalize(input) : path.resolve(ctx.workingDir ?? ctx.cwd, input);\n}\n\n/**\n * Roots every file tool may always reach, even in restricted mode: the\n * project root and the user-global `~/.wrongstack` directory (config, memory,\n * sessions, skills). `~/.wrongstack` honors the `WRONGSTACK_HOME` override.\n */\nfunction allowedRoots(ctx: Context): string[] {\n return [path.resolve(ctx.projectRoot), path.resolve(Core.wstackGlobalRoot())];\n}\n\n/** True if `target` is `root` itself or nested inside any of `roots`. */\nfunction isInsideAny(target: string, roots: string[]): boolean {\n return roots.some((root) => {\n const rel = path.relative(root, target);\n return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n });\n}\n\nexport function ensureInsideRoot(absPath: string, ctx: Context): string {\n const target = path.resolve(absPath);\n // Unrestricted filesystem access: skip the project-root containment check.\n if (ctx.allowOutsideProjectRoot) return target;\n if (isInsideAny(target, allowedRoots(ctx))) return target;\n throw new Error(`Path \"${absPath}\" is outside project root \"${path.resolve(ctx.projectRoot)}\"`);\n}\n\nexport function safeResolve(input: string, ctx: Context): string {\n return ensureInsideRoot(resolvePath(input, ctx), ctx);\n}\n\n/**\n * Defense against in-root→out-of-root symlink escape (CWE-59). `safeResolve`\n * only does a syntactic `../` check, so a symlink that lives *inside* the\n * project root but points outside still passes it. This resolves the path\n * through `fs.realpath` and re-verifies containment against the realpath of\n * the project root (comparing like-for-like, since the root itself may be a\n * symlink — macOS `/var`→`/private/var`, Windows 8.3 short names). For a path\n * that does not exist yet (e.g. a `write` to a new file) the nearest existing\n * ancestor directory is checked instead. Throws if the real target escapes.\n *\n * Mirrors the per-file guard already used in `replace.ts`/`grep.ts`; applied\n * to single-file `read`/`edit`/`write` it throws (rather than skips) because\n * the caller named exactly one file.\n */\nexport async function assertRealInsideRoot(absPath: string, ctx: Context): Promise<void> {\n // Unrestricted filesystem access: no symlink-escape check to perform.\n if (ctx.allowOutsideProjectRoot) return;\n // Compare like-for-like against the realpath of each always-allowed root\n // (project root + ~/.wrongstack), since a root may itself be a symlink.\n const realRoots = await Promise.all(\n allowedRoots(ctx).map((r) => fsp.realpath(r).catch(() => path.resolve(r))),\n );\n let probe = absPath;\n for (;;) {\n let real: string;\n try {\n real = await fsp.realpath(probe);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n const parent = path.dirname(probe);\n if (parent === probe) return; // reached fs root without escaping\n probe = parent;\n continue;\n }\n throw err;\n }\n if (isInsideAny(real, realRoots)) return;\n throw new Error(\n `Path \"${absPath}\" resolves through a symlink outside project root \"${realRoots[0]}\"`,\n );\n }\n}\n\n/** `safeResolve` + symlink realpath containment check. Async. */\nexport async function safeResolveReal(input: string, ctx: Context): Promise<string> {\n const abs = safeResolve(input, ctx);\n await assertRealInsideRoot(abs, ctx);\n return abs;\n}\n\nexport function truncateMiddle(s: string, max: number): string {\n if (Buffer.byteLength(s, 'utf8') <= max) return s;\n const half = Math.floor(max / 2);\n return (\n s.slice(0, half) +\n `\\n…[truncated ${Buffer.byteLength(s, 'utf8') - max} bytes from middle]…\\n` +\n s.slice(-half)\n );\n}\n\nexport function isBinaryBuffer(buf: Buffer): boolean {\n const len = Math.min(buf.length, 8192);\n for (let i = 0; i < len; i++) {\n if (buf[i] === 0) return true;\n }\n return false;\n}\n\n// ─── Command-output normalization (token-saving) ────────────────────────────\n//\n// Raw process output is full of tokens the model gains nothing from: ANSI\n// escapes, carriage-return progress spam, runs of identical warning lines, and\n// huge tails of build noise. These helpers strip that noise before the output\n// reaches the LLM. They are scoped to COMMAND tools (bash/git/exec and the\n// _spawn-stream consumers) — never applied to structured/code outputs.\n\n/** Unified byte cap for all command tool output fed to the model. */\nexport const COMMAND_OUTPUT_MAX_BYTES = 32_768;\n\n/** Runs of >= this many identical consecutive lines are collapsed. */\nconst REPEAT_RUN_THRESHOLD = 3;\n\n/**\n * Collapse carriage-return overwrites the way a terminal would: `\\r\\n` becomes\n * `\\n`, and a bare `\\r` (progress redraw) keeps only the text after the LAST\n * `\\r` on its physical line. Without this, a single progress bar that redraws\n * 200 times explodes into 200 lines.\n */\nexport function collapseCarriageReturns(text: string): string {\n const lf = text.replace(/\\r\\n/g, '\\n');\n if (!lf.includes('\\r')) return lf;\n return lf\n .split('\\n')\n .map((line) => (line.includes('\\r') ? line.slice(line.lastIndexOf('\\r') + 1) : line))\n .join('\\n');\n}\n\n/**\n * Collapse a run of `minRun`+ identical consecutive lines into the line once\n * plus a marker. Consecutive-only — it never reorders or dedups non-adjacent\n * lines, so diffs/source stay intact.\n */\nexport function collapseConsecutiveDuplicates(text: string, minRun = REPEAT_RUN_THRESHOLD): string {\n const lines = text.split('\\n');\n const out: string[] = [];\n let i = 0;\n while (i < lines.length) {\n let j = i + 1;\n while (j < lines.length && lines[j] === lines[i]) j++;\n const run = j - i;\n if (run >= minRun) {\n out.push(lines[i]!, `… ⟨repeated ${run}×⟩`);\n } else {\n for (let k = i; k < j; k++) out.push(lines[k]!);\n }\n i = j;\n }\n return out.join('\\n');\n}\n\n/** Largest prefix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeHeadBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(0, mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(0, lo);\n}\n\n/** Largest suffix of `s` whose UTF-8 byte length is <= `maxBytes`. */\nfunction takeTailBytes(s: string, maxBytes: number): string {\n if (maxBytes <= 0) return '';\n /* v8 ignore next -- only caller (truncateHeadTail) passes a budget smaller than s; defensive. */\n if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;\n let lo = 0;\n let hi = s.length;\n while (lo < hi) {\n const mid = Math.ceil((lo + hi) / 2);\n if (Buffer.byteLength(s.slice(s.length - mid), 'utf8') <= maxBytes) lo = mid;\n else hi = mid - 1;\n }\n return s.slice(s.length - lo);\n}\n\n/**\n * Truncate to `maxBytes` keeping BOTH ends — the head (what ran / early context)\n * and the tail (errors and summaries usually land last), biased ~45/55 toward\n * the tail. The result never exceeds `maxBytes`.\n */\nexport function truncateHeadTail(s: string, maxBytes: number): string {\n const total = Buffer.byteLength(s, 'utf8');\n if (total <= maxBytes) return s;\n // Reserve a fixed allowance for the marker so the final string can't exceed\n // the cap even though the dropped-byte count's digit width varies.\n const MARKER_RESERVE = 64;\n const avail = Math.max(0, maxBytes - MARKER_RESERVE);\n const headBudget = Math.floor(avail * 0.45);\n const head = takeHeadBytes(s, headBudget);\n const tail = takeTailBytes(s, avail - Buffer.byteLength(head, 'utf8'));\n const kept = Buffer.byteLength(head, 'utf8') + Buffer.byteLength(tail, 'utf8');\n return `${head}\\n…[truncated ${total - kept} bytes]…\\n${tail}`;\n}\n\n/**\n * Full token-saving pipeline for command tool output: strip ANSI → collapse\n * carriage-return progress → trim trailing whitespace → collapse identical\n * consecutive lines → squeeze blank-line runs → head+tail truncate to the cap.\n */\nexport function normalizeCommandOutput(\n raw: string,\n opts: { maxBytes?: number | undefined } = {},\n): string {\n if (!raw) return raw;\n let text = Core.stripAnsi(raw);\n text = collapseCarriageReturns(text);\n text = text.replace(/[ \\t]+$/gm, ''); // trailing whitespace per line\n text = collapseConsecutiveDuplicates(text);\n text = text.replace(/\\n{3,}/g, '\\n\\n'); // >=2 blank lines → 1\n return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);\n}\n","import * as fs from 'node:fs/promises';\nimport { atomicWrite, ToolValidationError, unifiedDiff } from '@wrongstack/core';\nimport type { Context, Tool } from '@wrongstack/core';\nimport { checkSyntax } from './_syntax-check.js';\nimport { safeResolveReal, sha256hex } from './_util.js';\n\ninterface WriteInput {\n path: string;\n content: string;\n}\n\ninterface WriteOutput {\n path: string;\n bytes_written: number;\n created: boolean;\n diff?: string | undefined;\n /**\n * Parse errors found in the written content (TS/JS/JSON only). The file is\n * on disk as written — fix these with a follow-up edit now.\n */\n syntax_errors?: string[] | undefined;\n note?: string | undefined;\n}\n\nexport const writeTool: Tool<WriteInput, WriteOutput> = {\n name: 'write',\n category: 'Filesystem',\n description:\n 'Write or completely overwrite a file on disk. ' +\n 'This is a high-privilege operation. For modifying existing files, you should almost always prefer the `edit` tool instead, ' +\n 'because `edit` is safer and works on the last-read version of the file.',\n usageHint:\n 'RULES FOR CORRECT USAGE:\\n' +\n '- Use `write` primarily for **new files** or when you want to replace the entire content.\\n' +\n '- For any existing file, strongly prefer `edit` (it requires a prior `read` in the same session and is more precise).\\n' +\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' +\n '- The path is resolved relative to the project root and protected against escaping the workspace.',\n selection: {\n doNotUseWhen: 'making a precise change to part of an existing file.',\n useInstead: ['edit'],\n },\n permission: 'confirm',\n mutating: true,\n timeoutMs: 5_000,\n capabilities: ['fs.write'],\n icon: 'file',\n inputSchema: {\n type: 'object',\n properties: {\n path: {\n type: 'string',\n description: 'Relative path from project root. Must not escape the project.',\n },\n content: {\n type: 'string',\n description: 'The complete new content of the file.',\n },\n },\n required: ['path', 'content'],\n },\n async execute(input, ctx, opts) {\n return writeFile(input, ctx, opts?.signal);\n },\n async *executeStream(input, ctx, opts) {\n const prepared = await prepareWrite(input, ctx);\n if (!prepared.existed) {\n for (const line of input.content.split('\\n')) {\n yield { type: 'partial_output', text: `${line}\\n`, data: { livePreview: true } };\n }\n }\n yield { type: 'final', output: await finishWrite(input, ctx, prepared, opts?.signal) };\n },\n};\n\ntype PreparedWrite = {\n absPath: string;\n existed: boolean;\n prev: string;\n};\n\nasync function writeFile(\n input: WriteInput,\n ctx: Context,\n signal?: AbortSignal | undefined,\n): Promise<WriteOutput> {\n return finishWrite(input, ctx, await prepareWrite(input, ctx), signal);\n}\n\nasync function prepareWrite(input: WriteInput, ctx: Context): Promise<PreparedWrite> {\n if (!input?.path) {\n throw new ToolValidationError({\n message: 'write: path is required',\n field: 'path',\n });\n }\n if (input.content === undefined) {\n throw new ToolValidationError({\n message: 'write: content is required',\n field: 'content',\n });\n }\n const absPath = await safeResolveReal(input.path, ctx);\n\n let existed = false;\n let prev = '';\n try {\n const stat = await fs.stat(absPath);\n existed = stat.isFile();\n if (existed) {\n if (!ctx.hasRead(absPath)) {\n // User approved this write (confirm → yes/always) but ctx has no\n // read record. The model may call write without a prior explicit\n // read. Read the file now so we can compute the diff and honor\n // the user's intent to overwrite. Tag as 'write' (NOT 'user') so\n // this internal read-for-diff does not widen the permission bypass\n // — the user never saw the old content (P1 #1).\n prev = await fs.readFile(absPath, 'utf8');\n ctx.recordRead(absPath, stat.mtimeMs, 'write', sha256hex(prev));\n } else {\n prev = await fs.readFile(absPath, 'utf8');\n }\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw err;\n }\n }\n\n return { absPath, existed, prev };\n}\n\nasync function finishWrite(\n input: WriteInput,\n ctx: Context,\n prepared: PreparedWrite,\n signal?: AbortSignal | undefined,\n): Promise<WriteOutput> {\n // Last exit before mutating the filesystem: a run aborted during\n // prepare/diff must not leave a fresh write behind. (atomicWrite itself\n // is all-or-nothing, so past this point the file is old or new — never\n // partial.)\n signal?.throwIfAborted();\n await atomicWrite(prepared.absPath, input.content);\n\n const diff = prepared.existed\n ? unifiedDiff(prepared.prev, input.content, { fromFile: input.path, toFile: input.path })\n : `+++ ${input.path}\\n+ (new file, ${input.content.split('\\n').length} lines)`;\n\n const stat = await fs.stat(prepared.absPath);\n // Tag as 'write' so the permission bypass does not auto-approve a later\n // write to this path — the user approved THIS write, not future ones\n // (P1 #1).\n ctx.recordRead(prepared.absPath, stat.mtimeMs, 'write', sha256hex(input.content));\n\n // Record for session rewind\n ctx.session.recordFileChange({\n path: prepared.absPath,\n action: prepared.existed ? 'modified' : 'created',\n before: prepared.existed ? prepared.prev : null,\n after: input.content,\n });\n\n // Post-write syntax validation (TS/JS/JSON). The file stays on disk as\n // written — errors come back in the same turn so the model fixes them\n // before the user ever opens a broken file.\n const syntax = await checkSyntax(\n prepared.absPath,\n input.content,\n prepared.existed ? prepared.prev : undefined,\n ).catch(() => undefined);\n const hasSyntaxErrors = syntax !== undefined && syntax.errors.length > 0;\n\n return {\n path: prepared.absPath,\n bytes_written: Buffer.byteLength(input.content, 'utf8'),\n created: !prepared.existed,\n diff,\n syntax_errors: hasSyntaxErrors ? syntax.errors : undefined,\n note: hasSyntaxErrors\n ? syntax.preExisting\n ? 'Syntax check: the file still has parse errors (they pre-date this write) — see syntax_errors.'\n : `Syntax check: the written content has ${syntax.errors.length} parse error(s) — fix them now, see syntax_errors.`\n : undefined,\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/tools",
3
- "version": "0.283.1",
3
+ "version": "0.284.0",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack built-in tools: read/write/edit, bash/exec, grep/glob, git, fetch, test, lint, and more.",
6
6
  "repository": {
@@ -32,6 +32,18 @@
32
32
  "types": "./dist/tool-icons.d.ts",
33
33
  "import": "./dist/tool-icons.js"
34
34
  },
35
+ "./tool-summary": {
36
+ "types": "./dist/tool-summary.d.ts",
37
+ "import": "./dist/tool-summary.js"
38
+ },
39
+ "./tool-diff": {
40
+ "types": "./dist/tool-diff.d.ts",
41
+ "import": "./dist/tool-diff.js"
42
+ },
43
+ "./next-steps": {
44
+ "types": "./dist/next-steps.d.ts",
45
+ "import": "./dist/next-steps.js"
46
+ },
35
47
  "./read": {
36
48
  "types": "./dist/read.d.ts",
37
49
  "import": "./dist/read.js"
@@ -184,7 +196,8 @@
184
196
  "turndown": "^7.2.4",
185
197
  "typescript": "^6.0.3",
186
198
  "undici": "^8.5.0",
187
- "@wrongstack/core": "0.283.1"
199
+ "@wrongstack/core": "0.284.0",
200
+ "@wrongstack/kanban": "0.284.0"
188
201
  },
189
202
  "devDependencies": {
190
203
  "@types/node": "^26.0.1",