@gajae-code/coding-agent 0.18.6 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/CHANGELOG.md +318 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +6 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/settings.d.ts +4 -0
  15. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  16. package/dist/types/cursor-delete-path.d.ts +1 -0
  17. package/dist/types/cursor-mcp-name.d.ts +8 -0
  18. package/dist/types/customization/inventory.d.ts +2 -0
  19. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  22. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  23. package/dist/types/discovery/helpers.d.ts +8 -3
  24. package/dist/types/export/html/css-color.d.ts +2 -0
  25. package/dist/types/export/html/template.generated.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  27. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  28. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  29. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  30. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  31. package/dist/types/extensibility/skill-management.d.ts +3 -1
  32. package/dist/types/extensibility/skills.d.ts +2 -0
  33. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  34. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  35. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  36. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  37. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  38. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  39. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  40. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  42. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  43. package/dist/types/lsp/workspace-path.d.ts +1 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  45. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  46. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  47. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  48. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  49. package/dist/types/modes/interactive-mode.d.ts +1 -0
  50. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  51. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  52. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  53. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  54. package/dist/types/progress/progress-contract.d.ts +154 -0
  55. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  56. package/dist/types/progress/project-progress.d.ts +141 -0
  57. package/dist/types/progress/render-progress.d.ts +28 -0
  58. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  59. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  60. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  61. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  62. package/dist/types/runtime-mcp/types.d.ts +6 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  64. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  65. package/dist/types/sdk/broker/broker.d.ts +18 -1
  66. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  67. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  68. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  69. package/dist/types/sdk/bus/index.d.ts +12 -1
  70. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  71. package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
  72. package/dist/types/sdk/client/client.d.ts +2 -0
  73. package/dist/types/sdk/host/host.d.ts +6 -0
  74. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  75. package/dist/types/sdk/index.d.ts +1 -0
  76. package/dist/types/sdk/router/session-router.d.ts +9 -0
  77. package/dist/types/sdk/session-list.d.ts +1 -1
  78. package/dist/types/sdk/session.d.ts +8 -0
  79. package/dist/types/session/agent-session.d.ts +38 -9
  80. package/dist/types/session/artifacts.d.ts +2 -2
  81. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  82. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  83. package/dist/types/session/internal/managed-session-storage.d.ts +36 -1
  84. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  85. package/dist/types/session/internal/task-artifact-owner-access.d.ts +19 -0
  86. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
  87. package/dist/types/session/session-manager.d.ts +31 -5
  88. package/dist/types/session/session-retirement.d.ts +71 -0
  89. package/dist/types/session/session-storage.d.ts +62 -48
  90. package/dist/types/session/session-transcript-header.d.ts +2 -0
  91. package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
  92. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  93. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  94. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  95. package/dist/types/setup/credential-import.d.ts +6 -4
  96. package/dist/types/skill-state/active-state.d.ts +38 -0
  97. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  98. package/dist/types/task/executor.d.ts +3 -0
  99. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  100. package/dist/types/tools/index.d.ts +3 -0
  101. package/dist/types/tools/python.d.ts +9 -1
  102. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  103. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  104. package/dist/types/tools/todo-write.d.ts +6 -0
  105. package/dist/types/utils/tools-checksum.d.ts +7 -0
  106. package/package.json +7 -7
  107. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  108. package/scripts/resident-memory-bench.ts +174 -20
  109. package/scripts/verify-managed-empty-stop.ts +28 -0
  110. package/src/async/job-manager.ts +228 -122
  111. package/src/capability/index.ts +18 -0
  112. package/src/capability/mcp.ts +2 -0
  113. package/src/capability/skill.ts +2 -0
  114. package/src/capability/types.ts +6 -0
  115. package/src/cli/auth-broker-cli.ts +1 -1
  116. package/src/cli/config-cli.ts +8 -18
  117. package/src/cli/customize-doctor.ts +15 -2
  118. package/src/cli/public-command-errors.ts +21 -1
  119. package/src/cli/setup-cli.ts +4 -4
  120. package/src/cli/skills-cli.ts +5 -0
  121. package/src/cli-main.ts +7 -8
  122. package/src/commands/deep-interview.ts +47 -8
  123. package/src/commands/sdk.ts +140 -29
  124. package/src/config/autorouting-tier-map.ts +117 -7
  125. package/src/config/file-lock.ts +307 -68
  126. package/src/config/mcp-schema.json +4 -0
  127. package/src/config/model-bindings-applier.ts +20 -0
  128. package/src/config/model-profile-activation.ts +240 -46
  129. package/src/config/model-registry.ts +47 -153
  130. package/src/config/settings.ts +44 -4
  131. package/src/config/skill-settings-defaults.ts +7 -0
  132. package/src/coordinator-mcp/policy.ts +3 -2
  133. package/src/cursor-delete-path.ts +10 -0
  134. package/src/cursor-mcp-name.ts +15 -0
  135. package/src/cursor.ts +13 -2
  136. package/src/customization/inventory.ts +3 -0
  137. package/src/debug/report-bundle-parent.ts +22 -0
  138. package/src/debug/report-bundle.ts +3 -0
  139. package/src/debug/system-info.ts +13 -1
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  141. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  142. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  143. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  144. package/src/defaults/gjc-defaults.test.ts +18 -1
  145. package/src/defaults/gjc-defaults.ts +3 -0
  146. package/src/defaults/gjc-skills.generated.ts +58 -36
  147. package/src/discovery/agents.ts +6 -1
  148. package/src/discovery/builtin.ts +10 -0
  149. package/src/discovery/helpers.ts +168 -23
  150. package/src/discovery/mcp-json.ts +6 -0
  151. package/src/discovery/ssh.ts +9 -4
  152. package/src/discovery/substitute-plugin-root.ts +3 -1
  153. package/src/edit/file-read-cache.ts +1 -1
  154. package/src/eval/py/executor.ts +380 -120
  155. package/src/export/html/css-color.ts +9 -0
  156. package/src/export/html/index.ts +5 -4
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/extensibility/extensions/runner.ts +4 -0
  159. package/src/extensibility/extensions/types.ts +7 -0
  160. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  161. package/src/extensibility/plugins/manager.ts +3 -2
  162. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  163. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  164. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  165. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  166. package/src/extensibility/skill-management.ts +6 -2
  167. package/src/extensibility/skills.ts +6 -1
  168. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  169. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  170. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  171. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  172. package/src/gjc-runtime/gc-runtime.ts +206 -8
  173. package/src/gjc-runtime/launch-worktree.ts +14 -4
  174. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  175. package/src/gjc-runtime/state-argv.ts +2 -0
  176. package/src/gjc-runtime/state-migrations.ts +91 -29
  177. package/src/gjc-runtime/state-runtime.ts +3716 -135
  178. package/src/gjc-runtime/state-writer.ts +83 -8
  179. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  180. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  181. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  182. package/src/harness-control-plane/operate.ts +12 -4
  183. package/src/harness-control-plane/owner.ts +5 -1
  184. package/src/hashline/parser.ts +48 -9
  185. package/src/hashline/recovery.ts +13 -4
  186. package/src/hindsight/config.ts +9 -3
  187. package/src/internal-urls/docs-index.generated.ts +19 -18
  188. package/src/internal-urls/skill-path.ts +19 -0
  189. package/src/internal-urls/skill-protocol.ts +2 -0
  190. package/src/lsp/edits.ts +6 -0
  191. package/src/lsp/workspace-path.ts +51 -0
  192. package/src/main.ts +195 -31
  193. package/src/modes/acp/acp-agent.ts +163 -7
  194. package/src/modes/components/custom-editor.ts +14 -0
  195. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  196. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  197. package/src/modes/components/extensions/state-manager.ts +15 -5
  198. package/src/modes/components/model-selector.ts +1 -4
  199. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  200. package/src/modes/components/status-line/segments.ts +4 -0
  201. package/src/modes/controllers/event-controller.ts +11 -4
  202. package/src/modes/controllers/input-controller.ts +74 -29
  203. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  204. package/src/modes/controllers/selector-controller.ts +28 -15
  205. package/src/modes/interactive-mode.ts +4 -0
  206. package/src/modes/runtime-init.ts +4 -0
  207. package/src/modes/utils/abort-message.ts +3 -1
  208. package/src/modes/utils/interrupt-ownership.ts +12 -0
  209. package/src/modes/utils/keybinding-matchers.ts +4 -0
  210. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  211. package/src/progress/collect-project-progress.ts +153 -0
  212. package/src/progress/progress-contract.ts +172 -0
  213. package/src/progress/progress-snapshot.ts +140 -0
  214. package/src/progress/project-progress.ts +446 -0
  215. package/src/progress/render-progress.ts +209 -0
  216. package/src/prompts/system/master-mode.md +1 -0
  217. package/src/prompts/tools/skill-discovery.md +1 -1
  218. package/src/runtime-mcp/client.ts +13 -4
  219. package/src/runtime-mcp/config-writer.ts +10 -6
  220. package/src/runtime-mcp/config.ts +1 -0
  221. package/src/runtime-mcp/elicitation.ts +142 -0
  222. package/src/runtime-mcp/oauth-flow.ts +5 -0
  223. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  224. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  225. package/src/runtime-mcp/smithery-registry.ts +22 -3
  226. package/src/runtime-mcp/transports/http.ts +2 -2
  227. package/src/runtime-mcp/types.ts +6 -0
  228. package/src/sdk/acp/adapter.ts +93 -8
  229. package/src/sdk/broker/broker-exit.ts +52 -23
  230. package/src/sdk/broker/broker.ts +83 -7
  231. package/src/sdk/broker/ensure.ts +2 -0
  232. package/src/sdk/broker/lifecycle.ts +820 -73
  233. package/src/sdk/broker/session-index.ts +191 -81
  234. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  235. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  236. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  238. package/src/sdk/bus/index.ts +241 -46
  239. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  240. package/src/sdk/bus/telegram-daemon.ts +4 -2
  241. package/src/sdk/cli/session-cli.ts +17 -3
  242. package/src/sdk/client/client.ts +28 -8
  243. package/src/sdk/host/host.ts +6 -0
  244. package/src/sdk/host/query/handlers.ts +9 -1
  245. package/src/sdk/host/session-runtime.test.ts +2805 -411
  246. package/src/sdk/host/session-runtime.ts +428 -111
  247. package/src/sdk/host/surface-policy.ts +1 -0
  248. package/src/sdk/index.ts +8 -0
  249. package/src/sdk/lifecycle-session.ts +3 -1
  250. package/src/sdk/mcp/server.ts +15 -3
  251. package/src/sdk/prompt-deadline-manager.ts +15 -0
  252. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  253. package/src/sdk/protocol/operation-registry.ts +9 -2
  254. package/src/sdk/router/session-router.ts +94 -14
  255. package/src/sdk/session-list.ts +1 -0
  256. package/src/sdk/session.ts +81 -38
  257. package/src/session/agent-session.ts +674 -224
  258. package/src/session/artifacts.ts +174 -70
  259. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  260. package/src/session/internal/managed-session-scope.ts +3457 -462
  261. package/src/session/internal/managed-session-storage.ts +482 -61
  262. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  263. package/src/session/internal/task-artifact-owner-access.ts +245 -0
  264. package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
  265. package/src/session/session-manager.ts +939 -133
  266. package/src/session/session-retirement.ts +714 -0
  267. package/src/session/session-storage.ts +418 -174
  268. package/src/session/session-transcript-header.ts +17 -0
  269. package/src/session/task-artifact-owner-codec.ts +641 -0
  270. package/src/session/task-artifact-owner-retirement.ts +477 -0
  271. package/src/session/task-artifact-owner.ts +289 -0
  272. package/src/setup/credential-auto-import.ts +12 -3
  273. package/src/setup/credential-import.ts +176 -5
  274. package/src/setup/paseo/announce.ts +6 -1
  275. package/src/skill-state/active-state.ts +132 -2
  276. package/src/slash-commands/builtin-registry.ts +39 -9
  277. package/src/slash-commands/helpers/progress-report.ts +23 -0
  278. package/src/stt/setup.ts +3 -5
  279. package/src/task/commands.ts +3 -1
  280. package/src/task/executor.ts +4 -0
  281. package/src/task/index.ts +6 -0
  282. package/src/tools/ask.ts +205 -6
  283. package/src/tools/ast-edit.ts +4 -0
  284. package/src/tools/bash-allowed-prefixes.ts +15 -0
  285. package/src/tools/bash-skill-urls.ts +56 -3
  286. package/src/tools/bash.ts +48 -79
  287. package/src/tools/descriptors.ts +5 -1
  288. package/src/tools/eval.ts +56 -36
  289. package/src/tools/image-gen.ts +1 -1
  290. package/src/tools/index.ts +3 -0
  291. package/src/tools/python.ts +187 -66
  292. package/src/tools/recipe/index.ts +9 -0
  293. package/src/tools/recipe/recipe-guard.ts +19 -0
  294. package/src/tools/restricted-cwd.ts +10 -0
  295. package/src/tools/skill-discovery.ts +5 -0
  296. package/src/tools/skill.ts +28 -1
  297. package/src/tools/todo-write.ts +10 -1
  298. package/src/tools/tool-catalog.generated.ts +1 -1
  299. package/src/tools/write.ts +1 -0
  300. package/src/utils/tools-checksum.ts +34 -0
  301. package/src/utils/tools-manager.ts +24 -6
  302. package/src/web/search/providers/codex.ts +3 -2
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  // Subpath import keeps this module native-free for the gjc-state-gates shards:
@@ -32,7 +33,9 @@ import {
32
33
  } from "../skill-state/workflow-state-contract";
33
34
  import { renderCliWriteReceipt } from "./cli-write-receipt";
34
35
  import { applyAmbiguityFloorToEnvelope } from "./deep-interview-ambiguity";
