@gajae-code/coding-agent 0.18.7 → 0.18.9

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 (344) hide show
  1. package/CHANGELOG.md +377 -1
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/job-manager.d.ts +1 -1
  4. package/dist/types/async/support.d.ts +0 -14
  5. package/dist/types/capability/mcp.d.ts +2 -0
  6. package/dist/types/capability/skill.d.ts +2 -0
  7. package/dist/types/capability/types.d.ts +2 -0
  8. package/dist/types/cli/public-command-errors.d.ts +3 -1
  9. package/dist/types/cli/setup-cli.d.ts +1 -1
  10. package/dist/types/commands/deep-interview.d.ts +3 -0
  11. package/dist/types/commands/mcp-serve.d.ts +4 -0
  12. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  13. package/dist/types/config/file-lock.d.ts +1 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  15. package/dist/types/config/model-profile-activation.d.ts +11 -3
  16. package/dist/types/config/model-registry.d.ts +4 -9
  17. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  18. package/dist/types/cursor-delete-path.d.ts +1 -0
  19. package/dist/types/cursor-mcp-name.d.ts +8 -0
  20. package/dist/types/customization/inventory.d.ts +2 -0
  21. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  24. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  25. package/dist/types/discovery/helpers.d.ts +6 -1
  26. package/dist/types/discovery/mcp-json.d.ts +1 -0
  27. package/dist/types/eval/py/kernel.d.ts +18 -0
  28. package/dist/types/export/html/css-color.d.ts +2 -0
  29. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  32. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  33. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  34. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  35. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  36. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  37. package/dist/types/extensibility/skill-management.d.ts +4 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  40. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  41. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  42. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  44. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  45. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  46. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  47. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  48. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  49. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  50. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  51. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  52. package/dist/types/lsp/config.d.ts +8 -5
  53. package/dist/types/lsp/workspace-path.d.ts +1 -0
  54. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  56. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  57. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  58. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  59. package/dist/types/modes/interactive-mode.d.ts +1 -0
  60. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  61. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  62. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  63. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  64. package/dist/types/progress/progress-contract.d.ts +154 -0
  65. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  66. package/dist/types/progress/project-progress.d.ts +141 -0
  67. package/dist/types/progress/render-progress.d.ts +28 -0
  68. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  69. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  70. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
  71. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  72. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  73. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  74. package/dist/types/runtime-mcp/types.d.ts +6 -0
  75. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  76. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  77. package/dist/types/sdk/broker/broker.d.ts +24 -2
  78. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  79. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  80. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  81. package/dist/types/sdk/bus/index.d.ts +12 -1
  82. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  83. package/dist/types/sdk/client/client.d.ts +2 -0
  84. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  85. package/dist/types/sdk/index.d.ts +1 -0
  86. package/dist/types/sdk/router/session-router.d.ts +2 -0
  87. package/dist/types/sdk/session-list.d.ts +1 -1
  88. package/dist/types/sdk/session.d.ts +8 -0
  89. package/dist/types/session/agent-session.d.ts +38 -9
  90. package/dist/types/session/artifacts.d.ts +2 -2
  91. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  92. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  93. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  94. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  95. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  96. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  97. package/dist/types/session/session-manager.d.ts +31 -5
  98. package/dist/types/session/session-retirement.d.ts +31 -7
  99. package/dist/types/session/session-storage.d.ts +69 -2
  100. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  101. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  102. package/dist/types/setup/credential-import.d.ts +6 -4
  103. package/dist/types/skill-state/active-state.d.ts +38 -0
  104. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  105. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  106. package/dist/types/task/executor.d.ts +3 -0
  107. package/dist/types/task/isolation-dir.d.ts +26 -0
  108. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  109. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  110. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  111. package/dist/types/tools/github-side-effect.d.ts +22 -0
  112. package/dist/types/tools/index.d.ts +3 -0
  113. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  114. package/dist/types/tools/python.d.ts +9 -1
  115. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  116. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  117. package/dist/types/tools/todo-write.d.ts +6 -0
  118. package/dist/types/utils/tools-checksum.d.ts +7 -0
  119. package/package.json +7 -7
  120. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  121. package/scripts/verify-managed-empty-stop.ts +28 -0
  122. package/src/async/endpoint-id.ts +26 -0
  123. package/src/async/job-manager.ts +247 -132
  124. package/src/async/support.ts +0 -25
  125. package/src/capability/index.ts +15 -0
  126. package/src/capability/mcp.ts +2 -0
  127. package/src/capability/skill.ts +2 -0
  128. package/src/capability/types.ts +2 -0
  129. package/src/cli/auth-broker-cli.ts +1 -1
  130. package/src/cli/customize-doctor.ts +15 -2
  131. package/src/cli/doctor/repairs.ts +4 -1
  132. package/src/cli/doctor-supervisor.ts +3 -0
  133. package/src/cli/public-command-errors.ts +21 -1
  134. package/src/cli/public-command-registry.ts +28 -6
  135. package/src/cli/setup-cli.ts +4 -4
  136. package/src/cli/skills-cli.ts +5 -0
  137. package/src/commands/deep-interview.ts +47 -8
  138. package/src/commands/mcp-serve.ts +7 -0
  139. package/src/commands/sdk.ts +455 -397
  140. package/src/config/autorouting-tier-map.ts +117 -7
  141. package/src/config/file-lock.ts +316 -79
  142. package/src/config/mcp-schema.json +4 -0
  143. package/src/config/model-bindings-applier.ts +20 -0
  144. package/src/config/model-profile-activation.ts +240 -46
  145. package/src/config/model-registry.ts +47 -153
  146. package/src/config/skill-settings-defaults.ts +7 -0
  147. package/src/coordinator-mcp/policy.ts +3 -2
  148. package/src/coordinator-mcp/server.ts +2 -0
  149. package/src/cursor-delete-path.ts +10 -0
  150. package/src/cursor-mcp-name.ts +15 -0
  151. package/src/cursor.ts +13 -2
  152. package/src/customization/inventory.ts +4 -13
  153. package/src/dap/config.ts +2 -2
  154. package/src/debug/report-bundle-parent.ts +22 -0
  155. package/src/debug/report-bundle.ts +3 -0
  156. package/src/debug/system-info.ts +13 -1
  157. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  158. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  159. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  160. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  161. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  162. package/src/defaults/gjc-defaults.test.ts +18 -1
  163. package/src/defaults/gjc-defaults.ts +3 -0
  164. package/src/defaults/gjc-skills.generated.ts +58 -36
  165. package/src/discovery/agents.ts +6 -1
  166. package/src/discovery/builtin.ts +13 -1
  167. package/src/discovery/helpers.ts +171 -29
  168. package/src/discovery/mcp-json.ts +26 -12
  169. package/src/discovery/ssh.ts +1 -1
  170. package/src/discovery/substitute-plugin-root.ts +3 -1
  171. package/src/edit/file-read-cache.ts +1 -1
  172. package/src/eval/py/executor.ts +796 -154
  173. package/src/eval/py/kernel.ts +79 -13
  174. package/src/eval/py/tool-bridge.ts +4 -0
  175. package/src/export/html/css-color.ts +9 -0
  176. package/src/export/html/html-export-destination.ts +16 -0
  177. package/src/export/html/index.ts +7 -10
  178. package/src/export/html/template.generated.ts +1 -1
  179. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  180. package/src/extensibility/extensions/runner.ts +15 -1
  181. package/src/extensibility/extensions/types.ts +7 -0
  182. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  183. package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
  184. package/src/extensibility/plugins/manager.ts +3 -2
  185. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  186. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  187. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  188. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  189. package/src/extensibility/skill-management.ts +9 -2
  190. package/src/extensibility/skills.ts +6 -1
  191. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  192. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  193. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  194. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  195. package/src/gjc-runtime/gc-runtime.ts +206 -9
  196. package/src/gjc-runtime/launch-worktree.ts +70 -9
  197. package/src/gjc-runtime/psmux-detect.ts +28 -4
  198. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  199. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  200. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  201. package/src/gjc-runtime/state-argv.ts +2 -0
  202. package/src/gjc-runtime/state-migrations.ts +91 -29
  203. package/src/gjc-runtime/state-runtime.ts +3716 -135
  204. package/src/gjc-runtime/state-writer.ts +105 -16
  205. package/src/gjc-runtime/tmux-common.ts +8 -5
  206. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  207. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  208. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  209. package/src/harness-control-plane/operate.ts +12 -4
  210. package/src/harness-control-plane/owner.ts +5 -1
  211. package/src/hashline/parser.ts +48 -9
  212. package/src/hashline/recovery.ts +13 -4
  213. package/src/internal-urls/docs-index.generated.ts +20 -19
  214. package/src/internal-urls/skill-path.ts +19 -0
  215. package/src/internal-urls/skill-protocol.ts +2 -0
  216. package/src/lsp/config.ts +12 -63
  217. package/src/lsp/edits.ts +6 -0
  218. package/src/lsp/workspace-path.ts +51 -0
  219. package/src/main.ts +195 -31
  220. package/src/memories/index.ts +88 -7
  221. package/src/modes/acp/acp-agent.ts +88 -4
  222. package/src/modes/components/custom-editor.ts +14 -0
  223. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  224. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  225. package/src/modes/components/extensions/state-manager.ts +15 -5
  226. package/src/modes/components/hook-editor.ts +1 -1
  227. package/src/modes/components/hook-selector.ts +1 -1
  228. package/src/modes/components/model-selector.ts +1 -4
  229. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  230. package/src/modes/components/status-line/segments.ts +4 -0
  231. package/src/modes/controllers/event-controller.ts +11 -4
  232. package/src/modes/controllers/input-controller.ts +76 -31
  233. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
  234. package/src/modes/controllers/selector-controller.ts +24 -15
  235. package/src/modes/controllers/todo-command-controller.ts +1 -1
  236. package/src/modes/interactive-mode.ts +5 -1
  237. package/src/modes/runtime-init.ts +4 -0
  238. package/src/modes/utils/abort-message.ts +3 -1
  239. package/src/modes/utils/interrupt-ownership.ts +12 -0
  240. package/src/modes/utils/keybinding-matchers.ts +4 -0
  241. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  242. package/src/progress/collect-project-progress.ts +153 -0
  243. package/src/progress/progress-contract.ts +172 -0
  244. package/src/progress/progress-snapshot.ts +140 -0
  245. package/src/progress/project-progress.ts +446 -0
  246. package/src/progress/render-progress.ts +209 -0
  247. package/src/prompts/system/master-mode.md +1 -0
  248. package/src/prompts/tools/skill-discovery.md +1 -1
  249. package/src/runtime-mcp/client.ts +4 -2
  250. package/src/runtime-mcp/config-writer.ts +10 -6
  251. package/src/runtime-mcp/config.ts +1 -0
  252. package/src/runtime-mcp/json-rpc.ts +2 -1
  253. package/src/runtime-mcp/manager.ts +29 -11
  254. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  255. package/src/runtime-mcp/oauth-flow.ts +14 -2
  256. package/src/runtime-mcp/oauth-public-url.ts +14 -0
  257. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  258. package/src/runtime-mcp/pool.ts +6 -0
  259. package/src/runtime-mcp/project-host-values.ts +46 -0
  260. package/src/runtime-mcp/smithery-registry.ts +22 -3
  261. package/src/runtime-mcp/transports/http.ts +4 -3
  262. package/src/runtime-mcp/types.ts +6 -0
  263. package/src/sdk/acp/adapter.ts +95 -8
  264. package/src/sdk/broker/broker-exit.ts +52 -23
  265. package/src/sdk/broker/broker.ts +81 -6
  266. package/src/sdk/broker/doctor-restart.ts +4 -0
  267. package/src/sdk/broker/ensure.ts +31 -2
  268. package/src/sdk/broker/lifecycle.ts +711 -66
  269. package/src/sdk/broker/session-index.ts +191 -81
  270. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  271. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  272. package/src/sdk/bus/index.ts +238 -43
  273. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  274. package/src/sdk/cli/session-cli.ts +17 -3
  275. package/src/sdk/client/client.ts +28 -8
  276. package/src/sdk/host/query/handlers.ts +9 -1
  277. package/src/sdk/host/session-runtime.test.ts +2705 -303
  278. package/src/sdk/host/session-runtime.ts +490 -143
  279. package/src/sdk/host/surface-policy.ts +1 -0
  280. package/src/sdk/index.ts +8 -0
  281. package/src/sdk/lifecycle-session.ts +3 -1
  282. package/src/sdk/prompt-deadline-manager.ts +15 -0
  283. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  284. package/src/sdk/protocol/operation-registry.ts +9 -2
  285. package/src/sdk/router/session-router.ts +82 -18
  286. package/src/sdk/session-list.ts +1 -0
  287. package/src/sdk/session.ts +69 -45
  288. package/src/session/agent-session.ts +679 -238
  289. package/src/session/artifacts.ts +176 -71
  290. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  291. package/src/session/internal/managed-session-scope.ts +3779 -462
  292. package/src/session/internal/managed-session-storage.ts +501 -78
  293. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  294. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  295. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  296. package/src/session/session-manager.ts +951 -133
  297. package/src/session/session-retirement.ts +573 -14
  298. package/src/session/session-storage.ts +447 -15
  299. package/src/session/task-artifact-owner-codec.ts +71 -8
  300. package/src/session/task-artifact-owner-retirement.ts +21 -19
  301. package/src/setup/credential-auto-import.ts +12 -3
  302. package/src/setup/credential-import.ts +176 -5
  303. package/src/setup/paseo/announce.ts +6 -1
  304. package/src/skill-state/active-state.ts +145 -2
  305. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  306. package/src/slash-commands/builtin-registry.ts +39 -9
  307. package/src/slash-commands/helpers/mcp.ts +8 -4
  308. package/src/slash-commands/helpers/progress-report.ts +23 -0
  309. package/src/stt/setup.ts +3 -5
  310. package/src/task/commands.ts +3 -1
  311. package/src/task/executor.ts +4 -0
  312. package/src/task/index.ts +6 -0
  313. package/src/task/isolation-dir.ts +143 -0
  314. package/src/task/worktree.ts +16 -7
  315. package/src/tools/ask.ts +205 -6
  316. package/src/tools/ast-edit-write-path.ts +68 -0
  317. package/src/tools/ast-edit.ts +10 -1
  318. package/src/tools/bash-allowed-prefixes.ts +15 -0
  319. package/src/tools/bash-skill-urls.ts +56 -3
  320. package/src/tools/bash.ts +48 -79
  321. package/src/tools/browser/cdp-url.ts +18 -0
  322. package/src/tools/browser.ts +8 -1
  323. package/src/tools/descriptors.ts +5 -1
  324. package/src/tools/eval.ts +56 -36
  325. package/src/tools/gh.ts +10 -0
  326. package/src/tools/github-side-effect.ts +112 -0
  327. package/src/tools/image-gen.ts +1 -1
  328. package/src/tools/index.ts +3 -0
  329. package/src/tools/monitor-mutation.ts +27 -0
  330. package/src/tools/monitor.ts +7 -0
  331. package/src/tools/python.ts +188 -66
  332. package/src/tools/recipe/index.ts +9 -0
  333. package/src/tools/recipe/recipe-guard.ts +19 -0
  334. package/src/tools/restricted-cwd.ts +10 -0
  335. package/src/tools/skill-discovery.ts +5 -0
  336. package/src/tools/skill.ts +28 -1
  337. package/src/tools/todo-write.ts +10 -1
  338. package/src/tools/tool-catalog.generated.ts +1 -1
  339. package/src/tools/write.ts +1 -0
  340. package/src/utils/external-editor.ts +3 -2
  341. package/src/utils/tools-checksum.ts +34 -0
  342. package/src/utils/tools-manager.ts +24 -6
  343. package/src/web/insane/url-guard.ts +2 -1
  344. package/src/web/search/providers/codex.ts +3 -2
