@prestyj/cli 5.28.1 → 5.29.1

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 (339) hide show
  1. package/assets/motion/bin/contact-sheet.mjs +9 -3
  2. package/assets/motion/bin/cues.mjs +337 -0
  3. package/assets/motion/bin/library.mjs +53 -10
  4. package/assets/motion/bin/motion-blur.mjs +943 -0
  5. package/assets/motion/bin/motion-check.mjs +354 -3
  6. package/assets/motion/bin/music-fit.mjs +436 -0
  7. package/assets/motion/bin/pdf-extract.mjs +22 -10
  8. package/assets/motion/bin/reference-study.mjs +346 -0
  9. package/assets/motion/bin/score-synth.mjs +1052 -93
  10. package/assets/motion/library/README.md +50 -14
  11. package/assets/motion/library/kit/moves.js +1981 -0
  12. package/assets/motion/library/library.json +232 -0
  13. package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
  14. package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
  15. package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
  16. package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
  17. package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
  18. package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
  19. package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
  20. package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
  21. package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
  22. package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
  23. package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
  24. package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
  25. package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
  26. package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
  27. package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
  28. package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
  29. package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
  30. package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
  31. package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
  32. package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
  33. package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
  34. package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
  35. package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
  36. package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
  37. package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
  38. package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
  39. package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
  40. package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
  41. package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
  42. package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
  43. package/assets/motion/library/sheets/diagram.jpg +0 -0
  44. package/assets/motion/library/sheets/frame.jpg +0 -0
  45. package/assets/motion/library/sheets/transition.jpg +0 -0
  46. package/assets/motion/library/sheets/ui.jpg +0 -0
  47. package/assets/motion/references/build-sheet.md +206 -0
  48. package/assets/motion/references/runtime/determinism-rules.md +1 -1
  49. package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
  50. package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
  51. package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
  52. package/assets/motion/references/runtime/minimal-composition.md +1 -1
  53. package/assets/motion/references/runtime/preview-render.md +3 -3
  54. package/assets/motion/skills/app-walkthrough/SKILL.md +70 -0
  55. package/assets/motion/skills/before-after/SKILL.md +57 -0
  56. package/assets/motion/skills/brand-kit/SKILL.md +3 -3
  57. package/assets/motion/skills/dev-tool-video/SKILL.md +61 -0
  58. package/assets/motion/skills/launch-video/SKILL.md +66 -0
  59. package/assets/motion/skills/match-reference/SKILL.md +62 -0
  60. package/assets/motion/skills/motion/SKILL.md +148 -98
  61. package/assets/motion/skills/source-ingest/SKILL.md +17 -6
  62. package/assets/motion/skills/website-video/SKILL.md +76 -0
  63. package/assets/skills/bulletproof/SKILL.md +36 -11
  64. package/assets/skills/bulletproof/references/agent-surface.md +19 -9
  65. package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
  66. package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
  67. package/assets/skills/bulletproof/references/provenance.md +26 -1
  68. package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
  69. package/assets/skills/bulletproof/references/supply-chain.md +21 -17
  70. package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
  71. package/assets/skills/bulletproof/references/verification.md +2 -0
  72. package/assets/skills/clarify/SKILL.md +25 -16
  73. package/assets/skills/code-review/SKILL.md +71 -13
  74. package/assets/skills/code-review/references/agent-diffs.md +27 -0
  75. package/assets/skills/code-review/references/tests.md +19 -0
  76. package/assets/skills/compliance-guard/SKILL.md +20 -5
  77. package/assets/skills/compliance-guard/references/artifacts.md +1 -1
  78. package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
  79. package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
  80. package/assets/skills/compliance-guard/references/provenance.md +41 -2
  81. package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
  82. package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
  83. package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
  84. package/assets/skills/compliance-guard/references/us.md +27 -21
  85. package/assets/skills/durable/SKILL.md +87 -79
  86. package/assets/skills/durable/references/agent-db-safety.md +69 -0
  87. package/assets/skills/durable/references/backups-and-runtime.md +19 -12
  88. package/assets/skills/durable/references/migrations-and-schema.md +13 -6
  89. package/assets/skills/evidence-led-ui/SKILL.md +69 -127
  90. package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
  91. package/assets/skills/evidence-led-ui/references/direction.md +124 -0
  92. package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
  93. package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
  94. package/assets/skills/lean/SKILL.md +90 -71
  95. package/assets/skills/lean/references/memory-and-processes.md +3 -2
  96. package/assets/skills/lean/references/playbooks.md +37 -12
  97. package/assets/skills/refactoring/SKILL.md +24 -3
  98. package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
  99. package/assets/skills/refactoring/references/legacy.md +21 -0
  100. package/assets/skills/root-cause/SKILL.md +20 -10
  101. package/assets/skills/shared-language/SKILL.md +16 -14
  102. package/assets/skills/tdd/SKILL.md +27 -15
  103. package/dist/app-sidecar.js +206 -47
  104. package/dist/app-sidecar.js.map +1 -1
  105. package/dist/cli/thinking-arg.d.ts +6 -0
  106. package/dist/cli/thinking-arg.js +13 -0
  107. package/dist/cli/thinking-arg.js.map +1 -0
  108. package/dist/cli.d.ts +1 -2
  109. package/dist/cli.js +18 -34
  110. package/dist/cli.js.map +1 -1
  111. package/dist/core/acceptance-checks.d.ts +48 -0
  112. package/dist/core/acceptance-checks.js +144 -0
  113. package/dist/core/acceptance-checks.js.map +1 -0
  114. package/dist/core/agent-session.d.ts +107 -72
  115. package/dist/core/agent-session.js +562 -407
  116. package/dist/core/agent-session.js.map +1 -1
  117. package/dist/core/agents.d.ts +6 -5
  118. package/dist/core/agents.js.map +1 -1
  119. package/dist/core/ask-user.d.ts +90 -8
  120. package/dist/core/ask-user.js +124 -13
  121. package/dist/core/ask-user.js.map +1 -1
  122. package/dist/core/bundled-agents.js +1 -3
  123. package/dist/core/bundled-agents.js.map +1 -1
  124. package/dist/core/cache-diagnostics.d.ts +68 -0
  125. package/dist/core/cache-diagnostics.js +196 -0
  126. package/dist/core/cache-diagnostics.js.map +1 -0
  127. package/dist/core/cache-expiry.d.ts +87 -0
  128. package/dist/core/cache-expiry.js +111 -0
  129. package/dist/core/cache-expiry.js.map +1 -0
  130. package/dist/core/codex-request-shape.d.ts +12 -0
  131. package/dist/core/codex-request-shape.js +31 -0
  132. package/dist/core/codex-request-shape.js.map +1 -0
  133. package/dist/core/compaction/compactor.js +84 -48
  134. package/dist/core/compaction/compactor.js.map +1 -1
  135. package/dist/core/compaction/plan-step-policy.d.ts +46 -0
  136. package/dist/core/compaction/plan-step-policy.js +57 -0
  137. package/dist/core/compaction/plan-step-policy.js.map +1 -0
  138. package/dist/core/destructive-git-guard.d.ts +90 -0
  139. package/dist/core/destructive-git-guard.js +871 -0
  140. package/dist/core/destructive-git-guard.js.map +1 -0
  141. package/dist/core/event-bus.d.ts +3 -0
  142. package/dist/core/event-bus.js +5 -0
  143. package/dist/core/event-bus.js.map +1 -1
  144. package/dist/core/injection-detect.d.ts +38 -0
  145. package/dist/core/injection-detect.js +232 -0
  146. package/dist/core/injection-detect.js.map +1 -0
  147. package/dist/core/keep-awake.d.ts +88 -0
  148. package/dist/core/keep-awake.js +251 -0
  149. package/dist/core/keep-awake.js.map +1 -0
  150. package/dist/core/mcp/client.d.ts +72 -0
  151. package/dist/core/mcp/client.js +264 -41
  152. package/dist/core/mcp/client.js.map +1 -1
  153. package/dist/core/mcp/content.js +6 -2
  154. package/dist/core/mcp/content.js.map +1 -1
  155. package/dist/core/mcp/store.d.ts +6 -1
  156. package/dist/core/mcp/store.js +12 -1
  157. package/dist/core/mcp/store.js.map +1 -1
  158. package/dist/core/mcp/types.d.ts +18 -0
  159. package/dist/core/model-unavailable.d.ts +14 -0
  160. package/dist/core/model-unavailable.js +23 -0
  161. package/dist/core/model-unavailable.js.map +1 -0
  162. package/dist/core/node-debugger.d.ts +148 -0
  163. package/dist/core/node-debugger.js +642 -0
  164. package/dist/core/node-debugger.js.map +1 -0
  165. package/dist/core/nolan-context.js +7 -3
  166. package/dist/core/nolan-context.js.map +1 -1
  167. package/dist/core/package-threats.d.ts +18 -0
  168. package/dist/core/package-threats.js +168 -0
  169. package/dist/core/package-threats.js.map +1 -0
  170. package/dist/core/persistent-shell.d.ts +58 -6
  171. package/dist/core/persistent-shell.js +331 -49
  172. package/dist/core/persistent-shell.js.map +1 -1
  173. package/dist/core/process-manager.d.ts +14 -0
  174. package/dist/core/process-manager.js +61 -0
  175. package/dist/core/process-manager.js.map +1 -1
  176. package/dist/core/progress/git-xp.js +8 -14
  177. package/dist/core/progress/git-xp.js.map +1 -1
  178. package/dist/core/session-history.d.ts +12 -0
  179. package/dist/core/session-history.js +27 -0
  180. package/dist/core/session-history.js.map +1 -1
  181. package/dist/core/session-manager.d.ts +13 -1
  182. package/dist/core/session-manager.js +38 -18
  183. package/dist/core/session-manager.js.map +1 -1
  184. package/dist/core/session-summary-index.d.ts +37 -0
  185. package/dist/core/session-summary-index.js +172 -0
  186. package/dist/core/session-summary-index.js.map +1 -0
  187. package/dist/core/settings-manager.d.ts +12 -0
  188. package/dist/core/settings-manager.js +20 -0
  189. package/dist/core/settings-manager.js.map +1 -1
  190. package/dist/core/shell-threats-popular-packages.d.ts +11 -0
  191. package/dist/core/shell-threats-popular-packages.js +675 -0
  192. package/dist/core/shell-threats-popular-packages.js.map +1 -0
  193. package/dist/core/shell-threats.d.ts +8 -0
  194. package/dist/core/shell-threats.js +186 -0
  195. package/dist/core/shell-threats.js.map +1 -0
  196. package/dist/core/skills.js +3 -1
  197. package/dist/core/skills.js.map +1 -1
  198. package/dist/core/stream-rules.d.ts +30 -0
  199. package/dist/core/stream-rules.js +151 -0
  200. package/dist/core/stream-rules.js.map +1 -0
  201. package/dist/core/subagent-manager.d.ts +20 -5
  202. package/dist/core/subagent-manager.js +22 -8
  203. package/dist/core/subagent-manager.js.map +1 -1
  204. package/dist/core/subagent-receipt.d.ts +54 -0
  205. package/dist/core/subagent-receipt.js +278 -0
  206. package/dist/core/subagent-receipt.js.map +1 -0
  207. package/dist/core/subagent-turn-record.d.ts +2 -0
  208. package/dist/core/subagent-turn-record.js.map +1 -1
  209. package/dist/core/test-impact.d.ts +73 -0
  210. package/dist/core/test-impact.js +467 -0
  211. package/dist/core/test-impact.js.map +1 -0
  212. package/dist/core/thinking-level.d.ts +1 -1
  213. package/dist/core/thinking-level.js +1 -1
  214. package/dist/core/thinking-level.js.map +1 -1
  215. package/dist/core/verification-gate.d.ts +2 -0
  216. package/dist/core/verification-gate.js +4 -0
  217. package/dist/core/verification-gate.js.map +1 -1
  218. package/dist/core/verification-snapshot.js +3 -5
  219. package/dist/core/verification-snapshot.js.map +1 -1
  220. package/dist/core/workspace-guard.d.ts +18 -7
  221. package/dist/core/workspace-guard.js +227 -60
  222. package/dist/core/workspace-guard.js.map +1 -1
  223. package/dist/interactive.js +2 -1
  224. package/dist/interactive.js.map +1 -1
  225. package/dist/modes/subagent-worker-mode.js +34 -6
  226. package/dist/modes/subagent-worker-mode.js.map +1 -1
  227. package/dist/motion-agent/motion-agent.d.ts +6 -2
  228. package/dist/motion-agent/motion-agent.js +5 -7
  229. package/dist/motion-agent/motion-agent.js.map +1 -1
  230. package/dist/motion-agent/motion-prompt.d.ts +1 -1
  231. package/dist/motion-agent/motion-prompt.js +16 -20
  232. package/dist/motion-agent/motion-prompt.js.map +1 -1
  233. package/dist/motion-agent/motion-review-session.js +7 -3
  234. package/dist/motion-agent/motion-review-session.js.map +1 -1
  235. package/dist/motion-agent/motion-review.d.ts +10 -3
  236. package/dist/motion-agent/motion-review.js +15 -7
  237. package/dist/motion-agent/motion-review.js.map +1 -1
  238. package/dist/motion-agent/motion-studio-context.js +1 -1
  239. package/dist/motion-agent/motion-studio-context.js.map +1 -1
  240. package/dist/system-prompt.js +15 -11
  241. package/dist/system-prompt.js.map +1 -1
  242. package/dist/test-support/keep-alive.d.ts +14 -0
  243. package/dist/test-support/keep-alive.js +17 -0
  244. package/dist/test-support/keep-alive.js.map +1 -0
  245. package/dist/tools/ask-user.js +3 -3
  246. package/dist/tools/ask-user.js.map +1 -1
  247. package/dist/tools/bash-read-evidence.d.ts +10 -0
  248. package/dist/tools/bash-read-evidence.js +133 -0
  249. package/dist/tools/bash-read-evidence.js.map +1 -0
  250. package/dist/tools/bash.d.ts +10 -1
  251. package/dist/tools/bash.js +115 -7
  252. package/dist/tools/bash.js.map +1 -1
  253. package/dist/tools/debug.d.ts +54 -0
  254. package/dist/tools/debug.js +233 -0
  255. package/dist/tools/debug.js.map +1 -0
  256. package/dist/tools/edit-targets.d.ts +10 -0
  257. package/dist/tools/edit-targets.js +31 -0
  258. package/dist/tools/edit-targets.js.map +1 -0
  259. package/dist/tools/edit.d.ts +11 -3
  260. package/dist/tools/edit.js +387 -305
  261. package/dist/tools/edit.js.map +1 -1
  262. package/dist/tools/goals.d.ts +1 -1
  263. package/dist/tools/index.d.ts +19 -2
  264. package/dist/tools/index.js +47 -7
  265. package/dist/tools/index.js.map +1 -1
  266. package/dist/tools/prompt-hints.js +6 -1
  267. package/dist/tools/prompt-hints.js.map +1 -1
  268. package/dist/tools/read-tracker.d.ts +5 -0
  269. package/dist/tools/read-tracker.js +19 -9
  270. package/dist/tools/read-tracker.js.map +1 -1
  271. package/dist/tools/read.js +3 -2
  272. package/dist/tools/read.js.map +1 -1
  273. package/dist/tools/skill.js +11 -3
  274. package/dist/tools/skill.js.map +1 -1
  275. package/dist/tools/subagent-control.js +44 -8
  276. package/dist/tools/subagent-control.js.map +1 -1
  277. package/dist/tools/subagent-shared.d.ts +29 -8
  278. package/dist/tools/subagent-shared.js +45 -14
  279. package/dist/tools/subagent-shared.js.map +1 -1
  280. package/dist/tools/subagent.d.ts +14 -2
  281. package/dist/tools/subagent.js +43 -13
  282. package/dist/tools/subagent.js.map +1 -1
  283. package/dist/tools/task-output.js +3 -2
  284. package/dist/tools/task-output.js.map +1 -1
  285. package/dist/tools/task-send.d.ts +1 -1
  286. package/dist/tools/task-send.js +15 -1
  287. package/dist/tools/task-send.js.map +1 -1
  288. package/dist/tools/tool-tiers.d.ts +8 -2
  289. package/dist/tools/tool-tiers.js +10 -3
  290. package/dist/tools/tool-tiers.js.map +1 -1
  291. package/dist/tools/truncate.d.ts +21 -0
  292. package/dist/tools/truncate.js +187 -0
  293. package/dist/tools/truncate.js.map +1 -1
  294. package/dist/tools/ui-adopt.js +2 -0
  295. package/dist/tools/ui-adopt.js.map +1 -1
  296. package/dist/ui/App.d.ts +0 -4
  297. package/dist/ui/App.js +12 -31
  298. package/dist/ui/App.js.map +1 -1
  299. package/dist/ui/components/ActivityIndicator.js +1 -0
  300. package/dist/ui/components/ActivityIndicator.js.map +1 -1
  301. package/dist/ui/components/ToolExecution.js +3 -2
  302. package/dist/ui/components/ToolExecution.js.map +1 -1
  303. package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
  304. package/dist/ui/hooks/useAgentLoop.js +1 -119
  305. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  306. package/dist/ui/render.d.ts +0 -4
  307. package/dist/ui/render.js +0 -2
  308. package/dist/ui/render.js.map +1 -1
  309. package/dist/ui/terminal-history.js +3 -1
  310. package/dist/ui/terminal-history.js.map +1 -1
  311. package/dist/ui/tool-line-summary.js +3 -1
  312. package/dist/ui/tool-line-summary.js.map +1 -1
  313. package/dist/utils/format.js +5 -1
  314. package/dist/utils/format.js.map +1 -1
  315. package/dist/utils/git.d.ts +77 -0
  316. package/dist/utils/git.js +285 -21
  317. package/dist/utils/git.js.map +1 -1
  318. package/dist/utils/github-ci.js +2 -1
  319. package/dist/utils/github-ci.js.map +1 -1
  320. package/dist/utils/github.js +11 -9
  321. package/dist/utils/github.js.map +1 -1
  322. package/dist/utils/image.d.ts +14 -0
  323. package/dist/utils/image.js +16 -0
  324. package/dist/utils/image.js.map +1 -1
  325. package/dist/utils/process.d.ts +20 -0
  326. package/dist/utils/process.js +98 -0
  327. package/dist/utils/process.js.map +1 -1
  328. package/package.json +4 -4
  329. package/assets/motion/references/motion-language.md +0 -128
  330. package/assets/motion/skills/video-qa/SKILL.md +0 -89
  331. package/dist/core/ideal-review-subagent.d.ts +0 -56
  332. package/dist/core/ideal-review-subagent.js +0 -112
  333. package/dist/core/ideal-review-subagent.js.map +0 -1
  334. package/dist/core/ideal-review.d.ts +0 -82
  335. package/dist/core/ideal-review.js +0 -242
  336. package/dist/core/ideal-review.js.map +0 -1
  337. package/dist/motion-agent/motion-check-tool.d.ts +0 -35
  338. package/dist/motion-agent/motion-check-tool.js +0 -514
  339. package/dist/motion-agent/motion-check-tool.js.map +0 -1
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { resolvePath, rejectSymlink } from "./path-utils.js";
4
4
  import { fuzzyFindText, normalizeForFuzzyMatch, countOccurrences, generateDiff, findClosestSnippet, findOccurrenceLines, stripBlankEdges, applyDotdotdots, applyMissingLeadingWhitespace, } from "./edit-diff.js";