36
+ import { crystalMarkdown, crystalSnapshotDigest, type DeepInterviewCrystal } from "./deep-interview-crystallize";
35
37
  import {
38
+ answerHash,
36
39
  assertDeepInterviewEnvelopeInputLimits,
37
40
  assertDeepInterviewInputWithinLimit,
38
41
  assertDeepInterviewIntentManifest,
@@ -43,7 +46,14 @@ import {
43
46
  mergeDeepInterviewEnvelope,
44
47
  normalizeDeepInterviewEnvelope,
45
48
  } from "./deep-interview-state";
46
- import { activeSnapshotPath, auditPath, modeStatePath, sessionStateDir } from "./session-layout";
49
+ import {
50
+ activeSnapshotPath,
51
+ auditPath,
52
+ modeStatePath,
53
+ sessionPlansDir,
54
+ sessionSpecsDir,
55
+ sessionStateDir,
56
+ } from "./session-layout";
47
57
  import {
48
58
  resolveGjcSessionForRead,
49
59
  resolveGjcSessionForWrite,
@@ -52,7 +62,7 @@ import {
52
62
  } from "./session-resolution";
53
63
  import { classifyStateArgv, firstStateFlagValue, type StateAction, type StateArgvClassification } from "./state-argv";
54
64
  import { renderStateGraph, type StateGraphFormat } from "./state-graph";
55
- import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
65
+ import { assertNotFutureWorkflowState, migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
56
66
  import {
57
67
  buildStateStatusSummary,
58
68
  compactProjectStateJson,
@@ -73,11 +83,14 @@ import {
73
83
  type GenericHardPruneTarget,
74
84
  hardPrune,
75
85
  readExistingStateForMutation,
86
+ readWorkflowTransactionJournal,
76
87
  type StateWriterAuditContext,
77
88
  softDelete,
78
89
  updateWorkflowTransactionJournal,
79
90
  type WorkflowEnvelopeIntegrityMismatch,
91
+ type WorkflowTransactionJournal,
80
92
  withWorkflowStateLock,
93
+ writeArtifact,
81
94
  writeGuardedWorkflowEnvelopeAtomic,
82
95
  } from "./state-writer";
83
96
  import { assertSafePathComponent, CommandError, flagValue, hasFlag, isPlainObject } from "./workflow-cli-common";
@@ -123,6 +136,195 @@ function assertKnownMode(mode: string): asserts mode is CanonicalGjcWorkflowSkil
123
136
  }
124
137
  }
125
138
 
139
+ function assertNoFutureWorkflowEnvelope(value: unknown, skill: CanonicalGjcWorkflowSkill, surface: string): void {
140
+ if (!isPlainObject(value)) return;
141
+ try {
142
+ assertNotFutureWorkflowState(value, skill, surface);
143
+ } catch (error) {
144
+ throw new StateCommandError(2, error instanceof Error ? error.message : String(error));
145
+ }
146
+ }
147
+
148
+ function assertDeepInterviewExecutionApprovalUnchanged(
149
+ existingEnvelope: Record<string, unknown>,
150
+ mergedEnvelope: Record<string, unknown>,
151
+ surface: string,
152
+ ): void {
153
+ const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
154
+ const mergedInner = isPlainObject(mergedEnvelope.state) ? mergedEnvelope.state : {};
155
+ if (mergedInner.execution_approval !== existingInner.execution_approval)
156
+ throw new StateCommandError(2, `crystallized execution approval is immutable through ${surface}`);
157
+ if (
158
+ JSON.stringify(mergedInner.execution_approval_receipt) !==
159
+ JSON.stringify(existingInner.execution_approval_receipt)
160
+ )
161
+ throw new StateCommandError(2, `crystallized execution approval provenance is immutable through ${surface}`);
162
+ }
163
+
164
+ function assertApprovedDeepInterviewLifecycleUnchanged(
165
+ existingEnvelope: Record<string, unknown>,
166
+ mergedEnvelope: Record<string, unknown>,
167
+ surface: string,
168
+ ): void {
169
+ const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
170
+ if (existingInner.execution_approval !== "approved") return;
171
+ if (
172
+ mergedEnvelope.active !== existingEnvelope.active ||
173
+ mergedEnvelope.current_phase !== existingEnvelope.current_phase
174
+ )
175
+ throw new StateCommandError(2, `approved Crystal lifecycle is immutable through ${surface}`);
176
+ }
177
+
178
+ /**
179
+ * Runtime-owned deep-interview evidence is monotonic once a Crystal exists.
180
+ * Keep this guard shared by the generic CLI write and the runtime reconciliation
181
+ * writer; having two subtly different fences is an authorization bypass.
182
+ */
183
+ function assertDeepInterviewEvidenceUnchanged(
184
+ existingEnvelope: Record<string, unknown>,
185
+ mergedEnvelope: Record<string, unknown>,
186
+ surface: string,
187
+ ): void {
188
+ const existingInner = isPlainObject(existingEnvelope.state) ? existingEnvelope.state : {};
189
+ const mergedInner = isPlainObject(mergedEnvelope.state) ? mergedEnvelope.state : {};
190
+
191
+ assertDeepInterviewExecutionApprovalUnchanged(existingEnvelope, mergedEnvelope, surface);
192
+
193
+ if (existingEnvelope.active === false && mergedEnvelope.active !== false)
194
+ throw new StateCommandError(
195
+ 2,
196
+ surface === "generic state write"
197
+ ? "generic state write cannot reactivate inactive deep-interview state"
198
+ : `inactive deep-interview state cannot be reactivated through ${surface}`,
199
+ );
200
+ if (existingInner.crystal === undefined && mergedInner.crystal !== undefined)
201
+ throw new StateCommandError(
202
+ 2,
203
+ surface === "generic state write"
204
+ ? "generic state write cannot introduce canonical Crystal state"
205
+ : `canonical Crystal is immutable through ${surface}`,
206
+ );
207
+ if (existingInner.intent_contract === undefined && mergedInner.intent_contract !== undefined)
208
+ throw new StateCommandError(
209
+ 2,
210
+ surface === "generic state write"
211
+ ? "generic state write cannot introduce a Round 0 intent contract"
212
+ : `canonical Round 0 intent contract is immutable through ${surface}`,
213
+ );
214
+ if (
215
+ existingInner.intent_contract !== undefined &&
216
+ JSON.stringify(existingInner.intent_contract) !== JSON.stringify(mergedInner.intent_contract)
217
+ )
218
+ throw new StateCommandError(2, `canonical Round 0 intent contract is immutable through ${surface}`);
219
+
220
+ if (
221
+ existingInner.execution_approval === "approved" &&
222
+ JSON.stringify(existingInner.intent_review) !== JSON.stringify(mergedInner.intent_review)
223
+ )
224
+ throw new StateCommandError(2, `approved intent review is immutable through ${surface}`);
225
+
226
+ if (existingInner.crystal !== undefined) {
227
+ if (JSON.stringify(existingInner.crystal) !== JSON.stringify(mergedInner.crystal))
228
+ throw new StateCommandError(
229
+ 2,
230
+ surface === "generic state write"
231
+ ? "canonical crystallized state cannot be replaced or deleted through generic state write"
232
+ : `canonical crystallized state cannot be replaced or deleted through ${surface}`,
233
+ );
234
+ for (const field of ["spec_path", "spec_sha256", "spec_slug", "spec_stage"] as const)
235
+ if (mergedEnvelope[field] !== existingEnvelope[field])
236
+ throw new StateCommandError(2, `crystallized ${field} is immutable through ${surface}`);
237
+ if (isPlainObject(existingInner.crystal) && existingInner.crystal.lifecycle === "ready") {
238
+ for (const field of ["rounds", "established_facts", "intent_review", "current_ambiguity"] as const)
239
+ if (JSON.stringify(mergedInner[field]) !== JSON.stringify(existingInner[field]))
240
+ throw new StateCommandError(2, `ready Crystal evidence is immutable through ${surface}`);
241
+ }
242
+ }
243
+ }
244
+
245
+ function assertHandoffLineageUnchanged(
246
+ existingEnvelope: Record<string, unknown>,
247
+ mergedEnvelope: Record<string, unknown>,
248
+ surface: string,
249
+ ): void {
250
+ for (const field of ["handoff_from", "handoff_to", "handoff_at", "upstream_handoff_at"] as const) {
251
+ const existing = typeof existingEnvelope[field] === "string" ? existingEnvelope[field].trim() : "";
252
+ const merged = typeof mergedEnvelope[field] === "string" ? mergedEnvelope[field].trim() : "";
253
+ if (!existing && merged && surface === "generic state write")
254
+ throw new StateCommandError(2, `handoff lineage cannot be introduced through ${surface}`);
255
+ if (existing && mergedEnvelope[field] !== existing)
256
+ throw new StateCommandError(2, `handoff lineage is immutable through ${surface}`);
257
+ }
258
+ }
259
+
260
+ function persistedWorkflowReceipt(
261
+ value: unknown,
262
+ expectedSkill: CanonicalGjcWorkflowSkill,
263
+ ): WorkflowStateReceipt | undefined {
264
+ if (!isPlainObject(value)) return undefined;
265
+ if (
266
+ value.version !== 1 ||
267
+ value.skill !== expectedSkill ||
268
+ (value.owner !== "gjc-state-cli" && value.owner !== "gjc-runtime" && value.owner !== "gjc-hook")
269
+ )
270
+ return undefined;
271
+ const requiredStrings = [
272
+ "command",
273
+ "state_path",
274
+ "storage_path",
275
+ "mutated_at",
276
+ "fresh_until",
277
+ "mutation_id",
278
+ ] as const;
279
+ if (requiredStrings.some(field => typeof value[field] !== "string")) return undefined;
280
+ if (value.status !== "fresh" && value.status !== "stale") return undefined;
281
+ const receipt: WorkflowStateReceipt = {
282
+ version: 1,
283
+ skill: expectedSkill,
284
+ owner: value.owner as WorkflowStateReceipt["owner"],
285
+ command: value.command as string,
286
+ state_path: value.state_path as string,
287
+ storage_path: value.storage_path as string,
288
+ mutated_at: value.mutated_at as string,
289
+ fresh_until: value.fresh_until as string,
290
+ status: value.status as WorkflowStateReceipt["status"],
291
+ mutation_id: value.mutation_id as string,
292
+ };
293
+ if (typeof value.verb === "string") receipt.verb = value.verb;
294
+ if (typeof value.from_phase === "string") receipt.from_phase = value.from_phase;
295
+ if (typeof value.to_phase === "string") receipt.to_phase = value.to_phase;
296
+ if (typeof value.forced === "boolean") receipt.forced = value.forced;
297
+ if (isPlainObject(value.content_sha256))
298
+ receipt.content_sha256 = value.content_sha256 as unknown as WorkflowStateReceipt["content_sha256"];
299
+ return receipt;
300
+ }
301
+
302
+ function handoffReceiptMatches(
303
+ value: unknown,
304
+ skill: CanonicalGjcWorkflowSkill,
305
+ statePath: string,
306
+ activePath: string,
307
+ mutationId: string,
308
+ handoffAt: string,
309
+ ): boolean {
310
+ const receipt = persistedWorkflowReceipt(value, skill);
311
+ if (
312
+ !receipt ||
313
+ receipt.mutation_id !== mutationId ||
314
+ receipt.mutated_at !== handoffAt ||
315
+ path.resolve(receipt.storage_path) !== path.resolve(statePath) ||
316
+ path.resolve(receipt.state_path) !== path.resolve(activePath)
317
+ )
318
+ return false;
319
+ const checksum = isPlainObject(receipt.content_sha256) ? receipt.content_sha256 : undefined;
320
+ return (
321
+ checksum?.algorithm === "sha256" &&
322
+ typeof checksum.value === "string" &&
323
+ checksum.value.length === 64 &&
324
+ path.resolve(checksum.covered_path) === path.resolve(statePath)
325
+ );
326
+ }
327
+
126
328
  async function readInputJson(value: string | undefined, cwd: string): Promise<Record<string, unknown> | undefined> {
127
329
  if (value === undefined) return undefined;
128
330
  const trimmed = value.trim();
@@ -158,10 +360,19 @@ interface ResolvedSelectors {
158
360
  payload: Record<string, unknown> | undefined;
159
361
  }
160
362
 
363
+ interface ResolvedHandoffSelectors {
364
+ selectors: ResolvedSelectors;
365
+ caller: CanonicalGjcWorkflowSkill;
366
+ callee: string;
367
+ calleeIsWorkflow: boolean;
368
+ callerPath: string;
369
+ calleePath?: string;
370
+ }
371
+
161
372
  // `clear` resolves like a read (explicit -> payload -> env -> latest-activity marker)
162
373
  // per the spec: read/status/clear may fall back to the most-recent session. Commands
163
374
  // that create or mutate new state roots still require an explicit/env session id.
164
- const WRITE_SESSION_ACTIONS = new Set<StateAction>(["write", "handoff", "prune", "migrate"]);
375
+ const WRITE_SESSION_ACTIONS = new Set<StateAction>(["write", "approve-execution", "handoff", "prune", "migrate"]);
165
376
 
166
377
  async function resolveSelectors(args: readonly string[], cwd: string, action: StateAction): Promise<ResolvedSelectors> {
167
378
  const classification = classifyStateArgv(args);
@@ -206,6 +417,32 @@ async function resolveSelectors(args: readonly string[], cwd: string, action: St
206
417
  };
207
418
  }
208
419
 
420
+ async function resolveHandoffSelectors(args: readonly string[], cwd: string): Promise<ResolvedHandoffSelectors> {
421
+ const selectors = await resolveSelectors(args, cwd, "handoff");
422
+ const caller = selectors.mode ?? (await inferModeFromActiveState(cwd, selectors.gjcSessionId));
423
+ if (!caller) {
424
+ throw new StateCommandError(
425
+ 2,
426
+ "gjc state handoff requires --mode <caller>, positional <caller>, input.skill, or an active workflow in the current session active state",
427
+ );
428
+ }
429
+ const calleeRaw = flagValue(args, "--to")?.trim();
430
+ if (!calleeRaw) throw new StateCommandError(2, "gjc state handoff requires --to <callee>");
431
+ assertSafePathComponent(calleeRaw, "to");
432
+ const callee = calleeRaw;
433
+ if (callee === caller)
434
+ throw new StateCommandError(2, `gjc state handoff: --to must differ from caller (both are "${caller}")`);
435
+ const calleeIsWorkflow = isKnownMode(callee);
436
+ return {
437
+ selectors,
438
+ caller,
439
+ callee,
440
+ calleeIsWorkflow,
441
+ callerPath: modeStateFile(cwd, caller, selectors.gjcSessionId),
442
+ ...(calleeIsWorkflow ? { calleePath: modeStateFile(cwd, callee, selectors.gjcSessionId) } : {}),
443
+ };
444
+ }
445
+
209
446
  async function inferModeFromActiveState(
210
447
  cwd: string,
211
448
  sessionId: string,
@@ -679,6 +916,31 @@ async function warnAndAuditOutOfBandIfNeeded(
679
916
  return message;
680
917
  }
681
918
 
919
+ async function assertHandoffRecoveryEnvelopeIntegrity(
920
+ cwd: string,
921
+ sessionId: string,
922
+ filePath: string,
923
+ skill: CanonicalGjcWorkflowSkill,
924
+ state: Record<string, unknown>,
925
+ label: "caller" | "callee",
926
+ ): Promise<void> {
927
+ const receipt = persistedWorkflowReceipt(state.receipt, skill);
928
+ const checksum = receipt?.content_sha256;
929
+ if (
930
+ checksum?.algorithm !== "sha256" ||
931
+ typeof checksum.value !== "string" ||
932
+ !/^[0-9a-f]{64}$/.test(checksum.value) ||
933
+ typeof checksum.covered_path !== "string" ||
934
+ path.resolve(checksum.covered_path) !== path.resolve(filePath) ||
935
+ typeof checksum.computed_at !== "string" ||
936
+ !checksum.computed_at.trim()
937
+ )
938
+ throw new StateCommandError(2, `handoff recovery requires checksummed canonical ${label} state`);
939
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, filePath, skill);
940
+ if (integrityWarning)
941
+ throw new StateCommandError(2, `${integrityWarning}; handoff recovery refuses tampered ${label} state`);
942
+ }
943
+
682
944
  function existingStateRevision(value: unknown): number | undefined {
683
945
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
684
946
  const revision = (value as Record<string, unknown>).state_revision;
@@ -1030,6 +1292,7 @@ export async function reconcileWorkflowSkillState(options: {
1030
1292
  return withWorkflowStateLock(
1031
1293
  path.relative(options.cwd, modeStateFile(options.cwd, options.mode, sessionId)),
1032
1294
  async () => reconcileWorkflowSkillStateUnlocked(options, sessionId),
1295
+ { cwd: options.cwd },
1033
1296
  );
1034
1297
  }
1035
1298
 
@@ -1041,7 +1304,10 @@ async function reconcileWorkflowSkillStateUnlocked(
1041
1304
  const filePath = modeStateFile(cwd, mode, sessionId);
1042
1305
  if (mode === "deep-interview") assertDeepInterviewStructuredResponseWithinLimit(payload);
1043
1306
  const existingRead = await readExistingStateForMutation(filePath);
1044
- const existingPayload = existingRead.kind === "valid" ? existingRead.value : {};
1307
+ if (existingRead.kind === "valid")
1308
+ assertNoFutureWorkflowEnvelope(existingRead.value, mode, `runtime reconciliation of ${mode}`);
1309
+ assertNoFutureWorkflowEnvelope(payload, mode, `runtime reconciliation of ${mode}`);
1310
+ const existingPayload = existingRead.kind === "valid" ? migrateWorkflowState(existingRead.value, mode).state : {};
1045
1311
  const nowIsoStr = nowIso();
1046
1312
  const mutationId = `${mode}:reconcile:${nowIsoStr}`;
1047
1313
 
@@ -1076,10 +1342,17 @@ async function reconcileWorkflowSkillStateUnlocked(
1076
1342
  unknown
1077
1343
  >)
1078
1344
  : mergeWithNullDelete(existingPayload, payload);
1079
- if (mode === "deep-interview") assertDeepInterviewEnvelopeInputLimits(merged);
1345
+ assertNoFutureWorkflowEnvelope(merged, mode, `runtime reconciliation of ${mode}`);
1346
+ assertHandoffLineageUnchanged(existingPayload, merged, "runtime reconciliation");
1347
+ if (mode === "deep-interview") {
1348
+ assertDeepInterviewEnvelopeInputLimits(merged);
1349
+ assertDeepInterviewEvidenceUnchanged(existingPayload, merged, "runtime reconciliation");
1350
+ }
1080
1351
  merged.skill = mode;
1081
1352
  merged.current_phase = trimmedPhase;
1082
1353
  merged.active = active;
1354
+ if (mode === "deep-interview")
1355
+ assertApprovedDeepInterviewLifecycleUnchanged(existingPayload, merged, "runtime reconciliation");
1083
1356
  merged.version = WORKFLOW_STATE_VERSION;
1084
1357
  merged.updated_at = nowIsoStr;
1085
1358
  merged.receipt = receipt;
@@ -1259,7 +1532,11 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
1259
1532
  `existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
1260
1533
  );
1261
1534
  }
1262
- const existingPayload = existingRead.kind === "valid" ? existingRead.value : {};
1535
+ if (existingRead.kind === "valid")
1536
+ assertNoFutureWorkflowEnvelope(existingRead.value, mode, `generic state write for ${mode}`);
1537
+ assertNoFutureWorkflowEnvelope(payload, mode, `generic state write for ${mode}`);
1538
+ const existingPayload =
1539
+ existingRead.kind === "valid" ? migrateWorkflowState(existingRead.value, mode).state : {};
1263
1540
  const nowIsoStr = nowIso();
1264
1541
  const mutationId = `${mode}:${nowIsoStr}`;
1265
1542
  const receipt = buildWorkflowStateReceipt({
@@ -1305,10 +1582,22 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
1305
1582
  delete merged.state;
1306
1583
  }
1307
1584
  }
1585
+ assertNoFutureWorkflowEnvelope(merged, mode, `generic state write for ${mode}`);
1308
1586
  const preDefaultValidation = validateWorkflowStateEnvelope(mode, merged);
1309
1587
  if (!preDefaultValidation.valid) {
1310
1588
  throw new StateCommandError(2, preDefaultValidation.error ?? `invalid ${mode} state envelope`);
1311
1589
  }
1590
+ assertHandoffLineageUnchanged(existingPayload, merged, "generic state write");
1591
+ if (
1592
+ mode === "ralplan" &&
1593
+ typeof existingPayload.run_id === "string" &&
1594
+ existingPayload.run_id.trim() !== "" &&
1595
+ merged.run_id !== existingPayload.run_id
1596
+ )
1597
+ throw new StateCommandError(2, "Ralplan run identity cannot change through generic state write");
1598
+ if (mode === "deep-interview") {
1599
+ assertDeepInterviewEvidenceUnchanged(existingPayload, merged, "generic state write");
1600
+ }
1312
1601
  merged.skill = mode;
1313
1602
  if (incomingPhase) {
1314
1603
  merged.current_phase = incomingPhase;
@@ -1319,7 +1608,25 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
1319
1608
  } else {
1320
1609
  merged.current_phase = merged.current_phase.trim();
1321
1610
  }
1611
+ if (mode === "deep-interview") {
1612
+ const mergedInner = isPlainObject(merged.state) ? merged.state : {};
1613
+ if (isPlainObject(mergedInner.crystal) && mergedInner.crystal.lifecycle !== "ready")
1614
+ merged.current_phase = "interviewing";
1615
+ assertApprovedDeepInterviewLifecycleUnchanged(existingPayload, merged, "generic state write");
1616
+ }
1322
1617
  merged.version = WORKFLOW_STATE_VERSION;
1618
+ const existingAdmissionMarker = isPlainObject(existingPayload.final_admission_phase_transition)
1619
+ ? existingPayload.final_admission_phase_transition
1620
+ : undefined;
1621
+ delete merged.final_admission_phase_transition;
1622
+ if (
1623
+ existingAdmissionMarker &&
1624
+ merged.run_id === existingAdmissionMarker.run_id &&
1625
+ createHash("sha256")
1626
+ .update(JSON.stringify(merged.auto_handoff ?? null))
1627
+ .digest("hex") === existingAdmissionMarker.auto_handoff_sha256
1628
+ )
1629
+ merged.final_admission_phase_transition = existingAdmissionMarker;
1323
1630
  if (typeof merged.active !== "boolean") merged.active = true;
1324
1631
  merged.updated_at = nowIsoStr;
1325
1632
  merged.receipt = receipt;
@@ -1333,12 +1640,42 @@ async function handleWrite(args: readonly string[], cwd: string): Promise<StateC
1333
1640
  throw new StateCommandError(2, `unknown ${mode} phase "${toPhase}"; use --force to bypass`);
1334
1641
  }
1335
1642
  if (fromPhase && toPhase && isKnownWorkflowState(mode, fromPhase) && isKnownWorkflowState(mode, toPhase)) {
1336
- if (!isValidTransition(mode, fromPhase, toPhase) && !forced) {
1643
+ let sanctionedRalplanHandoff = false;
1644
+ if (mode === "ralplan" && fromPhase === "final" && toPhase === "handoff" && sessionId) {
1645
+ try {
1646
+ await assertDeepInterviewExecutionLineage(cwd, sessionId, "ralplan", existingPayload);
1647
+ sanctionedRalplanHandoff = true;
1648
+ } catch {}
1649
+ }
1650
+ if (!isValidTransition(mode, fromPhase, toPhase) && !sanctionedRalplanHandoff && !forced) {
1337
1651
  throw new StateCommandError(
1338
1652
  2,
1339
1653
  `invalid ${mode} phase transition from ${fromPhase} to ${toPhase}; use --force to bypass`,
1340
1654
  );
1341
1655
  }
1656
+ if (sanctionedRalplanHandoff) {
1657
+ const evidence = await verifiedRalplanFinalEvidence(cwd, sessionId, existingPayload);
1658
+ if (!evidence)
1659
+ throw new StateCommandError(2, "Ralplan handoff phase requires verified final plan evidence");
1660
+ if (
1661
+ merged.run_id !== evidence.runId ||
1662
+ createHash("sha256")
1663
+ .update(JSON.stringify(merged.auto_handoff ?? null))
1664
+ .digest("hex") !==
1665
+ createHash("sha256")
1666
+ .update(JSON.stringify(existingPayload.auto_handoff ?? null))
1667
+ .digest("hex")
1668
+ )
1669
+ throw new StateCommandError(2, "Ralplan handoff phase cannot change final admission identity");
1670
+ merged.final_admission_phase_transition = {
1671
+ run_id: evidence.runId,
1672
+ final_path: evidence.finalPath,
1673
+ final_sha256: evidence.finalSha256,
1674
+ auto_handoff_sha256: createHash("sha256")
1675
+ .update(JSON.stringify(existingPayload.auto_handoff ?? null))
1676
+ .digest("hex"),
1677
+ };
1678
+ }
1342
1679
  }
1343
1680
 
1344
1681
  const validation = validateWorkflowStateEnvelope(mode, merged);
@@ -1421,6 +1758,7 @@ async function handleClear(args: readonly string[], cwd: string): Promise<StateC
1421
1758
  `existing state for ${mode} is corrupt or tampered (${existingRead.error}); use --force to overwrite`,
1422
1759
  );
1423
1760
  }
1761
+ if (existingRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingRead.value, mode, `clear ${mode}`);
1424
1762
  const existing = existingRead.kind === "valid" ? existingRead.value : {};
1425
1763
  const staleReason = await describeStaleClearState(cwd, sessionId, mode, existing);
1426
1764
  if (staleReason && !forced) {
@@ -1493,132 +1831,2466 @@ async function handleClear(args: readonly string[], cwd: string): Promise<StateC
1493
1831
 
1494
1832
  const DEEP_INTERVIEW_INTENT_ID_RE = /(?:artifact|surface|integration|constraint):[a-z0-9][a-z0-9._/-]{0,127}/g;
1495
1833
 
1496
- async function assertDeepInterviewHandoffReady(state: Record<string, unknown>): Promise<void> {
1497
- const specPath = typeof state.spec_path === "string" ? state.spec_path : undefined;
1498
- const expectedSha = typeof state.spec_sha256 === "string" ? state.spec_sha256 : undefined;
1499
- let content: string | undefined;
1500
- if (specPath) {
1501
- try {
1502
- content = await fs.readFile(specPath, "utf-8");
1503
- } catch (error) {
1504
- throw new StateCommandError(
1505
- 2,
1506
- `deep-interview handoff cannot read persisted spec: ${error instanceof Error ? error.message : String(error)}`,
1507
- );
1508
- }
1509
- const boundedContent = content.endsWith("\n") ? content.slice(0, -1) : content;
1510
- assertDeepInterviewInputWithinLimit(
1511
- boundedContent,
1512
- MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
1513
- "persisted deep-interview spec",
1514
- );
1515
- }
1516
- const envelope = normalizeDeepInterviewEnvelope(state);
1517
- const inner = envelope.state;
1518
- if (!inner) return;
1519
- if (inner.intent_contract === undefined) {
1520
- if (inner.intent_contract_required === true)
1521
- throw new StateCommandError(2, "deep-interview handoff requires a locked Round 0 intent contract");
1522
- return;
1523
- }
1524
- assertDeepInterviewIntentManifest(inner.intent_contract);
1525
- if (!specPath || !expectedSha || content === undefined)
1526
- throw new StateCommandError(2, "deep-interview handoff requires a persisted intent-validated spec");
1527
- if (createHash("sha256").update(content).digest("hex") !== expectedSha)
1528
- throw new StateCommandError(2, "deep-interview handoff spec hash mismatch");
1529
- const observedIds = [...new Set(content.match(DEEP_INTERVIEW_INTENT_ID_RE) ?? [])].sort();
1530
- const rounds = Array.isArray(inner.rounds)
1531
- ? inner.rounds
1532
- .filter(
1533
- (round): round is Record<string, unknown> =>
1534
- Boolean(round) && typeof round === "object" && !Array.isArray(round),
1535
- )
1536
- .map(round => ({ round: round.round, answer_hash: round.answer_hash }))
1537
- : [];
1834
+ function requireReadyCanonicalCrystal(value: unknown): Record<string, unknown> {
1835
+ if (!isPlainObject(value) || value.lifecycle !== "ready")
1836
+ throw new StateCommandError(2, "approve-execution requires a ready canonical Crystal");
1837
+ if (value.schema_version !== 1 || !Number.isSafeInteger(value.spec_version) || (value.spec_version as number) < 1)
1838
+ throw new StateCommandError(2, "approve-execution requires a versioned canonical Crystal");
1839
+ if (!isPlainObject(value.source) || !Array.isArray(value.source.messages))
1840
+ throw new StateCommandError(2, "approve-execution requires canonical Crystal source evidence");
1841
+ const source = value.source;
1842
+ if (
1843
+ !Number.isSafeInteger(source.revision) ||
1844
+ !Number.isSafeInteger(source.start) ||
1845
+ !Number.isSafeInteger(source.end) ||
1846
+ typeof source.digest !== "string" ||
1847
+ !/^[a-f0-9]{64}$/.test(source.digest)
1848
+ )
1849
+ throw new StateCommandError(2, "approve-execution requires canonical Crystal source evidence");
1538
1850
  try {
1539
- assertDeepInterviewIntentReview(
1540
- inner.intent_review,
1541
- inner.intent_contract as DeepInterviewIntentManifest,
1542
- observedIds,
1543
- rounds,
1544
- );
1851
+ if (
1852
+ crystalSnapshotDigest({
1853
+ revision: source.revision as number,
1854
+ start: source.start as number,
1855
+ end: source.end as number,
1856
+ messages: source.messages as Array<{
1857
+ index: number;
1858
+ role: "user" | "assistant" | "system" | "tool" | "toolResult" | "developer";
1859
+ content: string;
1860
+ }>,
1861
+ }) !== source.digest
1862
+ )
1863
+ throw new StateCommandError(2, "approve-execution requires authentic Crystal source evidence");
1545
1864
  } catch (error) {
1546
- throw new StateCommandError(
1547
- 2,
1548
- `deep-interview handoff intent validation failed: ${error instanceof Error ? error.message : String(error)}`,
1549
- );
1865
+ if (error instanceof StateCommandError) throw error;
1866
+ throw new StateCommandError(2, "approve-execution requires authentic Crystal source evidence");
1550
1867
  }
1868
+ if (!Array.isArray(value.items) || value.items.length === 0 || !isPlainObject(value.delta))
1869
+ throw new StateCommandError(2, "approve-execution requires complete canonical Crystal evidence");
1870
+ const safeId = (id: unknown): id is string => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(id);
1871
+ if (
1872
+ value.items.some(item => !isPlainObject(item) || !safeId(item.id)) ||
1873
+ ["removed_ids", "pending_removals"].some(
1874
+ field => value[field] !== undefined && (!Array.isArray(value[field]) || value[field].some(id => !safeId(id))),
1875
+ ) ||
1876
+ (value.removed_item_anchors !== undefined &&
1877
+ (!Array.isArray(value.removed_item_anchors) ||
1878
+ value.removed_item_anchors.some(anchor => !isPlainObject(anchor) || !safeId(anchor.item))))
1879
+ )
1880
+ throw new StateCommandError(2, "approve-execution requires safe canonical Crystal identifiers");
1881
+ if (value.execution_approval !== "not-approved")
1882
+ throw new StateCommandError(2, "canonical Crystal must remain execution_approval: not-approved");
1883
+ return value;
1551
1884
  }
1552
1885
 
1553
1886
  /**
1554
- * `handoff` exists in two distinct roles:
1555
- * - As a verb: this CLI action, which atomically transitions caller→callee.
1556
- * Writes the callee mode-state first, the caller mode-state second, then
1557
- * syncs both `skill-active-state.json` files. Every intermediate crashed
1558
- * state remains HUD-coherent: the active-state file either reflects the
1559
- * old skill entirely or the new skill entirely, never both as active.
1560
- * - As a phase: `current_phase: "handoff"` is set by this verb when demoting
1561
- * the caller. Agents writing `current_phase: "handoff"` manually via
1562
- * `gjc state <skill> write` are declaring "I am ready to be handed off";
1563
- * the next agent-initiated `skill` tool call will then satisfy the phase
1564
- * guard and may chain.
1565
- *
1566
- * `handoff` is in the terminal-phase set used by `isTerminalModeState` and by
1567
- * the skill tool's chain guard. A manual `current_phase: "handoff"` write does
1568
- * NOT mark `active: false` — only this verb does that — so a skill that wrote
1569
- * the phase remains in `skill-active-state.json` until a chain call (or
1570
- * explicit `clear`) demotes it.
1887
+ * A user-origin execution choice is deliberately kept outside the workflow
1888
+ * envelope. The envelope is agent/runtime state and therefore cannot itself
1889
+ * be evidence that a human selected the execution path. This record is the
1890
+ * short-lived bridge from the structured ask surface to the explicit
1891
+ * `approve-execution` command.
1571
1892
  */
1572
- async function handleHandoffUnlocked(args: readonly string[], cwd: string): Promise<StateCommandResult> {
1573
- const selectors = await resolveSelectors(args, cwd, "handoff");
1574
- const { gjcSessionId: sessionId, threadId, turnId } = selectors;
1575
- const caller = selectors.mode ?? (await inferModeFromActiveState(cwd, sessionId));
1576
- if (!caller) {
1577
- throw new StateCommandError(
1578
- 2,
1579
- "gjc state handoff requires --mode <caller>, positional <caller>, input.skill, or an active workflow in the current session active state",
1580
- );
1893
+ export const DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_FILE = "deep-interview-execution-approval.json";
1894
+ export const DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS = 15 * 60 * 1000;
1895
+ const EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES = 128 * 1024 * 1024;
1896
+ const DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES = 16 * 1024;
1897
+ const DEEP_INTERVIEW_EXECUTION_APPROVAL_ID_MAX_LENGTH = 256;
1898
+
1899
+ export interface ExecutionApprovalTranscriptBoundary {
1900
+ byte_length: number;
1901
+ device: string;
1902
+ inode: string;
1903
+ leaf_id: string | null;
1904
+ approval_tool_call_id: string;
1905
+ }
1906
+
1907
+ function isExecutionApprovalTranscriptBoundary(value: unknown): value is ExecutionApprovalTranscriptBoundary {
1908
+ return (
1909
+ isPlainObject(value) &&
1910
+ Number.isSafeInteger(value.byte_length) &&
1911
+ (value.byte_length as number) > 0 &&
1912
+ typeof value.device === "string" &&
1913
+ /^\d+$/.test(value.device) &&
1914
+ typeof value.inode === "string" &&
1915
+ /^\d+$/.test(value.inode) &&
1916
+ (value.leaf_id === null || isExecutionApprovalId(value.leaf_id)) &&
1917
+ isExecutionApprovalId(value.approval_tool_call_id)
1918
+ );
1919
+ }
1920
+
1921
+ function approvalTranscriptRecords(text: string): Record<string, unknown>[] {
1922
+ try {
1923
+ const records: unknown[] = Bun.JSONL.parse(text);
1924
+ if (records.some(record => !isPlainObject(record))) throw new Error("invalid record");
1925
+ return records as Record<string, unknown>[];
1926
+ } catch {
1927
+ throw new StateCommandError(2, "execution approval transcript is malformed");
1581
1928
  }
1582
- const calleeRaw = flagValue(args, "--to")?.trim();
1583
- if (!calleeRaw) {
1584
- throw new StateCommandError(2, "gjc state handoff requires --to <callee>");
1929
+ }
1930
+
1931
+ function approvalTranscriptPrefix(
1932
+ cwd: string,
1933
+ sessionId: string,
1934
+ text: string,
1935
+ ): {
1936
+ leaf: string | null;
1937
+ ids: Set<string>;
1938
+ askToolCallCounts: Map<string, number>;
1939
+ leafAskToolCallIds: Set<string>;
1940
+ } {
1941
+ const [header, ...records] = approvalTranscriptRecords(text);
1942
+ if (
1943
+ header?.type !== "session" ||
1944
+ header.id !== sessionId ||
1945
+ typeof header.cwd !== "string" ||
1946
+ path.resolve(header.cwd) !== path.resolve(cwd)
1947
+ )
1948
+ throw new StateCommandError(2, "execution approval transcript identity mismatch");
1949
+ const ids = new Set<string>();
1950
+ const askToolCallCounts = new Map<string, number>();
1951
+ let leafAskToolCallIds = new Set<string>();
1952
+ let leaf: string | null = null;
1953
+ for (const record of records) {
1954
+ if (record.type === "header_patch") {
1955
+ const patch = record.patch;
1956
+ if (!isPlainObject(patch) || ["id", "cwd", "version"].some(key => key in patch))
1957
+ throw new StateCommandError(2, "execution approval transcript identity patch is invalid");
1958
+ continue;
1959
+ }
1960
+ if (record.type === "entry_patch") {
1961
+ const patch = record.patch;
1962
+ if (
1963
+ !isPlainObject(patch) ||
1964
+ typeof record.entryId !== "string" ||
1965
+ !ids.has(record.entryId) ||
1966
+ ["id", "parentId", "type"].some(key => key in patch)
1967
+ )
1968
+ throw new StateCommandError(2, "execution approval transcript branch patch is invalid");
1969
+ continue;
1970
+ }
1971
+ if (
1972
+ !isExecutionApprovalId(record.id) ||
1973
+ ids.has(record.id) ||
1974
+ (record.parentId !== null && (typeof record.parentId !== "string" || !ids.has(record.parentId)))
1975
+ )
1976
+ throw new StateCommandError(2, "execution approval transcript branch is invalid");
1977
+ const recordAskToolCallIds = new Set<string>();
1978
+ if (
1979
+ record.type === "message" &&
1980
+ isPlainObject(record.message) &&
1981
+ record.message.role === "toolResult" &&
1982
+ record.message.toolName === "ask" &&
1983
+ typeof record.message.toolCallId === "string"
1984
+ ) {
1985
+ askToolCallCounts.set(record.message.toolCallId, (askToolCallCounts.get(record.message.toolCallId) ?? 0) + 1);
1986
+ }
1987
+ if (record.type === "message" && isPlainObject(record.message) && record.message.role === "assistant") {
1988
+ const content = record.message.content;
1989
+ if (Array.isArray(content)) {
1990
+ for (const block of content) {
1991
+ if (
1992
+ isPlainObject(block) &&
1993
+ block.type === "toolCall" &&
1994
+ block.name === "ask" &&
1995
+ typeof block.id === "string"
1996
+ ) {
1997
+ recordAskToolCallIds.add(block.id);
1998
+ askToolCallCounts.set(block.id, (askToolCallCounts.get(block.id) ?? 0) + 1);
1999
+ }
2000
+ }
2001
+ }
2002
+ }
2003
+ leafAskToolCallIds = recordAskToolCallIds;
2004
+ ids.add(record.id);
2005
+ leaf = record.id;
1585
2006
  }
1586
- assertSafePathComponent(calleeRaw, "to");
1587
- const callee = calleeRaw;
1588
- const calleeIsWorkflow = isKnownMode(callee);
1589
- if (callee === caller) {
1590
- throw new StateCommandError(2, `gjc state handoff: --to must differ from caller (both are "${caller}")`);
2007
+ return { leaf, ids, askToolCallCounts, leafAskToolCallIds };
2008
+ }
2009
+
2010
+ export async function captureExecutionApprovalTranscriptBoundary(
2011
+ cwd: string,
2012
+ sessionId: string,
2013
+ transcriptPath: string,
2014
+ transcriptSha256: string,
2015
+ approvalToolCallId: string,
2016
+ ): Promise<ExecutionApprovalTranscriptBoundary> {
2017
+ if (!isExecutionApprovalId(approvalToolCallId))
2018
+ throw new StateCommandError(2, "execution approval transcript tool-call identity is invalid");
2019
+ const before = await fs.lstat(transcriptPath, { bigint: true });
2020
+ const text = await readBoundedIdentityText(
2021
+ transcriptPath,
2022
+ EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES,
2023
+ "execution approval transcript",
2024
+ );
2025
+ const after = await fs.lstat(transcriptPath, { bigint: true });
2026
+ if ((await fs.realpath(transcriptPath)) !== path.resolve(transcriptPath))
2027
+ throw new StateCommandError(2, "execution approval transcript path is not canonical");
2028
+ if (
2029
+ !sameBoundedFileIdentity(before, after) ||
2030
+ text === undefined ||
2031
+ !text.endsWith("\n") ||
2032
+ createHash("sha256").update(text).digest("hex") !== transcriptSha256
2033
+ )
2034
+ throw new StateCommandError(2, "execution approval transcript changed before recording consent");
2035
+ const prefix = approvalTranscriptPrefix(cwd, sessionId, text);
2036
+ if (prefix.askToolCallCounts.get(approvalToolCallId) !== 1 || !prefix.leafAskToolCallIds.has(approvalToolCallId))
2037
+ throw new StateCommandError(2, "execution approval transcript boundary lacks the current Ask call");
2038
+ return {
2039
+ byte_length: Buffer.byteLength(text),
2040
+ device: after.dev.toString(),
2041
+ inode: after.ino.toString(),
2042
+ leaf_id: prefix.leaf,
2043
+ approval_tool_call_id: approvalToolCallId,
2044
+ };
2045
+ }
2046
+
2047
+ export async function assertExecutionApprovalTranscriptBoundary(
2048
+ cwd: string,
2049
+ sessionId: string,
2050
+ transcriptPath: string,
2051
+ transcriptSha256: string,
2052
+ boundary: unknown,
2053
+ ): Promise<void> {
2054
+ if (!isExecutionApprovalTranscriptBoundary(boundary))
2055
+ throw new StateCommandError(2, "execution approval transcript boundary is invalid");
2056
+ const before = await fs.lstat(transcriptPath, { bigint: true });
2057
+ const text = await readBoundedIdentityText(
2058
+ transcriptPath,
2059
+ EXECUTION_APPROVAL_TRANSCRIPT_MAX_BYTES,
2060
+ "execution approval transcript",
2061
+ );
2062
+ const after = await fs.lstat(transcriptPath, { bigint: true });
2063
+ if ((await fs.realpath(transcriptPath)) !== path.resolve(transcriptPath))
2064
+ throw new StateCommandError(2, "execution approval transcript path is not canonical");
2065
+ if (
2066
+ !sameBoundedFileIdentity(before, after) ||
2067
+ after.dev.toString() !== boundary.device ||
2068
+ after.ino.toString() !== boundary.inode ||
2069
+ text === undefined
2070
+ )
2071
+ throw new StateCommandError(2, "execution approval transcript identity changed");
2072
+ const bytes = Buffer.from(text);
2073
+ const prefix = bytes.subarray(0, boundary.byte_length);
2074
+ if (prefix.length !== boundary.byte_length || createHash("sha256").update(prefix).digest("hex") !== transcriptSha256)
2075
+ throw new StateCommandError(2, "execution approval transcript prefix changed after user approval");
2076
+ const branch = approvalTranscriptPrefix(cwd, sessionId, prefix.toString("utf8"));
2077
+ if (branch.leaf !== boundary.leaf_id)
2078
+ throw new StateCommandError(2, "execution approval transcript branch identity changed");
2079
+ if (
2080
+ branch.askToolCallCounts.get(boundary.approval_tool_call_id) !== 1 ||
2081
+ !branch.leafAskToolCallIds.has(boundary.approval_tool_call_id)
2082
+ )
2083
+ throw new StateCommandError(2, "execution approval Ask tool-call identity was reused from the captured prefix");
2084
+ const suffix = bytes.subarray(boundary.byte_length).toString("utf8");
2085
+ if (suffix && !suffix.endsWith("\n"))
2086
+ throw new StateCommandError(2, "execution approval transcript continuation is incomplete");
2087
+ let approvalAskResultSeen = false;
2088
+ for (const record of approvalTranscriptRecords(suffix)) {
2089
+ const message = record.message;
2090
+ const isUserBearingAskCall =
2091
+ record.type === "message" &&
2092
+ isPlainObject(message) &&
2093
+ message.role === "assistant" &&
2094
+ Array.isArray(message.content) &&
2095
+ message.content.some(block => isPlainObject(block) && block.type === "toolCall" && block.name === "ask");
2096
+ if (isUserBearingAskCall)
2097
+ throw new StateCommandError(2, "execution approval transcript continuation contains a new Ask call");
2098
+ const isUserBearingAskResult =
2099
+ record.type === "message" &&
2100
+ isPlainObject(message) &&
2101
+ message.role === "toolResult" &&
2102
+ message.toolName === "ask";
2103
+ const isRecordedApprovalAskResult =
2104
+ isUserBearingAskResult &&
2105
+ !approvalAskResultSeen &&
2106
+ message.toolCallId === boundary.approval_tool_call_id &&
2107
+ branch.leafAskToolCallIds.has(boundary.approval_tool_call_id);
2108
+ if (
2109
+ isUserBearingAskResult &&
2110
+ message.toolCallId === boundary.approval_tool_call_id &&
2111
+ !branch.leafAskToolCallIds.has(boundary.approval_tool_call_id)
2112
+ )
2113
+ throw new StateCommandError(
2114
+ 2,
2115
+ "execution approval Ask tool-call identity was reused from the captured prefix",
2116
+ );
2117
+ if (
2118
+ record.type !== "message" ||
2119
+ !isExecutionApprovalId(record.id) ||
2120
+ branch.ids.has(record.id) ||
2121
+ record.parentId !== branch.leaf ||
2122
+ !isPlainObject(record.message) ||
2123
+ !["assistant", "toolResult"].includes(String(record.message.role)) ||
2124
+ (isUserBearingAskResult && !isRecordedApprovalAskResult)
2125
+ )
2126
+ throw new StateCommandError(
2127
+ 2,
2128
+ isUserBearingAskResult
2129
+ ? "execution approval transcript continuation contains a user-bearing Ask result"
2130
+ : "execution approval transcript continuation changed user evidence or branch",
2131
+ );
2132
+ if (isRecordedApprovalAskResult) approvalAskResultSeen = true;
2133
+ branch.ids.add(record.id);
2134
+ branch.leaf = record.id;
1591
2135
  }
2136
+ if (!approvalAskResultSeen)
2137
+ throw new StateCommandError(2, "execution approval transcript continuation lacks the recorded Ask result");
2138
+ }
1592
2139
 
1593
- const callerPath = modeStateFile(cwd, caller, sessionId);
1594
- const calleePath = calleeIsWorkflow ? modeStateFile(cwd, callee, sessionId) : undefined;
1595
- const forced = hasFlag(args, "--force");
1596
- const callerRead = await readExistingStateForMutation(callerPath);
1597
- if (callerRead.kind === "corrupt" && !forced) {
1598
- throw new StateCommandError(
1599
- 2,
1600
- `existing state for ${caller} is corrupt or tampered (${callerRead.error}); use --force to overwrite`,
1601
- );
2140
+ export type ExecutionApprovalStage = "deep-interview" | "ralplan";
2141
+
2142
+ /** Select lineage before validation; malformed Crystal evidence must never fall back. */
2143
+ export async function executionApprovalLineage(
2144
+ cwd: string,
2145
+ sessionId: string,
2146
+ stage: ExecutionApprovalStage,
2147
+ ): Promise<"crystal" | "ordinary"> {
2148
+ const current = await readExistingStateForMutation(modeStateFile(cwd, stage, sessionId));
2149
+ if (current.kind !== "valid") throw new StateCommandError(2, "execution approval requires valid workflow state");
2150
+ assertNoFutureWorkflowEnvelope(current.value, stage, "execution approval");
2151
+ if (await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, modeStateFile(cwd, stage, sessionId), stage))
2152
+ throw new StateCommandError(2, "execution approval refuses tampered workflow state");
2153
+ let interview = current.value;
2154
+ if (stage === "ralplan") {
2155
+ const upstream = current.value.handoff_from;
2156
+ if (upstream && upstream !== "deep-interview")
2157
+ throw new StateCommandError(2, "execution approval has unsupported upstream lineage");
2158
+ if (!(await hasCurrentDeepInterviewHandoff(cwd, sessionId, "ralplan", current.value))) return "ordinary";
2159
+ const read = await readExistingStateForMutation(modeStateFile(cwd, "deep-interview", sessionId));
2160
+ if (read.kind !== "valid") throw new StateCommandError(2, "execution approval upstream state is unavailable");
2161
+ assertNoFutureWorkflowEnvelope(read.value, "deep-interview", "execution approval upstream");
2162
+ if (
2163
+ await warnAndAuditOutOfBandIfNeeded(
2164
+ cwd,
2165
+ sessionId,
2166
+ modeStateFile(cwd, "deep-interview", sessionId),
2167
+ "deep-interview",
2168
+ )
2169
+ )
2170
+ throw new StateCommandError(2, "execution approval refuses tampered upstream state");
2171
+ if (
2172
+ read.value.active !== false ||
2173
+ read.value.current_phase !== "handoff" ||
2174
+ read.value.handoff_to !== stage ||
2175
+ read.value.handoff_at !== (current.value.upstream_handoff_at ?? current.value.handoff_at)
2176
+ )
2177
+ throw new StateCommandError(2, "execution approval cannot authenticate Deep Interview approval lineage");
2178
+ interview = read.value;
1602
2179
  }
1603
- if (callerRead.kind === "absent") {
1604
- throw new StateCommandError(
1605
- 2,
1606
- `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
1607
- );
2180
+ const inner = isPlainObject(interview.state) ? interview.state : {};
2181
+ const receipt = isPlainObject(interview.receipt) ? interview.receipt : {};
2182
+ return inner.crystal !== undefined || receipt.command === "gjc deep-interview crystallize" ? "crystal" : "ordinary";
2183
+ }
2184
+
2185
+ interface NonCrystalExecutionApprovalRecord {
2186
+ schema_version: 1;
2187
+ status: "pending" | "consumed" | "revoked";
2188
+ session_id: string;
2189
+ stage: ExecutionApprovalStage;
2190
+ target: "ultragoal";
2191
+ state_path: string;
2192
+ state_revision: number;
2193
+ artifact_path: string;
2194
+ artifact_sha256: string;
2195
+ run_id: string | null;
2196
+ question_id: string;
2197
+ gate_id: string;
2198
+ answer_hash: string;
2199
+ transcript_path: string;
2200
+ transcript_sha256: string;
2201
+ transcript_boundary: ExecutionApprovalTranscriptBoundary;
2202
+ created_at: string;
2203
+ expires_at: string;
2204
+ }
2205
+
2206
+ export interface ExecutionApprovalPresentation {
2207
+ state_path: string;
2208
+ state_revision: number;
2209
+ artifact_path: string;
2210
+ artifact_sha256: string;
2211
+ run_id: string | null;
2212
+ }
2213
+
2214
+ export function nonCrystalExecutionApprovalRecordPath(
2215
+ cwd: string,
2216
+ sessionId: string,
2217
+ stage: ExecutionApprovalStage,
2218
+ ): string {
2219
+ return path.join(sessionStateDir(cwd, sessionId), `${stage}-ordinary-execution-approval.json`);
2220
+ }
2221
+
2222
+ async function nonCrystalApprovalPublication(cwd: string, sessionId: string, stage: ExecutionApprovalStage) {
2223
+ if ((await executionApprovalLineage(cwd, sessionId, stage)) !== "ordinary")
2224
+ throw new StateCommandError(2, "ordinary execution approval cannot authorize Crystal lineage");
2225
+ const statePath = modeStateFile(cwd, stage, sessionId);
2226
+ const read = await readExistingStateForMutation(statePath);
2227
+ if (read.kind !== "valid") throw new StateCommandError(2, "execution approval state is unavailable");
2228
+ const state = read.value;
2229
+ if (state.active !== true || !["final", "handoff"].includes(String(state.current_phase)))
2230
+ throw new StateCommandError(2, "execution approval requires an active final publication");
2231
+ if (stage === "ralplan") {
2232
+ const final = await verifiedRalplanFinalEvidence(cwd, sessionId, state);
2233
+ if (!final) throw new StateCommandError(2, "execution approval requires verified Ralplan final evidence");
2234
+ return {
2235
+ state_path: final.statePath,
2236
+ state_revision: final.stateRevision,
2237
+ artifact_path: final.finalPath,
2238
+ artifact_sha256: final.finalSha256,
2239
+ run_id: final.runId,
2240
+ };
1608
2241
  }
1609
- const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
2242
+ await assertDeepInterviewHandoffReady(state, { cwd, sessionId, statePath });
2243
+ const receipt = persistedWorkflowReceipt(state.receipt, "deep-interview");
2244
+ if (
2245
+ state.spec_stage !== "final" ||
2246
+ typeof state.spec_path !== "string" ||
2247
+ !isSha256(state.spec_sha256) ||
2248
+ receipt?.owner !== "gjc-runtime" ||
2249
+ receipt.command !== "gjc deep-interview persist-spec-state" ||
2250
+ receipt.content_sha256?.covered_path !== path.resolve(statePath)
2251
+ )
2252
+ throw new StateCommandError(2, "execution approval requires a canonically published final spec");
2253
+ const relative = path.relative(path.resolve(sessionSpecsDir(cwd, sessionId)), path.resolve(state.spec_path));
2254
+ if (
2255
+ relative.startsWith("..") ||
2256
+ path.isAbsolute(relative) ||
2257
+ (await hashIdentityFile(state.spec_path, "ordinary final spec")) !== state.spec_sha256
2258
+ )
2259
+ throw new StateCommandError(2, "execution approval final spec identity mismatch");
2260
+ const revision = existingStateRevision(state);
2261
+ if (!Number.isSafeInteger(revision) || typeof revision !== "number" || revision < 0)
2262
+ throw new StateCommandError(2, "execution approval state revision is invalid");
2263
+ return {
2264
+ state_path: path.resolve(statePath),
2265
+ state_revision: revision,
2266
+ artifact_path: path.resolve(state.spec_path),
2267
+ artifact_sha256: state.spec_sha256,
2268
+ run_id: null,
2269
+ };
2270
+ }
2271
+
2272
+ /** Capture the exact publication shown by an execution approval gate. */
2273
+ export async function captureExecutionApprovalPresentation(
2274
+ cwd: string,
2275
+ sessionId: string,
2276
+ stage: ExecutionApprovalStage,
2277
+ ): Promise<ExecutionApprovalPresentation> {
2278
+ if ((await executionApprovalLineage(cwd, sessionId, stage)) === "ordinary")
2279
+ return nonCrystalApprovalPublication(cwd, sessionId, stage);
2280
+ const statePath = modeStateFile(cwd, "deep-interview", sessionId);
2281
+ const read = await readExistingStateForMutation(statePath);
2282
+ if (read.kind !== "valid") throw new StateCommandError(2, "execution approval state is unavailable");
2283
+ const envelope = read.value;
2284
+ if (stage === "ralplan") {
2285
+ const ralplanRead = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
2286
+ if (ralplanRead.kind !== "valid")
2287
+ throw new StateCommandError(2, "Ralplan execution approval requires valid final state");
2288
+ const final = await verifiedRalplanFinalEvidence(cwd, sessionId, ralplanRead.value);
2289
+ if (!final) throw new StateCommandError(2, "execution approval requires verified Ralplan final evidence");
2290
+ return {
2291
+ state_path: path.resolve(statePath),
2292
+ state_revision: existingStateRevision(envelope)!,
2293
+ artifact_path: final.finalPath,
2294
+ artifact_sha256: final.finalSha256,
2295
+ run_id: final.runId,
2296
+ };
2297
+ }
2298
+ if (typeof envelope.spec_path !== "string" || !isSha256(envelope.spec_sha256))
2299
+ throw new StateCommandError(2, "execution approval requires a canonically published final spec");
2300
+ const revision = existingStateRevision(envelope);
2301
+ if (revision === undefined) throw new StateCommandError(2, "execution approval state revision is invalid");
2302
+ return {
2303
+ state_path: path.resolve(statePath),
2304
+ state_revision: revision,
2305
+ artifact_path: path.resolve(envelope.spec_path),
2306
+ artifact_sha256: envelope.spec_sha256,
2307
+ run_id: null,
2308
+ };
2309
+ }
1610
2310
 
1611
- const handoffAt = nowIso();
1612
- const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
1613
- const callerReceipt = buildWorkflowStateReceipt({
2311
+ async function writeNonCrystalApproval(cwd: string, record: NonCrystalExecutionApprovalRecord): Promise<void> {
2312
+ const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, record.session_id, record.stage);
2313
+ const content = `${JSON.stringify(record)}\n`;
2314
+ const digest = createHash("sha256").update(content).digest("hex");
2315
+ await writeArtifact(recordPath, content, {
1614
2316
  cwd,
1615
- skill: caller,
1616
- owner: "gjc-state-cli",
1617
- command: `gjc state ${caller} handoff --to ${callee}`,
1618
- sessionId,
1619
- nowIso: handoffAt,
1620
- mutationId,
2317
+ audit: {
2318
+ category: "artifact",
2319
+ verb: "write",
2320
+ owner: "gjc-runtime",
2321
+ skill: record.stage,
2322
+ sessionId: record.session_id,
2323
+ },
2324
+ });
2325
+ const auditEntry = {
2326
+ ts: nowIso(),
2327
+ category: "state",
2328
+ verb: "approve-execution",
2329
+ owner: "gjc-runtime",
2330
+ skill: record.stage,
2331
+ mutation_id: `${record.stage}:ordinary-execution-approval:${record.status}:${digest}`,
2332
+ forced: false,
2333
+ paths: [recordPath],
2334
+ ordinary_approval_status: record.status,
2335
+ question_id: record.question_id,
2336
+ gate_id: record.gate_id,
2337
+ artifact_sha256: record.artifact_sha256,
2338
+ run_id: record.run_id,
2339
+ ordinary_approval_sha256: digest,
2340
+ } satisfies AuditEntry & Record<string, unknown>;
2341
+ await appendAuditEntry(cwd, record.session_id, auditEntry);
2342
+ }
2343
+
2344
+ async function nonCrystalApprovalAuditRows(
2345
+ cwd: string,
2346
+ sessionId: string,
2347
+ stage: ExecutionApprovalStage,
2348
+ ): Promise<Record<string, unknown>[]> {
2349
+ const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage);
2350
+ const audit = await readBoundedIdentityText(auditPath(cwd, sessionId), 16 * 1024 * 1024, "execution approval audit");
2351
+ const rows = (audit ?? "")
2352
+ .split(/\r?\n/)
2353
+ .filter(Boolean)
2354
+ .map(line => {
2355
+ const row: unknown = JSON.parse(line);
2356
+ if (!isPlainObject(row)) throw new StateCommandError(2, "execution approval audit is invalid");
2357
+ return row;
2358
+ });
2359
+ return rows.filter(
2360
+ row =>
2361
+ row.owner === "gjc-runtime" &&
2362
+ row.verb === "approve-execution" &&
2363
+ row.skill === stage &&
2364
+ Array.isArray(row.paths) &&
2365
+ row.paths.includes(recordPath) &&
2366
+ row.ordinary_approval_sha256,
2367
+ );
2368
+ }
2369
+
2370
+ async function readNonCrystalApproval(
2371
+ cwd: string,
2372
+ sessionId: string,
2373
+ stage: ExecutionApprovalStage,
2374
+ ): Promise<NonCrystalExecutionApprovalRecord | undefined> {
2375
+ const recordPath = nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage);
2376
+ const content = await readBoundedIdentityText(
2377
+ recordPath,
2378
+ DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES,
2379
+ "ordinary execution approval",
2380
+ );
2381
+ if (content === undefined) return undefined;
2382
+ const value: unknown = JSON.parse(content);
2383
+ if (
2384
+ !isPlainObject(value) ||
2385
+ value.schema_version !== 1 ||
2386
+ value.session_id !== sessionId ||
2387
+ value.stage !== stage ||
2388
+ value.target !== "ultragoal" ||
2389
+ !["pending", "consumed", "revoked"].includes(String(value.status)) ||
2390
+ !isExecutionApprovalId(value.question_id) ||
2391
+ !isExecutionApprovalId(value.gate_id) ||
2392
+ !isSha256(value.answer_hash) ||
2393
+ typeof value.transcript_path !== "string" ||
2394
+ !path.isAbsolute(value.transcript_path) ||
2395
+ !isSha256(value.transcript_sha256) ||
2396
+ !isExecutionApprovalTranscriptBoundary(value.transcript_boundary) ||
2397
+ typeof value.expires_at !== "string" ||
2398
+ !Number.isFinite(Date.parse(value.expires_at))
2399
+ )
2400
+ throw new StateCommandError(2, "ordinary execution approval record is invalid");
2401
+ const digest = createHash("sha256").update(content).digest("hex");
2402
+ const latest = (await nonCrystalApprovalAuditRows(cwd, sessionId, stage)).at(-1);
2403
+ if (latest?.ordinary_approval_sha256 !== digest)
2404
+ throw new StateCommandError(2, "ordinary execution approval lacks sanctioned audit provenance");
2405
+ return value as unknown as NonCrystalExecutionApprovalRecord;
2406
+ }
2407
+
2408
+ export async function recordNonCrystalExecutionApproval(options: {
2409
+ cwd: string;
2410
+ sessionId: string;
2411
+ approvalStage: ExecutionApprovalStage;
2412
+ questionId: string;
2413
+ gateId: string;
2414
+ target: "ultragoal";
2415
+ selectedOptions: string[];
2416
+ transcriptPath: string;
2417
+ transcriptSha256: string;
2418
+ toolCallId: string;
2419
+ presentation: ExecutionApprovalPresentation;
2420
+ }): Promise<void> {
2421
+ const { cwd, sessionId, approvalStage: stage } = options;
2422
+ if (
2423
+ !isExecutionApprovalId(options.questionId) ||
2424
+ !isExecutionApprovalId(options.gateId) ||
2425
+ !isExecutionApprovalId(options.toolCallId) ||
2426
+ !options.presentation ||
2427
+ options.target !== "ultragoal" ||
2428
+ options.selectedOptions.length !== 1
2429
+ )
2430
+ throw new StateCommandError(2, "ordinary execution approval descriptor is invalid");
2431
+ await withWorkflowStateLock(
2432
+ modeStateFile(cwd, stage, sessionId),
2433
+ async () => {
2434
+ const publication = await nonCrystalApprovalPublication(cwd, sessionId, stage);
2435
+ if (options.presentation && JSON.stringify(options.presentation) !== JSON.stringify(publication))
2436
+ throw new StateCommandError(2, "ordinary execution approval publication changed while awaiting consent");
2437
+ await withWorkflowStateLock(
2438
+ nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
2439
+ async () => {
2440
+ await readNonCrystalApproval(cwd, sessionId, stage);
2441
+ const history = await nonCrystalApprovalAuditRows(cwd, sessionId, stage);
2442
+ if (
2443
+ history.some(
2444
+ row =>
2445
+ row.gate_id === options.gateId ||
2446
+ (row.ordinary_approval_status === "consumed" &&
2447
+ row.artifact_sha256 === publication.artifact_sha256 &&
2448
+ row.run_id === publication.run_id),
2449
+ )
2450
+ )
2451
+ throw new StateCommandError(2, "ordinary execution approval consent replay refused");
2452
+ await writeNonCrystalApproval(cwd, {
2453
+ schema_version: 1,
2454
+ status: "pending",
2455
+ session_id: sessionId,
2456
+ stage,
2457
+ target: "ultragoal",
2458
+ ...publication,
2459
+ question_id: options.questionId,
2460
+ gate_id: options.gateId,
2461
+ answer_hash: answerHash(options.selectedOptions, undefined),
2462
+ transcript_path: options.transcriptPath,
2463
+ transcript_sha256: options.transcriptSha256,
2464
+ transcript_boundary: await captureExecutionApprovalTranscriptBoundary(
2465
+ cwd,
2466
+ sessionId,
2467
+ options.transcriptPath,
2468
+ options.transcriptSha256,
2469
+ options.toolCallId,
2470
+ ),
2471
+ created_at: nowIso(),
2472
+ expires_at: new Date(Date.now() + DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS).toISOString(),
2473
+ });
2474
+ },
2475
+ { cwd },
2476
+ );
2477
+ },
2478
+ { cwd },
2479
+ );
2480
+ }
2481
+
2482
+ async function assertNonCrystalApprovalCurrent(
2483
+ cwd: string,
2484
+ sessionId: string,
2485
+ stage: ExecutionApprovalStage,
2486
+ status: "pending" | "consumed",
2487
+ ) {
2488
+ const record = await readNonCrystalApproval(cwd, sessionId, stage);
2489
+ if (!record || record.status !== status || (status === "pending" && Date.parse(record.expires_at) <= Date.now()))
2490
+ throw new StateCommandError(2, "ordinary execution approval is missing, expired or consumed");
2491
+ const publication = await nonCrystalApprovalPublication(cwd, sessionId, stage);
2492
+ for (const key of ["state_path", "artifact_path", "artifact_sha256", "run_id"] as const)
2493
+ if (record[key] !== publication[key])
2494
+ throw new StateCommandError(2, "ordinary execution approval publication is stale");
2495
+ if (record.state_revision !== publication.state_revision) {
2496
+ const current = await readExistingStateForMutation(publication.state_path);
2497
+ const sanctionedPhaseTransition =
2498
+ stage === "ralplan" &&
2499
+ current.kind === "valid" &&
2500
+ current.value.current_phase === "handoff" &&
2501
+ isPlainObject(current.value.final_admission_phase_transition) &&
2502
+ publication.state_revision === record.state_revision + 1;
2503
+ if (!sanctionedPhaseTransition)
2504
+ throw new StateCommandError(2, "ordinary execution approval publication is stale");
2505
+ }
2506
+ await assertExecutionApprovalTranscriptBoundary(
2507
+ cwd,
2508
+ sessionId,
2509
+ record.transcript_path,
2510
+ record.transcript_sha256,
2511
+ record.transcript_boundary,
2512
+ );
2513
+ return record;
2514
+ }
2515
+
2516
+ /** Caller holds the workflow state lock. Consumption never rewrites final publication evidence. */
2517
+ async function consumeNonCrystalApprovalUnlocked(
2518
+ cwd: string,
2519
+ sessionId: string,
2520
+ stage: ExecutionApprovalStage,
2521
+ ): Promise<StateCommandResult> {
2522
+ return withWorkflowStateLock(
2523
+ nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
2524
+ async () => {
2525
+ const record = await assertNonCrystalApprovalCurrent(cwd, sessionId, stage, "pending");
2526
+ await writeNonCrystalApproval(cwd, { ...record, status: "consumed" });
2527
+ return { status: 0, stdout: `${JSON.stringify({ skill: stage, execution_approval: "approved" })}\n` };
2528
+ },
2529
+ { cwd },
2530
+ );
2531
+ }
2532
+
2533
+ export async function revokeNonCrystalExecutionApproval(
2534
+ cwd: string,
2535
+ sessionId: string,
2536
+ stage: ExecutionApprovalStage,
2537
+ ): Promise<void> {
2538
+ await withWorkflowStateLock(
2539
+ nonCrystalExecutionApprovalRecordPath(cwd, sessionId, stage),
2540
+ async () => {
2541
+ const record = await readNonCrystalApproval(cwd, sessionId, stage);
2542
+ if (record?.status === "pending") await writeNonCrystalApproval(cwd, { ...record, status: "revoked" });
2543
+ },
2544
+ { cwd },
2545
+ );
2546
+ }
2547
+ export interface DeepInterviewExecutionApprovalRecord {
2548
+ schema_version: 1;
2549
+ status: "pending" | "consumed";
2550
+ session_id: string;
2551
+ target: "ultragoal";
2552
+ state_path: string;
2553
+ state_revision: number;
2554
+ spec_path: string;
2555
+ spec_sha256: string;
2556
+ crystal_spec_version: number;
2557
+ crystal_source_digest: string;
2558
+ crystal_digest: string;
2559
+ question_id: string;
2560
+ gate_id: string;
2561
+ answer_hash: string;
2562
+ transcript_path: string;
2563
+ transcript_sha256: string;
2564
+ transcript_boundary: ExecutionApprovalTranscriptBoundary;
2565
+ approval_stage?: "deep-interview" | "ralplan";
2566
+ ralplan_state_path?: string;
2567
+ ralplan_state_revision?: number;
2568
+ ralplan_run_id?: string;
2569
+ ralplan_final_path?: string;
2570
+ ralplan_final_sha256?: string;
2571
+ created_at: string;
2572
+ expires_at: string;
2573
+ consumed_at?: string;
2574
+ consumed_mutation_id?: string;
2575
+ }
2576
+
2577
+ export function deepInterviewExecutionApprovalRecordPath(cwd: string, sessionId: string): string {
2578
+ return path.join(sessionStateDir(cwd, sessionId), DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_FILE);
2579
+ }
2580
+
2581
+ function isExecutionApprovalId(value: unknown): value is string {
2582
+ return (
2583
+ typeof value === "string" &&
2584
+ value.trim().length > 0 &&
2585
+ value.length <= DEEP_INTERVIEW_EXECUTION_APPROVAL_ID_MAX_LENGTH
2586
+ );
2587
+ }
2588
+
2589
+ function isSha256(value: unknown): value is string {
2590
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
2591
+ }
2592
+
2593
+ function assertExecutionApprovalRecordShape(value: unknown): asserts value is DeepInterviewExecutionApprovalRecord {
2594
+ if (!isPlainObject(value)) throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2595
+ const allowed = new Set([
2596
+ "schema_version",
2597
+ "status",
2598
+ "session_id",
2599
+ "target",
2600
+ "state_path",
2601
+ "state_revision",
2602
+ "spec_path",
2603
+ "spec_sha256",
2604
+ "crystal_spec_version",
2605
+ "crystal_source_digest",
2606
+ "crystal_digest",
2607
+ "question_id",
2608
+ "gate_id",
2609
+ "answer_hash",
2610
+ "transcript_path",
2611
+ "transcript_sha256",
2612
+ "transcript_boundary",
2613
+ "approval_stage",
2614
+ "ralplan_state_path",
2615
+ "ralplan_state_revision",
2616
+ "ralplan_run_id",
2617
+ "ralplan_final_path",
2618
+ "ralplan_final_sha256",
2619
+ "created_at",
2620
+ "expires_at",
2621
+ "consumed_at",
2622
+ "consumed_mutation_id",
2623
+ ]);
2624
+ if (Object.keys(value).some(key => !allowed.has(key)))
2625
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2626
+ if (
2627
+ value.schema_version !== 1 ||
2628
+ (value.status !== "pending" && value.status !== "consumed") ||
2629
+ !isExecutionApprovalId(value.session_id) ||
2630
+ value.target !== "ultragoal" ||
2631
+ typeof value.state_path !== "string" ||
2632
+ !path.isAbsolute(value.state_path) ||
2633
+ !Number.isSafeInteger(value.state_revision) ||
2634
+ (value.state_revision as number) < 0 ||
2635
+ typeof value.spec_path !== "string" ||
2636
+ !path.isAbsolute(value.spec_path) ||
2637
+ !isSha256(value.spec_sha256) ||
2638
+ !Number.isSafeInteger(value.crystal_spec_version) ||
2639
+ (value.crystal_spec_version as number) < 1 ||
2640
+ !isSha256(value.crystal_source_digest) ||
2641
+ !isSha256(value.crystal_digest) ||
2642
+ !isExecutionApprovalId(value.question_id) ||
2643
+ !isExecutionApprovalId(value.gate_id) ||
2644
+ !isSha256(value.answer_hash) ||
2645
+ typeof value.transcript_path !== "string" ||
2646
+ !path.isAbsolute(value.transcript_path) ||
2647
+ !isSha256(value.transcript_sha256) ||
2648
+ !isExecutionApprovalTranscriptBoundary(value.transcript_boundary) ||
2649
+ typeof value.created_at !== "string" ||
2650
+ typeof value.expires_at !== "string"
2651
+ )
2652
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2653
+ const createdAt = Date.parse(value.created_at);
2654
+ const expiresAt = Date.parse(value.expires_at);
2655
+ if (
2656
+ !Number.isFinite(createdAt) ||
2657
+ !Number.isFinite(expiresAt) ||
2658
+ expiresAt <= createdAt ||
2659
+ expiresAt - createdAt > DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS
2660
+ )
2661
+ throw new StateCommandError(2, "deep-interview execution approval record timestamp is invalid");
2662
+ if (value.status === "pending") {
2663
+ if (value.consumed_at !== undefined || value.consumed_mutation_id !== undefined)
2664
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2665
+ } else if (
2666
+ typeof value.consumed_at !== "string" ||
2667
+ !Number.isFinite(Date.parse(value.consumed_at)) ||
2668
+ !isExecutionApprovalId(value.consumed_mutation_id)
2669
+ ) {
2670
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2671
+ }
2672
+ if (
2673
+ value.approval_stage !== undefined &&
2674
+ value.approval_stage !== "deep-interview" &&
2675
+ value.approval_stage !== "ralplan"
2676
+ )
2677
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2678
+ if (
2679
+ value.approval_stage === "ralplan" &&
2680
+ (typeof value.ralplan_state_path !== "string" ||
2681
+ !path.isAbsolute(value.ralplan_state_path) ||
2682
+ !Number.isSafeInteger(value.ralplan_state_revision) ||
2683
+ (value.ralplan_state_revision as number) < 0 ||
2684
+ !isExecutionApprovalId(value.ralplan_run_id) ||
2685
+ typeof value.ralplan_final_path !== "string" ||
2686
+ !path.isAbsolute(value.ralplan_final_path) ||
2687
+ !isSha256(value.ralplan_final_sha256))
2688
+ )
2689
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2690
+ if (
2691
+ value.approval_stage !== "ralplan" &&
2692
+ [
2693
+ value.ralplan_state_path,
2694
+ value.ralplan_state_revision,
2695
+ value.ralplan_run_id,
2696
+ value.ralplan_final_path,
2697
+ value.ralplan_final_sha256,
2698
+ ].some(field => field !== undefined)
2699
+ )
2700
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2701
+ }
2702
+
2703
+ async function readDeepInterviewExecutionApprovalRecord(
2704
+ filePath: string,
2705
+ ): Promise<DeepInterviewExecutionApprovalRecord | undefined> {
2706
+ const raw = await readBoundedIdentityText(
2707
+ filePath,
2708
+ DEEP_INTERVIEW_EXECUTION_APPROVAL_RECORD_MAX_BYTES,
2709
+ "deep-interview execution approval record",
2710
+ );
2711
+ if (raw === undefined) return undefined;
2712
+ let parsed: unknown;
2713
+ try {
2714
+ parsed = JSON.parse(raw);
2715
+ } catch {
2716
+ throw new StateCommandError(2, "deep-interview execution approval record is invalid");
2717
+ }
2718
+ assertExecutionApprovalRecordShape(parsed);
2719
+ return parsed;
2720
+ }
2721
+
2722
+ async function assertExecutionApprovalSpecIdentity(record: DeepInterviewExecutionApprovalRecord): Promise<void> {
2723
+ const content = await readBoundedIdentityText(
2724
+ record.spec_path,
2725
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
2726
+ "deep-interview execution approval spec",
2727
+ );
2728
+ if (content === undefined || createHash("sha256").update(content).digest("hex") !== record.spec_sha256)
2729
+ throw new StateCommandError(2, "deep-interview execution approval spec identity mismatch");
2730
+ }
2731
+
2732
+ async function writeDeepInterviewExecutionApprovalRecord(
2733
+ cwd: string,
2734
+ sessionId: string,
2735
+ record: DeepInterviewExecutionApprovalRecord,
2736
+ ): Promise<void> {
2737
+ const recordPath = deepInterviewExecutionApprovalRecordPath(cwd, sessionId);
2738
+ await writeArtifact(recordPath, `${JSON.stringify(record)}\n`, {
2739
+ cwd,
2740
+ audit: {
2741
+ category: "artifact",
2742
+ verb: "write",
2743
+ owner: "gjc-runtime",
2744
+ skill: "deep-interview",
2745
+ sessionId,
2746
+ },
2747
+ });
2748
+ }
2749
+
2750
+ function crystalCanonicalDigest(crystal: Record<string, unknown>): string {
2751
+ return createHash("sha256")
2752
+ .update(crystalMarkdown(crystal as unknown as DeepInterviewCrystal))
2753
+ .digest("hex");
2754
+ }
2755
+
2756
+ function assertExecutionApprovalRecordMatchesCurrentState(
2757
+ record: DeepInterviewExecutionApprovalRecord,
2758
+ options: {
2759
+ sessionId: string;
2760
+ statePath: string;
2761
+ envelope: Record<string, unknown>;
2762
+ crystal: Record<string, unknown>;
2763
+ recordRevision?: number;
2764
+ allowConsumed?: boolean;
2765
+ },
2766
+ ): void {
2767
+ const now = Date.now();
2768
+ const createdAt = Date.parse(record.created_at);
2769
+ const expiresAt = Date.parse(record.expires_at);
2770
+ if (
2771
+ (record.status === "consumed" && !options.allowConsumed) ||
2772
+ !Number.isFinite(createdAt) ||
2773
+ !Number.isFinite(expiresAt) ||
2774
+ (!options.allowConsumed && (createdAt > now + 30_000 || expiresAt <= now))
2775
+ )
2776
+ throw new StateCommandError(2, "deep-interview execution approval record is stale or consumed");
2777
+ if (record.status === "consumed") {
2778
+ const consumedAt = Date.parse(record.consumed_at ?? "");
2779
+ if (!Number.isFinite(consumedAt) || consumedAt < createdAt || consumedAt > now + 30_000)
2780
+ throw new StateCommandError(2, "deep-interview execution approval record provenance is invalid");
2781
+ }
2782
+ const expectedSpecPath =
2783
+ typeof options.envelope.spec_path === "string" ? path.resolve(options.envelope.spec_path) : "";
2784
+ const expectedSpecSha = typeof options.envelope.spec_sha256 === "string" ? options.envelope.spec_sha256 : "";
2785
+ const revision = options.recordRevision ?? existingStateRevision(options.envelope);
2786
+ if (
2787
+ record.session_id !== options.sessionId ||
2788
+ record.state_path !== path.resolve(options.statePath) ||
2789
+ record.state_revision !== revision ||
2790
+ record.spec_path !== expectedSpecPath ||
2791
+ record.spec_sha256 !== expectedSpecSha ||
2792
+ record.crystal_spec_version !== options.crystal.spec_version ||
2793
+ record.crystal_source_digest !== (options.crystal.source as Record<string, unknown>).digest ||
2794
+ record.crystal_digest !== crystalCanonicalDigest(options.crystal)
2795
+ )
2796
+ throw new StateCommandError(2, "deep-interview execution approval record does not match current Crystal");
2797
+ }
2798
+
2799
+ export async function recordDeepInterviewExecutionApproval(options: {
2800
+ cwd: string;
2801
+ sessionId: string;
2802
+ questionId: string;
2803
+ gateId?: string;
2804
+ target: string;
2805
+ selectedOptions: readonly string[];
2806
+ customInput?: string;
2807
+ transcriptPath: string;
2808
+ transcriptSha256: string;
2809
+ toolCallId: string;
2810
+ approvalStage?: "deep-interview" | "ralplan";
2811
+ presentation: ExecutionApprovalPresentation;
2812
+ }): Promise<{ path: string; record: DeepInterviewExecutionApprovalRecord }> {
2813
+ if (options.target !== "ultragoal")
2814
+ throw new StateCommandError(2, "deep-interview execution approval target must be ultragoal");
2815
+ if (
2816
+ !isExecutionApprovalId(options.sessionId) ||
2817
+ !isExecutionApprovalId(options.questionId) ||
2818
+ !isExecutionApprovalId(options.toolCallId) ||
2819
+ !options.presentation
2820
+ )
2821
+ throw new StateCommandError(2, "deep-interview execution approval descriptor is invalid");
2822
+ if (
2823
+ !Array.isArray(options.selectedOptions) ||
2824
+ options.selectedOptions.length !== 1 ||
2825
+ options.customInput !== undefined
2826
+ )
2827
+ throw new StateCommandError(2, "deep-interview execution approval answer is invalid");
2828
+ const statePath = modeStateFile(options.cwd, "deep-interview", options.sessionId);
2829
+ const recordPath = deepInterviewExecutionApprovalRecordPath(options.cwd, options.sessionId);
2830
+ return withWorkflowStateLock(
2831
+ statePath,
2832
+ async () => {
2833
+ const current = await readExistingStateForMutation(statePath);
2834
+ if (current.kind !== "valid")
2835
+ throw new StateCommandError(2, "deep-interview execution approval requires valid current state");
2836
+ const envelope = normalizeDeepInterviewEnvelope(current.value) as Record<string, unknown>;
2837
+ const ralplanApproval =
2838
+ options.approvalStage === "ralplan" &&
2839
+ envelope.active === false &&
2840
+ envelope.handoff_to === "ralplan" &&
2841
+ typeof envelope.handoff_at === "string" &&
2842
+ (await hasAuditedDeepInterviewHandoff(options.cwd, options.sessionId, "ralplan", {
2843
+ handoffAt: envelope.handoff_at,
2844
+ }));
2845
+ let ralplanFinal: VerifiedRalplanFinalEvidence | undefined;
2846
+ if (ralplanApproval) {
2847
+ const ralplanPath = modeStateFile(options.cwd, "ralplan", options.sessionId);
2848
+ const ralplanRead = await readExistingStateForMutation(ralplanPath);
2849
+ if (ralplanRead.kind !== "valid")
2850
+ throw new StateCommandError(2, "Ralplan execution approval requires valid final state");
2851
+ const ralplanState = migrateWorkflowState(ralplanRead.value, "ralplan").state;
2852
+ if (ralplanState.active !== true || ralplanState.current_phase !== "final")
2853
+ throw new StateCommandError(2, "Ralplan execution approval requires current final plan");
2854
+ ralplanFinal = await verifiedRalplanFinalEvidence(options.cwd, options.sessionId, ralplanState);
2855
+ if (!ralplanFinal)
2856
+ throw new StateCommandError(2, "Ralplan execution approval requires verified final plan evidence");
2857
+ }
2858
+ if (
2859
+ envelope.version !== WORKFLOW_STATE_VERSION ||
2860
+ (envelope.active !== true && !ralplanApproval) ||
2861
+ envelope.current_phase !== "handoff"
2862
+ )
2863
+ throw new StateCommandError(2, "deep-interview execution approval requires active handoff state");
2864
+ const inner = isPlainObject(envelope.state) ? envelope.state : {};
2865
+ const crystal = requireReadyCanonicalCrystal(inner.crystal);
2866
+ if (inner.execution_approval === "approved")
2867
+ throw new StateCommandError(2, "deep-interview execution approval is already consumed");
2868
+ const publicationReceipt = isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
2869
+ const publicationChecksum = isPlainObject(publicationReceipt?.content_sha256)
2870
+ ? publicationReceipt.content_sha256
2871
+ : undefined;
2872
+ if (
2873
+ (ralplanApproval
2874
+ ? publicationReceipt?.owner !== "gjc-state-cli" ||
2875
+ publicationReceipt.command !== "gjc state deep-interview handoff --to ralplan"
2876
+ : publicationReceipt?.owner !== "gjc-runtime" ||
2877
+ publicationReceipt.command !== "gjc deep-interview crystallize") ||
2878
+ publicationChecksum?.algorithm !== "sha256" ||
2879
+ !isSha256(publicationChecksum.value) ||
2880
+ publicationChecksum.covered_path !== path.resolve(statePath)
2881
+ )
2882
+ throw new StateCommandError(2, "deep-interview execution approval requires a published Crystal");
2883
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(
2884
+ options.cwd,
2885
+ options.sessionId,
2886
+ statePath,
2887
+ "deep-interview",
2888
+ );
2889
+ if (integrityWarning)
2890
+ throw new StateCommandError(2, `${integrityWarning}; execution approval refuses tampered mode-state`);
2891
+ await assertDeepInterviewHandoffReady(envelope, {
2892
+ cwd: options.cwd,
2893
+ sessionId: options.sessionId,
2894
+ statePath,
2895
+ });
2896
+ const specPath = typeof envelope.spec_path === "string" ? path.resolve(envelope.spec_path) : "";
2897
+ const specContent = await readBoundedIdentityText(
2898
+ specPath,
2899
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
2900
+ "canonical Crystal spec",
2901
+ );
2902
+ if (specContent === undefined || specContent !== crystalMarkdown(crystal as unknown as DeepInterviewCrystal))
2903
+ throw new StateCommandError(
2904
+ 2,
2905
+ "deep-interview execution approval requires canonical Crystal spec identity",
2906
+ );
2907
+ const currentRevision = existingStateRevision(envelope);
2908
+ if (typeof currentRevision !== "number" || !Number.isSafeInteger(currentRevision) || currentRevision < 0)
2909
+ throw new StateCommandError(2, "deep-interview execution approval requires a valid state revision");
2910
+ const currentPresentation: ExecutionApprovalPresentation = ralplanFinal
2911
+ ? {
2912
+ state_path: path.resolve(statePath),
2913
+ state_revision: currentRevision,
2914
+ artifact_path: ralplanFinal.finalPath,
2915
+ artifact_sha256: ralplanFinal.finalSha256,
2916
+ run_id: ralplanFinal.runId,
2917
+ }
2918
+ : {
2919
+ state_path: path.resolve(statePath),
2920
+ state_revision: currentRevision,
2921
+ artifact_path: specPath,
2922
+ artifact_sha256: envelope.spec_sha256 as string,
2923
+ run_id: null,
2924
+ };
2925
+ if (options.presentation && JSON.stringify(options.presentation) !== JSON.stringify(currentPresentation))
2926
+ throw new StateCommandError(
2927
+ 2,
2928
+ "deep-interview execution approval publication changed while awaiting consent",
2929
+ );
2930
+ const gateId = options.gateId ?? options.questionId;
2931
+ if (!isExecutionApprovalId(gateId))
2932
+ throw new StateCommandError(2, "deep-interview execution approval descriptor is invalid");
2933
+ const now = nowIso();
2934
+ const record: DeepInterviewExecutionApprovalRecord = {
2935
+ schema_version: 1,
2936
+ status: "pending",
2937
+ session_id: options.sessionId,
2938
+ target: "ultragoal",
2939
+ state_path: path.resolve(statePath),
2940
+ state_revision: currentRevision,
2941
+ spec_path: specPath,
2942
+ spec_sha256: createHash("sha256").update(specContent).digest("hex"),
2943
+ crystal_spec_version: crystal.spec_version as number,
2944
+ crystal_source_digest: (crystal.source as Record<string, unknown>).digest as string,
2945
+ crystal_digest: crystalCanonicalDigest(crystal),
2946
+ question_id: options.questionId,
2947
+ gate_id: gateId,
2948
+ answer_hash: answerHash([...options.selectedOptions], options.customInput),
2949
+ transcript_path: path.resolve(options.transcriptPath),
2950
+ transcript_sha256: options.transcriptSha256,
2951
+ transcript_boundary: await captureExecutionApprovalTranscriptBoundary(
2952
+ options.cwd,
2953
+ options.sessionId,
2954
+ options.transcriptPath,
2955
+ options.transcriptSha256,
2956
+ options.toolCallId,
2957
+ ),
2958
+ approval_stage: options.approvalStage ?? "deep-interview",
2959
+ ...(ralplanFinal
2960
+ ? {
2961
+ ralplan_state_path: ralplanFinal.statePath,
2962
+ ralplan_state_revision: ralplanFinal.stateRevision,
2963
+ ralplan_run_id: ralplanFinal.runId,
2964
+ ralplan_final_path: ralplanFinal.finalPath,
2965
+ ralplan_final_sha256: ralplanFinal.finalSha256,
2966
+ }
2967
+ : {}),
2968
+ created_at: now,
2969
+ expires_at: new Date(Date.parse(now) + DEEP_INTERVIEW_EXECUTION_APPROVAL_MAX_AGE_MS).toISOString(),
2970
+ };
2971
+ return withWorkflowStateLock(
2972
+ recordPath,
2973
+ async () => {
2974
+ const existing = await readDeepInterviewExecutionApprovalRecord(recordPath);
2975
+ if (existing?.status === "consumed") {
2976
+ if (record.gate_id === existing.gate_id)
2977
+ throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
2978
+ const newPublication =
2979
+ inner.execution_approval === "not-approved" &&
2980
+ inner.execution_approval_receipt === undefined &&
2981
+ (ralplanApproval || publicationReceipt?.command === "gjc deep-interview crystallize") &&
2982
+ record.crystal_spec_version > existing.crystal_spec_version &&
2983
+ record.crystal_source_digest !== existing.crystal_source_digest &&
2984
+ record.spec_sha256 !== existing.spec_sha256 &&
2985
+ isPlainObject(crystal.delta) &&
2986
+ crystal.delta.approval_invalidated === true;
2987
+ if (!newPublication && !existing.consumed_mutation_id?.startsWith("deep-interview:approval-revoked:"))
2988
+ throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
2989
+ if (newPublication) {
2990
+ await writeArtifact(
2991
+ `${recordPath}.${existing.crystal_spec_version}.${existing.spec_sha256}.consumed`,
2992
+ `${JSON.stringify(existing)}\n`,
2993
+ {
2994
+ cwd: options.cwd,
2995
+ audit: {
2996
+ category: "artifact",
2997
+ verb: "write",
2998
+ owner: "gjc-runtime",
2999
+ skill: "deep-interview",
3000
+ sessionId: options.sessionId,
3001
+ },
3002
+ },
3003
+ );
3004
+ }
3005
+ }
3006
+ if (existing?.status === "pending") {
3007
+ await assertExecutionApprovalSpecIdentity(existing);
3008
+ try {
3009
+ assertExecutionApprovalRecordMatchesCurrentState(existing, {
3010
+ sessionId: options.sessionId,
3011
+ statePath,
3012
+ envelope,
3013
+ crystal,
3014
+ allowConsumed: false,
3015
+ });
3016
+ if (
3017
+ existing.question_id === record.question_id &&
3018
+ existing.gate_id === record.gate_id &&
3019
+ existing.answer_hash === record.answer_hash &&
3020
+ existing.target === record.target
3021
+ )
3022
+ return { path: recordPath, record: existing };
3023
+ } catch (error) {
3024
+ if (
3025
+ !(error instanceof StateCommandError) ||
3026
+ ![
3027
+ "deep-interview execution approval record is stale or consumed",
3028
+ "deep-interview execution approval record does not match current Crystal",
3029
+ ].includes(error.message)
3030
+ )
3031
+ throw error;
3032
+ }
3033
+ if (record.gate_id === existing.gate_id)
3034
+ throw new StateCommandError(2, "deep-interview execution approval consent replay refused");
3035
+ }
3036
+ await writeDeepInterviewExecutionApprovalRecord(options.cwd, options.sessionId, record);
3037
+ return { path: recordPath, record };
3038
+ },
3039
+ { cwd: options.cwd },
3040
+ );
3041
+ },
3042
+ { cwd: options.cwd },
3043
+ );
3044
+ }
3045
+
3046
+ export async function revokeDeepInterviewExecutionApproval(cwd: string, sessionId: string): Promise<void> {
3047
+ const recordPath = deepInterviewExecutionApprovalRecordPath(cwd, sessionId);
3048
+ await withWorkflowStateLock(
3049
+ recordPath,
3050
+ async () => {
3051
+ const existing = await readDeepInterviewExecutionApprovalRecord(recordPath);
3052
+ if (existing?.status !== "pending") return;
3053
+ const revokedAt = nowIso();
3054
+ await writeDeepInterviewExecutionApprovalRecord(cwd, sessionId, {
3055
+ ...existing,
3056
+ status: "consumed",
3057
+ consumed_at: revokedAt,
3058
+ consumed_mutation_id: `deep-interview:approval-revoked:${revokedAt}`,
3059
+ });
3060
+ },
3061
+ { cwd },
3062
+ );
3063
+ }
3064
+
3065
+ function sameBoundedFileIdentity(left: nodeFs.BigIntStats, right: nodeFs.BigIntStats): boolean {
3066
+ return (
3067
+ left.dev === right.dev &&
3068
+ left.ino === right.ino &&
3069
+ left.mode === right.mode &&
3070
+ left.size === right.size &&
3071
+ left.mtimeNs === right.mtimeNs &&
3072
+ left.ctimeNs === right.ctimeNs &&
3073
+ left.nlink === right.nlink
3074
+ );
3075
+ }
3076
+
3077
+ async function readBoundedIdentityText(
3078
+ filePath: string,
3079
+ maxBytes: number,
3080
+ label: string,
3081
+ options: { tail?: boolean; offset?: number } = {},
3082
+ ): Promise<string | undefined> {
3083
+ let initialStat: nodeFs.BigIntStats;
3084
+ try {
3085
+ initialStat = await fs.lstat(filePath, { bigint: true });
3086
+ } catch (error) {
3087
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
3088
+ throw new StateCommandError(2, `failed to read ${label}: ${(error as Error).message}`);
3089
+ }
3090
+ if (initialStat.isSymbolicLink() || !initialStat.isFile()) throw new StateCommandError(2, `${label} is invalid`);
3091
+ const openFlags =
3092
+ nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
3093
+ let handle: fs.FileHandle | undefined;
3094
+ try {
3095
+ handle = await fs.open(filePath, openFlags);
3096
+ const openedStat = await handle.stat({ bigint: true });
3097
+ const beforeReadStat = await fs.lstat(filePath, { bigint: true });
3098
+ if (
3099
+ openedStat.isSymbolicLink() ||
3100
+ !openedStat.isFile() ||
3101
+ beforeReadStat.isSymbolicLink() ||
3102
+ !sameBoundedFileIdentity(initialStat, openedStat) ||
3103
+ !sameBoundedFileIdentity(initialStat, beforeReadStat)
3104
+ )
3105
+ throw new StateCommandError(2, `${label} is invalid`);
3106
+ const requestedOffset = options.offset === undefined ? undefined : BigInt(options.offset);
3107
+ if (requestedOffset !== undefined && (requestedOffset < 0n || requestedOffset > openedStat.size))
3108
+ throw new StateCommandError(2, `${label} offset is invalid`);
3109
+ const start =
3110
+ requestedOffset ??
3111
+ (options.tail ? (openedStat.size > BigInt(maxBytes) ? openedStat.size - BigInt(maxBytes) : 0n) : 0n);
3112
+ const available = openedStat.size - start;
3113
+ const readSize =
3114
+ requestedOffset === undefined ? available : available > BigInt(maxBytes) ? BigInt(maxBytes) : available;
3115
+ if (!options.tail && requestedOffset === undefined && readSize > BigInt(maxBytes))
3116
+ throw new StateCommandError(2, `${label} is invalid`);
3117
+ const buffer = Buffer.alloc(Number(readSize));
3118
+ let offset = 0;
3119
+ while (offset < buffer.length) {
3120
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, Number(start) + offset);
3121
+ if (bytesRead === 0) throw new StateCommandError(2, `${label} is invalid`);
3122
+ offset += bytesRead;
3123
+ }
3124
+ const afterReadStat = await handle.stat({ bigint: true });
3125
+ const afterPathStat = await fs.lstat(filePath, { bigint: true });
3126
+ if (
3127
+ afterPathStat.isSymbolicLink() ||
3128
+ !afterPathStat.isFile() ||
3129
+ !sameBoundedFileIdentity(initialStat, afterReadStat) ||
3130
+ !sameBoundedFileIdentity(initialStat, afterPathStat)
3131
+ )
3132
+ throw new StateCommandError(2, `${label} is invalid`);
3133
+ let text = buffer.subarray(0, offset).toString("utf-8");
3134
+ if (options.tail && start > 0n) {
3135
+ const firstNewline = text.indexOf("\n");
3136
+ text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
3137
+ }
3138
+ return text;
3139
+ } catch (error) {
3140
+ if (error instanceof StateCommandError) throw error;
3141
+ throw new StateCommandError(2, `failed to read ${label}: ${(error as Error).message}`);
3142
+ } finally {
3143
+ await handle?.close().catch(() => undefined);
3144
+ }
3145
+ }
3146
+
3147
+ async function hashIdentityFile(filePath: string, label: string): Promise<string | undefined> {
3148
+ let initialStat: nodeFs.BigIntStats;
3149
+ try {
3150
+ initialStat = await fs.lstat(filePath, { bigint: true });
3151
+ } catch (error) {
3152
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
3153
+ throw new StateCommandError(2, `failed to hash ${label}: ${(error as Error).message}`);
3154
+ }
3155
+ if (initialStat.isSymbolicLink() || !initialStat.isFile() || initialStat.size > BigInt(Number.MAX_SAFE_INTEGER))
3156
+ return undefined;
3157
+ const openFlags =
3158
+ nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
3159
+ let handle: fs.FileHandle | undefined;
3160
+ try {
3161
+ handle = await fs.open(filePath, openFlags);
3162
+ const openedStat = await handle.stat({ bigint: true });
3163
+ const beforeReadStat = await fs.lstat(filePath, { bigint: true });
3164
+ if (
3165
+ openedStat.isSymbolicLink() ||
3166
+ !openedStat.isFile() ||
3167
+ beforeReadStat.isSymbolicLink() ||
3168
+ !sameBoundedFileIdentity(initialStat, openedStat) ||
3169
+ !sameBoundedFileIdentity(initialStat, beforeReadStat)
3170
+ )
3171
+ return undefined;
3172
+ const hasher = createHash("sha256");
3173
+ const buffer = Buffer.alloc(64 * 1024);
3174
+ let position = 0;
3175
+ const size = Number(openedStat.size);
3176
+ while (position < size) {
3177
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, size - position), position);
3178
+ if (bytesRead === 0) return undefined;
3179
+ hasher.update(buffer.subarray(0, bytesRead));
3180
+ position += bytesRead;
3181
+ }
3182
+ const afterReadStat = await handle.stat({ bigint: true });
3183
+ const afterPathStat = await fs.lstat(filePath, { bigint: true });
3184
+ if (
3185
+ afterPathStat.isSymbolicLink() ||
3186
+ !afterPathStat.isFile() ||
3187
+ !sameBoundedFileIdentity(initialStat, afterReadStat) ||
3188
+ !sameBoundedFileIdentity(initialStat, afterPathStat)
3189
+ )
3190
+ return undefined;
3191
+ return hasher.digest("hex");
3192
+ } catch {
3193
+ return undefined;
3194
+ } finally {
3195
+ await handle?.close().catch(() => undefined);
3196
+ }
3197
+ }
3198
+
3199
+ async function assertSanctionedExecutionApprovalAudit(
3200
+ cwd: string,
3201
+ sessionId: string,
3202
+ statePath: string,
3203
+ approval: Record<string, unknown>,
3204
+ envelope?: Record<string, unknown>,
3205
+ ): Promise<void> {
3206
+ if (!cwd || !sessionId || !statePath)
3207
+ throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned transition provenance");
3208
+ const mutationId = typeof approval.mutation_id === "string" ? approval.mutation_id : undefined;
3209
+ const approvedAt = typeof approval.approved_at === "string" ? approval.approved_at : undefined;
3210
+ const resolvedStatePath = path.resolve(statePath);
3211
+ const resolvedActivePath = path.resolve(activeStateFile(cwd, sessionId));
3212
+ const approvalRevision = approval.state_revision;
3213
+ const approvalStatePath = typeof approval.state_path === "string" ? path.resolve(approval.state_path) : undefined;
3214
+ if (
3215
+ !mutationId ||
3216
+ !approvedAt ||
3217
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(approvedAt) ||
3218
+ !Number.isSafeInteger(approvalRevision) ||
3219
+ (approvalRevision as number) < 1 ||
3220
+ approvalStatePath !== resolvedStatePath ||
3221
+ mutationId !== `deep-interview:approve-execution:${approvedAt}`
3222
+ )
3223
+ throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned transition provenance");
3224
+
3225
+ const indexedApprovalPath = path.join(sessionStateDir(cwd, sessionId), "deep-interview-approval-audit.json");
3226
+ let indexedRaw = "";
3227
+ try {
3228
+ const sameFileIdentity = (left: nodeFs.BigIntStats, right: nodeFs.BigIntStats): boolean =>
3229
+ left.dev === right.dev &&
3230
+ left.ino === right.ino &&
3231
+ left.mode === right.mode &&
3232
+ left.size === right.size &&
3233
+ left.mtimeNs === right.mtimeNs &&
3234
+ left.ctimeNs === right.ctimeNs &&
3235
+ left.nlink === right.nlink;
3236
+ const initialStat = await fs.lstat(indexedApprovalPath, { bigint: true });
3237
+ if (initialStat.isSymbolicLink() || !initialStat.isFile())
3238
+ throw new StateCommandError(2, "deep-interview execution approval index is invalid");
3239
+ const openFlags =
3240
+ nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
3241
+ const handle = await fs.open(indexedApprovalPath, openFlags);
3242
+ try {
3243
+ const indexedStat = await handle.stat({ bigint: true });
3244
+ const beforeReadStat = await fs.lstat(indexedApprovalPath, { bigint: true });
3245
+ if (
3246
+ indexedStat.isSymbolicLink() ||
3247
+ !indexedStat.isFile() ||
3248
+ beforeReadStat.isSymbolicLink() ||
3249
+ !sameFileIdentity(initialStat, indexedStat) ||
3250
+ !sameFileIdentity(initialStat, beforeReadStat) ||
3251
+ indexedStat.size > 64n * 1024n
3252
+ )
3253
+ throw new StateCommandError(2, "deep-interview execution approval index is invalid");
3254
+ const buffer = Buffer.alloc(Number(indexedStat.size));
3255
+ let offset = 0;
3256
+ while (offset < buffer.length) {
3257
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
3258
+ if (bytesRead === 0) throw new StateCommandError(2, "deep-interview execution approval index is invalid");
3259
+ offset += bytesRead;
3260
+ }
3261
+ const afterReadStat = await handle.stat({ bigint: true });
3262
+ const afterPathStat = await fs.lstat(indexedApprovalPath, { bigint: true });
3263
+ if (
3264
+ afterPathStat.isSymbolicLink() ||
3265
+ !afterPathStat.isFile() ||
3266
+ !sameFileIdentity(initialStat, afterReadStat) ||
3267
+ !sameFileIdentity(initialStat, afterPathStat)
3268
+ )
3269
+ throw new StateCommandError(2, "deep-interview execution approval index is invalid");
3270
+ indexedRaw = buffer.subarray(0, offset).toString("utf-8");
3271
+ } finally {
3272
+ await handle.close();
3273
+ }
3274
+ const indexedRecord: unknown = JSON.parse(indexedRaw);
3275
+ if (!isPlainObject(indexedRecord))
3276
+ throw new StateCommandError(2, "deep-interview execution approval index is invalid");
3277
+ indexedRaw = `${JSON.stringify(indexedRecord)}\n`;
3278
+ } catch (error) {
3279
+ if (error instanceof StateCommandError) throw error;
3280
+ const err = error as NodeJS.ErrnoException;
3281
+ if (err.code !== "ENOENT")
3282
+ throw new StateCommandError(2, `failed to read execution approval index: ${err.message}`);
3283
+ }
3284
+ let auditRaw = "";
3285
+ try {
3286
+ const filePath = auditPath(cwd, sessionId);
3287
+ const stat = await fs.stat(filePath);
3288
+ const maxBytes = 1024 * 1024;
3289
+ const start = Math.max(0, stat.size - maxBytes);
3290
+ const handle = await fs.open(filePath, "r");
3291
+ try {
3292
+ const buffer = Buffer.alloc(stat.size - start);
3293
+ let offset = 0;
3294
+ while (offset < buffer.length) {
3295
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, start + offset);
3296
+ if (bytesRead === 0) throw new Error("execution approval audit ended before the bounded tail was read");
3297
+ offset += bytesRead;
3298
+ }
3299
+ auditRaw = buffer.subarray(0, offset).toString("utf-8");
3300
+ if (start > 0) {
3301
+ const firstNewline = auditRaw.indexOf("\n");
3302
+ auditRaw = firstNewline >= 0 ? auditRaw.slice(firstNewline + 1) : "";
3303
+ }
3304
+ } finally {
3305
+ await handle.close();
3306
+ }
3307
+ } catch (error) {
3308
+ const err = error as NodeJS.ErrnoException;
3309
+ if (err.code === "ENOENT" && !indexedRaw)
3310
+ throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned approval audit record");
3311
+ if (err.code !== "ENOENT")
3312
+ throw new StateCommandError(2, `failed to read execution approval audit: ${err.message}`);
3313
+ }
3314
+ const raw = `${indexedRaw}${auditRaw}`;
3315
+
3316
+ const found = raw.split(/\r?\n/).some(line => {
3317
+ if (!line.trim()) return false;
3318
+ let parsed: unknown;
3319
+ try {
3320
+ parsed = JSON.parse(line);
3321
+ } catch {
3322
+ return false;
3323
+ }
3324
+ if (!isPlainObject(parsed)) return false;
3325
+ const paths = Array.isArray(parsed.paths)
3326
+ ? parsed.paths.filter((value): value is string => typeof value === "string")
3327
+ : [];
3328
+ const auditReceipt = isPlainObject(parsed.receipt) ? parsed.receipt : undefined;
3329
+ const auditChecksum = isPlainObject(auditReceipt?.content_sha256) ? auditReceipt.content_sha256 : undefined;
3330
+ const auditPath = typeof parsed.state_path === "string" ? path.resolve(parsed.state_path) : undefined;
3331
+ const receiptIdentityMatches =
3332
+ Boolean(auditReceipt) &&
3333
+ auditReceipt?.version === 1 &&
3334
+ auditReceipt?.skill === "deep-interview" &&
3335
+ auditReceipt?.owner === "gjc-state-cli" &&
3336
+ auditReceipt?.command === "gjc state deep-interview approve-execution" &&
3337
+ auditReceipt?.verb === "approve-execution" &&
3338
+ auditReceipt?.from_phase === "handoff" &&
3339
+ auditReceipt?.to_phase === "handoff" &&
3340
+ auditReceipt?.forced === false &&
3341
+ auditReceipt?.mutation_id === mutationId &&
3342
+ auditReceipt?.mutated_at === approvedAt &&
3343
+ auditReceipt?.storage_path === resolvedStatePath &&
3344
+ auditReceipt?.state_path === resolvedActivePath &&
3345
+ auditChecksum?.algorithm === "sha256" &&
3346
+ typeof auditChecksum.value === "string" &&
3347
+ auditChecksum.value.length === 64 &&
3348
+ auditChecksum.covered_path === resolvedStatePath;
3349
+ const exactBinding =
3350
+ parsed.ts === approvedAt &&
3351
+ parsed.approved_at === approvedAt &&
3352
+ parsed.from_phase === "handoff" &&
3353
+ parsed.to_phase === "handoff" &&
3354
+ parsed.state_revision === approvalRevision &&
3355
+ parsed.receipt_state_revision === approvalRevision &&
3356
+ auditPath === resolvedStatePath &&
3357
+ receiptIdentityMatches;
3358
+ return (
3359
+ parsed.skill === "deep-interview" &&
3360
+ parsed.category === "state" &&
3361
+ parsed.verb === "approve-execution" &&
3362
+ parsed.owner === "gjc-state-cli" &&
3363
+ parsed.mutation_id === mutationId &&
3364
+ parsed.forced === false &&
3365
+ paths.length === 1 &&
3366
+ path.resolve(paths[0]) === resolvedStatePath &&
3367
+ exactBinding
3368
+ );
1621
3369
  });
