@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
@@ -14,6 +14,7 @@ import {
14
14
  } from "./deep-interview-state";
15
15
  import { sessionStateDir } from "./session-layout";
16
16
  import { resolveGjcSessionForWrite, SessionResolutionError, writeSessionActivityMarker } from "./session-resolution";
17
+ import { migrateWorkflowState } from "./state-migrations";
17
18
  import { runNativeStateCommand } from "./state-runtime";
18
19
  import {
19
20
  persistedStateRevision,
@@ -184,7 +185,12 @@ const RUNTIME_OWNED_ENVELOPE_KEYS = [
184
185
  * `invalid intent contract`, bricking the interview until a destructive
185
186
  * `clear --force`. The recorder is the only writer that can lock intent.
186
187
  */
187
- const RECORDER_OWNED_STATE_KEYS = ["intent_contract", "intent_review"] as const;
188
+ const RUNTIME_OWNED_STATE_KEYS = [
189
+ "intent_contract",
190
+ "intent_review",
191
+ "execution_approval",
192
+ "execution_approval_receipt",
193
+ ] as const;
188
194
 
189
195
  /**
190
196
  * Remove sanitizer-owned keys from a staged payload and classify the
@@ -213,7 +219,7 @@ function sanitizeStagedPayload(payload: Record<string, unknown>): {
213
219
  }
214
220
  if (isPlainObject(next.state)) {
215
221
  const state = { ...(next.state as Record<string, unknown>) };
216
- for (const key of RECORDER_OWNED_STATE_KEYS) {
222
+ for (const key of RUNTIME_OWNED_STATE_KEYS) {
217
223
  if (key in state) {
218
224
  delete state[key];
219
225
  ignoredKeys.push(`state.${key}`);
@@ -231,11 +237,11 @@ function sanitizeStagedPayload(payload: Record<string, unknown>): {
231
237
  }
232
238
 
233
239
  /**
234
- * Self-heal a poisoned merge base: a persisted `state.intent_contract` that
235
- * fails canonical validation can only come from a pre-guard poisoned write
236
- * (the recorder always persists valid manifests). Left in place it makes
237
- * every merge throw, bricking the interview. Drop it (and any equally
238
- * unverifiable intent_review) from the base and report the repair.
240
+ * Validate a persisted `state.intent_contract`: if it fails canonical validation
241
+ * and `intent_contract_required === true`, the contract is locked and must be valid.
242
+ * Fail closed with an integrity error to preserve the invalid contract for recovery/audit.
243
+ * When intent_contract_required is absent or false, healing may occur for backward
244
+ * compatibility with pre-guard poisoned writes, but only after explicit authorization.
239
245
  */
240
246
  function healPoisonedIntentContract(base: Record<string, unknown>): {
241
247
  base: Record<string, unknown>;
@@ -247,7 +253,16 @@ function healPoisonedIntentContract(base: Record<string, unknown>): {
247
253
  try {
248
254
  assertDeepInterviewIntentManifest(state.intent_contract);
249
255
  return { base, healed: false };
250
- } catch {
256
+ } catch (error) {
257
+ // If intent_contract_required is set (locked contract), fail closed
258
+ if (state.intent_contract_required === true) {
259
+ throw new DeepInterviewStageError(
260
+ "DI_STAGE_STATE_CORRUPT",
261
+ `locked intent contract is malformed or tampered: ${error instanceof Error ? error.message : String(error)}`,
262
+ "preserve the state for recovery; contact support with the session state for audit",
263
+ );
264
+ }
265
+ // For unlocked contracts, continue with healing for backward compatibility
251
266
  const healedState = { ...state };
252
267
  delete healedState.intent_contract;
253
268
  delete healedState.intent_review;
@@ -371,7 +386,7 @@ async function readCurrentState(cwd: string, sessionId: string): Promise<Current
371
386
  if (read.kind === "absent")
372
387
  return { value: {}, revision: 0, sha256: workflowEnvelopeContentSha256({}), exists: false };
373
388
  return {
374
- value: read.value,
389
+ value: migrateWorkflowState(read.value, "deep-interview").state,
375
390
  revision: persistedStateRevision(read.value),
376
391
  sha256: workflowEnvelopeContentSha256(read.value),
377
392
  exists: true,
@@ -430,6 +445,11 @@ function computeMergedEnvelope(
430
445
  draft: DeepInterviewStageDraft,
431
446
  nowIso: string,
432
447
  ): Record<string, unknown> {
448
+ if (current.active === false)
449
+ throw new DeepInterviewStageError(
450
+ "DI_STAGE_MERGE_REJECTED",
451
+ "cannot stage after deep-interview handoff or completion",
452
+ );
433
453
  // A poisoned (unverifiable) intent contract in the persisted base would make
434
454
  // every merge throw forever; heal it instead of bricking the interview.
435
455
  const { base: healedCurrent, healed } = healPoisonedIntentContract(current);
@@ -454,6 +474,39 @@ function computeMergedEnvelope(
454
474
  // one-fact patch cannot erase confirmed/disputed history (#3387 finding 2).
455
475
  const mergedState = isPlainObject(merged.state) ? (merged.state as Record<string, unknown>) : undefined;
456
476
  const priorState = isPlainObject(current.state) ? (current.state as Record<string, unknown>) : undefined;
477
+ if (priorState?.crystal === undefined && mergedState?.crystal !== undefined)
478
+ throw new DeepInterviewStageError(
479
+ "DI_STAGE_MERGE_REJECTED",
480
+ "staged apply cannot introduce canonical Crystal state",
481
+ );
482
+ if (priorState?.crystal !== undefined) {
483
+ if (!mergedState || JSON.stringify(mergedState.crystal) !== JSON.stringify(priorState.crystal))
484
+ throw new DeepInterviewStageError(
485
+ "DI_STAGE_MERGE_REJECTED",
486
+ "canonical crystallized state cannot be replaced or deleted through staged apply",
487
+ );
488
+ if (mergedState.execution_approval !== priorState.execution_approval)
489
+ throw new DeepInterviewStageError(
490
+ "DI_STAGE_MERGE_REJECTED",
491
+ "crystallized execution approval is immutable through staged apply",
492
+ );
493
+ if (
494
+ JSON.stringify(mergedState.execution_approval_receipt) !==
495
+ JSON.stringify(priorState.execution_approval_receipt)
496
+ )
497
+ throw new DeepInterviewStageError(
498
+ "DI_STAGE_MERGE_REJECTED",
499
+ "crystallized execution approval provenance is immutable through staged apply",
500
+ );
501
+ for (const field of ["spec_path", "spec_sha256", "spec_slug", "spec_stage"] as const)
502
+ if (merged[field] !== current[field])
503
+ throw new DeepInterviewStageError(
504
+ "DI_STAGE_MERGE_REJECTED",
505
+ `crystallized ${field} is immutable through staged apply`,
506
+ );
507
+ }
508
+ if (mergedState?.crystal && isPlainObject(mergedState.crystal) && mergedState.crystal.lifecycle !== "ready")
509
+ merged.current_phase = "interviewing";
457
510
  if (mergedState && priorState && Array.isArray(priorState.established_facts)) {
458
511
  mergedState.established_facts = mergeEstablishedFacts(
459
512
  priorState.established_facts,
@@ -461,6 +514,24 @@ function computeMergedEnvelope(
461
514
  );
462
515
  }
463
516
  merged = deriveRuntimeAmbiguity(merged, current);
517
+ if (isPlainObject(priorState?.crystal) && priorState.crystal.lifecycle === "ready") {
518
+ for (const field of [
519
+ "rounds",
520
+ "established_facts",
521
+ "intent_review",
522
+ "current_ambiguity",
523
+ "topology",
524
+ "auto_answered_rounds",
525
+ ] as const)
526
+ if (
527
+ JSON.stringify((merged.state as Record<string, unknown> | undefined)?.[field]) !==
528
+ JSON.stringify(priorState[field])
529
+ )
530
+ throw new DeepInterviewStageError(
531
+ "DI_STAGE_MERGE_REJECTED",
532
+ "ready Crystal evidence is immutable through staged apply",
533
+ );
534
+ }
464
535
  try {
465
536
  assertDeepInterviewEnvelopeInputLimits(merged);
466
537
  } catch (error) {
@@ -851,6 +922,14 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<Record
851
922
  );
852
923
  }
853
924
  const current = await readCurrentState(cwd, sessionId);
925
+ const currentInner = isPlainObject(current.value.state) ? current.value.state : {};
926
+ if (reset && currentInner.crystal !== undefined) {
927
+ throw new DeepInterviewStageError(
928
+ "DI_STAGE_MERGE_REJECTED",
929
+ "reset cannot rewrite canonical Crystal state",
930
+ "clear the workflow explicitly or continue through the approval and handoff lifecycle",
931
+ );
932
+ }
854
933
  const nowIso = new Date().toISOString();
855
934
  const syntheticDraft: DeepInterviewStageDraft = {
856
935
  version: DRAFT_VERSION,
@@ -862,17 +941,15 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<Record
862
941
  payload,
863
942
  created_at: nowIso,
864
943
  };
865
- // --reset replaces: merge against an empty base but re-lock the intent
866
- // contract from prior state through the merge's own immutability guard.
867
- const base = reset
868
- ? (() => {
869
- const priorState = isPlainObject(current.value.state)
870
- ? (current.value.state as Record<string, unknown>)
871
- : {};
872
- return priorState.intent_contract !== undefined
873
- ? { state: { intent_contract: priorState.intent_contract, intent_contract_required: true } }
874
- : {};
875
- })()
944
+ // Canonical Crystal resets are rejected above. Ordinary resets retain only
945
+ // locked intent, never legacy handoff/spec metadata or execution authority.
946
+ const base: Record<string, unknown> = reset
947
+ ? {
948
+ state: {
949
+ intent_contract: currentInner.intent_contract,
950
+ intent_contract_required: currentInner.intent_contract_required,
951
+ },
952
+ }
876
953
  : current.value;
877
954
  const merged = computeMergedEnvelope(base as Record<string, unknown>, syntheticDraft, nowIso);
878
955
  merged.last_applied_draft_id = syntheticDraft.draft_id;
@@ -930,7 +1007,7 @@ async function syncStageHud(cwd: string, sessionId: string, envelope: Record<str
930
1007
  await syncSkillActiveState({
931
1008
  cwd,
932
1009
  skill: "deep-interview",
933
- active: phase !== "complete",
1010
+ active: envelope.active !== false && phase !== "complete",
934
1011
  phase,
935
1012
  sessionId,
936
1013
  source: "gjc-deep-interview-native",
@@ -30,8 +30,22 @@ import {
30
30
  listCanonicalBlobs,
31
31
  removeCanonicalBlob,
32
32
  } from "../session/blob-store";
33
- import { probeSessionRetirement, retireSessionTranscript } from "../session/session-retirement";
34
- import { FileSessionStorage } from "../session/session-storage";
33
+ import type { ManagedGcSessionRetirementReceipt } from "../session/internal/managed-gc-retirement-codec";
34
+ import {
35
+ discoverManagedGcSessionRetirementReceipts,
36
+ type ManagedScope,
37
+ resolveManagedGcScopeForRead,
38
+ } from "../session/internal/managed-session-scope";
39
+ import { taskArtifactOwnerLocatorFromTranscriptBytes } from "../session/internal/task-artifact-owner-transcript";
40
+ import {
41
+ probeSessionRetirement,
42
+ retireOrphanSessionTranscript,
43
+ retireSessionTranscript,
44
+ type SessionRetirementContinuation,
45
+ type SessionRetirementOutcome,
46
+ } from "../session/session-retirement";
47
+ import { FileSessionStorage, type SessionStorageSnapshot } from "../session/session-storage";
48
+ import { parseFirstJsonlLine } from "../session/session-transcript-header";
35
49
  import {
36
50
  collectEmptyDeleteReceipts,
37
51
  type EmptyDeleteGcRecord,
@@ -757,6 +771,11 @@ export interface GcDiskRecord {
757
771
  action: GcDiskAction;
758
772
  reason: string;
759
773
  error?: string;
774
+ /** Exact incomplete retirement phase surfaced by the owning session adapter. */
775
+ phase?: "artifacts" | "transcript";
776
+ task_artifact_owner_retired?: true;
777
+ task_artifact_owner_payload_retired?: true;
778
+ task_artifact_owner_namespace_retained?: true;
760
779
  /** Set when `bytes` is a floor because a walk was capped or partially unreadable. */
761
780
  partial?: true;
762
781
  /** Set when this entry was a reclaim candidate that its surface withheld on incomplete evidence. */
@@ -1202,6 +1221,59 @@ async function discoverGcDiskTranscripts(sessionsRoot: string, errors: GcDiskErr
1202
1221
  * Classify (and optionally retire) session transcripts. Returns the transcripts
1203
1222
  * that survived, which is exactly the mark set for the blob sweep.
1204
1223
  */
1224
+ function sessionRetirementContinuation(
1225
+ storage: FileSessionStorage,
1226
+ sessionsRoot: string,
1227
+ transcriptPath: string,
1228
+ agentDir: string,
1229
+ ): SessionRetirementContinuation | undefined {
1230
+ let snapshot: SessionStorageSnapshot;
1231
+ try {
1232
+ snapshot = storage.readSnapshotSync(transcriptPath);
1233
+ } catch {
1234
+ return undefined;
1235
+ }
1236
+ const header = parseFirstJsonlLine(snapshot.bytes);
1237
+ if (header?.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string") return undefined;
1238
+ try {
1239
+ if (!taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, header.id)) return undefined;
1240
+ } catch {
1241
+ return undefined;
1242
+ }
1243
+ const resolved = resolveManagedGcScopeForRead({ cwd: header.cwd, agentDir, sessionsRoot });
1244
+ if (
1245
+ resolved.kind !== "resolved" ||
1246
+ path.resolve(resolved.scope.sessionsRoot) !== path.resolve(sessionsRoot) ||
1247
+ path.resolve(resolved.scope.directoryPath) !== path.resolve(path.dirname(transcriptPath))
1248
+ )
1249
+ return undefined;
1250
+ return { managedScope: resolved.scope };
1251
+ }
1252
+
1253
+ function orphanRetirementRecord(
1254
+ receipt: ManagedGcSessionRetirementReceipt,
1255
+ now: number,
1256
+ reason: string,
1257
+ withheld = false,
1258
+ ): GcDiskRecord {
1259
+ const mtimeMs = Number(receipt.transcriptIdentity.mtimeNs / 1_000_000n);
1260
+ const outcome = receipt.taskArtifactOwnerRetirementOutcome;
1261
+ return {
1262
+ surface: "sessions",
1263
+ id: receipt.sessionId,
1264
+ path: receipt.transcriptPath,
1265
+ bytes: 0,
1266
+ age_days: gcDiskAgeDays(now, mtimeMs),
1267
+ action: "keep",
1268
+ reason,
1269
+ ...(withheld ? { withheld: true as const } : {}),
1270
+ ...(receipt.state === "owner_retired" ? { task_artifact_owner_retired: true as const } : {}),
1271
+ ...(outcome?.kind === "payload_retired"
1272
+ ? { task_artifact_owner_payload_retired: true as const, task_artifact_owner_namespace_retained: true as const }
1273
+ : {}),
1274
+ };
1275
+ }
1276
+
1205
1277
  async function runGcDiskSessions(input: {
1206
1278
  surface: GcDiskSurfaceReport;
1207
1279
  transcripts: GcDiskTranscript[];
@@ -1209,8 +1281,35 @@ async function runGcDiskSessions(input: {
1209
1281
  policy: GcDiskPolicy;
1210
1282
  now: number;
1211
1283
  prune: boolean;
1284
+ agentDir: string;
1285
+ errors: GcDiskError[];
1212
1286
  }): Promise<GcDiskTranscript[]> {
1213
- const { surface, transcripts, references, policy, now, prune } = input;
1287
+ const { surface, transcripts, references, policy, now, prune, agentDir, errors } = input;
1288
+ const storage = new FileSessionStorage();
1289
+ let orphanReceipts: readonly {
1290
+ readonly scope: ManagedScope;
1291
+ readonly receipt: ManagedGcSessionRetirementReceipt;
1292
+ }[] = [];
1293
+ let receiptDiscoveryError: string | undefined;
1294
+ try {
1295
+ const entries = await fsp.readdir(surface.root, { withFileTypes: true });
1296
+ if (entries.some(entry => entry.name.startsWith("v2-"))) {
1297
+ orphanReceipts = (
1298
+ await discoverManagedGcSessionRetirementReceipts({
1299
+ agentDir,
1300
+ sessionsRoot: surface.root,
1301
+ })
1302
+ ).filter(
1303
+ item =>
1304
+ !storage.existsSync(item.receipt.transcriptPath) &&
1305
+ !transcripts.some(
1306
+ transcript => path.resolve(transcript.path) === path.resolve(item.receipt.transcriptPath),
1307
+ ),
1308
+ );
1309
+ }
1310
+ } catch (error) {
1311
+ if (!isEnoent(error)) receiptDiscoveryError = gcDiskErrorText(error);
1312
+ }
1214
1313
  const maxAgeMs = policy.sessions_max_age_days * GC_DISK_DAY_MS;
1215
1314
 
1216
1315
  // The newest transcript in each project directory is the `--continue` resume
@@ -1279,18 +1378,54 @@ async function runGcDiskSessions(input: {
1279
1378
  }
1280
1379
  }
1281
1380
 
1282
- const storage = new FileSessionStorage();
1283
1381
  if (!prune) {
1284
1382
  // Dry run must project the prune verdict, not just the policy verdict: the
1285
1383
  // retention pass re-checks its own preconditions before it may call the
1286
1384
  // delete authority, and a candidate that fails them is never removable.
1287
1385
  for (const item of classified) {
1288
1386
  if (item.record.action !== "would_reclaim") continue;
1289
- const probe = probeSessionRetirement(storage, surface.root, item.transcript.path);
1290
- if (probe.kind === "retirable") continue;
1291
- item.record.action = "keep";
1292
- item.record.reason = `retention_declined: ${probe.reason}`;
1387
+ try {
1388
+ const continuation = sessionRetirementContinuation(storage, surface.root, item.transcript.path, agentDir);
1389
+ const probe = await probeSessionRetirement(storage, surface.root, item.transcript.path, continuation);
1390
+ if (probe.kind === "retirable") {
1391
+ if (probe.taskArtifactOwnerRetired) item.record.task_artifact_owner_retired = true;
1392
+ if (probe.taskArtifactOwnerPayloadRetired) item.record.task_artifact_owner_payload_retired = true;
1393
+ if (probe.taskArtifactOwnerNamespaceRetained) item.record.task_artifact_owner_namespace_retained = true;
1394
+ continue;
1395
+ }
1396
+ if (probe.kind === "cleanup_pending") {
1397
+ item.record.action = "reclaim_failed";
1398
+ item.record.reason = `retention_incomplete: ${probe.reason}`;
1399
+ item.record.error = probe.reason;
1400
+ if (probe.phase) item.record.phase = probe.phase;
1401
+ if (probe.taskArtifactOwnerRetired) item.record.task_artifact_owner_retired = true;
1402
+ if (probe.taskArtifactOwnerPayloadRetired) item.record.task_artifact_owner_payload_retired = true;
1403
+ if (probe.taskArtifactOwnerNamespaceRetained) item.record.task_artifact_owner_namespace_retained = true;
1404
+ continue;
1405
+ }
1406
+ item.record.action = "keep";
1407
+ item.record.reason = `retention_declined: ${probe.reason}`;
1408
+ if (probe.reason.includes("task_artifact_owner_")) item.record.error = probe.reason;
1409
+ } catch (error) {
1410
+ const message = gcDiskErrorText(error);
1411
+ item.record.action = "keep";
1412
+ item.record.reason = `retention_declined: ${message}`;
1413
+ item.record.error = message;
1414
+ item.record.withheld = true;
1415
+ }
1416
+ }
1417
+ for (const { receipt } of orphanReceipts) {
1418
+ let reason = "transcript_missing_without_verified_retirement";
1419
+ if (!references.complete) reason = `reference_scan_incomplete: ${references.notes.join(", ")}`;
1420
+ else if (references.ids.has(receipt.sessionId)) reason = "referenced_by_live_surface";
1421
+ surface.records.push(orphanRetirementRecord(receipt, now, reason, true));
1293
1422
  }
1423
+ if (receiptDiscoveryError)
1424
+ errors.push({
1425
+ surface: "sessions",
1426
+ scope: surface.root,
1427
+ message: `managed_gc_retirement_discovery_incomplete: ${receiptDiscoveryError}`,
1428
+ });
1294
1429
  return classified.filter(item => item.record.action !== "would_reclaim").map(item => item.transcript);
1295
1430
  }
1296
1431
 
@@ -1304,9 +1439,21 @@ async function runGcDiskSessions(input: {
1304
1439
  survivors.push(item.transcript);
1305
1440
  continue;
1306
1441
  }
1307
- const outcome = await retireSessionTranscript(storage, surface.root, item.transcript.path);
1442
+ let outcome: SessionRetirementOutcome;
1443
+ try {
1444
+ const continuation = sessionRetirementContinuation(storage, surface.root, item.transcript.path, agentDir);
1445
+ outcome = await retireSessionTranscript(storage, surface.root, item.transcript.path, continuation);
1446
+ } catch (error) {
1447
+ const message = gcDiskErrorText(error);
1448
+ item.record.action = "reclaim_failed";
1449
+ item.record.reason = `retention_failed: ${message}`;
1450
+ item.record.error = message;
1451
+ survivors.push(item.transcript);
1452
+ continue;
1453
+ }
1308
1454
  if (outcome.kind === "retired") {
1309
1455
  item.record.action = "reclaimed";
1456
+ if (outcome.taskArtifactOwnerRetired) item.record.task_artifact_owner_retired = true;
1310
1457
  continue;
1311
1458
  }
1312
1459
  if (outcome.kind === "cleanup_pending") {
@@ -1316,13 +1463,61 @@ async function runGcDiskSessions(input: {
1316
1463
  item.record.action = "reclaim_failed";
1317
1464
  item.record.reason = `retention_incomplete: ${outcome.reason}`;
1318
1465
  item.record.error = outcome.reason;
1466
+ if (outcome.phase) item.record.phase = outcome.phase;
1467
+ if (outcome.taskArtifactOwnerRetired) item.record.task_artifact_owner_retired = true;
1468
+ if (outcome.taskArtifactOwnerPayloadRetired) item.record.task_artifact_owner_payload_retired = true;
1469
+ if (outcome.taskArtifactOwnerNamespaceRetained) item.record.task_artifact_owner_namespace_retained = true;
1319
1470
  survivors.push(item.transcript);
1320
1471
  continue;
1321
1472
  }
1322
1473
  item.record.action = "keep";
1323
1474
  item.record.reason = `retention_declined: ${outcome.reason}`;
1475
+ if (outcome.reason.includes("task_artifact_owner_")) item.record.error = outcome.reason;
1324
1476
  survivors.push(item.transcript);
1325
1477
  }
1478
+ for (const { scope, receipt } of orphanReceipts) {
1479
+ const record = orphanRetirementRecord(
1480
+ receipt,
1481
+ now,
1482
+ !references.complete
1483
+ ? `reference_scan_incomplete: ${references.notes.join(", ")}`
1484
+ : references.ids.has(receipt.sessionId)
1485
+ ? "referenced_by_live_surface"
1486
+ : "transcript_missing_without_verified_retirement",
1487
+ true,
1488
+ );
1489
+ if (!references.complete || references.ids.has(receipt.sessionId)) {
1490
+ surface.records.push(record);
1491
+ continue;
1492
+ }
1493
+ try {
1494
+ const outcome = await retireOrphanSessionTranscript(storage, surface.root, { managedScope: scope }, receipt);
1495
+ if (outcome.kind === "cleanup_pending") {
1496
+ record.action = "reclaim_failed";
1497
+ record.reason = `retention_incomplete: ${outcome.reason}`;
1498
+ record.error = outcome.reason;
1499
+ if (outcome.phase) record.phase = outcome.phase;
1500
+ if (outcome.taskArtifactOwnerRetired) record.task_artifact_owner_retired = true;
1501
+ if (outcome.taskArtifactOwnerPayloadRetired) record.task_artifact_owner_payload_retired = true;
1502
+ if (outcome.taskArtifactOwnerNamespaceRetained) record.task_artifact_owner_namespace_retained = true;
1503
+ } else {
1504
+ record.reason = outcome.kind === "kept" ? outcome.reason : "transcript_missing_without_verified_retirement";
1505
+ if (outcome.kind === "kept") record.error = outcome.reason;
1506
+ }
1507
+ } catch (error) {
1508
+ const message = gcDiskErrorText(error);
1509
+ record.action = "reclaim_failed";
1510
+ record.reason = `retention_failed: ${message}`;
1511
+ record.error = message;
1512
+ }
1513
+ surface.records.push(record);
1514
+ }
1515
+ if (receiptDiscoveryError)
1516
+ errors.push({
1517
+ surface: "sessions",
1518
+ scope: surface.root,
1519
+ message: `managed_gc_retirement_discovery_incomplete: ${receiptDiscoveryError}`,
1520
+ });
1326
1521
  return survivors;
1327
1522
  }
1328
1523
 
@@ -2204,6 +2399,8 @@ export async function collectGcDiskReport(input: {
2204
2399
  policy,
2205
2400
  now,
2206
2401
  prune,
2402
+ agentDir,
2403
+ errors,
2207
2404
  });
2208
2405
  await runGcDiskBlobs({
2209
2406
  surface: surfaces.blobs,
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
5
6
  import { shortenPath } from "../tools/render-utils";
6
7
 
7
8
  export type GjcLaunchWorktreeMode =
@@ -175,6 +176,8 @@ function sanitizePathToken(value: string): string {
175
176
  * beside the first.
176
177
  */
177
178
  const WORKTREE_BUCKET_ENV = "GJC_WORKTREE_DIR";
179
+ /** Captured when this module loads, before a later `chdir` or dotenv delete. */
180
+ const startupWorktreeBucketSnapshot = projectEnvSnapshot();
178
181
  /**
179
182
  * Expands to the repository directory name.
180
183
  *
@@ -205,19 +208,40 @@ export function resolveWorktreeBucketForPath(
205
208
  const template = expandHomePrefix(configured || DEFAULT_WORKTREE_BUCKET, home, pathApi);
206
209
  return pathApi.resolve(
207
210
  pathApi.dirname(repoRoot),
208
- template.replaceAll(REPO_NAME_PLACEHOLDER, pathApi.basename(repoRoot)),
211
+ // Function replacer so a `$` in the repository directory name is kept verbatim.
212
+ template.replaceAll(REPO_NAME_PLACEHOLDER, () => pathApi.basename(repoRoot)),
209
213
  );
210
214
  }
211
215
 
216
+ /**
217
+ * Operator override only. Bun has already copied the project dotenv into the
218
+ * environment, so a value that matches `projectEnvSnapshot` — a static hit or
219
+ * a dynamic declaration — must not select the bucket. An operator value the
220
+ * project does not declare is kept.
221
+ */
222
+ function trustedWorktreeBucketEnv(env: NodeJS.ProcessEnv): string | undefined {
223
+ const raw = env[WORKTREE_BUCKET_ENV];
224
+ if (!raw) return undefined;
225
+ const snapshot = startupWorktreeBucketSnapshot;
226
+ const key = canonicalEnvKey(WORKTREE_BUCKET_ENV);
227
+ const declared = snapshot.values[key];
228
+ const trimmed = raw.trim();
229
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
230
+ return undefined;
231
+ }
232
+ return trimmed.length > 0 ? trimmed : undefined;
233
+ }
234
+
212
235
  /**
213
236
  * Directory that holds this repository's launch worktrees.
214
237
  *
215
238
  * A relative override resolves against the repository's parent directory. The default
216
239
  * `{repo}/.worktrees` template places managed worktrees inside the repository, while
217
240
  * `{repo}.worktrees` adopts an existing sibling bucket. An absolute override is used verbatim.
241
+ * A project dotenv value is ignored; an operator value the project does not declare is kept.
218
242
  */
219
243
  function resolveWorktreeBucket(repoRoot: string): string {
220
- return resolveWorktreeBucketForPath(repoRoot, process.env[WORKTREE_BUCKET_ENV], os.homedir(), path);
244
+ return resolveWorktreeBucketForPath(repoRoot, trustedWorktreeBucketEnv(process.env), os.homedir(), path);
221
245
  }
222
246
 
223
247
  function ensureRepositoryBucketIgnored(repoRoot: string, bucketPath: string): void {
@@ -453,12 +477,51 @@ function isWorktreeDirty(worktreePath: string): boolean {
453
477
  return runGit(worktreePath, ["status", "--porcelain"]).length > 0;
454
478
  }
455
479
 
480
+ /** Porcelain status is clean when the only unique state is commits on this detached HEAD. */
481
+ function isCommitAncestor(cwd: string, ancestor: string, descendant: string): boolean {
482
+ const result = Bun.spawnSync(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
483
+ cwd,
484
+ stdout: "ignore",
485
+ stderr: "pipe",
486
+ });
487
+ if (result.exitCode === 0) return true;
488
+ if (result.exitCode === 1) return false;
489
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
490
+ throw new Error(stderr || "git merge-base --is-ancestor failed");
491
+ }
492
+
493
+ function formatWorktreeDiverged(worktreePath: string, existingHead: string, baseRef: string): string {
494
+ return [
495
+ `worktree_diverged:${worktreePath}`,
496
+ `GJC refused to move detached launch worktree ${existingHead} onto ${baseRef} because that commit is not an ancestor of the source HEAD.`,
497
+ `Path: ${worktreePath}`,
498
+ "Checking out the source HEAD would drop that commit from this worktree. Safe remediation: create a branch at HEAD so the commit stays reachable (git branch <name>), then retarget or remove the worktree yourself if a later launch should reuse this path. GJC did not change HEAD.",
499
+ ].join("\n");
500
+ }
501
+
502
+ function reuseDetachedLaunchHead(plan: GjcLaunchWorktreePlan, existingHead: string, dirty: boolean): void {
503
+ if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
504
+ if (!isCommitAncestor(plan.worktreePath, existingHead, plan.baseRef)) {
505
+ throw new Error(formatWorktreeDiverged(plan.worktreePath, existingHead, plan.baseRef));
506
+ }
507
+ runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
508
+ }
509
+
456
510
  function resolveOptionalWorktreeName(args: string[], index: number): { name: string | null; nextIndex: number } {
457
511
  const next = args[index + 1];
458
512
  if (!next) return { name: null, nextIndex: index };
459
- if (next === "--") return { name: null, nextIndex: index };
460
- if (next.startsWith("-")) return { name: null, nextIndex: index };
461
- return { name: next.trim() || null, nextIndex: index + 1 };
513
+ if (next === "--" || next.startsWith("-")) return { name: null, nextIndex: index };
514
+ // A bare --worktree is the detached selector. Only consume the following
515
+ // positional token as a branch name when it is a valid ref; otherwise it is
516
+ // the launch prompt and must remain in the forwarded arguments.
517
+ const trimmed = next.trim();
518
+ if (!trimmed || /\s/.test(trimmed)) return { name: null, nextIndex: index };
519
+ const valid =
520
+ Bun.spawnSync(["git", "check-ref-format", "--branch", trimmed], {
521
+ stdout: "ignore",
522
+ stderr: "ignore",
523
+ }).exitCode === 0;
524
+ return valid ? { name: trimmed, nextIndex: index + 1 } : { name: null, nextIndex: index };
462
525
  }
463
526
 
464
527
  export function parseLaunchWorktreeMode(args: string[]): ParsedLaunchWorktreeMode {
@@ -771,8 +834,7 @@ function ensureLaunchWorktreeSync(
771
834
  throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
772
835
  }
773
836
  if (existingAtPath.head !== plan.baseRef) {
774
- if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
775
- runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
837
+ reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
776
838
  dirty = false;
777
839
  }
778
840
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
@@ -867,8 +929,7 @@ export async function ensureLaunchWorktreeCancellable(
867
929
  throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
868
930
  }
869
931
  if (existingAtPath.head !== plan.baseRef) {
870
- if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
871
- runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
932
+ reuseDetachedLaunchHead(plan, existingAtPath.head, dirty);
872
933
  dirty = false;
873
934
  }
874
935
  } else if (existingAtPath.branchRef !== expectedBranchRef) {
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import { canonicalEnvKey, projectEnvSnapshot } from "@gajae-code/utils/env-file";
3
4
 
4
5
  /**
5
6
  * Windows psmux detection and tmux-binary resolution.
@@ -305,17 +306,40 @@ export interface ResolvedTmuxBinary {
305
306
  viaExplicitOverride: boolean;
306
307
  }
307
308
 
309
+ const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
310
+ /** Captured when this module loads, before a later `chdir` or dotenv delete. */
311
+ const startupTmuxCommandSnapshot = projectEnvSnapshot();
312
+
313
+ /**
314
+ * Operator override only. Bun has already copied the project dotenv into the
315
+ * environment, so a value that matches `projectEnvSnapshot` — a static hit or
316
+ * a dynamic declaration — must not select the binary. An operator value the
317
+ * project does not declare is kept.
318
+ */
319
+ function trustedTmuxCommand(env: NodeJS.ProcessEnv): string | undefined {
320
+ const raw = env[GJC_TMUX_COMMAND_ENV];
321
+ if (!raw) return undefined;
322
+ const snapshot = startupTmuxCommandSnapshot;
323
+ const key = canonicalEnvKey(GJC_TMUX_COMMAND_ENV);
324
+ const declared = snapshot.values[key];
325
+ const trimmed = raw.trim();
326
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw || declared === trimmed)) {
327
+ return undefined;
328
+ }
329
+ return trimmed.length > 0 ? trimmed : undefined;
330
+ }
331
+
308
332
  /**
309
- * Resolve the tmux command GJC should invoke. Honors the existing
310
- * GJC_TMUX_COMMAND override; on Windows when no
311
- * override is set, psmux (installed as psmux, pmux, or tmux) is picked
333
+ * Resolve the tmux command GJC should invoke. Honors an operator
334
+ * GJC_TMUX_COMMAND override; a project dotenv value is ignored. On Windows
335
+ * when no override is set, psmux (installed as psmux, pmux, or tmux) is picked
312
336
  * automatically so the default gjc --tmux flow lands on a real multiplexer.
313
337
  */
314
338
  export function resolveGjcTmuxBinary(options: ResolveGjcTmuxBinaryOptions = {}): ResolvedTmuxBinary {
315
339
  const env = options.env ?? process.env;
316
340
  const platform = options.platform ?? process.platform;
317
341
  const runner = options.runner ?? readSpawnRunner();
318
- const explicit = env.GJC_TMUX_COMMAND?.trim();
342
+ const explicit = trustedTmuxCommand(env);
319
343
  if (explicit) {
320
344
  const isPsmux =
321
345
  platform === "win32"