@wrongstack/tools 0.265.1 → 0.267.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as fs4 from 'node:fs/promises';
2
+ import { toErrorMessage } from '@wrongstack/core/utils';
2
3
  import * as path from 'node:path';
3
4
  import { resolve, sep, dirname, join } from 'node:path';
4
5
  import * as Core from '@wrongstack/core';
5
- import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, isPrivateIPv4, isPrivateIPv6, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, mutateTasks, formatTaskList, formatPlan, recordPackageAction, detectPackageEcosystem, computeTaskItemProgress, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
6
- import { toErrorMessage } from '@wrongstack/core/utils';
6
+ import { atomicWrite, unifiedDiff, detectNewlineStyle, normalizeToLf, toStyle, compileGlob, expectDefined, buildChildEnv, isPrivateIPv4, isPrivateIPv6, loadPlan, setPlanItemStatus, savePlan, loadTasks, saveTasks, mutatePlan, clearPlan, getPlanTemplate, addPlanItem, deriveTodosFromPlanItem, removePlanItem, mutateTasks, formatTaskList, formatPlan, assessCommitSafety, recordPackageAction, detectPackageEcosystem, computeTaskItemProgress, wstackGlobalRoot, resolveWstackPaths, truncate } from '@wrongstack/core';
7
7
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
8
8
  import * as os from 'node:os';
9
9
  import * as fs7 from 'node:fs';
@@ -168,6 +168,8 @@ function normalizeCommandOutput(raw, opts = {}) {
168
168
  text = text.replace(/\n{3,}/g, "\n\n");
169
169
  return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
170
170
  }
171
+
172
+ // src/read.ts
171
173
  var MAX_BYTES = 5 * 1024 * 1024;
