@wrongstack/tools 0.297.0 → 0.298.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 (253) hide show
  1. package/dist/_spawn-stream.d.ts +2 -0
  2. package/dist/_util.d.ts +29 -1
  3. package/dist/audit.js +113 -65
  4. package/dist/bash.js +5 -4
  5. package/dist/batch-tool-use.js +70 -7
  6. package/dist/browser/index.js +31 -1
  7. package/dist/builtin.js +841 -351
  8. package/dist/codebase-index/index.js +68 -16
  9. package/dist/codebase-index/project-server-protocol.d.ts +26 -0
  10. package/dist/codebase-index/project-server.js +60 -16
  11. package/dist/codebase-index/worker.js +36 -13
  12. package/dist/codebase-index/writer-search-helpers.d.ts +16 -0
  13. package/dist/codebase-index/writer.d.ts +13 -1
  14. package/dist/design.d.ts +1 -1
  15. package/dist/design.js +4 -3
  16. package/dist/diff.js +11 -1
  17. package/dist/e2e.js +9 -6
  18. package/dist/edit.js +9 -6
  19. package/dist/exec.js +560 -529
  20. package/dist/format.js +116 -65
  21. package/dist/git.js +22 -2
  22. package/dist/glob.js +11 -5
  23. package/dist/grep.js +160 -104
  24. package/dist/index.js +792 -290
  25. package/dist/install.js +120 -68
  26. package/dist/json.js +64 -18
  27. package/dist/kanban-board-inputs.d.ts +21 -4
  28. package/dist/kanban.js +20 -5
  29. package/dist/languages/index.js +128 -71
  30. package/dist/lint.js +113 -65
  31. package/dist/outdated.js +86 -26
  32. package/dist/pack.js +841 -351
  33. package/dist/patch.js +11 -1
  34. package/dist/plan.js +32 -10
  35. package/dist/read.js +77 -23
  36. package/dist/replace.js +11 -3
  37. package/dist/scaffold.js +9 -6
  38. package/dist/session-kanban.js +21 -5
  39. package/dist/task.js +3 -1
  40. package/dist/test.js +113 -65
  41. package/dist/todo.js +3 -1
  42. package/dist/tool-tier.js +841 -351
  43. package/dist/tool-use.d.ts +1 -1
  44. package/dist/tool-use.js +28 -4
  45. package/dist/tree.d.ts +1 -0
  46. package/dist/tree.js +44 -8
  47. package/dist/typecheck.js +113 -65
  48. package/dist/write.js +9 -6
  49. package/package.json +5 -4
  50. package/dist/_concurrency.d.ts.map +0 -1
  51. package/dist/_danger-detect.d.ts.map +0 -1
  52. package/dist/_edit-match.d.ts.map +0 -1
  53. package/dist/_env.d.ts.map +0 -1
  54. package/dist/_fetch-guard.d.ts.map +0 -1
  55. package/dist/_output-spool.d.ts.map +0 -1
  56. package/dist/_redact-command.d.ts.map +0 -1
  57. package/dist/_regex.d.ts.map +0 -1
  58. package/dist/_session-shell.d.ts.map +0 -1
  59. package/dist/_shell-pick.d.ts.map +0 -1
  60. package/dist/_spawn-stream.d.ts.map +0 -1
  61. package/dist/_syntax-check.d.ts.map +0 -1
  62. package/dist/_util.d.ts.map +0 -1
  63. package/dist/_win32-resolve.d.ts.map +0 -1
  64. package/dist/audit.d.ts.map +0 -1
  65. package/dist/audit.js.map +0 -7
  66. package/dist/auto-proceed-loop-guard.d.ts.map +0 -1
  67. package/dist/auto-proceed-loop-guard.js.map +0 -7
  68. package/dist/bash-kill-guard.d.ts.map +0 -1
  69. package/dist/bash.d.ts.map +0 -1
  70. package/dist/bash.js.map +0 -7
  71. package/dist/batch-tool-use.d.ts.map +0 -1
  72. package/dist/batch-tool-use.js.map +0 -7
  73. package/dist/browser/artifacts.d.ts.map +0 -1
  74. package/dist/browser/index.d.ts.map +0 -1
  75. package/dist/browser/index.js.map +0 -7
  76. package/dist/browser/manager.d.ts.map +0 -1
  77. package/dist/browser/network-guard-proxy.d.ts.map +0 -1
  78. package/dist/browser/security.d.ts.map +0 -1
  79. package/dist/browser/tools.d.ts.map +0 -1
  80. package/dist/browser/types.d.ts.map +0 -1
  81. package/dist/builtin.d.ts.map +0 -1
  82. package/dist/builtin.js.map +0 -7
  83. package/dist/circuit-breaker.d.ts.map +0 -1
  84. package/dist/circuit-breaker.js.map +0 -7
  85. package/dist/codebase-index/background-indexer.d.ts.map +0 -1
  86. package/dist/codebase-index/bm25.d.ts.map +0 -1
  87. package/dist/codebase-index/circuit-breaker.d.ts.map +0 -1
  88. package/dist/codebase-index/codebase-index-tool.d.ts.map +0 -1
  89. package/dist/codebase-index/codebase-search-tool.d.ts.map +0 -1
  90. package/dist/codebase-index/codebase-stats-tool.d.ts.map +0 -1
  91. package/dist/codebase-index/dead-code-scan.d.ts.map +0 -1
  92. package/dist/codebase-index/generic-parser.d.ts.map +0 -1
  93. package/dist/codebase-index/gitignore.d.ts.map +0 -1
  94. package/dist/codebase-index/go-parser.d.ts.map +0 -1
  95. package/dist/codebase-index/index-service.d.ts.map +0 -1
  96. package/dist/codebase-index/index.d.ts.map +0 -1
  97. package/dist/codebase-index/index.js.map +0 -7
  98. package/dist/codebase-index/indexer.d.ts.map +0 -1
  99. package/dist/codebase-index/json-parser.d.ts.map +0 -1
  100. package/dist/codebase-index/languages.d.ts.map +0 -1
  101. package/dist/codebase-index/lsp-kind.d.ts.map +0 -1
  102. package/dist/codebase-index/parser-dispatch.d.ts.map +0 -1
  103. package/dist/codebase-index/project-server-cache.d.ts.map +0 -1
  104. package/dist/codebase-index/project-server-client.d.ts.map +0 -1
  105. package/dist/codebase-index/project-server-endpoint.d.ts.map +0 -1
  106. package/dist/codebase-index/project-server-protocol.d.ts.map +0 -1
  107. package/dist/codebase-index/project-server.d.ts.map +0 -1
  108. package/dist/codebase-index/project-server.js.map +0 -7
  109. package/dist/codebase-index/py-parser.d.ts.map +0 -1
  110. package/dist/codebase-index/refs-extractor.d.ts.map +0 -1
  111. package/dist/codebase-index/rs-parser.d.ts.map +0 -1
  112. package/dist/codebase-index/schema.d.ts.map +0 -1
  113. package/dist/codebase-index/spawn-gate.d.ts.map +0 -1
  114. package/dist/codebase-index/sqlite-runtime.d.ts.map +0 -1
  115. package/dist/codebase-index/ts-parser.d.ts.map +0 -1
  116. package/dist/codebase-index/worker-protocol.d.ts.map +0 -1
  117. package/dist/codebase-index/worker.d.ts.map +0 -1
  118. package/dist/codebase-index/worker.js.map +0 -7
  119. package/dist/codebase-index/writer-admin.d.ts.map +0 -1
  120. package/dist/codebase-index/writer-bulk-insert.d.ts.map +0 -1
  121. package/dist/codebase-index/writer-graph-helpers.d.ts.map +0 -1
  122. package/dist/codebase-index/writer-graph-reader.d.ts.map +0 -1
  123. package/dist/codebase-index/writer-helpers.d.ts.map +0 -1
  124. package/dist/codebase-index/writer-pragmas.d.ts.map +0 -1
  125. package/dist/codebase-index/writer-ref-mapper.d.ts.map +0 -1
  126. package/dist/codebase-index/writer-schema.d.ts.map +0 -1
  127. package/dist/codebase-index/writer-search-helpers.d.ts.map +0 -1
  128. package/dist/codebase-index/writer-store-pool.d.ts.map +0 -1
  129. package/dist/codebase-index/writer.d.ts.map +0 -1
  130. package/dist/codebase-index/yaml-parser.d.ts.map +0 -1
  131. package/dist/design.d.ts.map +0 -1
  132. package/dist/design.js.map +0 -7
  133. package/dist/diff.d.ts.map +0 -1
  134. package/dist/diff.js.map +0 -7
  135. package/dist/document.d.ts.map +0 -1
  136. package/dist/document.js.map +0 -7
  137. package/dist/e2e.d.ts.map +0 -1
  138. package/dist/e2e.js.map +0 -7
  139. package/dist/edit.d.ts.map +0 -1
  140. package/dist/edit.js.map +0 -7
  141. package/dist/exec-kill-guard.d.ts.map +0 -1
  142. package/dist/exec.d.ts.map +0 -1
  143. package/dist/exec.js.map +0 -7
  144. package/dist/fetch.d.ts.map +0 -1
  145. package/dist/fetch.js.map +0 -7
  146. package/dist/format.d.ts.map +0 -1
  147. package/dist/format.js.map +0 -7
  148. package/dist/git.d.ts.map +0 -1
  149. package/dist/git.js.map +0 -7
  150. package/dist/glob.d.ts.map +0 -1
  151. package/dist/glob.js.map +0 -7
  152. package/dist/grep.d.ts.map +0 -1
  153. package/dist/grep.js.map +0 -7
  154. package/dist/index.d.ts.map +0 -1
  155. package/dist/index.js.map +0 -7
  156. package/dist/install.d.ts.map +0 -1
  157. package/dist/install.js.map +0 -7
  158. package/dist/json.d.ts.map +0 -1
  159. package/dist/json.js.map +0 -7
  160. package/dist/kanban-board-inputs.d.ts.map +0 -1
  161. package/dist/kanban-decomposition-actions.d.ts.map +0 -1
  162. package/dist/kanban-detail-actions.d.ts.map +0 -1
  163. package/dist/kanban-evidence-bridge.d.ts.map +0 -1
  164. package/dist/kanban-presence.d.ts.map +0 -1
  165. package/dist/kanban-split-task-handler.d.ts.map +0 -1
  166. package/dist/kanban-task-inputs.d.ts.map +0 -1
  167. package/dist/kanban-tool-results.d.ts.map +0 -1
  168. package/dist/kanban-tool-schema.d.ts.map +0 -1
  169. package/dist/kanban-tool-types.d.ts.map +0 -1
  170. package/dist/kanban.d.ts.map +0 -1
  171. package/dist/kanban.js.map +0 -7
  172. package/dist/languages/detect.d.ts.map +0 -1
  173. package/dist/languages/diagnostics.d.ts.map +0 -1
  174. package/dist/languages/execute-tool.d.ts.map +0 -1
  175. package/dist/languages/execute.d.ts.map +0 -1
  176. package/dist/languages/index.d.ts.map +0 -1
  177. package/dist/languages/index.js.map +0 -7
  178. package/dist/languages/legacy-bridge.d.ts.map +0 -1
  179. package/dist/languages/package-tool.d.ts.map +0 -1
  180. package/dist/languages/plan.d.ts.map +0 -1
  181. package/dist/languages/profile-helpers.d.ts.map +0 -1
  182. package/dist/languages/profiles/additional.d.ts.map +0 -1
  183. package/dist/languages/profiles/primary.d.ts.map +0 -1
  184. package/dist/languages/registry.d.ts.map +0 -1
  185. package/dist/languages/tool.d.ts.map +0 -1
  186. package/dist/languages/types.d.ts.map +0 -1
  187. package/dist/lint.d.ts.map +0 -1
  188. package/dist/lint.js.map +0 -7
  189. package/dist/logs.d.ts.map +0 -1
  190. package/dist/logs.js.map +0 -7
  191. package/dist/memory.d.ts.map +0 -1
  192. package/dist/memory.js.map +0 -7
  193. package/dist/mode.d.ts.map +0 -1
  194. package/dist/mode.js.map +0 -7
  195. package/dist/next-steps.d.ts.map +0 -1
  196. package/dist/next-steps.js.map +0 -7
  197. package/dist/outdated.d.ts.map +0 -1
  198. package/dist/outdated.js.map +0 -7
  199. package/dist/pack.d.ts.map +0 -1
  200. package/dist/pack.js.map +0 -7
  201. package/dist/patch.d.ts.map +0 -1
  202. package/dist/patch.js.map +0 -7
  203. package/dist/plan.d.ts.map +0 -1
  204. package/dist/plan.js.map +0 -7
  205. package/dist/process-guardian.d.ts.map +0 -1
  206. package/dist/process-registry-persistent.d.ts.map +0 -1
  207. package/dist/process-registry.d.ts.map +0 -1
  208. package/dist/process-registry.js.map +0 -7
  209. package/dist/ps-slash.d.ts.map +0 -1
  210. package/dist/ps-slash.js.map +0 -7
  211. package/dist/read.d.ts.map +0 -1
  212. package/dist/read.js.map +0 -7
  213. package/dist/replace.d.ts.map +0 -1
  214. package/dist/replace.js.map +0 -7
  215. package/dist/scaffold.d.ts.map +0 -1
  216. package/dist/scaffold.js.map +0 -7
  217. package/dist/search.d.ts.map +0 -1
  218. package/dist/search.js.map +0 -7
  219. package/dist/session-kanban.d.ts.map +0 -1
  220. package/dist/session-kanban.js.map +0 -7
  221. package/dist/set-working-dir.d.ts.map +0 -1
  222. package/dist/skill.d.ts.map +0 -1
  223. package/dist/skill.js.map +0 -7
  224. package/dist/spawn-background.d.ts.map +0 -1
  225. package/dist/task.d.ts.map +0 -1
  226. package/dist/task.js.map +0 -7
  227. package/dist/test.d.ts.map +0 -1
  228. package/dist/test.js.map +0 -7
  229. package/dist/todo.d.ts.map +0 -1
  230. package/dist/todo.js.map +0 -7
  231. package/dist/tool-diff.d.ts.map +0 -1
  232. package/dist/tool-diff.js.map +0 -7
  233. package/dist/tool-help.d.ts.map +0 -1
  234. package/dist/tool-help.js.map +0 -7
  235. package/dist/tool-icon-map.d.ts.map +0 -1
  236. package/dist/tool-icons.d.ts.map +0 -1
  237. package/dist/tool-icons.js.map +0 -7
  238. package/dist/tool-search.d.ts.map +0 -1
  239. package/dist/tool-search.js.map +0 -7
  240. package/dist/tool-summary.d.ts.map +0 -1
  241. package/dist/tool-summary.js.map +0 -7
  242. package/dist/tool-tier.d.ts.map +0 -1
  243. package/dist/tool-tier.js.map +0 -7
  244. package/dist/tool-use.d.ts.map +0 -1
  245. package/dist/tool-use.js.map +0 -7
  246. package/dist/tree.d.ts.map +0 -1
  247. package/dist/tree.js.map +0 -7
  248. package/dist/typecheck.d.ts.map +0 -1
  249. package/dist/typecheck.js.map +0 -7
  250. package/dist/win32.d.ts.map +0 -1
  251. package/dist/win32.js.map +0 -7
  252. package/dist/write.d.ts.map +0 -1
  253. package/dist/write.js.map +0 -7