3370
+ if (!found)
3371
+ throw new StateCommandError(2, "deep-interview execution approval lacks sanctioned approval audit record");
3372
+
3373
+ // Before the execution handoff itself, the top-level receipt still carries
3374
+ // the approval mutation. Bind the audit to that exact stamped receipt so a
3375
+ // restamp can never turn a forged approval into an authenticated one. A
3376
+ // completed handoff retry has a new top-level handoff receipt and instead
3377
+ // relies on the durable fields above plus the retry identity checks.
3378
+ const currentReceipt = envelope && isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
3379
+ if (currentReceipt?.mutation_id === mutationId) {
3380
+ const auditLine = raw
3381
+ .split(/\r?\n/)
3382
+ .map(line => {
3383
+ try {
3384
+ const parsed: unknown = line.trim() ? JSON.parse(line) : undefined;
3385
+ return isPlainObject(parsed) && parsed.mutation_id === mutationId && isPlainObject(parsed.receipt)
3386
+ ? parsed
3387
+ : undefined;
3388
+ } catch {
3389
+ return undefined;
3390
+ }
3391
+ })
3392
+ .find((entry): entry is Record<string, unknown> => Boolean(entry));
3393
+ const auditedReceipt = auditLine && isPlainObject(auditLine.receipt) ? auditLine.receipt : undefined;
3394
+ if (!auditedReceipt || JSON.stringify(auditedReceipt) !== JSON.stringify(currentReceipt))
3395
+ throw new StateCommandError(2, "deep-interview execution approval receipt binding mismatch");
3396
+ }
3397
+ }
3398
+
3399
+ /** Historical audit entries do not attach lineage to a replacement workflow run. */
3400
+ async function hasCurrentDeepInterviewHandoff(
3401
+ cwd: string,
3402
+ sessionId: string,
3403
+ callee: CanonicalGjcWorkflowSkill,
3404
+ state: Record<string, unknown>,
3405
+ ): Promise<boolean> {
3406
+ const handoffAt = state.upstream_handoff_at ?? state.handoff_at;
3407
+ if (
3408
+ state.handoff_from === undefined &&
3409
+ state.upstream_handoff_at === undefined &&
3410
+ (handoffAt === undefined || state.handoff_to !== undefined)
3411
+ )
3412
+ return false;
3413
+ if (
3414
+ typeof handoffAt !== "string" ||
3415
+ !handoffAt.trim() ||
3416
+ !(await hasAuditedDeepInterviewHandoff(cwd, sessionId, callee, { handoffAt }))
3417
+ )
3418
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3419
+ return true;
3420
+ }
3421
+
3422
+ async function hasAuditedDeepInterviewHandoff(
3423
+ cwd: string,
3424
+ sessionId: string,
3425
+ callee: CanonicalGjcWorkflowSkill,
3426
+ options: { handoffAt?: string; indexOnly?: boolean } = {},
3427
+ ): Promise<boolean> {
3428
+ const indexedRaw = await readBoundedIdentityText(
3429
+ path.join(sessionStateDir(cwd, sessionId), `deep-interview-handoff-${callee}-audit.json`),
3430
+ 64 * 1024,
3431
+ "deep-interview handoff index",
3432
+ );
3433
+ const auditRaw = options.indexOnly
3434
+ ? undefined
3435
+ : await readBoundedIdentityText(auditPath(cwd, sessionId), 1024 * 1024, "deep-interview handoff audit", {
3436
+ tail: true,
3437
+ });
3438
+ const raw = `${indexedRaw ?? ""}${auditRaw ?? ""}`;
3439
+ if (!raw) return false;
3440
+ const sourcePath = path.resolve(modeStateFile(cwd, "deep-interview", sessionId));
3441
+ const calleePath = path.resolve(modeStateFile(cwd, callee, sessionId));
3442
+ const activePath = path.resolve(activeStateFile(cwd, sessionId));
3443
+ const expectedAt = options.handoffAt;
3444
+ const expectedMutation = expectedAt ? `deep-interview:handoff:${callee}:${expectedAt}` : undefined;
3445
+ return raw.split(/\r?\n/).some(line => {
3446
+ if (!line.trim()) return false;
3447
+ let parsed: unknown;
3448
+ try {
3449
+ parsed = JSON.parse(line);
3450
+ } catch {
3451
+ return false;
3452
+ }
3453
+ if (!isPlainObject(parsed) || !Array.isArray(parsed.paths)) return false;
3454
+ const mutationId = parsed.mutation_id;
3455
+ const paths = parsed.paths
3456
+ .filter((value): value is string => typeof value === "string")
3457
+ .map(value => path.resolve(value));
3458
+ const callerReceipt = persistedWorkflowReceipt(parsed.caller_receipt, "deep-interview");
3459
+ const calleeReceipt = persistedWorkflowReceipt(parsed.callee_receipt, callee);
3460
+ const callerChecksum = callerReceipt?.content_sha256;
3461
+ const calleeChecksum = calleeReceipt?.content_sha256;
3462
+ const callerRevision = parsed.caller_state_revision;
3463
+ const calleeRevision = parsed.callee_state_revision;
3464
+ return (
3465
+ parsed.skill === "deep-interview" &&
3466
+ parsed.category === "state" &&
3467
+ parsed.verb === "handoff" &&
3468
+ parsed.owner === "gjc-state-cli" &&
3469
+ parsed.forced === false &&
3470
+ typeof mutationId === "string" &&
3471
+ (!expectedMutation
3472
+ ? mutationId.startsWith(`deep-interview:handoff:${callee}:`)
3473
+ : mutationId === expectedMutation) &&
3474
+ typeof parsed.ts === "string" &&
3475
+ (!expectedAt
3476
+ ? parsed.ts === mutationId.slice(`deep-interview:handoff:${callee}:`.length)
3477
+ : parsed.ts === expectedAt) &&
3478
+ parsed.handoff_at === parsed.ts &&
3479
+ parsed.to_phase === "handoff" &&
3480
+ paths.length === 3 &&
3481
+ new Set(paths).size === 3 &&
3482
+ paths.includes(sourcePath) &&
3483
+ paths.includes(calleePath) &&
3484
+ paths.includes(activePath) &&
3485
+ path.resolve(typeof parsed.caller_path === "string" ? parsed.caller_path : "") === sourcePath &&
3486
+ path.resolve(typeof parsed.callee_path === "string" ? parsed.callee_path : "") === calleePath &&
3487
+ path.resolve(typeof parsed.active_state_path === "string" ? parsed.active_state_path : "") === activePath &&
3488
+ Number.isSafeInteger(callerRevision) &&
3489
+ (callerRevision as number) > 0 &&
3490
+ Number.isSafeInteger(calleeRevision) &&
3491
+ (calleeRevision as number) > 0 &&
3492
+ Boolean(callerReceipt) &&
3493
+ Boolean(calleeReceipt) &&
3494
+ callerReceipt?.mutation_id === mutationId &&
3495
+ calleeReceipt?.mutation_id === mutationId &&
3496
+ callerReceipt?.mutated_at === parsed.ts &&
3497
+ calleeReceipt?.mutated_at === parsed.ts &&
3498
+ callerReceipt?.storage_path === sourcePath &&
3499
+ calleeReceipt?.storage_path === calleePath &&
3500
+ callerReceipt?.state_path === activePath &&
3501
+ calleeReceipt?.state_path === activePath &&
3502
+ callerChecksum?.algorithm === "sha256" &&
3503
+ typeof callerChecksum.value === "string" &&
3504
+ callerChecksum.value.length === 64 &&
3505
+ callerChecksum.covered_path === sourcePath &&
3506
+ calleeChecksum?.algorithm === "sha256" &&
3507
+ typeof calleeChecksum.value === "string" &&
3508
+ calleeChecksum.value.length === 64 &&
3509
+ calleeChecksum.covered_path === calleePath
3510
+ );
3511
+ });
3512
+ }
3513
+
3514
+ async function assertDeepInterviewHandoffReady(
3515
+ state: Record<string, unknown>,
3516
+ options: { cwd?: string; sessionId?: string; statePath?: string; requireExecutionApproval?: boolean } = {},
3517
+ ): Promise<void> {
3518
+ const specPath = typeof state.spec_path === "string" ? state.spec_path : undefined;
3519
+ const expectedSha = typeof state.spec_sha256 === "string" ? state.spec_sha256 : undefined;
3520
+ const rawInnerStatePresent = isPlainObject(state.state);
3521
+ let content: string | undefined;
3522
+ if (specPath) {
3523
+ try {
3524
+ content = await readBoundedIdentityText(
3525
+ specPath,
3526
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH * 4 + 1,
3527
+ "persisted deep-interview spec",
3528
+ );
3529
+ if (content === undefined) throw new Error("persisted spec is unavailable");
3530
+ } catch (error) {
3531
+ throw new StateCommandError(
3532
+ 2,
3533
+ `deep-interview handoff cannot read persisted spec: ${error instanceof Error ? error.message : String(error)}`,
3534
+ );
3535
+ }
3536
+ const boundedContent = content.endsWith("\n") ? content.slice(0, -1) : content;
3537
+ assertDeepInterviewInputWithinLimit(
3538
+ boundedContent,
3539
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
3540
+ "persisted deep-interview spec",
3541
+ );
3542
+ }
3543
+ const envelope = normalizeDeepInterviewEnvelope(state);
3544
+ const inner = envelope.state;
3545
+ if (!rawInnerStatePresent && options.requireExecutionApproval)
3546
+ throw new StateCommandError(2, "deep-interview execution handoff requires normalized inner state");
3547
+ if (!inner) return;
3548
+ const assertLockedIntentContract = (): void => {
3549
+ if (inner.intent_contract === undefined) {
3550
+ if (inner.intent_contract_required === true)
3551
+ throw new StateCommandError(2, "deep-interview handoff requires a locked Round 0 intent contract");
3552
+ return;
3553
+ }
3554
+ assertDeepInterviewIntentManifest(inner.intent_contract);
3555
+ if (!specPath || !expectedSha || content === undefined)
3556
+ throw new StateCommandError(2, "deep-interview handoff requires a persisted intent-validated spec");
3557
+ if (createHash("sha256").update(content).digest("hex") !== expectedSha)
3558
+ throw new StateCommandError(2, "deep-interview handoff spec hash mismatch");
3559
+ const observedIds = [...new Set(content.match(DEEP_INTERVIEW_INTENT_ID_RE) ?? [])].sort();
3560
+ const rounds = Array.isArray(inner.rounds)
3561
+ ? inner.rounds
3562
+ .filter(
3563
+ (round): round is Record<string, unknown> =>
3564
+ Boolean(round) && typeof round === "object" && !Array.isArray(round),
3565
+ )
3566
+ .map(round => ({ round: round.round, answer_hash: round.answer_hash }))
3567
+ : [];
3568
+ try {
3569
+ assertDeepInterviewIntentReview(
3570
+ inner.intent_review,
3571
+ inner.intent_contract as DeepInterviewIntentManifest,
3572
+ observedIds,
3573
+ rounds,
3574
+ );
3575
+ } catch (error) {
3576
+ throw new StateCommandError(
3577
+ 2,
3578
+ `deep-interview handoff intent validation failed: ${error instanceof Error ? error.message : String(error)}`,
3579
+ );
3580
+ }
3581
+ };
3582
+ if (inner.crystal !== undefined) {
3583
+ const crystal = requireReadyCanonicalCrystal(inner.crystal);
3584
+ if (!specPath || !expectedSha || content === undefined)
3585
+ throw new StateCommandError(2, "deep-interview crystallized handoff requires a persisted spec");
3586
+ if (options.cwd && options.sessionId) {
3587
+ const specsRoot = path.resolve(sessionSpecsDir(options.cwd, options.sessionId));
3588
+ const relativeSpec = path.relative(specsRoot, path.resolve(specPath));
3589
+ if (relativeSpec.startsWith("..") || path.isAbsolute(relativeSpec))
3590
+ throw new StateCommandError(2, "deep-interview crystallized handoff requires a session-scoped spec");
3591
+ }
3592
+ if (createHash("sha256").update(content).digest("hex") !== expectedSha)
3593
+ throw new StateCommandError(2, "deep-interview crystallized handoff spec hash mismatch");
3594
+ let canonicalContent: string;
3595
+ try {
3596
+ canonicalContent = crystalMarkdown(crystal as unknown as DeepInterviewCrystal);
3597
+ } catch {
3598
+ throw new StateCommandError(2, "deep-interview crystallized handoff requires complete canonical evidence");
3599
+ }
3600
+ if (canonicalContent !== content)
3601
+ throw new StateCommandError(
3602
+ 2,
3603
+ "deep-interview crystallized handoff spec is not the canonical Crystal rendering",
3604
+ );
3605
+ assertLockedIntentContract();
3606
+ if (inner.execution_approval !== "approved" && options.requireExecutionApproval)
3607
+ throw new StateCommandError(2, "deep-interview crystallization never grants execution approval");
3608
+ if (options.requireExecutionApproval) {
3609
+ const approval = isPlainObject(inner.execution_approval_receipt)
3610
+ ? inner.execution_approval_receipt
3611
+ : undefined;
3612
+ if (
3613
+ approval?.schema_version !== 1 ||
3614
+ approval.method !== "explicit-state-action" ||
3615
+ typeof approval.approved_at !== "string" ||
3616
+ typeof approval.mutation_id !== "string" ||
3617
+ approval.spec_sha256 !== expectedSha ||
3618
+ approval.crystal_spec_version !== crystal.spec_version ||
3619
+ approval.crystal_source_digest !== (crystal.source as Record<string, unknown>).digest ||
3620
+ typeof approval.transcript_path !== "string" ||
3621
+ !path.isAbsolute(approval.transcript_path) ||
3622
+ !isSha256(approval.transcript_sha256)
3623
+ )
3624
+ throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
3625
+ await assertExecutionApprovalTranscriptBoundary(
3626
+ options.cwd ?? "",
3627
+ options.sessionId ?? "",
3628
+ approval.transcript_path,
3629
+ approval.transcript_sha256,
3630
+ approval.transcript_boundary,
3631
+ );
3632
+ await assertSanctionedExecutionApprovalAudit(
3633
+ options.cwd ?? "",
3634
+ options.sessionId ?? "",
3635
+ options.statePath ?? "",
3636
+ approval,
3637
+ envelope,
3638
+ );
3639
+ }
3640
+ return;
3641
+ }
3642
+ if (options.requireExecutionApproval)
3643
+ await assertNonCrystalApprovalCurrent(options.cwd ?? "", options.sessionId ?? "", "deep-interview", "consumed");
3644
+ assertLockedIntentContract();
3645
+ }
3646
+
3647
+ interface VerifiedRalplanFinalEvidence {
3648
+ runId: string;
3649
+ statePath: string;
3650
+ stateRevision: number;
3651
+ finalPath: string;
3652
+ finalSha256: string;
3653
+ }
3654
+
3655
+ async function hasDurableRalplanPlanningStuck(
3656
+ cwd: string,
3657
+ sessionId: string,
3658
+ state: Record<string, unknown>,
3659
+ ): Promise<boolean> {
3660
+ if (isPlainObject(state.planning_stuck)) return true;
3661
+ const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
3662
+ if (admission?.degradationReason === "planning_stuck") return true;
3663
+ const runId = typeof state.run_id === "string" ? state.run_id.trim() : "";
3664
+ if (!runId) return false;
3665
+ try {
3666
+ assertSafePathComponent(runId, "ralplan run-id");
3667
+ const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
3668
+ const text = await readBoundedIdentityText(indexPath, 1024 * 1024, "ralplan final index");
3669
+ if (text === undefined) return false;
3670
+ return text.split(/\r?\n/).some(line => {
3671
+ if (!line.trim()) return false;
3672
+ try {
3673
+ const row = JSON.parse(line) as unknown;
3674
+ return isPlainObject(row) && (row.planning_stuck === true || row.event === "planning_stuck");
3675
+ } catch {
3676
+ return true;
3677
+ }
3678
+ });
3679
+ } catch {
3680
+ return true;
3681
+ }
3682
+ }
3683
+
3684
+ async function verifiedRalplanFinalEvidence(
3685
+ cwd: string,
3686
+ sessionId: string,
3687
+ state: Record<string, unknown>,
3688
+ ): Promise<VerifiedRalplanFinalEvidence | undefined> {
3689
+ if (isPlainObject(state.final_publication_pending)) return undefined;
3690
+ const runId = typeof state.run_id === "string" ? state.run_id.trim() : "";
3691
+ if (!runId) return undefined;
3692
+ try {
3693
+ assertSafePathComponent(runId, "ralplan run-id");
3694
+ } catch {
3695
+ return undefined;
3696
+ }
3697
+ const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
3698
+ if (!admission) return undefined;
3699
+ if (
3700
+ typeof admission.effectiveTarget !== "string" ||
3701
+ admission.degradationReason === "planning_stuck" ||
3702
+ typeof admission.source !== "string" ||
3703
+ !admission.source.trim()
3704
+ )
3705
+ return undefined;
3706
+ const ralplanPath = modeStateFile(cwd, "ralplan", sessionId);
3707
+ const receipt = persistedWorkflowReceipt(state.receipt, "ralplan");
3708
+ const checksum = receipt?.content_sha256;
3709
+ const phaseMarker = isPlainObject(state.final_admission_phase_transition)
3710
+ ? state.final_admission_phase_transition
3711
+ : undefined;
3712
+ const phaseTransitionReceipt =
3713
+ Boolean(phaseMarker) &&
3714
+ state.current_phase === "handoff" &&
3715
+ receipt?.owner === "gjc-state-cli" &&
3716
+ (receipt.command === "gjc state ralplan write" || receipt.command === "gjc state ralplan handoff --to ultragoal");
3717
+ if (
3718
+ (!phaseTransitionReceipt &&
3719
+ (receipt?.owner !== "gjc-runtime" || receipt.command !== "gjc ralplan final-admission")) ||
3720
+ checksum?.algorithm !== "sha256" ||
3721
+ typeof checksum.value !== "string" ||
3722
+ !/^[0-9a-f]{64}$/.test(checksum.value) ||
3723
+ checksum.covered_path !== path.resolve(ralplanPath) ||
3724
+ typeof checksum.computed_at !== "string" ||
3725
+ !checksum.computed_at.trim()
3726
+ )
3727
+ return undefined;
3728
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, ralplanPath, "ralplan");
3729
+ if (integrityWarning) return undefined;
3730
+
3731
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
3732
+ const indexPath = path.join(runDir, "index.jsonl");
3733
+ let indexText: string;
3734
+ try {
3735
+ const bounded = await readBoundedIdentityText(indexPath, 1024 * 1024, "ralplan final index");
3736
+ if (bounded === undefined) return undefined;
3737
+ indexText = bounded;
3738
+ } catch {
3739
+ return undefined;
3740
+ }
3741
+ let finalRow: Record<string, unknown> | undefined;
3742
+ let planningStuck = false;
3743
+ for (const line of indexText.split(/\r?\n/)) {
3744
+ if (!line.trim()) continue;
3745
+ let parsed: unknown;
3746
+ try {
3747
+ parsed = JSON.parse(line);
3748
+ } catch {
3749
+ return undefined;
3750
+ }
3751
+ if (!isPlainObject(parsed)) return undefined;
3752
+ if (parsed.stage === "final") finalRow = parsed;
3753
+ if (parsed.planning_stuck === true || parsed.event === "planning_stuck") planningStuck = true;
3754
+ }
3755
+ if (planningStuck || isPlainObject(state.planning_stuck)) return undefined;
3756
+ const indexedAdmission = isPlainObject(finalRow?.auto_handoff) ? finalRow.auto_handoff : undefined;
3757
+ if (
3758
+ !finalRow ||
3759
+ !indexedAdmission ||
3760
+ indexedAdmission.configuredTarget !== admission.configuredTarget ||
3761
+ indexedAdmission.effectiveTarget !== admission.effectiveTarget ||
3762
+ indexedAdmission.degradationReason !== admission.degradationReason ||
3763
+ indexedAdmission.source !== admission.source ||
3764
+ typeof finalRow.path !== "string" ||
3765
+ typeof finalRow.sha256 !== "string" ||
3766
+ !/^[0-9a-f]{64}$/.test(finalRow.sha256)
3767
+ )
3768
+ return undefined;
3769
+ const artifactPath = path.resolve(finalRow.path);
3770
+ if (!artifactPath.startsWith(`${path.resolve(runDir)}${path.sep}`)) return undefined;
3771
+ if ((await hashIdentityFile(artifactPath, "ralplan final artifact")) !== finalRow.sha256) return undefined;
3772
+ if (
3773
+ phaseMarker &&
3774
+ (phaseMarker.run_id !== runId ||
3775
+ phaseMarker.final_path !== artifactPath ||
3776
+ phaseMarker.final_sha256 !== finalRow.sha256 ||
3777
+ phaseMarker.auto_handoff_sha256 !== createHash("sha256").update(JSON.stringify(admission)).digest("hex"))
3778
+ )
3779
+ return undefined;
3780
+ const stateRevision = existingStateRevision(state);
3781
+ if (typeof stateRevision !== "number" || !Number.isSafeInteger(stateRevision) || stateRevision < 0) return undefined;
3782
+ return {
3783
+ runId,
3784
+ statePath: path.resolve(ralplanPath),
3785
+ stateRevision,
3786
+ finalPath: artifactPath,
3787
+ finalSha256: finalRow.sha256,
3788
+ };
3789
+ }
3790
+
3791
+ async function hasSanctionedRalplanFinalAdmission(
3792
+ cwd: string,
3793
+ sessionId: string,
3794
+ state: Record<string, unknown>,
3795
+ ): Promise<boolean> {
3796
+ const admission = isPlainObject(state.auto_handoff) ? state.auto_handoff : undefined;
3797
+ return (
3798
+ admission?.effectiveTarget === "ultragoal" &&
3799
+ admission.degradationReason === null &&
3800
+ (await verifiedRalplanFinalEvidence(cwd, sessionId, state)) !== undefined
3801
+ );
3802
+ }
3803
+
3804
+ /** Whether a verified automatic Ultragoal admission already authorizes handoff. */
3805
+ export async function hasSanctionedRalplanFinalAdmissionForHandoff(cwd: string, sessionId: string): Promise<boolean> {
3806
+ const read = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
3807
+ return read.kind === "valid" && (await hasSanctionedRalplanFinalAdmission(cwd, sessionId, read.value));
3808
+ }
3809
+
3810
+ async function assertRalplanApprovalRecordCurrent(
3811
+ cwd: string,
3812
+ sessionId: string,
3813
+ record: DeepInterviewExecutionApprovalRecord,
3814
+ state?: Record<string, unknown>,
3815
+ ): Promise<void> {
3816
+ if (record.approval_stage !== "ralplan") return;
3817
+ let ralplanState = state;
3818
+ if (!ralplanState) {
3819
+ const read = await readExistingStateForMutation(modeStateFile(cwd, "ralplan", sessionId));
3820
+ if (read.kind !== "valid")
3821
+ throw new StateCommandError(2, "Ralplan execution approval final state is unavailable");
3822
+ ralplanState = migrateWorkflowState(read.value, "ralplan").state;
3823
+ }
3824
+ const evidence = await verifiedRalplanFinalEvidence(cwd, sessionId, ralplanState);
3825
+ if (!evidence) throw new StateCommandError(2, "Ralplan execution approval final evidence is unavailable");
3826
+ if (
3827
+ record.ralplan_state_path !== evidence.statePath ||
3828
+ record.ralplan_run_id !== evidence.runId ||
3829
+ record.ralplan_final_path !== evidence.finalPath ||
3830
+ record.ralplan_final_sha256 !== evidence.finalSha256 ||
3831
+ (record.ralplan_state_revision !== evidence.stateRevision && ralplanState.current_phase !== "handoff")
3832
+ )
3833
+ throw new StateCommandError(2, "Ralplan execution approval does not match current final plan");
3834
+ }
3835
+
3836
+ async function assertDeepInterviewExecutionLineage(
3837
+ cwd: string,
3838
+ sessionId: string,
3839
+ caller: CanonicalGjcWorkflowSkill,
3840
+ existingCaller: Record<string, unknown>,
3841
+ ): Promise<void> {
3842
+ let upstreamRaw = typeof existingCaller.handoff_from === "string" ? existingCaller.handoff_from.trim() : "";
3843
+ if (!upstreamRaw) {
3844
+ if (!(await hasCurrentDeepInterviewHandoff(cwd, sessionId, caller, existingCaller))) return;
3845
+ upstreamRaw = "deep-interview";
3846
+ } else if (
3847
+ upstreamRaw === "deep-interview" &&
3848
+ !(await hasCurrentDeepInterviewHandoff(cwd, sessionId, caller, existingCaller))
3849
+ ) {
3850
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3851
+ }
3852
+
3853
+ const seen = new Set<CanonicalGjcWorkflowSkill>();
3854
+ let currentSkill = caller;
3855
+ let currentState =
3856
+ typeof existingCaller.handoff_from === "string" && existingCaller.handoff_from.trim() !== ""
3857
+ ? existingCaller
3858
+ : { ...existingCaller, handoff_from: upstreamRaw };
3859
+ while (true) {
3860
+ if (seen.has(currentSkill))
3861
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3862
+ seen.add(currentSkill);
3863
+ let upstreamValue = typeof currentState.handoff_from === "string" ? currentState.handoff_from.trim() : undefined;
3864
+ if (!upstreamValue && (await hasCurrentDeepInterviewHandoff(cwd, sessionId, currentSkill, currentState)))
3865
+ upstreamValue = "deep-interview";
3866
+ if (!upstreamValue) return;
3867
+ const upstream = canonicalWorkflowSkill(upstreamValue);
3868
+ if (!upstream)
3869
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3870
+ const upstreamPath = modeStateFile(cwd, upstream, sessionId);
3871
+ const upstreamRead = await readExistingStateForMutation(upstreamPath);
3872
+ if (upstreamRead.kind !== "valid")
3873
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3874
+ assertNoFutureWorkflowEnvelope(upstreamRead.value, upstream, `execution handoff upstream ${upstream}`);
3875
+ const upstreamState = migrateWorkflowState(upstreamRead.value, upstream).state;
3876
+ if (upstreamState.version !== WORKFLOW_STATE_VERSION)
3877
+ throw new StateCommandError(2, "execution handoff requires current upstream workflow state version");
3878
+ if (upstream === "ralplan" && !(await verifiedRalplanFinalEvidence(cwd, sessionId, upstreamState)))
3879
+ throw new StateCommandError(2, "execution handoff cannot traverse a stuck or unverifiable Ralplan final");
3880
+ if (upstream === "deep-interview") {
3881
+ const currentLineageHandoffAt =
3882
+ typeof currentState.upstream_handoff_at === "string"
3883
+ ? currentState.upstream_handoff_at
3884
+ : currentState.handoff_at;
3885
+ if (
3886
+ upstreamState.active !== false ||
3887
+ upstreamState.current_phase !== "handoff" ||
3888
+ upstreamState.handoff_to !== currentSkill ||
3889
+ typeof upstreamState.handoff_at !== "string" ||
3890
+ typeof currentLineageHandoffAt !== "string" ||
3891
+ upstreamState.handoff_at !== currentLineageHandoffAt
3892
+ )
3893
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3894
+ const lineageHandoffAt = upstreamState.handoff_at as string;
3895
+ if (
3896
+ !(await hasAuditedDeepInterviewHandoff(cwd, sessionId, currentSkill, {
3897
+ handoffAt: lineageHandoffAt,
3898
+ }))
3899
+ )
3900
+ throw new StateCommandError(2, "execution handoff cannot authenticate Deep Interview approval lineage");
3901
+ const receipt = isPlainObject(upstreamState.receipt) ? upstreamState.receipt : undefined;
3902
+ const checksum = isPlainObject(receipt?.content_sha256) ? receipt.content_sha256 : undefined;
3903
+ if (
3904
+ checksum?.algorithm !== "sha256" ||
3905
+ typeof checksum.value !== "string" ||
3906
+ checksum.value.length !== 64 ||
3907
+ checksum.covered_path !== path.resolve(upstreamPath)
3908
+ )
3909
+ throw new StateCommandError(2, "execution handoff requires checksummed canonical state");
3910
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, upstreamPath, "deep-interview");
3911
+ if (integrityWarning)
3912
+ throw new StateCommandError(2, `${integrityWarning}; execution handoff refuses tampered mode-state`);
3913
+ const ralplanAdmission =
3914
+ currentSkill === "ralplan" && (await hasSanctionedRalplanFinalAdmission(cwd, sessionId, currentState));
3915
+ const ordinaryRalplanApproval =
3916
+ currentSkill === "ralplan" &&
3917
+ isPlainObject(upstreamState.state) &&
3918
+ upstreamState.state.crystal === undefined;
3919
+ if (ordinaryRalplanApproval && !ralplanAdmission)
3920
+ await assertNonCrystalApprovalCurrent(cwd, sessionId, "ralplan", "consumed");
3921
+ try {
3922
+ await assertDeepInterviewHandoffReady(upstreamState, {
3923
+ cwd,
3924
+ sessionId,
3925
+ statePath: upstreamPath,
3926
+ requireExecutionApproval: !ralplanAdmission && !ordinaryRalplanApproval,
3927
+ });
3928
+ } catch (error) {
3929
+ throw new StateCommandError(
3930
+ 2,
3931
+ `execution handoff cannot authenticate Deep Interview approval lineage: ${error instanceof Error ? error.message : String(error)}`,
3932
+ );
3933
+ }
3934
+ const upstreamInner = isPlainObject(upstreamState.state) ? upstreamState.state : {};
3935
+ const upstreamApproval = isPlainObject(upstreamInner.execution_approval_receipt)
3936
+ ? upstreamInner.execution_approval_receipt
3937
+ : undefined;
3938
+ if (upstreamApproval?.approval_stage === "ralplan") {
3939
+ const record = await readDeepInterviewExecutionApprovalRecord(
3940
+ deepInterviewExecutionApprovalRecordPath(cwd, sessionId),
3941
+ );
3942
+ if (record?.status !== "consumed")
3943
+ throw new StateCommandError(2, "execution handoff requires consumed Ralplan approval evidence");
3944
+ if (
3945
+ record.approval_stage !== "ralplan" ||
3946
+ upstreamApproval.approval_stage !== record.approval_stage ||
3947
+ upstreamApproval.ralplan_state_path !== record.ralplan_state_path ||
3948
+ upstreamApproval.ralplan_state_revision !== record.ralplan_state_revision ||
3949
+ upstreamApproval.ralplan_run_id !== record.ralplan_run_id ||
3950
+ upstreamApproval.ralplan_final_path !== record.ralplan_final_path ||
3951
+ upstreamApproval.ralplan_final_sha256 !== record.ralplan_final_sha256 ||
3952
+ upstreamApproval.question_id !== record.question_id ||
3953
+ upstreamApproval.gate_id !== record.gate_id ||
3954
+ upstreamApproval.answer_hash !== record.answer_hash ||
3955
+ upstreamApproval.transcript_path !== record.transcript_path ||
3956
+ upstreamApproval.transcript_sha256 !== record.transcript_sha256 ||
3957
+ JSON.stringify(upstreamApproval.transcript_boundary) !== JSON.stringify(record.transcript_boundary)
3958
+ )
3959
+ throw new StateCommandError(2, "execution handoff Ralplan approval receipt identity mismatch");
3960
+ await assertRalplanApprovalRecordCurrent(cwd, sessionId, record, currentState);
3961
+ }
3962
+ return;
3963
+ }
3964
+ currentSkill = upstream;
3965
+ currentState = upstreamState;
3966
+ }
3967
+ }
3968
+
3969
+ function handoffJournalPathsMatch(
3970
+ journal: Record<string, unknown>,
3971
+ callerPath: string,
3972
+ calleePath: string,
3973
+ activePath: string,
3974
+ ): boolean {
3975
+ if (journal.caller !== path.basename(callerPath, "-state.json")) return false;
3976
+ if (journal.callee !== path.basename(calleePath, "-state.json")) return false;
3977
+ const paths = Array.isArray(journal.paths)
3978
+ ? journal.paths.filter((value): value is string => typeof value === "string").map(value => path.resolve(value))
3979
+ : [];
3980
+ return (
3981
+ paths.length === 3 &&
3982
+ new Set(paths).size === 3 &&
3983
+ paths.includes(path.resolve(callerPath)) &&
3984
+ paths.includes(path.resolve(calleePath)) &&
3985
+ paths.includes(path.resolve(activePath))
3986
+ );
3987
+ }
3988
+
3989
+ async function findPendingHandoffRecovery(options: {
3990
+ cwd: string;
3991
+ sessionId: string;
3992
+ caller: CanonicalGjcWorkflowSkill;
3993
+ callee: CanonicalGjcWorkflowSkill;
3994
+ callerPath: string;
3995
+ calleePath: string;
3996
+ activePath: string;
3997
+ calleeState: Record<string, unknown>;
3998
+ }): Promise<{ journal: Record<string, unknown>; mutationId: string; handoffAt: string } | undefined> {
3999
+ const transactionsDir = path.join(stateDirFor(options.cwd, options.sessionId), "transactions");
4000
+ let names: string[];
4001
+ try {
4002
+ names = await fs.readdir(transactionsDir);
4003
+ } catch (error) {
4004
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
4005
+ throw error;
4006
+ }
4007
+ for (const name of names.filter(entry => entry.endsWith(".json")).sort()) {
4008
+ const journalPath = path.join(transactionsDir, name);
4009
+ let parsed: unknown;
4010
+ try {
4011
+ parsed = JSON.parse(await fs.readFile(journalPath, "utf-8"));
4012
+ } catch (error) {
4013
+ throw new StateCommandError(
4014
+ 2,
4015
+ `handoff recovery journal is unreadable: ${error instanceof Error ? error.message : String(error)}`,
4016
+ );
4017
+ }
4018
+ if (!isPlainObject(parsed) || parsed.status !== "pending") continue;
4019
+ if (parsed.caller !== options.caller || parsed.callee !== options.callee) continue;
4020
+ if (!handoffJournalPathsMatch(parsed, options.callerPath, options.calleePath, options.activePath))
4021
+ throw new StateCommandError(2, "handoff recovery journal identity mismatch");
4022
+ const steps = Array.isArray(parsed.steps)
4023
+ ? parsed.steps.filter((step): step is string => typeof step === "string")
4024
+ : [];
4025
+ if (!steps.includes("callee-mode-state")) continue;
4026
+ const mutationId = typeof parsed.mutation_id === "string" ? parsed.mutation_id : "";
4027
+ const handoffAt =
4028
+ typeof options.calleeState.handoff_at === "string"
4029
+ ? options.calleeState.handoff_at
4030
+ : isPlainObject(options.calleeState.receipt) && typeof options.calleeState.receipt.mutated_at === "string"
4031
+ ? options.calleeState.receipt.mutated_at
4032
+ : "";
4033
+ const calleeReceipt = persistedWorkflowReceipt(options.calleeState.receipt, options.callee);
4034
+ if (
4035
+ !handoffAt ||
4036
+ !mutationId ||
4037
+ mutationId !== `${options.caller}:handoff:${options.callee}:${handoffAt}` ||
4038
+ options.calleeState.handoff_from !== options.caller ||
4039
+ options.calleeState.handoff_at !== handoffAt ||
4040
+ !calleeReceipt ||
4041
+ calleeReceipt.mutation_id !== mutationId ||
4042
+ calleeReceipt.mutated_at !== handoffAt
4043
+ )
4044
+ throw new StateCommandError(2, "handoff recovery journal/callee receipt identity mismatch");
4045
+ return { journal: parsed, mutationId, handoffAt };
4046
+ }
4047
+ return undefined;
4048
+ }
4049
+
4050
+ async function syncHandoffRetryProjection(options: {
4051
+ cwd: string;
4052
+ sessionId: string;
4053
+ caller: CanonicalGjcWorkflowSkill;
4054
+ callee: CanonicalGjcWorkflowSkill;
4055
+ callerPath: string;
4056
+ calleePath: string;
4057
+ callerState: Record<string, unknown>;
4058
+ calleeState: Record<string, unknown>;
4059
+ threadId?: string;
4060
+ turnId?: string;
4061
+ handoffAt: string;
4062
+ }): Promise<void> {
4063
+ const callerReceipt = persistedWorkflowReceipt(options.callerState.receipt, options.caller);
4064
+ const calleeReceipt = persistedWorkflowReceipt(options.calleeState.receipt, options.callee);
4065
+ const calleePhase =
4066
+ typeof options.calleeState.current_phase === "string"
4067
+ ? options.calleeState.current_phase
4068
+ : initialPhaseForSkill(options.callee);
4069
+ await applyHandoffToActiveState({
4070
+ cwd: options.cwd,
4071
+ nowIso: options.handoffAt,
4072
+ strict: true,
4073
+ caller: {
4074
+ cwd: options.cwd,
4075
+ skill: options.caller,
4076
+ active: false,
4077
+ phase: "handoff",
4078
+ sessionId: options.sessionId,
4079
+ threadId: options.threadId,
4080
+ turnId: options.turnId,
4081
+ source: "gjc-state-cli",
4082
+ hud: buildHudForMode(options.caller, options.callerState),
4083
+ handoff_to: options.callee,
4084
+ handoff_at: options.handoffAt,
4085
+ ...(callerReceipt ? { receipt: callerReceipt } : {}),
4086
+ },
4087
+ callee: {
4088
+ cwd: options.cwd,
4089
+ skill: options.callee,
4090
+ active: options.calleeState.active !== false,
4091
+ phase: calleePhase,
4092
+ sessionId: options.sessionId,
4093
+ threadId: options.threadId,
4094
+ turnId: options.turnId,
4095
+ source: "gjc-state-cli",
4096
+ hud: buildHudForMode(options.callee, options.calleeState),
4097
+ ...(typeof options.calleeState.handoff_from === "string"
4098
+ ? { handoff_from: options.calleeState.handoff_from }
4099
+ : { handoff_from: options.caller }),
4100
+ ...(typeof options.calleeState.handoff_at === "string"
4101
+ ? { handoff_at: options.calleeState.handoff_at }
4102
+ : { handoff_at: options.handoffAt }),
4103
+ ...(calleeReceipt ? { receipt: calleeReceipt } : {}),
4104
+ },
4105
+ });
4106
+ }
4107
+
4108
+ interface HandoffAuditOptions {
4109
+ cwd: string;
4110
+ sessionId: string;
4111
+ caller: CanonicalGjcWorkflowSkill;
4112
+ callee: CanonicalGjcWorkflowSkill;
4113
+ callerPath: string;
4114
+ calleePath: string;
4115
+ activePath: string;
4116
+ mutationId: string;
4117
+ handoffAt: string;
4118
+ fromPhase?: string;
4119
+ callerState: Record<string, unknown>;
4120
+ calleeState: Record<string, unknown>;
4121
+ forced: boolean;
4122
+ }
4123
+
4124
+ function buildHandoffAuditEntry(options: HandoffAuditOptions): AuditEntry & Record<string, unknown> {
4125
+ const callerReceipt = isPlainObject(options.callerState.receipt) ? options.callerState.receipt : undefined;
4126
+ const calleeReceipt = isPlainObject(options.calleeState.receipt) ? options.calleeState.receipt : undefined;
4127
+ const callerRevision = options.callerState.state_revision;
4128
+ const calleeRevision = options.calleeState.state_revision;
4129
+ if (
4130
+ !callerReceipt ||
4131
+ !calleeReceipt ||
4132
+ !Number.isSafeInteger(callerRevision) ||
4133
+ !Number.isSafeInteger(calleeRevision) ||
4134
+ callerReceipt.mutation_id !== options.mutationId ||
4135
+ calleeReceipt.mutation_id !== options.mutationId ||
4136
+ callerReceipt.mutated_at !== options.handoffAt ||
4137
+ calleeReceipt.mutated_at !== options.handoffAt
4138
+ )
4139
+ throw new StateCommandError(1, "handoff writer did not return matching caller/callee receipts");
4140
+ return {
4141
+ ts: options.handoffAt,
4142
+ skill: options.caller,
4143
+ category: "state",
4144
+ verb: "handoff",
4145
+ owner: "gjc-state-cli",
4146
+ mutation_id: options.mutationId,
4147
+ from_phase: options.fromPhase,
4148
+ to_phase: "handoff",
4149
+ forced: options.forced,
4150
+ paths: [options.callerPath, options.calleePath, options.activePath],
4151
+ handoff_at: options.handoffAt,
4152
+ caller_path: path.resolve(options.callerPath),
4153
+ callee_path: path.resolve(options.calleePath),
4154
+ active_state_path: path.resolve(options.activePath),
4155
+ caller_state_revision: callerRevision,
4156
+ callee_state_revision: calleeRevision,
4157
+ caller_receipt: callerReceipt,
4158
+ callee_receipt: calleeReceipt,
4159
+ } as AuditEntry & Record<string, unknown>;
4160
+ }
4161
+
4162
+ async function writeHandoffAuditIndex(
4163
+ options: HandoffAuditOptions,
4164
+ entry: AuditEntry & Record<string, unknown>,
4165
+ ): Promise<void> {
4166
+ if (options.caller !== "deep-interview") return;
4167
+ await writeArtifact(
4168
+ path.join(sessionStateDir(options.cwd, options.sessionId), `deep-interview-handoff-${options.callee}-audit.json`),
4169
+ `${JSON.stringify(entry)}\n`,
4170
+ {
4171
+ cwd: options.cwd,
4172
+ audit: {
4173
+ category: "artifact",
4174
+ verb: "write",
4175
+ owner: "gjc-state-cli",
4176
+ skill: "deep-interview",
4177
+ sessionId: options.sessionId,
4178
+ mutationId: options.mutationId,
4179
+ },
4180
+ },
4181
+ );
4182
+ }
4183
+
4184
+ async function appendHandoffAudit(
4185
+ options: HandoffAuditOptions,
4186
+ hooks: { afterAudit?: () => Promise<unknown>; afterIndex?: () => Promise<unknown> } = {},
4187
+ ): Promise<void> {
4188
+ const entry = buildHandoffAuditEntry(options);
4189
+ await writeHandoffAuditIndex(options, entry);
4190
+ await hooks.afterIndex?.();
4191
+ await appendAuditEntry(options.cwd, options.sessionId, entry);
4192
+ await hooks.afterAudit?.();
4193
+ }
4194
+
4195
+ /**
4196
+ * `handoff` exists in two distinct roles:
4197
+ * - As a verb: this CLI action, which atomically transitions caller→callee.
4198
+ * Writes the callee mode-state first, the caller mode-state second, then
4199
+ * syncs both `skill-active-state.json` files. Every intermediate crashed
4200
+ * state remains HUD-coherent: the active-state file either reflects the
4201
+ * old skill entirely or the new skill entirely, never both as active.
4202
+ * - As a phase: `current_phase: "handoff"` is set by this verb when demoting
4203
+ * the caller. Agents writing `current_phase: "handoff"` manually via
4204
+ * `gjc state <skill> write` are declaring "I am ready to be handed off";
4205
+ * the next agent-initiated `skill` tool call will then satisfy the phase
4206
+ * guard and may chain.
4207
+ *
4208
+ * `handoff` is in the terminal-phase set used by `isTerminalModeState` and by
4209
+ * the skill tool's chain guard. A manual `current_phase: "handoff"` write does
4210
+ * NOT mark `active: false` — only this verb does that — so a skill that wrote
4211
+ * the phase remains in `skill-active-state.json` until a chain call (or
4212
+ * explicit `clear`) demotes it.
4213
+ */
4214
+ async function handleHandoffUnlocked(
4215
+ args: readonly string[],
4216
+ cwd: string,
4217
+ resolved: ResolvedHandoffSelectors,
4218
+ options: { callerLockHeld?: boolean; calleeLockHeld?: boolean } = {},
4219
+ ): Promise<StateCommandResult> {
4220
+ const { selectors, caller, callee, calleeIsWorkflow, callerPath, calleePath } = resolved;
4221
+ const { gjcSessionId: sessionId, threadId, turnId } = selectors;
4222
+ const forced = hasFlag(args, "--force");
4223
+ const callerRead = await readExistingStateForMutation(callerPath);
4224
+ if (callerRead.kind === "corrupt" && !forced) {
4225
+ throw new StateCommandError(
4226
+ 2,
4227
+ `existing state for ${caller} is corrupt or tampered (${callerRead.error}); use --force to overwrite`,
4228
+ );
4229
+ }
4230
+ if (callerRead.kind === "absent") {
4231
+ throw new StateCommandError(
4232
+ 2,
4233
+ `gjc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
4234
+ );
4235
+ }
4236
+ const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
4237
+ if (callerRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingCaller, caller, `handoff from ${caller}`);
4238
+ if (
4239
+ !forced &&
4240
+ existingCaller.active !== true &&
4241
+ !(existingCaller.current_phase === "handoff" && existingCaller.handoff_to === callee)
4242
+ )
4243
+ throw new StateCommandError(2, `gjc state ${caller} handoff: caller is not active`);
4244
+ if (caller === "deep-interview" && callee === "ultragoal") {
4245
+ const exactRecovery = existingCaller.active === false && existingCaller.handoff_to === callee;
4246
+ if ((!exactRecovery && existingCaller.active !== true) || existingCaller.current_phase !== "handoff")
4247
+ throw new StateCommandError(2, "deep-interview execution handoff requires active handoff phase");
4248
+ if (existingCaller.version !== WORKFLOW_STATE_VERSION)
4249
+ throw new StateCommandError(
4250
+ 2,
4251
+ "deep-interview execution handoff requires current deep-interview state version",
4252
+ );
4253
+ const receipt = isPlainObject(existingCaller.receipt) ? existingCaller.receipt : undefined;
4254
+ const checksum = isPlainObject(receipt?.content_sha256) ? receipt.content_sha256 : undefined;
4255
+ if (
4256
+ checksum?.algorithm !== "sha256" ||
4257
+ typeof checksum?.value !== "string" ||
4258
+ checksum.value.length !== 64 ||
4259
+ checksum.covered_path !== path.resolve(callerPath)
4260
+ )
4261
+ throw new StateCommandError(2, "deep-interview execution handoff requires checksummed canonical state");
4262
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(cwd, sessionId, callerPath, "deep-interview");
4263
+ if (integrityWarning)
4264
+ throw new StateCommandError(2, `${integrityWarning}; execution handoff refuses tampered mode-state`);
4265
+ }
4266
+ if (caller === "ralplan" && (await hasDurableRalplanPlanningStuck(cwd, sessionId, existingCaller)))
4267
+ throw new StateCommandError(2, "planning-stuck Ralplan is terminal and cannot hand off");
4268
+ let ralplanExecutionFinal: VerifiedRalplanFinalEvidence | undefined;
4269
+ if (caller === "ralplan" && callee === "ultragoal") {
4270
+ ralplanExecutionFinal = await verifiedRalplanFinalEvidence(cwd, sessionId, existingCaller);
4271
+ if (!ralplanExecutionFinal)
4272
+ throw new StateCommandError(2, "Ralplan execution handoff requires non-stuck verified final plan evidence");
4273
+ if (
4274
+ (await executionApprovalLineage(cwd, sessionId, "ralplan")) === "ordinary" &&
4275
+ !(await hasSanctionedRalplanFinalAdmission(cwd, sessionId, existingCaller))
4276
+ )
4277
+ await assertNonCrystalApprovalCurrent(cwd, sessionId, "ralplan", "consumed");
4278
+ }
4279
+ if (callee === "ultragoal" && caller !== "deep-interview")
4280
+ await assertDeepInterviewExecutionLineage(cwd, sessionId, caller, existingCaller);
4281
+
4282
+ let handoffAt = nowIso();
4283
+ let mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
4284
+ let callerReceipt = buildWorkflowStateReceipt({
4285
+ cwd,
4286
+ skill: caller,
4287
+ owner: "gjc-state-cli",
4288
+ command: `gjc state ${caller} handoff --to ${callee}`,
4289
+ sessionId,
4290
+ nowIso: handoffAt,
4291
+ mutationId,
4292
+ });
4293
+ callerReceipt.forced = forced;
1622
4294
  const normalizedCaller =
1623
4295
  caller === "deep-interview"
1624
4296
  ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
@@ -1626,7 +4298,17 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1626
4298
  unknown
1627
4299
  >)
1628
4300
  : migrateWorkflowState(existingCaller, caller).state;
1629
- if (caller === "deep-interview") await assertDeepInterviewHandoffReady(normalizedCaller);
4301
+ if (caller === "deep-interview" && callee === "ultragoal" && !isPlainObject(existingCaller.state))
4302
+ throw new StateCommandError(2, "deep-interview execution handoff requires normalized inner state");
4303
+ if (caller === "deep-interview" && !calleeIsWorkflow)
4304
+ throw new StateCommandError(2, "deep-interview handoff requires a canonical GJC workflow skill");
4305
+ if (caller === "deep-interview")
4306
+ await assertDeepInterviewHandoffReady(normalizedCaller, {
4307
+ cwd,
4308
+ sessionId,
4309
+ statePath: callerPath,
4310
+ requireExecutionApproval: callee === "ultragoal",
4311
+ });
1630
4312
 
1631
4313
  // Runtime callees have no native mode-state to clear later, so do not
1632
4314
  // persist them as active-state entries; the prompt observer tracks them
@@ -1643,7 +4325,6 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1643
4325
  updated_at: handoffAt,
1644
4326
  receipt: callerReceipt,
1645
4327
  };
1646
- const force = hasFlag(args, "--force");
1647
4328
  await beginWorkflowTransactionJournal({
1648
4329
  cwd,
1649
4330
  sessionId,
@@ -1655,9 +4336,10 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1655
4336
  sessionId,
1656
4337
  skill: caller,
1657
4338
  mutationId,
1658
- force,
4339
+ force: forced,
1659
4340
  fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
1660
4341
  toPhase: "handoff",
4342
+ lockHeld: options.callerLockHeld,
1661
4343
  });
1662
4344
  await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["caller-mode-state"] });
1663
4345
  if (callerWrite.warning) emitStateWarning(callerWrite.warning);
@@ -1710,35 +4392,332 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1710
4392
  if (!calleePath) {
1711
4393
  throw new StateCommandError(2, `gjc state handoff failed to resolve workflow callee path for ${callee}`);
1712
4394
  }
4395
+ const workflowCallee = canonicalWorkflowSkill(callee);
4396
+ if (!workflowCallee)
4397
+ throw new StateCommandError(2, `gjc state handoff failed to resolve workflow callee path for ${callee}`);
4398
+ if (!options.calleeLockHeld) {
4399
+ return withWorkflowStateLock(
4400
+ calleePath,
4401
+ () => handleHandoffUnlocked(args, cwd, resolved, { ...options, calleeLockHeld: true }),
4402
+ { cwd },
4403
+ );
4404
+ }
1713
4405
  const calleeRead = await readExistingStateForMutation(calleePath);
4406
+ if (calleeRead.kind === "valid")
4407
+ assertNoFutureWorkflowEnvelope(calleeRead.value, workflowCallee, `handoff to ${workflowCallee}`);
4408
+ const exactRecovery = existingCaller.active === false && existingCaller.handoff_to === callee;
4409
+ const callerReceiptForRetry = isPlainObject(existingCaller.receipt) ? existingCaller.receipt : undefined;
4410
+ const retryMutationId =
4411
+ callerReceiptForRetry && typeof callerReceiptForRetry.mutation_id === "string"
4412
+ ? callerReceiptForRetry.mutation_id
4413
+ : undefined;
4414
+ let retryJournal: WorkflowTransactionJournal | undefined;
4415
+ if (exactRecovery && retryMutationId) {
4416
+ try {
4417
+ retryJournal = await readWorkflowTransactionJournal(cwd, sessionId, retryMutationId);
4418
+ } catch (error) {
4419
+ throw new StateCommandError(
4420
+ 2,
4421
+ `handoff recovery journal is unreadable: ${error instanceof Error ? error.message : String(error)}`,
4422
+ );
4423
+ }
4424
+ }
1714
4425
  if (calleeRead.kind === "corrupt" && !forced) {
1715
4426
  throw new StateCommandError(
1716
4427
  2,
1717
4428
  `existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
1718
4429
  );
1719
4430
  }
4431
+ if (callee === "ultragoal" && calleeRead.kind === "valid") {
4432
+ const calleeVersion = calleeRead.value.version;
4433
+ if (
4434
+ calleeVersion !== undefined &&
4435
+ (!Number.isSafeInteger(calleeVersion) ||
4436
+ (calleeVersion as number) < 1 ||
4437
+ (calleeVersion as number) > WORKFLOW_STATE_VERSION)
4438
+ )
4439
+ throw new StateCommandError(2, "execution handoff requires current ultragoal state version");
4440
+ }
4441
+ const activePath = activeStateFile(cwd, sessionId);
4442
+ let pendingCalleeRecovery: { journal: Record<string, unknown>; mutationId: string; handoffAt: string } | undefined;
4443
+ let pendingRecoveryForced = forced;
4444
+ if (!exactRecovery && existingCaller.active === true && calleeRead.kind === "valid") {
4445
+ pendingCalleeRecovery = await findPendingHandoffRecovery({
4446
+ cwd,
4447
+ sessionId,
4448
+ caller,
4449
+ callee: workflowCallee,
4450
+ callerPath,
4451
+ calleePath,
4452
+ activePath,
4453
+ calleeState: calleeRead.value,
4454
+ });
4455
+ if (pendingCalleeRecovery) {
4456
+ handoffAt = pendingCalleeRecovery.handoffAt;
4457
+ mutationId = pendingCalleeRecovery.mutationId;
4458
+ callerReceipt = buildWorkflowStateReceipt({
4459
+ cwd,
4460
+ skill: caller,
4461
+ owner: "gjc-state-cli",
4462
+ command: `gjc state ${caller} handoff --to ${callee}`,
4463
+ sessionId,
4464
+ nowIso: handoffAt,
4465
+ mutationId,
4466
+ });
4467
+ const recoveredCalleeReceipt = persistedWorkflowReceipt(calleeRead.value.receipt, workflowCallee);
4468
+ pendingRecoveryForced = recoveredCalleeReceipt?.forced === true;
4469
+ callerReceipt.forced = pendingRecoveryForced;
4470
+ }
4471
+ }
4472
+ if (exactRecovery) {
4473
+ if (calleeRead.kind !== "valid")
4474
+ throw new StateCommandError(2, "completed handoff retry cannot recover a missing callee state");
4475
+ await assertHandoffRecoveryEnvelopeIntegrity(cwd, sessionId, callerPath, caller, existingCaller, "caller");
4476
+ await assertHandoffRecoveryEnvelopeIntegrity(
4477
+ cwd,
4478
+ sessionId,
4479
+ calleePath,
4480
+ workflowCallee,
4481
+ calleeRead.value,
4482
+ "callee",
4483
+ );
4484
+ const retryAt = typeof existingCaller.handoff_at === "string" ? existingCaller.handoff_at.trim() : undefined;
4485
+ if (!retryAt || !retryMutationId || retryMutationId !== `${caller}:handoff:${callee}:${retryAt}`)
4486
+ throw new StateCommandError(2, "handoff retry lacks durable transition identity");
4487
+ if (
4488
+ !handoffReceiptMatches(callerReceiptForRetry, caller, callerPath, activePath, retryMutationId, retryAt) ||
4489
+ calleeRead.value.handoff_from !== caller ||
4490
+ calleeRead.value.handoff_at !== retryAt ||
4491
+ calleeRead.value.active === false
4492
+ )
4493
+ throw new StateCommandError(2, "handoff retry cannot authenticate the persisted caller/callee state");
4494
+ if (retryJournal) {
4495
+ if (retryJournal.status !== "pending" && retryJournal.status !== "committed")
4496
+ throw new StateCommandError(2, "handoff recovery journal has unsupported status");
4497
+ if (
4498
+ retryJournal.mutation_id !== retryMutationId ||
4499
+ !handoffJournalPathsMatch(
4500
+ retryJournal as unknown as Record<string, unknown>,
4501
+ callerPath,
4502
+ calleePath,
4503
+ activePath,
4504
+ )
4505
+ )
4506
+ throw new StateCommandError(2, "handoff recovery journal identity mismatch");
4507
+ }
4508
+ const retryCalleeState = calleeRead.value;
4509
+ await syncHandoffRetryProjection({
4510
+ cwd,
4511
+ sessionId,
4512
+ caller,
4513
+ callee: workflowCallee,
4514
+ callerPath,
4515
+ calleePath,
4516
+ callerState: existingCaller,
4517
+ calleeState: retryCalleeState,
4518
+ threadId,
4519
+ turnId,
4520
+ handoffAt: retryAt,
4521
+ });
4522
+ if (retryJournal?.status === "pending") {
4523
+ const steps = new Set((retryJournal.steps ?? []).filter((step): step is string => typeof step === "string"));
4524
+ steps.add("callee-mode-state");
4525
+ steps.add("caller-mode-state");
4526
+ steps.add("active-state");
4527
+ await updateWorkflowTransactionJournal(cwd, sessionId, retryMutationId, { steps: [...steps] });
4528
+ if (caller === "deep-interview") {
4529
+ const auditOptions: HandoffAuditOptions = {
4530
+ cwd,
4531
+ sessionId,
4532
+ caller,
4533
+ callee: workflowCallee,
4534
+ callerPath,
4535
+ calleePath,
4536
+ activePath,
4537
+ mutationId: retryMutationId,
4538
+ handoffAt: retryAt,
4539
+ fromPhase: undefined,
4540
+ callerState: existingCaller,
4541
+ calleeState: retryCalleeState,
4542
+ forced: callerReceiptForRetry?.forced === true,
4543
+ };
4544
+ const indexPresent = await hasAuditedDeepInterviewHandoff(cwd, sessionId, workflowCallee, {
4545
+ handoffAt: retryAt,
4546
+ indexOnly: true,
4547
+ });
4548
+ const persistSteps = () =>
4549
+ updateWorkflowTransactionJournal(cwd, sessionId, retryMutationId, { steps: [...steps] });
4550
+ if (!indexPresent) {
4551
+ const entry = buildHandoffAuditEntry(auditOptions);
4552
+ await writeHandoffAuditIndex(auditOptions, entry);
4553
+ steps.add("handoff-index");
4554
+ await persistSteps();
4555
+ if (!steps.has("handoff-audit")) {
4556
+ await appendAuditEntry(cwd, sessionId, entry);
4557
+ steps.add("handoff-audit");
4558
+ await persistSteps();
4559
+ }
4560
+ } else if (!steps.has("handoff-index")) {
4561
+ steps.add("handoff-index");
4562
+ await persistSteps();
4563
+ }
4564
+ }
4565
+ await completeWorkflowTransactionJournal(cwd, sessionId, retryMutationId);
4566
+ }
4567
+ await touchStateActivityMarker(cwd, sessionId, callerPath);
4568
+ return {
4569
+ status: 0,
4570
+ stdout: renderCliWriteReceipt({
4571
+ ok: true,
4572
+ from: caller,
4573
+ to: callee,
4574
+ handoff_at: retryAt,
4575
+ phases: {
4576
+ from: "handoff",
4577
+ to:
4578
+ typeof retryCalleeState.current_phase === "string"
4579
+ ? retryCalleeState.current_phase
4580
+ : initialPhaseForSkill(workflowCallee),
4581
+ },
4582
+ paths: { from: callerPath, to: calleePath, active_state: activePath },
4583
+ }),
4584
+ };
4585
+ }
4586
+ if (pendingCalleeRecovery) {
4587
+ if (calleeRead.kind !== "valid")
4588
+ throw new StateCommandError(2, "handoff recovery cannot proceed without persisted callee state");
4589
+ await assertHandoffRecoveryEnvelopeIntegrity(cwd, sessionId, callerPath, caller, existingCaller, "caller");
4590
+ await assertHandoffRecoveryEnvelopeIntegrity(
4591
+ cwd,
4592
+ sessionId,
4593
+ calleePath,
4594
+ workflowCallee,
4595
+ calleeRead.value,
4596
+ "callee",
4597
+ );
4598
+ const recoveryCalleeState = calleeRead.value;
4599
+ const recoveryCallerState: Record<string, unknown> = {
4600
+ ...normalizedCaller,
4601
+ skill: caller,
4602
+ version: WORKFLOW_STATE_VERSION,
4603
+ active: false,
4604
+ current_phase: "handoff",
4605
+ handoff_to: callee,
4606
+ ...(typeof normalizedCaller.handoff_from === "string" && typeof normalizedCaller.handoff_at === "string"
4607
+ ? { upstream_handoff_at: normalizedCaller.upstream_handoff_at ?? normalizedCaller.handoff_at }
4608
+ : {}),
4609
+ ...(ralplanExecutionFinal
4610
+ ? {
4611
+ final_admission_phase_transition: {
4612
+ run_id: ralplanExecutionFinal.runId,
4613
+ final_path: ralplanExecutionFinal.finalPath,
4614
+ final_sha256: ralplanExecutionFinal.finalSha256,
4615
+ auto_handoff_sha256: createHash("sha256")
4616
+ .update(JSON.stringify(normalizedCaller.auto_handoff ?? null))
4617
+ .digest("hex"),
4618
+ },
4619
+ }
4620
+ : {}),
4621
+ handoff_at: handoffAt,
4622
+ updated_at: handoffAt,
4623
+ receipt: callerReceipt,
4624
+ };
4625
+ const callerWrite = await writeJsonAtomic(cwd, callerPath, recoveryCallerState, "handoff", {
4626
+ sessionId,
4627
+ skill: caller,
4628
+ mutationId,
4629
+ force: pendingRecoveryForced,
4630
+ fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
4631
+ toPhase: "handoff",
4632
+ lockHeld: options.callerLockHeld,
4633
+ });
4634
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4635
+ steps: ["callee-mode-state", "caller-mode-state"],
4636
+ });
4637
+ await syncHandoffRetryProjection({
4638
+ cwd,
4639
+ sessionId,
4640
+ caller,
4641
+ callee: workflowCallee,
4642
+ callerPath,
4643
+ calleePath,
4644
+ callerState: callerWrite.stamped,
4645
+ calleeState: recoveryCalleeState,
4646
+ threadId,
4647
+ turnId,
4648
+ handoffAt,
4649
+ });
4650
+ await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4651
+ steps: ["callee-mode-state", "caller-mode-state", "active-state"],
4652
+ });
4653
+ const recoveryAuditOptions: HandoffAuditOptions = {
4654
+ cwd,
4655
+ sessionId,
4656
+ caller,
4657
+ callee: workflowCallee,
4658
+ callerPath,
4659
+ calleePath,
4660
+ activePath,
4661
+ mutationId,
4662
+ handoffAt,
4663
+ fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
4664
+ callerState: callerWrite.stamped,
4665
+ calleeState: recoveryCalleeState,
4666
+ forced: pendingRecoveryForced,
4667
+ };
4668
+ await appendHandoffAudit(recoveryAuditOptions, {
4669
+ afterIndex: () =>
4670
+ updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4671
+ steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index"],
4672
+ }),
4673
+ afterAudit: () =>
4674
+ updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4675
+ steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index", "handoff-audit"],
4676
+ }),
4677
+ });
4678
+ await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
4679
+ await touchStateActivityMarker(cwd, sessionId, callerPath);
4680
+ return {
4681
+ status: 0,
4682
+ stdout: renderCliWriteReceipt({
4683
+ ok: true,
4684
+ from: caller,
4685
+ to: callee,
4686
+ handoff_at: handoffAt,
4687
+ phases: {
4688
+ from: "handoff",
4689
+ to:
4690
+ typeof recoveryCalleeState.current_phase === "string"
4691
+ ? recoveryCalleeState.current_phase
4692
+ : initialPhaseForSkill(workflowCallee),
4693
+ },
4694
+ paths: { from: callerPath, to: calleePath, active_state: activePath },
4695
+ }),
4696
+ };
4697
+ }
1720
4698
  const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
