@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
@@ -1,14 +1,32 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import type { Stats } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { isSettingsInitialized, Settings } from "../config/settings";
6
+ import { listManagedSessionCandidates, resolveManagedSessionScope } from "../sdk/session-directory";
7
+ import {
8
+ type FileEntry,
9
+ isProjectSessionTranscriptPath,
10
+ listProjectSessionTranscriptFiles,
11
+ parseSessionEntries,
12
+ RESUME_TRANSCRIPT_MAX_BYTES,
13
+ readAuthorizedProjectSessionTranscript,
14
+ } from "../session/session-manager";
5
15
  import { syncSkillActiveState } from "../skill-state/active-state";
6
16
  import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
7
17
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
18
+ import {
19
+ type CrystalSnapshot,
20
+ crystallizeDeepInterview,
21
+ crystalMarkdown,
22
+ crystalSnapshotDigest,
23
+ type DeepInterviewCrystal,
24
+ } from "./deep-interview-crystallize";
8
25
  import { isDeepInterviewStageVerb, runDeepInterviewStageCommand } from "./deep-interview-stage";
9
26
  import {
10
27
  assertDeepInterviewInputWithinLimit,
11
28
  assertDeepInterviewIntentReview,
29
+ assertDeepInterviewStructuredResponseWithinLimit,
12
30
  type DeepInterviewIntentCategory,
13
31
  type DeepInterviewIntentItem,
14
32
  type DeepInterviewIntentManifest,
@@ -19,13 +37,39 @@ import {
19
37
  reviewDeepInterviewIntent,
20
38
  } from "./deep-interview-state";
21
39
  import { runNativeRalplanCommand } from "./ralplan-runtime";
22
- import { modeStatePath, sessionSpecsDir } from "./session-layout";
40
+ import { modeStatePath, sessionSpecsDir, transactionJournalPath } from "./session-layout";
23
41
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
24
42
  import { runNativeStateCommand } from "./state-runtime";
25
- import { appendJsonl, readExistingStateForMutation, writeArtifact, writeWorkflowEnvelopeAtomic } from "./state-writer";
43
+ import {
44
+ appendJsonl,
45
+ beginWorkflowTransactionJournal,
46
+ completeWorkflowTransactionJournal,
47
+ detectWorkflowEnvelopeIntegrityMismatch,
48
+ readExistingStateForMutation,
49
+ updateWorkflowTransactionJournal,
50
+ type WorkflowTransactionJournal,
51
+ withWorkflowStateLock,
52
+ writeArtifact,
53
+ writeWorkflowEnvelopeAtomic,
54
+ } from "./state-writer";
26
55
  import { assertSafePathComponent, CommandError, flagValue, hasFlag } from "./workflow-cli-common";
27
56
  import { resolveWorkflowSetting } from "./workflow-settings";
28
57
 
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ function assertNoFutureDeepInterviewEnvelope(value: Record<string, unknown>, surface: string): void {
63
+ if (
64
+ value.version !== undefined &&
65
+ (!Number.isSafeInteger(value.version) || (value.version as number) > WORKFLOW_STATE_VERSION)
66
+ )
67
+ throw new DeepInterviewCommandError(
68
+ 2,
69
+ `${surface} has unsupported future deep-interview state version ${String(value.version)}; refusing downgrade`,
70
+ );
71
+ }
72
+
29
73
  export * from "./deep-interview-recorder";
30
74
 
31
75
  /**
@@ -97,6 +141,17 @@ const TRACE_SOURCE_EXTENSIONS = new Set([
97
141
  ".yaml",
98
142
  ]);
99
143
 
144
+ const DEEP_INTERVIEW_NON_TEXT_CONTENT_TYPES = new Set([
145
+ "image",
146
+ "audio",
147
+ "video",
148
+ "file",
149
+ "content",
150
+ "toolCall",
151
+ "thinking",
152
+ "redactedThinking",
153
+ ]);
154
+
100
155
  interface DeepInterviewTraceSummary {
101
156
  enabled: true;
102
157
  generated_at: string;
@@ -133,6 +188,1325 @@ const VALUE_FLAGS = new Set([
133
188
  "--handoff",
134
189
  ]);
135
190
 
191
+ // Keep the runtime's transcript and publication bounds aligned with the pure
192
+ // Crystal validator. These are byte bounds for recovery reads; normal input
193
+ // validation remains code-point bounded by deep-interview-state.ts.
194
+ const CRYSTAL_MAX_MESSAGES = 200;
195
+ const CRYSTAL_MAX_JOURNAL_BYTES = 64 * 1024;
196
+ const CRYSTAL_MAX_INDEX_BYTES = 1_000_000;
197
+ const CRYSTAL_INDEX_RECOVERY_BYTES = CRYSTAL_MAX_INDEX_BYTES + 64 * 1024;
198
+ const CRYSTAL_MAX_ARTIFACT_BYTES = MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH * 4 + 4096;
199
+ const READ_NOFOLLOW_FLAGS =
200
+ fs.constants.O_RDONLY | (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0);
201
+
202
+ interface CrystalIndexRow {
203
+ slug: string;
204
+ stage: "final";
205
+ path: string;
206
+ created_at: string;
207
+ sha256: string;
208
+ canonicalPath: string;
209
+ }
210
+
211
+ interface CrystalIndexCatalog {
212
+ rows: CrystalIndexRow[];
213
+ }
214
+
215
+ function serializeCrystalIndexRows(rows: readonly CrystalIndexRow[]): string {
216
+ return rows
217
+ .map(row =>
218
+ JSON.stringify({
219
+ slug: row.slug,
220
+ stage: row.stage,
221
+ path: row.path,
222
+ created_at: row.created_at,
223
+ sha256: row.sha256,
224
+ }),
225
+ )
226
+ .join("\n")
227
+ .concat(rows.length > 0 ? "\n" : "");
228
+ }
229
+
230
+ function compactCrystalIndexRows(
231
+ rows: readonly CrystalIndexRow[],
232
+ protectedPaths: ReadonlySet<string>,
233
+ ): CrystalIndexRow[] {
234
+ const latestByPath = new Map<string, CrystalIndexRow>();
235
+ for (const row of rows) {
236
+ latestByPath.delete(row.canonicalPath);
237
+ latestByPath.set(row.canonicalPath, row);
238
+ }
239
+ const retained = [...latestByPath.values()];
240
+ while (Buffer.byteLength(serializeCrystalIndexRows(retained), "utf-8") > CRYSTAL_MAX_INDEX_BYTES) {
241
+ const removable = retained.findIndex(row => !protectedPaths.has(row.canonicalPath));
242
+ if (removable < 0) throw new DeepInterviewCommandError(2, "Crystal index live publication set exceeds its bound");
243
+ retained.splice(removable, 1);
244
+ }
245
+ return retained;
246
+ }
247
+
248
+ interface CrystalJournalRecord extends WorkflowTransactionJournal {
249
+ artifact_sha256?: unknown;
250
+ }
251
+
252
+ function isErrnoCode(error: unknown, code: string): boolean {
253
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code;
254
+ }
255
+
256
+ function sameFileIdentity(left: Stats, right: Stats): boolean {
257
+ return (
258
+ left.dev === right.dev &&
259
+ left.ino === right.ino &&
260
+ left.mode === right.mode &&
261
+ left.size === right.size &&
262
+ left.mtimeMs === right.mtimeMs &&
263
+ left.ctimeMs === right.ctimeMs &&
264
+ left.nlink === right.nlink
265
+ );
266
+ }
267
+
268
+ function isPathWithin(root: string, target: string): boolean {
269
+ const relative = path.relative(path.resolve(root), path.resolve(target));
270
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
271
+ }
272
+
273
+ async function readBoundedFileBytes(
274
+ filePath: string,
275
+ maxBytes: number,
276
+ label: string,
277
+ options: { allowMissing?: boolean; rejectSymlink?: boolean } = {},
278
+ ): Promise<Buffer | undefined> {
279
+ let lexicalStat: Stats;
280
+ try {
281
+ lexicalStat = await fs.lstat(filePath);
282
+ } catch (error) {
283
+ if (options.allowMissing && isErrnoCode(error, "ENOENT")) return undefined;
284
+ if (isErrnoCode(error, "ENOENT")) throw new DeepInterviewCommandError(2, `${label} is missing`);
285
+ throw new DeepInterviewCommandError(2, `failed to read ${label}`);
286
+ }
287
+ if (options.rejectSymlink !== false && lexicalStat.isSymbolicLink())
288
+ throw new DeepInterviewCommandError(2, `${label} must not be a symlink`);
289
+ if (!lexicalStat.isFile()) throw new DeepInterviewCommandError(2, `${label} is not a regular file`);
290
+ let handle: fs.FileHandle | undefined;
291
+ try {
292
+ handle = await fs.open(filePath, READ_NOFOLLOW_FLAGS);
293
+ const initial = await handle.stat();
294
+ if (!initial.isFile()) throw new DeepInterviewCommandError(2, `${label} is not a regular file`);
295
+ if (!sameFileIdentity(lexicalStat, initial))
296
+ throw new DeepInterviewCommandError(2, `${label} changed before recovery read`);
297
+ if (initial.size > maxBytes) throw new DeepInterviewCommandError(2, `${label} exceeds the bounded read limit`);
298
+ const output = Buffer.alloc(Number(initial.size));
299
+ let offset = 0;
300
+ while (offset < output.length) {
301
+ const { bytesRead } = await handle.read(output, offset, output.length - offset, offset);
302
+ if (bytesRead <= 0) throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
303
+ offset += bytesRead;
304
+ }
305
+ const final = await handle.stat();
306
+ if (!sameFileIdentity(initial, final))
307
+ throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
308
+ const finalPath = await fs.lstat(filePath);
309
+ if (!finalPath.isFile() || finalPath.isSymbolicLink() || !sameFileIdentity(final, finalPath))
310
+ throw new DeepInterviewCommandError(2, `${label} changed during recovery read`);
311
+ return output;
312
+ } catch (error) {
313
+ if (error instanceof DeepInterviewCommandError) throw error;
314
+ throw new DeepInterviewCommandError(2, `failed to read ${label}`);
315
+ } finally {
316
+ await handle?.close().catch(() => undefined);
317
+ }
318
+ }
319
+
320
+ function boundedUtf8(bytes: Buffer, label: string): string {
321
+ try {
322
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
323
+ } catch {
324
+ throw new DeepInterviewCommandError(2, `${label} is not valid UTF-8`);
325
+ }
326
+ }
327
+
328
+ function crystalSpecsRoot(cwd: string, sessionId: string): string {
329
+ return path.resolve(sessionSpecsDir(cwd, sessionId));
330
+ }
331
+
332
+ function canonicalPublicationPath(cwd: string, sessionId: string, value: string): string {
333
+ const canonical = path.resolve(value);
334
+ if (!isPathWithin(crystalSpecsRoot(cwd, sessionId), canonical))
335
+ throw new DeepInterviewCommandError(2, "Crystal publication path escapes the session specs directory");
336
+ return canonical;
337
+ }
338
+
339
+ async function readCrystalIndexCatalog(
340
+ cwd: string,
341
+ sessionId: string,
342
+ indexPath: string,
343
+ ): Promise<CrystalIndexCatalog> {
344
+ const bytes = await readBoundedFileBytes(indexPath, CRYSTAL_INDEX_RECOVERY_BYTES, "Crystal index", {
345
+ allowMissing: true,
346
+ });
347
+ if (!bytes) return { rows: [] };
348
+ const text = boundedUtf8(bytes, "Crystal index");
349
+ const rows: CrystalIndexRow[] = [];
350
+ const pathHashes = new Map<string, string>();
351
+ for (const line of text.split(/\r?\n/)) {
352
+ if (!line.trim()) continue;
353
+ let parsed: unknown;
354
+ try {
355
+ parsed = JSON.parse(line);
356
+ } catch {
357
+ throw new DeepInterviewCommandError(2, "Crystal index contains malformed JSON");
358
+ }
359
+ if (!isRecord(parsed)) throw new DeepInterviewCommandError(2, "Crystal index contains a malformed row");
360
+ const slug = parsed.slug;
361
+ const stage = parsed.stage;
362
+ const rowPath = parsed.path;
363
+ const createdAt = parsed.created_at;
364
+ const sha256 = parsed.sha256;
365
+ if (
366
+ typeof slug !== "string" ||
367
+ slug.trim() === "" ||
368
+ typeof stage !== "string" ||
369
+ stage !== "final" ||
370
+ typeof rowPath !== "string" ||
371
+ typeof createdAt !== "string" ||
372
+ createdAt.trim() === "" ||
373
+ typeof sha256 !== "string" ||
374
+ !/^[a-f0-9]{64}$/.test(sha256)
375
+ )
376
+ throw new DeepInterviewCommandError(2, "Crystal index contains a malformed row");
377
+ try {
378
+ assertSafePathComponent(slug, "Crystal index slug");
379
+ } catch {
380
+ throw new DeepInterviewCommandError(2, "Crystal index contains an unsafe slug");
381
+ }
382
+ const canonicalPath = canonicalPublicationPath(cwd, sessionId, rowPath);
383
+ const previousHash = pathHashes.get(canonicalPath);
384
+ // Versioned Crystal artifacts are immutable. A differing hash for one
385
+ // path therefore proves a conflicting ledger row. Direct spec writes are
386
+ // intentionally mutable and retain their append-only history.
387
+ if (previousHash && previousHash !== sha256 && /-v[0-9]+\.md$/.test(path.basename(canonicalPath)))
388
+ throw new DeepInterviewCommandError(2, "Crystal index contains conflicting rows");
389
+ pathHashes.set(canonicalPath, sha256);
390
+ rows.push({
391
+ slug: slug.trim(),
392
+ stage: "final",
393
+ path: rowPath,
394
+ created_at: createdAt,
395
+ sha256,
396
+ canonicalPath,
397
+ });
398
+ }
399
+ if (bytes.length > CRYSTAL_MAX_INDEX_BYTES) {
400
+ const protectedPaths = new Set<string>();
401
+ const currentState = await readExistingStateForMutation(modeStatePath(cwd, sessionId, "deep-interview"));
402
+ if (currentState.kind === "valid" && typeof currentState.value.spec_path === "string")
403
+ protectedPaths.add(path.resolve(currentState.value.spec_path));
404
+ const compacted = compactCrystalIndexRows(rows, protectedPaths);
405
+ await writeArtifact(indexPath, serializeCrystalIndexRows(compacted), {
406
+ cwd,
407
+ audit: { category: "ledger", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
408
+ });
409
+ return { rows: compacted };
410
+ }
411
+ return { rows };
412
+ }
413
+
414
+ async function appendCrystalIndexRow(
415
+ cwd: string,
416
+ sessionId: string,
417
+ indexPath: string,
418
+ row: Omit<CrystalIndexRow, "canonicalPath">,
419
+ protectedPaths: readonly string[] = [],
420
+ ): Promise<void> {
421
+ await withWorkflowStateLock(
422
+ indexPath,
423
+ async () => {
424
+ const catalog = await readCrystalIndexCatalog(cwd, sessionId, indexPath);
425
+ const canonicalRow: CrystalIndexRow = {
426
+ ...row,
427
+ canonicalPath: canonicalPublicationPath(cwd, sessionId, row.path),
428
+ };
429
+ const line = `${JSON.stringify(row)}\n`;
430
+ const currentSize = await fs
431
+ .stat(indexPath)
432
+ .then(stat => stat.size)
433
+ .catch(error => {
434
+ if (isErrnoCode(error, "ENOENT")) return 0;
435
+ throw error;
436
+ });
437
+ if (currentSize + Buffer.byteLength(line, "utf-8") <= CRYSTAL_MAX_INDEX_BYTES) {
438
+ await appendJsonl(indexPath, row, {
439
+ cwd,
440
+ audit: { category: "ledger", verb: "append", owner: "gjc-runtime", skill: "deep-interview", sessionId },
441
+ });
442
+ return;
443
+ }
444
+ const protectedCanonicalPaths = new Set(protectedPaths.map(value => path.resolve(value)));
445
+ protectedCanonicalPaths.add(canonicalRow.canonicalPath);
446
+ const compacted = compactCrystalIndexRows([...catalog.rows, canonicalRow], protectedCanonicalPaths);
447
+ await writeArtifact(indexPath, serializeCrystalIndexRows(compacted), {
448
+ cwd,
449
+ audit: { category: "ledger", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
450
+ });
451
+ },
452
+ { cwd },
453
+ );
454
+ }
455
+
456
+ function indexRowsForPath(catalog: CrystalIndexCatalog, targetPath: string): CrystalIndexRow[] {
457
+ const canonical = path.resolve(targetPath);
458
+ return catalog.rows.filter(row => row.canonicalPath === canonical);
459
+ }
460
+
461
+ async function verifyPublishedArtifactIndex(options: {
462
+ cwd: string;
463
+ sessionId: string;
464
+ indexPath: string;
465
+ specPath: string;
466
+ specHash: string;
467
+ crystal?: DeepInterviewCrystal;
468
+ }): Promise<CrystalIndexCatalog> {
469
+ if (!/^[a-f0-9]{64}$/.test(options.specHash))
470
+ throw new DeepInterviewCommandError(2, "published Crystal hash is invalid");
471
+ const canonicalSpecPath = canonicalPublicationPath(options.cwd, options.sessionId, options.specPath);
472
+ const catalog = await readCrystalIndexCatalog(options.cwd, options.sessionId, options.indexPath);
473
+ const matchingRows = indexRowsForPath(catalog, canonicalSpecPath);
474
+ if (!matchingRows.some(row => row.sha256 === options.specHash))
475
+ throw new DeepInterviewCommandError(2, "published Crystal index verification failed");
476
+ const artifact = await readBoundedFileBytes(
477
+ canonicalSpecPath,
478
+ CRYSTAL_MAX_ARTIFACT_BYTES,
479
+ "published Crystal artifact",
480
+ );
481
+ if (!artifact) throw new DeepInterviewCommandError(2, "published Crystal artifact is missing");
482
+ const actualHash = createHash("sha256").update(artifact).digest("hex");
483
+ if (actualHash !== options.specHash)
484
+ throw new DeepInterviewCommandError(2, "published Crystal artifact verification failed");
485
+ if (options.crystal) {
486
+ const expectedHash = createHash("sha256").update(crystalMarkdown(options.crystal)).digest("hex");
487
+ if (expectedHash !== options.specHash)
488
+ throw new DeepInterviewCommandError(2, "published Crystal artifact does not match stored Crystal");
489
+ }
490
+ return catalog;
491
+ }
492
+
493
+ async function readCrystallizeInput(rawInput: string | undefined, cwd: string): Promise<Record<string, unknown>> {
494
+ if (!rawInput?.trim())
495
+ throw new DeepInterviewCommandError(2, "--input is required for deep-interview --crystallize");
496
+ let raw = rawInput.trim();
497
+ if (raw.startsWith("@")) {
498
+ const filePath = path.resolve(cwd, raw.slice(1));
499
+ try {
500
+ const stat = await fs.stat(filePath);
501
+ if (!stat.isFile() || stat.size > 1_000_000) throw new Error("input file exceeds 1 MiB");
502
+ raw = await fs.readFile(filePath, "utf-8");
503
+ } catch (error) {
504
+ throw new DeepInterviewCommandError(
505
+ 2,
506
+ `failed to read --input file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
507
+ );
508
+ }
509
+ }
510
+ if ([...raw].length > 1_000_000) throw new DeepInterviewCommandError(2, "crystallize input exceeds 1 MiB");
511
+ let parsed: unknown;
512
+ try {
513
+ parsed = JSON.parse(raw);
514
+ } catch (error) {
515
+ throw new DeepInterviewCommandError(
516
+ 2,
517
+ `--input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
518
+ );
519
+ }
520
+ assertDeepInterviewStructuredResponseWithinLimit(parsed);
521
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
522
+ throw new DeepInterviewCommandError(2, "crystallize input must be a JSON object");
523
+ return parsed as Record<string, unknown>;
524
+ }
525
+
526
+ function activeSessionEntries(entries: FileEntry[]): FileEntry[] {
527
+ const body = entries.slice(1);
528
+ if (body.length === 0) return body;
529
+ const hasBranchMetadata = body.some(entry => "id" in entry || "parentId" in entry);
530
+ if (!hasBranchMetadata) return body;
531
+ const byId = new Map<string, FileEntry>();
532
+ for (const entry of body) {
533
+ const id = (entry as { id?: unknown }).id;
534
+ const parentId = (entry as { parentId?: unknown }).parentId;
535
+ if (
536
+ typeof id !== "string" ||
537
+ id.trim() === "" ||
538
+ !("parentId" in entry) ||
539
+ (parentId !== null && typeof parentId !== "string")
540
+ )
541
+ throw new DeepInterviewCommandError(2, "live session transcript branch metadata is malformed");
542
+ if (byId.has(id)) throw new DeepInterviewCommandError(2, "live session transcript branch contains duplicate IDs");
543
+ byId.set(id, entry);
544
+ }
545
+ for (const entry of body) {
546
+ const parentId = (entry as { parentId: string | null }).parentId;
547
+ if (parentId !== null && !byId.has(parentId))
548
+ throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
549
+ }
550
+ const branch: typeof body = [];
551
+ const visited = new Set<string>();
552
+ let current = body.at(-1);
553
+ while (current) {
554
+ const id = (current as { id: string }).id;
555
+ if (visited.has(id)) throw new DeepInterviewCommandError(2, "live session transcript branch contains a cycle");
556
+ visited.add(id);
557
+ branch.push(current);
558
+ const parentId = (current as { parentId: string | null }).parentId;
559
+ if (parentId === null) break;
560
+ current = byId.get(parentId);
561
+ if (!current) throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
562
+ }
563
+ branch.reverse();
564
+ return branch;
565
+ }
566
+
567
+ function validateTranscriptRecords(records: unknown[]): void {
568
+ if (records.length === 0 || !isRecord(records[0]) || records[0].type !== "session")
569
+ throw new DeepInterviewCommandError(2, "live session transcript must begin with one session header");
570
+ const headers = records.filter(record => isRecord(record) && record.type === "session");
571
+ if (headers.length !== 1)
572
+ throw new DeepInterviewCommandError(2, "live session transcript has multiple session headers");
573
+ const header = records[0] as Record<string, unknown>;
574
+ if (typeof header.id !== "string" || header.id.trim() === "" || typeof header.cwd !== "string")
575
+ throw new DeepInterviewCommandError(2, "live session transcript header is malformed");
576
+ const version = header.version === undefined ? 1 : header.version;
577
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1)
578
+ throw new DeepInterviewCommandError(2, "live session transcript header is malformed");
579
+ const body = records.slice(1).filter((record): record is Record<string, unknown> => isRecord(record));
580
+ if (body.length !== records.length - 1)
581
+ throw new DeepInterviewCommandError(2, "live session transcript contains a malformed entry");
582
+ const entries = body.filter(record => record.type !== "header_patch" && record.type !== "entry_patch");
583
+ const hasBranchMetadata = entries.some(entry => "id" in entry || "parentId" in entry);
584
+ if (version >= 2 || hasBranchMetadata) {
585
+ const ids = new Set<string>();
586
+ for (const entry of entries) {
587
+ const id = entry.id;
588
+ const parentId = entry.parentId;
589
+ if (
590
+ typeof entry.type !== "string" ||
591
+ typeof id !== "string" ||
592
+ id.trim() === "" ||
593
+ !("parentId" in entry) ||
594
+ (parentId !== null && typeof parentId !== "string")
595
+ )
596
+ throw new DeepInterviewCommandError(2, "live session transcript branch metadata is malformed");
597
+ if (ids.has(id))
598
+ throw new DeepInterviewCommandError(2, "live session transcript branch contains duplicate IDs");
599
+ ids.add(id);
600
+ }
601
+ for (const entry of entries) {
602
+ const parentId = entry.parentId;
603
+ if (parentId !== null && !ids.has(parentId as string))
604
+ throw new DeepInterviewCommandError(2, "live session transcript branch has a missing parent");
605
+ }
606
+ }
607
+ for (const record of body) {
608
+ if (record.type === "header_patch") {
609
+ if (!isRecord(record.patch) || !Object.keys(record).every(key => key === "type" || key === "patch"))
610
+ throw new DeepInterviewCommandError(2, "live session transcript contains malformed branch metadata");
611
+ }
612
+ if (record.type === "entry_patch") {
613
+ if (
614
+ typeof record.entryId !== "string" ||
615
+ !isRecord(record.patch) ||
616
+ !Object.keys(record).every(key => key === "type" || key === "entryId" || key === "patch")
617
+ )
618
+ throw new DeepInterviewCommandError(2, "live session transcript contains malformed branch metadata");
619
+ }
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Crystallization treats a project transcript as authoritative conversation
625
+ * evidence, so only transcripts written by the session manager's managed
626
+ * containers (`agent-session/`, `sessions/`) are eligible. The shared project
627
+ * classifier additionally accepts legacy root-level `.gjc/*.jsonl` files, which
628
+ * any repository content can forge with a matching header; those are rejected
629
+ * here before evidence or approval binding.
630
+ */
631
+ function isManagedProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
632
+ if (!isProjectSessionTranscriptPath(projectGjcDir, filePath)) return false;
633
+ const relative = path.relative(projectGjcDir, filePath);
634
+ const segments = relative.split(path.sep);
635
+ return segments.length >= 2;
636
+ }
637
+
638
+ export async function authoritativeConversationSnapshot(
639
+ cwd: string,
640
+ sessionId: string,
641
+ ): Promise<{
642
+ revision: number;
643
+ messages: CrystalSnapshot["messages"];
644
+ transcriptPath: string;
645
+ transcriptSha256: string;
646
+ }> {
647
+ let sessionFile = process.env.GJC_SESSION_FILE?.trim();
648
+ let explicitProjectTranscript = false;
649
+ if (sessionFile) {
650
+ sessionFile = path.resolve(cwd, sessionFile);
651
+ try {
652
+ const explicitStat = await fs.lstat(sessionFile);
653
+ if (!explicitStat.isFile() || explicitStat.isSymbolicLink())
654
+ throw new Error("symlink or non-regular transcript");
655
+ const explicitRealPath = await fs.realpath(sessionFile);
656
+ if (explicitRealPath !== sessionFile) throw new Error("symlink transcript");
657
+ sessionFile = explicitRealPath;
658
+ // Try authorized read first (atomic identity-bound read); if it fails,
659
+ // the transcript is either unauthorized or not a managed project transcript.
660
+ const authorizedBytes = readAuthorizedProjectSessionTranscript(
661
+ path.resolve(cwd, ".gjc"),
662
+ explicitRealPath,
663
+ RESUME_TRANSCRIPT_MAX_BYTES,
664
+ );
665
+ explicitProjectTranscript = !!authorizedBytes;
666
+ if (!explicitProjectTranscript) throw new Error("unauthorized project transcript");
667
+ } catch {
668
+ throw new DeepInterviewCommandError(2, "GJC_SESSION_FILE is not a managed canonical session transcript");
669
+ }
670
+ }
671
+ const canonicalCandidates = new Set<string>();
672
+ const managedCandidates = new Set<string>();
673
+ const lexicalCandidates = new Set<string>();
674
+ if (!sessionFile) {
675
+ const projectGjcDir = path.resolve(cwd, ".gjc");
676
+ for (const candidate of listProjectSessionTranscriptFiles(cwd)) {
677
+ const resolved = path.resolve(candidate);
678
+ // Legacy root-level `.gjc/*.jsonl` files are listed for session resume
679
+ // compatibility, but they are not trusted-writer provenance, so they can
680
+ // never become authoritative crystallization evidence.
681
+ if (isManagedProjectSessionTranscriptPath(projectGjcDir, resolved)) lexicalCandidates.add(resolved);
682
+ }
683
+ }
684
+ if (!explicitProjectTranscript) {
685
+ const managedScope = await resolveManagedSessionScope({ cwd });
686
+ if (managedScope.kind === "error")
687
+ throw new DeepInterviewCommandError(2, "managed session transcript scope is unavailable");
688
+ if (managedScope.kind === "resolved") {
689
+ const managedListing = await listManagedSessionCandidates({ scope: managedScope.scope });
690
+ if (managedListing.kind === "error")
691
+ throw new DeepInterviewCommandError(2, "managed session transcript listing is unavailable");
692
+ for (const candidate of managedListing.owned) {
693
+ const resolved = path.resolve(candidate.path);
694
+ managedCandidates.add(resolved);
695
+ lexicalCandidates.add(resolved);
696
+ }
697
+ }
698
+ }
699
+ // The native command accepts an explicit workspace cwd, which may differ from
700
+ // process.cwd(). Resolve relative managed transcript paths against that same
701
+ // workspace so transcript identity and content cannot drift with process launch location.
702
+ if (sessionFile) {
703
+ if (!explicitProjectTranscript && !managedCandidates.has(sessionFile))
704
+ throw new DeepInterviewCommandError(2, "GJC_SESSION_FILE is not a managed canonical session transcript");
705
+ } else {
706
+ if (lexicalCandidates.size > 1000)
707
+ throw new DeepInterviewCommandError(2, "session transcript discovery exceeded the bounded candidate limit");
708
+ for (const candidate of [...lexicalCandidates].sort()) {
709
+ try {
710
+ const stat = await fs.lstat(candidate);
711
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
712
+ const realPath = await fs.realpath(candidate);
713
+ if (realPath !== candidate) continue;
714
+ canonicalCandidates.add(realPath);
715
+ } catch {}
716
+ }
717
+ for (const candidate of [...canonicalCandidates].sort()) {
718
+ try {
719
+ const projectGjcDir = path.resolve(cwd, ".gjc");
720
+ // Try authorized project session transcript first (atomic identity-bound read),
721
+ // then fall back to general bounded read for non-managed transcripts.
722
+ let bytes = readAuthorizedProjectSessionTranscript(projectGjcDir, candidate, RESUME_TRANSCRIPT_MAX_BYTES);
723
+ if (!bytes) {
724
+ bytes = await readBoundedFileBytes(candidate, RESUME_TRANSCRIPT_MAX_BYTES, "session transcript", {
725
+ allowMissing: true,
726
+ });
727
+ }
728
+ if (!bytes) continue;
729
+ const header = JSON.parse(boundedUtf8(bytes, "session transcript").split(/\r?\n/, 1)[0]) as Record<
730
+ string,
731
+ unknown
732
+ >;
733
+ if (
734
+ header.id === sessionId &&
735
+ typeof header.cwd === "string" &&
736
+ path.resolve(header.cwd) === path.resolve(cwd)
737
+ ) {
738
+ sessionFile = candidate;
739
+ break;
740
+ }
741
+ } catch {}
742
+ }
743
+ }
744
+ if (!sessionFile)
745
+ throw new DeepInterviewCommandError(2, "an authenticated session transcript is required for crystallization");
746
+ try {
747
+ const projectGjcDir = path.resolve(cwd, ".gjc");
748
+ // Try authorized project session transcript first (atomic identity-bound read),
749
+ // then fall back to general bounded read for non-managed transcripts.
750
+ let bytes = readAuthorizedProjectSessionTranscript(projectGjcDir, sessionFile, RESUME_TRANSCRIPT_MAX_BYTES);
751
+ if (!bytes) {
752
+ bytes = await readBoundedFileBytes(sessionFile, RESUME_TRANSCRIPT_MAX_BYTES, "live session transcript");
753
+ }
754
+ if (!bytes) throw new DeepInterviewCommandError(2, "live session transcript is unavailable");
755
+ const text = boundedUtf8(bytes, "live session transcript");
756
+ const records: unknown[] = [];
757
+ for (const line of text.split(/\r?\n/)) {
758
+ if (!line.trim()) continue;
759
+ let parsed: unknown;
760
+ try {
761
+ parsed = JSON.parse(line);
762
+ } catch {
763
+ throw new DeepInterviewCommandError(2, "live session transcript is malformed");
764
+ }
765
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
766
+ throw new DeepInterviewCommandError(2, "live session transcript is malformed");
767
+ records.push(parsed);
768
+ }
769
+ validateTranscriptRecords(records);
770
+ const entries = parseSessionEntries(text);
771
+ const header = entries[0];
772
+ if (
773
+ header?.type !== "session" ||
774
+ header?.id !== sessionId ||
775
+ typeof header?.cwd !== "string" ||
776
+ path.resolve(header.cwd) !== path.resolve(cwd)
777
+ )
778
+ throw new DeepInterviewCommandError(2, "live session transcript identity mismatch");
779
+ const messages: CrystalSnapshot["messages"] = [];
780
+ for (const entry of activeSessionEntries(entries)) {
781
+ if (entry.type !== "message") continue;
782
+ const index = messages.length;
783
+ const message = entry.message as unknown;
784
+ if (!isRecord(message) || typeof message.role !== "string")
785
+ throw new DeepInterviewCommandError(2, "live session transcript contains a malformed message");
786
+ const role =
787
+ message.role === "user" && (message.attribution === "agent" || message.synthetic === true)
788
+ ? "developer"
789
+ : ["custom", "hookMessage"].includes(message.role)
790
+ ? "system"
791
+ : ["bashExecution", "pythonExecution", "fileMention"].includes(message.role)
792
+ ? "tool"
793
+ : message.role;
794
+ if (!["user", "assistant", "system", "developer", "tool", "toolResult"].includes(role))
795
+ throw new DeepInterviewCommandError(2, "live session transcript contains an unsupported message role");
796
+ let projectedContent: string;
797
+ if (["bashExecution", "pythonExecution", "fileMention"].includes(message.role)) {
798
+ projectedContent = `[${message.role} sha256:${createHash("sha256").update(JSON.stringify(message)).digest("hex")}]`;
799
+ } else if (typeof message.content === "string") {
800
+ projectedContent = message.content;
801
+ } else if (Array.isArray(message.content)) {
802
+ const projectedParts: string[] = [];
803
+ for (const item of message.content) {
804
+ if (!isRecord(item) || typeof item.type !== "string")
805
+ throw new DeepInterviewCommandError(
806
+ 2,
807
+ "live session transcript contains unsupported message content",
808
+ );
809
+ if (item.type === "text") {
810
+ if (typeof item.text !== "string")
811
+ throw new DeepInterviewCommandError(2, "live session transcript contains malformed text content");
812
+ projectedParts.push(item.text);
813
+ } else if (DEEP_INTERVIEW_NON_TEXT_CONTENT_TYPES.has(item.type)) {
814
+ projectedParts.push(`[${item.type}]`);
815
+ } else {
816
+ throw new DeepInterviewCommandError(
817
+ 2,
818
+ "live session transcript contains unsupported message content",
819
+ );
820
+ }
821
+ }
822
+ projectedContent = projectedParts.join("");
823
+ } else {
824
+ throw new DeepInterviewCommandError(2, "live session transcript contains malformed message content");
825
+ }
826
+ messages.push({
827
+ index,
828
+ role: role as CrystalSnapshot["messages"][number]["role"],
829
+ content: projectedContent.normalize("NFC").trim(),
830
+ });
831
+ }
832
+ if (messages.length === 0) throw new DeepInterviewCommandError(2, "live session transcript has no messages");
833
+ return {
834
+ revision: messages.length,
835
+ messages,
836
+ transcriptPath: path.resolve(sessionFile),
837
+ transcriptSha256: createHash("sha256").update(bytes).digest("hex"),
838
+ };
839
+ } catch (error) {
840
+ if (error instanceof DeepInterviewCommandError) throw error;
841
+ throw new DeepInterviewCommandError(2, "live session transcript is unavailable");
842
+ }
843
+ }
844
+
845
+ function parseCrystalMutationId(mutationId: string, sessionId: string): { specVersion: number } {
846
+ const prefix = `crystal:${sessionId}:`;
847
+ if (!mutationId.startsWith(prefix))
848
+ throw new DeepInterviewCommandError(2, "Crystal transaction journal identity mismatch");
849
+ const suffix = mutationId.slice(prefix.length);
850
+ const separator = suffix.indexOf(":");
851
+ if (
852
+ separator <= 0 ||
853
+ !/^\d+$/.test(suffix.slice(0, separator)) ||
854
+ !/^[a-f0-9]{64}$/.test(suffix.slice(separator + 1))
855
+ )
856
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
857
+ const specVersion = Number(suffix.slice(0, separator));
858
+ if (!Number.isSafeInteger(specVersion) || specVersion < 1)
859
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
860
+ return { specVersion };
861
+ }
862
+
863
+ function validateCrystalJournalRecord(
864
+ value: unknown,
865
+ cwd: string,
866
+ sessionId: string,
867
+ indexPath: string,
868
+ statePath: string,
869
+ ): CrystalJournalRecord {
870
+ if (!isRecord(value)) throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
871
+ if (value.version !== 1 || typeof value.mutation_id !== "string")
872
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
873
+ parseCrystalMutationId(value.mutation_id, sessionId);
874
+ if (value.status !== "pending" && value.status !== "committed")
875
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
876
+ if (
877
+ typeof value.created_at !== "string" ||
878
+ typeof value.updated_at !== "string" ||
879
+ !Array.isArray(value.paths) ||
880
+ value.paths.length !== 3 ||
881
+ !Array.isArray(value.steps) ||
882
+ value.steps.some(step => typeof step !== "string")
883
+ )
884
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
885
+ const [specPath, journalIndexPath, journalStatePath] = value.paths;
886
+ if (
887
+ typeof specPath !== "string" ||
888
+ typeof journalIndexPath !== "string" ||
889
+ typeof journalStatePath !== "string" ||
890
+ path.resolve(journalIndexPath) !== path.resolve(indexPath) ||
891
+ path.resolve(journalStatePath) !== path.resolve(statePath)
892
+ )
893
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal identity mismatch");
894
+ canonicalPublicationPath(cwd, sessionId, specPath);
895
+ const allowedSteps = new Set(["artifact", "index", "state"]);
896
+ const steps = value.steps as unknown[];
897
+ if (
898
+ new Set(steps).size !== steps.length ||
899
+ steps.some(step => !allowedSteps.has(step as string)) ||
900
+ (steps.includes("index") && !steps.includes("artifact")) ||
901
+ (steps.includes("state") && (!steps.includes("artifact") || !steps.includes("index")))
902
+ )
903
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal steps are invalid");
904
+ if (
905
+ value.artifact_sha256 !== undefined &&
906
+ (typeof value.artifact_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.artifact_sha256))
907
+ )
908
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal hash is invalid");
909
+ return value as unknown as CrystalJournalRecord;
910
+ }
911
+
912
+ async function readCrystalJournal(
913
+ cwd: string,
914
+ sessionId: string,
915
+ mutationId: string,
916
+ indexPath: string,
917
+ statePath: string,
918
+ ): Promise<CrystalJournalRecord | undefined> {
919
+ const journalPath = transactionJournalPath(cwd, sessionId, mutationId);
920
+ const bytes = await readBoundedFileBytes(journalPath, CRYSTAL_MAX_JOURNAL_BYTES, "Crystal transaction journal", {
921
+ allowMissing: true,
922
+ });
923
+ if (!bytes) return undefined;
924
+ let parsed: unknown;
925
+ try {
926
+ parsed = JSON.parse(boundedUtf8(bytes, "Crystal transaction journal"));
927
+ } catch {
928
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is corrupt");
929
+ }
930
+ return validateCrystalJournalRecord(parsed, cwd, sessionId, indexPath, statePath);
931
+ }
932
+
933
+ async function verifyPriorPendingCrystalJournals(options: {
934
+ cwd: string;
935
+ sessionId: string;
936
+ indexPath: string;
937
+ statePath: string;
938
+ existing: Record<string, unknown>;
939
+ currentMutationId?: string;
940
+ }): Promise<CrystalJournalRecord | undefined> {
941
+ const transactionsDir = path.dirname(transactionJournalPath(options.cwd, options.sessionId, "scan"));
942
+ let names: string[];
943
+ try {
944
+ names = (await fs.readdir(transactionsDir)).filter(name => name.endsWith(".json")).sort();
945
+ } catch (error) {
946
+ if (isErrnoCode(error, "ENOENT")) return undefined;
947
+ throw new DeepInterviewCommandError(2, "Crystal transaction journal discovery failed");
948
+ }
949
+ if (names.length > 128)
950
+ throw new DeepInterviewCommandError(2, "Crystal transaction journal discovery exceeded the bounded limit");
951
+ const catalog = await readCrystalIndexCatalog(options.cwd, options.sessionId, options.indexPath);
952
+ let current: CrystalJournalRecord | undefined;
953
+ for (const name of names) {
954
+ const journalPath = path.join(transactionsDir, name);
955
+ const bytes = await readBoundedFileBytes(journalPath, CRYSTAL_MAX_JOURNAL_BYTES, "Crystal transaction journal");
956
+ if (!bytes) continue;
957
+ let parsed: unknown;
958
+ try {
959
+ parsed = JSON.parse(boundedUtf8(bytes, "Crystal transaction journal"));
960
+ } catch {
961
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is corrupt");
962
+ }
963
+ if (!isRecord(parsed) || typeof parsed.mutation_id !== "string")
964
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction journal is invalid");
965
+ if (!parsed.mutation_id.startsWith(`crystal:${options.sessionId}:`)) continue;
966
+ const journal = validateCrystalJournalRecord(
967
+ parsed,
968
+ options.cwd,
969
+ options.sessionId,
970
+ options.indexPath,
971
+ options.statePath,
972
+ );
973
+ if (journal.status !== "pending") continue;
974
+ if (journal.mutation_id === options.currentMutationId) {
975
+ current = journal;
976
+ continue;
977
+ }
978
+ const specPath = journal.paths[0]!;
979
+ const canonicalSpecPath = canonicalPublicationPath(options.cwd, options.sessionId, specPath);
980
+ const rows = indexRowsForPath(catalog, canonicalSpecPath);
981
+ let expectedHash = typeof journal.artifact_sha256 === "string" ? journal.artifact_sha256 : undefined;
982
+ for (const row of rows) {
983
+ if (expectedHash && expectedHash !== row.sha256)
984
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction hash mismatch");
985
+ expectedHash = row.sha256;
986
+ }
987
+ const existingSpecPath = typeof options.existing.spec_path === "string" ? options.existing.spec_path : undefined;
988
+ const existingSpecHash =
989
+ typeof options.existing.spec_sha256 === "string" ? options.existing.spec_sha256 : undefined;
990
+ if (existingSpecPath && path.resolve(existingSpecPath) === canonicalSpecPath) {
991
+ if (expectedHash && existingSpecHash && expectedHash !== existingSpecHash)
992
+ throw new DeepInterviewCommandError(2, "pending Crystal transaction hash mismatch");
993
+ expectedHash = existingSpecHash ?? expectedHash;
994
+ }
995
+ if (!expectedHash) throw new DeepInterviewCommandError(2, "pending Crystal transaction cannot be authenticated");
996
+ const artifact = await readBoundedFileBytes(
997
+ canonicalSpecPath,
998
+ CRYSTAL_MAX_ARTIFACT_BYTES,
999
+ "pending Crystal artifact",
1000
+ );
1001
+ if (!artifact || createHash("sha256").update(artifact).digest("hex") !== expectedHash)
1002
+ throw new DeepInterviewCommandError(2, "pending Crystal artifact verification failed");
1003
+ if (!rows.some(row => row.sha256 === expectedHash))
1004
+ throw new DeepInterviewCommandError(2, "pending Crystal index verification failed");
1005
+ if (
1006
+ !existingSpecPath ||
1007
+ !existingSpecHash ||
1008
+ path.resolve(existingSpecPath) !== canonicalSpecPath ||
1009
+ existingSpecHash !== expectedHash
1010
+ )
1011
+ throw new DeepInterviewCommandError(2, "pending Crystal state verification failed");
1012
+ await completeWorkflowTransactionJournal(options.cwd, options.sessionId, journal.mutation_id);
1013
+ }
1014
+ return current;
1015
+ }
1016
+
1017
+ const CRYSTAL_ROLES = new Set(["user", "assistant", "system", "tool", "toolResult", "developer"]);
1018
+ const CRYSTAL_LIFECYCLES = new Set(["ready", "needs-questions", "stale", "superseded"]);
1019
+ const CRYSTAL_DELTA_KINDS = new Set(["none", "additive", "intent-changed", "goal-replaced", "stale"]);
1020
+ const CRYSTAL_ITEM_KINDS = new Set(["goal", "constraint", "decision", "acceptance_criterion", "non_goal"]);
1021
+ const CRYSTAL_CLASSIFICATIONS = new Set(["confirmed", "inferred", "disputed"]);
1022
+
1023
+ function parseCrystalSnapshot(value: unknown, label: string): CrystalSnapshot {
1024
+ if (!isRecord(value)) throw new DeepInterviewCommandError(2, `${label} is malformed`);
1025
+ const revision = value.revision;
1026
+ const start = value.start;
1027
+ const end = value.end;
1028
+ if (
1029
+ typeof revision !== "number" ||
1030
+ !Number.isSafeInteger(revision) ||
1031
+ typeof start !== "number" ||
1032
+ !Number.isSafeInteger(start) ||
1033
+ typeof end !== "number" ||
1034
+ !Number.isSafeInteger(end) ||
1035
+ start < 0 ||
1036
+ end < start ||
1037
+ !Array.isArray(value.messages) ||
1038
+ value.messages.length !== end - start + 1 ||
1039
+ value.messages.length > CRYSTAL_MAX_MESSAGES
1040
+ )
1041
+ throw new DeepInterviewCommandError(2, `${label} is malformed`);
1042
+ const messages = value.messages.map((entry, index) => {
1043
+ if (!isRecord(entry)) throw new DeepInterviewCommandError(2, `${label} contains a malformed message`);
1044
+ const messageIndex = entry.index;
1045
+ const role = entry.role;
1046
+ const content = entry.content;
1047
+ if (
1048
+ typeof messageIndex !== "number" ||
1049
+ !Number.isSafeInteger(messageIndex) ||
1050
+ messageIndex !== start + index ||
1051
+ typeof role !== "string" ||
1052
+ !CRYSTAL_ROLES.has(role) ||
1053
+ typeof content !== "string"
1054
+ )
1055
+ throw new DeepInterviewCommandError(2, `${label} contains a malformed message`);
1056
+ return { index: messageIndex, role: role as CrystalSnapshot["messages"][number]["role"], content };
1057
+ });
1058
+ if (typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest))
1059
+ throw new DeepInterviewCommandError(2, `${label} digest is invalid`);
1060
+ const snapshot = { revision, start, end, digest: value.digest, messages } as CrystalSnapshot;
1061
+ if (crystalSnapshotDigest(snapshot) !== snapshot.digest)
1062
+ throw new DeepInterviewCommandError(2, `${label} digest mismatch`);
1063
+ return snapshot;
1064
+ }
1065
+
1066
+ function parseStoredCrystal(value: unknown): DeepInterviewCrystal {
1067
+ if (!isRecord(value) || value.schema_version !== 1 || !Number.isSafeInteger(value.spec_version))
1068
+ throw new DeepInterviewCommandError(2, "stored Crystal is invalid");
1069
+ if (
1070
+ (value.spec_version as number) < 1 ||
1071
+ typeof value.lifecycle !== "string" ||
1072
+ !CRYSTAL_LIFECYCLES.has(value.lifecycle)
1073
+ )
1074
+ throw new DeepInterviewCommandError(2, "stored Crystal is invalid");
1075
+ if (value.execution_approval !== "not-approved")
1076
+ throw new DeepInterviewCommandError(2, "stored Crystal execution approval is invalid");
1077
+ parseCrystalSnapshot(value.source, "stored Crystal source");
1078
+ if (!Array.isArray(value.items) || value.items.length === 0 || value.items.length > 128)
1079
+ throw new DeepInterviewCommandError(2, "stored Crystal items are invalid");
1080
+ const ids = new Set<string>();
1081
+ for (const item of value.items) {
1082
+ if (
1083
+ !isRecord(item) ||
1084
+ typeof item.id !== "string" ||
1085
+ item.id.trim() === "" ||
1086
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(item.id) ||
1087
+ ids.has(item.id) ||
1088
+ typeof item.kind !== "string" ||
1089
+ !CRYSTAL_ITEM_KINDS.has(item.kind) ||
1090
+ typeof item.classification !== "string" ||
1091
+ !CRYSTAL_CLASSIFICATIONS.has(item.classification) ||
1092
+ typeof item.statement !== "string" ||
1093
+ item.statement.trim() === ""
1094
+ )
1095
+ throw new DeepInterviewCommandError(2, "stored Crystal items are invalid");
1096
+ if (item.classification === "confirmed") {
1097
+ if (
1098
+ !isRecord(item.anchor) ||
1099
+ !Number.isSafeInteger(item.anchor.message_index) ||
1100
+ typeof item.anchor.quote !== "string"
1101
+ )
1102
+ throw new DeepInterviewCommandError(2, "stored Crystal confirmed item anchor is invalid");
1103
+ }
1104
+ ids.add(item.id);
1105
+ }
1106
+ if (!isRecord(value.delta) || typeof value.delta.kind !== "string" || !CRYSTAL_DELTA_KINDS.has(value.delta.kind))
1107
+ throw new DeepInterviewCommandError(2, "stored Crystal delta is invalid");
1108
+ if (
1109
+ !Array.isArray(value.delta.changed_ids) ||
1110
+ !Array.isArray(value.delta.added_ids) ||
1111
+ !Array.isArray(value.delta.preserved_ids) ||
1112
+ typeof value.delta.approval_invalidated !== "boolean" ||
1113
+ [...value.delta.changed_ids, ...value.delta.added_ids, ...value.delta.preserved_ids].some(
1114
+ id => typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(id),
1115
+ )
1116
+ )
1117
+ throw new DeepInterviewCommandError(2, "stored Crystal delta is invalid");
1118
+ for (const field of ["open_gaps", "conflicts"] as const) {
1119
+ if (!Array.isArray(value[field]) || value[field].some(item => typeof item !== "string"))
1120
+ throw new DeepInterviewCommandError(2, "stored Crystal resolution fields are invalid");
1121
+ }
1122
+ for (const field of ["removed_ids", "pending_removals"] as const) {
1123
+ if (
1124
+ value[field] !== undefined &&
1125
+ (!Array.isArray(value[field]) ||
1126
+ value[field].some(item => typeof item !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(item)))
1127
+ )
1128
+ throw new DeepInterviewCommandError(2, "stored Crystal removal fields are invalid");
1129
+ }
1130
+ if (
1131
+ value.removed_item_anchors !== undefined &&
1132
+ (!Array.isArray(value.removed_item_anchors) ||
1133
+ value.removed_item_anchors.some(
1134
+ anchor =>
1135
+ !isRecord(anchor) ||
1136
+ typeof anchor.item !== "string" ||
1137
+ !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(anchor.item),
1138
+ ))
1139
+ )
1140
+ throw new DeepInterviewCommandError(2, "stored Crystal removal anchors are invalid");
1141
+ return value as unknown as DeepInterviewCrystal;
1142
+ }
1143
+
1144
+ function verifyCrystalSourceAgainstLive(
1145
+ crystal: DeepInterviewCrystal,
1146
+ liveSnapshot: { revision: number; messages: Array<{ index: number; role: string; content: string }> },
1147
+ ): CrystalSnapshot {
1148
+ const source = parseCrystalSnapshot(crystal.source, "stored Crystal source");
1149
+ if (source.end >= liveSnapshot.messages.length)
1150
+ throw new DeepInterviewCommandError(2, "stored Crystal source is outside the live transcript");
1151
+ const expected = liveSnapshot.messages.slice(source.start, source.end + 1);
1152
+ if (JSON.stringify(expected) !== JSON.stringify(source.messages))
1153
+ throw new DeepInterviewCommandError(2, "stored Crystal source evidence does not match the live transcript");
1154
+ return source;
1155
+ }
1156
+
1157
+ export async function assertDeepInterviewCrystalCoversLiveTranscript(
1158
+ cwd: string,
1159
+ sessionId: string,
1160
+ requireCrystalTail = true,
1161
+ ): Promise<{ transcriptPath: string; transcriptSha256: string }> {
1162
+ const statePath = deepInterviewStatePath(cwd, sessionId);
1163
+ let parsed: unknown;
1164
+ try {
1165
+ parsed = await Bun.file(statePath).json();
1166
+ } catch {
1167
+ throw new DeepInterviewCommandError(2, "execution approval requires valid Deep Interview state");
1168
+ }
1169
+ if (!isRecord(parsed) || !isRecord(parsed.state))
1170
+ throw new DeepInterviewCommandError(2, "execution approval requires canonical Crystal state");
1171
+ const crystal = parseStoredCrystal(parsed.state.crystal);
1172
+ const liveSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
1173
+ const source = verifyCrystalSourceAgainstLive(crystal, liveSnapshot);
1174
+ if (
1175
+ requireCrystalTail &&
1176
+ liveSnapshot.messages.slice(source.end + 1).some(message => !["assistant", "toolResult"].includes(message.role))
1177
+ )
1178
+ throw new DeepInterviewCommandError(2, "execution approval requires re-crystallization after transcript changes");
1179
+ return { transcriptPath: liveSnapshot.transcriptPath, transcriptSha256: liveSnapshot.transcriptSha256 };
1180
+ }
1181
+
1182
+ async function handleCrystallize(args: readonly string[], cwd: string): Promise<DeepInterviewCommandResult> {
1183
+ assertCrystallizeArgs(args);
1184
+ const input = await readCrystallizeInput(flagValue(args, "--input"), cwd);
1185
+ const session = resolveGjcSessionForWrite(cwd, {
1186
+ flagValue: flagValue(args, "--session-id"),
1187
+ payloadSessionId: input.session_id,
1188
+ envSessionId: process.env.GJC_SESSION_ID,
1189
+ });
1190
+ const sessionId = session.gjcSessionId;
1191
+ assertSafePathComponent(sessionId, "session-id");
1192
+ const statePath = deepInterviewStatePath(cwd, sessionId);
1193
+ return withWorkflowStateLock(
1194
+ statePath,
1195
+ async () => handleCrystallizeUnlocked(args, cwd, sessionId, statePath, input),
1196
+ { cwd },
1197
+ );
1198
+ }
1199
+
1200
+ function assertCrystallizeArgs(args: readonly string[]): void {
1201
+ let valueExpected = false;
1202
+ for (const arg of args) {
1203
+ if (valueExpected) {
1204
+ valueExpected = false;
1205
+ continue;
1206
+ }
1207
+ if (arg === "--input" || arg === "--session-id" || arg === "--slug") {
1208
+ valueExpected = true;
1209
+ continue;
1210
+ }
1211
+ if (arg === "--crystallize" || arg === "--json") continue;
1212
+ throw new DeepInterviewCommandError(2, `unsupported crystallize argument: ${arg}`);
1213
+ }
1214
+ if (valueExpected) throw new DeepInterviewCommandError(2, "crystallize option requires a value");
1215
+ const slug = flagValue(args, "--slug")?.trim();
1216
+ if (!slug) throw new DeepInterviewCommandError(2, "--slug is required for deep-interview --crystallize");
1217
+ }
1218
+
1219
+ async function handleCrystallizeUnlocked(
1220
+ args: readonly string[],
1221
+ cwd: string,
1222
+ sessionId: string,
1223
+ statePath: string,
1224
+ input: Record<string, unknown>,
1225
+ ): Promise<DeepInterviewCommandResult> {
1226
+ const existingRead = await readExistingStateForMutation(statePath);
1227
+ if (existingRead.kind === "corrupt")
1228
+ throw new DeepInterviewCommandError(
1229
+ 2,
1230
+ `existing deep-interview state is corrupt or tampered (${existingRead.error})`,
1231
+ );
1232
+ if (existingRead.kind === "valid") assertNoFutureDeepInterviewEnvelope(existingRead.value, "crystallize state");
1233
+ const existing =
1234
+ existingRead.kind === "valid"
1235
+ ? normalizeDeepInterviewEnvelope(existingRead.value)
1236
+ : normalizeDeepInterviewEnvelope({});
1237
+ if (existingRead.kind === "valid" && existing.active === false)
1238
+ throw new DeepInterviewCommandError(2, "cannot crystallize an inactive deep-interview state");
1239
+ const existingInner = isRecord(existing.state) ? existing.state : {};
1240
+ const storedPrior = existingInner.crystal;
1241
+ const priorCrystal = storedPrior === undefined ? undefined : parseStoredCrystal(storedPrior);
1242
+ if (existingRead.kind === "valid" && priorCrystal) {
1243
+ try {
1244
+ const integrity = await detectWorkflowEnvelopeIntegrityMismatch(statePath);
1245
+ if (integrity) throw new DeepInterviewCommandError(2, "stored Crystal state integrity verification failed");
1246
+ } catch (error) {
1247
+ if (error instanceof DeepInterviewCommandError) throw error;
1248
+ throw new DeepInterviewCommandError(2, "stored Crystal state integrity verification failed");
1249
+ }
1250
+ }
1251
+ if (input.prior !== undefined && storedPrior === undefined)
1252
+ throw new DeepInterviewCommandError(2, "supplied prior crystal requires canonical stored crystal provenance");
1253
+ if (
1254
+ input.prior !== undefined &&
1255
+ storedPrior !== undefined &&
1256
+ JSON.stringify(input.prior) !== JSON.stringify(storedPrior)
1257
+ )
1258
+ throw new DeepInterviewCommandError(2, "supplied prior crystal does not match canonical stored crystal");
1259
+ const liveSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
1260
+ if (liveSnapshot.revision !== input.current_revision)
1261
+ throw new DeepInterviewCommandError(2, "conversation snapshot is stale against the live session transcript");
1262
+ const snapshot = parseCrystalSnapshot(input.snapshot, "crystallize snapshot");
1263
+ if (snapshot.revision !== input.current_revision)
1264
+ throw new DeepInterviewCommandError(2, "conversation snapshot is stale");
1265
+ if (snapshot.end !== liveSnapshot.messages.length - 1)
1266
+ throw new DeepInterviewCommandError(2, "crystallize snapshot must cover the live transcript tail");
1267
+ const expectedMessages = liveSnapshot.messages.slice(snapshot.start, snapshot.end + 1);
1268
+ if (JSON.stringify(expectedMessages) !== JSON.stringify(snapshot.messages))
1269
+ throw new DeepInterviewCommandError(2, "conversation snapshot does not match the live session transcript");
1270
+ const existingSpecPath = typeof existing.spec_path === "string" ? existing.spec_path : undefined;
1271
+ const existingSpecHash = typeof existing.spec_sha256 === "string" ? existing.spec_sha256 : undefined;
1272
+ const indexPath = path.join(sessionSpecsDir(cwd, sessionId), "deep-interview-index.jsonl");
1273
+ const priorSource = priorCrystal ? verifyCrystalSourceAgainstLive(priorCrystal, liveSnapshot) : undefined;
1274
+ if ((existingSpecPath && !existingSpecHash) || (!existingSpecPath && existingSpecHash))
1275
+ throw new DeepInterviewCommandError(2, "existing Crystal publication identity is incomplete");
1276
+ if (existingSpecPath && existingSpecHash)
1277
+ await verifyPublishedArtifactIndex({
1278
+ cwd,
1279
+ sessionId,
1280
+ indexPath,
1281
+ specPath: existingSpecPath,
1282
+ specHash: existingSpecHash,
1283
+ crystal: priorCrystal?.lifecycle === "ready" ? priorCrystal : undefined,
1284
+ });
1285
+ if (
1286
+ priorCrystal &&
1287
+ priorSource &&
1288
+ priorSource.revision === snapshot.revision &&
1289
+ priorSource.digest === snapshot.digest
1290
+ ) {
1291
+ if (
1292
+ priorSource.start !== snapshot.start ||
1293
+ priorSource.end !== snapshot.end ||
1294
+ JSON.stringify(priorSource.messages) !== JSON.stringify(snapshot.messages)
1295
+ )
1296
+ throw new DeepInterviewCommandError(2, "stored Crystal source evidence does not match the replay snapshot");
1297
+ if (priorCrystal.lifecycle === "ready") {
1298
+ if (!existingSpecPath || !existingSpecHash)
1299
+ throw new DeepInterviewCommandError(2, "stored Crystal publication identity is incomplete");
1300
+ const requestedSlug = flagValue(args, "--slug")!.trim();
1301
+ const expectedName = path.basename(existingSpecPath);
1302
+ const match = /^deep-interview-(.+)-v[0-9]+\.md$/.exec(expectedName);
1303
+ if (!match || match[1] !== requestedSlug)
1304
+ throw new DeepInterviewCommandError(2, "conversation snapshot was already crystallized under another slug");
1305
+ const mutationId = `crystal:${sessionId}:${priorCrystal.spec_version}:${createHash("sha256")
1306
+ .update(`${requestedSlug}\0${path.resolve(existingSpecPath)}`)
1307
+ .digest("hex")}`;
1308
+ const pending = await verifyPriorPendingCrystalJournals({
1309
+ cwd,
1310
+ sessionId,
1311
+ indexPath,
1312
+ statePath,
1313
+ existing,
1314
+ currentMutationId: mutationId,
1315
+ });
1316
+ if (pending) {
1317
+ if (
1318
+ pending.paths.some(
1319
+ (value, index) =>
1320
+ path.resolve(value) !== path.resolve([existingSpecPath, indexPath, statePath][index]!),
1321
+ )
1322
+ )
1323
+ throw new DeepInterviewCommandError(2, "Crystal promotion journal identity mismatch");
1324
+ await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
1325
+ }
1326
+ await syncDeepInterviewHud({
1327
+ cwd,
1328
+ sessionId,
1329
+ payload: existing,
1330
+ phase: typeof existing.current_phase === "string" ? existing.current_phase : "handoff",
1331
+ specStatus: "persisted",
1332
+ });
1333
+ await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-runtime", path: statePath });
1334
+ const summary = {
1335
+ skill: "deep-interview",
1336
+ mode: "crystallize",
1337
+ crystal: priorCrystal,
1338
+ spec_path: existingSpecPath,
1339
+ state_path: statePath,
1340
+ };
1341
+ return {
1342
+ status: 0,
1343
+ stdout: hasFlag(args, "--json")
1344
+ ? `${JSON.stringify(summary)}\n`
1345
+ : `Crystal v${priorCrystal.spec_version} created\nReadiness: ${priorCrystal.lifecycle}\nExecution approval: none\nspec_path=${existingSpecPath}\n`,
1346
+ };
1347
+ }
1348
+ throw new DeepInterviewCommandError(2, "conversation snapshot was already crystallized");
1349
+ }
1350
+ if (priorSource && snapshot.revision <= priorSource.revision)
1351
+ throw new DeepInterviewCommandError(2, "conversation snapshot is stale against the stored Crystal");
1352
+ if (!priorCrystal && (liveSnapshot.messages.length > CRYSTAL_MAX_MESSAGES || snapshot.start !== 0))
1353
+ throw new DeepInterviewCommandError(
1354
+ 2,
1355
+ `first Crystal must cover the full authenticated transcript from index 0 (maximum ${CRYSTAL_MAX_MESSAGES} messages); continue the ordinary interview flow instead of dropping earlier messages`,
1356
+ );
1357
+ const payload = { ...input, prior: storedPrior };
1358
+ const crystal = crystallizeDeepInterview(payload);
1359
+ const slug = flagValue(args, "--slug")!.trim();
1360
+ assertSafePathComponent(slug, "slug");
1361
+ const specPath =
1362
+ crystal.lifecycle === "ready"
1363
+ ? path.join(sessionSpecsDir(cwd, sessionId), `deep-interview-${slug}-v${crystal.spec_version}.md`)
1364
+ : undefined;
1365
+ const now = new Date().toISOString();
1366
+ const specContent = specPath ? crystalMarkdown(crystal) : undefined;
1367
+ if (specContent && [...specContent].length > MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH)
1368
+ throw new DeepInterviewCommandError(2, "crystallized specification exceeds the structured response limit");
1369
+ const specHash = specContent ? createHash("sha256").update(specContent).digest("hex") : undefined;
1370
+ const mutationId =
1371
+ specPath && specHash
1372
+ ? `crystal:${sessionId}:${crystal.spec_version}:${createHash("sha256").update(`${slug}\0${specPath}`).digest("hex")}`
1373
+ : undefined;
1374
+ const currentJournal = await verifyPriorPendingCrystalJournals({
1375
+ cwd,
1376
+ sessionId,
1377
+ indexPath,
1378
+ statePath,
1379
+ existing,
1380
+ currentMutationId: mutationId,
1381
+ });
1382
+ const indexCatalog = await readCrystalIndexCatalog(cwd, sessionId, indexPath);
1383
+ const publicationSnapshot = await authoritativeConversationSnapshot(cwd, sessionId);
1384
+ if (
1385
+ publicationSnapshot.revision !== liveSnapshot.revision ||
1386
+ JSON.stringify(publicationSnapshot.messages) !== JSON.stringify(liveSnapshot.messages)
1387
+ )
1388
+ throw new DeepInterviewCommandError(2, "conversation snapshot changed before Crystal publication");
1389
+ if (specPath && specContent && mutationId) {
1390
+ await beginWorkflowTransactionJournal({
1391
+ cwd,
1392
+ sessionId,
1393
+ mutationId: mutationId!,
1394
+ paths: [specPath, indexPath, statePath],
1395
+ });
1396
+ const journal = currentJournal ?? (await readCrystalJournal(cwd, sessionId, mutationId, indexPath, statePath));
1397
+ if (journal && journal.status !== "pending")
1398
+ throw new DeepInterviewCommandError(2, "Crystal promotion journal is already committed");
1399
+ if (journal && path.resolve(journal.paths[0]!) !== path.resolve(specPath))
1400
+ throw new DeepInterviewCommandError(2, "Crystal promotion journal identity mismatch");
1401
+ if (journal?.artifact_sha256 !== undefined && journal.artifact_sha256 !== specHash)
1402
+ throw new DeepInterviewCommandError(2, "Crystal promotion journal hash mismatch");
1403
+ let artifactReady = false;
1404
+ const existingArtifact = await readBoundedFileBytes(specPath, CRYSTAL_MAX_ARTIFACT_BYTES, "Crystal artifact", {
1405
+ allowMissing: true,
1406
+ });
1407
+ if (existingArtifact) {
1408
+ if (createHash("sha256").update(existingArtifact).digest("hex") !== specHash)
1409
+ throw new DeepInterviewCommandError(2, "existing Crystal artifact conflicts with the requested promotion");
1410
+ artifactReady = true;
1411
+ } else if (journal?.steps.includes("artifact")) {
1412
+ throw new DeepInterviewCommandError(2, "pending Crystal artifact verification failed");
1413
+ }
1414
+ if (!artifactReady)
1415
+ await writeArtifact(specPath, specContent, {
1416
+ cwd,
1417
+ audit: { category: "artifact", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
1418
+ });
1419
+ if (journal || mutationId)
1420
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1421
+ steps: ["artifact"],
1422
+ artifact_sha256: specHash,
1423
+ } as Partial<WorkflowTransactionJournal>);
1424
+ const indexAlreadyContains = indexRowsForPath(indexCatalog, specPath).some(row => row.sha256 === specHash);
1425
+ if (journal?.steps.includes("index") && !indexAlreadyContains)
1426
+ throw new DeepInterviewCommandError(2, "pending Crystal index verification failed");
1427
+ if (!indexAlreadyContains)
1428
+ await appendCrystalIndexRow(
1429
+ cwd,
1430
+ sessionId,
1431
+ indexPath,
1432
+ { slug, stage: "final", path: specPath!, created_at: now, sha256: specHash! },
1433
+ existingSpecPath ? [existingSpecPath] : [],
1434
+ );
1435
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1436
+ steps: ["artifact", "index"],
1437
+ artifact_sha256: specHash,
1438
+ } as Partial<WorkflowTransactionJournal>);
1439
+ }
1440
+ const state: Record<string, unknown> = { ...existingInner, crystal, execution_approval: "not-approved" };
1441
+ delete state.execution_approval_receipt;
1442
+ const envelope = {
1443
+ ...existing,
1444
+ active: true,
1445
+ current_phase: crystal.lifecycle === "ready" ? "handoff" : "interviewing",
1446
+ skill: "deep-interview",
1447
+ version: WORKFLOW_STATE_VERSION,
1448
+ session_id: sessionId,
1449
+ ...(specPath && specContent
1450
+ ? {
1451
+ spec_slug: slug,
1452
+ spec_path: specPath,
1453
+ spec_stage: "final",
1454
+ spec_sha256: createHash("sha256").update(specContent).digest("hex"),
1455
+ spec_persisted_at: now,
1456
+ }
1457
+ : {}),
1458
+ state,
1459
+ updated_at: now,
1460
+ };
1461
+ if (!specPath) {
1462
+ delete envelope.spec_slug;
1463
+ delete envelope.spec_path;
1464
+ delete envelope.spec_stage;
1465
+ delete envelope.spec_sha256;
1466
+ delete envelope.spec_persisted_at;
1467
+ }
1468
+ await writeWorkflowEnvelopeAtomic(statePath, envelope, {
1469
+ lockHeld: true,
1470
+ cwd,
1471
+ receipt: {
1472
+ cwd,
1473
+ skill: "deep-interview",
1474
+ owner: "gjc-runtime",
1475
+ command: "gjc deep-interview crystallize",
1476
+ sessionId,
1477
+ nowIso: now,
1478
+ },
1479
+ audit: { category: "state", verb: "write", owner: "gjc-runtime", skill: "deep-interview", sessionId },
1480
+ });
1481
+ if (specPath && specContent && mutationId)
1482
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1483
+ steps: ["artifact", "index", "state"],
1484
+ artifact_sha256: specHash,
1485
+ } as Partial<WorkflowTransactionJournal>);
1486
+ await writeSessionActivityMarker(cwd, sessionId, { writer: "deep-interview-runtime", path: statePath });
1487
+ await syncDeepInterviewHud({
1488
+ cwd,
1489
+ sessionId,
1490
+ payload: envelope,
1491
+ phase: envelope.current_phase,
1492
+ specStatus: specPath ? "persisted" : "not_persisted",
1493
+ });
1494
+ if (specPath && specContent && mutationId) await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
1495
+ const summary = {
1496
+ skill: "deep-interview",
1497
+ mode: "crystallize",
1498
+ crystal,
1499
+ ...(specPath ? { spec_path: specPath } : {}),
1500
+ state_path: statePath,
1501
+ };
1502
+ return {
1503
+ status: 0,
1504
+ stdout: hasFlag(args, "--json")
1505
+ ? `${JSON.stringify(summary)}\n`
1506
+ : `Crystal v${crystal.spec_version} created\nReadiness: ${crystal.lifecycle}\nExecution approval: none\nspec_path=${specPath}\n`,
1507
+ };
1508
+ }
1509
+
136
1510
  function defaultSpecSlug(now: Date = new Date()): string {
137
1511
  const yyyy = now.getUTCFullYear().toString().padStart(4, "0");
138
1512
  const mm = (now.getUTCMonth() + 1).toString().padStart(2, "0");
@@ -612,6 +1986,17 @@ function resolveLockedIntentReview(existing: unknown, content: string): DeepInte
612
1986
  export async function persistDeepInterviewSpec(
613
1987
  cwd: string,
614
1988
  resolved: ResolvedDeepInterviewSpecWriteArgs,
1989
+ ): Promise<PersistedDeepInterviewSpec> {
1990
+ return withWorkflowStateLock(
1991
+ deepInterviewStatePath(cwd, resolved.sessionId),
1992
+ () => persistDeepInterviewSpecUnlocked(cwd, resolved),
1993
+ { cwd },
1994
+ );
1995
+ }
1996
+
1997
+ async function persistDeepInterviewSpecUnlocked(
1998
+ cwd: string,
1999
+ resolved: ResolvedDeepInterviewSpecWriteArgs,
615
2000
  ): Promise<PersistedDeepInterviewSpec> {
616
2001
  assertDeepInterviewInputWithinLimit(
617
2002
  resolved.spec,
@@ -626,7 +2011,15 @@ export async function persistDeepInterviewSpec(
626
2011
  `existing deep-interview state is corrupt or tampered (${existingRead.error}); use --force to overwrite ${statePath}`,
627
2012
  );
628
2013
  }
2014
+ if (existingRead.kind === "valid") assertNoFutureDeepInterviewEnvelope(existingRead.value, "spec publication state");
629
2015
  const existing = existingRead.kind === "valid" ? existingRead.value : {};
2016
+ const existingInner =
2017
+ existing.state && typeof existing.state === "object" && !Array.isArray(existing.state)
2018
+ ? (existing.state as Record<string, unknown>)
2019
+ : undefined;
2020
+ if (existingInner?.crystal !== undefined) {
2021
+ throw new DeepInterviewCommandError(2, "direct spec write is unavailable while canonical Crystal state exists");
2022
+ }
630
2023
 
631
2024
  const content = resolved.spec.endsWith("\n") ? resolved.spec : `${resolved.spec}\n`;
632
2025
  const intentReview = resolveLockedIntentReview(existing, content);
@@ -644,19 +2037,11 @@ export async function persistDeepInterviewSpec(
644
2037
 
645
2038
  const sha256 = createHash("sha256").update(content).digest("hex");
646
2039
  const createdAt = new Date().toISOString();
647
- await appendJsonl(
2040
+ await appendCrystalIndexRow(
2041
+ cwd,
2042
+ resolved.sessionId,
648
2043
  path.join(sessionSpecsDir(cwd, resolved.sessionId), "deep-interview-index.jsonl"),
649
2044
  { slug: resolved.slug, stage: resolved.stage, path: specPath, created_at: createdAt, sha256 },
650
- {
651
- cwd,
652
- audit: {
653
- category: "ledger",
654
- verb: "append",
655
- owner: "gjc-runtime",
656
- skill: "deep-interview",
657
- sessionId: resolved.sessionId,
658
- },
659
- },
660
2045
  );
661
2046
 
662
2047
  const payload = normalizeDeepInterviewEnvelope({
@@ -679,6 +2064,7 @@ export async function persistDeepInterviewSpec(
679
2064
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
680
2065
  await writeWorkflowEnvelopeAtomic(statePath, payload, {
681
2066
  cwd,
2067
+ lockHeld: true,
682
2068
  receipt: {
683
2069
  cwd,
684
2070
  skill: "deep-interview",
@@ -716,8 +2102,20 @@ export async function persistDeepInterviewSpec(
716
2102
  }
717
2103
 
718
2104
  async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
2105
+ const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
2106
+ return withWorkflowStateLock(statePath, () => seedDeepInterviewStateUnlocked(cwd, resolved), { cwd });
2107
+ }
2108
+
2109
+ async function seedDeepInterviewStateUnlocked(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
719
2110
  const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
720
2111
  assertDeepInterviewInputWithinLimit(resolved.idea, MAX_INITIAL_CONTEXT_LENGTH, "initial_idea");
2112
+ const existingRead = await readExistingStateForMutation(statePath);
2113
+ if (existingRead.kind === "valid") {
2114
+ assertNoFutureDeepInterviewEnvelope(existingRead.value, "seed state");
2115
+ const existingInner = isRecord(existingRead.value.state) ? existingRead.value.state : undefined;
2116
+ if (existingInner?.crystal !== undefined)
2117
+ throw new DeepInterviewCommandError(2, "deep-interview seed cannot replace canonical Crystal state");
2118
+ }
721
2119
  const now = new Date().toISOString();
722
2120
  const payload: Record<string, unknown> = {
723
2121
  active: true,
@@ -756,6 +2154,7 @@ async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepIntervi
756
2154
  if (resolved.sessionId) payload.session_id = resolved.sessionId;
757
2155
  await writeWorkflowEnvelopeAtomic(statePath, payload, {
758
2156
  cwd,
2157
+ lockHeld: true,
759
2158
  receipt: {
760
2159
  cwd,
761
2160
  skill: "deep-interview",
@@ -877,7 +2276,10 @@ export async function runNativeDeepInterviewCommand(
877
2276
  ): Promise<DeepInterviewCommandResult> {
878
2277
  try {
879
2278
  const [firstArg, ...restArgs] = args;
2279
+ if (firstArg === "approve-execution")
2280
+ return await runNativeStateCommand(["approve-execution", "--mode", "deep-interview", ...restArgs], cwd);
880
2281
  if (isDeepInterviewStageVerb(firstArg)) return await runDeepInterviewStageCommand(firstArg, restArgs, cwd);
2282
+ if (hasFlag(args, "--crystallize")) return await handleCrystallize(args, cwd);
881
2283
  if (isDeepInterviewSpecWriteInvocation(args)) return await handleSpecWrite(args, cwd, options.agentDir);
882
2284
  const resolved = await resolveDeepInterviewArgs(args, cwd, options.agentDir);
883
2285
  if (!resolved.idea) {