package/dist/index.js CHANGED
@@ -148,11 +148,12 @@ function createOutputSpool(opts) {
148
148
  return {
149
149
  write(text) {
150
150
  if (finalized || !text) return;
151
- totalBytes += Buffer.byteLength(text, "utf8");
151
+ const textBytes = Buffer.byteLength(text, "utf8");
152
+ totalBytes += textBytes;
152
153
  if (!stream && !failed) {
153
- if (headBytes + text.length <= threshold) {
154
+ if (headBytes + textBytes <= threshold) {
154
155
  head += text;
155
- headBytes += text.length;
156
+ headBytes += textBytes;
156
157
  return;
157
158
  }
158
159
  head += text;
@@ -162,7 +163,7 @@ function createOutputSpool(opts) {
162
163
  }
163
164
  if (stream) {
164
165
  if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
165
- droppedBytes += Buffer.byteLength(text, "utf8");
166
+ droppedBytes += textBytes;
166
167
  return;
167
168
  }
168
169
  stream.write(text);
@@ -849,8 +850,11 @@ async function* spawnStream(opts) {
849
850
  const max = opts.maxBytes ?? 2e5;
850
851
  const flushAt = opts.flushBytes ?? 4 * 1024;
851
852
  const maxQueue = opts.maxQueueSize ?? 500;
853
+ const maxQueueBytes = opts.maxQueueBytes ?? 1024 * 1024;
852
854
  let stdout = "";
853
855
  let stderr = "";
856
+ let stdoutRetainedBytes = 0;
857
+ let stderrRetainedBytes = 0;
854
858
  let pending2 = "";
855
859
  let error;
856
860
  const spool = createOutputSpool({ tool: opts.cmd, thresholdBytes: max });
@@ -892,6 +896,7 @@ async function* spawnStream(opts) {
892
896
  });
893
897
  }
894
898
  const queue = [];
899
+ let queuedBytes = 0;
895
900
  let waiter;
896
901
  let paused = false;
897
902
  const wake = () => {
@@ -902,7 +907,7 @@ async function* spawnStream(opts) {
902
907
  }
903
908
  };
904
909
  const resume = () => {
905
- if (paused && queue.length < maxQueue) {
910
+ if (paused && queue.length <= Math.floor(maxQueue / 2) && queuedBytes <= Math.floor(maxQueueBytes / 2)) {
906
911
  paused = false;
907
912
  child.stdout?.resume();
908
913
  child.stderr?.resume();
@@ -912,11 +917,16 @@ async function* spawnStream(opts) {
912
917
  const s = c.toString();
913
918
  stdoutBytes += c.byteLength;
914
919
  emitProcessOutput({ pid, stream: "stdout", chunk: c });
915
- if (stdout.length < max) stdout += s;
920
+ if (stdoutRetainedBytes < max) {
921
+ const retained = utf8Prefix(s, max - stdoutRetainedBytes);
922
+ stdout += retained;
923
+ stdoutRetainedBytes += Buffer.byteLength(retained, "utf8");
924
+ }
916
925
  spool.write(s);
917
- queue.push({ kind: "out", data: s });
926
+ queue.push({ kind: "out", data: s, bytes: c.byteLength });
927
+ queuedBytes += c.byteLength;
918
928
  wake();
919
- if (!paused && queue.length >= maxQueue) {
929
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
920
930
  paused = true;
921
931
  child.stdout?.pause();
922
932
  child.stderr?.pause();
@@ -926,11 +936,16 @@ async function* spawnStream(opts) {
926
936
  const s = c.toString();
927
937
  stderrBytes += c.byteLength;
928
938
  emitProcessOutput({ pid, stream: "stderr", chunk: c });
929
- if (stderr.length < max) stderr += s;
939
+ if (stderrRetainedBytes < max) {
940
+ const retained = utf8Prefix(s, max - stderrRetainedBytes);
941
+ stderr += retained;
942
+ stderrRetainedBytes += Buffer.byteLength(retained, "utf8");
943
+ }
930
944
  spool.write(s);
931
- queue.push({ kind: "err", data: s });
945
+ queue.push({ kind: "err", data: s, bytes: c.byteLength });
946
+ queuedBytes += c.byteLength;
932
947
  wake();
933
- if (!paused && queue.length >= maxQueue) {
948
+ if (!paused && (queue.length >= maxQueue || queuedBytes >= maxQueueBytes)) {
934
949
  paused = true;
935
950
  child.stdout?.pause();
936
951
  child.stderr?.pause();
@@ -940,7 +955,9 @@ async function* spawnStream(opts) {
940
955
  child.stderr?.on("data", onErr);
941
956
  child.on("error", (e) => {
942
957
  error = e.message;
943
- queue.push({ kind: "error", data: e.message });
958
+ const bytes = Buffer.byteLength(e.message, "utf8");
959
+ queue.push({ kind: "error", data: e.message, bytes });
960
+ queuedBytes += bytes;
944
961
  wake();
945
962
  });
946
963
  const completeTelemetry = (code, signal, timedOut = false) => {
@@ -961,7 +978,13 @@ async function* spawnStream(opts) {
961
978
  if (typeof pid === "number") registry.unregister(pid);
962
979
  const exitCode2 = code ?? (signal ? 1 : 0);
963
980
  completeTelemetry(exitCode2, signal ?? void 0);
964
- queue.push({ kind: "close", data: "", code: exitCode2, ...signal ? { signal } : {} });
981
+ queue.push({
982
+ kind: "close",
983
+ data: "",
984
+ bytes: 0,
985
+ code: exitCode2,
986
+ ...signal ? { signal } : {}
987
+ });
965
988
  wake();
966
989
  });
967
990
  const onAbort = () => {
@@ -973,7 +996,7 @@ async function* spawnStream(opts) {
973
996
  } catch {
974
997
  }
975
998
  }
976
- queue.push({ kind: "close", data: "", code: 124 });
999
+ queue.push({ kind: "close", data: "", bytes: 0, code: 124 });
977
1000
  completeTelemetry(124, "SIGKILL", true);
978
1001
  wake();
979
1002
  };
@@ -991,6 +1014,7 @@ async function* spawnStream(opts) {
991
1014
  });
992
1015
  }
993
1016
  const chunk = queue.shift();
1017
+ queuedBytes -= chunk.bytes;
994
1018
  resume();
995
1019
  if (chunk.kind === "close") {
996
1020
  if (!spawnFailed) exitCode = chunk.code ?? 0;
@@ -1017,7 +1041,7 @@ async function* spawnStream(opts) {
1017
1041
  stdout: spooled ? stdout + spoolNote(spooled) : stdout,
1018
1042
  stderr,
1019
1043
  exitCode,
1020
- truncated: stdout.length >= max || stderr.length >= max,
1044
+ truncated: stdoutBytes > stdoutRetainedBytes || stderrBytes > stderrRetainedBytes,
1021
1045
  error,
1022
1046
  spoolPath: spooled?.path,
1023
1047
  spoolBytes: spooled?.bytes
@@ -1041,13 +1065,27 @@ async function* spawnStream(opts) {
1041
1065
  }
1042
1066
  }
1043
1067
  }
1068
+ function utf8Prefix(text, maxBytes) {
1069
+ if (maxBytes <= 0) return "";
1070
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
1071
+ let low = 0;
1072
+ let high = text.length;
1073
+ while (low < high) {
1074
+ const mid = Math.ceil((low + high) / 2);
1075
+ if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) low = mid;
1076
+ else high = mid - 1;
1077
+ }
1078
+ let end = low;
1079
+ if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
1080
+ return text.slice(0, end);
1081
+ }
1044
1082
  var isWin;
1045
1083
  var init_spawn_stream = __esm({
1046
1084
  "src/_spawn-stream.ts"() {
1047
1085
  "use strict";
1048
1086
  init_output_spool();
1049
- init_process_registry();
1050
1087
  init_win32_resolve();
1088
+ init_process_registry();
1051
1089
  isWin = process.platform === "win32";
1052
1090
  }
1053
1091
  });
@@ -1061,14 +1099,14 @@ function sha256hex(content) {
1061
1099
  return createHash("sha256").update(content, "utf8").digest("hex");
1062
1100
  }
1063
1101
  async function detectPackageManager(cwd) {
1064
- const { stat: stat18 } = await import("node:fs/promises");
1102
+ const { stat: stat19 } = await import("node:fs/promises");
1065
1103
  try {
1066
- await stat18(`${cwd}/pnpm-lock.yaml`);
1104
+ await stat19(`${cwd}/pnpm-lock.yaml`);
1067
1105
  return "pnpm";
1068
1106
  } catch {
1069
1107
  }
1070
1108
  try {
1071
- await stat18(`${cwd}/yarn.lock`);
1109
+ await stat19(`${cwd}/yarn.lock`);
1072
1110
  return "yarn";
1073
1111
  } catch {
1074
1112
  }
@@ -1096,11 +1134,15 @@ function safeResolve(input, ctx) {
1096
1134
  return ensureInsideRoot(resolvePath(input, ctx), ctx);
1097
1135
  }
1098
1136
  async function assertRealInsideRoot(absPath, ctx) {
1099
- if (ctx.allowOutsideProjectRoot) return;
1137
+ await resolveRealInsideRoot(absPath, ctx);
1138
+ }
1139
+ async function resolveRealInsideRoot(absPath, ctx) {
1140
+ if (ctx.allowOutsideProjectRoot) return absPath;
1100
1141
  const realRoots = await Promise.all(
1101
1142
  allowedRoots(ctx).map((r) => fsp2.realpath(r).catch(() => path3.resolve(r)))
1102
1143
  );
1103
1144
  let probe2 = absPath;
1145
+ const pendingTail = [];
1104
1146
  for (; ; ) {
1105
1147
  let real;
1106
1148
  try {
@@ -1108,13 +1150,16 @@ async function assertRealInsideRoot(absPath, ctx) {
1108
1150
  } catch (err) {
1109
1151
  if (err.code === "ENOENT") {
1110
1152
  const parent = path3.dirname(probe2);
1111
- if (parent === probe2) return;
1153
+ if (parent === probe2) return absPath;
1154
+ pendingTail.unshift(path3.basename(probe2));
1112
1155
  probe2 = parent;
1113
1156
  continue;
1114
1157
  }
1115
1158
  throw err;
1116
1159
  }
1117
- if (isInsideAny(real, realRoots)) return;
1160
+ if (isInsideAny(real, realRoots)) {
1161
+ return pendingTail.length > 0 ? path3.join(real, ...pendingTail) : real;
1162
+ }
1118
1163
  throw new Error(
1119
1164
  `Path "${absPath}" resolves through a symlink outside project root "${realRoots[0]}"`
1120
1165
  );
@@ -1122,8 +1167,7 @@ async function assertRealInsideRoot(absPath, ctx) {
1122
1167
  }
1123
1168
  async function safeResolveReal(input, ctx) {
1124
1169
  const abs = safeResolve(input, ctx);
1125
- await assertRealInsideRoot(abs, ctx);
1126
- return abs;
1170
+ return await resolveRealInsideRoot(abs, ctx);
1127
1171
  }
1128
1172
  function truncateMiddle(s, max) {
1129
1173
  if (Buffer.byteLength(s, "utf8") <= max) return s;
@@ -2687,8 +2731,8 @@ async function scanDirectory(directory, depth, profiles, limits, state, extraIgn
2687
2731
  collectFileEvidence(directory, fullPath, entry.name, profiles, state);
2688
2732
  }
2689
2733
  }
2690
- function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2691
- const lower = basename13.toLowerCase();
2734
+ function collectFileEvidence(directory, fullPath, basename14, profiles, state) {
2735
+ const lower = basename14.toLowerCase();
2692
2736
  const extension = path4.extname(lower);
2693
2737
  for (const profile of profiles) {
2694
2738
  const detector = profile.detectors.find(
@@ -2699,7 +2743,7 @@ function collectFileEvidence(directory, fullPath, basename13, profiles, state) {
2699
2743
  candidate.evidence.push({
2700
2744
  kind: detector.kind,
2701
2745
  path: fullPath,
2702
- value: basename13,
2746
+ value: basename14,
2703
2747
  weight: detector.weight
2704
2748
  });
2705
2749
  if (detector.kind === "manifest" || detector.kind === "config") {
@@ -2827,8 +2871,8 @@ function normalizeLimits(input) {
2827
2871
  async function canonicalDirectory(input) {
2828
2872
  const resolved = path4.resolve(input);
2829
2873
  const real = await fs2.realpath(resolved);
2830
- const stat18 = await fs2.stat(real);
2831
- if (!stat18.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2874
+ const stat19 = await fs2.stat(real);
2875
+ if (!stat19.isDirectory()) throw new Error(`Project root is not a directory: ${input}`);
2832
2876
  return real;
2833
2877
  }
2834
2878
  async function canonicalInside(input, root, label) {
@@ -3384,6 +3428,7 @@ import * as fs3 from "node:fs/promises";
3384
3428
  import * as path6 from "node:path";
3385
3429
  async function planLanguageOperation(options) {
3386
3430
  validateOperationOptions(options.operation, options.operationOptions?.packages);
3431
+ validateFilterOption(options.operationOptions?.filter);
3387
3432
  const detection = await detectLanguageWorkspaces(options);
3388
3433
  const candidates = detection.workspaces.filter(
3389
3434
  (workspace) => workspace.capabilities.includes(options.operation)
@@ -3556,6 +3601,15 @@ function workspaceDepth(workspaceRoot, projectRoot) {
3556
3601
  const relative13 = path6.relative(path6.resolve(projectRoot), path6.resolve(workspaceRoot));
3557
3602
  return relative13 === "" ? 0 : relative13.split(path6.sep).length;
3558
3603
  }
3604
+ function validateFilterOption(filter) {
3605
+ if (filter === void 0) return;
3606
+ if (filter.startsWith("-") || filter.includes(" --")) {
3607
+ throw new Error(`Invalid test filter (parsed as a runner option): "${filter}"`);
3608
+ }
3609
+ if (filter.length > MAX_ARGUMENT_LENGTH || /[\r\n\0]/.test(filter)) {
3610
+ throw new Error("Invalid test filter (oversized or contains a control character)");
3611
+ }
3612
+ }
3559
3613
  function validateOperationOptions(operation, packages) {
3560
3614
  if (!operation.startsWith("package-") || !packages) return;
3561
3615
  for (const value of packages) {
@@ -3597,7 +3651,7 @@ var init_plan = __esm({
3597
3651
  MAX_ARGUMENT_LENGTH = 4096;
3598
3652
  PACKAGE_NAME_RE = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+(?:@[a-z0-9*+._~^<>=|-]+)?$/i;
3599
3653
  COMPOSER_PACKAGE_RE = /^[a-z0-9_.-]+\/[a-z0-9_.-]+(?::[a-z0-9*+._~^<>=|-]+)?$/i;
3600
- GO_MODULE_RE = /^(?:[a-z0-9.-]+\.)+[a-z0-9.-]+\/[a-z0-9._~+/@-]+$/i;
3654
+ GO_MODULE_RE = /^(?:[a-z0-9-]+\.)+[a-z0-9-]+\/[a-z0-9._~+/@-]+$/i;
3601
3655
  PYTHON_PACKAGE_RE = /^[a-z0-9._-]+(?:\[[a-z0-9._,-]+\])?==[a-z0-9*+._!~-]+$/i;
3602
3656
  }
3603
3657
  });
@@ -3717,8 +3771,8 @@ async function executeInternal(options, startedAt) {
3717
3771
  const target = options.plan.evidence.find((item) => item.kind === "target")?.path;
3718
3772
  if (!target) return unavailableResult(options, "Internal syntax plan has no target evidence.");
3719
3773
  const safeTarget = await assertContainedFile(target, options.projectRoot);
3720
- const stat18 = await fs4.stat(safeTarget);
3721
- if (stat18.size > MAX_INTERNAL_SOURCE_BYTES) {
3774
+ const stat19 = await fs4.stat(safeTarget);
3775
+ if (stat19.size > MAX_INTERNAL_SOURCE_BYTES) {
3722
3776
  return unavailableResult(
3723
3777
  options,
3724
3778
  `Internal syntax target exceeds ${MAX_INTERNAL_SOURCE_BYTES} bytes.`
@@ -4005,8 +4059,8 @@ async function snapshotPaths(paths) {
4005
4059
  const existing = [];
4006
4060
  for (const candidate of paths) {
4007
4061
  try {
4008
- const stat18 = await fs4.stat(candidate);
4009
- if (!stat18.isFile()) continue;
4062
+ const stat19 = await fs4.stat(candidate);
4063
+ if (!stat19.isFile()) continue;
4010
4064
  existing.push(candidate);
4011
4065
  } catch {
4012
4066
  }
@@ -4034,8 +4088,8 @@ async function snapshotSizes(paths) {
4034
4088
  const sizes = /* @__PURE__ */ new Map();
4035
4089
  for (const candidate of paths) {
4036
4090
  try {
4037
- const stat18 = await fs4.stat(candidate);
4038
- if (stat18.isFile()) sizes.set(candidate, stat18.size);
4091
+ const stat19 = await fs4.stat(candidate);
4092
+ if (stat19.isFile()) sizes.set(candidate, stat19.size);
4039
4093
  } catch {
4040
4094
  }
4041
4095
  }
@@ -4195,6 +4249,9 @@ var init_execute_tool = __esm({
4195
4249
  useInstead: ["language_info", "install", "audit", "outdated"]
4196
4250
  },
4197
4251
  permission: "confirm",
4252
+ // WS-046: gives permission decisions something to key on.
4253
+ // The action performed (build/test/lint); the cwd is where, not what.
4254
+ subjectKey: "action",
4198
4255
  mutating: true,
4199
4256
  riskTier: "standard",
4200
4257
  capabilities: ["shell.restricted", "fs.write"],
@@ -4427,6 +4484,9 @@ var init_package_tool = __esm({
4427
4484
  useInstead: ["language_info", "language", "install", "audit", "outdated"]
4428
4485
  },
4429
4486
  permission: "confirm",
4487
+ // WS-046: gives permission decisions something to key on.
4488
+ // The package operation performed — install/remove are not interchangeable.
4489
+ subjectKey: "operation",
4430
4490
  mutating: true,
4431
4491
  riskTier: "destructive",
4432
4492
  capabilities: ["shell.restricted", "fs.write", "net.outbound", "package.install"],
@@ -6481,9 +6541,9 @@ function parseSymbols6(opts) {
6481
6541
  function regexParse2(opts) {
6482
6542
  const { file, content, lang } = opts;
6483
6543
  const symbols = [];
6484
- const basename13 = path22.basename(file).toLowerCase();
6485
- const isPackageJson = basename13 === "package.json";
6486
- const isTsconfig = basename13 === "tsconfig.json" || basename13 === "tsconfig.build.json";
6544
+ const basename14 = path22.basename(file).toLowerCase();
6545
+ const isPackageJson = basename14 === "package.json";
6546
+ const isTsconfig = basename14 === "tsconfig.json" || basename14 === "tsconfig.build.json";
6487
6547
  const isJsonSchema = content.includes("$schema") || content.includes("$id") || content.includes("$ref");
6488
6548
  const isOpenApi = content.includes("openapi") || content.includes("swagger");
6489
6549
  const lines = content.split("\n");
@@ -8695,30 +8755,59 @@ ${hint}` : ""),
8695
8755
  import {
8696
8756
  GOVERNED_TOOL_EXECUTOR_META_KEY
8697
8757
  } from "@wrongstack/core/types";
8758
+ var BATCHABLE_TOOL_NAMES = [
8759
+ "read",
8760
+ "glob",
8761
+ "grep",
8762
+ "tree",
8763
+ "codebase-stats",
8764
+ "codebase-search",
8765
+ "language_info",
8766
+ "tool_search",
8767
+ "memory_for_file",
8768
+ "memory_for_path",
8769
+ "memory_search"
8770
+ ];
8771
+ var BATCHABLE_TOOLS = new Set(BATCHABLE_TOOL_NAMES);
8772
+ var MAX_BATCH_CALLS = 8;
8773
+ var MAX_PARALLEL_BATCH_CALLS = 3;
8698
8774
  var batchToolUseTool = {
8699
8775
  name: "batch_tool_use",
8700
8776
  category: "Meta",
8701
- description: "Execute a batch of tool calls either sequentially or in parallel. Returns structured results for every call.",
8702
- usageHint: "ADVANCED / POWER USER TOOL:\n\n- Useful when you have a clear list of independent operations to perform.\n- `parallel: true` (default) runs them concurrently for speed.\n- `stop_on_error: true` makes it fail fast on the first error.\nUse with care \u2014 batching many mutating operations can be risky. Prefer explicit sequential steps for important work.",
8777
+ description: "Execute a small batch of independent, read-only discovery calls. Process, mutation, browser-action, MCP, delegation, and nested meta tools are not eligible.",
8778
+ usageHint: `BOUNDED READ-ONLY BATCHING:
8779
+
8780
+ - Use only for a small set of independent reads or searches that are all needed now.
8781
+ - Do not wrap normal sequential reasoning or every tool call in a batch.
8782
+ - \`parallel: true\` (default) runs at most three eligible calls concurrently.
8783
+ - \`stop_on_error: true\` makes it fail fast on the first error.
8784
+ - Eligible tools: ${BATCHABLE_TOOL_NAMES.join(", ")}.`,
8703
8785
  permission: "confirm",
8786
+ // Scheduling barrier: keep the outer meta-call out of ToolExecutor's
8787
+ // non-mutating fan-out even though every eligible inner tool is read-only.
8704
8788
  mutating: true,
8705
8789
  timeoutMs: 12e4,
8706
- capabilities: ["tool.mutate.any"],
8790
+ capabilities: ["tool.meta"],
8707
8791
  icon: "meta",
8708
8792
  inputSchema: {
8709
8793
  type: "object",
8710
8794
  properties: {
8711
8795
  calls: {
8712
8796
  type: "array",
8797
+ maxItems: MAX_BATCH_CALLS,
8713
8798
  items: {
8714
8799
  type: "object",
8715
8800
  properties: {
8716
- tool: { type: "string" },
8801
+ tool: {
8802
+ type: "string",
8803
+ enum: [...BATCHABLE_TOOL_NAMES],
8804
+ description: "An explicitly batch-safe, read-only discovery tool."
8805
+ },
8717
8806
  input: { type: "object" }
8718
8807
  },
8719
8808
  required: ["tool"]
8720
8809
  },
8721
- description: "Array of tool calls to execute"
8810
+ description: `Up to ${MAX_BATCH_CALLS} independent, batch-safe read calls.`
8722
8811
  },
8723
8812
  stop_on_error: {
8724
8813
  type: "boolean",
@@ -8741,6 +8830,9 @@ var batchToolUseTool = {
8741
8830
  stop_on_error: false
8742
8831
  };
8743
8832
  }
8833
+ if (input.calls.length > MAX_BATCH_CALLS) {
8834
+ throw new Error(`batch_tool_use: calls must contain at most ${MAX_BATCH_CALLS} items`);
8835
+ }
8744
8836
  const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY];
8745
8837
  if (typeof governedExecute !== "function") {
8746
8838
  return {
@@ -8760,8 +8852,11 @@ var batchToolUseTool = {
8760
8852
  let succeeded = 0;
8761
8853
  let failed = 0;
8762
8854
  if (input.parallel !== false) {
8763
- const promises = input.calls.map(async (call) => executeSingle(call, ctx, governedExecute));
8764
- const allResults = await Promise.all(promises);
8855
+ const allResults = await mapWithConcurrency(
8856
+ input.calls,
8857
+ MAX_PARALLEL_BATCH_CALLS,
8858
+ (call) => executeSingle(call, ctx, governedExecute)
8859
+ );
8765
8860
  results.push(...allResults);
8766
8861
  succeeded = allResults.filter((r) => r.success).length;
8767
8862
  failed = allResults.filter((r) => !r.success).length;
@@ -8786,8 +8881,28 @@ var batchToolUseTool = {
8786
8881
  };
8787
8882
  }
8788
8883
  };
8884
+ async function mapWithConcurrency(items, limit, run) {
8885
+ const results = new Array(items.length);
8886
+ let nextIndex = 0;
8887
+ const worker2 = async () => {
8888
+ while (nextIndex < items.length) {
8889
+ const index = nextIndex++;
8890
+ results[index] = await run(items[index]);
8891
+ }
8892
+ };
8893
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker2()));
8894
+ return results;
8895
+ }
8789
8896
  async function executeSingle(call, ctx, governedExecute) {
8790
8897
  const start = Date.now();
8898
+ if (call.tool === batchToolUseTool.name) {
8899
+ return {
8900
+ tool: call.tool,
8901
+ success: false,
8902
+ error: "recursive batch_tool_use execution is not allowed",
8903
+ executionMs: Date.now() - start
8904
+ };
8905
+ }
8791
8906
  const tool = ctx.tools.find((candidate) => candidate.name === call.tool);
8792
8907
  if (!tool) {
8793
8908
  return {
@@ -8797,6 +8912,14 @@ async function executeSingle(call, ctx, governedExecute) {
8797
8912
  executionMs: Date.now() - start
8798
8913
  };
8799
8914
  }
8915
+ if (!BATCHABLE_TOOLS.has(tool.name) || tool.mutating) {
8916
+ return {
8917
+ tool: call.tool,
8918
+ success: false,
8919
+ error: `tool "${call.tool}" is not eligible for batch execution; call it directly. Allowed: ${BATCHABLE_TOOL_NAMES.join(", ")}`,
8920
+ executionMs: Date.now() - start
8921
+ };
8922
+ }
8800
8923
  try {
8801
8924
  const result = await governedExecute(call.tool, call.input);
8802
8925
  return {
@@ -8823,8 +8946,14 @@ import * as path10 from "node:path";
8823
8946
  import { atomicWrite, ulid } from "@wrongstack/core/utils";
8824
8947
  var ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
8825
8948
  var sweptRoots = /* @__PURE__ */ new Set();
8949
+ var MAX_SWEPT_ROOTS = 128;
8826
8950
  function sweepOldArtifacts(root) {
8827
8951
  if (sweptRoots.has(root)) return;
8952
+ while (sweptRoots.size >= MAX_SWEPT_ROOTS) {
8953
+ const oldest = sweptRoots.values().next().value;
8954
+ if (oldest === void 0) break;
8955
+ sweptRoots.delete(oldest);
8956
+ }
8828
8957
  sweptRoots.add(root);
8829
8958
  void (async () => {
8830
8959
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
@@ -8847,8 +8976,8 @@ function sweepOldArtifacts(root) {
8847
8976
  for (const name of names) {
8848
8977
  const target = path10.join(dir, name);
8849
8978
  try {
8850
- const stat18 = await fs7.stat(target);
8851
- if (stat18.isFile() && stat18.mtimeMs < cutoff) {
8979
+ const stat19 = await fs7.stat(target);
8980
+ if (stat19.isFile() && stat19.mtimeMs < cutoff) {
8852
8981
  await fs7.rm(target, { force: true });
8853
8982
  removed++;
8854
8983
  }
@@ -8876,14 +9005,14 @@ var BrowserArtifactStore = class {
8876
9005
  }
8877
9006
  async record(id, sessionId, kind, target, mimeType) {
8878
9007
  await fs7.chmod(target, 384).catch(() => void 0);
8879
- const [stat18, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
9008
+ const [stat19, sha256] = await Promise.all([fs7.stat(target), hashFile(target)]);
8880
9009
  const artifact = {
8881
9010
  id,
8882
9011
  kind,
8883
9012
  sensitivity: "sensitive",
8884
9013
  path: target,
8885
9014
  mimeType,
8886
- sizeBytes: stat18.size,
9015
+ sizeBytes: stat19.size,
8887
9016
  sha256,
8888
9017
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
8889
9018
  };
@@ -9258,7 +9387,7 @@ var BrowserSessionManager = class {
9258
9387
  constructor(options, launcher = defaultLauncher) {
9259
9388
  this.launcher = launcher;
9260
9389
  this.artifacts = new BrowserArtifactStore(options.artifactRoot);
9261
- this.allowPrivateHosts = options.allowPrivateHosts ?? true;
9390
+ this.allowPrivateHosts = options.allowPrivateHosts ?? false;
9262
9391
  this.allowedPrivateOrigins = options.allowedPrivateOrigins ?? [];
9263
9392
  this.networkProxy = new BrowserNetworkGuardProxy({
9264
9393
  allowPrivateHosts: this.allowPrivateHosts,
@@ -9481,8 +9610,8 @@ var BrowserSessionManager = class {
9481
9610
  if (realRelative.startsWith("..") || path11.isAbsolute(realRelative)) {
9482
9611
  throw new Error("browser: upload files must not escape the project root through a symlink");
9483
9612
  }
9484
- const stat18 = await fs8.stat(realFile);
9485
- if (!stat18.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9613
+ const stat19 = await fs8.stat(realFile);
9614
+ if (!stat19.isFile()) throw new Error(`browser: upload target is not a file: ${file}`);
9486
9615
  resolved.push(realFile);
9487
9616
  }
9488
9617
  await this.runPageOperation(
@@ -9834,6 +9963,9 @@ var browserScreenshotTool = {
9834
9963
  description: "Capture a page or element PNG and return sensitive artifact metadata with integrity hash.",
9835
9964
  usageHint: "browser_screenshot({ sessionId, fullPage?, selector? })",
9836
9965
  permission: "confirm",
9966
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9967
+ // rule must distinguish clicking one control from another.
9968
+ subjectKey: "selector",
9837
9969
  mutating: true,
9838
9970
  riskTier: "safe",
9839
9971
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9863,6 +9995,9 @@ function selectorTool(name, description, run) {
9863
9995
  description,
9864
9996
  usageHint: `${name}({ sessionId, selector })`,
9865
9997
  permission: "confirm",
9998
+ // WS-046: the element/expression acted on, not the sessionId — a trust
9999
+ // rule must distinguish clicking one control from another.
10000
+ subjectKey: "selector",
9866
10001
  mutating: true,
9867
10002
  riskTier: "standard",
9868
10003
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9892,6 +10027,9 @@ var browserTypeTool = {
9892
10027
  description: "Fill an input in an owned browser session. Use secretEnv instead of text for credentials so values stay out of tool arguments and session audit.",
9893
10028
  usageHint: "browser_type({ sessionId, selector, text? | secretEnv? })",
9894
10029
  permission: "confirm",
10030
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10031
+ // rule must distinguish clicking one control from another.
10032
+ subjectKey: "selector",
9895
10033
  mutating: true,
9896
10034
  riskTier: "standard",
9897
10035
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9926,6 +10064,9 @@ var browserSelectTool = {
9926
10064
  description: "Select an option in an owned browser session.",
9927
10065
  usageHint: "browser_select({ sessionId, selector, value })",
9928
10066
  permission: "confirm",
10067
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10068
+ // rule must distinguish clicking one control from another.
10069
+ subjectKey: "selector",
9929
10070
  mutating: true,
9930
10071
  riskTier: "standard",
9931
10072
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9954,6 +10095,9 @@ var browserPressTool = {
9954
10095
  description: "Press a keyboard key in an owned browser session.",
9955
10096
  usageHint: "browser_press({ sessionId, key })",
9956
10097
  permission: "confirm",
10098
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10099
+ // rule must distinguish clicking one control from another.
10100
+ subjectKey: "key",
9957
10101
  mutating: true,
9958
10102
  riskTier: "standard",
9959
10103
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -9972,6 +10116,9 @@ var browserDragTool = {
9972
10116
  description: "Drag one element to another in an owned browser session.",
9973
10117
  usageHint: "browser_drag({ sessionId, from, to })",
9974
10118
  permission: "confirm",
10119
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10120
+ // rule must distinguish clicking one control from another.
10121
+ subjectKey: "from",
9975
10122
  mutating: true,
9976
10123
  riskTier: "standard",
9977
10124
  capabilities: [ToolCapabilities.NET_OUTBOUND],
@@ -10021,6 +10168,9 @@ var browserEvaluateTool = {
10021
10168
  description: "Evaluate bounded JavaScript in the page. Requires confirmation because page code is arbitrary.",
10022
10169
  usageHint: "browser_evaluate({ sessionId, expression })",
10023
10170
  permission: "confirm",
10171
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10172
+ // rule must distinguish clicking one control from another.
10173
+ subjectKey: "expression",
10024
10174
  mutating: true,
10025
10175
  riskTier: "destructive",
10026
10176
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.TOOL_MUTATE_ANY],
@@ -10043,6 +10193,9 @@ var browserUploadTool = {
10043
10193
  description: "Upload project-local files through a file input in an owned browser session.",
10044
10194
  usageHint: "browser_upload({ sessionId, selector, files })",
10045
10195
  permission: "confirm",
10196
+ // WS-046: the element/expression acted on, not the sessionId — a trust
10197
+ // rule must distinguish clicking one control from another.
10198
+ subjectKey: "selector",
10046
10199
  mutating: true,
10047
10200
  riskTier: "destructive",
10048
10201
  capabilities: [ToolCapabilities.NET_OUTBOUND, ToolCapabilities.FS_READ],
@@ -10986,6 +11139,7 @@ var REFS_INDEX_SQL = [
10986
11139
  var SYMBOLS_FTS_SQL = "CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(text, tokenize = 'unicode61')";
10987
11140
 
10988
11141
  // src/codebase-index/writer-search-helpers.ts
11142
+ var SEARCH_CANDIDATE_SCAN_CAP = 5e3;
10989
11143
  function normalizeSearchLimit(limit) {
10990
11144
  return typeof limit === "number" && Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : void 0;
10991
11145
  }
@@ -11015,8 +11169,8 @@ function buildWriterSearchWhere(query, filter) {
11015
11169
  }
11016
11170
  if (query.trim()) {
11017
11171
  const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
11018
- conditions.push(`(${tokens.map(() => "text LIKE ?").join(" OR ")})`);
11019
- for (const token of tokens) values.push(`%${token}%`);
11172
+ conditions.push(`(${tokens.map(() => "text LIKE ? ESCAPE '\\'").join(" OR ")})`);
11173
+ for (const token of tokens) values.push(`%${escapeLike(token)}%`);
11020
11174
  }
11021
11175
  return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values };
11022
11176
  }
@@ -11125,6 +11279,7 @@ var StorePool = class {
11125
11279
 
11126
11280
  // src/codebase-index/writer.ts
11127
11281
  var DB_FILE2 = "index.db";
11282
+ var MAX_STATEMENT_CACHE = 128;
11128
11283
  var IndexStore = class _IndexStore {
11129
11284
  db;
11130
11285
  /** Absolute path to this project's index directory. */
@@ -11162,12 +11317,31 @@ var IndexStore = class _IndexStore {
11162
11317
  // Dirty on open so the first getOrBuildBm25() rebuilds against current rows;
11163
11318
  // an empty or pre-existing corpus makes a stale IDF table meaningless.
11164
11319
  bm25Dirty = true;
11165
- /** Prepare-once helper: compile `sql` on first use, reuse thereafter. */
11320
+ /**
11321
+ * Prepare-once helper: compile `sql` on first use, reuse thereafter.
11322
+ *
11323
+ * Bounded LRU rather than an open Map. The cache is keyed by SQL TEXT, and
11324
+ * the fallback search builder emits one `text LIKE ?` clause per query token
11325
+ * — so the SQL varies with the token count and a stream of differently-sized
11326
+ * queries grew the cache without limit. Sage's store already bounds its
11327
+ * equivalent at 128 (WS-096).
11328
+ *
11329
+ * Re-inserting on a hit keeps the hot fixed-SQL statements (upsertFile,
11330
+ * insertRefs, …) at the young end, so a burst of one-off search SQL evicts
11331
+ * itself rather than the reindex hot path.
11332
+ */
11166
11333
  stmt(sql) {
11167
- let s = this.stmtCache.get(sql);
11168
- if (s === void 0) {
11169
- s = this.db.prepare(sql);
11170
- this.stmtCache.set(sql, s);
11334
+ const cached = this.stmtCache.get(sql);
11335
+ if (cached !== void 0) {
11336
+ this.stmtCache.delete(sql);
11337
+ this.stmtCache.set(sql, cached);
11338
+ return cached;
11339
+ }
11340
+ const s = this.db.prepare(sql);
11341
+ this.stmtCache.set(sql, s);
11342
+ if (this.stmtCache.size > MAX_STATEMENT_CACHE) {
11343
+ const oldest = this.stmtCache.keys().next();
11344
+ if (!oldest.done) this.stmtCache.delete(oldest.value);
11171
11345
  }
11172
11346
  return s;
11173
11347
  }
@@ -11440,7 +11614,7 @@ var IndexStore = class _IndexStore {
11440
11614
  const { where, values } = built;
11441
11615
  const limit = normalizeSearchLimit(opts?.limit);
11442
11616
  const limitSql = limit !== void 0 ? " LIMIT ?" : "";
11443
- const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment, text FROM symbols ${where}${limitSql}`;
11617
+ const sql = `SELECT id, lang, kind, name, file, line, col, signature, doc_comment FROM symbols ${where}${limitSql}`;
11444
11618
  const binds = limit !== void 0 ? [...values, limit] : values;
11445
11619
  const rows = this.stmt(sql).all(
11446
11620
  ...binds
@@ -11569,11 +11743,13 @@ var IndexStore = class _IndexStore {
11569
11743
  /** Legacy ranked path: LIKE candidates + in-process BM25 + JS snippets. */
11570
11744
  searchRankedFallback(query, filter, limit) {
11571
11745
  if (!query.trim()) {
11572
- const total = this.countSearch(query, filter);
11573
- if (total === 0) return { results: [], total: 0 };
11574
- return { results: this.search(query, filter, { limit }), total };
11746
+ const total2 = this.countSearch(query, filter);
11747
+ if (total2 === 0) return { results: [], total: 0 };
11748
+ return { results: this.search(query, filter, { limit }), total: total2 };
11575
11749
  }
11576
- const candidates = this.search(query, filter);
11750
+ const total = this.countSearch(query, filter);
11751
+ if (total === 0) return { results: [], total: 0 };
11752
+ const candidates = this.search(query, filter, { limit: SEARCH_CANDIDATE_SCAN_CAP });
11577
11753
  if (candidates.length === 0) return { results: [], total: 0 };
11578
11754
  const candidateById = new Map(candidates.map((c) => [c.id, c]));
11579
11755
  const bm25 = this.getOrBuildBm25();
@@ -11599,7 +11775,7 @@ var IndexStore = class _IndexStore {
11599
11775
  const c = expectDefined2(candidateById.get(id));
11600
11776
  return { ...c, score, snippet: bm25.extractSnippet(id, qTokens) };
11601
11777
  });
11602
- return { results, total: candidates.length };
11778
+ return { results, total };
11603
11779
  }
11604
11780
  getAllIndexable() {
11605
11781
  return getAllIndexableWithStatement((sql) => this.stmt(sql));
@@ -12018,12 +12194,12 @@ var buildIdCache;
12018
12194
  function projectIndexServerBuildId(entrypoint) {
12019
12195
  const file = entrypoint instanceof URL || entrypoint.startsWith("file:") ? fileURLToPath(entrypoint) : path16.resolve(entrypoint);
12020
12196
  try {
12021
- const stat18 = fs11.statSync(file);
12022
- if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat18.mtimeMs && buildIdCache.size === stat18.size) {
12197
+ const stat19 = fs11.statSync(file);
12198
+ if (buildIdCache?.file === file && buildIdCache.mtimeMs === stat19.mtimeMs && buildIdCache.size === stat19.size) {
12023
12199
  return buildIdCache.buildId;
12024
12200
  }
12025
12201
  const buildId = createHash4("sha256").update(fs11.readFileSync(file)).digest("hex").slice(0, 24);
12026
- buildIdCache = { file, mtimeMs: stat18.mtimeMs, size: stat18.size, buildId };
12202
+ buildIdCache = { file, mtimeMs: stat19.mtimeMs, size: stat19.size, buildId };
12027
12203
  return buildId;
12028
12204
  } catch {
12029
12205
  return `unreadable:${path16.basename(file)}`;
@@ -12193,6 +12369,8 @@ var ProjectServerConnection = class {
12193
12369
  connectResolve = null;
12194
12370
  connectReject = null;
12195
12371
  nextId = 1;
12372
+ /** WS-027: read from the owner-only metadata file — see currentAuthToken. */
12373
+ authToken;
12196
12374
  pending = /* @__PURE__ */ new Map();
12197
12375
  transition(status, options = {}) {
12198
12376
  const previous = connectionStates.get(this.endpoint);
@@ -12329,6 +12507,31 @@ var ProjectServerConnection = class {
12329
12507
  this.transition("offline");
12330
12508
  maybeStopHeartbeatLoop();
12331
12509
  }
12510
+ /**
12511
+ * WS-027: the per-process token, read from the daemon's owner-only metadata
12512
+ * file — never from the `hello` frame, which the daemon sends to every
12513
+ * socket that connects (the mistake WS-028 found in the SAGE daemon).
12514
+ *
12515
+ * Read lazily and re-read while unknown: the daemon starts listening before
12516
+ * it writes metadata (endpoint ownership has to be won first), and a
12517
+ * respawned daemon mints a new token, so a cached wrong one would be sticky.
12518
+ */
12519
+ currentAuthToken() {
12520
+ if (this.authToken === void 0) {
12521
+ try {
12522
+ const raw = fs12.readFileSync(
12523
+ projectIndexServerMetadataPath(this.projectRoot, this.indexDir),
12524
+ "utf8"
12525
+ );
12526
+ const parsed = JSON.parse(raw);
12527
+ if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) {
12528
+ this.authToken = parsed.authToken;
12529
+ }
12530
+ } catch {
12531
+ }
12532
+ }
12533
+ return this.authToken;
12534
+ }
12332
12535
  request(message, options) {
12333
12536
  const socket = this.socket;
12334
12537
  if (!socket || socket.destroyed) {
@@ -12372,7 +12575,7 @@ var ProjectServerConnection = class {
12372
12575
  return;
12373
12576
  }
12374
12577
  }
12375
- this.write({ ...message, id });
12578
+ this.write({ ...message, id, authToken: this.currentAuthToken() });
12376
12579
  });
12377
12580
  }
12378
12581
  async ensureConnected(spawnIfMissing) {
@@ -12419,6 +12622,7 @@ var ProjectServerConnection = class {
12419
12622
  connectOnce() {
12420
12623
  this.socket?.destroy();
12421
12624
  this.socket = null;
12625
+ this.authToken = void 0;
12422
12626
  this.info = null;
12423
12627
  this.activity = null;
12424
12628
  this.health = null;
@@ -13062,9 +13266,9 @@ async function runIndexerWithStore(store, opts) {
13062
13266
  const statReadParse = await Promise.allSettled(
13063
13267
  batchFiles.map(
13064
13268
  async (file) => {
13065
- let stat18;
13269
+ let stat19;
13066
13270
  try {
13067
- stat18 = await fs17.stat(file, statOpts);
13271
+ stat19 = await fs17.stat(file, statOpts);
13068
13272
  } catch (e) {
13069
13273
  if (isAbortError(e)) throw e;
13070
13274
  return {
@@ -13076,21 +13280,21 @@ async function runIndexerWithStore(store, opts) {
13076
13280
  missing: isMissingPathError(e)
13077
13281
  };
13078
13282
  }
13079
- if (!stat18.isFile()) return { file, stat: stat18, lang: "", parsed: null };
13283
+ if (!stat19.isFile()) return { file, stat: stat19, lang: "", parsed: null };
13080
13284
  const lang = detectLang(file);
13081
- if (!lang) return { file, stat: stat18, lang: "", parsed: null };
13082
- if (stat18.size > MAX_INDEX_FILE_BYTES) {
13285
+ if (!lang) return { file, stat: stat19, lang: "", parsed: null };
13286
+ if (stat19.size > MAX_INDEX_FILE_BYTES) {
13083
13287
  return {
13084
13288
  file,
13085
- stat: stat18,
13289
+ stat: stat19,
13086
13290
  lang,
13087
13291
  parsed: null,
13088
- error: `file too large (${stat18.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13292
+ error: `file too large (${stat19.size} bytes; max ${MAX_INDEX_FILE_BYTES})`
13089
13293
  };
13090
13294
  }
13091
13295
  const meta = existingMeta.get(file);
13092
- if (!force && meta && meta.mtimeMs === Math.floor(stat18.mtimeMs)) {
13093
- return { file, stat: stat18, lang, parsed: null, skippedMeta: meta };
13296
+ if (!force && meta && meta.mtimeMs === Math.floor(stat19.mtimeMs)) {
13297
+ return { file, stat: stat19, lang, parsed: null, skippedMeta: meta };
13094
13298
  }
13095
13299
  let content;
13096
13300
  try {
@@ -13099,7 +13303,7 @@ async function runIndexerWithStore(store, opts) {
13099
13303
  if (isAbortError(e)) throw e;
13100
13304
  return {
13101
13305
  file,
13102
- stat: stat18,
13306
+ stat: stat19,
13103
13307
  lang,
13104
13308
  parsed: null,
13105
13309
  error: `read error: ${e instanceof Error ? e.message : String(e)}`
@@ -13111,13 +13315,13 @@ async function runIndexerWithStore(store, opts) {
13111
13315
  } catch (e) {
13112
13316
  return {
13113
13317
  file,
13114
- stat: stat18,
13318
+ stat: stat19,
13115
13319
  lang,
13116
13320
  parsed: null,
13117
13321
  error: `parse error: ${e instanceof Error ? e.message : String(e)}`
13118
13322
  };
13119
13323
  }
13120
- return { file, stat: stat18, lang, parsed, content };
13324
+ return { file, stat: stat19, lang, parsed, content };
13121
13325
  }
13122
13326
  )
13123
13327
  );
@@ -13138,7 +13342,7 @@ async function runIndexerWithStore(store, opts) {
13138
13342
  errors.push(`${file}: ${result.error}`);
13139
13343
  continue;
13140
13344
  }
13141
- const { stat: stat18, lang, parsed } = result;
13345
+ const { stat: stat19, lang, parsed } = result;
13142
13346
  if (result.skippedMeta) {
13143
13347
  langStats[lang] = (langStats[lang] ?? 0) + result.skippedMeta.symbolCount;
13144
13348
  symbolsIndexed += result.skippedMeta.symbolCount;
@@ -13150,7 +13354,7 @@ async function runIndexerWithStore(store, opts) {
13150
13354
  store.upsertFile({
13151
13355
  file,
13152
13356
  lang,
13153
- mtimeMs: Math.floor(stat18.mtimeMs),
13357
+ mtimeMs: Math.floor(stat19.mtimeMs),
13154
13358
  symbolCount: 0,
13155
13359
  lastIndexed: Date.now()
13156
13360
  });
@@ -13162,7 +13366,7 @@ async function runIndexerWithStore(store, opts) {
13162
13366
  store.replaceEmptyFile({
13163
13367
  file,
13164
13368
  lang,
13165
- mtimeMs: Math.floor(stat18.mtimeMs),
13369
+ mtimeMs: Math.floor(stat19.mtimeMs),
13166
13370
  symbolCount: 0,
13167
13371
  lastIndexed: Date.now()
13168
13372
  });
@@ -13174,7 +13378,7 @@ async function runIndexerWithStore(store, opts) {
13174
13378
  lang,
13175
13379
  symbols: parsed.symbols,
13176
13380
  refs: parsed.refs ?? [],
13177
- mtimeMs: Math.floor(stat18.mtimeMs),
13381
+ mtimeMs: Math.floor(stat19.mtimeMs),
13178
13382
  symbolCount: parsed.symbols.length
13179
13383
  });
13180
13384
  deleteForFiles.push(file);
@@ -14370,8 +14574,9 @@ function runDeadCodeScan(projectRoot, opts = {}) {
14370
14574
  const content = fs19.readFileSync(epFile, "utf8");
14371
14575
  const strippedContent = content.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length));
14372
14576
  const reExportRe = /export\s+(?:(?:type\s+)?\{[\s\S]*?\}\s+from|\*\s+as\s+\w+\s+from|\*\s+from)\s+['"]([^'"]+)['"]/g;
14373
- let match;
14374
- while ((match = reExportRe.exec(strippedContent)) !== null) {
14577
+ for (; ; ) {
14578
+ const match = reExportRe.exec(strippedContent);
14579
+ if (match === null) break;
14375
14580
  const moduleSpec = match[1];
14376
14581
  const resolvedFiles = resolveModulePath(epFile, moduleSpec, indexedFiles);
14377
14582
  for (const rf of resolvedFiles) {
@@ -14577,6 +14782,9 @@ var designTool = {
14577
14782
  description: 'Browse, load, customize, and enforce curated frontend/mobile UI design kits. Use BEFORE writing UI code to commit to one coherent, modern, responsive, dark/light, accessible design. Actions: "list" (menu), "use" (load+pin a kit for a stack), "foundations" (baseline), "set" (override kit colors/tokens), "materialize" (write the tokens to a real theme file \u2014 CSS @theme/OKLCH or native), "verify" (scan UI files for off-palette colors).',
14578
14783
  usageHint: 'Flow: `design {action:"use", kit:"minimal-clarity", stack:"web"}` \u2192 optionally `design {action:"set", set:{primary:"oklch(62% 0.2 25)"}}` \u2192 `design {action:"materialize"}` to write tokens to disk \u2192 implement against them \u2192 `design {action:"verify"}`.',
14579
14784
  permission: "confirm",
14785
+ // WS-046: gives permission decisions something to key on.
14786
+ // The action performed; `out` is optional so it cannot be the subject.
14787
+ subjectKey: "action",
14580
14788
  mutating: true,
14581
14789
  capabilities: ["fs.write"],
14582
14790
  timeoutMs: 15e3,
@@ -14757,9 +14965,7 @@ Run \`design {action:"materialize"}\` to write the tuned tokens to your theme fi
14757
14965
  const abs = path25.join(absParent, path25.basename(absResolved));
14758
14966
  const rel = path25.relative(root, abs);
14759
14967
  if (rel.startsWith("..") || path25.isAbsolute(rel)) {
14760
- throw new Error(
14761
- `design: materialize path "${result.path}" would escape the project root`
14762
- );
14968
+ throw new Error(`design: materialize path "${result.path}" would escape the project root`);
14763
14969
  }
14764
14970
  let exists = false;
14765
14971
  try {
@@ -14834,6 +15040,7 @@ import { statSync as statSync3 } from "node:fs";
14834
15040
  import * as fs21 from "node:fs/promises";
14835
15041
  import * as path26 from "node:path";
14836
15042
  import { buildChildEnv as buildChildEnv3 } from "@wrongstack/core/utils";
15043
+ var MAX_FILE_DUMP_BYTES = 5 * 1024 * 1024;
14837
15044
  var diffTool = {
14838
15045
  name: "diff",
14839
15046
  category: "Filesystem",
@@ -14841,6 +15048,7 @@ var diffTool = {
14841
15048
  usageHint: 'USE FOR CODE REVIEW AND CHANGE INSPECTION:\n\n- `files` + no `a`/`b` \u2192 show file content with line numbers (NOT a unified diff; no +/- prefixes).\n- `a` and/or `b` \u2192 git-style commit/branch diff (unified format, real +/- prefixes).\n- `staged: true` \u2192 only show staged changes.\n- `mode` can be "unified", "stat", or "side-by-side" (only affects the git-diff path).\n\nNOTE: For a true file-vs-file unified diff, supply `a` and `b` so the tool delegates to `git diff`. The `files`-only path is a line-numbered dump, not a diff.\n\nThis tool has important safety guards against flag injection (see previous security findings).',
14842
15049
  permission: "auto",
14843
15050
  mutating: false,
15051
+ maxOutputBytes: 262144,
14844
15052
  capabilities: ["fs.read"],
14845
15053
  icon: "diff",
14846
15054
  timeoutMs: 1e4,
@@ -14916,8 +15124,8 @@ function findGitDir(cwd) {
14916
15124
  let dir = cwd;
14917
15125
  for (let i = 0; i < 20; i++) {
14918
15126
  try {
14919
- const stat18 = statSync3(path26.join(dir, ".git"));
14920
- if (stat18.isDirectory()) return dir;
15127
+ const stat19 = statSync3(path26.join(dir, ".git"));
15128
+ if (stat19.isDirectory()) return dir;
14921
15129
  } catch {
14922
15130
  }
14923
15131
  const parent = path26.dirname(dir);
@@ -14959,10 +15167,18 @@ async function fileDiff(input, ctx, _signal) {
14959
15167
  };
14960
15168
  }
14961
15169
  const results = [];
15170
+ let truncated = false;
14962
15171
  for (const file of files) {
14963
15172
  const absPath = safeResolve(file, ctx);
14964
- const stat18 = await fs21.stat(absPath).catch(() => null);
14965
- if (!stat18?.isFile()) continue;
15173
+ const stat19 = await fs21.stat(absPath).catch(() => null);
15174
+ if (!stat19?.isFile()) continue;
15175
+ if (stat19.size > MAX_FILE_DUMP_BYTES) {
15176
+ truncated = true;
15177
+ results.push(
15178
+ `--- ${file} (skipped: ${stat19.size} bytes exceeds the ${MAX_FILE_DUMP_BYTES} limit; use the read tool with offset/limit) ---`
15179
+ );
15180
+ continue;
15181
+ }
14966
15182
  const content = await fs21.readFile(absPath, "utf8");
14967
15183
  const lines = content.split(/\r?\n/);
14968
15184
  results.push(formatWithLineNumbers(file, lines));
@@ -14970,7 +15186,7 @@ async function fileDiff(input, ctx, _signal) {
14970
15186
  return {
14971
15187
  diff: results.join("\n\n"),
14972
15188
  files,
14973
- truncated: false,
15189
+ truncated,
14974
15190
  mode: input.mode ?? "unified"
14975
15191
  };
14976
15192
  }
@@ -15063,8 +15279,8 @@ async function resolveFiles(filesInput, cwd) {
15063
15279
  for (const f of files) {
15064
15280
  const absPath = f.trim().startsWith("/") ? f.trim() : `${cwd}/${f.trim()}`;
15065
15281
  try {
15066
- const stat18 = await fs22.stat(absPath);
15067
- if (stat18.isFile()) resolved.push(absPath);
15282
+ const stat19 = await fs22.stat(absPath);
15283
+ if (stat19.isFile()) resolved.push(absPath);
15068
15284
  } catch {
15069
15285
  }
15070
15286
  }
@@ -15917,7 +16133,7 @@ var editTool = {
15917
16133
  });
15918
16134
  }
15919
16135
  const absPath = await safeResolveReal(input.path, ctx);
15920
- const stat18 = await fs23.stat(absPath).catch((err) => {
16136
+ const stat19 = await fs23.stat(absPath).catch((err) => {
15921
16137
  if (err.code === "ENOENT") {
15922
16138
  throw new ToolValidationError({
15923
16139
  message: `edit: file "${input.path}" does not exist. Use \`write\` instead.`,
@@ -15927,7 +16143,7 @@ var editTool = {
15927
16143
  }
15928
16144
  throw err;
15929
16145
  });
15930
- if (!stat18.isFile()) {
16146
+ if (!stat19.isFile()) {
15931
16147
  throw new ToolValidationError({
15932
16148
  message: `edit: "${input.path}" is not a regular file`,
15933
16149
  field: "path"
@@ -15957,7 +16173,7 @@ var editTool = {
15957
16173
  });
15958
16174
  }
15959
16175
  }
15960
- if (autoRead && updated.mtimeMs > stat18.mtimeMs + mtimeTolerance) {
16176
+ if (autoRead && updated.mtimeMs > stat19.mtimeMs + mtimeTolerance) {
15961
16177
  throw new ToolValidationError({
15962
16178
  message: `edit: file "${input.path}" changed while being auto-read. Retry the edit.`,
15963
16179
  field: "path",
@@ -16082,7 +16298,6 @@ import {
16082
16298
  import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils/error";
16083
16299
  init_output_spool();
16084
16300
  init_util();
16085
- init_process_registry();
16086
16301
  init_win32_resolve();
16087
16302
 
16088
16303
  // src/exec-kill-guard.ts
@@ -16321,6 +16536,7 @@ async function checkKillTarget(target) {
16321
16536
  }
16322
16537
 
16323
16538
  // src/exec.ts
16539
+ init_process_registry();
16324
16540
  var isWin4 = process.platform === "win32";
16325
16541
  var DEFAULT_ALLOWED_COMMANDS = /* @__PURE__ */ new Set([
16326
16542
  // JS / TS toolchain
@@ -16986,7 +17202,17 @@ var BLOCKED_ARG_PATTERNS = {
16986
17202
  bun: [],
16987
17203
  docker: [],
16988
17204
  // find -exec/-ok/-execdir execute arbitrary commands
16989
- find: [/^-exec$/, /^-exec;$/, /^-ok$/, /^-ok;$/, /^-execdir$/, /^-execdir;$/, /^-exec=/, /^-ok=/, /^-execdir=/],
17205
+ find: [
17206
+ /^-exec$/,
17207
+ /^-exec;$/,
17208
+ /^-ok$/,
17209
+ /^-ok;$/,
17210
+ /^-execdir$/,
17211
+ /^-execdir;$/,
17212
+ /^-exec=/,
17213
+ /^-ok=/,
17214
+ /^-execdir=/
17215
+ ],
16990
17216
  // rm -rf / is catastrophic — block absolute paths, home, dot-dirs,
16991
17217
  // and glob patterns that could expand to dangerous targets.
16992
17218
  // `rm -rf ./src/*` expands to project files; `rm -rf ../../` escapes upward;
@@ -17057,6 +17283,14 @@ var execTool = {
17057
17283
  useInstead: ["bash"]
17058
17284
  },
17059
17285
  permission: "confirm",
17286
+ // WS-046: without this, every exec call collapsed onto the bare tool name.
17287
+ // Three consequences followed: "always allow" stored a rule that could never
17288
+ // match, pressing "no" once blocked ALL exec for the session, and the
17289
+ // permission cache keyed one decision for every command. `subjectKey:
17290
+ // 'command'` renders the FULL invocation (`command` + `args`) — using the
17291
+ // program alone would make `exec git status` and `exec git push --force` the
17292
+ // same subject, so trusting one would silently authorize the other.
17293
+ subjectKey: "command",
17060
17294
  mutating: true,
17061
17295
  riskTier: "standard",
17062
17296
  timeoutMs: DEFAULT_TIMEOUT_MS3,
@@ -17273,7 +17507,14 @@ function runCommand(cmd, args, cwd, timeout, signal, sessionId, danger) {
17273
17507
  const pid = child.pid;
17274
17508
  if (typeof pid === "number") {
17275
17509
  const fullCommand = `${cmd} ${args.join(" ")}`;
17276
- registry.register({ pid, name: "exec", command: redactCommand(fullCommand), startedAt: Date.now(), sessionId, child });
17510
+ registry.register({
17511
+ pid,
17512
+ name: "exec",
17513
+ command: redactCommand(fullCommand),
17514
+ startedAt: Date.now(),
17515
+ sessionId,
17516
+ child
17517
+ });
17277
17518
  }
17278
17519
  const timer = setTimeout(() => {
17279
17520
  killed = true;
@@ -17713,6 +17954,9 @@ var formatTool = {
17713
17954
  description: "Format source files according to project style (Biome). Can also run in check-only mode.",
17714
17955
  usageHint: "RUN REGULARLY:\n\n- Use on changed files before committing.\n- `check: true` verifies formatting without making changes (useful in CI-like flows).\nThis project has very consistent formatting expectations. Always ensure your changes are formatted.",
17715
17956
  permission: "confirm",
17957
+ // WS-046: gives permission decisions something to key on.
17958
+ // The files being rewritten are the subject; the fixer is how, not what.
17959
+ subjectKey: "files",
17716
17960
  mutating: true,
17717
17961
  capabilities: ["fs.write", "shell.restricted"],
17718
17962
  icon: "code",
@@ -17817,13 +18061,13 @@ var formatTool = {
17817
18061
  }
17818
18062
  };
17819
18063
  async function detectFixer(cwd) {
17820
- const { stat: stat18 } = await import("node:fs/promises");
18064
+ const { stat: stat19 } = await import("node:fs/promises");
17821
18065
  try {
17822
- await stat18(`${cwd}/biome.json`);
18066
+ await stat19(`${cwd}/biome.json`);
17823
18067
  return "biome";
17824
18068
  } catch {
17825
18069
  try {
17826
- await stat18(`${cwd}/.prettierrc`);
18070
+ await stat19(`${cwd}/.prettierrc`);
17827
18071
  return "prettier";
17828
18072
  } catch {
17829
18073
  return "biome";
@@ -17850,6 +18094,10 @@ var gitTool = {
17850
18094
  // Conservative: any of these may mutate. The non-mutating commands
17851
18095
  // (status/log/diff/branch/fetch) are still gated on `permission: 'confirm'`
17852
18096
  // and `MUTATING_SUBCOMMANDS` is consulted at runtime for per-call checks.
18097
+ // WS-046: gives permission decisions something to key on.
18098
+ // The git subcommand (status/commit/push) is what a trust rule needs to
18099
+ // distinguish; `git status` and `git push --force` must not share a subject.
18100
+ subjectKey: "command",
17853
18101
  mutating: true,
17854
18102
  capabilities: ["fs.write", "shell.restricted"],
17855
18103
  timeoutMs: TIMEOUT_MS2,
@@ -17918,6 +18166,8 @@ var gitTool = {
17918
18166
  truncated: false
17919
18167
  };
17920
18168
  }
18169
+ const branchGuard = validateBranchInput(input);
18170
+ if (branchGuard) return branchGuard;
17921
18171
  if (input.command === "worktree") {
17922
18172
  const guard = validateWorktreeInput(input, ctx.projectRoot);
17923
18173
  if (guard) return guard;
@@ -17966,6 +18216,18 @@ var gitTool = {
17966
18216
  return result;
17967
18217
  }
17968
18218
  };
18219
+ function validateBranchInput(input) {
18220
+ const branch = input.branch;
18221
+ if (branch === void 0) return null;
18222
+ if (!branch.startsWith("-") && !branch.includes(" --")) return null;
18223
+ return {
18224
+ command: input.command,
18225
+ stdout: "",
18226
+ stderr: `unsafe branch name (parsed as a git option): ${branch}`,
18227
+ exitCode: 1,
18228
+ truncated: false
18229
+ };
18230
+ }
17969
18231
  function validateWorktreeInput(input, projectRoot) {
17970
18232
  const reject = (stderr) => ({
17971
18233
  command: "worktree",
@@ -17974,7 +18236,6 @@ function validateWorktreeInput(input, projectRoot) {
17974
18236
  exitCode: 1,
17975
18237
  truncated: false
17976
18238
  });
17977
- if (input.branch?.startsWith("-")) return reject(`unsafe branch name: ${input.branch}`);
17978
18239
  if (input.worktreePath?.startsWith("-")) {
17979
18240
  return reject(`unsafe worktree path: ${input.worktreePath}`);
17980
18241
  }
@@ -17992,8 +18253,8 @@ function findGitDir2(cwd, projectRoot) {
17992
18253
  let dir = cwd;
17993
18254
  for (let i = 0; i < 20; i++) {
17994
18255
  try {
17995
- const stat18 = statSync4(`${dir}/.git`);
17996
- if (stat18.isDirectory() || stat18.isFile()) return dir;
18256
+ const stat19 = statSync4(`${dir}/.git`);
18257
+ if (stat19.isDirectory() || stat19.isFile()) return dir;
17997
18258
  } catch {
17998
18259
  }
17999
18260
  if (dir === root) break;
@@ -18028,7 +18289,10 @@ function buildArgs(input) {
18028
18289
  ...files.length ? ["--", ...files] : []
18029
18290
  ];
18030
18291
  case "branch":
18031
- return input.branch ? ["branch", ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]] : ["branch"];
18292
+ return input.branch ? [
18293
+ "branch",
18294
+ ...input.branch.startsWith("-") || input.branch.includes(" --") ? [] : [input.branch]
18295
+ ] : ["branch"];
18032
18296
  case "checkout":
18033
18297
  return [
18034
18298
  "checkout",
@@ -18121,7 +18385,7 @@ import * as path30 from "node:path";
18121
18385
  import { compileGlob as compileGlob2, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS2 } from "@wrongstack/core/utils";
18122
18386
 
18123
18387
  // src/_concurrency.ts
18124
- async function mapWithConcurrency(items, limit, fn) {
18388
+ async function mapWithConcurrency2(items, limit, fn) {
18125
18389
  if (items.length === 0) return [];
18126
18390
  const effectiveLimit = Math.max(1, Math.min(limit | 0, items.length));
18127
18391
  const results = new Array(items.length);
@@ -18256,10 +18520,10 @@ var globTool = {
18256
18520
  }
18257
18521
  if (truncated) return;
18258
18522
  }
18259
- await mapWithConcurrency(matchedFiles, WALK_CONCURRENCY, pushResult);
18523
+ await mapWithConcurrency2(matchedFiles, WALK_CONCURRENCY, pushResult);
18260
18524
  if (truncated) return;
18261
18525
  const remainingSubdirs = truncated ? [] : subdirs;
18262
- await mapWithConcurrency(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18526
+ await mapWithConcurrency2(remainingSubdirs, WALK_CONCURRENCY, ({ full, rel }) => walk2(full, rel));
18263
18527
  };
18264
18528
  await walk2(base, "");
18265
18529
  results.sort((a, b) => b.mtime - a.mtime);
@@ -18268,12 +18532,16 @@ var globTool = {
18268
18532
  };
18269
18533
 
18270
18534
  // src/grep.ts
18271
- import { expectDefined as expectDefined7 } from "@wrongstack/core/utils";
18272
18535
  import { spawn as spawn11 } from "node:child_process";
18273
18536
  import * as fs25 from "node:fs/promises";
18274
18537
  import * as path31 from "node:path";
18275
18538
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
18276
- import { buildChildEnv as buildChildEnv5, compileGlob as compileGlob3, DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3 } from "@wrongstack/core/utils";
18539
+ import {
18540
+ buildChildEnv as buildChildEnv5,
18541
+ compileGlob as compileGlob3,
18542
+ DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS3,
18543
+ expectDefined as expectDefined7
18544
+ } from "@wrongstack/core/utils";
18277
18545
 
18278
18546
  // src/_regex.ts
18279
18547
  var MAX_PATTERN_LEN = 256;
@@ -18326,6 +18594,8 @@ var DEFAULT_IGNORE3 = DEFAULT_WALK_IGNORE_DIRS3;
18326
18594
  var NATIVE_SCAN_CONCURRENCY = 32;
18327
18595
  var NATIVE_READ_CHUNK_BYTES = 64 * 1024;
18328
18596
  var NATIVE_MAX_FILE_BYTES = 1e6;
18597
+ var RG_MAX_QUEUE_CHUNKS = 128;
18598
+ var RG_MAX_QUEUE_CHARS = 8 * 1024 * 1024;
18329
18599
  var grepTool = {
18330
18600
  name: "grep",
18331
18601
  category: "Search",
@@ -18453,9 +18723,19 @@ async function* runRgStream(input, base, mode, limit, signal) {
18453
18723
  const FLUSH_AT = 16;
18454
18724
  const MAX_BUF_BYTES = 1e6;
18455
18725
  let bufOverflow = false;
18456
- const child = spawn11("rg", args, { signal, env: buildChildEnv5(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
18726
+ const child = spawn11("rg", args, {
18727
+ signal,
18728
+ env: buildChildEnv5(),
18729
+ // rg diagnostics are not part of the tool result. Ignoring stderr avoids
18730
+ // an unread pipe filling up and pinning the child when rg reports many
18731
+ // filesystem errors.
18732
+ stdio: ["ignore", "pipe", "ignore"],
18733
+ windowsHide: true
18734
+ });
18457
18735
  const queue = [];
18736
+ let queuedChars = 0;
18458
18737
  let waiter;
18738
+ let paused = false;
18459
18739
  const wake = () => {
18460
18740
  if (waiter) {
18461
18741
  const w = waiter;
@@ -18463,93 +18743,133 @@ async function* runRgStream(input, base, mode, limit, signal) {
18463
18743
  w();
18464
18744
  }
18465
18745
  };
18466
- child.stdout?.on("data", (c) => {
18467
- queue.push({ kind: "out", data: c.toString() });
18746
+ const pauseIfFlooded = () => {
18747
+ if (paused || queue.length < RG_MAX_QUEUE_CHUNKS && queuedChars < RG_MAX_QUEUE_CHARS)
18748
+ return;
18749
+ paused = true;
18750
+ child.stdout?.pause();
18751
+ };
18752
+ const resumeIfDrained = () => {
18753
+ if (!paused || queue.length >= RG_MAX_QUEUE_CHUNKS || queuedChars >= RG_MAX_QUEUE_CHARS)
18754
+ return;
18755
+ paused = false;
18756
+ child.stdout?.resume();
18757
+ };
18758
+ const onStdoutData = (c) => {
18759
+ const data = c.toString();
18760
+ queue.push({ kind: "out", data });
18761
+ queuedChars += data.length;
18468
18762
  wake();
18469
- });
18470
- child.on("error", (e) => {
18763
+ pauseIfFlooded();
18764
+ };
18765
+ const onError = (e) => {
18471
18766
  queue.push({ kind: "error", data: e.message });
18472
18767
  wake();
18473
- });
18474
- child.on("close", () => {
18768
+ };
18769
+ const onClose = () => {
18475
18770
  queue.push({ kind: "close", data: "" });
18476
18771
  wake();
18477
- });
18772
+ };
18773
+ child.stdout?.on("data", onStdoutData);
18774
+ child.on("error", onError);
18775
+ child.on("close", onClose);
18478
18776
  let pendingBatch = [];
18479
18777
  let errored = false;
18480
- for (; ; ) {
18481
- while (queue.length === 0) {
18482
- await new Promise((r) => {
18483
- waiter = r;
18484
- });
18485
- }
18486
- const c = expectDefined7(queue.shift());
18487
- if (c.kind === "error") {
18488
- errored = true;
18489
- continue;
18490
- }
18491
- if (c.kind === "close") break;
18492
- buf += c.data;
18493
- if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18494
- bufOverflow = true;
18495
- buf = buf.slice(-MAX_BUF_BYTES);
18496
- try {
18497
- child.kill("SIGTERM");
18498
- } catch {
18778
+ let closed = false;
18779
+ try {
18780
+ for (; ; ) {
18781
+ while (queue.length === 0) {
18782
+ await new Promise((r) => {
18783
+ waiter = r;
18784
+ });
18499
18785
  }
18500
- }
18501
- const idx = buf.lastIndexOf("\n");
18502
- if (idx === -1) continue;
18503
- const ready = buf.slice(0, idx);
18504
- buf = buf.slice(idx + 1);
18505
- for (const line of ready.split("\n")) {
18506
- if (!line) continue;
18507
- totalLines++;
18508
- if (mode === "count") totalCount += parseRgCountLine(line);
18509
- if (matches.length < limit) {
18510
- matches.push(line);
18511
- pendingBatch.push(line);
18512
- batchSinceFlush++;
18786
+ const c = expectDefined7(queue.shift());
18787
+ queuedChars = Math.max(0, queuedChars - c.data.length);
18788
+ resumeIfDrained();
18789
+ if (c.kind === "error") {
18790
+ errored = true;
18791
+ continue;
18792
+ }
18793
+ if (c.kind === "close") {
18794
+ closed = true;
18795
+ break;
18796
+ }
18797
+ buf += c.data;
18798
+ if (buf.length > MAX_BUF_BYTES && !bufOverflow) {
18799
+ bufOverflow = true;
18800
+ buf = buf.slice(-MAX_BUF_BYTES);
18801
+ try {
18802
+ child.kill("SIGTERM");
18803
+ } catch {
18804
+ }
18805
+ }
18806
+ const idx = buf.lastIndexOf("\n");
18807
+ if (idx === -1) continue;
18808
+ const ready = buf.slice(0, idx);
18809
+ buf = buf.slice(idx + 1);
18810
+ for (const line of ready.split("\n")) {
18811
+ if (!line) continue;
18812
+ totalLines++;
18813
+ if (mode === "count") totalCount += parseRgCountLine(line);
18814
+ if (matches.length < limit) {
18815
+ matches.push(line);
18816
+ pendingBatch.push(line);
18817
+ batchSinceFlush++;
18818
+ }
18819
+ }
18820
+ if (batchSinceFlush >= FLUSH_AT) {
18821
+ yield {
18822
+ type: "partial_output",
18823
+ text: pendingBatch.join("\n"),
18824
+ data: { matches_so_far: matches.length }
18825
+ };
18826
+ pendingBatch = [];
18827
+ batchSinceFlush = 0;
18828
+ }
18829
+ }
18830
+ if (buf.trim()) {
18831
+ for (const line of buf.split("\n")) {
18832
+ if (!line) continue;
18833
+ totalLines++;
18834
+ if (mode === "count") totalCount += parseRgCountLine(line);
18835
+ if (matches.length < limit) {
18836
+ matches.push(line);
18837
+ pendingBatch.push(line);
18838
+ }
18513
18839
  }
18514
18840
  }
18515
- if (batchSinceFlush >= FLUSH_AT) {
18841
+ if (pendingBatch.length > 0) {
18516
18842
  yield {
18517
18843
  type: "partial_output",
18518
18844
  text: pendingBatch.join("\n"),
18519
18845
  data: { matches_so_far: matches.length }
18520
18846
  };
18521
- pendingBatch = [];
18522
- batchSinceFlush = 0;
18523
- }
18524
- }
18525
- if (buf.trim()) {
18526
- for (const line of buf.split("\n")) {
18527
- if (!line) continue;
18528
- totalLines++;
18529
- if (mode === "count") totalCount += parseRgCountLine(line);
18530
- if (matches.length < limit) {
18531
- matches.push(line);
18532
- pendingBatch.push(line);
18533
- }
18534
18847
  }
18535
- }
18536
- if (pendingBatch.length > 0) {
18848
+ if (errored) throw new Error("rg: spawn error");
18537
18849
  yield {
18538
- type: "partial_output",
18539
- text: pendingBatch.join("\n"),
18540
- data: { matches_so_far: matches.length }
18850
+ type: "final",
18851
+ output: {
18852
+ matches,
18853
+ count: mode === "count" ? totalCount : totalLines,
18854
+ truncated: totalLines > limit || bufOverflow,
18855
+ used: "rg"
18856
+ }
18541
18857
  };
18542
- }
18543
- if (errored) throw new Error("rg: spawn error");
18544
- yield {
18545
- type: "final",
18546
- output: {
18547
- matches,
18548
- count: mode === "count" ? totalCount : totalLines,
18549
- truncated: totalLines > limit || bufOverflow,
18550
- used: "rg"
18858
+ } finally {
18859
+ child.stdout?.off("data", onStdoutData);
18860
+ child.off("error", onError);
18861
+ child.off("close", onClose);
18862
+ child.stdout?.destroy();
18863
+ queue.length = 0;
18864
+ queuedChars = 0;
18865
+ waiter = void 0;
18866
+ if (!closed && child.exitCode === null) {
18867
+ try {
18868
+ child.kill("SIGTERM");
18869
+ } catch {
18870
+ }
18551
18871
  }
18552
- };
18872
+ }
18553
18873
  }
18554
18874
  function parseRgCountLine(line) {
18555
18875
  const idx = line.lastIndexOf(":");
@@ -18579,8 +18899,8 @@ async function runNative(input, base, mode, limit, signal) {
18579
18899
  if (globRe && !globRe.test(name) && !globRe.test(full)) return;
18580
18900
  if (globRe) globRe.lastIndex = 0;
18581
18901
  try {
18582
- const stat18 = await fs25.stat(full);
18583
- if (!stat18.isFile() || stat18.size > maxBytes || stopped || signal.aborted) return;
18902
+ const stat19 = await fs25.stat(full);
18903
+ if (!stat19.isFile() || stat19.size > maxBytes || stopped || signal.aborted) return;
18584
18904
  const file = await fs25.open(full, "r");
18585
18905
  try {
18586
18906
  let bytesReadTotal = 0;
@@ -18680,8 +19000,8 @@ async function runNative(input, base, mode, limit, signal) {
18680
19000
  files.push({ full, name: e.name });
18681
19001
  }
18682
19002
  }
18683
- await mapWithConcurrency(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
18684
- await mapWithConcurrency(
19003
+ await mapWithConcurrency2(files, NATIVE_SCAN_CONCURRENCY, ({ full, name }) => scanFile(full, name));
19004
+ await mapWithConcurrency2(
18685
19005
  subdirs,
18686
19006
  Math.min(16, NATIVE_SCAN_CONCURRENCY),
18687
19007
  ({ full, rel }) => walk2(full, rel)
@@ -18700,7 +19020,7 @@ async function runNative(input, base, mode, limit, signal) {
18700
19020
  init_spawn_stream();
18701
19021
  init_util();
18702
19022
  init_legacy_bridge();
18703
- import { join as join24 } from "node:path";
19023
+ import { join as join25 } from "node:path";
18704
19024
  import {
18705
19025
  detectEcosystem as detectPackageEcosystem,
18706
19026
  recordPackageAction
@@ -18711,6 +19031,10 @@ var installTool = {
18711
19031
  description: "Install, update or manage packages using the detected package manager (pnpm/npm/yarn). Strongly preferred over raw shell commands for dependency management because it is structured and safer.",
18712
19032
  usageHint: "ALWAYS USE THIS INSTEAD OF BASH FOR PACKAGE WORK:\n\n- Empty `packages` \u2192 normal `install` (respects lockfile).\n- Provide names \u2192 adds/updates specific packages.\n- `dry_run: true` for safe preview.\n- Set `save` appropriately.\nThis tool has proper capability declaration and is heavily recommended in the security posture of the project.",
18713
19033
  permission: "confirm",
19034
+ // WS-046: gives permission decisions something to key on.
19035
+ // What gets installed is the subject — installing a package runs its
19036
+ // lifecycle scripts, so `express` and `evil-pkg` must not be interchangeable.
19037
+ subjectKey: "packages",
18714
19038
  mutating: true,
18715
19039
  riskTier: "standard",
18716
19040
  icon: "package",
@@ -18880,10 +19204,10 @@ function resolveManifestPath(cwd, pkgManager) {
18880
19204
  case "pnpm":
18881
19205
  case "yarn":
18882
19206
  case "npm":
18883
- return join24(cwd, "package.json");
19207
+ return join25(cwd, "package.json");
18884
19208
  /* v8 ignore next 2 -- pkgManager is always pnpm/yarn/npm; the default is defensive. */
18885
19209
  default:
18886
- return join24(cwd, "package.json");
19210
+ return join25(cwd, "package.json");
18887
19211
  }
18888
19212
  }
18889
19213
 
@@ -18891,6 +19215,24 @@ function resolveManifestPath(cwd, pkgManager) {
18891
19215
  init_util();
18892
19216
  import * as fs26 from "node:fs/promises";
18893
19217
  import { deepMerge as deepMergeCore } from "@wrongstack/core/utils";
19218
+ var MAX_JSON_FILE_BYTES = 16 * 1024 * 1024;
19219
+ var MAX_JSON_FILE_BYTES_HUMAN = "16 MiB";
19220
+ var JsonFileTooLargeError = class extends Error {
19221
+ constructor(filePath, size) {
19222
+ super(
19223
+ `json: "${filePath}" is ${size} bytes \u2014 exceeds the ${MAX_JSON_FILE_BYTES_HUMAN} file-size limit. Extract the relevant portion into a smaller file first, or use a shell tool to query it directly.`
19224
+ );
19225
+ this.name = "JsonFileTooLargeError";
19226
+ }
19227
+ };
19228
+ async function readJsonFileBounded(filePath, ctx) {
19229
+ const resolved = await safeResolveReal(filePath, ctx);
19230
+ const stat19 = await fs26.stat(resolved);
19231
+ if (stat19.size > MAX_JSON_FILE_BYTES) {
19232
+ throw new JsonFileTooLargeError(filePath, stat19.size);
19233
+ }
19234
+ return fs26.readFile(resolved, "utf8");
19235
+ }
18894
19236
  var jsonTool = {
18895
19237
  name: "json",
18896
19238
  category: "Data",
@@ -18898,6 +19240,7 @@ var jsonTool = {
18898
19240
  usageHint: 'VERY USEFUL FOR DATA INSPECTION:\n\n- `action: "parse"` (default): read/pretty-print/convert JSON, JSON5, or YAML from `file` or `data`.\n- `action: "query"`: JMESPath-like query (`a.b[0].c`, `items[*].name`, filters, functions).\n- `action: "validate"`: validate data against a JSON Schema (`schema` param).\n- `action: "transform"`: chain multiple JMESPath transforms (`transforms` param).\n- `action: "merge"`: deep merge `base` and `patch` objects (`conflictResolution` param).\nPrefer this over raw `read` + manual parsing when dealing with configuration or data files.',
18899
19241
  permission: "auto",
18900
19242
  mutating: false,
19243
+ maxOutputBytes: 262144,
18901
19244
  timeoutMs: 5e3,
18902
19245
  capabilities: ["fs.read"],
18903
19246
  icon: "json",
@@ -18964,9 +19307,15 @@ async function executeParse(input, ctx) {
18964
19307
  let raw;
18965
19308
  if (input.file) {
18966
19309
  try {
18967
- raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
18968
- } catch {
18969
- return { data: null, formatted: "", type: "unknown", action: "parse", error: "Could not read file" };
19310
+ raw = await readJsonFileBounded(input.file, ctx);
19311
+ } catch (error) {
19312
+ return {
19313
+ data: null,
19314
+ formatted: "",
19315
+ type: "unknown",
19316
+ action: "parse",
19317
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read file"
19318
+ };
18970
19319
  }
18971
19320
  } else if (input.data) {
18972
19321
  raw = input.data;
@@ -19022,10 +19371,16 @@ async function executeQuery(input, ctx) {
19022
19371
  let parsed;
19023
19372
  if (input.file) {
19024
19373
  try {
19025
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19374
+ const raw = await readJsonFileBounded(input.file, ctx);
19026
19375
  parsed = JSON.parse(raw);
19027
- } catch {
19028
- return { data: null, formatted: "", type: "unknown", action: "query", error: "Could not read/parse file" };
19376
+ } catch (error) {
19377
+ return {
19378
+ data: null,
19379
+ formatted: "",
19380
+ type: "unknown",
19381
+ action: "query",
19382
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19383
+ };
19029
19384
  }
19030
19385
  } else if (input.data) {
19031
19386
  try {
@@ -19064,10 +19419,16 @@ async function executeValidate(input, ctx) {
19064
19419
  let parsed;
19065
19420
  if (input.file) {
19066
19421
  try {
19067
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19422
+ const raw = await readJsonFileBounded(input.file, ctx);
19068
19423
  parsed = JSON.parse(raw);
19069
- } catch {
19070
- return { data: null, formatted: "", type: "unknown", action: "validate", error: "Could not read/parse file" };
19424
+ } catch (error) {
19425
+ return {
19426
+ data: null,
19427
+ formatted: "",
19428
+ type: "unknown",
19429
+ action: "validate",
19430
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19431
+ };
19071
19432
  }
19072
19433
  } else if (input.data) {
19073
19434
  try {
@@ -19106,10 +19467,16 @@ async function executeTransform(input, ctx) {
19106
19467
  let parsed;
19107
19468
  if (input.file) {
19108
19469
  try {
19109
- const raw = await fs26.readFile(await safeResolveReal(input.file, ctx), "utf8");
19470
+ const raw = await readJsonFileBounded(input.file, ctx);
19110
19471
  parsed = JSON.parse(raw);
19111
- } catch {
19112
- return { data: null, formatted: "", type: "unknown", action: "transform", error: "Could not read/parse file" };
19472
+ } catch (error) {
19473
+ return {
19474
+ data: null,
19475
+ formatted: "",
19476
+ type: "unknown",
19477
+ action: "transform",
19478
+ error: error instanceof JsonFileTooLargeError ? error.message : "Could not read/parse file"
19479
+ };
19113
19480
  }
19114
19481
  } else if (input.data) {
19115
19482
  try {
@@ -19713,6 +20080,10 @@ async function handleKanbanDetailAction(projectRoot, input) {
19713
20080
  }
19714
20081
 
19715
20082
  // src/kanban-board-inputs.ts
20083
+ function agentSettableGate(enforcement) {
20084
+ if (enforcement === void 0 || enforcement === "off") return {};
20085
+ return { completionGate: { enforcement } };
20086
+ }
19716
20087
  function boardCreateInput(input, title) {
19717
20088
  return {
19718
20089
  title,
@@ -19725,7 +20096,7 @@ function boardCreateInput(input, title) {
19725
20096
  decomposition: input.atomicityDecomposition ?? "propose"
19726
20097
  }
19727
20098
  } : {},
19728
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20099
+ ...agentSettableGate(input.gateEnforcement)
19729
20100
  };
19730
20101
  }
19731
20102
  function boardUpdatePatch(input) {
@@ -19739,7 +20110,7 @@ function boardUpdatePatch(input) {
19739
20110
  decomposition: input.atomicityDecomposition ?? "propose"
19740
20111
  }
19741
20112
  } : {},
19742
- ...input.gateEnforcement !== void 0 ? { completionGate: { enforcement: input.gateEnforcement } } : {}
20113
+ ...agentSettableGate(input.gateEnforcement)
19743
20114
  };
19744
20115
  }
19745
20116
  function duplicateBoardOptions(input) {
@@ -19754,6 +20125,7 @@ function duplicateBoardOptions(input) {
19754
20125
 
19755
20126
  // src/kanban-task-inputs.ts
19756
20127
  import { randomUUID } from "node:crypto";
20128
+ import { clampSubagentCapabilities } from "@wrongstack/core/security";
19757
20129
  function taskInput(input) {
19758
20130
  const assignment = hasAssignmentInput(input) ? assignmentForTaskCreate(input) : void 0;
19759
20131
  return {
@@ -19851,6 +20223,10 @@ function taskPatch(input) {
19851
20223
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
19852
20224
  };
19853
20225
  }
20226
+ function clampRequestedCapabilities(requested) {
20227
+ if (requested === void 0) return void 0;
20228
+ return clampSubagentCapabilities(requested).granted;
20229
+ }
19854
20230
  function assignmentInput(input) {
19855
20231
  return {
19856
20232
  agentId: input.agentId,
@@ -19861,7 +20237,7 @@ function assignmentInput(input) {
19861
20237
  fallbackProfile: input.fallbackProfile,
19862
20238
  fallbackModels: input.fallbackModels,
19863
20239
  tools: input.tools,
19864
- allowedCapabilities: input.allowedCapabilities,
20240
+ allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities),
19865
20241
  assignee: input.assignee,
19866
20242
  leaseId: input.leaseId,
19867
20243
  claimedAt: input.claimedAt,
@@ -19888,7 +20264,7 @@ function assignmentForTaskCreate(input) {
19888
20264
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
19889
20265
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
19890
20266
  ...input.tools !== void 0 ? { tools: input.tools } : {},
19891
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
20267
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: clampRequestedCapabilities(input.allowedCapabilities) } : {},
19892
20268
  ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
19893
20269
  ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
19894
20270
  ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
@@ -20099,7 +20475,13 @@ var KANBAN_INPUT_SCHEMA = {
20099
20475
  moveTasksToColumnId: { type: "string" },
20100
20476
  atomicityMode: { type: "string", enum: ["off", "assess", "enforce"] },
20101
20477
  atomicityDecomposition: { type: "string", enum: ["auto", "propose"] },
20102
- gateEnforcement: { type: "string", enum: ["strict", "soft", "off"] },
20478
+ gateEnforcement: {
20479
+ type: "string",
20480
+ // WS-023: `'off'` is deliberately absent. The agent whose work this gate
20481
+ // checks must not be able to switch it off; it may only tighten. Turning
20482
+ // a gate off stays a human decision, made through board config.
20483
+ enum: ["strict", "soft"]
20484
+ },
20103
20485
  subtasks: {
20104
20486
  type: "array",
20105
20487
  minItems: 2,
@@ -20141,7 +20523,7 @@ function createKanbanPresenceWrapper(projectRoot, input, ctx) {
20141
20523
 
20142
20524
  // src/session-kanban.ts
20143
20525
  import { watch } from "node:fs";
20144
- import { basename as basename12, dirname as dirname15 } from "node:path";
20526
+ import { basename as basename13, dirname as dirname15 } from "node:path";
20145
20527
  import { getSharedProjectMailbox } from "@wrongstack/core/coordination";
20146
20528
  import {
20147
20529
  loadPlan,
@@ -20231,7 +20613,9 @@ async function ensureSessionKanbanBoard(projectRoot, sessionId) {
20231
20613
  description: "Live session work: todos, tasks, and plan items.",
20232
20614
  tags: sessionBoardTags(sessionId),
20233
20615
  columns: SESSION_KANBAN_COLUMNS,
20234
- generatedBy: `session-kanban:${sessionId}`
20616
+ generatedBy: `session-kanban:${sessionId}`,
20617
+ kind: "session_mirror",
20618
+ retention: { mode: "archive_after_ttl", ttlMs: 7 * 24 * 60 * 60 * 1e3 }
20235
20619
  });
20236
20620
  }
20237
20621
  if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {
@@ -20466,12 +20850,26 @@ function projectSessionPlanToKanban(projectRoot, items, sessionId) {
20466
20850
  function fireAndForget(context, work) {
20467
20851
  void work.catch((err) => {
20468
20852
  const message = err instanceof Error ? err.message : String(err);
20469
- console.warn(JSON.stringify({ level: "warn", event: "session-kanban", context, message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
20853
+ console.warn(
20854
+ JSON.stringify({
20855
+ level: "warn",
20856
+ event: "session-kanban",
20857
+ context,
20858
+ message,
20859
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20860
+ })
20861
+ );
20470
20862
  });
20471
20863
  }
20472
20864
  function broadcastTodoUpdate(context, todos) {
20473
20865
  const sessionId = context.session?.id ?? "";
20474
20866
  if (!context.agentId || !sessionId) return;
20867
+ const statusCounts = { pending: 0, inProgress: 0, completed: 0 };
20868
+ for (const todo of todos) {
20869
+ if (todo.status === "completed") statusCounts.completed++;
20870
+ else if (todo.status === "in_progress") statusCounts.inProgress++;
20871
+ else statusCounts.pending++;
20872
+ }
20475
20873
  const projectDir = resolveWstackPaths3({ projectRoot: context.projectRoot }).projectDir;
20476
20874
  const mailbox = getSharedProjectMailbox(projectDir);
20477
20875
  void mailbox.send({
@@ -20483,11 +20881,11 @@ function broadcastTodoUpdate(context, todos) {
20483
20881
  kind: "kanban.todos.updated",
20484
20882
  sessionId,
20485
20883
  revision: context.state.revision,
20486
- todos
20884
+ todoCount: todos.length,
20885
+ statusCounts
20487
20886
  }),
20488
20887
  priority: "normal",
20489
- senderSessionId: sessionId,
20490
- ttlMs: 6 * 60 * 60 * 1e3
20888
+ senderSessionId: sessionId
20491
20889
  }).catch(() => {
20492
20890
  });
20493
20891
  }
@@ -20623,8 +21021,8 @@ function attachSessionKanbanMirror(context) {
20623
21021
  const name = filename?.toString();
20624
21022
  const currentPlanPath = context.meta["plan.path"];
20625
21023
  const currentTaskPath = context.meta["task.path"];
20626
- const planName = typeof currentPlanPath === "string" ? basename12(currentPlanPath) : "";
20627
- const taskName = typeof currentTaskPath === "string" ? basename12(currentTaskPath) : "";
21024
+ const planName = typeof currentPlanPath === "string" ? basename13(currentPlanPath) : "";
21025
+ const taskName = typeof currentTaskPath === "string" ? basename13(currentTaskPath) : "";
20628
21026
  if (name && name !== planName && name !== taskName) return;
20629
21027
  if (timer) clearTimeout(timer);
20630
21028
  timer = setTimeout(() => fireAndForget("refresh-files", refreshFiles()), 60);
@@ -21640,11 +22038,11 @@ var lintTool = {
21640
22038
  }
21641
22039
  };
21642
22040
  async function detectLinter(cwd) {
21643
- const { stat: stat18 } = await import("node:fs/promises");
22041
+ const { stat: stat19 } = await import("node:fs/promises");
21644
22042
  const checks = ["biome.json", ".eslintrc.json", "tslint.json", ".eslintrc.js", "tsconfig.json"];
21645
22043
  for (const f of checks) {
21646
22044
  try {
21647
- await stat18(`${cwd}/${f}`);
22045
+ await stat19(`${cwd}/${f}`);
21648
22046
  if (f.includes("biome")) return "biome";
21649
22047
  if (f.includes("eslint")) return "eslint";
21650
22048
  if (f.includes("tslint")) return "tslint";
@@ -21888,6 +22286,9 @@ var outdatedTool = {
21888
22286
  // tool.confirm_needed flow on every invocation. M-1 originally
21889
22287
  // fixed four sibling tools (mcp_control, shellcheck, shellcheck (scan mode),
21890
22288
  // search) but missed this one; applying the same contract here.
22289
+ // WS-046: gives permission decisions something to key on.
22290
+ // The working directory scanned.
22291
+ subjectKey: "cwd",
21891
22292
  mutating: true,
21892
22293
  // Capability is outbound network — the tool only hits the package
21893
22294
  // registry over HTTP, never touches the filesystem or runs shell.
@@ -22069,6 +22470,10 @@ var patchTool = {
22069
22470
  useInstead: ["edit"]
22070
22471
  },
22071
22472
  permission: "confirm",
22473
+ // WS-046: gives permission decisions something to key on.
22474
+ // The target directory. The patch body itself is unstable (every diff
22475
+ // differs), so it could never re-match its own stored rule.
22476
+ subjectKey: "directory",
22072
22477
  mutating: true,
22073
22478
  capabilities: ["fs.write"],
22074
22479
  icon: "edit",
@@ -22135,8 +22540,8 @@ var patchTool = {
22135
22540
  const before = beforeContents.get(target) ?? null;
22136
22541
  const after = await readTextForTracking(target);
22137
22542
  if (after === null || after === before) continue;
22138
- const stat18 = await fs27.stat(target).catch(() => null);
22139
- if (stat18) ctx.recordRead?.(target, stat18.mtimeMs, "write", sha256hex(after));
22543
+ const stat19 = await fs27.stat(target).catch(() => null);
22544
+ if (stat19) ctx.recordRead?.(target, stat19.mtimeMs, "write", sha256hex(after));
22140
22545
  ctx.session?.recordFileChange?.({
22141
22546
  path: target,
22142
22547
  action: before === null ? "created" : "modified",
@@ -22161,8 +22566,8 @@ var patchTool = {
22161
22566
  var MAX_TRACKING_BYTES = 5 * 1024 * 1024;
22162
22567
  async function readTextForTracking(absPath) {
22163
22568
  try {
22164
- const stat18 = await fs27.stat(absPath);
22165
- if (!stat18.isFile() || stat18.size > MAX_TRACKING_BYTES) return null;
22569
+ const stat19 = await fs27.stat(absPath);
22570
+ if (!stat19.isFile() || stat19.size > MAX_TRACKING_BYTES) return null;
22166
22571
  const buf = await fs27.readFile(absPath);
22167
22572
  if (buf.includes(0)) return null;
22168
22573
  return buf.toString("utf8");
@@ -22192,7 +22597,13 @@ function runPatch(args, cwd, signal) {
22192
22597
  let stdout = "";
22193
22598
  let stderr = "";
22194
22599
  const env = { ...buildChildEnv8(), LANG: "C", LC_ALL: "C" };
22195
- const child = spawn14("patch", args, { cwd, signal, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
22600
+ const child = spawn14("patch", args, {
22601
+ cwd,
22602
+ signal,
22603
+ env,
22604
+ stdio: ["pipe", "pipe", "pipe"],
22605
+ windowsHide: true
22606
+ });
22196
22607
  child.stdout?.on("data", (c) => {
22197
22608
  stdout += c.toString();
22198
22609
  });
@@ -22213,6 +22624,7 @@ function extractPatchedFiles(output) {
22213
22624
  }
22214
22625
 
22215
22626
  // src/plan.ts
22627
+ import { randomUUID as randomUUID2 } from "node:crypto";
22216
22628
  import {
22217
22629
  addPlanItem,
22218
22630
  clearPlan,
@@ -22220,20 +22632,20 @@ import {
22220
22632
  formatPlan,
22221
22633
  getPlanTemplate,
22222
22634
  mutatePlan as mutatePlan2,
22635
+ mutateTasks as mutateTasks2,
22223
22636
  removePlanItem,
22224
22637
  setPlanItemStatus
22225
22638
  } from "@wrongstack/core/storage";
22226
- import {
22227
- mutateTasks as mutateTasks2
22228
- } from "@wrongstack/core/storage";
22229
22639
  import { formatTaskList } from "@wrongstack/core/utils";
22230
- import { randomUUID as randomUUID2 } from "node:crypto";
22231
22640
  var planTool = {
22232
22641
  name: "plan",
22233
22642
  category: "Session",
22234
22643
  description: 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. By default plans are isolated to this session; use `scope: "project"` to store the plan in a shared project-level file visible to all sessions.',
22235
22644
  usageHint: 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\n\n- Start by creating a high-level plan with `action: "add"` or using templates (`template_use`).\n- Use `promote` to turn a plan item into actionable todos.\n- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\n- Keep plans at the "why and what" level, and todos at the "how and next step" level.\n- Common templates: "new-feature", "bug-fix", "refactor", "release", "security-audit".\n\nThis tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\nUse `scope: "project"` to use a shared project-level plan file.',
22236
22645
  permission: "confirm",
22646
+ // WS-046: gives permission decisions something to key on.
22647
+ // The action performed; plan has no single file or path subject.
22648
+ subjectKey: "action",
22237
22649
  mutating: true,
22238
22650
  capabilities: ["fs.write"],
22239
22651
  icon: "plan",
@@ -22290,7 +22702,10 @@ var planTool = {
22290
22702
  let planPath;
22291
22703
  if (input.scope === "project") {
22292
22704
  if (typeof sessionPlanPath === "string") {
22293
- const lastSep = Math.max(sessionPlanPath.lastIndexOf("/"), sessionPlanPath.lastIndexOf("\\"));
22705
+ const lastSep = Math.max(
22706
+ sessionPlanPath.lastIndexOf("/"),
22707
+ sessionPlanPath.lastIndexOf("\\")
22708
+ );
22294
22709
  planPath = lastSep >= 0 ? sessionPlanPath.slice(0, lastSep + 1) + "backlog.plan.json" : "backlog.plan.json";
22295
22710
  }
22296
22711
  } else {
@@ -22327,7 +22742,11 @@ var planTool = {
22327
22742
  case "start":
22328
22743
  case "done": {
22329
22744
  if (!input.target) {
22330
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22745
+ early = mkResult(
22746
+ p,
22747
+ false,
22748
+ `${input.action} requires \`target\` (id|index|substring).`
22749
+ );
22331
22750
  return p;
22332
22751
  }
22333
22752
  const next = setPlanItemStatus(
@@ -22355,7 +22774,11 @@ var planTool = {
22355
22774
  }
22356
22775
  case "promote": {
22357
22776
  if (!input.target) {
22358
- early = mkResult(p, false, `${input.action} requires \`target\` (id|index|substring).`);
22777
+ early = mkResult(
22778
+ p,
22779
+ false,
22780
+ `${input.action} requires \`target\` (id|index|substring).`
22781
+ );
22359
22782
  return p;
22360
22783
  }
22361
22784
  const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);
@@ -22398,7 +22821,11 @@ var planTool = {
22398
22821
  return clearPlan(p);
22399
22822
  case "taskify": {
22400
22823
  if (!input.target) {
22401
- early = mkResult(p, false, "taskify requires `target` (plan item id|index|substring).");
22824
+ early = mkResult(
22825
+ p,
22826
+ false,
22827
+ "taskify requires `target` (plan item id|index|substring)."
22828
+ );
22402
22829
  return p;
22403
22830
  }
22404
22831
  let itemIdx = -1;
@@ -22471,7 +22898,11 @@ var planTool = {
22471
22898
  ${formatTaskList(taskFile.tasks)}`
22472
22899
  );
22473
22900
  } catch (err) {
22474
- return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);
22901
+ return mkResult(
22902
+ plan,
22903
+ false,
22904
+ `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`
22905
+ );
22475
22906
  }
22476
22907
  }
22477
22908
  return mkResult(plan, true, `Plan ${input.action} ok.`);
@@ -22548,9 +22979,9 @@ var readTool = {
22548
22979
  }
22549
22980
  const absPath = await safeResolveReal(input.path, ctx);
22550
22981
  const shouldIncludeSymbols = input.includeSymbols === true || input.includeSymbols !== false && ctx.meta[ADVANCED_MODE_META_KEY] === true;
22551
- let stat18;
22982
+ let stat19;
22552
22983
  try {
22553
- stat18 = await fs28.stat(absPath);
22984
+ stat19 = await fs28.stat(absPath);
22554
22985
  } catch (err) {
22555
22986
  const code = err.code;
22556
22987
  if (code === "ENOENT") {
@@ -22569,7 +23000,7 @@ var readTool = {
22569
23000
  cause: err
22570
23001
  });
22571
23002
  }
22572
- if (!stat18.isFile()) {
23003
+ if (!stat19.isFile()) {
22573
23004
  throw new FsError({
22574
23005
  message: `read: "${input.path}" is not a regular file`,
22575
23006
  code: "FS_READ_FAILED",
@@ -22577,23 +23008,23 @@ var readTool = {
22577
23008
  context: { reason: "not-a-regular-file" }
22578
23009
  });
22579
23010
  }
22580
- if (stat18.size > MAX_BYTES2) {
23011
+ if (stat19.size > MAX_BYTES2) {
22581
23012
  throw new FsError({
22582
- message: `read: file too large (${stat18.size} bytes, limit ${MAX_BYTES2})`,
23013
+ message: `read: file too large (${stat19.size} bytes, limit ${MAX_BYTES2})`,
22583
23014
  code: "FS_READ_FAILED",
22584
23015
  path: absPath,
22585
- context: { size: stat18.size, limit: MAX_BYTES2, reason: "too-large" }
23016
+ context: { size: stat19.size, limit: MAX_BYTES2, reason: "too-large" }
22586
23017
  });
22587
23018
  }
22588
23019
  const offset = Math.max(1, input.offset ?? 1);
22589
23020
  const limit = Math.max(0, Math.min(input.limit ?? 2e3, 5e3));
22590
23021
  const prior = getReadRangeRecord(ctx, absPath);
22591
23022
  const requestedEnd = prior ? Math.min(offset + limit - 1, prior.totalLines) : offset + limit - 1;
22592
- if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat18.mtimeMs, offset, requestedEnd)) {
22593
- ctx.recordRead(absPath, stat18.mtimeMs);
23023
+ if (input.mode !== "summary" && limit > 0 && prior && coversRange(prior, stat19.mtimeMs, offset, requestedEnd)) {
23024
+ ctx.recordRead(absPath, stat19.mtimeMs);
22594
23025
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22595
23026
  return {
22596
- text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat18.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
23027
+ text: `[unchanged since previous read: "${input.path}" mtime=${Math.round(stat19.mtimeMs)}; requested lines ${offset}-${requestedEnd} were already shown. Use offset/limit for a new range if needed.]`,
22597
23028
  total_lines: prior.totalLines,
22598
23029
  encoding: "utf8",
22599
23030
  truncated: requestedEnd < prior.totalLines,
@@ -22611,11 +23042,11 @@ var readTool = {
22611
23042
  const allLines = text.split(/\r\n|\r|\n/);
22612
23043
  const total = allLines.length;
22613
23044
  if (input.mode === "summary") {
22614
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22615
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, Math.min(total, 200));
23045
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23046
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, Math.min(total, 200));
22616
23047
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22617
23048
  return {
22618
- text: summarizeFile(input.path, stat18.size, allLines),
23049
+ text: summarizeFile(input.path, stat19.size, allLines),
22619
23050
  total_lines: total,
22620
23051
  encoding: "utf8",
22621
23052
  truncated: total > 200,
@@ -22627,8 +23058,8 @@ var readTool = {
22627
23058
  };
22628
23059
  }
22629
23060
  if (limit === 0) {
22630
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22631
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, 1, 0);
23061
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23062
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, 1, 0);
22632
23063
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22633
23064
  return {
22634
23065
  text: "",
@@ -22640,8 +23071,8 @@ var readTool = {
22640
23071
  };
22641
23072
  }
22642
23073
  if (offset > total) {
22643
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22644
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, total + 1, total + 1);
23074
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23075
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, total + 1, total + 1);
22645
23076
  const symResult2 = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22646
23077
  return {
22647
23078
  text: `[offset ${offset} is past end of file "${input.path}" \u2014 file has ${total} line(s). Do not retry this offset.]`,
@@ -22656,8 +23087,8 @@ var readTool = {
22656
23087
  const truncated = offset - 1 + slice.length < total;
22657
23088
  const width = String(offset + slice.length - 1).length;
22658
23089
  const numbered = slice.map((line, i) => `${String(offset + i).padStart(width, " ")}\u2192${line}`).join("\n");
22659
- ctx.recordRead(absPath, stat18.mtimeMs, "user", contentHash);
22660
- rememberReadRange(ctx, absPath, stat18.mtimeMs, total, offset, offset + slice.length - 1);
23090
+ ctx.recordRead(absPath, stat19.mtimeMs, "user", contentHash);
23091
+ rememberReadRange(ctx, absPath, stat19.mtimeMs, total, offset, offset + slice.length - 1);
22661
23092
  const symResult = shouldIncludeSymbols ? await fetchSymbolsForFile(absPath, ctx, execOpts?.signal) : void 0;
22662
23093
  return {
22663
23094
  text: numbered,
@@ -22763,20 +23194,20 @@ ${interesting.join("\n")}` : "symbols/imports: (none detected)"
22763
23194
  }
22764
23195
 
22765
23196
  // src/replace.ts
22766
- import { expectDefined as expectDefined8 } from "@wrongstack/core/utils";
22767
23197
  import { spawn as spawn15 } from "node:child_process";
22768
23198
  import * as fs29 from "node:fs/promises";
22769
23199
  import * as path33 from "node:path";
23200
+ import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22770
23201
  import {
22771
23202
  atomicWrite as atomicWrite3,
22772
23203
  buildChildEnv as buildChildEnv9,
22773
23204
  compileGlob as compileGlob4,
22774
23205
  detectNewlineStyle as detectNewlineStyle2,
23206
+ expectDefined as expectDefined8,
22775
23207
  normalizeToLf as normalizeToLf2,
22776
23208
  toStyle as toStyle2,
22777
23209
  unifiedDiff as unifiedDiff2
22778
23210
  } from "@wrongstack/core/utils";
22779
- import { ToolValidationError as ToolValidationError6 } from "@wrongstack/core/types";
22780
23211
  init_util();
22781
23212
  var DEFAULT_IGNORE4 = ["node_modules", ".git", "dist", "build", ".next", "coverage"];
22782
23213
  var replaceTool = {
@@ -22785,6 +23216,10 @@ var replaceTool = {
22785
23216
  description: "Perform a search-and-replace across multiple files using a regex pattern. This is a powerful bulk transformation tool. Dry-run is ON by default \u2014 set `dry_run: false` to apply changes.",
22786
23217
  usageHint: "DANGEROUS IF USED CARELESSLY \u2014 review the diff output carefully.\n\nRecommended workflow:\n1. Run without `dry_run: false` first to see exactly what would change (dry-run is the default).\n2. Review the diff output, then re-run with `dry_run: false` to apply.\n3. Use a specific enough `pattern` (and `glob` / `files`) to avoid accidental broad changes.\n4. `replace_all` controls whether only the first match per file or all matches are replaced.\nThis tool is excellent for large-scale refactors (renaming, import updates, etc.) but must be used with caution.",
22787
23218
  permission: "confirm",
23219
+ // WS-046: gives permission decisions something to key on.
23220
+ // The file scope being rewritten, not the pattern: a trust rule should say
23221
+ // where a bulk replace may run.
23222
+ subjectKey: "files",
22788
23223
  mutating: true,
22789
23224
  capabilities: ["fs.write"],
22790
23225
  icon: "edit",
@@ -22857,8 +23292,8 @@ var replaceTool = {
22857
23292
  }
22858
23293
  const rel = path33.relative(realRoot, realPath);
22859
23294
  if (rel.startsWith("..") || path33.isAbsolute(rel)) continue;
22860
- const stat18 = await fs29.stat(realPath).catch(() => null);
22861
- if (!stat18?.isFile()) continue;
23295
+ const stat19 = await fs29.stat(realPath).catch(() => null);
23296
+ if (!stat19?.isFile()) continue;
22862
23297
  let content;
22863
23298
  try {
22864
23299
  const buf = await fs29.readFile(realPath);
@@ -22883,7 +23318,7 @@ var replaceTool = {
22883
23318
  totalReplacements += count;
22884
23319
  if (!dryRun) {
22885
23320
  const newContent = toStyle2(newContentLf, style);
22886
- await atomicWrite3(realPath, newContent, { mode: stat18.mode & 511 });
23321
+ await atomicWrite3(realPath, newContent, { mode: stat19.mode & 511 });
22887
23322
  const written = await fs29.stat(realPath).catch(() => null);
22888
23323
  if (written) {
22889
23324
  ctx.recordRead?.(realPath, written.mtimeMs, "write", sha256hex(newContent));
@@ -22923,8 +23358,8 @@ async function resolveFiles2(filesInput, ctx, extraGlob) {
22923
23358
  const resolved = [];
22924
23359
  for (const p of parts) {
22925
23360
  const absPath = safeResolve(p, ctx);
22926
- const stat18 = await fs29.stat(absPath).catch(() => null);
22927
- if (stat18?.isFile()) {
23361
+ const stat19 = await fs29.stat(absPath).catch(() => null);
23362
+ if (stat19?.isFile()) {
22928
23363
  resolved.push(absPath);
22929
23364
  }
22930
23365
  }
@@ -22944,7 +23379,11 @@ async function globFiles(pattern, base, extraGlob) {
22944
23379
  function checkRg() {
22945
23380
  return new Promise((resolve17) => {
22946
23381
  try {
22947
- const p = spawn15("rg", ["--version"], { env: buildChildEnv9(), stdio: "ignore", windowsHide: true });
23382
+ const p = spawn15("rg", ["--version"], {
23383
+ env: buildChildEnv9(),
23384
+ stdio: "ignore",
23385
+ windowsHide: true
23386
+ });
22948
23387
  p.on("error", () => resolve17(false));
22949
23388
  p.on("close", (code) => resolve17(code === 0));
22950
23389
  } catch {
@@ -22995,8 +23434,8 @@ async function globNative(pattern, base, extraGlob) {
22995
23434
  if (DEFAULT_IGNORE4.includes(e.name)) continue;
22996
23435
  const full = path33.join(dir, e.name);
22997
23436
  try {
22998
- const stat18 = await fs29.lstat(full);
22999
- if (stat18.isSymbolicLink()) continue;
23437
+ const stat19 = await fs29.lstat(full);
23438
+ if (stat19.isSymbolicLink()) continue;
23000
23439
  } catch {
23001
23440
  continue;
23002
23441
  }
@@ -23120,6 +23559,7 @@ var scaffoldTool = {
23120
23559
  usageHint: "PREFERRED FOR SCAFFOLDING:\n\n- Use built-in templates when they match your needs (e.g. react-component, npm-package).\n- Supports `dry_run` so you can preview exactly what will be created.\n- Has the powerful `fs.write.outside-project` capability \u2014 review paths carefully.\nMuch cleaner and safer than manually writing multiple files.",
23121
23560
  permission: "confirm",
23122
23561
  mutating: true,
23562
+ subjectKey: "name",
23123
23563
  capabilities: ["fs.write.outside-project", "fs.write"],
23124
23564
  icon: "scaffold",
23125
23565
  timeoutMs: 3e4,
@@ -23194,15 +23634,17 @@ async function handleBuiltIn(name, templateFiles, cwd, ctx, dryRun, vars) {
23194
23634
  output: dryRun ? `Would create ${filesCreated} files: ${files.join(", ")}` : `Created ${filesCreated} files: ${files.join(", ")}`
23195
23635
  };
23196
23636
  }
23637
+ function escapeRegexLiteral(value) {
23638
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23639
+ }
23197
23640
  function substituteVars(content, name, vars) {
23641
+ const kebab = name.toLowerCase().replace(/\s+/g, "-");
23642
+ const pascal = name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase());
23198
23643
  let result = content;
23199
- result = result.replace(/\{\{name\}\}/g, name.toLowerCase().replace(/\s+/g, "-"));
23200
- result = result.replace(
23201
- /\{\{Name\}\}/g,
23202
- name.replace(/(?:^|[-_\s]+)([a-z])/g, (_, c) => c.toUpperCase())
23203
- );
23644
+ result = result.replace(/\{\{name\}\}/g, () => kebab);
23645
+ result = result.replace(/\{\{Name\}\}/g, () => pascal);
23204
23646
  for (const [k, v] of Object.entries(vars)) {
23205
- result = result.replace(new RegExp(`\\{\\{${k}\\}\\}`, "g"), v);
23647
+ result = result.replace(new RegExp(`\\{\\{${escapeRegexLiteral(k)}\\}\\}`, "g"), () => v);
23206
23648
  }
23207
23649
  return result;
23208
23650
  }
@@ -24138,11 +24580,11 @@ var testTool = {
24138
24580
  }
24139
24581
  };
24140
24582
  async function detectRunner(cwd) {
24141
- const { stat: stat18 } = await import("node:fs/promises");
24583
+ const { stat: stat19 } = await import("node:fs/promises");
24142
24584
  const candidates = ["vitest.config.ts", "jest.config.js", ".mocharc.json"];
24143
24585
  for (const f of candidates) {
24144
24586
  try {
24145
- await stat18(path35.join(cwd, f));
24587
+ await stat19(path35.join(cwd, f));
24146
24588
  if (f.includes("vitest")) return "vitest";
24147
24589
  if (f.includes("jest")) return "jest";
24148
24590
  if (f.includes("mocha")) return "mocha";
@@ -24539,12 +24981,19 @@ var toolSearchTool = {
24539
24981
  };
24540
24982
 
24541
24983
  // src/tool-use.ts
24984
+ import {
24985
+ GOVERNED_TOOL_EXECUTOR_META_KEY as GOVERNED_TOOL_EXECUTOR_META_KEY2
24986
+ } from "@wrongstack/core/types";
24542
24987
  var toolUseTool = {
24543
24988
  name: "tool_use",
24544
24989
  category: "Meta",
24545
24990
  description: "Directly execute any registered tool by its exact name, bypassing normal discovery. This is a powerful meta-tool intended for cases where the agent has a clear plan and knows precisely which tool to invoke.",
24546
24991
  usageHint: "ADVANCED META TOOL \u2014 USE WITH CARE:\n\n- Only use when you are certain of the exact tool name and its expected input shape.\n- Prefer using the normal tool calling mechanism when possible.\n- Very useful in batch-tool-use or when orchestrating complex workflows programmatically.\n- The call still goes through full permission checks and capability validation.",
24547
24992
  permission: "confirm",
24993
+ // WS-046: gives permission decisions something to key on.
24994
+ // The tool being invoked through this indirection — the whole point of a
24995
+ // permission decision here is WHICH tool is being reached.
24996
+ subjectKey: "tool",
24548
24997
  mutating: true,
24549
24998
  timeoutMs: 6e4,
24550
24999
  capabilities: ["tool.mutate.any"],
@@ -24563,7 +25012,7 @@ var toolUseTool = {
24563
25012
  },
24564
25013
  required: ["tool"]
24565
25014
  },
24566
- async execute(input, ctx, opts) {
25015
+ async execute(input, ctx) {
24567
25016
  const start = Date.now();
24568
25017
  if (!input?.tool) {
24569
25018
  return {
@@ -24582,6 +25031,14 @@ var toolUseTool = {
24582
25031
  executionMs: Date.now() - start
24583
25032
  };
24584
25033
  }
25034
+ if (tool.name === toolUseTool.name) {
25035
+ return {
25036
+ tool: input.tool,
25037
+ success: false,
25038
+ error: "tool_use: recursive meta-tool execution is not allowed",
25039
+ executionMs: Date.now() - start
25040
+ };
25041
+ }
24585
25042
  if (tool.permission === "deny") {
24586
25043
  return {
24587
25044
  tool: input.tool,
@@ -24590,12 +25047,21 @@ var toolUseTool = {
24590
25047
  executionMs: Date.now() - start
24591
25048
  };
24592
25049
  }
25050
+ const governedExecute = ctx.meta[GOVERNED_TOOL_EXECUTOR_META_KEY2];
25051
+ if (typeof governedExecute !== "function") {
25052
+ return {
25053
+ tool: input.tool,
25054
+ success: false,
25055
+ error: "tool_use: governed nested execution is unavailable; call the tool directly",
25056
+ executionMs: Date.now() - start
25057
+ };
25058
+ }
24593
25059
  try {
24594
- const result = await tool.execute(input.input, ctx, opts);
25060
+ const result = await governedExecute(input.tool, input.input ?? {});
24595
25061
  return {
24596
25062
  tool: input.tool,
24597
- success: true,
24598
- result,
25063
+ success: result.success,
25064
+ ...result.success ? { result: result.result } : { error: result.error ?? "nested tool failed" },
24599
25065
  executionMs: Date.now() - start
24600
25066
  };
24601
25067
  } catch (e) {
@@ -24611,10 +25077,12 @@ var toolUseTool = {
24611
25077
 
24612
25078
  // src/tree.ts
24613
25079
  init_util();
24614
- import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24615
25080
  import * as fs32 from "node:fs/promises";
24616
25081
  import * as path36 from "node:path";
25082
+ import { DEFAULT_WALK_IGNORE_DIRS as DEFAULT_WALK_IGNORE_DIRS4, expectDefined as expectDefined10 } from "@wrongstack/core/utils";
24617
25083
  var DEFAULT_IGNORE5 = [...DEFAULT_WALK_IGNORE_DIRS4, ".wrongstack", ".ssh", ".gnupg", ".aws"];
25084
+ var DEFAULT_MAX_ENTRIES2 = 5e3;
25085
+ var MAX_TREE_OUTPUT_BYTES = 256 * 1024;
24618
25086
  var treeTool = {
24619
25087
  name: "tree",
24620
25088
  category: "Filesystem",
@@ -24658,6 +25126,12 @@ var treeTool = {
24658
25126
  show_hidden: {
24659
25127
  type: "boolean",
24660
25128
  description: "Show hidden files starting with . (default: false)"
25129
+ },
25130
+ max_entries: {
25131
+ type: "integer",
25132
+ description: `Maximum entries retained in the rendered tree (default ${DEFAULT_MAX_ENTRIES2}).`,
25133
+ minimum: 1,
25134
+ maximum: 5e4
24661
25135
  }
24662
25136
  }
24663
25137
  },
@@ -24671,7 +25145,7 @@ var treeTool = {
24671
25145
  if (!final) throw new Error("tree: stream ended without final event");
24672
25146
  return final;
24673
25147
  },
24674
- async *executeStream(input, ctx) {
25148
+ async *executeStream(input, ctx, opts) {
24675
25149
  const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
24676
25150
  const maxDepth = input.depth ?? 3;
24677
25151
  const showFiles = input.show_files ?? true;
@@ -24679,8 +25153,17 @@ var treeTool = {
24679
25153
  const showHidden = input.show_hidden ?? false;
24680
25154
  const exclude = /* @__PURE__ */ new Set([...DEFAULT_IGNORE5, ...input.exclude ?? []]);
24681
25155
  const filterGlob = input.glob;
25156
+ const maxEntries = input.max_entries ?? DEFAULT_MAX_ENTRIES2;
25157
+ const signal = opts?.signal ?? ctx.signal ?? new AbortController().signal;
24682
25158
  const lines = [basePath];
24683
25159
  const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };
25160
+ const retention = {
25161
+ entries: 0,
25162
+ outputBytes: Buffer.byteLength(basePath, "utf8"),
25163
+ maxEntries,
25164
+ maxOutputBytes: MAX_TREE_OUTPUT_BYTES,
25165
+ truncated: false
25166
+ };
24684
25167
  const queue = [];
24685
25168
  const FLUSH_EVERY = 200;
24686
25169
  let lastEmittedTotal = 0;
@@ -24707,12 +25190,19 @@ var treeTool = {
24707
25190
  isLast: true,
24708
25191
  totalFiles: totals.totalFiles,
24709
25192
  totalDirs: totals.totalDirs,
24710
- onProgress: tickProgress
25193
+ onProgress: tickProgress,
25194
+ retention,
25195
+ signal
24711
25196
  });
24712
25197
  let walkDone = false;
24713
- walkPromise.finally(() => {
24714
- walkDone = true;
24715
- });
25198
+ void walkPromise.then(
25199
+ () => {
25200
+ walkDone = true;
25201
+ },
25202
+ () => {
25203
+ walkDone = true;
25204
+ }
25205
+ );
24716
25206
  while (!walkDone || queue.length > 0) {
24717
25207
  if (queue.length > 0) {
24718
25208
  yield expectDefined10(queue.shift());
@@ -24735,13 +25225,15 @@ var treeTool = {
24735
25225
  tree: lines.join("\n"),
24736
25226
  total_files: totals.totalFiles.value,
24737
25227
  total_dirs: totals.totalDirs.value,
24738
- truncated: false,
25228
+ truncated: retention.truncated,
24739
25229
  path: basePath
24740
25230
  }
24741
25231
  };
24742
25232
  }
24743
25233
  };
24744
25234
  async function walkDir(dir, depth, opts) {
25235
+ opts.signal.throwIfAborted();
25236
+ if (opts.retention.truncated) return;
24745
25237
  const entries = await fs32.readdir(dir, { withFileTypes: true }).catch(() => []);
24746
25238
  const filtered = entries.filter((e) => {
24747
25239
  if (!opts.showHidden && e.name.startsWith(".")) return false;
@@ -24761,6 +25253,8 @@ async function walkDir(dir, depth, opts) {
24761
25253
  return a.name.localeCompare(b.name);
24762
25254
  });
24763
25255
  for (let i = 0; i < items.length; i++) {
25256
+ opts.signal.throwIfAborted();
25257
+ if (opts.retention.truncated) return;
24764
25258
  const entry = items[i];
24765
25259
  if (!entry) continue;
24766
25260
  const isLast = i === items.length - 1;
@@ -24769,7 +25263,15 @@ async function walkDir(dir, depth, opts) {
24769
25263
  const displayName = entry.name + (entry.isDirectory() ? "/" : "");
24770
25264
  if (!opts.showDirs && entry.isDirectory()) continue;
24771
25265
  if (!opts.showFiles && entry.isFile()) continue;
24772
- opts.lines.push(opts.prefix + branch + displayName);
25266
+ const line = opts.prefix + branch + displayName;
25267
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
25268
+ if (opts.retention.entries >= opts.retention.maxEntries || opts.retention.outputBytes + lineBytes > opts.retention.maxOutputBytes) {
25269
+ opts.retention.truncated = true;
25270
+ return;
25271
+ }
25272
+ opts.lines.push(line);
25273
+ opts.retention.entries++;
25274
+ opts.retention.outputBytes += lineBytes;
24773
25275
  if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
24774
25276
  const childPrefix = opts.prefix + connector;
24775
25277
  await walkDir(path36.join(dir, entry.name), depth + 1, {
@@ -24884,11 +25386,11 @@ var typecheckTool = {
24884
25386
  }
24885
25387
  };
24886
25388
  async function findTsConfig(cwd) {
24887
- const { stat: stat18 } = await import("node:fs/promises");
25389
+ const { stat: stat19 } = await import("node:fs/promises");
24888
25390
  const candidates = ["tsconfig.json", "tsconfig.base.json"];
24889
25391
  for (const f of candidates) {
24890
25392
  try {
24891
- const s = await stat18(path37.join(cwd, f));
25393
+ const s = await stat19(path37.join(cwd, f));
24892
25394
  if (s.isFile()) return path37.join(cwd, f);
24893
25395
  } catch {
24894
25396
  }
@@ -24963,12 +25465,12 @@ async function prepareWrite(input, ctx) {
24963
25465
  let existed = false;
24964
25466
  let prev = "";
24965
25467
  try {
24966
- const stat18 = await fs33.stat(absPath);
24967
- existed = stat18.isFile();
25468
+ const stat19 = await fs33.stat(absPath);
25469
+ existed = stat19.isFile();
24968
25470
  if (existed) {
24969
25471
  if (!ctx.hasRead(absPath)) {
24970
25472
  prev = await fs33.readFile(absPath, "utf8");
24971
- ctx.recordRead(absPath, stat18.mtimeMs, "write", sha256hex(prev));
25473
+ ctx.recordRead(absPath, stat19.mtimeMs, "write", sha256hex(prev));
24972
25474
  } else {
24973
25475
  prev = await fs33.readFile(absPath, "utf8");
24974
25476
  }
@@ -24985,8 +25487,8 @@ async function finishWrite(input, ctx, prepared, signal) {
24985
25487
  await atomicWrite5(prepared.absPath, input.content);
24986
25488
  const diff = prepared.existed ? unifiedDiff3(prepared.prev, input.content, { fromFile: input.path, toFile: input.path }) : `+++ ${input.path}
24987
25489
  + (new file, ${input.content.split("\n").length} lines)`;
24988
- const stat18 = await fs33.stat(prepared.absPath);
24989
- ctx.recordRead(prepared.absPath, stat18.mtimeMs, "write", sha256hex(input.content));
25490
+ const stat19 = await fs33.stat(prepared.absPath);
25491
+ ctx.recordRead(prepared.absPath, stat19.mtimeMs, "write", sha256hex(input.content));
24990
25492
  ctx.session.recordFileChange({
24991
25493
  path: prepared.absPath,
24992
25494
  action: prepared.existed ? "modified" : "created",
@@ -26174,9 +26676,9 @@ async function walk(root, dir, out) {
26174
26676
  } else if (e.isFile()) {
26175
26677
  if (e.name === "SKILL.md" || e.name === "SKILL.save.md") continue;
26176
26678
  try {
26177
- const stat18 = await fs34.stat(fullPath);
26679
+ const stat19 = await fs34.stat(fullPath);
26178
26680
  const rel = path38.relative(root, fullPath).split(path38.sep).join("/");
26179
- out.push({ path: rel, bytes: stat18.size });
26681
+ out.push({ path: rel, bytes: stat19.size });
26180
26682
  } catch {
26181
26683
  }
26182
26684
  }