1721
4699
  const calleeReceipt = buildWorkflowStateReceipt({
1722
4700
  cwd,
1723
- skill: callee,
4701
+ skill: workflowCallee,
1724
4702
  owner: "gjc-state-cli",
1725
4703
  command: `gjc state ${caller} handoff --to ${callee}`,
1726
4704
  sessionId,
1727
4705
  nowIso: handoffAt,
1728
4706
  mutationId,
1729
4707
  });
4708
+ calleeReceipt.forced = forced;
1730
4709
 
1731
- const calleeInitial = initialPhaseForSkill(callee);
4710
+ const calleeInitial = initialPhaseForSkill(workflowCallee);
1732
4711
  const normalizedCallee =
1733
- callee === "deep-interview"
1734
- ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee, callee).state) as Record<
4712
+ workflowCallee === "deep-interview"
4713
+ ? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee, workflowCallee).state) as Record<
1735
4714
  string,
1736
4715
  unknown
1737
4716
  >)
1738
- : migrateWorkflowState(existingCallee, callee).state;
4717
+ : migrateWorkflowState(existingCallee, workflowCallee).state;
1739
4718
  const mergedCalleeState: Record<string, unknown> = {
1740
4719
  ...normalizedCallee,
1741
- skill: callee,
4720
+ skill: workflowCallee,
1742
4721
  version: WORKFLOW_STATE_VERSION,
1743
4722
  active: true,
1744
4723
  current_phase: calleeInitial,
@@ -1757,6 +4736,21 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1757
4736
  active: false,
1758
4737
  current_phase: "handoff",
1759
4738
  handoff_to: callee,
4739
+ ...(typeof normalizedCaller.handoff_from === "string" && typeof normalizedCaller.handoff_at === "string"
4740
+ ? { upstream_handoff_at: normalizedCaller.upstream_handoff_at ?? normalizedCaller.handoff_at }
4741
+ : {}),
4742
+ ...(ralplanExecutionFinal
4743
+ ? {
4744
+ final_admission_phase_transition: {
4745
+ run_id: ralplanExecutionFinal.runId,
4746
+ final_path: ralplanExecutionFinal.finalPath,
4747
+ final_sha256: ralplanExecutionFinal.finalSha256,
4748
+ auto_handoff_sha256: createHash("sha256")
4749
+ .update(JSON.stringify(normalizedCaller.auto_handoff ?? null))
4750
+ .digest("hex"),
4751
+ },
4752
+ }
4753
+ : {}),
1760
4754
  handoff_at: handoffAt,
1761
4755
  updated_at: handoffAt,
1762
4756
  receipt: callerReceipt,
@@ -1767,7 +4761,7 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1767
4761
  sessionId,
1768
4762
  mutationId,
1769
4763
  caller,
1770
- callee,
4764
+ callee: workflowCallee,
1771
4765
  paths: [calleePath, callerPath, activeStateFile(cwd, sessionId)],
1772
4766
  });
