opencode-dux 1.0.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,768 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { platform } from 'node:os';
5
+
6
+ import { parsePatch } from './codec';
7
+ import { createApplyPatchHook } from './index';
8
+ import { applyPreparedChanges, preparePatchChanges } from './operations';
9
+ import { createTempDir, DEFAULT_OPTIONS, writeFixture } from './test-helpers';
10
+
11
+ function createHook() {
12
+ return createApplyPatchHook({
13
+ client: {} as never,
14
+ directory: '/tmp/hook-root',
15
+ worktree: '/tmp/hook-root',
16
+ } as never);
17
+ }
18
+
19
+ describe('apply-patch/hook', () => {
20
+ test('ignores tools other than apply_patch', async () => {
21
+ const hook = createHook();
22
+ const patchText = '*** Begin Patch\n*** End Patch';
23
+ const output = { args: { patchText } };
24
+
25
+ await hook['tool.execute.before']({ tool: 'read' }, output);
26
+
27
+ expect(output.args.patchText).toBe(patchText);
28
+ });
29
+
30
+ test('blocks an unrecoverable patch as verification before native execution', async () => {
31
+ const root = await createTempDir('apply-patch-hook-');
32
+ await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
33
+ const hook = createHook();
34
+ const patchText = `*** Begin Patch
35
+ *** Update File: sample.txt
36
+ @@
37
+ -missing
38
+ +omega
39
+ *** End Patch`;
40
+ const output = { args: { patchText } };
41
+
42
+ await expect(
43
+ hook['tool.execute.before'](
44
+ { tool: 'apply_patch', directory: root },
45
+ output,
46
+ ),
47
+ ).rejects.toThrow(
48
+ 'apply_patch verification failed: Failed to find expected lines',
49
+ );
50
+
51
+ expect(output.args.patchText).toBe(patchText);
52
+ });
53
+
54
+ test('normalizes an exact patch wrapped in a heredoc before native execution', async () => {
55
+ const root = await createTempDir('apply-patch-hook-');
56
+ await writeFixture(
57
+ root,
58
+ 'sample.txt',
59
+ 'line-01\nexact-top\nexact-old\nexact-bottom\nline-05\n',
60
+ );
61
+ const hook = createHook();
62
+ const cleanPatchText = `*** Begin Patch
63
+ *** Update File: sample.txt
64
+ @@ exact-top
65
+ -exact-old
66
+ +exact-new
67
+ exact-bottom
68
+ *** End Patch`;
69
+ const output = {
70
+ args: {
71
+ patchText: `cat <<'PATCH'
72
+ ${cleanPatchText}
73
+ PATCH`,
74
+ },
75
+ };
76
+
77
+ await hook['tool.execute.before'](
78
+ { tool: 'apply_patch', directory: root },
79
+ output,
80
+ );
81
+
82
+ expect(output.args.patchText).toBe(cleanPatchText);
83
+
84
+ const changes = await preparePatchChanges(
85
+ root,
86
+ output.args.patchText as string,
87
+ DEFAULT_OPTIONS,
88
+ );
89
+ await applyPreparedChanges(changes);
90
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
91
+ 'line-01\nexact-top\nexact-new\nexact-bottom\nline-05\n',
92
+ );
93
+ });
94
+
95
+ test('normalizes absolute paths inside root before native execution', async () => {
96
+ const root = await createTempDir('apply-patch-hook-');
97
+ const absolutePath = path.join(root, 'sample.txt');
98
+ await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
99
+ const hook = createHook();
100
+ const patchText = `*** Begin Patch
101
+ *** Update File: ${absolutePath}
102
+ @@
103
+ -alpha
104
+ +omega
105
+ *** End Patch`;
106
+ const output = { args: { patchText } };
107
+
108
+ await hook['tool.execute.before'](
109
+ { tool: 'apply_patch', directory: root },
110
+ output,
111
+ );
112
+
113
+ expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
114
+ type: 'update',
115
+ path: 'sample.txt',
116
+ });
117
+ });
118
+
119
+ test('passes through an absolute target outside root/worktree before native execution', async () => {
120
+ const root = await createTempDir('apply-patch-hook-');
121
+ const outsideDir = await createTempDir('apply-patch-hook-outside-');
122
+ const outsidePath = path.join(outsideDir, 'outside.txt');
123
+ await writeFile(outsidePath, 'outside\n', 'utf-8');
124
+ const hook = createApplyPatchHook({
125
+ client: {} as never,
126
+ directory: root,
127
+ worktree: root,
128
+ } as never);
129
+ const patchText = `*** Begin Patch
130
+ *** Update File: ${outsidePath}
131
+ @@
132
+ -outside
133
+ +changed
134
+ *** End Patch`;
135
+ const output = { args: { patchText } };
136
+
137
+ await expect(
138
+ hook['tool.execute.before'](
139
+ { tool: 'apply_patch', directory: root },
140
+ output,
141
+ ),
142
+ ).resolves.toBeUndefined();
143
+
144
+ expect(output.args.patchText).toBe(patchText);
145
+ expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
146
+ });
147
+
148
+ test('passes through mixed stale inside and absolute outside patch without partial rewrite', async () => {
149
+ const root = await createTempDir('apply-patch-hook-');
150
+ const outsideDir = await createTempDir('apply-patch-hook-outside-');
151
+ const outsidePath = path.join(outsideDir, 'outside.txt');
152
+ await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
153
+ await writeFile(outsidePath, 'outside\n', 'utf-8');
154
+ const hook = createApplyPatchHook({
155
+ client: {} as never,
156
+ directory: root,
157
+ worktree: root,
158
+ } as never);
159
+ const patchText = `*** Begin Patch
160
+ *** Update File: sample.txt
161
+ @@
162
+ prefix
163
+ -old-value
164
+ +new-value
165
+ suffix
166
+ *** Update File: ${outsidePath}
167
+ @@
168
+ -outside
169
+ +changed
170
+ *** End Patch`;
171
+ const output = { args: { patchText } };
172
+
173
+ await expect(
174
+ hook['tool.execute.before'](
175
+ { tool: 'apply_patch', directory: root },
176
+ output,
177
+ ),
178
+ ).resolves.toBeUndefined();
179
+
180
+ expect(output.args.patchText).toBe(patchText);
181
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
182
+ 'prefix\nstale-value\nsuffix\n',
183
+ );
184
+ expect(await readFile(outsidePath, 'utf-8')).toBe('outside\n');
185
+ });
186
+
187
+ test('rewrites a stale prefix patch and remains applicable', async () => {
188
+ const root = await createTempDir('apply-patch-hook-');
189
+ await writeFixture(
190
+ root,
191
+ 'sample.txt',
192
+ 'top\nA\nB-stale\nC\nD\nE\nbottom\n',
193
+ );
194
+ const hook = createHook();
195
+ const patchText = `*** Begin Patch
196
+ *** Update File: sample.txt
197
+ @@ top
198
+ A
199
+ -B
200
+ -C
201
+ -D
202
+ -E
203
+ +B
204
+ +C
205
+ +D
206
+ +X
207
+ *** End Patch`;
208
+ const output = { args: { patchText } };
209
+
210
+ await hook['tool.execute.before'](
211
+ { tool: 'apply_patch', directory: root },
212
+ output,
213
+ );
214
+
215
+ const rewritten = parsePatch(output.args.patchText as string).hunks[0];
216
+ expect(rewritten.type).toBe('update');
217
+ expect(
218
+ rewritten.type === 'update' && rewritten.chunks[0]?.old_lines,
219
+ ).toEqual(['A', 'B-stale', 'C', 'D', 'E']);
220
+
221
+ const changes = await preparePatchChanges(
222
+ root,
223
+ output.args.patchText as string,
224
+ DEFAULT_OPTIONS,
225
+ );
226
+ await applyPreparedChanges(changes);
227
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
228
+ 'top\nA\nB\nC\nD\nX\nbottom\n',
229
+ );
230
+ });
231
+
232
+ test('does not alter new_lines during rewrite', async () => {
233
+ const root = await createTempDir('apply-patch-hook-');
234
+ await writeFixture(
235
+ root,
236
+ 'sample.txt',
237
+ 'top\nprefix\nstale-value\nsuffix\nbottom\n',
238
+ );
239
+ const hook = createHook();
240
+ const patchText = `*** Begin Patch
241
+ *** Update File: sample.txt
242
+ @@ top
243
+ prefix
244
+ -old-value
245
+ + \tverbatim "" Ω
246
+ suffix
247
+ *** End Patch`;
248
+ const expected = parsePatch(patchText).hunks[0];
249
+ const output = { args: { patchText } };
250
+
251
+ await hook['tool.execute.before'](
252
+ { tool: 'apply_patch', directory: root },
253
+ output,
254
+ );
255
+
256
+ const rewritten = parsePatch(output.args.patchText as string).hunks[0];
257
+ expect(expected.type).toBe('update');
258
+ expect(rewritten.type).toBe('update');
259
+ expect(
260
+ expected.type === 'update' && rewritten.type === 'update'
261
+ ? rewritten.chunks[0]?.new_lines
262
+ : undefined,
263
+ ).toEqual(expected.type === 'update' ? expected.chunks[0]?.new_lines : []);
264
+ });
265
+
266
+ test('rewrites a unicode-only stale patch and remains applicable', async () => {
267
+ const root = await createTempDir('apply-patch-hook-');
268
+ await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
269
+ const hook = createHook();
270
+ const patchText = `*** Begin Patch
271
+ *** Update File: sample.txt
272
+ @@
273
+ -const title = "Hola";
274
+ +const title = "Hola mundo";
275
+ *** End Patch`;
276
+ const output = { args: { patchText } };
277
+
278
+ await hook['tool.execute.before'](
279
+ { tool: 'apply_patch', directory: root },
280
+ output,
281
+ );
282
+
283
+ const rewritten = parsePatch(output.args.patchText as string).hunks[0];
284
+ expect(rewritten.type).toBe('update');
285
+ expect(
286
+ rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
287
+ ).toEqual(['const title = “Hola”;']);
288
+
289
+ const changes = await preparePatchChanges(
290
+ root,
291
+ output.args.patchText as string,
292
+ DEFAULT_OPTIONS,
293
+ );
294
+ await applyPreparedChanges(changes);
295
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
296
+ 'const title = "Hola mundo";\n',
297
+ );
298
+ });
299
+
300
+ test('rewrites a trim-end stale patch and remains applicable', async () => {
301
+ const root = await createTempDir('apply-patch-hook-');
302
+ await writeFixture(root, 'sample.txt', 'alpha \n');
303
+ const hook = createHook();
304
+ const patchText = `*** Begin Patch
305
+ *** Update File: sample.txt
306
+ @@
307
+ -alpha
308
+ +omega
309
+ *** End Patch`;
310
+ const output = { args: { patchText } };
311
+
312
+ await hook['tool.execute.before'](
313
+ { tool: 'apply_patch', directory: root },
314
+ output,
315
+ );
316
+
317
+ const rewritten = parsePatch(output.args.patchText as string).hunks[0];
318
+ expect(rewritten.type).toBe('update');
319
+ expect(
320
+ rewritten.type === 'update' ? rewritten.chunks[0]?.old_lines : undefined,
321
+ ).toEqual(['alpha ']);
322
+
323
+ const changes = await preparePatchChanges(
324
+ root,
325
+ output.args.patchText as string,
326
+ DEFAULT_OPTIONS,
327
+ );
328
+ await applyPreparedChanges(changes);
329
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
330
+ 'omega\n',
331
+ );
332
+ });
333
+
334
+ test('blocks a trim-only stale patch as verification', async () => {
335
+ const root = await createTempDir('apply-patch-hook-');
336
+ await writeFixture(root, 'sample.txt', ' alpha \n');
337
+ const hook = createHook();
338
+ const patchText = `*** Begin Patch
339
+ *** Update File: sample.txt
340
+ @@
341
+ -alpha
342
+ +omega
343
+ *** End Patch`;
344
+ const output = { args: { patchText } };
345
+
346
+ await expect(
347
+ hook['tool.execute.before'](
348
+ { tool: 'apply_patch', directory: root },
349
+ output,
350
+ ),
351
+ ).rejects.toThrow(
352
+ 'apply_patch verification failed: Failed to find expected lines',
353
+ );
354
+
355
+ expect(output.args.patchText).toBe(patchText);
356
+ });
357
+
358
+ test('blocks a malformed @@ at runtime before native execution', async () => {
359
+ const root = await createTempDir('apply-patch-hook-');
360
+ await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
361
+ const hook = createHook();
362
+ const patchText = `*** Begin Patch
363
+ *** Update File: sample.txt
364
+ @@
365
+ alpha
366
+ garbage
367
+ -beta
368
+ +BETA
369
+ *** End Patch`;
370
+ const output = { args: { patchText } };
371
+
372
+ await expect(
373
+ hook['tool.execute.before'](
374
+ { tool: 'apply_patch', directory: root },
375
+ output,
376
+ ),
377
+ ).rejects.toThrow(
378
+ 'apply_patch validation failed: Invalid patch format: unexpected line in patch chunk: garbage',
379
+ );
380
+
381
+ expect(output.args.patchText).toBe(patchText);
382
+ });
383
+
384
+ test('blocks a malformed Add File at runtime before native execution', async () => {
385
+ const root = await createTempDir('apply-patch-hook-');
386
+ const hook = createHook();
387
+ const patchText = `*** Begin Patch
388
+ *** Add File: added.txt
389
+ +fresh
390
+ garbage
391
+ *** End Patch`;
392
+ const output = { args: { patchText } };
393
+
394
+ await expect(
395
+ hook['tool.execute.before'](
396
+ { tool: 'apply_patch', directory: root },
397
+ output,
398
+ ),
399
+ ).rejects.toThrow(
400
+ 'apply_patch validation failed: Invalid patch format: unexpected line in Add File body: garbage',
401
+ );
402
+
403
+ expect(output.args.patchText).toBe(patchText);
404
+ });
405
+
406
+ const testGuardError = platform() === 'win32' ? test.skip : test;
407
+ testGuardError('blocks internal guard errors before native execution', async () => {
408
+ const root = await createTempDir('apply-patch-hook-');
409
+ const lockedDir = path.join(root, 'locked');
410
+ await mkdir(lockedDir, { recursive: true });
411
+ await chmod(lockedDir, 0o000);
412
+ const hook = createHook();
413
+ const patchText = `*** Begin Patch
414
+ *** Add File: locked/child.txt
415
+ +fresh
416
+ *** End Patch`;
417
+ const output = { args: { patchText } };
418
+
419
+ try {
420
+ await expect(
421
+ hook['tool.execute.before'](
422
+ { tool: 'apply_patch', directory: root },
423
+ output,
424
+ ),
425
+ ).rejects.toThrow('apply_patch internal error:');
426
+
427
+ expect(output.args.patchText).toBe(patchText);
428
+ } finally {
429
+ await chmod(lockedDir, 0o755);
430
+ }
431
+ });
432
+
433
+ test('blocks a dangerous indented case as verification', async () => {
434
+ const root = await createTempDir('apply-patch-hook-');
435
+ await writeFixture(
436
+ root,
437
+ 'sample.yml',
438
+ 'root:\n child:\n enabled: false\nnext: true\n',
439
+ );
440
+ const hook = createHook();
441
+ const patchText = `*** Begin Patch
442
+ *** Update File: sample.yml
443
+ @@
444
+ -enabled: false
445
+ +enabled: true
446
+ *** End Patch`;
447
+ const output = { args: { patchText } };
448
+
449
+ await expect(
450
+ hook['tool.execute.before'](
451
+ { tool: 'apply_patch', directory: root },
452
+ output,
453
+ ),
454
+ ).rejects.toThrow(
455
+ 'apply_patch verification failed: Failed to find expected lines',
456
+ );
457
+
458
+ expect(output.args.patchText).toBe(patchText);
459
+ });
460
+
461
+ test('rewrites anchored insertion to avoid native EOF handling', async () => {
462
+ const root = await createTempDir('apply-patch-hook-');
463
+ await writeFixture(
464
+ root,
465
+ 'sample.txt',
466
+ 'top\nanchor-insert\nafter-anchor\nend\n',
467
+ );
468
+ const hook = createHook();
469
+ const patchText = `*** Begin Patch
470
+ *** Update File: sample.txt
471
+ @@ anchor-insert
472
+ +middle-inserted
473
+ *** End Patch`;
474
+ const output = { args: { patchText } };
475
+
476
+ await hook['tool.execute.before'](
477
+ { tool: 'apply_patch', directory: root },
478
+ output,
479
+ );
480
+
481
+ const changes = await preparePatchChanges(
482
+ root,
483
+ output.args.patchText as string,
484
+ DEFAULT_OPTIONS,
485
+ );
486
+ await applyPreparedChanges(changes);
487
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
488
+ 'top\nanchor-insert\nmiddle-inserted\nafter-anchor\nend\n',
489
+ );
490
+ });
491
+
492
+ test('blocks a pure insertion when the anchor is missing', async () => {
493
+ const root = await createTempDir('apply-patch-hook-');
494
+ await writeFixture(root, 'sample.txt', 'top\nafter-anchor\nend\n');
495
+ const hook = createHook();
496
+ const patchText = `*** Begin Patch
497
+ *** Update File: sample.txt
498
+ @@ anchor-insert
499
+ +middle-inserted
500
+ *** End Patch`;
501
+ const output = { args: { patchText } };
502
+
503
+ await expect(
504
+ hook['tool.execute.before'](
505
+ { tool: 'apply_patch', directory: root },
506
+ output,
507
+ ),
508
+ ).rejects.toThrow(
509
+ 'apply_patch verification failed: Failed to find insertion anchor',
510
+ );
511
+
512
+ expect(output.args.patchText).toBe(patchText);
513
+ });
514
+
515
+ test('blocks a pure insertion when the anchor is ambiguous', async () => {
516
+ const root = await createTempDir('apply-patch-hook-');
517
+ await writeFixture(
518
+ root,
519
+ 'sample.txt',
520
+ 'top\nanchor-insert\nafter-first\nsplit\nanchor-insert\nafter-second\nend\n',
521
+ );
522
+ const hook = createHook();
523
+ const patchText = `*** Begin Patch
524
+ *** Update File: sample.txt
525
+ @@ anchor-insert
526
+ +middle-inserted
527
+ *** End Patch`;
528
+ const output = { args: { patchText } };
529
+
530
+ await expect(
531
+ hook['tool.execute.before'](
532
+ { tool: 'apply_patch', directory: root },
533
+ output,
534
+ ),
535
+ ).rejects.toThrow(
536
+ 'apply_patch verification failed: Insertion anchor was ambiguous',
537
+ );
538
+
539
+ expect(output.args.patchText).toBe(patchText);
540
+ });
541
+
542
+ test('blocks real patch ambiguity before native execution', async () => {
543
+ const root = await createTempDir('apply-patch-hook-');
544
+ await writeFixture(
545
+ root,
546
+ 'sample.txt',
547
+ 'left\nstale-one\nright\nseparator\nleft\nstale-two\nright\n',
548
+ );
549
+ const hook = createHook();
550
+ const patchText = `*** Begin Patch
551
+ *** Update File: sample.txt
552
+ @@
553
+ left
554
+ -old
555
+ +new
556
+ right
557
+ *** End Patch`;
558
+ const output = { args: { patchText } };
559
+
560
+ await expect(
561
+ hook['tool.execute.before'](
562
+ { tool: 'apply_patch', directory: root },
563
+ output,
564
+ ),
565
+ ).rejects.toThrow('apply_patch verification failed:');
566
+
567
+ expect(output.args.patchText).toBe(patchText);
568
+ });
569
+
570
+ test('rewrites only the update hunk in a patch with add + update', async () => {
571
+ const root = await createTempDir('apply-patch-hook-');
572
+ await writeFixture(
573
+ root,
574
+ 'sample.txt',
575
+ 'top\nprefix\nstale-value\nsuffix\n',
576
+ );
577
+ const hook = createHook();
578
+ const patchText = `*** Begin Patch
579
+ *** Add File: added.txt
580
+ +fresh
581
+ *** Update File: sample.txt
582
+ @@ top
583
+ prefix
584
+ -old-value
585
+ +new-value
586
+ suffix
587
+ *** End Patch`;
588
+ const output = { args: { patchText } };
589
+
590
+ await hook['tool.execute.before'](
591
+ { tool: 'apply_patch', directory: root },
592
+ output,
593
+ );
594
+
595
+ const rewritten = parsePatch(output.args.patchText as string);
596
+ expect(rewritten.hunks[0]).toEqual({
597
+ type: 'add',
598
+ path: 'added.txt',
599
+ contents: 'fresh',
600
+ });
601
+ expect(rewritten.hunks[1]).toEqual({
602
+ type: 'update',
603
+ path: 'sample.txt',
604
+ chunks: [
605
+ {
606
+ old_lines: ['prefix', 'stale-value', 'suffix'],
607
+ new_lines: ['prefix', 'new-value', 'suffix'],
608
+ change_context: 'top',
609
+ is_end_of_file: undefined,
610
+ },
611
+ ],
612
+ });
613
+
614
+ const changes = await preparePatchChanges(
615
+ root,
616
+ output.args.patchText as string,
617
+ DEFAULT_OPTIONS,
618
+ );
619
+ await applyPreparedChanges(changes);
620
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
621
+ 'top\nprefix\nnew-value\nsuffix\n',
622
+ );
623
+ expect(await readFile(path.join(root, 'added.txt'), 'utf-8')).toBe(
624
+ 'fresh\n',
625
+ );
626
+ });
627
+
628
+ test('passes through sibling-directory targets outside root/worktree before native execution', async () => {
629
+ const root = await createTempDir('apply-patch-hook-');
630
+ const outside = path.join(path.dirname(root), 'outside.txt');
631
+ await writeFile(outside, 'outside\n', 'utf-8');
632
+ const hook = createHook();
633
+ const patchText = `*** Begin Patch
634
+ *** Update File: ../outside.txt
635
+ @@
636
+ -outside
637
+ +changed
638
+ *** End Patch`;
639
+ const output = { args: { patchText } };
640
+
641
+ await expect(
642
+ hook['tool.execute.before'](
643
+ { tool: 'apply_patch', directory: root },
644
+ output,
645
+ ),
646
+ ).resolves.toBeUndefined();
647
+
648
+ expect(output.args.patchText).toBe(patchText);
649
+ expect(await readFile(outside, 'utf-8')).toBe('outside\n');
650
+ });
651
+
652
+ test('normalizes an absolute path inside worktree even when it is outside root', async () => {
653
+ const worktree = await createTempDir('apply-patch-worktree-');
654
+ const root = path.join(worktree, 'subdir');
655
+ await mkdir(root, { recursive: true });
656
+ const siblingPath = path.join(worktree, 'shared.txt');
657
+ const hook = createApplyPatchHook({
658
+ client: {} as never,
659
+ directory: root,
660
+ worktree,
661
+ } as never);
662
+ const patchText = `*** Begin Patch
663
+ *** Add File: ${siblingPath}
664
+ +fresh
665
+ *** End Patch`;
666
+ const output = { args: { patchText } };
667
+
668
+ await expect(
669
+ hook['tool.execute.before'](
670
+ { tool: 'apply_patch', directory: root },
671
+ output,
672
+ ),
673
+ ).resolves.toBeUndefined();
674
+
675
+ expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
676
+ type: 'add',
677
+ path: '../shared.txt',
678
+ contents: 'fresh',
679
+ });
680
+ });
681
+
682
+ test('passes through mixed patches with outside paths without partial rewrite', async () => {
683
+ const root = await createTempDir('apply-patch-hook-');
684
+ const outsideDir = await createTempDir('apply-patch-hook-outside-');
685
+ await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
686
+ await writeFixture(outsideDir, 'outside.txt', 'legacy\n');
687
+ const hook = createHook();
688
+ const outsideAdded = path.join(path.dirname(root), 'outside-added.txt');
689
+ const patchText = `*** Begin Patch
690
+ *** Add File: ../outside-added.txt
691
+ +fresh
692
+ *** Update File: sample.txt
693
+ @@
694
+ prefix
695
+ -old-value
696
+ +new-value
697
+ suffix
698
+ *** Delete File: ../${path.basename(outsideDir)}/outside.txt
699
+ *** End Patch`;
700
+ const output = { args: { patchText } };
701
+
702
+ await expect(
703
+ hook['tool.execute.before'](
704
+ { tool: 'apply_patch', directory: root },
705
+ output,
706
+ ),
707
+ ).resolves.toBeUndefined();
708
+
709
+ expect(output.args.patchText).toBe(patchText);
710
+ expect(await readFile(path.join(root, 'sample.txt'), 'utf-8')).toBe(
711
+ 'prefix\nstale-value\nsuffix\n',
712
+ );
713
+ expect(await stat(outsideAdded).catch(() => null)).toBeNull();
714
+ expect(await readFile(path.join(outsideDir, 'outside.txt'), 'utf-8')).toBe(
715
+ 'legacy\n',
716
+ );
717
+ });
718
+
719
+ test('keeps normal behavior for patches entirely inside root/worktree', async () => {
720
+ const root = await createTempDir('apply-patch-hook-');
721
+ await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
722
+ const hook = createHook();
723
+ const patchText = `*** Begin Patch
724
+ *** Update File: sample.txt
725
+ @@
726
+ -alpha
727
+ +omega
728
+ beta
729
+ *** End Patch`;
730
+ const output = { args: { patchText } };
731
+
732
+ await expect(
733
+ hook['tool.execute.before'](
734
+ { tool: 'apply_patch', directory: root },
735
+ output,
736
+ ),
737
+ ).resolves.toBeUndefined();
738
+
739
+ expect(output.args.patchText).toBe(patchText);
740
+ });
741
+
742
+ test('does not expose the tool.execute.after hook', () => {
743
+ const hook = createHook() as Record<string, unknown>;
744
+
745
+ expect(hook['tool.execute.after']).toBeUndefined();
746
+ });
747
+
748
+ test('does not alter an exact patch', async () => {
749
+ const root = await createTempDir('apply-patch-hook-');
750
+ await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
751
+ const hook = createHook();
752
+ const patchText = `*** Begin Patch
753
+ *** Update File: sample.txt
754
+ @@
755
+ -alpha
756
+ +omega
757
+ beta
758
+ *** End Patch`;
759
+ const output = { args: { patchText } };
760
+
761
+ await hook['tool.execute.before'](
762
+ { tool: 'apply_patch', directory: root },
763
+ output,
764
+ );
765
+
766
+ expect(output.args.patchText).toBe(patchText);
767
+ });
768
+ });