5
5
  import { localOperations } from "./operations.js";
6
- import { assertFresh, recordWrite } from "./read-tracker.js";
6
+ import { isFresh, recordWrite } from "./read-tracker.js";
7
7
  import { resolveAnchoredEdit } from "../core/hashline.js";
8
8
  import { getActiveGoalMode, goalModeMutationRestriction, isPlanModeActive, planModeRestriction, } from "../core/runtime-mode.js";
9
9
  import { resolveWriteGuard } from "../core/workspace-guard.js";
@@ -85,27 +85,48 @@ const STRINGIFIED_EDITS_ERROR = "`edits` arrived as a JSON-encoded string and co
85
85
  "Send `edits` as a real JSON array of objects, never as a string. " +
86
86
  "Re-sending the same large payload usually breaks the same way: split the work into " +
87
87
  "several `edit` calls carrying one or two smaller edits each.";
88
- const EditParams = z.object({
88
+ const EditList = z
89
+ .preprocess(coerceStringifiedEdits, z
90
+ .array(EditItem, {
91
+ // Narrow to "an array was expected, a string arrived" so a nested
92
+ // string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
93
+ // its own accurate message. Any non-matching issue returns undefined
94
+ // and falls through to Zod's default.
95
+ error: (issue) => issue.code === "invalid_type" &&
96
+ issue.expected === "array" &&
97
+ typeof issue.input === "string"
98
+ ? STRINGIFIED_EDITS_ERROR
99
+ : undefined,
100
+ })
101
+ .min(1))
102
+ .describe("One or more edits applied in order. Each edit operates on the result of the previous one.");
103
+ // Multi-file entries take the text form only: a replay test batched all 7
104
+ // files 8/8 times either way, and dropping the anchor/span forms here keeps
105
+ // ~2k chars of duplicated schema out of every request.
106
+ const TextEditItem = EditItem.pick({ old_text: true, new_text: true, replace_all: true });
107
+ const FileEdits = z.object({
89
108
  file_path: z.string().describe("The file path to edit"),
90
109
  edits: z
91
- .preprocess(coerceStringifiedEdits, z
92
- .array(EditItem, {
93
- // Narrow to "an array was expected, a string arrived" so a nested
94
- // string-typed mistake (`anchor: "x"`, `replace_all: "true"`) keeps
95
- // its own accurate message. Any non-matching issue returns undefined
96
- // and falls through to Zod's default.
97
- error: (issue) => issue.code === "invalid_type" &&
98
- issue.expected === "array" &&
99
- typeof issue.input === "string"
100
- ? STRINGIFIED_EDITS_ERROR
101
- : undefined,
102
- })
103
- .min(1))
104
- .describe("One or more edits applied in order. Each edit operates on the result of the previous one."),
110
+ .preprocess(coerceStringifiedEdits, z.array(TextEditItem).min(1))
111
+ .describe("Text-form edits for this file, applied in order."),
112
+ });
113
+ // `files` lets one call cover a multi-file change. Measured on gpt-6-astra
114
+ // (replay of a 7-file refactor at the point where every file had been read):
115
+ // with it the model sent all 7 files in one call 8/8 times; without it 5/8
116
+ // responses carried every edit and the rest fell back to one file per turn.
117
+ const EditParams = z.object({
118
+ file_path: z.string().optional().describe("The file path to edit (single-file form)"),
119
+ edits: EditList.optional(),
120
+ files: z
121
+ .array(FileEdits)
122
+ .min(1)
123
+ .optional()
124
+ .describe("Several files in ONE call: one entry per file, each with text-form edits (old_text/new_text). " +
125
+ "Use instead of file_path/edits when a change spans files."),
105
126
  atomic: z
106
127
  .boolean()
107
128
  .optional()
108
- .describe("If true, fail the whole batch when any edit fails — no changes written. " +
129
+ .describe("If true, fail a file's whole batch when any of its edits fails — that file is left unchanged. " +
109
130
  "Default false: partial-apply, keep every successful edit and report failures " +
110
131
  "for retry. Use atomic only when later edits depend on earlier ones in a way " +
111
132
  "where a half-applied state would be worse than nothing."),
@@ -175,10 +196,11 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
175
196
  "text edits then run on the result.\n" +
176
197
  "Partial-apply by default: failed edits are listed for retry, successful ones are still written — " +
177
198
  "re-issue ONLY the listed failures, not the whole batch. " +
178
- "Returns a unified diff.",
199
+ "Returns a unified diff.\n" +
200
+ "Multi-file: pass `files` (one {file_path, edits} entry per file) to change several files in a single call.",
179
201
  parameters: EditParams,
180
202
  executionMode: "sequential",
181
- async execute({ file_path, edits, atomic = false }) {
203
+ async execute({ file_path, edits, files, atomic = false }) {
182
204
  const goalMode = getActiveGoalMode(goalModeRef);
183
205
  if (goalMode !== "off") {
184
206
  return goalModeMutationRestriction("edit", goalMode);
@@ -186,325 +208,385 @@ export function createEditTool(cwd, readFiles, ops = localOperations, planModeRe
186
208
  if (isPlanModeActive(planModeRef)) {
187
209
  return planModeRestriction("edit");
188
210
  }
189
- const resolved = resolvePath(cwd, file_path);
190
- await rejectSymlink(resolved);
191
- // Workspace write guard: outside cwd/tmp/~/.ezcoder requires user approval.
192
- const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
193
- if (!guard.allowed) {
194
- return `Error: ${guard.reason}`;
211
+ const targets = resolveEditTargets(file_path, edits, files);
212
+ if (!targets.ok)
213
+ throw new Error(targets.error);
214
+ if (targets.value.length === 1) {
215
+ const only = targets.value[0];
216
+ return editOneFile(only.file_path, only.edits, atomic);
195
217
  }
196
- await assertFresh(readFiles, resolved, ops);
197
- const original = await ops.readFile(resolved);
198
- const hasCRLF = original.includes("\r\n");
199
- const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
200
- // Anchors pin lines in the file AS READ, so they always verify against the
201
- // original (pre-edit) line array — earlier edits in the batch don't shift
202
- // what an anchor refers to.
203
- const originalLines = originalNormalized.split("\n");
204
- const fileName = path.basename(resolved);
205
- const outcomes = new Array(edits.length);
206
- // ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
207
- // against the file AS READ and apply bottom-up so indices stay valid.
208
- // Text-form edits then run on the result in phase 2.
209
- const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
210
- const spanResolved = [];
211
- for (let i = 0; i < edits.length; i++) {
212
- const e = edits[i];
213
- if (!isSpanForm(e)) {
214
- if (e.old_text === undefined || e.new_text === undefined) {
215
- outcomes[i] = {
216
- ok: false,
217
- failure: {
218
- reason: "invalid",
219
- detail: "provide either old_text+new_text, or span+lines — this edit has neither",
220
- },
221
- };
222
- }
223
- continue;
218
+ // Files apply in order, each independently: one file's failure never
219
+ // rolls back another, and the result says exactly which to re-issue.
220
+ const parts = [];
221
+ const diffs = [];
222
+ let failed = 0;
223
+ for (const target of targets.value) {
224
+ try {
225
+ const result = await editOneFile(target.file_path, target.edits, atomic);
226
+ const content = typeof result === "string" ? result : result.content;
227
+ if (typeof result !== "string" && result.details.diff)
228
+ diffs.push(result.details.diff);
229
+ if (typeof result === "string" && result.startsWith("Error:"))
230
+ failed++;
231
+ parts.push(`### ${target.file_path}\n${content}`);
224
232
  }
225
- if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
233
+ catch (error) {
234
+ failed++;
235
+ parts.push(`### ${target.file_path}\nFAILED — ${error instanceof Error ? error.message : String(error)}`);
236
+ }
237
+ }
238
+ const total = targets.value.length;
239
+ if (failed === total)
240
+ throw new Error(`All ${total} files failed; no changes written.\n\n${parts.join("\n\n")}`);
241
+ const header = failed === 0
242
+ ? `Edited ${total} files.`
243
+ : `Edited ${total - failed} of ${total} files. ${failed} failed — re-issue ONLY the failed files (the rest are already done).`;
244
+ return { content: `${header}\n\n${parts.join("\n\n")}`, details: { diff: diffs.join("\n") } };
245
+ },
246
+ };
247
+ async function editOneFile(file_path, edits, atomic) {
248
+ const resolved = resolvePath(cwd, file_path);
249
+ await rejectSymlink(resolved);
250
+ // Workspace write guard: outside cwd/tmp/~/.ezcoder requires user approval.
251
+ const guard = resolveWriteGuard(cwd, resolved, getWriteGuardSettings?.());
252
+ if (!guard.allowed) {
253
+ return `Error: ${guard.reason}`;
254
+ }
255
+ // No prior read is required: every old_text, span and anchor is matched
256
+ // against the live content below, which proves the model knows the text
257
+ // it replaces (it may have seen it via `cat`, or changed the file itself
258
+ // with a script). A read that no longer matches disk is forgotten, so the
259
+ // file counts as unread again: the edit still validates against live
260
+ // content, but a later full-file `write` needs a fresh read.
261
+ if (readFiles?.has(resolved) && !(await isFresh(readFiles, resolved, ops))) {
262
+ readFiles.delete(resolved);
263
+ }
264
+ const original = await ops.readFile(resolved);
265
+ const hasCRLF = original.includes("\r\n");
266
+ const originalNormalized = hasCRLF ? original.replace(/\r\n/g, "\n") : original;
267
+ // Anchors pin lines in the file AS READ, so they always verify against the
268
+ // original (pre-edit) line array — earlier edits in the batch don't shift
269
+ // what an anchor refers to.
270
+ const originalLines = originalNormalized.split("\n");
271
+ const fileName = path.basename(resolved);
272
+ const outcomes = new Array(edits.length);
273
+ // ── Phase 1: span-form edits (hash-anchored replacement). Spans resolve
274
+ // against the file AS READ and apply bottom-up so indices stay valid.
275
+ // Text-form edits then run on the result in phase 2.
276
+ const isSpanForm = (e) => e.span !== undefined || e.lines !== undefined;
277
+ const spanResolved = [];
278
+ for (let i = 0; i < edits.length; i++) {
279
+ const e = edits[i];
280
+ if (!isSpanForm(e)) {
281
+ if (e.old_text === undefined || e.new_text === undefined) {
226
282
  outcomes[i] = {
227
283
  ok: false,
228
284
  failure: {
229
285
  reason: "invalid",
230
- detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
286
+ detail: "provide either old_text+new_text, or span+lines — this edit has neither",
231
287
  },
232
288
  };
233
- continue;
234
289
  }
235
- const res = resolveAnchoredEdit(originalLines, e.span);
290
+ continue;
291
+ }
292
+ if (!e.span || !e.lines || e.old_text !== undefined || e.new_text !== undefined) {
293
+ outcomes[i] = {
294
+ ok: false,
295
+ failure: {
296
+ reason: "invalid",
297
+ detail: "span form requires BOTH span and lines, and must not mix with old_text/new_text",
298
+ },
299
+ };
300
+ continue;
301
+ }
302
+ const res = resolveAnchoredEdit(originalLines, e.span);
303
+ if (!res.ok) {
304
+ outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
305
+ continue;
306
+ }
307
+ spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
308
+ }
309
+ // Reject overlapping spans (keep the first, fail the rest) — overlap means
310
+ // the model double-addressed the same region and the result is undefined.
311
+ spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
312
+ const spanApplied = [];
313
+ let lastEnd = -1;
314
+ for (const s of spanResolved) {
315
+ if (s.start <= lastEnd) {
316
+ outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
317
+ continue;
318
+ }
319
+ spanApplied.push(s);
320
+ lastEnd = s.end;
321
+ }
322
+ const workingLines = [...originalLines];
323
+ for (let i = spanApplied.length - 1; i >= 0; i--) {
324
+ const s = spanApplied[i];
325
+ workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
326
+ outcomes[s.index] = { ok: true, source: "span" };
327
+ }
328
+ let working = workingLines.join("\n");
329
+ // ── Phase 2: text-form edits, sequential on the working buffer.
330
+ for (let i = 0; i < edits.length; i++) {
331
+ if (outcomes[i] !== undefined)
332
+ continue; // span-form or invalid, already settled
333
+ const { old_text, new_text, replace_all, anchor } = edits[i];
334
+ if (old_text === undefined || new_text === undefined)
335
+ continue; // settled above
336
+ // Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
337
+ // if the model supplied an anchor and the file drifted since it read it,
338
+ // reject this edit instead of risking a misplaced fuzzy match. The fuzzy
339
+ // path below is byte-identical to today when `anchor` is absent.
340
+ if (anchor) {
341
+ const res = resolveAnchoredEdit(originalLines, anchor);
236
342
  if (!res.ok) {
237
343
  outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
238
344
  continue;
239
345
  }
240
- spanResolved.push({ index: i, start: res.startIndex, end: res.endIndex, lines: e.lines });
241
346
  }
242
- // Reject overlapping spans (keep the first, fail the rest) — overlap means
243
- // the model double-addressed the same region and the result is undefined.
244
- spanResolved.sort((a, b) => a.start - b.start || a.index - b.index);
245
- const spanApplied = [];
246
- let lastEnd = -1;
247
- for (const s of spanResolved) {
248
- if (s.start <= lastEnd) {
249
- outcomes[s.index] = { ok: false, failure: { reason: "overlap" } };
250
- continue;
251
- }
252
- spanApplied.push(s);
253
- lastEnd = s.end;
347
+ const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
348
+ const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
349
+ const replaceAll = replace_all ?? false;
350
+ // Identical replacements are explicit no-op successes. They should not
351
+ // block atomic batches that contain real edits, and all-no-op batches
352
+ // should report success without writing.
353
+ if (normalizedOld === normalizedNew) {
354
+ outcomes[i] = { ok: true };
355
+ continue;
254
356
  }
255
- const workingLines = [...originalLines];
256
- for (let i = spanApplied.length - 1; i >= 0; i--) {
257
- const s = spanApplied[i];
258
- workingLines.splice(s.start, s.end - s.start + 1, ...s.lines);
259
- outcomes[s.index] = { ok: true, source: "span" };
357
+ if (normalizedOld.length === 0 ||
358
+ (!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
359
+ outcomes[i] = {
360
+ ok: false,
361
+ failure: {
362
+ reason: "invalid",
363
+ detail: "old_text is empty after normalization; provide visible surrounding context.",
364
+ },
365
+ };
366
+ continue;
260
367
  }
261
- let working = workingLines.join("\n");
262
- // ── Phase 2: text-form edits, sequential on the working buffer.
263
- for (let i = 0; i < edits.length; i++) {
264
- if (outcomes[i] !== undefined)
265
- continue; // span-form or invalid, already settled
266
- const { old_text, new_text, replace_all, anchor } = edits[i];
267
- if (old_text === undefined || new_text === undefined)
268
- continue; // settled above
269
- // Optional staleness guard (opt-in). Runs BEFORE the fuzzy match ladder:
270
- // if the model supplied an anchor and the file drifted since it read it,
271
- // reject this edit instead of risking a misplaced fuzzy match. The fuzzy
272
- // path below is byte-identical to today when `anchor` is absent.
273
- if (anchor) {
274
- const res = resolveAnchoredEdit(originalLines, anchor);
275
- if (!res.ok) {
276
- outcomes[i] = { ok: false, failure: { reason: "stale_anchor" } };
277
- continue;
278
- }
368
+ // Aider's full fallback ladder, run only when the primary match
369
+ // returns "not_found". Ambiguous matches deliberately don't fall
370
+ // through — the model needs to add context, not paraphrase further.
371
+ // Order mirrors aider/coders/editblock_coder.py:
372
+ // 1. exact + smart-quote/dash fuzzy (in tryMatch)
373
+ // 2. indent-flex (model omitted/shortened leading whitespace)
374
+ // 3. drop spurious leading/trailing blank lines, retry 1+2
375
+ // 4. dotdotdots (`...` elision with preserved middle)
376
+ let source = "text";
377
+ let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
378
+ const tryFallbacks = (oldText) => {
379
+ const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
380
+ if (flexed !== null)
381
+ return flexed;
382
+ // Re-run primary matcher on the stripped variant as a cheap retry.
383
+ const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
384
+ if (exact.ok)
385
+ return exact.newWorking;
386
+ return null;
387
+ };
388
+ if (!result.ok && result.reason === "not_found") {
389
+ const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
390
+ if (indentFlexed !== null) {
391
+ result = { ok: true, newWorking: indentFlexed };
392
+ source = "indent_flex";
279
393
  }
280
- const normalizedOld = hasCRLF ? old_text.replace(/\r\n/g, "\n") : old_text;
281
- const normalizedNew = hasCRLF ? new_text.replace(/\r\n/g, "\n") : new_text;
282
- const replaceAll = replace_all ?? false;
283
- // Identical replacements are explicit no-op successes. They should not
284
- // block atomic batches that contain real edits, and all-no-op batches
285
- // should report success without writing.
286
- if (normalizedOld === normalizedNew) {
287
- outcomes[i] = { ok: true };
288
- continue;
394
+ }
395
+ if (!result.ok && result.reason === "not_found") {
396
+ const stripped = stripBlankEdges(normalizedOld);
397
+ if (stripped !== null) {
398
+ const candidate = tryFallbacks(stripped);
399
+ if (candidate !== null) {
400
+ result = { ok: true, newWorking: candidate };
401
+ source = "blank_edges";
402
+ }
289
403
  }
290
- if (normalizedOld.length === 0 ||
291
- (!working.includes(normalizedOld) && normalizeForFuzzyMatch(normalizedOld).length === 0)) {
404
+ }
405
+ if (!result.ok && result.reason === "not_found") {
406
+ if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
292
407
  outcomes[i] = {
293
408
  ok: false,
294
409
  failure: {
295
410
  reason: "invalid",
296
- detail: "old_text is empty after normalization; provide visible surrounding context.",
411
+ detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
297
412
  },
298
413
  };
299
414
  continue;
300
415
  }
301
- // Aider's full fallback ladder, run only when the primary match
302
- // returns "not_found". Ambiguous matches deliberately don't fall
303
- // through — the model needs to add context, not paraphrase further.
304
- // Order mirrors aider/coders/editblock_coder.py:
305
- // 1. exact + smart-quote/dash fuzzy (in tryMatch)
306
- // 2. indent-flex (model omitted/shortened leading whitespace)
307
- // 3. drop spurious leading/trailing blank lines, retry 1+2
308
- // 4. dotdotdots (`...` elision with preserved middle)
309
- let source = "text";
310
- let result = tryMatch(working, normalizedOld, normalizedNew, replaceAll);
311
- const tryFallbacks = (oldText) => {
312
- const flexed = applyMissingLeadingWhitespace(working, oldText, normalizedNew);
313
- if (flexed !== null)
314
- return flexed;
315
- // Re-run primary matcher on the stripped variant as a cheap retry.
316
- const exact = tryMatch(working, oldText, normalizedNew, replaceAll);
317
- if (exact.ok)
318
- return exact.newWorking;
319
- return null;
320
- };
321
- if (!result.ok && result.reason === "not_found") {
322
- const indentFlexed = applyMissingLeadingWhitespace(working, normalizedOld, normalizedNew);
323
- if (indentFlexed !== null) {
324
- result = { ok: true, newWorking: indentFlexed };
325
- source = "indent_flex";
326
- }
327
- }
328
- if (!result.ok && result.reason === "not_found") {
329
- const stripped = stripBlankEdges(normalizedOld);
330
- if (stripped !== null) {
331
- const candidate = tryFallbacks(stripped);
332
- if (candidate !== null) {
333
- result = { ok: true, newWorking: candidate };
334
- source = "blank_edges";
335
- }
336
- }
337
- }
338
- if (!result.ok && result.reason === "not_found") {
339
- if (replaceAll && /^[ \t]*\.\.\.[ \t]*$/m.test(normalizedOld)) {
340
- outcomes[i] = {
341
- ok: false,
342
- failure: {
343
- reason: "invalid",
344
- detail: "replace_all does not support ... elision; use complete matching text or separate uniquely anchored edits.",
345
- },
346
- };
347
- continue;
348
- }
349
- const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
350
- if (elided !== null) {
351
- result = { ok: true, newWorking: elided };
352
- source = "dotdotdot";
353
- }
354
- }
355
- if (result.ok) {
356
- working = result.newWorking;
357
- outcomes[i] = { ok: true, source };
358
- continue;
359
- }
360
- if (result.reason === "not_found") {
361
- // Capture the closest-match snippet eagerly against the current
362
- // working buffer; we'll decide whether to render it post-loop based
363
- // on whether other edits in this batch succeeded.
364
- const closest = findClosestSnippet(working, normalizedOld);
365
- outcomes[i] = {
366
- ok: false,
367
- failure: {
368
- reason: "not_found",
369
- closestSnippet: closest?.snippet ?? null,
370
- closestLine: closest?.topLine ?? null,
371
- redactedHint: redactedOldTextHint(normalizedOld),
372
- },
373
- };
374
- }
375
- else {
376
- const occurrences = result.occurrences ?? 0;
377
- const matches = findOccurrenceLines(working, normalizedOld);
378
- const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
379
- const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
380
- outcomes[i] = {
381
- ok: false,
382
- failure: { reason: "ambiguous", occurrences, matchLines, more },
383
- };
416
+ const elided = applyDotdotdots(working, normalizedOld, normalizedNew);
417
+ if (elided !== null) {
418
+ result = { ok: true, newWorking: elided };
419
+ source = "dotdotdot";
384
420
  }
385
421
  }
386
- const failures = outcomes
387
- .map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
388
- .filter((x) => x !== null);
389
- const successCount = outcomes.length - failures.length;
390
- // Closest-match snippets only get suppressed when successes will ACTUALLY
391
- // be persisted (partial-apply with at least one win). In atomic mode we
392
- // throw before writing, so the model retries against an unchanged file
393
- // and the snippet is its only guidance — keep it.
394
- const willPersistSuccesses = successCount > 0 && !atomic;
395
- const formatFailureMessage = (f) => {
396
- if (f.reason === "stale_anchor") {
397
- return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
398
- }
399
- if (f.reason === "noop") {
400
- return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
401
- }
402
- if (f.reason === "invalid") {
403
- return `invalid edit: ${f.detail}.`;
404
- }
405
- if (f.reason === "overlap") {
406
- return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
407
- `Merge the overlapping spans into one edit and retry.`);
408
- }
409
- if (f.reason === "ambiguous") {
410
- return (`old_text found ${f.occurrences} times in ${fileName}. ` +
411
- "Include more surrounding context to make the match unique, " +
412
- "or set replace_all: true to swap every occurrence.\n" +
413
- "Matches at:\n" +
414
- f.matchLines +
415
- f.more);
416
- }
417
- const base = `old_text not found in ${fileName}. ` +
418
- "Text must match verbatim — do not paraphrase. " +
419
- "Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
420
- "the file is unchanged, so successful edits and prior reads are still valid." +
421
- f.redactedHint;
422
- // Build a bounded read suggestion around the closest-match line so the
423
- // model can re-read just that region (e.g. ±25 lines) instead of the
424
- // whole file. Skipped when willPersistSuccesses — see comment above.
425
- const readHint = f.closestLine !== null && !willPersistSuccesses
426
- ? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
422
+ if (result.ok) {
423
+ working = result.newWorking;
424
+ outcomes[i] = { ok: true, source };
425
+ continue;
426
+ }
427
+ if (result.reason === "not_found") {
428
+ // Capture the closest-match snippet eagerly against the current
429
+ // working buffer; we'll decide whether to render it post-loop based
430
+ // on whether other edits in this batch succeeded.
431
+ const closest = findClosestSnippet(working, normalizedOld);
432
+ outcomes[i] = {
433
+ ok: false,
434
+ failure: {
435
+ reason: "not_found",
436
+ closestSnippet: closest?.snippet ?? null,
437
+ closestLine: closest?.topLine ?? null,
438
+ redactedHint: redactedOldTextHint(normalizedOld),
439
+ },
440
+ };
441
+ }
442
+ else {
443
+ const occurrences = result.occurrences ?? 0;
444
+ const matches = findOccurrenceLines(working, normalizedOld);
445
+ const matchLines = matches.map((m) => ` line ${m.line}: ${m.preview}`).join("\n");
446
+ const more = occurrences > matches.length ? `\n …and ${occurrences - matches.length} more` : "";
447
+ outcomes[i] = {
448
+ ok: false,
449
+ failure: { reason: "ambiguous", occurrences, matchLines, more },
450
+ };
451
+ }
452
+ }
453
+ const failures = outcomes
454
+ .map((o, i) => (o.ok || !o.failure ? null : { index: i, failure: o.failure }))
455
+ .filter((x) => x !== null);
456
+ const successCount = outcomes.length - failures.length;
457
+ // Closest-match snippets only get suppressed when successes will ACTUALLY
458
+ // be persisted (partial-apply with at least one win). In atomic mode we
459
+ // throw before writing, so the model retries against an unchanged file
460
+ // and the snippet is its only guidance — keep it.
461
+ const willPersistSuccesses = successCount > 0 && !atomic;
462
+ const formatFailureMessage = (f) => {
463
+ if (f.reason === "stale_anchor") {
464
+ return `the file changed since you read it (anchor mismatch) — re-read \`${file_path}\` and retry`;
465
+ }
466
+ if (f.reason === "noop") {
467
+ return `old_text and new_text are identical in ${fileName} — this edit would be a no-op. Either fix new_text or drop this edit.`;
468
+ }
469
+ if (f.reason === "invalid") {
470
+ return `invalid edit: ${f.detail}.`;
471
+ }
472
+ if (f.reason === "overlap") {
473
+ return (`span overlaps another span edit in this batch — the overlapping region was addressed twice. ` +
474
+ `Merge the overlapping spans into one edit and retry.`);
475
+ }
476
+ if (f.reason === "ambiguous") {
477
+ return (`old_text found ${f.occurrences} times in ${fileName}. ` +
478
+ "Include more surrounding context to make the match unique, " +
479
+ "or set replace_all: true to swap every occurrence.\n" +
480
+ "Matches at:\n" +
481
+ f.matchLines +
482
+ f.more);
483
+ }
484
+ const base = `old_text not found in ${fileName}. ` +
485
+ "Text must match verbatim — do not paraphrase. " +
486
+ "Fix this edit's old_text to match the file exactly (re-read the region below if unsure); " +
487
+ "the file is unchanged, so successful edits and prior reads are still valid." +
488
+ f.redactedHint;
489
+ // Build a bounded read suggestion around the closest-match line so the
490
+ // model can re-read just that region (e.g. ±25 lines) instead of the
491
+ // whole file. Skipped when willPersistSuccesses — see comment above.
492
+ const readHint = f.closestLine !== null && !willPersistSuccesses
493
+ ? `\nSuggested re-read: \`read file_path="${file_path}" offset=${Math.max(1, f.closestLine - 25)} limit=50\``
494
+ : "";
495
+ if (willPersistSuccesses || !f.closestSnippet)
496
+ return base + readHint;
497
+ return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
498
+ };
499
+ const formatFailures = () => {
500
+ if (failures.length === 1 && edits.length === 1) {
501
+ return formatFailureMessage(failures[0].failure);
502
+ }
503
+ return failures
504
+ .map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
505
+ .join("\n\n");
506
+ };
507
+ // Atomic-mode failure, OR partial-mode failure where literally nothing
508
+ // succeeded. Either way nothing should be written and we throw to make
509
+ // the model retry the whole batch.
510
+ if (failures.length > 0 && (atomic || successCount === 0)) {
511
+ // Nothing was written — the file is byte-identical to the last read, so
512
+ // the read tracker stays valid. We deliberately do NOT invalidate it
513
+ // here: doing so turned a precise "old_text not found (closest match
514
+ // below)" error into a misleading "File must be read first" on every
515
+ // following edit, hiding the real fix from the model. A genuine on-disk
516
+ // change (formatter/external edit) is caught by the freshness check.
517
+ const header = atomic && failures.length > 0
518
+ ? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
519
+ : edits.length > 1
520
+ ? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
427
521
  : "";
428
- if (willPersistSuccesses || !f.closestSnippet)
429
- return base + readHint;
430
- return `${base}${readHint}\nClosest match in file:\n${f.closestSnippet}`;
431
- };
432
- const formatFailures = () => {
433
- if (failures.length === 1 && edits.length === 1) {
434
- return formatFailureMessage(failures[0].failure);
522
+ throw new Error(header + formatFailures());
523
+ }
524
+ // Never replace real secrets with the placeholder the model was shown.
525
+ const lossError = redactionLossError(originalNormalized, working, fileName);
526
+ if (lossError)
527
+ throw new Error(lossError);
528
+ const relPath = path.relative(cwd, resolved);
529
+ const diff = generateDiff(originalNormalized, working, relPath);
530
+ const changed = working !== originalNormalized;
531
+ // LSP diagnostics for the just-written content. Best-effort enhancement:
532
+ // any failure (or an opted-out provider) leaves output identical to today.
533
+ let diagnosticsNote = "";
534
+ if (changed) {
535
+ const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
536
+ // Snapshot the pre-mutation on-disk state for /rewind before writing.
537
+ await onPreFileMutation?.(resolved);
538
+ await ops.writeFile(resolved, finalContent);
539
+ await recordWrite(readFiles, resolved, finalContent, ops, "edit");
540
+ await mutationCallback?.(resolved);
541
+ // recordWrite refreshed the tracker to the just-written content, so the
542
+ // next edit (including retries of the skipped ones) validates against an
543
+ // accurate snapshot. No invalidation — the failure message already
544
+ // carries the closest match and a bounded re-read hint.
545
+ if (getDiagnostics) {
546
+ try {
547
+ diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
435
548
  }
436
- return failures
437
- .map((f) => `[edit ${f.index + 1}/${edits.length}] ${formatFailureMessage(f.failure)}`)
438
- .join("\n\n");
439
- };
440
- // Atomic-mode failure, OR partial-mode failure where literally nothing
441
- // succeeded. Either way nothing should be written and we throw to make
442
- // the model retry the whole batch.
443
- if (failures.length > 0 && (atomic || successCount === 0)) {
444
- // Nothing was written — the file is byte-identical to the last read, so
445
- // the read tracker stays valid. We deliberately do NOT invalidate it
446
- // here: doing so turned a precise "old_text not found (closest match
447
- // below)" error into a misleading "File must be read first" on every
448
- // following edit, hiding the real fix from the model. assertFresh still
449
- // catches genuine on-disk changes (formatter/external edit).
450
- const header = atomic && failures.length > 0
451
- ? `${failures.length} of ${edits.length} edit${edits.length === 1 ? "" : "s"} failed; no changes written (atomic).\n\n`
452
- : edits.length > 1
453
- ? `${failures.length} of ${edits.length} edits failed; no changes written.\n\n`
454
- : "";
455
- throw new Error(header + formatFailures());
456
- }
457
- // Never replace real secrets with the placeholder the model was shown.
458
- const lossError = redactionLossError(originalNormalized, working, fileName);
459
- if (lossError)
460
- throw new Error(lossError);
461
- const relPath = path.relative(cwd, resolved);
462
- const diff = generateDiff(originalNormalized, working, relPath);
463
- const changed = working !== originalNormalized;
464
- // LSP diagnostics for the just-written content. Best-effort enhancement:
465
- // any failure (or an opted-out provider) leaves output identical to today.
466
- let diagnosticsNote = "";
467
- if (changed) {
468
- const finalContent = hasCRLF ? working.replace(/\n/g, "\r\n") : working;
469
- // Snapshot the pre-mutation on-disk state for /rewind before writing.
470
- await onPreFileMutation?.(resolved);
471
- await ops.writeFile(resolved, finalContent);
472
- await recordWrite(readFiles, resolved, finalContent, ops, "edit");
473
- await mutationCallback?.(resolved);
474
- // recordWrite refreshed the tracker to the just-written content, so the
475
- // next edit (including retries of the skipped ones) validates against an
476
- // accurate snapshot. No invalidation — the failure message already
477
- // carries the closest match and a bounded re-read hint.
478
- if (getDiagnostics) {
479
- try {
480
- diagnosticsNote = await getDiagnostics(resolved, finalContent, riskiestSource(outcomes));
481
- }
482
- catch {
483
- // Diagnostics must never break a successful edit.
484
- }
549
+ catch {
550
+ // Diagnostics must never break a successful edit.
485
551
  }
486
552
  }
487
- if (failures.length === 0) {
488
- if (!changed) {
489
- const summary = edits.length > 1
490
- ? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
491
- : `No changes needed in ${relPath}; edit was a no-op.`;
492
- return { content: summary, details: { diff } };
493
- }
553
+ }
554
+ if (failures.length === 0) {
555
+ if (!changed) {
494
556
  const summary = edits.length > 1
495
- ? `Successfully applied ${edits.length} edits to ${relPath}.`
496
- : `Successfully replaced text in ${relPath}.`;
497
- return { content: summary + diagnosticsNote, details: { diff } };
557
+ ? `No changes needed in ${relPath}; ${edits.length} edits were no-ops.`
558
+ : `No changes needed in ${relPath}; edit was a no-op.`;
559
+ return { content: summary, details: { diff } };
498
560
  }
499
- // Partial success — the loud header is deliberate: the model has to know
500
- // that work was saved AND that only the listed edits need to be retried.
501
- const noun = failures.length === 1 ? "edit" : "edits";
502
- const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
503
- `${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
504
- formatFailures() +
505
- diagnosticsNote;
506
- return { content, details: { diff } };
507
- },
508
- };
561
+ const summary = edits.length > 1
562
+ ? `Successfully applied ${edits.length} edits to ${relPath}.`
563
+ : `Successfully replaced text in ${relPath}.`;
564
+ return { content: summary + diagnosticsNote, details: { diff } };
565
+ }
566
+ // Partial success — the loud header is deliberate: the model has to know
567
+ // that work was saved AND that only the listed edits need to be retried.
568
+ const noun = failures.length === 1 ? "edit" : "edits";
569
+ const content = `Applied ${successCount} of ${edits.length} edits to ${relPath}.\n` +
570
+ `${failures.length} ${noun} skipped — re-issue ONLY these (the rest are already done, do not redo them):\n\n` +
571
+ formatFailures() +
572
+ diagnosticsNote;
573
+ return { content, details: { diff } };
574
+ }
575
+ }
576
+ /** Exactly one form: `file_path`+`edits`, or `files`. */
577
+ function resolveEditTargets(file_path, edits, files) {
578
+ const single = file_path !== undefined || edits !== undefined;
579
+ if (files !== undefined && single) {
580
+ return { ok: false, error: "Use either file_path+edits or files, not both." };
581
+ }
582
+ if (files !== undefined)
583
+ return { ok: true, value: files };
584
+ if (file_path === undefined || edits === undefined) {
585
+ return {
586
+ ok: false,
587
+ error: "Provide file_path and edits (one file), or files (several files).",
588
+ };
589
+ }
590
+ return { ok: true, value: [{ file_path, edits }] };
509
591
  }
510
592
  //# sourceMappingURL=edit.js.map