1773
4767
 
@@ -1778,23 +4772,27 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1778
4772
  // and write order keeps the session-scoped source of truth ahead of the
1779
4773
  // root aggregate. strict:true on the active-state read tolerates ENOENT
1780
4774
  // only; corrupt JSON / IO failures propagate as non-zero CLI status.
1781
- const force = hasFlag(args, "--force");
1782
4775
  const calleeWrite = await writeJsonAtomic(cwd, calleePath, mergedCalleeState, "handoff", {
1783
4776
  sessionId,
1784
- skill: callee,
4777
+ skill: workflowCallee,
1785
4778
  mutationId,
1786
- force,
4779
+ force: forced,
1787
4780
  fromPhase: typeof existingCallee.current_phase === "string" ? existingCallee.current_phase : undefined,
1788
4781
  toPhase: calleeInitial,
4782
+ lockHeld: options.calleeLockHeld,
1789
4783
  });
1790
4784
  await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["callee-mode-state"] });
4785
+ if (process.env.GJC_STATE_HANDOFF_FAIL_AFTER_CALLEE === mutationId) {
4786
+ throw new StateCommandError(1, `injected handoff failure after callee write for ${mutationId}`);
4787
+ }
1791
4788
  const callerWrite = await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
1792
4789
  sessionId,