172
174
  var readTool = {
173
175
  name: "read",
@@ -194,6 +196,11 @@ var readTool = {
194
196
  limit: {
195
197
  type: "integer",
196
198
  description: "Maximum number of lines to return (default is 2000)."
199
+ },
200
+ mode: {
201
+ type: "string",
202
+ enum: ["content", "summary"],
203
+ description: "Return full line-numbered content (default) or a compact file summary with imports/exports/symbols."
197
204
  }
198
205
  },
199
206
  required: ["path"]
@@ -207,14 +214,27 @@ var readTool = {
207
214
  } catch (err) {
208
215
  const code = err.code;
209
216
  if (code === "ENOENT") throw new Error(`read: file not found "${input.path}"`);
210
- throw new Error(
211
- `read: failed to stat "${input.path}": ${toErrorMessage(err)}`
212
- );
217
+ throw new Error(`read: failed to stat "${input.path}": ${toErrorMessage(err)}`);
213
218
  }
214
219
  if (!stat11.isFile()) throw new Error(`read: "${input.path}" is not a regular file`);
215
220
  if (stat11.size > MAX_BYTES) {
216
221
  throw new Error(`read: file too large (${stat11.size} bytes, limit ${MAX_BYTES})`);
217
222
  }
223
+ const offset = Math.max(1, input.offset ?? 1);
224
+ const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
225
+ const prior = getReadRangeRecord(ctx, absPath);
226
+ const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
227
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat11.mtimeMs, offset, requestedEnd)) {
228
+ ctx.recordRead(absPath, stat11.mtimeMs);
229
+ return {
230
+ 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.]`,
231
+ total_lines: prior.totalLines,
232
+ encoding: "utf8",
233
+ truncated: requestedEnd < prior.totalLines,
234
+ cached: true,
235
+ note: "Repeated read suppressed to save tokens."
236
+ };
237
+ }
218
238
  const buf = await fs4.readFile(absPath);
219
239
  if (isBinaryBuffer(buf)) {
220
240
  throw new Error(`read: "${input.path}" appears to be binary`);
@@ -222,17 +242,38 @@ var readTool = {
222
242
  const text = buf.toString("utf8");
223
243
  const allLines = text.split(/\r\n|\r|\n/);
224
244
  const total = allLines.length;
225
- const offset = Math.max(1, input.offset ?? 1);
226
- const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
245
+ if (input.mode === "summary") {
246
+ ctx.recordRead(absPath, stat11.mtimeMs);
247
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, Math.min(total, 200));
248
+ return {
249
+ text: summarizeFile(input.path, stat11.size, allLines),
250
+ total_lines: total,
251
+ encoding: "utf8",
252
+ truncated: total > 200,
253
+ note: "Summary mode returned compact structure instead of full file content."
254
+ };
255
+ }
227
256
  if (limit === 0) {
228
257
  ctx.recordRead(absPath, stat11.mtimeMs);
258
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, 1, 0);
229
259
  return { text: "", total_lines: total, encoding: "utf8", truncated: total > 0 };
230
260
  }
261
+ if (offset > total) {
262
+ ctx.recordRead(absPath, stat11.mtimeMs);
263
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, total + 1, total + 1);
264
+ return {
265
+ text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
266
+ total_lines: total,
267
+ encoding: "utf8",
268
+ truncated: false
269
+ };
270
+ }
231
271
  const slice = allLines.slice(offset - 1, offset - 1 + limit);
232
272
  const truncated = offset - 1 + slice.length < total;
233
273
  const width = String(offset + slice.length - 1).length;
234
274
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
235
275
  ctx.recordRead(absPath, stat11.mtimeMs);
276
+ rememberReadRange(ctx, absPath, stat11.mtimeMs, total, offset, offset + slice.length - 1);
236
277
  return {
237
278
  text: numbered,
238
279
  total_lines: total,
@@ -241,6 +282,62 @@ var readTool = {
241
282
  };
242
283
  }
243
284
  };
285
+ var READ_RANGES_META_KEY = "tools.read.ranges.v1";
286
+ function getReadRanges(ctx) {
287
+ const existing = ctx.meta[READ_RANGES_META_KEY];
288
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
289
+ return existing;
290
+ }
291
+ const next = {};
292
+ ctx.meta[READ_RANGES_META_KEY] = next;
293
+ return next;
294
+ }
295
+ function getReadRangeRecord(ctx, absPath) {
296
+ return getReadRanges(ctx)[absPath];
297
+ }
298
+ function rememberReadRange(ctx, absPath, mtimeMs, totalLines, start, end) {
299
+ if (end < start) return;
300
+ const ranges = getReadRanges(ctx);
301
+ const prior = ranges[absPath];
302
+ const nextRanges = prior && Math.abs(prior.mtimeMs - mtimeMs) <= 1 ? prior.ranges.slice() : [];
303
+ nextRanges.push({ start, end });
304
+ ranges[absPath] = {
305
+ mtimeMs,
306
+ totalLines,
307
+ ranges: mergeRanges(nextRanges)
308
+ };
309
+ }
310
+ function coversRange(record, mtimeMs, start, end) {
311
+ if (Math.abs(record.mtimeMs - mtimeMs) > 1) return false;
312
+ return record.ranges.some((range) => range.start <= start && range.end >= end);
313
+ }
314
+ function mergeRanges(ranges) {
315
+ const sorted = ranges.slice().sort((a, b) => a.start - b.start);
316
+ const merged = [];
317
+ for (const range of sorted) {
318
+ const last = merged[merged.length - 1];
319
+ if (!last || range.start > last.end + 1) {
320
+ merged.push({ ...range });
321
+ continue;
322
+ }
323
+ last.end = Math.max(last.end, range.end);
324
+ }
325
+ return merged;
326
+ }
327
+ function summarizeFile(filePath, bytes, lines) {
328
+ const interesting = lines.map((line, index) => ({ line: line.trim(), number: index + 1 })).filter(
329
+ ({ line }) => /^(import\s|export\s|class\s|interface\s|type\s|function\s|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|def\s+|async\s+function\s)/.test(
330
+ line
331
+ )
332
+ ).slice(0, 80).map(({ line, number }) => `${number}: ${line}`);
333
+ return [
334
+ `summary: ${filePath}`,
335
+ `bytes=${bytes}`,
336
+ `total_lines=${lines.length}`,
337
+ interesting.length > 0 ? `symbols/imports:
338
+ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
339
+ ].join("\n");
340
+ }
244
341
  var writeTool = {
245
342
  name: "write",
246
343
  category: "Filesystem",
@@ -309,8 +406,8 @@ var writeTool = {
309
406
  var editTool = {
310
407
  name: "edit",
311
408
  category: "Filesystem",
312
- description: "Perform a precise, surgical text replacement in a file. This is the preferred tool for modifying existing code. It requires that you have previously called `read` on the file in the current session. Fails safely if the `old_string` appears more than once unless `replace_all` is set.",
313
- usageHint: "MANDATORY WORKFLOW:\n1. Call `read` on the target file first (in the same conversation).\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\nThis tool is much safer than `write` for existing files because it works against the last-read version.",
409
+ 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.",
410
+ 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.",
314
411
  permission: "confirm",
315
412
  mutating: true,
316
413
  capabilities: ["fs.write"],
@@ -339,9 +436,7 @@ var editTool = {
339
436
  throw err;
340
437
  });
341
438
  if (!stat11.isFile()) throw new Error(`edit: "${input.path}" is not a regular file`);
342
- if (!ctx.hasRead(absPath)) {
343
- throw new Error(`edit: file "${input.path}" was not read in this session. Read it first.`);
344
- }
439
+ const autoRead = !ctx.hasRead(absPath);
345
440
  const original = await fs4.readFile(absPath, "utf8");
346
441
  const updated = await fs4.stat(absPath);
347
442
  const mtimeTolerance = process.platform === "win32" ? 2e3 : 1;
@@ -349,15 +444,21 @@ var editTool = {
349
444
  if (lastReadMtime !== void 0 && updated.mtimeMs > lastReadMtime + mtimeTolerance) {
350
445
  throw new Error(`edit: file "${input.path}" was modified externally. Re-read it first.`);
351
446
  }
447
+ if (autoRead && updated.mtimeMs > stat11.mtimeMs + mtimeTolerance) {
448
+ throw new Error(`edit: file "${input.path}" changed while being auto-read. Retry the edit.`);
449
+ }
450
+ const autoReadNote = autoRead ? `No prior read was recorded for "${input.path}"; edit auto-read the current file and applied the replacement only after the ambiguity checks passed.` : void 0;
352
451
  const style = detectNewlineStyle(original);
353
452
  const fileLf = normalizeToLf(original);
354
453
  const oldLf = normalizeToLf(input.old_string);
355
454
  const newLf = normalizeToLf(input.new_string);
356
455
  if (oldLf === newLf) {
456
+ if (autoRead) ctx.recordRead(absPath, updated.mtimeMs);
357
457
  return {
358
458
  path: absPath,
359
459
  replacements: 0,
360
- diff: "(no-op: old and new are identical)"
460
+ diff: "(no-op: old and new are identical)",
461
+ note: autoReadNote
361
462
  };
362
463
  }
363
464
  let count = 0;
@@ -398,7 +499,8 @@ var editTool = {
398
499
  return {
399
500
  path: absPath,
400
501
  replacements: input.replace_all ? count : 1,
401
- diff
502
+ diff,
503
+ note: autoReadNote
402
504
  };
403
505
  }
404
506
  };
@@ -3167,7 +3269,7 @@ var gitTool = {
3167
3269
  name: "git",
3168
3270
  category: "Git",
3169
3271
  description: "Safe wrapper around common git operations. Supports status, log, diff, commit, branch, checkout, stash, push, pull, fetch, reset, worktree, etc. This is the preferred way to interact with git instead of using the raw `bash` or `exec` tools.",
3170
- usageHint: "ALWAYS prefer this tool over raw shell git commands.\n\nKey fields:\n- `command`: one of the supported subcommands (status, log, diff, commit, etc.)\n- Use `message` only for commit operations.\n- Use `files` array for operations that take paths (status, diff, add, etc.).\n- Non-mutating commands (status, log, diff, branch, fetch) are still permission:confirm for safety.\nNever pass raw git flags through `args` for dangerous operations \u2014 use the structured fields.",
3272
+ usageHint: "ALWAYS prefer this tool over raw shell git commands.\n\nKey fields:\n- `command`: one of the supported subcommands (status, log, diff, commit, etc.)\n- Use `message` only for commit operations.\n- Use `files` array for operations that take paths (status, diff, add, etc.).\n- Non-mutating commands (status, log, diff, branch, fetch) are still permission:confirm for safety.\n- For `commit` in a possibly-shared working tree, pass an explicit `files` list scoped to what YOU changed. A bare commit (no `files`) includes ALL staged changes and may capture another agent's half-done work. Heed the `warning` field on the result.\nNever pass raw git flags through `args` for dangerous operations \u2014 use the structured fields.",
3171
3273
  permission: "confirm",
3172
3274
  icon: "git",
3173
3275
  // Conservative: any of these may mutate. The non-mutating commands
@@ -3256,6 +3358,22 @@ var gitTool = {
3256
3358
  };
3257
3359
  }
3258
3360
  const args = buildArgs(input);
3361
+ let safetyWarning;
3362
+ if (input.command === "commit") {
3363
+ try {
3364
+ const report = await assessCommitSafety({
3365
+ cwd: ctx.cwd,
3366
+ projectRoot: ctx.projectRoot,
3367
+ sessionId: ctx.session?.id,
3368
+ signal: opts.signal
3369
+ });
3370
+ if (report.warning) {
3371
+ const scopeNote = input.files ? "" : "\nNote: this commit has no explicit `files` list, so it will include ALL staged changes. Pass `files` to scope the commit to only what you changed.";
3372
+ safetyWarning = report.warning + scopeNote;
3373
+ }
3374
+ } catch {
3375
+ }
3376
+ }
3259
3377
  let stagedDiff;
3260
3378
  if (input.command === "commit" && !input.dry_run) {
3261
3379
  try {
@@ -3269,6 +3387,7 @@ var gitTool = {
3269
3387
  }
3270
3388
  const result = await runGit(args, gitDir, opts.signal);
3271
3389
  if (stagedDiff !== void 0) result.diff = stagedDiff;
3390
+ if (safetyWarning !== void 0) result.warning = safetyWarning;
3272
3391
  return result;
3273
3392
  }
3274
3393
  };
@@ -4839,12 +4958,15 @@ var outdatedTool = {
4839
4958
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck_scan,
4840
4959
  // web_search) but missed this one; applying the same contract here.
4841
4960
  mutating: true,
4842
- // Capability is just "network" — the tool only hits the package
4961
+ // Capability is outbound network — the tool only hits the package
4843
4962
  // registry over HTTP, never touches the filesystem or runs shell.
4963
+ // Use the canonical `net.outbound` capability (not the non-existent
4964
+ // `network` string) so the subagent allowlist recognises it and
4965
+ // permits read-only registry lookups under a director.
4844
4966
  // The H7 invariant test requires this array to be non-empty for
4845
4967
  // any mutating:true tool (meta-tools whitelisted). See
4846
4968
  // tests/permission-mutating-invariant.test.ts:92.
4847
- capabilities: ["network"],
4969
+ capabilities: ["net.outbound"],
4848
4970
  timeoutMs: 6e4,
4849
4971
  inputSchema: {
4850
4972
  type: "object",
@@ -9566,8 +9688,8 @@ var TOOL_ICON_CONFIG = {
9566
9688
  // emerald
9567
9689
  folder: { icon: "folder", color: "#8b5cf6" },
9568
9690
  // violet
9569
- terminal: { icon: "terminal", color: "#ef4444" },
9570
- // red
9691
+ terminal: { icon: "terminal", color: "#fb923c" },
9692
+ // orange
9571
9693
  web: { icon: "web", color: "#06b6d4" },
9572
9694
  // cyan
9573
9695
  git: { icon: "git", color: "#f97316" },