@@ -0,0 +1,143 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+
5
+ const ALLOCATION_ATTEMPTS = 8;
6
+
7
+ type PathApi = Pick<typeof path, "dirname" | "isAbsolute" | "relative" | "resolve" | "sep">;
8
+
9
+ function isErrno(error: unknown, code: string): boolean {
10
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
11
+ }
12
+
13
+ function defaultToken(): string {
14
+ return randomBytes(9).toString("hex");
15
+ }
16
+
17
+ /**
18
+ * Parent directory of `mergedDir`, but only when it is a single entry inside
19
+ * `worktreeRoot`. Root, empty, nested, and escaped paths are refused so
20
+ * cleanup cannot delete the worktree root or another session's tree by
21
+ * walking up.
22
+ */
23
+ export function resolveIsolationRemovalTarget(
24
+ mergedDir: string,
25
+ worktreeRoot: string,
26
+ pathApi: PathApi = path,
27
+ ): string {
28
+ if (mergedDir.length === 0) {
29
+ throw new Error("Refusing to remove an isolation directory for an empty merged path.");
30
+ }
31
+ if (worktreeRoot.length === 0) {
32
+ throw new Error("Refusing to remove an isolation directory without a worktree root.");
33
+ }
34
+ const baseDir = pathApi.resolve(pathApi.dirname(mergedDir));
35
+ const root = pathApi.resolve(worktreeRoot);
36
+ const relative = pathApi.relative(root, baseDir);
37
+ if (
38
+ relative.length === 0 ||
39
+ relative === "." ||
40
+ relative.startsWith("..") ||
41
+ pathApi.isAbsolute(relative) ||
42
+ relative.includes(pathApi.sep) ||
43
+ relative.includes("/") ||
44
+ relative.includes("\\")
45
+ ) {
46
+ throw new Error("Refusing to remove an isolation directory that is not a single worktree entry.");
47
+ }
48
+ return baseDir;
49
+ }
50
+
51
+ /**
52
+ * Create an exclusive directory next to `canonicalBaseDir`. Never removes
53
+ * `canonicalBaseDir` or any path that already exists.
54
+ */
55
+ export async function allocateDisjointIsolationDir(
56
+ canonicalBaseDir: string,
57
+ tokenFactory: () => string = defaultToken,
58
+ ): Promise<string> {
59
+ const parent = path.dirname(canonicalBaseDir);
60
+ const stem = path.basename(canonicalBaseDir);
61
+ if (stem.length === 0 || stem === "." || stem === ".." || stem.includes("/") || stem.includes("\\")) {
62
+ throw new Error("Isolation directory name is not a single path segment.");
63
+ }
64
+ await fs.mkdir(parent, { recursive: true, mode: 0o700 });
65
+
66
+ for (let attempt = 0; attempt < ALLOCATION_ATTEMPTS; attempt++) {
67
+ const token = tokenFactory();
68
+ if (!/^[0-9a-f]+$/i.test(token)) {
69
+ throw new Error("Isolation directory token is not hexadecimal.");
70
+ }
71
+ const name = `${stem}-${token}`;
72
+ const baseDir = path.join(parent, name);
73
+ const relative = path.relative(parent, baseDir);
74
+ if (relative !== name || path.isAbsolute(relative)) {
75
+ throw new Error("Isolation directory escaped its parent.");
76
+ }
77
+ try {
78
+ await fs.mkdir(baseDir, { mode: 0o700 });
79
+ return baseDir;
80
+ } catch (error) {
81
+ if (isErrno(error, "EEXIST")) continue;
82
+ if (isErrno(error, "EPERM")) {
83
+ try {
84
+ await fs.lstat(baseDir);
85
+ continue;
86
+ } catch (statError) {
87
+ if (!isErrno(statError, "ENOENT")) throw statError;
88
+ }
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ throw new Error("Failed to allocate a disjoint task isolation directory.");
95
+ }
96
+
97
+ /**
98
+ * Confirm native teardown may see `mergedDir`.
99
+ *
100
+ * `isoStop` deletes that path. The parent entry and `merged` itself must
101
+ * already be real directories, because native removal follows symlinks.
102
+ * A missing parent means there is nothing to stop. This does not close a
103
+ * replacement that happens after the lstat returns.
104
+ */
105
+ export async function assertIsolationTeardownTarget(mergedDir: string, worktreeRoot: string): Promise<"stop" | "skip"> {
106
+ const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
107
+ let baseInfo: { isSymbolicLink(): boolean };
108
+ try {
109
+ baseInfo = await fs.lstat(baseDir);
110
+ } catch (error) {
111
+ if (isErrno(error, "ENOENT")) return "skip";
112
+ throw error;
113
+ }
114
+ if (baseInfo.isSymbolicLink()) {
115
+ throw new Error("Refusing to remove a symlinked isolation directory.");
116
+ }
117
+ try {
118
+ const mergedInfo = await fs.lstat(mergedDir);
119
+ if (mergedInfo.isSymbolicLink()) {
120
+ throw new Error("Refusing to tear down a symlinked isolation merged directory.");
121
+ }
122
+ } catch (error) {
123
+ if (isErrno(error, "ENOENT")) return "stop";
124
+ throw error;
125
+ }
126
+ return "stop";
127
+ }
128
+
129
+ /** Remove the isolation directory that owns `mergedDir`. Missing directories are ignored. */
130
+ export async function removeIsolationDirectory(mergedDir: string, worktreeRoot: string): Promise<void> {
131
+ const baseDir = resolveIsolationRemovalTarget(mergedDir, worktreeRoot);
132
+ let info: { isSymbolicLink(): boolean };
133
+ try {
134
+ info = await fs.lstat(baseDir);
135
+ } catch (error) {
136
+ if (isErrno(error, "ENOENT")) return;
137
+ throw error;
138
+ }
139
+ if (info.isSymbolicLink()) {
140
+ throw new Error("Refusing to remove a symlinked isolation directory.");
141
+ }
142
+ await fs.rm(baseDir, { recursive: true, force: true });
143
+ }
@@ -5,8 +5,9 @@ import * as path from "node:path";
5
5
 
6
6
  import type * as natives from "@gajae-code/natives";
7
7
 
8
- import { getWorktreeDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
8
+ import { getWorktreeDir, getWorktreesDir, hashPath, logger, Snowflake } from "@gajae-code/utils";
9
9
  import * as git from "../utils/git";
10
+ import { allocateDisjointIsolationDir, assertIsolationTeardownTarget, removeIsolationDirectory } from "./isolation-dir";
10
11
 
11
12
  let nativeWorktreeBindings: typeof import("@gajae-code/natives") | undefined;
12
13
 
@@ -401,8 +402,13 @@ export async function ensureIsolation(
401
402
  preferred?: IsoBackendKind,
402
403
  ): Promise<IsolationHandle> {
403
404
  const repoRoot = await getRepoRoot(baseCwd);
404
- const baseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
405
+ // Task ids are allocated inside one session, so `<id>-<repo hash>` is shared
406
+ // by every fresh session's first task. Never remove that canonical directory.
407
+ // Each attempt gets an exclusive sibling and only that sibling is removed.
408
+ const canonicalBaseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
409
+ const baseDir = await allocateDisjointIsolationDir(canonicalBaseDir);
405
410
  const mergedDir = path.join(baseDir, "merged");
411
+ const worktreeRoot = getWorktreesDir();
406
412
  const natives = nativeWorktree();
407
413
 
408
414
  const resolution = natives.isoResolve(preferred ?? null);
@@ -410,7 +416,7 @@ export async function ensureIsolation(
410
416
  let fallbackReason = resolution.reason ?? null;
411
417
 
412
418
  for (const candidate of candidates) {
413
- await fs.rm(baseDir, { recursive: true, force: true });
419
+ await removeIsolationDirectory(mergedDir, worktreeRoot);
414
420
  try {
415
421
  await natives.isoStart(candidate, repoRoot, mergedDir);
416
422
  return {
@@ -420,7 +426,7 @@ export async function ensureIsolation(
420
426
  fallbackReason,
421
427
  };
422
428
  } catch (err) {
423
- await fs.rm(baseDir, { recursive: true, force: true });
429
+ await removeIsolationDirectory(mergedDir, worktreeRoot);
424
430
  const message = errorMessage(err);
425
431
  if (!natives.isoIsUnavailableError(message)) {
426
432
  throw err;
@@ -434,6 +440,11 @@ export async function ensureIsolation(
434
440
 
435
441
  /** Tear down a handle returned by {@link ensureIsolation}. */
436
442
  export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
443
+ const worktreeRoot = getWorktreesDir();
444
+ // Native isoStop removes `mergedDir` and follows symlinks. Refuse a
445
+ // symlink that is already present before that call.
446
+ const teardown = await assertIsolationTeardownTarget(handle.mergedDir, worktreeRoot);
447
+ if (teardown === "skip") return;
437
448
  const natives = nativeWorktree();
438
449
  try {
439
450
  try {
@@ -446,9 +457,7 @@ export async function cleanupIsolation(handle: IsolationHandle): Promise<void> {
446
457
  });
447
458
  }
448
459
  } finally {
449
- // baseDir is the parent of the merged directory
450
- const baseDir = path.dirname(handle.mergedDir);
451
- await fs.rm(baseDir, { recursive: true, force: true });
460
+ await removeIsolationDirectory(handle.mergedDir, worktreeRoot);
452
461
  }
453
462
  }
454
463
 
package/src/tools/ask.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * - Questions may time out and auto-select the recommended option (configurable, disabled in plan mode)
16
16
  */
17
17
 
18
+ import { randomUUID } from "node:crypto";
18
19
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
19
20
  import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
20
21
  import { validateToolArguments } from "@gajae-code/ai/utils/validation";
@@ -36,12 +37,25 @@ import {
36
37
  } from "../deep-interview/render-middleware";
37
38
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
38
39
  import { appendOrMergeDeepInterviewRound, syncDeepInterviewRecorderHud } from "../gjc-runtime/deep-interview-recorder";
39
- import { deepInterviewStatePath } from "../gjc-runtime/deep-interview-runtime";
40
+ import {
41
+ assertDeepInterviewCrystalCoversLiveTranscript,
42
+ authoritativeConversationSnapshot,
43
+ deepInterviewStatePath,
44
+ } from "../gjc-runtime/deep-interview-runtime";
40
45
  import {
41
46
  assertDeepInterviewInputWithinLimit,
42
47
  assertDeepInterviewStructuredResponseWithinLimit,
43
48
  MAX_USER_RESPONSE_LENGTH,
44
49
  } from "../gjc-runtime/deep-interview-state";
50
+ import type { ExecutionApprovalPresentation } from "../gjc-runtime/state-runtime";
51
+ import {
52
+ captureExecutionApprovalPresentation,
53
+ executionApprovalLineage,
54
+ recordDeepInterviewExecutionApproval,
55
+ recordNonCrystalExecutionApproval,
56
+ revokeDeepInterviewExecutionApproval,
57
+ revokeNonCrystalExecutionApproval,
58
+ } from "../gjc-runtime/state-runtime";
45
59
  import {
46
60
  type AskGateQuestion,
47
61
  gateAnswerToResult,
@@ -75,7 +89,7 @@ import {
75
89
  export { askSchema } from "./ask-contract";
76
90
 
77
91
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
78
- import { ToolAbortError } from "./tool-errors";
92
+ import { ToolAbortError, ToolError } from "./tool-errors";
79
93
  import { assertUltragoalAskAllowed } from "./ultragoal-ask-guard";
80
94
 
81
95
  // =============================================================================
@@ -158,6 +172,23 @@ function nonEmptyCustomInput(value: string | undefined): string | undefined {
158
172
  return value !== undefined && value.trim().length > 0 ? value : undefined;
159
173
  }
160
174
 
175
+ function deepInterviewExecutionTarget(selectedOptions: readonly string[]): "ultragoal" | undefined {
176
+ if (selectedOptions.length !== 1) return undefined;
177
+ const normalized = selectedOptions[0]
178
+ ?.trim()
179
+ .replace(/\s*\(Recommended\)\s*$/i, "")
180
+ .toLowerCase();
181
+ return normalized === "approve execution via ultragoal" ||
182
+ /^execute with ultragoal(?: \(only when spec is already implementation-ready and really simple\))?$/.test(
183
+ normalized ?? "",
184
+ ) ||
185
+ normalized === "ultragoal" ||
186
+ normalized === "/skill:ultragoal" ||
187
+ normalized === "gjc ultragoal"
188
+ ? "ultragoal"
189
+ : undefined;
190
+ }
191
+
161
192
  function isAskTimeoutError(error: unknown): boolean {
162
193
  return typeof error === "object" && error !== null && (error as { code?: unknown }).code === GJC_ASK_TIMEOUT_CODE;
163
194
  }
@@ -320,6 +351,8 @@ interface SelectionResult {
320
351
  selectedOptions: string[];
321
352
  customInput?: string;
322
353
  clarificationQuestion?: string;
354
+ executionGateId?: string;
355
+ executionPresentation?: ExecutionApprovalPresentation;
323
356
  timedOut: boolean;
324
357
  navigation?: "back" | "forward";
325
358
  cancelled?: boolean;
@@ -340,6 +373,7 @@ interface AskSingleQuestionOptions {
340
373
  otherOptionLabel?: string;
341
374
  clarificationOptionLabel?: string;
342
375
  autoSelectOnTimeout?: boolean;
376
+ executionPresentation?: ExecutionApprovalPresentation;
343
377
  onRemoteState?: (state: {
344
378
  interaction: "selector" | "custom_editor" | "clarification_editor";
345
379
  selectedCount: number;
@@ -849,6 +883,65 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
849
883
  );
850
884
  }
851
885
 
886
+ /**
887
+ * The Deep Interview execution choice is the only ask answer that can mint
888
+ * execution authority. The runtime records only the redacted answer hash;
889
+ * the subsequent CLI action must consume that record before it can approve
890
+ * the canonical Crystal.
891
+ */
892
+ async #recordDeepInterviewExecutionApproval(
893
+ q: AskParams["questions"][number],
894
+ selectedOptions: string[],
895
+ customInput: string | undefined,
896
+ toolCallId: string,
897
+ executionGateId?: string,
898
+ executionPresentation?: ExecutionApprovalPresentation,
899
+ ): Promise<void> {
900
+ const deepInterviewExecution = q.workflowGate?.stage === "deep-interview" && q.workflowGate.kind === "execution";
901
+ const ralplanApproval = q.workflowGate?.stage === "ralplan" && q.workflowGate.kind === "approval";
902
+ if (!deepInterviewExecution && !ralplanApproval) return;
903
+ const target = deepInterviewExecutionTarget(selectedOptions);
904
+ const sessionId = this.session.getSessionId?.();
905
+ if (!target || customInput !== undefined) {
906
+ if (sessionId) {
907
+ await revokeDeepInterviewExecutionApproval(this.session.cwd, sessionId);
908
+ await revokeNonCrystalExecutionApproval(
909
+ this.session.cwd,
910
+ sessionId,
911
+ ralplanApproval ? "ralplan" : "deep-interview",
912
+ );
913
+ }
914
+ return;
915
+ }
916
+ if (!sessionId) throw new ToolAbortError("Deep Interview execution approval requires a session");
917
+ if (!executionPresentation)
918
+ throw new ToolAbortError("Execution approval publication could not be authenticated before consent");
919
+ const approvalStage = ralplanApproval ? "ralplan" : "deep-interview";
920
+ const lineage = await executionApprovalLineage(this.session.cwd, sessionId, approvalStage);
921
+ const transcriptEvidence =
922
+ lineage === "crystal"
923
+ ? await assertDeepInterviewCrystalCoversLiveTranscript(this.session.cwd, sessionId, deepInterviewExecution)
924
+ : await authoritativeConversationSnapshot(this.session.cwd, sessionId);
925
+ const recordApproval =
926
+ lineage === "crystal" ? recordDeepInterviewExecutionApproval : recordNonCrystalExecutionApproval;
927
+ await recordApproval({
928
+ cwd: this.session.cwd,
929
+ sessionId,
930
+ questionId: q.id,
931
+ gateId: executionGateId ?? q.id,
932
+ toolCallId,
933
+ target,
934
+ selectedOptions,
935
+ transcriptPath: transcriptEvidence.transcriptPath,
936
+ transcriptSha256: transcriptEvidence.transcriptSha256,
937
+ approvalStage,
938
+ presentation: executionPresentation,
939
+ });
940
+ // Ralplan approval is consumed by the subsequent handoff, after the agent
941
+ // persists this Ask tool result. Consuming here would validate the boundary
942
+ // before the matching provider toolResult exists in the transcript.
943
+ }
944
+
852
945
  async execute(
853
946
  _toolCallId: string,
854
947
  params: AskParams,
@@ -1200,6 +1293,14 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1200
1293
  // Send notification if waiting and not suppressed
1201
1294
  this.#sendAskNotification();
1202
1295
 
1296
+ if (
1297
+ params.questions.filter(
1298
+ question =>
1299
+ (question.workflowGate?.stage === "deep-interview" && question.workflowGate.kind === "execution") ||
1300
+ (question.workflowGate?.stage === "ralplan" && question.workflowGate.kind === "approval"),
1301
+ ).length > 1
1302
+ )
1303
+ throw new ToolError("Ask accepts at most one execution-authorizing workflow gate per invocation");
1203
1304
  const askQuestion = async (
1204
1305
  q: AskParams["questions"][number],
1205
1306
  options?: {
@@ -1212,6 +1313,9 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1212
1313
  const recommended = validRecommendedIndex(q.recommended, rawOptionLabels.length);
1213
1314
  const questionIndex = params.questions.indexOf(q);
1214
1315
  const emptyCustomAttempt = options?.emptyCustomAttempt ?? 0;
1316
+ const isExecutionApprovalQuestion =
1317
+ (q.workflowGate?.stage === "deep-interview" && q.workflowGate.kind === "execution") ||
1318
+ (q.workflowGate?.stage === "ralplan" && q.workflowGate.kind === "approval");
1215
1319
  // Route headless asks through the SDK workflow-gate emitter; a connected
1216
1320
  // SDK responder supplies the durable answer instead of an interactive UI.
1217
1321
  if (gateEmitter && canUseWorkflowGate) {
@@ -1226,19 +1330,79 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1226
1330
  allowEmpty: q.multi === true && params.questions.length > 1,
1227
1331
  navigationLabel: questionIndex === params.questions.length - 1 ? "Done" : "Next",
1228
1332
  };
1229
- const answer = await awaitAskAnswer(() => gateEmitter.emitGate(questionToGate(gateQuestion)));
1333
+ let executionGateId: string | undefined = isExecutionApprovalQuestion
1334
+ ? `interactive-${randomUUID()}`
1335
+ : undefined;
1336
+ let executionPresentation: ExecutionApprovalPresentation | undefined;
1337
+ if (isExecutionApprovalQuestion) {
1338
+ const sessionId = this.session.getSessionId?.();
1339
+ if (!sessionId) throw new ToolAbortError("Execution approval requires a session");
1340
+ try {
1341
+ executionPresentation = await captureExecutionApprovalPresentation(
1342
+ this.session.cwd,
1343
+ sessionId,
1344
+ q.workflowGate?.stage === "ralplan" ? "ralplan" : "deep-interview",
1345
+ );
1346
+ } catch (error) {
1347
+ throw new ToolAbortError(
1348
+ error instanceof Error
1349
+ ? `Execution approval publication could not be authenticated: ${error.message}`
1350
+ : "Execution approval publication could not be authenticated",
1351
+ );
1352
+ }
1353
+ }
1354
+ const stopGateObservation = gateEmitter.onGateEmitted?.(gate => {
1355
+ const stageState = gate.context?.stage_state;
1356
+ if (
1357
+ gate.stage === q.workflowGate?.stage &&
1358
+ gate.kind === q.workflowGate?.kind &&
1359
+ ((gate.stage === "deep-interview" && gate.kind === "execution") ||
1360
+ (gate.stage === "ralplan" && gate.kind === "approval")) &&
1361
+ stageState?.question_id === q.id
1362
+ )
1363
+ executionGateId = gate.gate_id;
1364
+ });
1365
+ let answer: unknown;
1366
+ try {
1367
+ answer = await gateEmitter.emitGate(questionToGate(gateQuestion));
1368
+ } finally {
1369
+ stopGateObservation?.();
1370
+ }
1230
1371
  const decoded = gateAnswerToResult(gateQuestion, answer);
1231
1372
  return {
1232
1373
  optionLabels: rawOptionLabels,
1233
1374
  selectedOptions: decoded.selectedOptions,
1234
1375
  customInput: decoded.customInput,
1235
1376
  clarificationQuestion: decoded.clarificationQuestion,
1377
+ executionGateId,
1378
+ executionPresentation,
1236
1379
  navigation: undefined as NavigationControls | undefined,
1237
1380
  cancelled: false,
1238
1381
  timedOut: false,
1239
1382
  };
1240
1383
  }
1241
1384
  try {
1385
+ let executionPresentation: ExecutionApprovalPresentation | undefined;
1386
+ if (isExecutionApprovalQuestion) {
1387
+ const sessionId = this.session.getSessionId?.();
1388
+ if (!sessionId) throw new ToolAbortError("Execution approval requires a session");
1389
+ try {
1390
+ executionPresentation = await captureExecutionApprovalPresentation(
1391
+ this.session.cwd,
1392
+ sessionId,
1393
+ q.workflowGate?.stage === "ralplan" ? "ralplan" : "deep-interview",
1394
+ );
1395
+ } catch (error) {
1396
+ throw new ToolAbortError(
1397
+ error instanceof Error
1398
+ ? `Execution approval publication could not be authenticated: ${error.message}`
1399
+ : "Execution approval publication could not be authenticated",
1400
+ );
1401
+ }
1402
+ }
1403
+ const executionGateId: string | undefined = isExecutionApprovalQuestion
1404
+ ? `interactive-${randomUUID()}`
1405
+ : undefined;
1242
1406
  const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
1243
1407
  const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
1244
1408
  const baseDisplayQuestion = deepInterviewPrompt ?? q.question;
@@ -1307,6 +1471,7 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1307
1471
  !intentReview(q.deepInterview) &&
1308
1472
  (q.workflowGate === undefined || q.workflowGate.kind === "question"),
1309
1473
  clarificationOptionLabel,
1474
+ executionPresentation,
1310
1475
  onRemoteState: state => {
1311
1476
  activeRemoteRequest = {
1312
1477
  question: displayQuestion,
@@ -1395,6 +1560,8 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1395
1560
  selectedOptions,
1396
1561
  customInput,
1397
1562
  clarificationQuestion,
1563
+ executionGateId,
1564
+ executionPresentation,
1398
1565
  navigation,
1399
1566
  cancelled,
1400
1567
  timedOut,
@@ -1418,8 +1585,16 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1418
1585
 
1419
1586
  if (params.questions.length === 1) {
1420
1587
  const [q] = params.questions;
1421
- const { optionLabels, selectedOptions, customInput, clarificationQuestion, cancelled, timedOut } =
1422
- await askQuestion(q);
1588
+ const {
1589
+ optionLabels,
1590
+ selectedOptions,
1591
+ customInput,
1592
+ clarificationQuestion,
1593
+ executionGateId,
1594
+ executionPresentation,
1595
+ cancelled,
1596
+ timedOut,
1597
+ } = await askQuestion(q);
1423
1598
 
1424
1599
  if (
1425
1600
  !timedOut &&
@@ -1435,6 +1610,14 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1435
1610
  ) {
1436
1611
  await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
1437
1612
  }
1613
+ await this.#recordDeepInterviewExecutionApproval(
1614
+ q,
1615
+ selectedOptions,
1616
+ customInput,
1617
+ _toolCallId,
1618
+ executionGateId,
1619
+ executionPresentation,
1620
+ );
1438
1621
  const details: AskToolDetails = {
1439
1622
  question: q.question,
1440
1623
  options: optionLabels,
@@ -1476,6 +1659,10 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1476
1659
  }
1477
1660
 
1478
1661
  const resultsByIndex: Array<QuestionResult | undefined> = Array.from({ length: params.questions.length });
1662
+ const executionGateIdsByIndex: Array<string | undefined> = Array.from({ length: params.questions.length });
1663
+ const executionPresentationsByIndex: Array<ExecutionApprovalPresentation | undefined> = Array.from({
1664
+ length: params.questions.length,
1665
+ });
1479
1666
  let questionIndex = 0;
1480
1667
  while (questionIndex < params.questions.length) {
1481
1668
  const q = params.questions[questionIndex]!;
@@ -1490,6 +1677,8 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1490
1677
  selectedOptions,
1491
1678
  customInput,
1492
1679
  clarificationQuestion,
1680
+ executionGateId,
1681
+ executionPresentation,
1493
1682
  navigation: navAction,
1494
1683
  cancelled,
1495
1684
  timedOut,
@@ -1509,6 +1698,8 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1509
1698
  customInput,
1510
1699
  clarificationQuestion,
1511
1700
  };
1701
+ executionGateIdsByIndex[questionIndex] = executionGateId;
1702
+ executionPresentationsByIndex[questionIndex] = executionPresentation;
1512
1703
 
1513
1704
  if (
1514
1705
  clarificationQuestion === undefined &&
@@ -1521,7 +1712,6 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1521
1712
  questionIndex = Math.max(0, questionIndex - 1);
1522
1713
  continue;
1523
1714
  }
1524
-
1525
1715
  questionIndex += 1;
1526
1716
  }
1527
1717
 
@@ -1538,6 +1728,15 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
1538
1728
  });
1539
1729
 
1540
1730
  const details: AskToolDetails = { results };
1731
+ for (const [index, result] of results.entries())
1732
+ await this.#recordDeepInterviewExecutionApproval(
1733
+ params.questions[index]!,
1734
+ result.selectedOptions,
1735
+ result.customInput,
1736
+ _toolCallId,
1737
+ executionGateIdsByIndex[index],
1738
+ executionPresentationsByIndex[index],
1739
+ );
1541
1740
  const responseLines = results.map(formatQuestionResult);
1542
1741
  const responseText = `User answers:\n${responseLines.join("\n")}`;
1543
1742
 
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ const MAX_ANCESTOR_WALK = 64;
5
+
6
+ function isInside(root: string, target: string): boolean {
7
+ const relative = path.relative(root, target);
8
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
9
+ }
10
+
11
+ /**
12
+ * Path the native writer will mutate: realpath of an existing file, or realpath
13
+ * of the nearest existing ancestor plus the missing tail.
14
+ */
15
+ export async function canonicalizeAstEditWritePath(absolutePath: string): Promise<string> {
16
+ const suffix: string[] = [];
17
+ let current = path.resolve(absolutePath);
18
+ for (let depth = 0; depth < MAX_ANCESTOR_WALK; depth++) {
19
+ try {
20
+ const real = await fs.realpath(current);
21
+ return suffix.length === 0 ? real : path.join(real, ...suffix.toReversed());
22
+ } catch {
23
+ const parent = path.dirname(current);
24
+ if (parent === current) break;
25
+ suffix.push(path.basename(current));
26
+ current = parent;
27
+ }
28
+ }
29
+ return path.resolve(absolutePath);
30
+ }
31
+
32
+ /**
33
+ * Preview paths as the workflow guard must see them.
34
+ *
35
+ * The guard compares lexically against the session cwd. A raw realpath misses
36
+ * `.gjc/**` when that cwd is not already canonical (`/var` vs `/private/var`,
37
+ * or a symlinked workspace), so an in-workspace real path is reattached to `cwd`.
38
+ */
39
+ export async function resolveAstEditPreviewWritePaths(
40
+ cwd: string,
41
+ previewedPaths: readonly string[],
42
+ ): Promise<string[]> {
43
+ const resolvedCwd = path.resolve(cwd);
44
+ let realCwd = resolvedCwd;
45
+ try {
46
+ realCwd = await fs.realpath(resolvedCwd);
47
+ } catch {
48
+ // Missing cwd has no canonical spelling; keep the lexical root.
49
+ }
50
+ const resolved: string[] = [];
51
+ for (const previewed of previewedPaths) {
52
+ const lexical = path.resolve(resolvedCwd, previewed);
53
+ const real = await canonicalizeAstEditWritePath(lexical);
54
+ const relative = path.relative(realCwd, real);
55
+ const canonical =
56
+ isInside(realCwd, real) && relative !== ""
57
+ ? path.resolve(resolvedCwd, relative)
58
+ : isInside(realCwd, real)
59
+ ? resolvedCwd
60
+ : real;
61
+ // The guard matches `.gjc` lexically. Keep the preview spelling so a
62
+ // symlinked `.gjc` directory is still recognized, and add the canonical
63
+ // spelling so `src` → `.gjc` is recognized too.
64
+ resolved.push(previewed);
65
+ if (path.resolve(canonical) !== lexical) resolved.push(canonical);
66
+ }
67
+ return resolved;
68
+ }
@@ -13,10 +13,13 @@ import { assertWorkflowMutationRawPathsAllowed } from "../skill-state/workflow-m
13
13
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
+ import { resolveAstEditPreviewWritePaths } from "./ast-edit-write-path";
17
+ import { assertEditableFile } from "./auto-generated-guard";
16
18
  import { createFileRecorder, formatResultPath } from "./file-recorder";
17
19
  import { formatGroupedFiles } from "./grouped-file-output";
18
20
  import type { OutputMeta } from "./output-meta";
19
21
  import { resolveToolSearchScope } from "./path-utils";
22
+ import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
20
23
  import {
21
24
  appendParseErrorsBulletList,
22
25
  capParseErrors,
@@ -337,11 +340,17 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
337
340
  label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
338
341
  sourceToolName: this.name,
339
342
  apply: async (_reason: string) => {
343
+ for (const filePath of previewedFiles) {
344
+ enforcePlanModeWrite(this.session, filePath, { op: "update" });
345
+ }
340
346
  await assertWorkflowMutationRawPathsAllowed({
341
347
  cwd: this.session.cwd,
342
348
  sessionId: this.session.getSessionId?.() ?? undefined,
343
- rawPaths: previewedFiles,
349
+ rawPaths: await resolveAstEditPreviewWritePaths(this.session.cwd, previewedFiles),
344
350
  });
351
+ for (const filePath of previewedFiles) {
352
+ await assertEditableFile(resolvePlanPath(this.session, filePath), filePath, this.session.settings);
353
+ }
345
354
  const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
346
355
  rewrites: normalizedRewrites,
347
356
  dryRun: false,