1793
4790
  skill: caller,
1794
4791
  mutationId,
1795
- force,
4792
+ force: forced,
1796
4793
  fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
1797
4794
  toPhase: "handoff",
4795
+ lockHeld: options.callerLockHeld,
1798
4796
  });
1799
4797
  await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1800
4798
  steps: ["callee-mode-state", "caller-mode-state"],
@@ -1828,14 +4826,14 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1828
4826
  },
1829
4827
  callee: {
1830
4828
  cwd,
1831
- skill: callee,
4829
+ skill: workflowCallee,
1832
4830
  active: true,
1833
4831
  phase: calleeInitial,
1834
4832
  sessionId,
1835
4833
  threadId,
1836
4834
  turnId,
1837
4835
  source: "gjc-state-cli",
1838
- hud: buildHudForMode(callee, mergedCalleeState),
4836
+ hud: buildHudForMode(workflowCallee, mergedCalleeState),
1839
4837
  handoff_from: caller,
1840
4838
  handoff_at: handoffAt,
1841
4839
  receipt: calleeReceipt,
@@ -1844,6 +4842,31 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1844
4842
  await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
1845
4843
  steps: ["callee-mode-state", "caller-mode-state", "active-state"],
1846
4844
  });
4845
+ const handoffAuditOptions: HandoffAuditOptions = {
4846
+ cwd,
4847
+ sessionId,
4848
+ caller,
4849
+ callee: workflowCallee,
4850
+ callerPath,
4851
+ calleePath,
4852
+ activePath,
4853
+ mutationId,
4854
+ handoffAt,
4855
+ fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
4856
+ callerState: callerWrite.stamped,
4857
+ calleeState: calleeWrite.stamped,
4858
+ forced,
4859
+ };
4860
+ await appendHandoffAudit(handoffAuditOptions, {
4861
+ afterIndex: () =>
4862
+ updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4863
+ steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index"],
4864
+ }),
4865
+ afterAudit: () =>
4866
+ updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
4867
+ steps: ["callee-mode-state", "caller-mode-state", "active-state", "handoff-index", "handoff-audit"],
4868
+ }),
4869
+ });
1847
4870
  await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
