@wrongstack/tools 0.300.0 → 0.302.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
@@ -851,7 +851,7 @@ import {
851
851
  } from "@wrongstack/core/observability";
852
852
  import { buildChildEnv } from "@wrongstack/core/utils";
853
853
  async function* spawnStream(opts) {
854
- const max = opts.maxBytes ?? 2e5;
854
+ const max = opts.maxBytes ?? 999999999;
855
855
  const flushAt = opts.flushBytes ?? 4 * 1024;
856
856
  const maxQueue = opts.maxQueueSize ?? 500;
857
857
  const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
@@ -5927,16 +5927,23 @@ function looksBinary(content) {
5927
5927
  }
5928
5928
  return bad / sample.length > 0.1;
5929
5929
  }
5930
- function lineColAt(content, index) {
5931
- let line = 1;
5932
- let lastNl = -1;
5933
- for (let i = 0; i < index && i < content.length; i++) {
5934
- if (content.charCodeAt(i) === 10) {
5935
- line++;
5936
- lastNl = i;
5937
- }
5930
+ function newlineOffsets2(content) {
5931
+ const offsets = [];
5932
+ for (let i = 0; i < content.length; i++) {
5933
+ if (content.charCodeAt(i) === 10) offsets.push(i);
5938
5934
  }
5939
- return { line, col: index - lastNl };
5935
+ return offsets;
5936
+ }
5937
+ function lineColAt(offsets, index) {
5938
+ let low = 0;
5939
+ let high = offsets.length;
5940
+ while (low < high) {
5941
+ const mid = low + high >>> 1;
5942
+ if ((offsets[mid] ?? 0) < index) low = mid + 1;
5943
+ else high = mid;
5944
+ }
5945
+ const lastNl = low > 0 ? offsets[low - 1] : -1;
5946
+ return { line: low + 1, col: index - lastNl };
5940
5947
  }
5941
5948
  function parseGeneric2(opts) {
5942
5949
  const { file, lang } = opts;
@@ -5949,6 +5956,7 @@ function parseGeneric2(opts) {
5949
5956
  const patterns = patternsFor(lang);
5950
5957
  const symbols = [];
5951
5958
  const seen = /* @__PURE__ */ new Set();
5959
+ const nlOffsets = newlineOffsets2(content);
5952
5960
  for (const pattern of patterns) {
5953
5961
  const re = new RegExp(pattern.re.source, pattern.re.flags.includes("g") ? pattern.re.flags : `${pattern.re.flags}g`);
5954
5962
  re.lastIndex = 0;
@@ -5962,7 +5970,7 @@ function parseGeneric2(opts) {
5962
5970
  if (!/^[A-Za-z_#.@/\w][\w.\-:/#!?]*$/.test(name) && lang !== "md" && lang !== "toml") {
5963
5971
  continue;
5964
5972
  }
5965
- const { line, col } = lineColAt(content, match.index);
5973
+ const { line, col } = lineColAt(nlOffsets, match.index ?? 0);
5966
5974
  const key = `${name}\0${line}\0${pattern.kind}`;
5967
5975
  if (seen.has(key)) continue;
5968
5976
  seen.add(key);
@@ -9115,7 +9123,9 @@ async function executeSingle(call, ctx, governedExecute) {
9115
9123
  executionMs: Date.now() - start
9116
9124
  };
9117
9125
  }
9118
- const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
9126
+ const tool = (ctx.catalogTools ?? ctx.tools).find(
9127
+ (candidate) => candidate.name === call.tool
9128
+ );
9119
9129
  if (!tool) {
9120
9130
  return {
9121
9131
  tool: call.tool,
@@ -9337,7 +9347,7 @@ function parsePrivateOriginAllowlist(raw) {
9337
9347
  if (!raw?.trim()) return [];
9338
9348
  const origins = /* @__PURE__ */ new Set();
9339
9349
  for (const entry of raw.split(",")) {
9340
- const candidate = entry.trim();
9350
+ const candidate = entry.trim().replace(/^["']+|["']+$/gu, "");
9341
9351
  if (!candidate) continue;
9342
9352
  const url = parseBrowserUrl(candidate, true);
9343
9353
  if (url.pathname !== "/" || url.search || url.hash) {
@@ -10581,9 +10591,9 @@ import * as path16 from "node:path";
10581
10591
  // src/codebase-index/bm25.ts
10582
10592
  var K1 = 1.5;
10583
10593
  var B = 0.75;
10594
+ var TOKENISE_RE = new RegExp("[^\\p{L}\\p{N}$']", "gu");
10584
10595
  function tokenise(text) {
10585
- const sanitised = text.replace(/[^\p{L}\p{N}$'_]/gu, " ").replace(/_/g, " ");
10586
- return sanitised.toLowerCase().split(" ").filter(Boolean);
10596
+ return text.replace(TOKENISE_RE, " ").toLowerCase().trim().split(/\s+/).filter(Boolean);
10587
10597
  }
10588
10598
  function splitName(name) {
10589
10599
  return name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/([\p{L}])(\d)/gu, "$1 $2").replace(/(\d)([\p{L}])/gu, "$1 $2").replace(/[_-]+/g, " ").trim();
@@ -13069,7 +13079,9 @@ var PROJECT_INDEX_SERVER_METADATA_FILE = "server.json";
13069
13079
  var PROJECT_INDEX_SERVER_SOCKET_DIR = `wsci-v${PROJECT_INDEX_SERVER_PROTOCOL_VERSION}`;
13070
13080
  var buildIdCache;
13071
13081
  function projectIndexServerBuildId(entrypoint) {
13072
- const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path17.resolve(entrypoint);
13082
+ const href = entrypoint instanceof URL ? entrypoint.href : entrypoint;
13083
+ const cleanHref = href.split(/[?#]/, 1)[0] ?? href;
13084
+ const file = cleanHref.startsWith("file:") ? fileURLToPath(cleanHref) : path17.resolve(cleanHref);
13073
13085
  try {
13074
13086
  const stat19 = fs12.statSync(file);
13075
13087
  if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat19.mtimeMs && buildIdCache.size === stat19.size) {
@@ -19888,11 +19900,8 @@ function buildArgs(input) {
19888
19900
  ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
19889
19901
  ] : ["branch"];
19890
19902
  case "checkout":
19891
- return [
19892
- "checkout",
19893
- ...input.branch ? ["--", input.branch] : [],
19894
- ...files.length ? ["--", ...files] : []
19895
- ];
19903
+ if (files.length) return ["checkout", "--", ...files];
19904
+ return input.branch ? ["checkout", input.branch, "--"] : ["checkout"];
19896
19905
  case "stash":
19897
19906
  return input.message ? ["stash", "push", "-m", input.message] : ["stash", "push"];
19898
19907
  case "push":
@@ -19997,7 +20006,7 @@ async function mapWithConcurrency2(items, limit, fn) {
19997
20006
 
19998
20007
  // src/glob.ts
19999
20008
  init_util();
20000
- var DEFAULT_IGNORE2 = DEFAULT_WALK_IGNORE_DIRS2;
20009
+ var DEFAULT_IGNORE2 = new Set(DEFAULT_WALK_IGNORE_DIRS2);
20001
20010
  var WALK_CONCURRENCY = 16;
20002
20011
  var globTool = {
20003
20012
  name: "glob",
@@ -20076,7 +20085,7 @@ var globTool = {
20076
20085
  const matchedFiles = [];
20077
20086
  for (const e of entries) {
20078
20087
  const name = e.name;
20079
- if (DEFAULT_IGNORE2.includes(name)) continue;
20088
+ if (DEFAULT_IGNORE2.has(name)) continue;
20080
20089
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
20081
20090
  const full = path30.join(dir, name);
20082
20091
  if (e.isDirectory()) {
@@ -20150,6 +20159,81 @@ var DANGEROUS_PATTERNS = [
20150
20159
  // Greedy quantifier inside lookahead/lookbehind — (?!.*a+)
20151
20160
  /[([][^)\]]*[+*][^)\]]*[)\]][^)]*\?\??/
20152
20161
  ];
20162
+ function hasAmbiguousQuantifiedAlternation(pattern) {
20163
+ for (let i = 0; i < pattern.length; i++) {
20164
+ if (pattern[i] !== "(") continue;
20165
+ if (i > 0 && pattern[i - 1] === "\\") continue;
20166
+ let depth = 0;
20167
+ let inClass = false;
20168
+ let j = i;
20169
+ for (; j < pattern.length; j++) {
20170
+ const ch = pattern[j];
20171
+ if (ch === "\\") {
20172
+ j++;
20173
+ continue;
20174
+ }
20175
+ if (inClass) {
20176
+ if (ch === "]") inClass = false;
20177
+ continue;
20178
+ }
20179
+ if (ch === "[") {
20180
+ inClass = true;
20181
+ continue;
20182
+ }
20183
+ if (ch === "(") depth++;
20184
+ else if (ch === ")") {
20185
+ depth--;
20186
+ if (depth === 0) break;
20187
+ }
20188
+ }
20189
+ if (j >= pattern.length) return false;
20190
+ const next = pattern[j + 1];
20191
+ if (next !== "+" && next !== "*" && next !== "{") continue;
20192
+ let inner = pattern.slice(i + 1, j);
20193
+ inner = inner.replace(/^\?(?::|<?[=!])/u, "");
20194
+ const branches = [];
20195
+ let current = "";
20196
+ let d = 0;
20197
+ let cls = false;
20198
+ for (let k = 0; k < inner.length; k++) {
20199
+ const ch = inner[k];
20200
+ if (ch === "\\") {
20201
+ current += ch + (inner[k + 1] ?? "");
20202
+ k++;
20203
+ continue;
20204
+ }
20205
+ if (cls) {
20206
+ if (ch === "]") cls = false;
20207
+ current += ch;
20208
+ continue;
20209
+ }
20210
+ if (ch === "[") {
20211
+ cls = true;
20212
+ current += ch;
20213
+ continue;
20214
+ }
20215
+ if (ch === "(") d++;
20216
+ if (ch === ")") d--;
20217
+ if (ch === "|" && d === 0) {
20218
+ branches.push(current);
20219
+ current = "";
20220
+ continue;
20221
+ }
20222
+ current += ch;
20223
+ }
20224
+ branches.push(current);
20225
+ if (branches.length < 2) continue;
20226
+ for (let a = 0; a < branches.length; a++) {
20227
+ for (let b = a + 1; b < branches.length; b++) {
20228
+ const x = branches[a];
20229
+ const y = branches[b];
20230
+ if (x === "" || y === "") return true;
20231
+ if (x === y || x.startsWith(y) || y.startsWith(x)) return true;
20232
+ }
20233
+ }
20234
+ }
20235
+ return false;
20236
+ }
20153
20237
  function compileUserRegex(pattern, flags) {
20154
20238
  if (typeof pattern !== "string") {
20155
20239
  return { ok: false, reason: "pattern must be a string" };
@@ -20168,6 +20252,12 @@ function compileUserRegex(pattern, flags) {
20168
20252
  };
20169
20253
  }
20170
20254
  }
20255
+ if (hasAmbiguousQuantifiedAlternation(pattern)) {
20256
+ return {
20257
+ ok: false,
20258
+ reason: "pattern quantifies an alternation with overlapping branches \u2014 rewrite so no two branches can match the same text"
20259
+ };
20260
+ }
20171
20261
  try {
20172
20262
  return { ok: true, regex: new RegExp(pattern, flags) };
20173
20263
  } catch (err) {
@@ -20184,7 +20274,7 @@ function capSubject(line) {
20184
20274
 
20185
20275
  // src/grep.ts
20186
20276
  init_util();
20187
- var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
20277
+ var DEFAULT_IGNORE3 = new Set(DEFAULT_WALK_IGNORE_DIRS3);
20188
20278
  var NATIVE_SCAN_CONCURRENCY = 32;
20189
20279
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
20190
20280
  var NATIVE_MAX_FILE_BYTES = 1e6;
@@ -20257,7 +20347,7 @@ var grepTool = {
20257
20347
  field: "pattern"
20258
20348
  });
20259
20349
  }
20260
- const base = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
20350
+ const base = input.path ? await safeResolveReal(input.path, ctx) : ctx.cwd;
20261
20351
  const mode = input.output_mode ?? "content";
20262
20352
  const limit = Math.max(1, Math.min(input.limit ?? 200, 2e3));
20263
20353
  const validation = compileUserRegex(input.pattern, input.case_insensitive ? "i" : "");
@@ -20582,7 +20672,7 @@ async function runNative(input, base, mode, limit, signal) {
20582
20672
  const subdirs = [];
20583
20673
  for (const e of entries) {
20584
20674
  if (stopped) return;
20585
- if (DEFAULT_IGNORE3.includes(e.name)) continue;
20675
+ if (DEFAULT_IGNORE3.has(e.name)) continue;
20586
20676
  if (e.isSymbolicLink()) continue;
20587
20677
  const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
20588
20678
  const full = path31.join(dir, e.name);
@@ -24057,12 +24147,12 @@ import { spawn as spawn13 } from "node:child_process";
24057
24147
  import * as fs27 from "node:fs/promises";
24058
24148
  import * as os9 from "node:os";
24059
24149
  import * as path32 from "node:path";
24060
- import { buildChildEnv as buildChildEnv8 } from "@wrongstack/core/utils";
24150
+ import { buildChildEnv as buildChildEnv8, toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
24061
24151
  var patchTool = {
24062
24152
  name: "patch",
24063
24153
  category: "Filesystem",
24064
24154
  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.",
24065
- 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.",
24155
+ 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- Applied with `--merge`: a conflicting hunk writes git-style conflict\n markers (<<<<<<< / ======= / >>>>>>>) INTO the file and reports failure.\n It does NOT create .rej/.orig files. `files` lists what changed on disk\n even when the patch failed, so read those back before retrying.\nOften cleaner than many small `edit` operations for larger changes.",
24066
24156
  selection: {
24067
24157
  doNotUseWhen: "you do not already have a unified diff or only need one precise replacement.",
24068
24158
  useInstead: ["edit"]
@@ -24088,31 +24178,50 @@ var patchTool = {
24088
24178
  },
24089
24179
  async execute(input, ctx, opts) {
24090
24180
  if (!input?.patch) throw new Error("patch: patch content is required");
24091
- const dir = input.directory ? safeResolve(input.directory, ctx) : ctx.cwd;
24092
24181
  const strip = Math.max(1, input.strip ?? 1);
24093
24182
  const dryRun = input.dry_run ?? false;
24183
+ const refuse = (message) => ({
24184
+ applied: 0,
24185
+ rejected: 1,
24186
+ files: [],
24187
+ dry_run: dryRun,
24188
+ message
24189
+ });
24190
+ let dir;
24191
+ try {
24192
+ dir = input.directory ? await safeResolveReal(input.directory, ctx) : ctx.cwd;
24193
+ } catch (err) {
24194
+ return refuse(`patch refused: ${toErrorMessage4(err)}`);
24195
+ }
24196
+ const realRoot = await fs27.realpath(ctx.projectRoot).catch(() => path32.resolve(ctx.projectRoot));
24094
24197
  const targets = extractDiffTargets(input.patch);
24095
24198
  const resolvedTargets = [];
24096
24199
  for (const t of targets) {
24097
- const stripped = stripPathComponents(t, strip);
24200
+ const stripped = stripPathComponents(t.raw, strip);
24098
24201
  if (!stripped) continue;
24202
+ if (path32.isAbsolute(stripped)) {
24203
+ return refuse(`patch refused: target "${t.raw}" strips to absolute path`);
24204
+ }
24099
24205
  const candidate = path32.resolve(dir, stripped);
24100
- const rel = path32.relative(ctx.projectRoot, candidate);
24206
+ let real;
24207
+ try {
24208
+ real = await resolveRealInsideRoot(candidate, ctx);
24209
+ } catch (err) {
24210
+ return refuse(`patch refused: target "${t.raw}" ${toErrorMessage4(err)}`);
24211
+ }
24212
+ const rel = path32.relative(realRoot, real);
24101
24213
  if (rel.startsWith("..") || path32.isAbsolute(rel)) {
24102
- return {
24103
- applied: 0,
24104
- rejected: 1,
24105
- files: [],
24106
- dry_run: dryRun,
24107
- message: `patch refused: target "${t}" resolves outside project root`
24108
- };
24214
+ return refuse(`patch refused: target "${t.raw}" resolves outside project root`);
24109
24215
  }
24110
- resolvedTargets.push(candidate);
24216
+ resolvedTargets.push({ raw: t.raw, deleted: t.deleted, abs: real });
24111
24217
  }
24112
24218
  const beforeContents = /* @__PURE__ */ new Map();
24219
+ const beforeExisted = /* @__PURE__ */ new Set();
24113
24220
  if (!dryRun) {
24114
24221
  for (const target of resolvedTargets) {
24115
- beforeContents.set(target, await readTextForTracking(target));
24222
+ const existed = (await fs27.stat(target.abs).catch(() => null))?.isFile() ?? false;
24223
+ if (existed) beforeExisted.add(target.abs);
24224
+ beforeContents.set(target.abs, await readTextForTracking(target.abs));
24116
24225
  }
24117
24226
  }
24118
24227
  const tmpDir = await fs27.mkdtemp(path32.join(os9.tmpdir(), ".wstack_patch_"));
@@ -24122,32 +24231,70 @@ var patchTool = {
24122
24231
  const patchFile = path32.join(tmpDir, "in.diff");
24123
24232
  await fs27.writeFile(patchFile, input.patch, { mode: 384 });
24124
24233
  const args = [`-p${strip}`, "--merge", ...dryRun ? ["--dry-run"] : [], "-i", patchFile];
24125
- const result = await runPatch(args, dir, opts.signal);
24126
- if (result.exitCode !== 0 && !dryRun) {
24127
- return {
24128
- applied: 0,
24129
- rejected: 1,
24130
- files: [],
24131
- dry_run: dryRun,
24132
- message: `patch failed: ${result.stderr || result.stdout}`
24133
- };
24134
- }
24135
- const patched = extractPatchedFiles(result.stdout);
24234
+ const result = await runPatch(args, dir, opts.signal, {
24235
+ patchFile,
24236
+ strip,
24237
+ dryRun
24238
+ });
24239
+ const touched = [];
24136
24240
  if (!dryRun) {
24137
24241
  for (const target of resolvedTargets) {
24138
- const before = beforeContents.get(target) ?? null;
24139
- const after = await readTextForTracking(target);
24242
+ const abs = target.abs;
24243
+ const before = beforeContents.get(abs) ?? null;
24244
+ const stat19 = await fs27.stat(abs).catch(() => null);
24245
+ if (!stat19?.isFile()) {
24246
+ if (beforeExisted.has(abs)) {
24247
+ touched.push(abs);
24248
+ ctx.session?.recordFileChange?.({
24249
+ path: abs,
24250
+ action: "deleted",
24251
+ before,
24252
+ after: null
24253
+ });
24254
+ }
24255
+ continue;
24256
+ }
24257
+ const after = await readTextForTracking(abs);
24140
24258
  if (after === null || after === before) continue;
24141
- const stat19 = await fs27.stat(target).catch(() => null);
24142
- if (stat19) ctx.recordRead?.(target, stat19.mtimeMs, "write", sha256hex(after));
24259
+ touched.push(abs);
24260
+ ctx.recordRead?.(abs, stat19.mtimeMs, "write", sha256hex(after));
24143
24261
  ctx.session?.recordFileChange?.({
24144
- path: target,
24262
+ path: abs,
24145
24263
  action: before === null ? "created" : "modified",
24146
24264
  before,
24147
24265
  after
24148
24266
  });
24149
24267
  }
24150
24268
  }
24269
+ if (result.exitCode !== 0) {
24270
+ if (!dryRun) {
24271
+ const partial = touched.length > 0 ? ` ${touched.length} file(s) were still modified on disk and have been recorded for rewind: ${touched.map((p) => path32.relative(realRoot, p) || p).join(", ")}.` : "";
24272
+ return {
24273
+ applied: touched.length,
24274
+ rejected: 1,
24275
+ // Normalize to relative-to-realRoot for API consistency with the
24276
+ // success path (which returns GNU patch's dir-relative names).
24277
+ // `touched` entries are realpaths from resolveRealInsideRoot, and
24278
+ // realRoot is also a realpath, so path.relative is like-for-like.
24279
+ files: touched.map((p) => path32.relative(realRoot, p) || p),
24280
+ dry_run: dryRun,
24281
+ message: `patch failed: ${result.stderr || result.stdout}${partial}`
24282
+ };
24283
+ }
24284
+ const wouldPatch = extractPatchedFiles(result.stdout);
24285
+ return {
24286
+ applied: wouldPatch.length,
24287
+ rejected: 1,
24288
+ files: wouldPatch,
24289
+ dry_run: dryRun,
24290
+ message: `patch preview: would conflict \u2014 ${result.stderr || result.stdout}`
24291
+ };
24292
+ }
24293
+ const patched = result.engine === "git" ? [
24294
+ ...new Set(
24295
+ resolvedTargets.map((target) => path32.relative(dir, target.abs) || target.abs)
24296
+ )
24297
+ ] : extractPatchedFiles(result.stdout);
24151
24298
  return {
24152
24299
  applied: patched.length,
24153
24300
  rejected: 0,
@@ -24175,27 +24322,86 @@ async function readTextForTracking(absPath) {
24175
24322
  }
24176
24323
  function extractDiffTargets(patch) {
24177
24324
  const out = [];
24178
- const re = /^\+\+\+\s+([^\t\r\n]+)/gm;
24179
- for (const m of patch.matchAll(re)) {
24180
- const raw = m[1];
24181
- if (!raw) continue;
24182
- const target = raw.length > 4096 ? raw.slice(0, 4096).trim() : raw.trim();
24183
- if (!target || target === "/dev/null") continue;
24184
- out.push(target);
24325
+ const clean = (raw) => {
24326
+ if (!raw) return "";
24327
+ return (raw.length > 4096 ? raw.slice(0, 4096) : raw).trim();
24328
+ };
24329
+ let lastOld;
24330
+ let inHunk = false;
24331
+ let oldLinesLeft = 0;
24332
+ let newLinesLeft = 0;
24333
+ for (const line of patch.split(/\r?\n/)) {
24334
+ const hunkMatch = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
24335
+ if (hunkMatch) {
24336
+ inHunk = true;
24337
+ oldLinesLeft = hunkMatch[1] ? Number(hunkMatch[1]) : 1;
24338
+ newLinesLeft = hunkMatch[2] ? Number(hunkMatch[2]) : 1;
24339
+ lastOld = void 0;
24340
+ continue;
24341
+ }
24342
+ if (inHunk) {
24343
+ const ch = line[0];
24344
+ if (ch === "-") oldLinesLeft--;
24345
+ else if (ch === "+") newLinesLeft--;
24346
+ else if (ch === " " || ch === void 0) {
24347
+ oldLinesLeft--;
24348
+ newLinesLeft--;
24349
+ }
24350
+ if (oldLinesLeft <= 0 && newLinesLeft <= 0) inHunk = false;
24351
+ continue;
24352
+ }
24353
+ const oldMatch = /^---\s+([^\t\r\n]+)/.exec(line);
24354
+ if (oldMatch) {
24355
+ lastOld = clean(oldMatch[1]);
24356
+ continue;
24357
+ }
24358
+ const newMatch = /^\+\+\+\s+([^\t\r\n]+)/.exec(line);
24359
+ if (!newMatch) continue;
24360
+ const newTarget = clean(newMatch[1]);
24361
+ if (newTarget && newTarget !== "/dev/null") {
24362
+ out.push({ raw: newTarget, deleted: false });
24363
+ } else if (lastOld && lastOld !== "/dev/null") {
24364
+ out.push({ raw: lastOld, deleted: true });
24365
+ }
24366
+ lastOld = void 0;
24185
24367
  }
24186
24368
  return out;
24187
24369
  }
24188
24370
  function stripPathComponents(p, strip) {
24189
- const parts = p.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".");
24190
- if (parts.length <= strip) return void 0;
24191
- return parts.slice(strip).join("/");
24371
+ const s = p.replace(/\\/g, "/");
24372
+ let idx = 0;
24373
+ for (let i = 0; i < strip; i++) {
24374
+ while (idx < s.length && s[idx] !== "/") idx++;
24375
+ let hadSlash = false;
24376
+ while (idx < s.length && s[idx] === "/") {
24377
+ idx++;
24378
+ hadSlash = true;
24379
+ }
24380
+ if (!hadSlash) return void 0;
24381
+ }
24382
+ return s.slice(idx) || void 0;
24383
+ }
24384
+ function runPatch(args, cwd, signal, fallback) {
24385
+ return runPatchProcess("patch", args, cwd, signal).then(async (result) => {
24386
+ if (!result.unavailable) return { ...result, engine: "patch" };
24387
+ const gitArgs = [
24388
+ "apply",
24389
+ "--unsafe-paths",
24390
+ `-p${fallback.strip}`,
24391
+ "--verbose",
24392
+ ...fallback.dryRun ? ["--check"] : [],
24393
+ fallback.patchFile
24394
+ ];
24395
+ const gitResult = await runPatchProcess("git", gitArgs, cwd, signal);
24396
+ return { ...gitResult, engine: "git" };
24397
+ });
24192
24398
  }
24193
- function runPatch(args, cwd, signal) {
24399
+ function runPatchProcess(command, args, cwd, signal) {
24194
24400
  return new Promise((resolve17) => {
24195
24401
  let stdout = "";
24196
24402
  let stderr = "";
24197
24403
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
24198
- const child = spawn13("patch", args, {
24404
+ const child = spawn13(command, args, {
24199
24405
  cwd,
24200
24406
  signal,
24201
24407
  env,
@@ -24208,13 +24414,24 @@ function runPatch(args, cwd, signal) {
24208
24414
  child.stderr?.on("data", (c) => {
24209
24415
  stderr += c.toString();
24210
24416
  });
24211
- child.on("close", (code) => resolve17({ exitCode: code ?? 1, stdout, stderr }));
24212
- child.on("error", (e) => resolve17({ exitCode: 1, stdout: "", stderr: e.message }));
24417
+ child.on(
24418
+ "close",
24419
+ (code) => resolve17({ exitCode: code ?? 1, stdout, stderr, unavailable: false })
24420
+ );
24421
+ child.on(
24422
+ "error",
24423
+ (e) => resolve17({
24424
+ exitCode: 1,
24425
+ stdout: "",
24426
+ stderr: e.message,
24427
+ unavailable: e.code === "ENOENT"
24428
+ })
24429
+ );
24213
24430
  });
24214
24431
  }
24215
24432
  function extractPatchedFiles(output) {
24216
24433
  const files = [];
24217
- const re = /patching file (.+)/gi;
24434
+ const re = /(?:patching|checking) file (.+)/gi;
24218
24435
  for (const m of output.matchAll(re)) {
24219
24436
  if (m[1]) files.push(m[1]);
24220
24437
  }
@@ -24523,7 +24740,7 @@ function mkResult(plan, ok, message, todos) {
24523
24740
  init_util();
24524
24741
  import * as fs28 from "node:fs/promises";
24525
24742
  import { FsError, ToolValidationError as ToolValidationError5 } from "@wrongstack/core/types";
24526
- import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
24743
+ import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
24527
24744
  var ADVANCED_MODE_META_KEY = "tools.read.advancedMode";
24528
24745
  var MAX_BYTES2 = 5 * 1024 * 1024;
24529
24746
  var readTool = {
@@ -24591,7 +24808,7 @@ var readTool = {
24591
24808
  });
24592
24809
  }
24593
24810
  throw new FsError({
24594
- message: `read: failed to stat "${input.path}": ${toErrorMessage4(err)}`,
24811
+ message: `read: failed to stat "${input.path}": ${toErrorMessage5(err)}`,
24595
24812
  code: "FS_READ_FAILED",
24596
24813
  path: absPath,
24597
24814
  context: { errno: code },
@@ -25250,7 +25467,7 @@ function substituteVars(content, name, vars) {
25250
25467
  // src/search.ts
25251
25468
  import { FetchError as FetchError3, ToolValidationError as ToolValidationError7 } from "@wrongstack/core/types";
25252
25469
  import { expectDefined as expectDefined9 } from "@wrongstack/core/utils";
25253
- import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
25470
+ import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
25254
25471
  var DEFAULT_NUM = 10;
25255
25472
  var MAX_RESULTS = 50;
25256
25473
  var TIMEOUT_MS3 = 15e3;
@@ -25454,7 +25671,7 @@ async function duckduckgoSearch(query, num, signal) {
25454
25671
  return parseDuckDuckGo(html, num);
25455
25672
  } catch (err) {
25456
25673
  console.log(
25457
- JSON.stringify({ level: "debug", event: "search_failed", query, error: toErrorMessage5(err) })
25674
+ JSON.stringify({ level: "debug", event: "search_failed", query, error: toErrorMessage6(err) })
25458
25675
  );
25459
25676
  return [{ title: "Search unavailable", url: "https://duckduckgo.com/unavailable", snippet: "Could not reach DuckDuckGo", score: 0 }];
25460
25677
  }
@@ -25638,7 +25855,7 @@ function decodeHtmlEntities(text) {
25638
25855
 
25639
25856
  // src/set-working-dir.ts
25640
25857
  import * as fs31 from "node:fs/promises";
25641
- import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
25858
+ import { toErrorMessage as toErrorMessage7 } from "@wrongstack/core/utils";
25642
25859
  var setWorkingDirTool = {
25643
25860
  name: "set_working_dir",
25644
25861
  category: "Context",
@@ -25672,7 +25889,7 @@ var setWorkingDirTool = {
25672
25889
  } catch (err) {
25673
25890
  return {
25674
25891
  current: ctx.workingDir,
25675
- error: toErrorMessage6(err)
25892
+ error: toErrorMessage7(err)
25676
25893
  };
25677
25894
  }
25678
25895
  try {
@@ -26407,7 +26624,7 @@ var toolHelpTool = {
26407
26624
  const format = input.format ?? "short";
26408
26625
  const includeExamples = input.include_examples ?? false;
26409
26626
  if (input.tool) {
26410
- const tool = ctx.tools.find((t) => t.name === input.tool);
26627
+ const tool = (ctx.catalogTools ?? ctx.tools).find((t) => t.name === input.tool);
26411
26628
  if (!tool) {
26412
26629
  return {
26413
26630
  tool: input.tool,
@@ -26432,7 +26649,7 @@ var toolHelpTool = {
26432
26649
  total: 1
26433
26650
  };
26434
26651
  }
26435
- const allTools = ctx.tools.map((t) => ({
26652
+ const allTools = (ctx.catalogTools ?? ctx.tools).map((t) => ({
26436
26653
  name: t.name,
26437
26654
  description: t.description,
26438
26655
  usageHint: t.usageHint ?? "",
@@ -26540,7 +26757,7 @@ var toolSearchTool = {
26540
26757
  },
26541
26758
  async execute(input, ctx) {
26542
26759
  const limit = Math.min(input.limit ?? 20, 100);
26543
- const tools = ctx.tools;
26760
+ const tools = ctx.catalogTools ?? ctx.tools;
26544
26761
  const query = input.query?.toLowerCase() ?? "";
26545
26762
  const filtered = tools.filter((t) => {
26546
26763
  if (query && !t.name.toLowerCase().includes(query) && !t.description.toLowerCase().includes(query)) {
@@ -26620,7 +26837,7 @@ var toolUseTool = {
26620
26837
  executionMs: 0
26621
26838
  };
26622
26839
  }
26623
- const tool = ctx.tools.find((t) => t.name === input.tool);
26840
+ const tool = (ctx.catalogTools ?? ctx.tools).find((t) => t.name === input.tool);
26624
26841
  if (!tool) {
26625
26842
  return {
26626
26843
  tool: input.tool,
@@ -26678,7 +26895,13 @@ init_util();
26678
26895
  import * as fs32 from "node:fs/promises";
26679
26896
  import * as path36 from "node:path";
26680
26897
  import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
26681
- var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
26898
+ var DEFAULT_IGNORE5 = /* @__PURE__ */ new Set([
26899
+ ...DEFAULT_WALK_IGNORE_DIRS4,
26900
+ ".wrongstack",
26901
+ ".ssh",
26902
+ ".gnupg",
26903
+ ".aws"
26904
+ ]);
26682
26905
  var DEFAULT_MAX_ENTRIES2 = 5e3;
26683
26906
  var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
26684
26907
  var treeTool = {
@@ -26839,8 +27062,12 @@ async function walkDir(dir, depth, opts) {
26839
27062
  return true;
26840
27063
  });
26841
27064
  if (depth > 0) {
26842
- const dirCount = filtered.filter((e) => e.isDirectory()).length;
26843
- const fileCount = filtered.filter((e) => e.isFile()).length;
27065
+ let dirCount = 0;
27066
+ let fileCount = 0;
27067
+ for (const e of filtered) {
27068
+ if (e.isDirectory()) dirCount++;
27069
+ else if (e.isFile()) fileCount++;
27070
+ }
26844
27071
  opts.totalDirs.value += dirCount;
26845
27072
  opts.totalFiles.value += fileCount;
26846
27073
  opts.onProgress?.();
@@ -27521,6 +27748,62 @@ ${mode.description}`
27521
27748
  };
27522
27749
  }
27523
27750
 
27751
+ // src/next-steps-tool.ts
27752
+ import {
27753
+ MAX_PENDING_NEXT_STEPS,
27754
+ writePendingNextSteps
27755
+ } from "@wrongstack/core/agent";
27756
+ var nextStepsTool = {
27757
+ name: "nextsteps",
27758
+ category: "Session",
27759
+ description: "Record the after-task follow-on suggestions for this turn. Equivalent to ending your final message with a <nextsteps> block \u2014 use whichever you prefer. The list is fully replaced on every call (not appended).",
27760
+ usageHint: 'Call this at most once, on the turn you are finishing the work \u2014 not mid-task.\n- Each `text` is the **exact prompt message** that gets submitted back to you when the user picks it. Write agent-directed work ("Run the parser tests and fix any failures"), never a chore for the user to do by hand.\n- Order by priority; 1-4 items. Do not pad with filler or invent work to fill the list.\n- `auto: true` is honored on the first item only. Set it when that prompt is safe to run unattended \u2014 YOLO+auto executes it verbatim, so it must be complete and self-contained.\n- Omit the call entirely while any todo is still `pending` or `in_progress`; suggestions recorded in that state are discarded.\n- If you also write a <nextsteps> block in your message, that block wins.',
27761
+ permission: "auto",
27762
+ mutating: false,
27763
+ // mutates only turn-scoped conversation state — no confirmation needed
27764
+ timeoutMs: 5e3,
27765
+ capabilities: ["session.nextsteps"],
27766
+ icon: "todo",
27767
+ inputSchema: {
27768
+ type: "object",
27769
+ properties: {
27770
+ steps: {
27771
+ type: "array",
27772
+ minItems: 1,
27773
+ maxItems: MAX_PENDING_NEXT_STEPS,
27774
+ items: {
27775
+ type: "object",
27776
+ properties: {
27777
+ text: {
27778
+ type: "string",
27779
+ description: "The exact natural-language prompt message to submit back to the agent when the user selects this item."
27780
+ },
27781
+ auto: {
27782
+ type: "boolean",
27783
+ description: "Safe to run unattended. Honored on the first item only; ignored elsewhere."
27784
+ }
27785
+ },
27786
+ required: ["text"]
27787
+ },
27788
+ description: `The complete suggestion list (1-${MAX_PENDING_NEXT_STEPS} items), highest priority first. Replaces any previous list for this turn.`
27789
+ }
27790
+ },
27791
+ required: ["steps"]
27792
+ },
27793
+ async execute(input, ctx) {
27794
+ if (!Array.isArray(input?.steps)) {
27795
+ throw new Error("nextsteps: steps must be an array");
27796
+ }
27797
+ const steps = input.steps.filter((s) => typeof s?.text === "string").map((s) => s.auto === true ? { text: s.text, auto: true } : { text: s.text });
27798
+ if (steps.length === 0) {
27799
+ throw new Error("nextsteps: steps must contain at least one item with a non-empty text");
27800
+ }
27801
+ writePendingNextSteps(ctx, steps);
27802
+ const accepted = Math.min(steps.length, MAX_PENDING_NEXT_STEPS);
27803
+ return { accepted, auto: steps[0]?.auto === true };
27804
+ }
27805
+ };
27806
+
27524
27807
  // src/pack.ts
27525
27808
  var builtinToolsPack = {
27526
27809
  name: "builtin-tools",
@@ -28121,6 +28404,11 @@ function createGlobalPsSlashCommand() {
28121
28404
  // src/skill.ts
28122
28405
  import * as fs34 from "node:fs/promises";
28123
28406
  import * as path38 from "node:path";
28407
+ import {
28408
+ missingRequiredRuntimeTools,
28409
+ missingRuntimeCapabilities,
28410
+ runtimeToolReferencesFromText
28411
+ } from "@wrongstack/core/agent-catalog";
28124
28412
  import { SKILL_LIMITS, stripFrontmatter } from "@wrongstack/core/skills";
28125
28413
  import { ToolValidationError as ToolValidationError10 } from "@wrongstack/core/types";
28126
28414
  var MAX_BODY_CHARS = SKILL_LIMITS.MAX_SKILL_BODY_CHARS;
@@ -28164,12 +28452,37 @@ function makeSkillTool(skillLoader) {
28164
28452
  field: "name"
28165
28453
  });
28166
28454
  }
28455
+ const availableToolNames = (ctx?.catalogTools ?? ctx?.tools ?? []).map((tool) => tool.name);
28456
+ const missingCapabilities = missingRuntimeCapabilities(
28457
+ manifest.requiredCapabilities,
28458
+ availableToolNames
28459
+ );
28460
+ const missingTools = missingRequiredRuntimeTools(manifest.requiredTools, availableToolNames);
28461
+ if (missingCapabilities.length > 0 || missingTools.length > 0) {
28462
+ throw new ToolValidationError10({
28463
+ message: `skill "${name}" is unavailable in this runtime; ` + [
28464
+ missingCapabilities.length > 0 ? `missing capabilities: ${missingCapabilities.join(", ")}` : "",
28465
+ missingTools.length > 0 ? `missing tools: ${missingTools.join(", ")}` : ""
28466
+ ].filter(Boolean).join("; "),
28467
+ field: "name"
28468
+ });
28469
+ }
28167
28470
  const dir = path38.dirname(manifest.path);
28168
28471
  let loadedResource;
28169
28472
  if (input.resource?.trim()) {
28170
28473
  loadedResource = await loadResource(dir, input.resource.trim());
28171
28474
  }
28172
28475
  const raw = await skillLoader.readBody(name);
28476
+ const missingBodyTools = missingRequiredRuntimeTools(
28477
+ runtimeToolReferencesFromText(raw),
28478
+ availableToolNames
28479
+ );
28480
+ if (missingBodyTools.length > 0) {
28481
+ throw new ToolValidationError10({
28482
+ message: `skill "${name}" references unregistered tools: ${missingBodyTools.join(", ")}`,
28483
+ field: "name"
28484
+ });
28485
+ }
28173
28486
  const body = stripFrontmatter(raw).trim().slice(0, MAX_BODY_CHARS);
28174
28487
  const resources = loadedResource ? [] : await listResources(dir);
28175
28488
  try {
@@ -28228,9 +28541,26 @@ async function loadResource(skillDir, rel) {
28228
28541
  field: "resource"
28229
28542
  });
28230
28543
  }
28544
+ let realPath;
28545
+ let realRoot;
28546
+ try {
28547
+ realRoot = await fs34.realpath(root);
28548
+ realPath = await fs34.realpath(absPath);
28549
+ } catch {
28550
+ throw new ToolValidationError10({
28551
+ message: `skill: resource "${rel}" not readable`,
28552
+ field: "resource"
28553
+ });
28554
+ }
28555
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path38.sep)) {
28556
+ throw new ToolValidationError10({
28557
+ message: `skill: resource "${rel}" resolves outside the skill directory`,
28558
+ field: "resource"
28559
+ });
28560
+ }
28231
28561
  let buf;
28232
28562
  try {
28233
- buf = await fs34.readFile(absPath);
28563
+ buf = await fs34.readFile(realPath);
28234
28564
  } catch {
28235
28565
  throw new ToolValidationError10({
28236
28566
  message: `skill: resource "${rel}" not readable`,
@@ -28241,7 +28571,9 @@ async function loadResource(skillDir, rel) {
28241
28571
  const truncated = raw.length > MAX_RESOURCE_CHARS;
28242
28572
  return {
28243
28573
  rel: norm,
28244
- absPath,
28574
+ // The canonical path — the one actually opened, and the one a follow-up
28575
+ // `bash` invocation should use.
28576
+ absPath: realPath,
28245
28577
  content: truncated ? raw.slice(0, MAX_RESOURCE_CHARS) : raw,
28246
28578
  bytes: buf.length,
28247
28579
  truncated
@@ -28349,6 +28681,9 @@ var TOOL_ICON_MAP = {
28349
28681
  // Task management
28350
28682
  todo: "todo",
28351
28683
  todos: "todo",
28684
+ // After-task suggestions — same family as the todo board, so it reuses the
28685
+ // icon rather than widening ToolIconId (every UI maps that union by hand).
28686
+ nextsteps: "todo",
28352
28687
  // Planning
28353
28688
  plan: "plan",
28354
28689
  planning: "plan",
@@ -28475,11 +28810,7 @@ function selectBuiltinToolsForTier(tier, allTools) {
28475
28810
  }
28476
28811
  case "aggressive": {
28477
28812
  const tier1Names = toolNameSet(TIER1_TOOLS);
28478
- const tier2Names = toolNameSet(TIER2_TOOLS);
28479
- const tier3Names = toolNameSet(TIER3_TOOLS);
28480
- return allTools.filter(
28481
- (tool) => tier1Names.has(tool.name) || tier2Names.has(tool.name) && tool.name !== "task" || tier3Names.has(tool.name) && tool.name !== "set_working_dir"
28482
- );
28813
+ return allTools.filter((tool) => tier1Names.has(tool.name));
28483
28814
  }
28484
28815
  }
28485
28816
  }
@@ -28606,6 +28937,7 @@ export {
28606
28937
  mirrorSessionPlanToKanban,
28607
28938
  mirrorSessionTasksToKanban,
28608
28939
  mirrorSessionTodosToKanban,
28940
+ nextStepsTool,
28609
28941
  normalizeShell,
28610
28942
  onIndexStateChange,
28611
28943
  outdatedTool,