1848
4871
  await touchStateActivityMarker(cwd, sessionId, callerPath);
1849
4872
 
@@ -1881,7 +4904,8 @@ async function handleHandoffUnlocked(args: readonly string[], cwd: string): Prom
1881
4904
  }
1882
4905
 
1883
4906
  async function handleHandoff(args: readonly string[], cwd: string): Promise<StateCommandResult> {
1884
- const selectors = await resolveSelectors(args, cwd, "handoff");
4907
+ const resolved = await resolveHandoffSelectors(args, cwd);
4908
+ const { selectors, callerPath } = resolved;
1885
4909
  // Serialize concurrent handoffs on a dedicated sentinel lock, NOT on the
1886
4910
  // derived `skill-active-state.json` cache. The inner transaction
1887
4911
  // (applyHandoffToActiveState / syncSkillActiveState -> rebuildActiveSnapshot)
@@ -1891,7 +4915,560 @@ async function handleHandoff(args: readonly string[], cwd: string): Promise<Stat
1891
4915
  // `{ cwd }` so the sentinel resolves against the handoff cwd rather than
1892
4916
  // `process.cwd()`.
1893
4917
  const handoffLock = path.join(sessionStateDir(cwd, selectors.gjcSessionId), "handoff");
1894
- return withWorkflowStateLock(handoffLock, async () => handleHandoffUnlocked(args, cwd), { cwd });
4918
+ return withWorkflowStateLock(
4919
+ handoffLock,
4920
+ () =>
4921
+ withWorkflowStateLock(callerPath, () => handleHandoffUnlocked(args, cwd, resolved, { callerLockHeld: true }), {
4922
+ cwd,
4923
+ }),
4924
+ { cwd },
4925
+ );
4926
+ }
4927
+
4928
+ interface ExecutionApprovalAuditOptions {
4929
+ cwd: string;
4930
+ sessionId: string;
4931
+ statePath: string;
4932
+ approvalRecordPath?: string;
4933
+ questionId?: string;
4934
+ gateId?: string;
4935
+ target?: "ultragoal";
4936
+ answerHash?: string;
4937
+ approvedAt: string;
4938
+ mutationId: string;
4939
+ revision: number;
4940
+ receipt: WorkflowStateReceipt;
4941
+ }
4942
+
4943
+ function buildExecutionApprovalAuditEntry(
4944
+ options: ExecutionApprovalAuditOptions,
4945
+ ): AuditEntry & Record<string, unknown> {
4946
+ return {
4947
+ ts: options.approvedAt,
4948
+ skill: "deep-interview",
4949
+ category: "state",
4950
+ verb: "approve-execution",
4951
+ owner: "gjc-state-cli",
4952
+ mutation_id: options.mutationId,
4953
+ from_phase: "handoff",
4954
+ to_phase: "handoff",
4955
+ forced: false,
4956
+ paths: [options.statePath],
4957
+ ...(options.approvalRecordPath ? { approval_record_path: options.approvalRecordPath } : {}),
4958
+ ...(options.questionId ? { question_id: options.questionId } : {}),
4959
+ ...(options.gateId ? { gate_id: options.gateId } : {}),
4960
+ ...(options.target ? { target: options.target } : {}),
4961
+ ...(options.answerHash ? { answer_hash: options.answerHash } : {}),
4962
+ approved_at: options.approvedAt,
4963
+ state_path: options.statePath,
4964
+ state_revision: options.revision,
4965
+ receipt_state_revision: options.revision,
4966
+ receipt: options.receipt,
4967
+ } as AuditEntry & Record<string, unknown>;
4968
+ }
4969
+
4970
+ function isSpecializedApprovalAuditLine(line: string, mutationId: string): boolean {
4971
+ if (!line.includes(mutationId) || !line.includes('"approve-execution"')) return false;
4972
+ try {
4973
+ const parsed: unknown = JSON.parse(line);
4974
+ return (
4975
+ isPlainObject(parsed) &&
4976
+ parsed.skill === "deep-interview" &&
4977
+ parsed.category === "state" &&
4978
+ parsed.verb === "approve-execution" &&
4979
+ parsed.mutation_id === mutationId &&
4980
+ typeof parsed.approved_at === "string" &&
4981
+ Number.isSafeInteger(parsed.receipt_state_revision)
4982
+ );
4983
+ } catch {
4984
+ return false;
4985
+ }
4986
+ }
4987
+
4988
+ async function approvalAuditContainsMutation(filePath: string, mutationId: string): Promise<boolean> {
4989
+ const raw = await readBoundedIdentityText(filePath, 8 * 1024 * 1024, "execution approval recovery audit", {
4990
+ tail: true,
4991
+ });
4992
+ if (!raw) return false;
4993
+ return raw.split(/\r?\n/).some(line => isSpecializedApprovalAuditLine(line, mutationId));
4994
+ }
4995
+
4996
+ async function approvalAuditContainsMutationAtOffset(
4997
+ filePath: string,
4998
+ mutationId: string,
4999
+ offset: number,
5000
+ ): Promise<boolean> {
5001
+ const raw = await readBoundedIdentityText(filePath, 128 * 1024, "execution approval recovery audit offset", {
5002
+ offset,
5003
+ });
5004
+ if (raw === undefined) return false;
5005
+ return isSpecializedApprovalAuditLine(raw.split(/\r?\n/, 1)[0] ?? "", mutationId);
5006
+ }
5007
+
5008
+ async function appendExecutionApprovalAuditIdempotent(
5009
+ options: ExecutionApprovalAuditOptions,
5010
+ entry: AuditEntry & Record<string, unknown>,
5011
+ knownOffset?: number,
5012
+ beforeAppend?: (offset: number) => Promise<unknown>,
5013
+ ): Promise<number | undefined> {
5014
+ const filePath = auditPath(options.cwd, options.sessionId);
5015
+ return withWorkflowStateLock(
5016
+ filePath,
5017
+ async () => {
5018
+ if (
5019
+ knownOffset !== undefined &&
5020
+ Number.isSafeInteger(knownOffset) &&
5021
+ knownOffset >= 0 &&
5022
+ (await approvalAuditContainsMutationAtOffset(filePath, options.mutationId, knownOffset))
5023
+ )
5024
+ return knownOffset;
5025
+ if (knownOffset === undefined && (await approvalAuditContainsMutation(filePath, options.mutationId))) return;
5026
+ let appendedOffset: number | undefined;
5027
+ await appendAuditEntry(options.cwd, options.sessionId, entry, {
5028
+ lockHeld: true,
5029
+ beforeAppend: async offset => {
5030
+ appendedOffset = offset;
5031
+ await beforeAppend?.(offset);
5032
+ },
5033
+ });
5034
+ return appendedOffset;
5035
+ },
5036
+ { cwd: options.cwd },
5037
+ );
5038
+ }
5039
+
5040
+ async function writeExecutionApprovalIndex(
5041
+ options: ExecutionApprovalAuditOptions,
5042
+ entry: AuditEntry & Record<string, unknown>,
5043
+ ): Promise<void> {
5044
+ await writeArtifact(
5045
+ path.join(sessionStateDir(options.cwd, options.sessionId), "deep-interview-approval-audit.json"),
5046
+ `${JSON.stringify(entry)}\n`,
5047
+ {
5048
+ cwd: options.cwd,
5049
+ audit: {
5050
+ category: "artifact",
5051
+ verb: "write",
5052
+ owner: "gjc-state-cli",
5053
+ skill: "deep-interview",
5054
+ sessionId: options.sessionId,
5055
+ mutationId: options.mutationId,
5056
+ },
5057
+ },
5058
+ );
5059
+ }
5060
+
5061
+ async function appendExecutionApprovalAudit(
5062
+ options: ExecutionApprovalAuditOptions,
5063
+ hooks: {
5064
+ afterIndex?: () => Promise<unknown>;
5065
+ beforeAudit?: (offset: number) => Promise<unknown>;
5066
+ afterAudit?: () => Promise<unknown>;
5067
+ } = {},
5068
+ ): Promise<void> {
5069
+ const entry = buildExecutionApprovalAuditEntry(options);
5070
+ await writeExecutionApprovalIndex(options, entry);
5071
+ await hooks.afterIndex?.();
5072
+ const filePath = auditPath(options.cwd, options.sessionId);
5073
+ await withWorkflowStateLock(
5074
+ filePath,
5075
+ async () => {
5076
+ if (process.env.GJC_STATE_APPROVAL_FAIL_BEFORE_AUDIT === "1")
5077
+ await appendAuditEntry(options.cwd, options.sessionId, entry, {
5078
+ lockHeld: true,
5079
+ beforeAppend: async offset => {
5080
+ await hooks.beforeAudit?.(offset);
5081
+ throw new StateCommandError(1, "injected approval audit failure");
5082
+ },
5083
+ });
5084
+ else
5085
+ await appendAuditEntry(options.cwd, options.sessionId, entry, {
5086
+ lockHeld: true,
5087
+ beforeAppend: hooks.beforeAudit,
5088
+ });
5089
+ },
5090
+ { cwd: options.cwd },
5091
+ );
5092
+ await hooks.afterAudit?.();
5093
+ }
5094
+
5095
+ async function handleApproveExecutionUnlocked(cwd: string, selectors: ResolvedSelectors): Promise<StateCommandResult> {
5096
+ if (
5097
+ (selectors.mode === "deep-interview" || selectors.mode === "ralplan") &&
5098
+ (await executionApprovalLineage(cwd, selectors.gjcSessionId, selectors.mode)) === "ordinary"
5099
+ )
5100
+ return consumeNonCrystalApprovalUnlocked(cwd, selectors.gjcSessionId, selectors.mode);
5101
+ if (selectors.mode !== "deep-interview")
5102
+ throw new StateCommandError(2, "approve-execution requires --mode deep-interview");
5103
+ const approvalRecordPath = deepInterviewExecutionApprovalRecordPath(cwd, selectors.gjcSessionId);
5104
+ return withWorkflowStateLock(
5105
+ approvalRecordPath,
5106
+ () => handleApproveExecutionRecordLocked(cwd, selectors, approvalRecordPath),
5107
+ { cwd },
5108
+ );
5109
+ }
5110
+
5111
+ async function handleApproveExecutionRecordLocked(
5112
+ cwd: string,
5113
+ selectors: ResolvedSelectors,
5114
+ approvalRecordPath: string,
5115
+ ): Promise<StateCommandResult> {
5116
+ if (selectors.mode !== "deep-interview")
5117
+ throw new StateCommandError(2, "approve-execution requires --mode deep-interview");
5118
+ const statePath = modeStateFile(cwd, "deep-interview", selectors.gjcSessionId);
5119
+ const current = await readExistingStateForMutation(statePath);
5120
+ if (current.kind !== "valid")
5121
+ throw new StateCommandError(2, "approve-execution requires valid active deep-interview state");
5122
+ const envelope = normalizeDeepInterviewEnvelope(current.value) as Record<string, unknown>;
5123
+ if (envelope.version !== WORKFLOW_STATE_VERSION)
5124
+ throw new StateCommandError(2, "approve-execution requires current deep-interview state version");
5125
+ const ralplanApproval =
5126
+ envelope.active === false &&
5127
+ envelope.handoff_to === "ralplan" &&
5128
+ typeof envelope.handoff_at === "string" &&
5129
+ (await hasAuditedDeepInterviewHandoff(cwd, selectors.gjcSessionId, "ralplan", {
5130
+ handoffAt: envelope.handoff_at,
5131
+ }));
5132
+ if (envelope.active !== true && !ralplanApproval)
5133
+ throw new StateCommandError(2, "approve-execution requires active or Ralplan-handed-off deep-interview state");
5134
+ if (envelope.current_phase !== "handoff")
5135
+ throw new StateCommandError(2, "approve-execution requires deep-interview current_phase handoff");
5136
+ const inner = isPlainObject(envelope.state) ? envelope.state : {};
5137
+ const crystal = requireReadyCanonicalCrystal(inner.crystal);
5138
+ const approvedAt = nowIso();
5139
+ const mutationId = `deep-interview:approve-execution:${approvedAt}`;
5140
+ const currentRevision = existingStateRevision(envelope);
5141
+ if (typeof currentRevision !== "number" || !Number.isSafeInteger(currentRevision) || currentRevision < 0)
5142
+ throw new StateCommandError(2, "approve-execution requires a valid deep-interview state revision");
5143
+ const approvalRevision = currentRevision + 1;
5144
+ const resolvedStatePath = path.resolve(statePath);
5145
+ const publicationReceipt = isPlainObject(envelope.receipt) ? envelope.receipt : undefined;
5146
+ const publicationChecksum = isPlainObject(publicationReceipt?.content_sha256)
5147
+ ? publicationReceipt.content_sha256
5148
+ : undefined;
5149
+ if (
5150
+ inner.execution_approval !== "approved" &&
5151
+ ((ralplanApproval
5152
+ ? publicationReceipt?.owner !== "gjc-state-cli" ||
5153
+ publicationReceipt.command !== "gjc state deep-interview handoff --to ralplan"
5154
+ : publicationReceipt?.owner !== "gjc-runtime" ||
5155
+ publicationReceipt.command !== "gjc deep-interview crystallize") ||
5156
+ publicationChecksum?.algorithm !== "sha256" ||
5157
+ typeof publicationChecksum?.value !== "string" ||
5158
+ publicationChecksum.value.length !== 64 ||
5159
+ publicationChecksum.covered_path !== path.resolve(statePath))
5160
+ )
5161
+ throw new StateCommandError(2, "approve-execution requires a canonically published Crystal receipt");
5162
+ const integrityWarning = await warnAndAuditOutOfBandIfNeeded(
5163
+ cwd,
5164
+ selectors.gjcSessionId,
5165
+ statePath,
5166
+ "deep-interview",
5167
+ { mutationId },
5168
+ );
5169
+ if (integrityWarning) throw new StateCommandError(2, `${integrityWarning}; approval refuses tampered mode-state`);
5170
+ await assertDeepInterviewHandoffReady(envelope, {
5171
+ cwd,
5172
+ sessionId: selectors.gjcSessionId,
5173
+ statePath,
5174
+ });
5175
+ const existingReceipt = isPlainObject(inner.execution_approval_receipt)
5176
+ ? inner.execution_approval_receipt
5177
+ : undefined;
5178
+ if (inner.execution_approval === "approved") {
5179
+ if (existingReceipt?.schema_version !== 1 || existingReceipt.method !== "explicit-state-action")
5180
+ throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
5181
+ if (typeof existingReceipt.mutation_id !== "string")
5182
+ throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
5183
+ }
5184
+ const approvalRecord = await readDeepInterviewExecutionApprovalRecord(approvalRecordPath);
5185
+ if (!approvalRecord)
5186
+ throw new StateCommandError(2, "approve-execution requires a user-origin execution approval record");
5187
+ await assertExecutionApprovalSpecIdentity(approvalRecord);
5188
+ await assertExecutionApprovalTranscriptBoundary(
5189
+ cwd,
5190
+ selectors.gjcSessionId,
5191
+ approvalRecord.transcript_path,
5192
+ approvalRecord.transcript_sha256,
5193
+ approvalRecord.transcript_boundary,
5194
+ );
5195
+ await assertRalplanApprovalRecordCurrent(cwd, selectors.gjcSessionId, approvalRecord);
5196
+ assertExecutionApprovalRecordMatchesCurrentState(approvalRecord, {
5197
+ sessionId: selectors.gjcSessionId,
5198
+ statePath,
5199
+ envelope,
5200
+ crystal,
5201
+ recordRevision: inner.execution_approval === "approved" ? currentRevision - 1 : currentRevision,
5202
+ allowConsumed: inner.execution_approval === "approved",
5203
+ });
5204
+ if (inner.execution_approval !== "approved" && approvalRecord.status !== "pending")
5205
+ throw new StateCommandError(2, "approve-execution requires an unconsumed execution approval record");
5206
+ if (inner.execution_approval === "approved") {
5207
+ if (
5208
+ !existingReceipt ||
5209
+ typeof existingReceipt.mutation_id !== "string" ||
5210
+ typeof existingReceipt.approved_at !== "string" ||
5211
+ typeof existingReceipt.state_revision !== "number"
5212
+ )
5213
+ throw new StateCommandError(2, "deep-interview execution approval lacks explicit provenance");
5214
+ if (
5215
+ existingReceipt.question_id !== approvalRecord.question_id ||
5216
+ existingReceipt.gate_id !== approvalRecord.gate_id ||
5217
+ existingReceipt.answer_hash !== approvalRecord.answer_hash ||
5218
+ existingReceipt.transcript_path !== approvalRecord.transcript_path ||
5219
+ existingReceipt.transcript_sha256 !== approvalRecord.transcript_sha256 ||
5220
+ JSON.stringify(existingReceipt.transcript_boundary) !== JSON.stringify(approvalRecord.transcript_boundary) ||
5221
+ existingReceipt.target !== approvalRecord.target ||
5222
+ existingReceipt.approval_stage !== (approvalRecord.approval_stage ?? "deep-interview") ||
5223
+ existingReceipt.ralplan_state_path !== approvalRecord.ralplan_state_path ||
5224
+ existingReceipt.ralplan_state_revision !== approvalRecord.ralplan_state_revision ||
5225
+ existingReceipt.ralplan_run_id !== approvalRecord.ralplan_run_id ||
5226
+ existingReceipt.ralplan_final_path !== approvalRecord.ralplan_final_path ||
5227
+ existingReceipt.ralplan_final_sha256 !== approvalRecord.ralplan_final_sha256
5228
+ )
5229
+ throw new StateCommandError(2, "deep-interview execution approval recovery receipt identity mismatch");
5230
+ if (
5231
+ approvalRecord.status === "consumed" &&
5232
+ (approvalRecord.consumed_mutation_id !== existingReceipt.mutation_id ||
5233
+ approvalRecord.consumed_at !== existingReceipt.approved_at)
5234
+ )
5235
+ throw new StateCommandError(2, "deep-interview execution approval record provenance mismatch");
5236
+ const pendingJournal = await readWorkflowTransactionJournal(
5237
+ cwd,
5238
+ selectors.gjcSessionId,
5239
+ existingReceipt.mutation_id,
5240
+ );
5241
+ if (pendingJournal?.status !== "pending")
5242
+ throw new StateCommandError(2, "deep-interview execution approval record is already consumed");
5243
+ const persistedReceipt = persistedWorkflowReceipt(envelope.receipt, "deep-interview");
5244
+ const persistedRevision = existingStateRevision(envelope);
5245
+ const expectedJournalPaths = [
5246
+ resolvedStatePath,
5247
+ path.resolve(approvalRecordPath),
5248
+ auditPath(cwd, selectors.gjcSessionId),
5249
+ ].map(value => path.resolve(value));
5250
+ if (
5251
+ !persistedReceipt ||
5252
+ typeof persistedRevision !== "number" ||
5253
+ persistedRevision !== existingReceipt.state_revision ||
5254
+ persistedReceipt.mutation_id !== existingReceipt.mutation_id ||
5255
+ persistedReceipt.mutated_at !== existingReceipt.approved_at ||
5256
+ pendingJournal.mutation_id !== existingReceipt.mutation_id ||
5257
+ pendingJournal.paths.length !== expectedJournalPaths.length ||
5258
+ pendingJournal.paths.some((value, index) => path.resolve(value) !== expectedJournalPaths[index])
5259
+ )
5260
+ throw new StateCommandError(2, "pending execution approval recovery journal identity mismatch");
5261
+ const recoverySteps = new Set(pendingJournal.steps);
5262
+ if (approvalRecord.status === "pending") {
5263
+ const consumedRecord: DeepInterviewExecutionApprovalRecord = {
5264
+ ...approvalRecord,
5265
+ status: "consumed",
5266
+ consumed_at: existingReceipt.approved_at as string,
5267
+ consumed_mutation_id: existingReceipt.mutation_id,
5268
+ };
5269
+ await writeDeepInterviewExecutionApprovalRecord(cwd, selectors.gjcSessionId, consumedRecord);
5270
+ recoverySteps.add("approval-record");
5271
+ }
5272
+ const approvalOptions: ExecutionApprovalAuditOptions = {
5273
+ cwd,
5274
+ sessionId: selectors.gjcSessionId,
5275
+ statePath: resolvedStatePath,
5276
+ approvalRecordPath: path.resolve(approvalRecordPath),
5277
+ questionId: approvalRecord.question_id,
5278
+ gateId: approvalRecord.gate_id,
5279
+ target: approvalRecord.target,
5280
+ answerHash: approvalRecord.answer_hash,
5281
+ approvedAt: existingReceipt.approved_at as string,
5282
+ mutationId: existingReceipt.mutation_id,
5283
+ revision: persistedRevision,
5284
+ receipt: persistedReceipt,
5285
+ };
5286
+ const indexed = await readBoundedIdentityText(
5287
+ path.join(sessionStateDir(cwd, selectors.gjcSessionId), "deep-interview-approval-audit.json"),
5288
+ 64 * 1024,
5289
+ "deep-interview execution approval index",
5290
+ );
5291
+ const expectedApprovalEntry = buildExecutionApprovalAuditEntry(approvalOptions);
5292
+ let indexedMatches = false;
5293
+ if (indexed) {
5294
+ try {
5295
+ const parsed: unknown = JSON.parse(indexed);
5296
+ indexedMatches = isPlainObject(parsed) && JSON.stringify(parsed) === JSON.stringify(expectedApprovalEntry);
5297
+ } catch {}
5298
+ }
5299
+ if (!indexedMatches) await writeExecutionApprovalIndex(approvalOptions, expectedApprovalEntry);
5300
+ recoverySteps.add("approval-index");
5301
+ let approvalAuditOffset = pendingJournal.approval_audit_offset;
5302
+ if (!recoverySteps.has("approval-audit")) {
5303
+ approvalAuditOffset = await appendExecutionApprovalAuditIdempotent(
5304
+ approvalOptions,
5305
+ expectedApprovalEntry,
5306
+ approvalAuditOffset,
5307
+ offset =>
5308
+ updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, approvalOptions.mutationId, {
5309
+ steps: [...recoverySteps],
5310
+ approval_audit_offset: offset,
5311
+ }),
5312
+ );
5313
+ recoverySteps.add("approval-audit");
5314
+ }
5315
+ await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, existingReceipt.mutation_id, {
5316
+ steps: [...recoverySteps],
5317
+ approval_audit_offset: approvalAuditOffset,
5318
+ });
5319
+ await completeWorkflowTransactionJournal(cwd, selectors.gjcSessionId, existingReceipt.mutation_id);
5320
+ await assertDeepInterviewHandoffReady(envelope, {
5321
+ cwd,
5322
+ sessionId: selectors.gjcSessionId,
5323
+ statePath,
5324
+ requireExecutionApproval: true,
5325
+ });
5326
+ return {
5327
+ status: 0,
5328
+ stdout: `${JSON.stringify({ skill: "deep-interview", execution_approval: "approved", state_path: statePath })}\n`,
5329
+ };
5330
+ }
5331
+ inner.execution_approval = "approved";
5332
+ inner.execution_approval_receipt = {
5333
+ schema_version: 1,
5334
+ method: "explicit-state-action",
5335
+ approved_at: approvedAt,
5336
+ mutation_id: mutationId,
5337
+ state_revision: approvalRevision,
5338
+ state_path: resolvedStatePath,
5339
+ spec_sha256: envelope.spec_sha256,
5340
+ crystal_spec_version: crystal.spec_version,
5341
+ crystal_source_digest: (crystal.source as Record<string, unknown>).digest,
5342
+ approval_record_path: path.resolve(approvalRecordPath),
5343
+ question_id: approvalRecord.question_id,
5344
+ gate_id: approvalRecord.gate_id,
5345
+ answer_hash: approvalRecord.answer_hash,
5346
+ transcript_path: approvalRecord.transcript_path,
5347
+ transcript_sha256: approvalRecord.transcript_sha256,
5348
+ transcript_boundary: approvalRecord.transcript_boundary,
5349
+ target: approvalRecord.target,
5350
+ approval_stage: approvalRecord.approval_stage ?? "deep-interview",
5351
+ ...(approvalRecord.approval_stage === "ralplan"
5352
+ ? {
5353
+ ralplan_state_path: approvalRecord.ralplan_state_path,
5354
+ ralplan_state_revision: approvalRecord.ralplan_state_revision,
5355
+ ralplan_run_id: approvalRecord.ralplan_run_id,
5356
+ ralplan_final_path: approvalRecord.ralplan_final_path,
5357
+ ralplan_final_sha256: approvalRecord.ralplan_final_sha256,
5358
+ }
5359
+ : {}),
5360
+ };
5361
+ envelope.state = inner;
5362
+ envelope.updated_at = approvedAt;
5363
+ await beginWorkflowTransactionJournal({
5364
+ cwd,
5365
+ sessionId: selectors.gjcSessionId,
5366
+ mutationId,
5367
+ caller: "deep-interview",
5368
+ paths: [resolvedStatePath, path.resolve(approvalRecordPath), auditPath(cwd, selectors.gjcSessionId)],
5369
+ });
5370
+ const writeResult = await writeGuardedWorkflowEnvelopeAtomic(statePath, envelope, {
5371
+ cwd,
5372
+ policy: "source",
5373
+ lockHeld: true,
5374
+ receipt: {
5375
+ cwd,
5376
+ skill: "deep-interview",
5377
+ owner: "gjc-state-cli",
5378
+ command: "gjc state deep-interview approve-execution",
5379
+ sessionId: selectors.gjcSessionId,
5380
+ nowIso: approvedAt,
5381
+ mutationId,
5382
+ verb: "approve-execution",
5383
+ fromPhase: "handoff",
5384
+ toPhase: "handoff",
5385
+ forced: false,
5386
+ },
5387
+ audit: {
5388
+ category: "state",
5389
+ verb: "approve-execution",
5390
+ owner: "gjc-state-cli",
5391
+ sessionId: selectors.gjcSessionId,
5392
+ skill: "deep-interview",
5393
+ mutationId,
5394
+ fromPhase: "handoff",
5395
+ toPhase: "handoff",
5396
+ forced: false,
5397
+ },
5398
+ });
5399
+ if (!writeResult.written || !isPlainObject(writeResult.stamped))
5400
+ throw new StateCommandError(1, "approval writer did not return a stamped workflow envelope");
5401
+ const stampedApprovalReceipt = isPlainObject(writeResult.stamped.receipt) ? writeResult.stamped.receipt : undefined;
5402
+ if (!stampedApprovalReceipt)
5403
+ throw new StateCommandError(1, "approval writer did not return a stamped workflow receipt");
5404
+ await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
5405
+ steps: ["approval-state"],
5406
+ });
5407
+ const consumedApprovalRecord: DeepInterviewExecutionApprovalRecord = {
5408
+ ...approvalRecord,
5409
+ status: "consumed",
5410
+ consumed_at: approvedAt,
5411
+ consumed_mutation_id: mutationId,
5412
+ };
5413
+ await writeDeepInterviewExecutionApprovalRecord(cwd, selectors.gjcSessionId, consumedApprovalRecord);
5414
+ await updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
5415
+ steps: ["approval-state", "approval-record"],
5416
+ });
5417
+ const approvalAuditOptions: ExecutionApprovalAuditOptions = {
5418
+ cwd,
5419
+ sessionId: selectors.gjcSessionId,
5420
+ statePath: resolvedStatePath,
5421
+ approvalRecordPath: path.resolve(approvalRecordPath),
5422
+ questionId: approvalRecord.question_id,
5423
+ gateId: approvalRecord.gate_id,
5424
+ target: approvalRecord.target,
5425
+ answerHash: approvalRecord.answer_hash,
5426
+ approvedAt,
5427
+ mutationId,
5428
+ revision: writeResult.revision,
5429
+ receipt: persistedWorkflowReceipt(stampedApprovalReceipt, "deep-interview")!,
5430
+ };
5431
+ await appendExecutionApprovalAudit(approvalAuditOptions, {
5432
+ afterIndex: () =>
5433
+ updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
5434
+ steps: ["approval-state", "approval-record", "approval-index"],
5435
+ }),
5436
+ beforeAudit: offset =>
5437
+ updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
5438
+ steps: ["approval-state", "approval-record", "approval-index"],
5439
+ approval_audit_offset: offset,
5440
+ }),
5441
+ afterAudit: () =>
5442
+ updateWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId, {
5443
+ steps: ["approval-state", "approval-record", "approval-index", "approval-audit"],
5444
+ }),
5445
+ });
5446
+ await completeWorkflowTransactionJournal(cwd, selectors.gjcSessionId, mutationId);
5447
+ await syncSkillActiveState({
5448
+ cwd,
5449
+ skill: "deep-interview",
5450
+ active: true,
5451
+ phase: typeof envelope.current_phase === "string" ? envelope.current_phase : "handoff",
5452
+ sessionId: selectors.gjcSessionId,
5453
+ threadId: selectors.threadId,
5454
+ turnId: selectors.turnId,
5455
+ source: "gjc-state-cli",
5456
+ hud: buildHudForMode("deep-interview", envelope),
5457
+ sourceRevision: writeResult.revision,
5458
+ });
5459
+ await touchStateActivityMarker(cwd, selectors.gjcSessionId, statePath);
5460
+ return {
5461
+ status: 0,
5462
+ stdout: `${JSON.stringify({ skill: "deep-interview", execution_approval: "approved", state_path: statePath, mutation_id: mutationId })}\n`,
5463
+ };
5464
+ }
5465
+
5466
+ async function handleApproveExecution(args: readonly string[], cwd: string): Promise<StateCommandResult> {
5467
+ const selectors = await resolveSelectors(args, cwd, "approve-execution");
5468
+ if (selectors.mode !== "deep-interview" && selectors.mode !== "ralplan")
5469
+ throw new StateCommandError(2, "approve-execution requires --mode deep-interview or ralplan");
5470
+ const statePath = modeStateFile(cwd, selectors.mode, selectors.gjcSessionId);
5471
+ return withWorkflowStateLock(statePath, () => handleApproveExecutionUnlocked(cwd, selectors), { cwd });
1895
5472
  }
1896
5473
 
1897
5474
  async function handleContract(args: readonly string[], cwd: string): Promise<StateCommandResult> {
@@ -2206,6 +5783,8 @@ async function handleMigrate(args: readonly string[], cwd: string): Promise<Stat
2206
5783
  if (mismatchWarning && !forced) {
2207
5784
  throw new StateCommandError(2, `${mismatchWarning}; use --force to migrate tampered mode-state`);
2208
5785
  }
5786
+ const existingRead = await readExistingStateForMutation(filePath);
5787
+ if (existingRead.kind === "valid") assertNoFutureWorkflowEnvelope(existingRead.value, mode, `migration of ${mode}`);
2209
5788
  const result = await migrateAndPersistLegacyState({
2210
5789
  cwd,
2211
5790
  skill: mode,
@@ -2232,6 +5811,8 @@ export async function runNativeStateCommand(args: string[], cwd = process.cwd())
2232
5811
  return await handleClear(args, cwd);
2233
5812
  case "contract":
2234
5813
  return await handleContract(args, cwd);
5814
+ case "approve-execution":
5815
+ return await handleApproveExecution(args, cwd);
2235
5816
  case "status":
2236
5817
  return await handleStatus(args, cwd);
2237
5818
  case "doctor":