@deksden-com/dd-flow-cli 0.9.0-beta.14 → 0.9.0-beta.141

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 (493) hide show
  1. package/CHANGELOG.md +740 -0
  2. package/README.md +143 -1
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +376 -0
  6. package/dist/cli/command-metadata.js +197 -0
  7. package/dist/cli/command-option-descriptions.js +191 -0
  8. package/dist/cli/completion-cli.js +70 -0
  9. package/dist/cli/completion.js +57 -0
  10. package/dist/cli/error-output.js +52 -0
  11. package/dist/cli/executor-onboarding.js +76 -0
  12. package/dist/cli/flows.js +29 -0
  13. package/dist/cli/harnesses.js +84 -0
  14. package/dist/cli/help.js +328 -18
  15. package/dist/cli/hook-ingress.js +78 -0
  16. package/dist/cli/input-preparation.js +165 -0
  17. package/dist/cli/memory-flow-admission.js +44 -0
  18. package/dist/cli/memory-init-invocation.js +78 -0
  19. package/dist/cli/memory-init.js +240 -0
  20. package/dist/cli/memory-storage-admission.js +53 -0
  21. package/dist/cli/models.js +39 -0
  22. package/dist/cli/native-hook-ingress.js +80 -0
  23. package/dist/cli/operator-profiles.js +61 -0
  24. package/dist/cli/output-options.js +35 -0
  25. package/dist/cli/response-file.js +71 -0
  26. package/dist/cli/run-cli.js +1689 -545
  27. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  28. package/dist/cli/runtime-maintenance-ingress.js +52 -0
  29. package/dist/cli/runtime-process.js +153 -0
  30. package/dist/cli/setup.js +149 -0
  31. package/dist/cli.js +71 -2
  32. package/dist/domain/stage-catalog.js +1 -1
  33. package/dist/domain/stage-handler-identities.js +20 -0
  34. package/dist/harness-runtime/DD-ZCODE.md +112 -0
  35. package/dist/harness-runtime/OPENCODE-LICENSE.txt +24 -0
  36. package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
  37. package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
  38. package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
  39. package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
  40. package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
  41. package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
  42. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  43. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  44. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  45. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  46. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  47. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  48. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  49. package/dist/harness-runtime/lib/daemon-operations.mjs +371 -0
  50. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  51. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +728 -0
  52. package/dist/harness-runtime/lib/dd-agy.mjs +70 -0
  53. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  54. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +196 -0
  55. package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
  56. package/dist/harness-runtime/lib/dd-codex.mjs +817 -0
  57. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +140 -0
  58. package/dist/harness-runtime/lib/dd-droid.mjs +584 -0
  59. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +498 -0
  60. package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
  61. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +557 -0
  62. package/dist/harness-runtime/lib/dd-opencode.mjs +363 -0
  63. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +751 -0
  64. package/dist/harness-runtime/lib/dd-zcode.mjs +1145 -0
  65. package/dist/harness-runtime/lib/delegation-instructions.d.mts +14 -0
  66. package/dist/harness-runtime/lib/delegation-instructions.mjs +157 -0
  67. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  68. package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
  69. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  70. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  71. package/dist/harness-runtime/lib/lease-renewal.mjs +37 -0
  72. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  73. package/dist/harness-runtime/lib/lifecycle-command.mjs +273 -0
  74. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  75. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  76. package/dist/harness-runtime/lib/managed-daemon.d.mts +16 -0
  77. package/dist/harness-runtime/lib/managed-daemon.mjs +683 -0
  78. package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
  79. package/dist/harness-runtime/lib/model-observations.mjs +101 -0
  80. package/dist/harness-runtime/lib/native-children.d.mts +16 -0
  81. package/dist/harness-runtime/lib/native-children.mjs +113 -0
  82. package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
  83. package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
  84. package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
  85. package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
  86. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  87. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  88. package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
  89. package/dist/harness-runtime/lib/opencode-plugin.mjs +85 -0
  90. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  91. package/dist/harness-runtime/lib/operation-errors.d.mts +16 -0
  92. package/dist/harness-runtime/lib/operation-errors.mjs +127 -0
  93. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  94. package/dist/harness-runtime/lib/operation-progress.mjs +114 -0
  95. package/dist/harness-runtime/lib/process-json.d.mts +5 -0
  96. package/dist/harness-runtime/lib/process-json.mjs +201 -0
  97. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  98. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +13 -0
  99. package/dist/harness-runtime/lib/provider-overload-policy.mjs +138 -0
  100. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  101. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  102. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  103. package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
  104. package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
  105. package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
  106. package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
  107. package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
  108. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-1y4a697q.js +81 -0
  109. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-2ny4vxct.js +92 -0
  110. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-6mptkzbc.js +0 -0
  111. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-7x67cf1q.js +52 -0
  112. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9rqn6x4v.js +16 -0
  113. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9vdz0zwn.js +1661 -0
  114. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ecs5ftka.js +1466 -0
  115. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-frbwqjmf.js +145 -0
  116. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gne7h7mq.js +50 -0
  117. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gx9wnkzr.js +105 -0
  118. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-m2eywc8w.js +0 -0
  119. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-nwcpakzx.js +12 -0
  120. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-q4g3afnw.js +623 -0
  121. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-qav6mfdz.js +0 -0
  122. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-sxt88q69.js +12 -0
  123. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-t1xa31vm.js +39 -0
  124. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ve4aws36.js +103 -0
  125. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vmytr4q8.js +4 -0
  126. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vt9jbch8.js +36 -0
  127. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vyas3vgt.js +0 -0
  128. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-wjyeq25y.js +30 -0
  129. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-xgnj2w4v.js +20 -0
  130. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-y2etg5bs.js +9 -0
  131. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ydhz1zrh.js +238 -0
  132. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-z6qdwcsf.js +62 -0
  133. package/dist/harness-runtime/vendor/opencode-client/contract.d.ts +1 -0
  134. package/dist/harness-runtime/vendor/opencode-client/contract.js +12 -0
  135. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.d.ts +2806 -0
  136. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.js +0 -0
  137. package/dist/harness-runtime/vendor/opencode-client/effect/api.d.ts +4 -0
  138. package/dist/harness-runtime/vendor/opencode-client/effect/api.js +0 -0
  139. package/dist/harness-runtime/vendor/opencode-client/effect/client.d.ts +2530 -0
  140. package/dist/harness-runtime/vendor/opencode-client/effect/client.js +15 -0
  141. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.d.ts +7 -0
  142. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.js +6 -0
  143. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.d.ts +2529 -0
  144. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.js +8 -0
  145. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.d.ts +2 -0
  146. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.js +12 -0
  147. package/dist/harness-runtime/vendor/opencode-client/effect/index.d.ts +35 -0
  148. package/dist/harness-runtime/vendor/opencode-client/effect/index.js +75 -0
  149. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.d.ts +20 -0
  150. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.js +11 -0
  151. package/dist/harness-runtime/vendor/opencode-client/effect/service.d.ts +90 -0
  152. package/dist/harness-runtime/vendor/opencode-client/effect/service.js +187 -0
  153. package/dist/harness-runtime/vendor/opencode-client/package.json +84 -0
  154. package/dist/harness-runtime/vendor/opencode-client/promise/api.d.ts +21 -0
  155. package/dist/harness-runtime/vendor/opencode-client/promise/api.js +0 -0
  156. package/dist/harness-runtime/vendor/opencode-client/promise/client.d.ts +252 -0
  157. package/dist/harness-runtime/vendor/opencode-client/promise/client.js +14 -0
  158. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.d.ts +8 -0
  159. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.js +6 -0
  160. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.d.ts +259 -0
  161. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.js +7 -0
  162. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.d.ts +3 -0
  163. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.js +79 -0
  164. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.d.ts +8932 -0
  165. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.js +70 -0
  166. package/dist/harness-runtime/vendor/opencode-client/promise/index.d.ts +6 -0
  167. package/dist/harness-runtime/vendor/opencode-client/promise/index.js +83 -0
  168. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.d.ts +32 -0
  169. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.js +7 -0
  170. package/dist/harness-runtime/vendor/opencode-client/promise/service.d.ts +24 -0
  171. package/dist/harness-runtime/vendor/opencode-client/promise/service.js +187 -0
  172. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.d.ts +12 -0
  173. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.js +14 -0
  174. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.d.ts +20 -0
  175. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.js +14 -0
  176. package/dist/harness-runtime/vendor/opencode-client/service-contender.d.ts +37 -0
  177. package/dist/harness-runtime/vendor/opencode-client/service-contender.js +10 -0
  178. package/dist/harness-runtime/vendor/opencode-client/service-probe.d.ts +70 -0
  179. package/dist/harness-runtime/vendor/opencode-client/service-probe.js +16 -0
  180. package/dist/harness-runtime/vendor/opencode-client/service-timing.d.ts +12 -0
  181. package/dist/harness-runtime/vendor/opencode-client/service-timing.js +10 -0
  182. package/dist/harness-runtime/vendor/opencode-client/service-version.d.ts +2 -0
  183. package/dist/harness-runtime/vendor/opencode-client/service-version.js +6 -0
  184. package/dist/harness-runtime/vendor/opencode-client/service.d.ts +52 -0
  185. package/dist/harness-runtime/vendor/opencode-client/service.js +1 -0
  186. package/dist/harness-runtime/vendor/opencode-client/shared-events.d.ts +11 -0
  187. package/dist/harness-runtime/vendor/opencode-client/shared-events.js +8 -0
  188. package/dist/harness-runtime/vendor/opencode-client/solid/connection.d.ts +37 -0
  189. package/dist/harness-runtime/vendor/opencode-client/solid/connection.js +10 -0
  190. package/dist/harness-runtime/vendor/opencode-client/solid/data.d.ts +211 -0
  191. package/dist/harness-runtime/vendor/opencode-client/solid/data.js +18 -0
  192. package/dist/harness-runtime/vendor/opencode-client/solid/index.d.ts +3 -0
  193. package/dist/harness-runtime/vendor/opencode-client/solid/index.js +32 -0
  194. package/dist/harness-runtime/vendor/opencode-client/solid/pty.d.ts +22 -0
  195. package/dist/harness-runtime/vendor/opencode-client/solid/pty.js +8 -0
  196. package/dist/harness-runtime/vendor/opencode-plugin.mjs +3 -0
  197. package/dist/protocol/local-files.js +18 -9
  198. package/dist/runtime/context.js +8 -4
  199. package/dist/schemas/agent-profile.schema.json +1 -1
  200. package/dist/schemas/code-review-result.schema.json +1 -1
  201. package/dist/schemas/code-verification.schema.json +7 -5
  202. package/dist/schemas/code-work-batch.schema.json +2 -2
  203. package/dist/schemas/discovery-result.schema.json +184 -0
  204. package/dist/schemas/execution-bundle.schema.json +205 -0
  205. package/dist/schemas/execution-settings.schema.json +162 -0
  206. package/dist/schemas/flow-descriptor.schema.json +54 -0
  207. package/dist/schemas/flow-run.schema.json +28 -0
  208. package/dist/schemas/harness-catalog.schema.json +80 -0
  209. package/dist/schemas/mb-init-compose-result.schema.json +457 -0
  210. package/dist/schemas/mb-init-discover-result.schema.json +500 -0
  211. package/dist/schemas/mb-init-finish-result.schema.json +422 -0
  212. package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
  213. package/dist/schemas/mb-init-review-result.schema.json +396 -0
  214. package/dist/schemas/mb-init-verify-result.schema.json +391 -0
  215. package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
  216. package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
  217. package/dist/schemas/memory-composition-work-result.schema.json +38 -0
  218. package/dist/schemas/memory-repair-work-result.schema.json +49 -0
  219. package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
  220. package/dist/schemas/memory-review-work-result.schema.json +43 -0
  221. package/dist/schemas/model-catalog.schema.json +153 -0
  222. package/dist/schemas/plan-review-decision.schema.json +3 -3
  223. package/dist/schemas/plan-review-result.schema.json +2 -2
  224. package/dist/schemas/profile-settings.schema.json +122 -0
  225. package/dist/schemas/release-manifest.schema.json +291 -0
  226. package/dist/schemas/run-control-receipt.schema.json +118 -0
  227. package/dist/schemas/run-control-request.schema.json +36 -0
  228. package/dist/schemas/run-engine-binding.schema.json +1 -1
  229. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  230. package/dist/services/branch-context.js +3 -3
  231. package/dist/services/canon.js +11 -8
  232. package/dist/services/check-runner-evidence.js +250 -0
  233. package/dist/services/cleanup.js +133 -40
  234. package/dist/services/cli-operation-classifier.js +25 -62
  235. package/dist/services/cli-upgrade-allowlist.js +27 -0
  236. package/dist/services/code-checks.js +1168 -107
  237. package/dist/services/codex-hook-delivery.js +32 -0
  238. package/dist/services/command-context.js +80 -0
  239. package/dist/services/compatibility-preflight.js +13 -3
  240. package/dist/services/config.js +11 -8
  241. package/dist/services/continuation-outcome.js +20 -0
  242. package/dist/services/controller-child-capacity.js +13 -0
  243. package/dist/services/controller-child-observer.js +115 -0
  244. package/dist/services/controller-execution.js +88 -0
  245. package/dist/services/controller-fanout.js +202 -0
  246. package/dist/services/dashboard.js +143 -58
  247. package/dist/services/descriptor-coordinator-session.js +35 -0
  248. package/dist/services/descriptor-root-work.js +53 -0
  249. package/dist/services/descriptor-run-settlement.js +105 -0
  250. package/dist/services/descriptor-run-start.js +449 -0
  251. package/dist/services/descriptor-stage-coordinator.js +197 -0
  252. package/dist/services/descriptor-stage-settlement.js +222 -0
  253. package/dist/services/descriptor-stage-start.js +139 -0
  254. package/dist/services/discovery/agy-discovery.js +2 -0
  255. package/dist/services/discovery/codex-discovery.js +2 -0
  256. package/dist/services/discovery/droid-discovery.js +2 -0
  257. package/dist/services/discovery/grok-discovery.js +2 -0
  258. package/dist/services/discovery/opencode-discovery.js +2 -0
  259. package/dist/services/discovery/static.js +31 -0
  260. package/dist/services/discovery/zcode-discovery.js +2 -0
  261. package/dist/services/engines.js +294 -153
  262. package/dist/services/eval-snapshots.js +1215 -116
  263. package/dist/services/execution-bundles.js +263 -0
  264. package/dist/services/execution-policy.js +185 -0
  265. package/dist/services/executor-onboarding.js +56 -0
  266. package/dist/services/external-work-launch.js +308 -0
  267. package/dist/services/flow-catalog.js +21 -0
  268. package/dist/services/flow-descriptors.js +82 -0
  269. package/dist/services/frozen-engine.js +250 -0
  270. package/dist/services/generic-execution.js +164 -0
  271. package/dist/services/harness-adapter.js +355 -36
  272. package/dist/services/harness-config.js +2 -0
  273. package/dist/services/harness-discovery.js +29 -0
  274. package/dist/services/harness-doctor.js +75 -0
  275. package/dist/services/harness-probe-effects.js +51 -0
  276. package/dist/services/harness-version-probe.js +100 -0
  277. package/dist/services/hooks.js +771 -325
  278. package/dist/services/hybrid-contracts.js +483 -0
  279. package/dist/services/ids.js +29 -1
  280. package/dist/services/installation-lock.js +95 -0
  281. package/dist/services/installed-flow-source.js +183 -0
  282. package/dist/services/lanes.js +78 -65
  283. package/dist/services/lifecycle-command.js +1 -288
  284. package/dist/services/lifecycle-contract.js +1 -0
  285. package/dist/services/lifecycle-invocations.js +2007 -0
  286. package/dist/services/managed-daemon-binding.js +38 -0
  287. package/dist/services/managed-lease-monitor.js +133 -0
  288. package/dist/services/managed-processes.js +475 -55
  289. package/dist/services/memory-accepted-composition.js +77 -0
  290. package/dist/services/memory-baseline-preservation.js +65 -0
  291. package/dist/services/memory-completed-correction-wave.js +320 -0
  292. package/dist/services/memory-compose-handler.js +258 -0
  293. package/dist/services/memory-composition-acceptance.js +199 -0
  294. package/dist/services/memory-composition-graph.js +134 -0
  295. package/dist/services/memory-composition-registration.js +146 -0
  296. package/dist/services/memory-composition-work-custody.js +151 -0
  297. package/dist/services/memory-composition-work-results.js +96 -0
  298. package/dist/services/memory-corrected-review-acceptance.js +129 -0
  299. package/dist/services/memory-corrected-review-batch.js +30 -0
  300. package/dist/services/memory-corrected-review-findings.js +106 -0
  301. package/dist/services/memory-corrected-review-inputs.js +210 -0
  302. package/dist/services/memory-corrected-review-projection.js +78 -0
  303. package/dist/services/memory-corrected-review-receipt.js +170 -0
  304. package/dist/services/memory-corrected-review-registration.js +191 -0
  305. package/dist/services/memory-correction-check-receipt.js +219 -0
  306. package/dist/services/memory-correction-input-owner.js +6 -0
  307. package/dist/services/memory-discover-handler.js +104 -0
  308. package/dist/services/memory-finish-evidence.js +435 -0
  309. package/dist/services/memory-finish-handler.js +215 -0
  310. package/dist/services/memory-finish-integration.js +280 -0
  311. package/dist/services/memory-finish-readiness.js +122 -0
  312. package/dist/services/memory-git-integration.js +151 -0
  313. package/dist/services/memory-historical-corrected-review.js +206 -0
  314. package/dist/services/memory-historical-original-review.js +130 -0
  315. package/dist/services/memory-historical-repair-registration.js +99 -0
  316. package/dist/services/memory-historical-second-review.js +128 -0
  317. package/dist/services/memory-init-bootstrap-readiness.js +210 -0
  318. package/dist/services/memory-init-contexts.js +89 -0
  319. package/dist/services/memory-init-controller-start.js +84 -0
  320. package/dist/services/memory-init-engine-release.js +223 -0
  321. package/dist/services/memory-init-git-policy.js +136 -0
  322. package/dist/services/memory-init-lint-capability.js +185 -0
  323. package/dist/services/memory-init-native-capacity.js +296 -0
  324. package/dist/services/memory-init-preflight.js +330 -0
  325. package/dist/services/memory-init-project-bootstrap.js +599 -0
  326. package/dist/services/memory-init-request-candidate.js +157 -0
  327. package/dist/services/memory-init-request-input.js +66 -0
  328. package/dist/services/memory-init-resource-storage.js +125 -0
  329. package/dist/services/memory-init-run-allocation.js +81 -0
  330. package/dist/services/memory-init-run-bundle.js +98 -0
  331. package/dist/services/memory-init-start.js +181 -0
  332. package/dist/services/memory-init-startup-custody.js +80 -0
  333. package/dist/services/memory-init-startup-input.js +240 -0
  334. package/dist/services/memory-init-storage.js +81 -0
  335. package/dist/services/memory-init-stores.js +359 -0
  336. package/dist/services/memory-init-worktrunk-capability.js +178 -0
  337. package/dist/services/memory-layer-publication.js +611 -0
  338. package/dist/services/memory-layer.js +156 -0
  339. package/dist/services/memory-lint-config.js +85 -0
  340. package/dist/services/memory-native-review-copy.js +165 -0
  341. package/dist/services/memory-normal-flow-entry.js +75 -0
  342. package/dist/services/memory-ordinary-defaults.js +112 -0
  343. package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
  344. package/dist/services/memory-preflight-handler.js +259 -0
  345. package/dist/services/memory-repair-current-view.js +299 -0
  346. package/dist/services/memory-repair-document-delta.js +451 -0
  347. package/dist/services/memory-repair-history.js +491 -0
  348. package/dist/services/memory-repair-physical-binding.js +312 -0
  349. package/dist/services/memory-repair-publication-files.js +152 -0
  350. package/dist/services/memory-repair-publication-intent.js +27 -0
  351. package/dist/services/memory-repair-publication-plan.js +160 -0
  352. package/dist/services/memory-repair-publication-source.js +225 -0
  353. package/dist/services/memory-repair-publication.js +794 -0
  354. package/dist/services/memory-repair-registration.js +239 -0
  355. package/dist/services/memory-repair-work-baseline.js +413 -0
  356. package/dist/services/memory-repair-work-completion.js +97 -0
  357. package/dist/services/memory-repair-work-custody.js +73 -0
  358. package/dist/services/memory-repair-work-results.js +89 -0
  359. package/dist/services/memory-repair-workspace.js +252 -0
  360. package/dist/services/memory-repair-writer-copy.js +735 -0
  361. package/dist/services/memory-result-binding.js +173 -0
  362. package/dist/services/memory-retained-review-authority.js +129 -0
  363. package/dist/services/memory-retained-review-results.js +70 -0
  364. package/dist/services/memory-retained-second-baseline.js +102 -0
  365. package/dist/services/memory-retained-second-repairs.js +156 -0
  366. package/dist/services/memory-review-acceptance.js +214 -0
  367. package/dist/services/memory-review-correction-plan.js +171 -0
  368. package/dist/services/memory-review-handler.js +585 -0
  369. package/dist/services/memory-review-inputs.js +200 -0
  370. package/dist/services/memory-review-receipt.js +180 -0
  371. package/dist/services/memory-review-registration.js +108 -0
  372. package/dist/services/memory-review-work-custody.js +124 -0
  373. package/dist/services/memory-review-work-input.js +72 -0
  374. package/dist/services/memory-review-work-results.js +73 -0
  375. package/dist/services/memory-second-corrected-review-inputs.js +165 -0
  376. package/dist/services/memory-second-correction-plan.js +76 -0
  377. package/dist/services/memory-second-repair-baseline.js +91 -0
  378. package/dist/services/memory-second-repair-launch.js +614 -0
  379. package/dist/services/memory-second-repair-registration.js +88 -0
  380. package/dist/services/memory-second-repair-source.js +187 -0
  381. package/dist/services/memory-second-review-acceptance.js +115 -0
  382. package/dist/services/memory-source-observations.js +117 -0
  383. package/dist/services/memory-stage-dispatch.js +28 -0
  384. package/dist/services/memory-stage-results.js +199 -0
  385. package/dist/services/memory-verification-acceptance.js +135 -0
  386. package/dist/services/memory-verification-checks.js +252 -0
  387. package/dist/services/memory-verification-receipt.js +159 -0
  388. package/dist/services/memory-verify-handler.js +135 -0
  389. package/dist/services/memory-work-result-preparation.js +63 -0
  390. package/dist/services/merge-queue.js +228 -149
  391. package/dist/services/merge-server.js +161 -35
  392. package/dist/services/migrations.js +37 -16
  393. package/dist/services/model-catalog.js +109 -0
  394. package/dist/services/model-discovery.js +46 -0
  395. package/dist/services/native-daemon-history.js +49 -0
  396. package/dist/services/native-session-control.js +39 -0
  397. package/dist/services/operator-profiles.js +236 -0
  398. package/dist/services/plan-runtime.js +26 -11
  399. package/dist/services/plans.js +28 -25
  400. package/dist/services/projects.js +37 -17
  401. package/dist/services/prompts.js +28 -17
  402. package/dist/services/protocols.js +163 -87
  403. package/dist/services/provider-capacity.js +385 -0
  404. package/dist/services/recovery-observation-budget.js +121 -0
  405. package/dist/services/recovery-snapshot-database.js +560 -0
  406. package/dist/services/release-archive-producer.js +52 -0
  407. package/dist/services/release-archive.js +143 -0
  408. package/dist/services/release-cache.js +142 -0
  409. package/dist/services/release-compatibility.js +18 -0
  410. package/dist/services/release-download.js +67 -0
  411. package/dist/services/release-envelope.js +104 -0
  412. package/dist/services/release-manifest.js +45 -0
  413. package/dist/services/release-payload.js +114 -0
  414. package/dist/services/release-producer.js +63 -0
  415. package/dist/services/release-provenance.js +160 -0
  416. package/dist/services/release-registry.js +66 -0
  417. package/dist/services/release-source.js +194 -0
  418. package/dist/services/repair-intents.js +42 -0
  419. package/dist/services/review-copy.js +165 -0
  420. package/dist/services/review-deferral.js +23 -0
  421. package/dist/services/run-control-receipt.js +56 -0
  422. package/dist/services/run-control-worker.js +411 -0
  423. package/dist/services/run-control.js +1056 -0
  424. package/dist/services/run-controller-adapter.js +399 -0
  425. package/dist/services/run-controller-capture.js +186 -0
  426. package/dist/services/run-controller-process.js +208 -0
  427. package/dist/services/run-controller-recovery.js +289 -0
  428. package/dist/services/run-controller-state.js +97 -0
  429. package/dist/services/run-controller.js +1205 -0
  430. package/dist/services/run-engine-bindings.js +1 -1
  431. package/dist/services/run-fork.js +147 -0
  432. package/dist/services/run-health.js +149 -0
  433. package/dist/services/run-observations.js +152 -0
  434. package/dist/services/run-projection.js +22 -64
  435. package/dist/services/run-recovery-guard.js +77 -0
  436. package/dist/services/run-recovery-runtime.js +112 -0
  437. package/dist/services/run-recovery.js +489 -0
  438. package/dist/services/run-start-custody.js +177 -0
  439. package/dist/services/runs.js +1019 -247
  440. package/dist/services/runtime-budget.js +430 -0
  441. package/dist/services/runtime-command.js +43 -0
  442. package/dist/services/runtime-scope-capture.js +52 -0
  443. package/dist/services/runtime-scope-control.js +468 -0
  444. package/dist/services/runtime-scope-resume.js +599 -0
  445. package/dist/services/runtime-scope-stop.js +210 -0
  446. package/dist/services/runtime-scope-worker.js +287 -0
  447. package/dist/services/runtime-service.js +96 -0
  448. package/dist/services/schema-validation.js +10 -8
  449. package/dist/services/scope-writer-cleanup.js +27 -0
  450. package/dist/services/sessions.js +35 -69
  451. package/dist/services/setup.js +377 -0
  452. package/dist/services/stage-blocker.js +17 -7
  453. package/dist/services/stage-context.js +193 -19
  454. package/dist/services/stage-handlers.js +74 -0
  455. package/dist/services/stage-lifecycle.js +152 -116
  456. package/dist/services/stage-pause.js +89 -75
  457. package/dist/services/stage-report-renderer.js +234 -6
  458. package/dist/services/stage-work-graph.js +52 -0
  459. package/dist/services/tooling.js +41 -30
  460. package/dist/services/usage.js +49 -101
  461. package/dist/services/verification-matrix.js +375 -0
  462. package/dist/services/vnext-code-review.js +223 -88
  463. package/dist/services/vnext-code.js +327 -226
  464. package/dist/services/vnext-contracts.js +10 -1
  465. package/dist/services/vnext-execution-profile.js +80 -5
  466. package/dist/services/vnext-fanout.js +311 -19
  467. package/dist/services/vnext-merge.js +332 -111
  468. package/dist/services/vnext-plan-review.js +279 -127
  469. package/dist/services/vnext-plan.js +228 -104
  470. package/dist/services/vnext-protocolize.js +531 -95
  471. package/dist/services/vnext-specify.js +119 -81
  472. package/dist/services/vnext-workspace-policy.js +7 -2
  473. package/dist/services/work-registry.js +1627 -199
  474. package/dist/services/workspace-bootstrap.js +52 -15
  475. package/dist/services/workspace-files.js +85 -0
  476. package/dist/services/worktrees.js +105 -48
  477. package/dist/shared/entity-references.js +8 -0
  478. package/dist/shared/errors.js +27 -0
  479. package/dist/storage/agent-output.js +26 -0
  480. package/dist/storage/database.js +891 -39
  481. package/dist/storage/foreign-key-transaction.js +39 -0
  482. package/dist/storage/immutable-input.js +45 -0
  483. package/dist/storage/import-guard.js +102 -0
  484. package/dist/storage/native-cleanup.js +52 -0
  485. package/dist/storage/paths.js +17 -4
  486. package/dist/storage/projection-lock.js +73 -0
  487. package/dist/storage/work-references.js +23 -0
  488. package/dist/storage/writer-contract.js +89 -0
  489. package/dist/storage/writer-migration.js +85 -0
  490. package/package.json +43 -13
  491. package/tools/create-canon-release.mjs +33 -0
  492. package/tools/produce-hybrid-release.mjs +41 -0
  493. package/tools/repair-paused-run-status.mjs +84 -0
@@ -2,10 +2,13 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
5
+ import { withProjectionLock } from "../storage/projection-lock.js";
6
+ import { reconcileRunStageReports } from "./stage-report-renderer.js";
7
+ import { isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
8
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
9
  import { AppError } from "../shared/errors.js";
8
- import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
10
+ import { nextRunId } from "./ids.js";
11
+ import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
12
  import { appendAudit } from "./audit.js";
10
13
  import { registerProject, requireProjectByRoot } from "./projects.js";
11
14
  import { buildRunFlowGuidance } from "./flow-guidance.js";
@@ -14,12 +17,18 @@ import { recalculateRunUsage } from "./usage.js";
14
17
  import { refreshRunSessionProjection } from "./run-projection.js";
15
18
  import { resolveCanonRoot } from "./canon.js";
16
19
  import { bindCurrentEngineToRun } from "./engines.js";
17
- import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
20
+ import { assertOperationalDecisionSource, executionProfilePath, loadVnextExecutionProfileSnapshot } from "./vnext-execution-profile.js";
21
+ import { loadVnextWorkspacePolicy } from "./vnext-workspace-policy.js";
22
+ import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransition, snapshotAgentProfiles } from "./execution-policy.js";
18
23
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
19
24
  import { publicSessionIdentity } from "./session-identity.js";
25
+ import { flowCommand } from "./stage-pause.js";
26
+ import { RetainedExecutionBundle } from "./execution-bundles.js";
27
+ import { admitBundledFlow, bundledHandlerContext } from "./stage-handlers.js";
28
+ import { hybridDigest } from "./hybrid-contracts.js";
29
+ import { projectPacketLocators, runContextRelocations } from "./stage-context.js";
20
30
  const runSchemaId = "dd-flow/flow-run@3";
21
31
  const runtimeSchemaId = "dd-flow/flow-run@3";
22
- const runIdType = "RUN";
23
32
  const allowedRunFlowKinds = [
24
33
  "mb_sdlc",
25
34
  "coding",
@@ -35,22 +44,26 @@ const allowedRunFlowKinds = [
35
44
  "review",
36
45
  "custom"
37
46
  ];
38
- export function startFlowRun(context, input) {
47
+ export function prepareFlowRunStart(context, input) {
48
+ const subjectId = requiredPlain(input.subjectId, "subject-id");
49
+ const subjectType = requiredPlain(input.subjectType, "subject-type");
50
+ const slug = normalizeSlug(input.slug);
51
+ for (const [name, value] of [["task profile", input.taskProfile], ["protocol overrides", input.protocolOverrides], ["RUN overrides", input.runOverrides]]) {
52
+ if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value)))
53
+ throw new AppError("validation", `${name} must be a JSON object`, 2);
54
+ }
39
55
  const projectRoot = resolveProjectRoot(input.projectRoot);
56
+ const flowKind = parseRunFlowKind(input.flowKind);
57
+ const vnext = flowKind === "vnext_protocolize" || flowKind === "vnext_specify";
58
+ if (input.executionRoutingFile !== undefined && !vnext)
59
+ throw new AppError("usage", "Execution routing input is supported only for vNext RUNs", 2);
60
+ const routingInput = input.executionRoutingFile !== undefined ? loadExecutionRoutingInput(input.executionRoutingFile) : undefined;
61
+ const executionProfile = vnext ? snapshotVnextExecutionProfile(projectRoot, context.ddFlowHome, routingInput, context.now()) : undefined;
40
62
  const flowContract = input.flowKind === "mb-upgrade"
41
63
  ? loadUpgradeFlowContract(context, projectRoot)
42
64
  : loadProjectFlowContract(projectRoot);
43
- registerProject(context, { root: projectRoot });
44
- const project = requireProjectByRoot(context, projectRoot);
45
65
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
46
- const flowKind = parseRunFlowKind(input.flowKind);
47
- const slug = normalizeSlug(input.slug);
48
- const runId = nextRunId(context, project.id, slug);
49
- const { shortId } = parseFullEntityId(runId);
50
66
  const now = context.now();
51
- const executionProfile = (flowKind === "vnext_protocolize" || flowKind === "vnext_specify")
52
- ? snapshotVnextExecutionProfile(projectRoot)
53
- : undefined;
54
67
  const flowFlags = resolveFlowFlags(flowContract, {
55
68
  flowKind,
56
69
  ...(input.preset ? { preset: input.preset } : {}),
@@ -60,17 +73,42 @@ export function startFlowRun(context, input) {
60
73
  snapshotRevision: 1,
61
74
  now
62
75
  });
76
+ return { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags };
77
+ }
78
+ export function startFlowRun(context, input, prepared = prepareFlowRunStart(context, input)) {
79
+ const { projectRoot, flowKind, routingInput, executionProfile, workspaceRoot, slug, subjectId, subjectType, now, flowFlags } = prepared;
80
+ // Ordinary settings are already frozen by preparation; only scoped authority
81
+ // requires its live acceptance source to survive final binding unchanged.
82
+ if (executionProfile?.operational_decision) {
83
+ if (crypto.createHash("sha256").update(fs.readFileSync(executionProfile.source_path)).digest("hex") !== executionProfile.source_checksum)
84
+ throw new AppError("execution_profile_changed", "Execution profile changed after RUN preparation", 2);
85
+ if (executionProfile.operational_decision)
86
+ assertOperationalDecisionSource(executionProfile.operational_decision, projectRoot, context.now());
87
+ }
88
+ registerProject(context, { root: projectRoot });
89
+ const project = requireProjectByRoot(context, projectRoot);
90
+ const runId = nextRunId(context, project.id, slug);
91
+ const { shortId } = parseFullEntityId(runId);
63
92
  const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
64
93
  const runDirRelative = path.posix.join("runs", runId);
65
94
  const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
95
+ const policyBytes = executionProfile?.operational_decision ? fs.readFileSync(path.resolve(projectRoot, executionProfile.operational_decision.policy.path)) : undefined;
96
+ const policySource = policyBytes ? { path: "operational-policy.source.md", sha256: crypto.createHash("sha256").update(policyBytes).digest("hex") } : undefined;
97
+ if (policySource && policySource.sha256 !== executionProfile?.operational_decision?.policy.sha256)
98
+ throw new AppError("operational_decision_policy_changed", "Policy changed during final RUN binding", 2);
99
+ const binding = { project_id: project.id, run_id: runId, project_root: projectRoot, ...(executionProfile?.operational_decision?.scope.eval_id ? { eval_id: executionProfile.operational_decision.scope.eval_id } : {}), ...(executionProfile?.operational_decision?.scope.execution_id ? { execution_id: executionProfile.operational_decision.scope.execution_id } : {}) };
100
+ const operationalBytes = executionProfile ? `${JSON.stringify({ schema_id: "dd-flow/run-operational-contract@1", binding, settings: { merge_delivery: executionProfile.settings.merge_delivery, merge_cleanup: executionProfile.settings.merge_cleanup }, ...(executionProfile.operational_decision ? { decision: executionProfile.operational_decision, policy_source: policySource } : {}) }, null, 2)}\n` : undefined;
101
+ if (executionProfile && operationalBytes)
102
+ executionProfile.operational_contract = { path: "operational-contract.json", sha256: crypto.createHash("sha256").update(operationalBytes).digest("hex"), ...(policySource ? { policy_source: policySource } : {}), binding };
66
103
  const index = {
67
104
  schema_id: runSchemaId,
68
105
  run_id: runId,
69
106
  short_id: shortId,
70
107
  flow_kind: flowKind,
108
+ ...(["vnext_specify", "vnext_protocolize"].includes(flowKind) ? { verification_matrix_contract: "dd-flow/verification-matrix@1", final_check_coverage_contract: "dd-flow/final-check-coverage@1" } : {}),
71
109
  subject: {
72
- id: requiredPlain(input.subjectId, "subject-id"),
73
- title: requiredPlain(input.subjectId, "subject-id")
110
+ id: subjectId,
111
+ title: subjectId
74
112
  },
75
113
  project: {
76
114
  id: project.id,
@@ -126,9 +164,14 @@ export function startFlowRun(context, input) {
126
164
  };
127
165
  ensureDir(path.dirname(runtimePath));
128
166
  ensureDir(runHome);
167
+ if (policyBytes && policySource)
168
+ fs.writeFileSync(path.join(runHome, policySource.path), policyBytes, { flag: "wx" });
169
+ if (operationalBytes)
170
+ fs.writeFileSync(path.join(runHome, "operational-contract.json"), operationalBytes, { flag: "wx" });
171
+ if (routingInput)
172
+ fs.writeFileSync(path.join(runHome, "execution-routing.input.json"), routingInput.bytes, { flag: "wx", mode: 0o600 });
129
173
  bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
130
174
  const persistedIndex = persistedVnextIndex(index);
131
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
132
175
  context.db.run(`INSERT INTO runs
133
176
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
134
177
  status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, run_root, layout_version, artifact_root_kind,
@@ -141,7 +184,7 @@ export function startFlowRun(context, input) {
141
184
  project.root,
142
185
  workspaceRoot,
143
186
  flowKind,
144
- requiredPlain(input.subjectType, "subject-type"),
187
+ subjectType,
145
188
  index.subject.id,
146
189
  index.status,
147
190
  index.verdict,
@@ -162,8 +205,9 @@ export function startFlowRun(context, input) {
162
205
  eventType: "flow_run.started",
163
206
  payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
164
207
  });
165
- appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
166
- appendRunTimeline(runHome, {
208
+ context.db.afterCommit(() => publishRunProjections(context, project.id, runId));
209
+ appendRunTimeline(context, project.id, runId, { at: now, type: "run_started", run_id: runId });
210
+ appendRunTimeline(context, project.id, runId, {
167
211
  at: now,
168
212
  type: "flow_flags_resolved",
169
213
  run_id: runId,
@@ -173,13 +217,16 @@ export function startFlowRun(context, input) {
173
217
  });
174
218
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
175
219
  }
176
- function snapshotVnextExecutionProfile(projectRoot) {
177
- const profile = loadVnextExecutionProfile(projectRoot);
220
+ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput, now = new Date().toISOString()) {
221
+ const { profile, bytes } = loadVnextExecutionProfileSnapshot(projectRoot);
222
+ if (profile.operational_decision)
223
+ assertOperationalDecisionSource(profile.operational_decision, projectRoot, now);
178
224
  const sourcePath = executionProfilePath(projectRoot);
179
- return {
225
+ const frozen = freezeRunExecutionRouting(ddFlowHome, {
180
226
  schema_id: "dd-flow/run-execution-profile@1",
181
227
  source_path: sourcePath,
182
- source_checksum: crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"),
228
+ source_checksum: crypto.createHash("sha256").update(bytes).digest("hex"),
229
+ ...(profile.operational_decision ? { operational_decision: profile.operational_decision } : {}),
183
230
  settings: {
184
231
  stage_session_mode: profile.stage_session_mode,
185
232
  plan_review_mode: profile.plan_review_mode,
@@ -188,9 +235,47 @@ function snapshotVnextExecutionProfile(projectRoot) {
188
235
  merge_delivery: profile.merge_delivery,
189
236
  merge_cleanup: profile.merge_cleanup,
190
237
  stop_target: profile.stop_target,
191
- code_bootstrap: profile.code_bootstrap
238
+ ...(profile.code_bootstrap ? { code_bootstrap: profile.code_bootstrap } : {}),
239
+ ...(profile.execution ? { execution: profile.execution } : {})
192
240
  }
193
- };
241
+ }, routingInput);
242
+ if (frozen.settings.merge_mode === "same_session" && frozen.settings.stop_target === "merge_completed"
243
+ && fs.existsSync(path.join(projectRoot, ".memory-bank", "dd-flow", "project-workspace.json"))
244
+ && loadVnextWorkspacePolicy(projectRoot).workspace.route === "feature_worktree") {
245
+ throw new AppError("merge_session_workspace_conflict", "Feature-worktree MERGE targets the integration checkout; use server MERGE or a qualified new Session", 2, { project_root: projectRoot });
246
+ }
247
+ return frozen;
248
+ }
249
+ /** Fresh RUN and fresh stage-entry experiments freeze through the same resolver. */
250
+ export function freezeRunExecutionRouting(ddFlowHome, base, input) {
251
+ const execution = input?.execution ?? base.settings.execution;
252
+ const profiles = execution ? snapshotAgentProfiles(ddFlowHome, execution) : undefined;
253
+ if (execution && profiles && base.settings.merge_mode === "same_session" && base.settings.stop_target === "merge_completed") {
254
+ const mode = base.settings.code_review_mode;
255
+ assertSameSessionMerge({ ddFlowHome, policy: execution, profiles, previousStages: mode === "off" ? ["code"] : mode === "auto" ? ["code", "code-review"] : ["code-review"] });
256
+ }
257
+ return { ...base, ...(profiles ? { agent_profiles: profiles } : {}),
258
+ ...(input ? { routing_input: { source_path: input.source_path, source_checksum: input.source_checksum, artifact: "execution-routing.input.json" } } : {}),
259
+ settings: { ...base.settings, ...(execution ? { execution } : {}) } };
260
+ }
261
+ /** Called only after a stage-entry import has archived and detached source owners. */
262
+ export function importFlowRunExecutionProfile(context, input) {
263
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
264
+ const run = resolveRun(context, project.id, input.runId);
265
+ assertRunMutationAllowed(context, project.id, run.id);
266
+ const index = authoritativeIndex(run);
267
+ const lineage = JSON.parse(fs.readFileSync(input.lineageFile, "utf8"));
268
+ if (lineage.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || lineage.run_id !== run.id || path.resolve(lineage.restored_runtime ?? "") !== path.resolve(context.ddFlowHome)
269
+ || !vnextStages.some(stage => stage.id === input.stage)
270
+ || (context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'") && context.db.get("SELECT 1 FROM run_controllers WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id]))
271
+ || index.stage_runs.some(stage => stage.stage === input.stage || !["done", "skipped"].includes(stage.status))
272
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? AND status IN ('active','idle') LIMIT 1", [project.id, run.id])) {
273
+ throw new AppError("snapshot_execution_active", "Execution policy can be selected only at a detached stage-entry import", 1);
274
+ }
275
+ index.execution_profile = { ...input.profile, stage_entry_import: { stage: input.stage, lineage_file: input.lineageFile } };
276
+ index.updated_at = context.now();
277
+ persistRunState(context, project, run, index);
278
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "execution_policy_imported", stage: input.stage, lineage_file: input.lineageFile, source_checksum: input.profile.routing_input?.source_checksum });
194
279
  }
195
280
  /**
196
281
  * A vNext route may materialize a feature checkout after its semantic
@@ -200,19 +285,82 @@ function snapshotVnextExecutionProfile(projectRoot) {
200
285
  export function rebindFlowRunWorkspace(context, input) {
201
286
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
202
287
  const run = resolveRun(context, project.id, input.runId);
288
+ const generation = assertRunMutationAllowed(context, project.id, run.id, input.expectedGeneration);
203
289
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot);
204
290
  const index = authoritativeIndex(run);
291
+ const receipt = input.routeReceipt ? { ...input.routeReceipt, policy: { ...input.routeReceipt.policy,
292
+ worktree_path: input.routeReceipt.policy.worktree_path ? resolveWorkspaceRoot(input.routeReceipt.policy.worktree_path) : null } } : undefined;
293
+ if (index.execution_bundle && !["vnext_specify", "vnext_protocolize"].includes(run.flow_kind)) {
294
+ if (workspaceRoot === run.workspace_root)
295
+ return { ok: true, changed: false };
296
+ throw new AppError("workspace_route_invalid", "Only SDLC may provision a different RUN workspace", 1, { effect: "no_effect" });
297
+ }
298
+ if (workspaceRoot === run.workspace_root) {
299
+ if (receipt && index.workspace_route && hybridDigest(retainedWorkspaceRoute(context, run).receipt) !== hybridDigest(receipt)) {
300
+ throw new AppError("workspace_route_conflict", "Committed workspace route differs from the repeated request", 1, { effect: "no_effect" });
301
+ }
302
+ publishRunProjections(context, project.id, run.id);
303
+ return { ok: true, changed: false, workspace_root: workspaceRoot };
304
+ }
305
+ if (index.workspace_route)
306
+ throw new AppError("workspace_route_conflict", "RUN already has a committed workspace route", 1, { effect: "no_effect" });
307
+ if (input.expectedRevision !== undefined && (index.runtime_revision ?? 0) !== input.expectedRevision)
308
+ throw new AppError("run_revision_changed", "RUN changed while its workspace was provisioned", 1, { effect: "no_effect", expected_revision: input.expectedRevision, actual_revision: index.runtime_revision ?? 0 });
309
+ if (index.execution_bundle && !receipt)
310
+ throw new AppError("workspace_route_missing", "Bundled SDLC rebind requires its prepared route receipt", 1, { effect: "no_effect" });
311
+ if (receipt && (run.workspace_root !== project.root || receipt.schema_id !== "dd-flow/vnext-workspace-route@1" || receipt.policy.route !== "feature_worktree"
312
+ || !receipt.policy.provisioned || receipt.policy.worktree_path !== workspaceRoot || !receipt.created || !receipt.bootstrap)) {
313
+ throw new AppError("workspace_route_invalid", "Prepared workspace route does not match this RUN", 1, { effect: "no_effect" });
314
+ }
205
315
  const now = context.now();
206
316
  const facts = gitFacts(workspaceRoot);
317
+ if (receipt && (facts.branch !== receipt.policy.feature_branch || !receipt.policy.base_ref || !facts.head
318
+ || spawnSync("git", ["-C", workspaceRoot, "merge-base", "--is-ancestor", receipt.policy.base_ref, facts.head]).status !== 0)) {
319
+ throw new AppError("workspace_route_invalid", "Prepared checkout differs from the feature branch/base receipt", 1, { effect: "no_effect", run_id: run.id });
320
+ }
207
321
  index.workspace = { ...index.workspace, workspace_path: workspaceRoot, branch: facts.branch ?? "unknown", base: facts.head ?? "unknown", manager: "official-worktrunk" };
208
322
  index.execution = { ...index.execution, project_root: project.root, workspace_root: workspaceRoot, git: facts };
209
323
  index.updated_at = now;
210
- context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
211
- persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
212
- appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason } });
213
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
324
+ const route = receipt ? { schema_id: "dd-flow/run-workspace-route@1", run_id: run.id, project_id: project.id, project_root: project.root,
325
+ generation, previous_workspace: run.workspace_root, workspace_root: workspaceRoot, revision: (index.runtime_revision ?? 0) + 1,
326
+ git: facts, receipt, receipt_sha256: hybridDigest(receipt) } : undefined;
327
+ if (route)
328
+ index.workspace_route = { revision: route.revision, sha256: hybridDigest(route) };
329
+ context.db.writeTransaction(() => {
330
+ assertRunMutationAllowed(context, project.id, run.id, generation);
331
+ const latest = resolveRun(context, project.id, run.id);
332
+ if (latest.workspace_root !== run.workspace_root)
333
+ throw new AppError("workspace_route_changed", "RUN route changed during preparation", 1, { effect: "no_effect" });
334
+ context.db.run("UPDATE runs SET workspace_root = ? WHERE project_id = ? AND id = ?", [workspaceRoot, project.id, run.id]);
335
+ persistRunState(context, project, { ...run, workspace_root: workspaceRoot }, index);
336
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.workspace_rebound", payload: { run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, ...(route ? { route } : {}) } });
337
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "workspace_rebound", run_id: run.id, workspace_root: workspaceRoot, reason: input.reason, git: facts });
338
+ }, "runs.rebindWorkspace");
214
339
  return { ok: true, run_id: run.id, project_root: project.root, workspace_root: workspaceRoot, git: facts };
215
340
  }
341
+ function retainedWorkspaceRoute(context, run) {
342
+ const index = authoritativeIndex(run), binding = index.workspace_route;
343
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.workspace_rebound' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.route.revision') = ?", [run.project_id, run.id, binding?.revision ?? -1]);
344
+ const original = rows.length === 1 ? JSON.parse(rows[0].payload_json).route : undefined;
345
+ if (!binding || !original || original.schema_id !== "dd-flow/run-workspace-route@1" || hybridDigest(original) !== binding.sha256 || hybridDigest(original.receipt) !== original.receipt_sha256) {
346
+ throw new AppError("workspace_route_invalid", "RUN committed workspace route evidence changed", 1, { effect: "no_effect", run_id: run.id });
347
+ }
348
+ // Relocation receipts are admitted by snapshot import; never rewrite bundle
349
+ // or source route authority merely because a physical checkout moved.
350
+ const route = JSON.parse(projectPacketLocators(JSON.stringify(original), runContextRelocations(context, run.project_id, run.id)));
351
+ if (route.receipt_sha256 !== original.receipt_sha256
352
+ || route.run_id !== run.id || route.project_id !== run.project_id || route.project_root !== run.project_root || route.workspace_root !== run.workspace_root
353
+ || index.workspace.workspace_path !== run.workspace_root || index.execution?.workspace_root !== run.workspace_root || index.execution?.project_root !== run.project_root
354
+ || index.workspace.manager !== "official-worktrunk" || index.workspace.branch !== route.receipt.policy.feature_branch
355
+ // execution.git is the latest observation (stage completion/refresh updates
356
+ // it). The immutable review base is workspace.base, not that moving HEAD.
357
+ || index.workspace.base !== route.git.head
358
+ || !Number.isInteger(route.generation) || route.generation < 0
359
+ || route.receipt.policy.worktree_path !== run.workspace_root) {
360
+ throw new AppError("workspace_route_invalid", "RUN has no exact coherent committed workspace route", 1, { effect: "no_effect", run_id: run.id });
361
+ }
362
+ return route;
363
+ }
216
364
  function loadUpgradeFlowContract(context, projectRoot) {
217
365
  const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
218
366
  if (!canon.ok || !canon.canon) {
@@ -224,12 +372,57 @@ function loadUpgradeFlowContract(context, projectRoot) {
224
372
  }
225
373
  return loadCanonicalFlowContract(canon.canon.flow_root);
226
374
  }
227
- export function getFlowRunStatus(context, input) {
375
+ export function getFlowRunStatus(context, input, preparedBundle) {
228
376
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
229
377
  const run = resolveRun(context, project.id, input.runId);
378
+ if (preparedBundle)
379
+ assertPreparedRunBundleSqlAuthority(context, { projectId: project.id, runId: run.id }, preparedBundle);
230
380
  const index = authoritativeIndex(run);
231
381
  const runtime = readRuntimeSnapshot(run.runtime_path);
232
- return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
382
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index, preparedBundle), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
383
+ }
384
+ /** A read-only projection of the next legal lifecycle action.
385
+ * It is derived from the persisted RUN/Work facts so controllers never infer a
386
+ * transition from a model's prose or a hard-coded stage array. */
387
+ function runContinuation(context, run, index, preparedBundle) {
388
+ if (["done", "failed", "cancelled"].includes(run.status))
389
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
390
+ const control = runControlDirective(context, run.project_id, run.id);
391
+ if (control)
392
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "operator_control", command: null, control };
393
+ const active = index.stage_runs.find((stage) => !["done", "skipped"].includes(stage.status));
394
+ if (active) {
395
+ const runningWork = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' ORDER BY updated_at DESC LIMIT 1", [run.project_id, run.id]);
396
+ const kind = active.status === "running" ? "continue_stage" : active.status === "paused" || active.status === "waiting_for_user" ? "paused" : "blocked";
397
+ return { kind, stage: active.stage, attempt: active.attempt ?? null, work_id: active.pause?.work_id ?? runningWork?.work_id ?? null, reason: active.status, command: null };
398
+ }
399
+ if (run.status === "waiting_for_user" || run.status === "paused")
400
+ return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
401
+ if (run.status === "blocked")
402
+ return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
403
+ if (index.execution_bundle) {
404
+ const bundle = preparedBundle ?? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id });
405
+ const next = bundledNextStage(bundle.descriptor, index);
406
+ if (!next)
407
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
408
+ const command = bundle.engine().command(context.ddFlowHome, ["stage", "start", run.id, "--stage", next, "--project-root", run.project_root, "--json"]);
409
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command,
410
+ execution: { status: "resolved", ...bundle.execution.stage(next) } };
411
+ }
412
+ if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
413
+ const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
414
+ const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
415
+ if (!next)
416
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
417
+ const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
418
+ const profile = index.execution_profile;
419
+ const execution = profile?.settings.execution ? profile.agent_profiles
420
+ ? { status: "resolved", ...resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(completed ? { fromStage: completed.stage } : {}), stage: next, stageSessionMode: profile.settings.stage_session_mode, mergeMode: profile.settings.merge_mode }) }
421
+ : { status: "unfrozen", reason: "execution_profile_not_frozen" }
422
+ : undefined;
423
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command, ...(execution ? { execution } : {}) };
424
+ }
425
+ return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
233
426
  }
234
427
  export function getFlowRunConfig(context, input) {
235
428
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -250,9 +443,12 @@ export function getFlowRunVariables(context, input) {
250
443
  return { ok: true, run_id: run.id, variables };
251
444
  }
252
445
  export function setFlowRunVariable(context, input) {
446
+ return setRunVariable(context, input, input.value, prepareFlowRunVariable(context, input));
447
+ }
448
+ export function prepareFlowRunVariable(context, input) {
253
449
  if (!input.key.startsWith("user."))
254
450
  throw new AppError("validation", "Only user.* RUN variables may be set directly", 2, { key: input.key });
255
- return setRunVariable(context, input, input.value);
451
+ return prepareRunVariable(context, input);
256
452
  }
257
453
  /** Controllers own policy.* and runtime.* values; agents never call this directly. */
258
454
  export function setRuntimeRunVariable(context, input) {
@@ -261,20 +457,7 @@ export function setRuntimeRunVariable(context, input) {
261
457
  return setRunVariable(context, input, input.value);
262
458
  }
263
459
  export function setFlowRunConfig(context, input) {
264
- if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
265
- throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
266
- }
267
- if (!["auto", "off", "standard", "deep"].includes(input.value)) {
268
- throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
269
- }
270
- const reason = requiredPlain(input.reason, "reason");
271
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
272
- const run = resolveRun(context, project.id, input.runId);
273
- const index = authoritativeIndex(run);
274
- const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
275
- if (index.stage_runs.some((stage) => stage.stage === stageName)) {
276
- throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
277
- }
460
+ const { reason, project, run, index } = prepareFlowRunConfig(context, input);
278
461
  const now = context.now();
279
462
  const settings = runSettings(index);
280
463
  const previous = input.key === "plan_review.mode" ? settings.plan_review : settings.code_review;
@@ -289,13 +472,32 @@ export function setFlowRunConfig(context, input) {
289
472
  index.updated_at = now;
290
473
  persistRunState(context, project, run, index);
291
474
  appendAudit(context, { projectId: project.id, eventType: "flow_run.config_set", payload: { run_id: run.id, key: input.key, value: mode, source, reason } });
292
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
475
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
293
476
  return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
294
477
  }
478
+ export function prepareFlowRunConfig(context, input) {
479
+ if (input.key !== "plan_review.mode" && input.key !== "code_review.mode") {
480
+ throw new AppError("validation", `Unknown RUN config key: ${input.key}`, 2);
481
+ }
482
+ if (!["auto", "off", "standard", "deep"].includes(input.value)) {
483
+ throw new AppError("validation", "--value for review mode must be auto, off, standard, or deep", 2);
484
+ }
485
+ const reason = requiredPlain(input.reason, "reason");
486
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
487
+ const run = resolveRun(context, project.id, input.runId);
488
+ assertRunMutationAllowed(context, project.id, run.id);
489
+ const index = authoritativeIndex(run);
490
+ const stageName = input.key === "plan_review.mode" ? "plan-review" : "code-review";
491
+ if (index.stage_runs.some((stage) => stage.stage === stageName)) {
492
+ throw new AppError("invalid_run_config_state", `${input.key} is frozen after ${stageName} starts`, 2, { run_id: run.id });
493
+ }
494
+ return { reason, project, run, index };
495
+ }
295
496
  /** Freeze an automatic review request once from already accepted policy or PLAN facts. */
296
497
  export function freezeFlowRunReviewMode(context, input) {
297
498
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
298
499
  const run = resolveRun(context, project.id, input.runId);
500
+ assertRunMutationAllowed(context, project.id, run.id);
299
501
  const index = authoritativeIndex(run);
300
502
  const settings = runSettings(index);
301
503
  const key = input.review === "plan" ? "plan_review" : "code_review";
@@ -312,8 +514,8 @@ export function getFlowRunFlagsStatus(context, input) {
312
514
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
313
515
  const run = resolveRun(context, project.id, input.runId);
314
516
  const index = authoritativeIndex(run);
315
- const runtime = readRuntimeSnapshot(run.runtime_path);
316
- const flags = runtime?.flow_flags ?? index.flow_flags;
517
+ const legacyRuntime = index.flow_flags ? null : readRuntimeSnapshot(run.runtime_path);
518
+ const flags = index.flow_flags ?? legacyRuntime?.flow_flags;
317
519
  if (!flags) {
318
520
  return {
319
521
  ok: true,
@@ -331,29 +533,31 @@ export function getFlowRunFlagsStatus(context, input) {
331
533
  snapshot_revision: flags.snapshot_revision,
332
534
  snapshot_checksum: flags.snapshot_checksum,
333
535
  flow_flags: flags,
334
- runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
536
+ runtime_revision: index.runtime_revision ?? legacyRuntime?.runtime_revision ?? null
335
537
  };
336
538
  }
337
539
  export function reviseFlowRunFlags(context, input) {
338
540
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
339
541
  const run = resolveRun(context, project.id, input.runId);
542
+ assertRunMutationAllowed(context, project.id, run.id);
340
543
  const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
341
- context.db.exec("BEGIN IMMEDIATE");
342
- let committed = false;
343
- try {
544
+ const priorMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
545
+ if (priorMutation)
546
+ return { ...JSON.parse(priorMutation.result_json), idempotent: true };
547
+ // Canonical contract bytes are prepared before taking the writer. Revision
548
+ // and idempotency are compared again against SQL authority under the lock.
549
+ const contract = loadProjectFlowContract(run.project_root);
550
+ return context.db.writeTransaction(() => {
344
551
  const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
345
552
  if (existingMutation) {
346
553
  const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
347
- context.db.exec("COMMIT");
348
- committed = true;
349
554
  return result;
350
555
  }
351
556
  // Read the authority only after the write lock: two callers with the same
352
557
  // expected revision must not both pass the compare-and-set check.
353
558
  const lockedRun = requireRunById(context, project.id, run.id);
354
559
  const index = authoritativeIndex(lockedRun);
355
- const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
356
- const current = runtime?.flow_flags ?? index.flow_flags;
560
+ const current = index.flow_flags;
357
561
  if (!current) {
358
562
  throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
359
563
  }
@@ -370,7 +574,6 @@ export function reviseFlowRunFlags(context, input) {
370
574
  });
371
575
  }
372
576
  const now = context.now();
373
- const contract = loadProjectFlowContract(lockedRun.project_root);
374
577
  const next = resolveFlowFlags(contract, {
375
578
  flowKind: index.flow_kind,
376
579
  preset: input.preset ?? current.preset.applied,
@@ -426,14 +629,12 @@ export function reviseFlowRunFlags(context, input) {
426
629
  context.db.run(`INSERT INTO flow_run_flag_mutations
427
630
  (project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
428
631
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
429
- context.db.exec("COMMIT");
430
- committed = true;
431
632
  appendAudit(context, {
432
633
  projectId: project.id,
433
634
  eventType: "flow_run.flags_revised",
434
635
  payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
435
636
  });
436
- appendRunTimeline(runArtifactRoot(run), {
637
+ appendRunTimeline(context, project.id, run.id, {
437
638
  at: now,
438
639
  type: "flow_flags_revised",
439
640
  run_id: run.id,
@@ -443,12 +644,7 @@ export function reviseFlowRunFlags(context, input) {
443
644
  ...(reason ? { reason } : {})
444
645
  });
445
646
  return result;
446
- }
447
- catch (error) {
448
- if (!committed)
449
- context.db.exec("ROLLBACK");
450
- throw error;
451
- }
647
+ }, "runs.reviseFlowFlags");
452
648
  }
453
649
  export function listFlowRuns(context, input) {
454
650
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -459,113 +655,268 @@ export function listFlowRuns(context, input) {
459
655
  .map(flowRunSummary);
460
656
  return { ok: true, project, runs };
461
657
  }
462
- export function attachFlowRunStage(context, input) {
658
+ export function prepareFlowRunStageAttachment(context, input, archive = false) {
463
659
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
464
660
  const run = resolveRun(context, project.id, input.runId);
465
- const index = authoritativeIndex(run);
466
- const now = context.now();
661
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
467
662
  const stage = requiredPlain(input.stage, "stage");
468
- const dir = requiredStageDir(input.dir);
663
+ const dir = prepareStageDirectory(input.dir);
664
+ const index = authoritativeIndex(run);
469
665
  const status = parseStageStatus(input.status);
470
666
  const existing = index.stage_runs.find((item) => item.stage === stage);
471
- assertVnextStageStart(run, index, stage, status, existing);
472
- if (status === "running" && existing) {
473
- archiveExistingStageAttempt(runArtifactRoot(run), dir);
474
- }
475
- const stageRun = {
476
- ...(existing ?? { order: index.stage_runs.length + 1 }),
477
- stage,
478
- dir,
479
- status,
480
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
481
- updated_at: now
482
- };
483
- if (status === "running") {
484
- stageRun.started_at = now;
485
- delete stageRun.completed_at;
486
- stageRun.duration_ms = null;
487
- stageRun.git = gitFacts(run.workspace_root);
488
- stageRun.attempt = nextAttempt(existing);
489
- }
490
- upsertStage(index, stageRun);
491
- index.current_stage = stage;
492
- index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
493
- index.updated_at = now;
494
- persistRunState(context, project, run, index);
495
- appendAudit(context, {
496
- projectId: project.id,
497
- eventType: "flow_run.stage_attached",
498
- payload: { run_id: run.id, stage, dir, status }
499
- });
500
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
501
- // A stage transition must not rewrite every Session in the RUN. A RUN may
502
- // contain independent reviewer/worker sessions; their stage is set by the
503
- // stage/work binding that actually owns that Session.
504
- if (status === "running")
505
- refreshRunSessionProjection(context, project.id, run.id);
667
+ const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id }) : null;
668
+ if (bundle)
669
+ assertBundledStageStart(bundle.descriptor, index, stage, dir, status, existing);
670
+ else
671
+ assertVnextStageStart(run, index, stage, status, existing);
672
+ if (archive) {
673
+ const runRoot = runArtifactRoot(run);
674
+ if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
675
+ archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
676
+ }
677
+ }
678
+ return { project, run, index, stage, dir, status, existing, generation, bundle, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
679
+ }
680
+ export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
681
+ // Filesystem archive and Git inspection are preparation, never writer work.
682
+ const { project, run, stage, dir, status, generation, git } = prepared;
683
+ const attached = context.db.writeTransaction(() => {
684
+ assertRunMutationAllowed(context, project.id, run.id, generation);
685
+ const latest = requireRunById(context, project.id, run.id);
686
+ const index = authoritativeIndex(latest);
687
+ const existing = index.stage_runs.find((item) => item.stage === stage);
688
+ if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
689
+ throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
690
+ }
691
+ if (prepared.bundle) {
692
+ if (index.execution_bundle?.manifest_sha256 !== prepared.bundle.sha256)
693
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle changed during stage preparation", 1);
694
+ assertBundledStageStart(prepared.bundle.descriptor, index, stage, dir, status, existing);
695
+ }
696
+ else
697
+ assertVnextStageStart(latest, index, stage, status, existing);
698
+ const now = context.now();
699
+ const stageRun = {
700
+ ...(existing ?? { order: index.stage_runs.length + 1 }),
701
+ stage,
702
+ dir,
703
+ status,
704
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
705
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
706
+ updated_at: now
707
+ };
708
+ if (status === "running") {
709
+ stageRun.started_at = now;
710
+ delete stageRun.completed_at;
711
+ stageRun.duration_ms = null;
712
+ if (git)
713
+ stageRun.git = git;
714
+ stageRun.attempt = prepared.attempt;
715
+ }
716
+ upsertStage(index, stageRun);
717
+ index.current_stage = stage;
718
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
719
+ index.updated_at = now;
720
+ persistRunState(context, project, run, index);
721
+ appendAudit(context, {
722
+ projectId: project.id,
723
+ eventType: "flow_run.stage_attached",
724
+ payload: { run_id: run.id, stage, dir, status }
725
+ });
726
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
727
+ context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
728
+ // A stage transition must not rewrite every Session in the RUN. A RUN may
729
+ // contain independent reviewer/worker sessions; their stage is set by the
730
+ // stage/work binding that actually owns that Session.
731
+ if (status === "running")
732
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
733
+ return { stageRun, index };
734
+ }, "stage.attach");
506
735
  const updatedRun = requireRunById(context, project.id, run.id);
507
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
736
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: attached.stageRun, index: attached.index, flow_guidance: guidanceForRun(context, updatedRun, attached.index) };
737
+ }
738
+ /** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
739
+ export function withStageSettlement(context, operation) {
740
+ return context.db.writeTransaction(operation, "stage.settle");
508
741
  }
509
- export function completeFlowRunStage(context, input) {
742
+ export function prepareFlowRunStageCompletion(context, input, admittedBundle) {
743
+ for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
744
+ if (value !== undefined)
745
+ requiredPlain(value, name);
746
+ }
747
+ if (input.aliases !== undefined) {
748
+ if (!Array.isArray(input.aliases))
749
+ throw new AppError("validation", "artifact aliases must be an array", 2);
750
+ for (const alias of input.aliases)
751
+ requiredPlain(alias, "artifact alias");
752
+ }
510
753
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
511
754
  const run = resolveRun(context, project.id, input.runId);
755
+ if (["done", "failed", "skipped"].includes(input.status))
756
+ assertStageSettlementAllowed(context, project.id, run.id, input.stage);
757
+ else
758
+ assertRunMutationAllowed(context, project.id, run.id);
512
759
  const index = authoritativeIndex(run);
513
- const now = context.now();
514
760
  const stage = requiredPlain(input.stage, "stage");
515
761
  const status = parseStageStatus(input.status);
516
762
  const existing = index.stage_runs.find((item) => item.stage === stage);
517
763
  if (!existing) {
518
764
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
519
765
  }
520
- const stageRun = {
521
- ...existing,
522
- status,
523
- ...(input.stageReport ? { stage_report: input.stageReport } : {}),
524
- ...(input.data ? { data: input.data } : {}),
525
- ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
526
- ...(input.report ? { report: input.report } : {}),
527
- ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
528
- updated_at: now,
529
- ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
530
- ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
531
- : {})
532
- };
533
- upsertStage(index, stageRun);
534
- if (status === "waiting_for_user") {
535
- index.status = "waiting_for_user";
536
- index.verdict = "waiting_for_user";
537
- }
538
- else if (status === "blocked" || status === "failed") {
539
- index.status = status;
540
- index.verdict = status;
766
+ // Bound completion must admit the same immutable descriptor/engine authority
767
+ // as stage entry. Keep filesystem admission outside the SQLite writer.
768
+ const bundle = admittedBundle === undefined
769
+ ? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id }) : admittedBundle;
770
+ if (bundle && !index.execution_bundle)
771
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor disappeared after finish admission", 1);
772
+ if (bundle)
773
+ assertCompletionBundleAuthority(context, run, index);
774
+ if (index.execution_bundle && (!bundle || bundle.sha256 !== index.execution_bundle.manifest_sha256
775
+ || bundle.manifest.descriptor.sha256 !== index.execution_bundle.descriptor_sha256
776
+ || hybridDigest(bundleAuthority(bundle.admission())) !== index.execution_bundle.admission_sha256)) {
777
+ throw new AppError("run_bundle_anchor_invalid", "Prepared finish bundle does not match authoritative RUN admission", 1);
541
778
  }
542
- else if (status === "done" && index.status === "waiting_for_user") {
543
- index.status = "running";
544
- index.verdict = "running";
779
+ if (bundle) {
780
+ const declared = bundle.descriptor.stage(stage);
781
+ if (declared.directory !== existing.dir || index.current_stage !== stage) {
782
+ throw new AppError("flow_stage_completion_invalid", "Completion does not belong to the active descriptor stage", 2, { phase: "prepare", effect: "no_effect", run_id: run.id, stage });
783
+ }
545
784
  }
546
- index.current_stage = stage;
547
- index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
548
- index.updated_at = now;
549
- index.execution = {
550
- project_root: index.execution?.project_root ?? run.project_root,
551
- workspace_root: index.execution?.workspace_root ?? run.workspace_root,
552
- git: gitFacts(run.workspace_root)
553
- };
554
- persistRunState(context, project, run, index);
555
- refreshRunSessionProjection(context, project.id, run.id);
556
- appendAudit(context, {
557
- projectId: project.id,
558
- eventType: "flow_run.stage_completed",
559
- payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
560
- });
561
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
785
+ const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
786
+ const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
787
+ if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
788
+ throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
789
+ return { project, run, index, stage, status, existing, bundle, dataSha256, git: input.git ?? gitFacts(run.workspace_root), generation: ["done", "failed", "skipped"].includes(status) ? assertStageSettlementAllowed(context, project.id, run.id, stage) : assertRunMutationAllowed(context, project.id, run.id) };
790
+ }
791
+ /** Raw historical control is not a finish-sealing bypass for a descriptor-bound RUN. */
792
+ export function prepareRawFlowRunStageCompletion(context, input) {
793
+ const prepared = prepareFlowRunStageCompletion(context, input);
794
+ if (prepared.bundle)
795
+ throw new AppError("stage_finish_required", "Descriptor-bound stages must settle through their registered stage finish handler", 2, { phase: "prepare", effect: "no_effect", run_id: prepared.run.id, stage: prepared.stage });
796
+ return prepared;
797
+ }
798
+ function stageUsageProjection(context, run, stage, startedAt) {
799
+ if (!startedAt)
800
+ return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
801
+ const value = recalculateRunUsage(context, { projectId: run.project_id, runId: run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
802
+ const totals = value.totals, coverage = value.coverage;
803
+ const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
804
+ const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
805
+ const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
806
+ return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
807
+ }
808
+ export function completeFlowRunStage(context, input, prepared = prepareFlowRunStageCompletion(context, input)) {
809
+ const { project, run, stage, status, dataSha256, git, generation } = prepared;
810
+ const completed = context.db.writeTransaction(() => {
811
+ if (["done", "failed", "skipped"].includes(status))
812
+ assertStageSettlementAllowed(context, project.id, run.id, stage, generation);
813
+ else
814
+ assertRunMutationAllowed(context, project.id, run.id, generation);
815
+ const latest = requireRunById(context, project.id, run.id);
816
+ const index = authoritativeIndex(latest);
817
+ const existing = index.stage_runs.find((item) => item.stage === stage);
818
+ if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
819
+ throw new AppError("stage_revision_changed", "Stage attempt changed during finish preparation", 1, { run_id: run.id, stage, expected_attempt: prepared.existing.attempt, actual_attempt: existing?.attempt ?? null });
820
+ }
821
+ if (hybridDigest(index.execution_bundle ?? null) !== hybridDigest(prepared.index.execution_bundle ?? null)) {
822
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle authority changed during finish preparation", 1);
823
+ }
824
+ if (prepared.bundle && (index.current_stage !== stage || existing.dir !== prepared.bundle.descriptor.stage(stage).directory)) {
825
+ throw new AppError("flow_stage_completion_invalid", "Active descriptor stage changed during finish preparation", 1);
826
+ }
827
+ if (prepared.bundle)
828
+ assertCompletionBundleAuthority(context, latest, index);
829
+ const now = context.now();
830
+ const stageRun = {
831
+ ...existing,
832
+ status,
833
+ ...(input.stageReport ? { stage_report: input.stageReport } : {}),
834
+ ...(input.data ? { data: input.data } : {}),
835
+ ...(dataSha256 ? { data_sha256: dataSha256 } : {}),
836
+ ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
837
+ ...(input.report ? { report: input.report } : {}),
838
+ usage: input.usage ?? stageUsageProjection(context, latest, stage, existing.started_at),
839
+ ...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
840
+ updated_at: now,
841
+ ...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
842
+ ? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git }
843
+ : {})
844
+ };
845
+ upsertStage(index, stageRun);
846
+ if (status === "waiting_for_user") {
847
+ index.status = "waiting_for_user";
848
+ index.verdict = "waiting_for_user";
849
+ }
850
+ else if (status === "blocked" || status === "failed") {
851
+ index.status = status;
852
+ index.verdict = status;
853
+ }
854
+ else if (status === "done" && index.status === "waiting_for_user") {
855
+ index.status = "running";
856
+ index.verdict = "running";
857
+ }
858
+ index.current_stage = stage;
859
+ index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
860
+ index.updated_at = now;
861
+ index.execution = {
862
+ project_root: index.execution?.project_root ?? run.project_root,
863
+ workspace_root: index.execution?.workspace_root ?? run.workspace_root,
864
+ git
865
+ };
866
+ persistRunState(context, project, latest, index);
867
+ context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
868
+ appendAudit(context, {
869
+ projectId: project.id,
870
+ eventType: "flow_run.stage_completed",
871
+ payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
872
+ });
873
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
874
+ return { index, stageRun };
875
+ }, "runs.completeFlowRunStage");
562
876
  const updatedRun = requireRunById(context, project.id, run.id);
563
- return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
877
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
878
+ }
879
+ /** Writer-safe readback of independently retained SQL authority; no filesystem admission. */
880
+ function assertCompletionBundleAuthority(context, run, index) {
881
+ const anchor = index.execution_bundle;
882
+ if (!anchor)
883
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor disappeared during finish", 1);
884
+ assertRunBundleSqlAuthority(context, { projectId: run.project_id, runId: run.id }, anchor);
885
+ }
886
+ /** Metadata-only fencing shared by lifecycle ownership and the settlement writer.
887
+ * False means genuine historical authority, never a lost/orphaned bundle anchor.
888
+ * Physical descriptor/engine admission remains an independent outside-writer check.
889
+ */
890
+ export function assertRunBundleSqlAuthority(context, scope, anchor) {
891
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
892
+ const row = hasAuthority ? context.db.get("SELECT manifest_sha256, admission_json FROM run_bundle_authorities WHERE project_id = ? AND run_id = ?", [scope.projectId, scope.runId]) : undefined;
893
+ if (!anchor) {
894
+ if (row)
895
+ throw new AppError("run_bundle_anchor_invalid", "Retained RUN authority exists without its execution anchor", 1);
896
+ return false;
897
+ }
898
+ if (anchor.schema_id !== "dd-flow/run-execution-bundle@1" || typeof anchor.flow_id !== "string"
899
+ || [anchor.manifest_sha256, anchor.descriptor_sha256, anchor.admission_sha256].some(hash => typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash)))
900
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor is invalid", 1);
901
+ if (!row || row.manifest_sha256 !== anchor.manifest_sha256)
902
+ throw new AppError("run_bundle_authority_missing", "RUN bundle admission authority disappeared during finish", 1);
903
+ let valid = false;
904
+ try {
905
+ if (Buffer.byteLength(row.admission_json, "utf8") <= 1024 * 1024) {
906
+ const retained = JSON.parse(row.admission_json);
907
+ valid = retained.runId === scope.runId && hybridDigest(retained) === anchor.admission_sha256;
908
+ }
909
+ }
910
+ catch { /* Corrupt authority cannot be replaced by a prepared object. */ }
911
+ if (!valid)
912
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority changed during finish", 1);
913
+ return true;
564
914
  }
565
915
  /** Pause one live stage without completing it or creating a new attempt. */
566
916
  export function pauseFlowRunStage(context, input) {
567
917
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
568
918
  const run = resolveRun(context, project.id, input.runId);
919
+ assertRunMutationAllowed(context, project.id, run.id);
569
920
  const index = authoritativeIndex(run);
570
921
  const stage = requiredPlain(input.stage, "stage");
571
922
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -594,13 +945,14 @@ export function pauseFlowRunStage(context, input) {
594
945
  persistRunState(context, project, run, index);
595
946
  refreshRunSessionProjection(context, project.id, run.id);
596
947
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_paused", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, reason: "waiting_for_user" } });
597
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
948
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_waiting_for_user", run_id: run.id, stage, work_id: input.workId, pause_id: input.pauseId, question_path: input.questionPath });
598
949
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
599
950
  }
600
951
  /** Resume the same stage, Work and attempt after a user answer is recorded. */
601
952
  export function resumeFlowRunStage(context, input) {
602
953
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
603
954
  const run = resolveRun(context, project.id, input.runId);
955
+ assertRunMutationAllowed(context, project.id, run.id);
604
956
  const index = authoritativeIndex(run);
605
957
  const stage = requiredPlain(input.stage, "stage");
606
958
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
@@ -624,19 +976,28 @@ export function resumeFlowRunStage(context, input) {
624
976
  persistRunState(context, project, run, index);
625
977
  refreshRunSessionProjection(context, project.id, run.id);
626
978
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_resumed", payload: { run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath } });
627
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
628
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
979
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "user_answer_received", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, answer_path: input.answerPath });
980
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_resumed", run_id: run.id, stage, work_id: input.workId, pause_id: pause.id, paused_ms: pausedMs });
629
981
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
630
982
  }
631
983
  /** Pause the current stage for a non-user runtime blocker. */
632
- export function blockFlowRunStage(context, input) {
984
+ export function prepareFlowRunStageBlock(context, input) {
985
+ if (!["engine", "harness", "environment"].includes(input.kind) || typeof input.retryable !== "boolean")
986
+ throw new AppError("validation", "stage block requires a valid kind and boolean retryable", 2);
987
+ requiredPlain(input.code, "code");
988
+ requiredPlain(input.summary, "summary");
633
989
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
634
990
  const run = resolveRun(context, project.id, input.runId);
991
+ assertRunMutationAllowed(context, project.id, run.id);
635
992
  const index = authoritativeIndex(run);
636
993
  const stage = requiredPlain(input.stage, "stage");
637
994
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
638
995
  if (!stageRun || stageRun.status !== "running")
639
996
  throw new AppError("invalid_stage_state", "Only a running stage can be blocked", 1, { run_id: run.id, stage, status: stageRun?.status ?? "missing" });
997
+ return { project, run, index, stage, stageRun };
998
+ }
999
+ export function blockFlowRunStage(context, input, prepared = prepareFlowRunStageBlock(context, input)) {
1000
+ const { project, run, index, stage, stageRun } = prepared;
640
1001
  const now = context.now();
641
1002
  stageRun.status = "blocked";
642
1003
  stageRun.updated_at = now;
@@ -650,20 +1011,24 @@ export function blockFlowRunStage(context, input) {
650
1011
  persistRunState(context, project, run, index);
651
1012
  refreshRunSessionProjection(context, project.id, run.id);
652
1013
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_blocked", payload: { run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, retryable: input.retryable } });
653
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
1014
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_blocked", run_id: run.id, stage, work_id: input.workId, kind: input.kind, code: input.code, summary: input.summary, retryable: input.retryable });
654
1015
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
655
1016
  }
656
1017
  /** Continue the same stage after its non-user blocker was repaired externally. */
657
- export function unblockFlowRunStage(context, input) {
1018
+ export function prepareFlowRunStageUnblock(context, input) {
658
1019
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
659
1020
  const run = resolveRun(context, project.id, input.runId);
1021
+ assertRunMutationAllowed(context, project.id, run.id);
660
1022
  const index = authoritativeIndex(run);
661
1023
  const stage = requiredPlain(input.stage, "stage");
662
1024
  const stageRun = index.stage_runs.find((item) => item.stage === stage);
663
1025
  if (!stageRun || stageRun.status !== "blocked" || !stageRun.blocker || stageRun.blocker.work_id !== input.workId)
664
1026
  throw new AppError("invalid_stage_state", "Stage unblock requires its matching runtime blocker", 1, { run_id: run.id, stage, work_id: input.workId, status: stageRun?.status ?? "missing" });
1027
+ return { project, run, index, stage, stageRun, blocker: stageRun.blocker };
1028
+ }
1029
+ export function unblockFlowRunStage(context, input, prepared = prepareFlowRunStageUnblock(context, input)) {
1030
+ const { project, run, index, stage, stageRun, blocker } = prepared;
665
1031
  const now = context.now();
666
- const blocker = stageRun.blocker;
667
1032
  const blockedMs = durationMs(blocker.blocked_at, now) ?? 0;
668
1033
  stageRun.status = "running";
669
1034
  stageRun.updated_at = now;
@@ -678,7 +1043,7 @@ export function unblockFlowRunStage(context, input) {
678
1043
  persistRunState(context, project, run, index);
679
1044
  refreshRunSessionProjection(context, project.id, run.id);
680
1045
  appendAudit(context, { projectId: project.id, eventType: "flow_run.stage_unblocked", payload: { run_id: run.id, stage, work_id: input.workId, code: blocker.code } });
681
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
1046
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_unblocked", run_id: run.id, stage, work_id: input.workId, code: blocker.code, blocked_ms: blockedMs });
682
1047
  return { run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
683
1048
  }
684
1049
  function operationalRunStatus(context, projectId, runId) {
@@ -687,11 +1052,76 @@ function operationalRunStatus(context, projectId, runId) {
687
1052
  const leaves = unfinished.filter((work) => !parents.has(work.work_id));
688
1053
  return leaves.length > 0 && leaves.every((work) => work.status === "paused") ? "paused" : "running";
689
1054
  }
690
- export function completeFlowRun(context, input) {
691
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1055
+ /** Keep the logical RUN projection aligned with recovery authority. Stage/Work
1056
+ * evidence and verdicts survive a reversible stop; terminal runs never reopen. */
1057
+ export function setFlowRunRecoveryPaused(context, projectId, runId, paused) {
1058
+ const run = resolveRun(context, projectId, runId);
1059
+ if (!["running", "paused"].includes(run.status))
1060
+ return;
1061
+ const index = authoritativeIndex(run);
1062
+ const status = paused || index.stage_runs.some(stage => ["paused", "waiting_for_user", "blocked"].includes(stage.status))
1063
+ ? "paused" : operationalRunStatus(context, projectId, runId);
1064
+ if (run.status === status && index.status === status)
1065
+ return;
1066
+ index.status = status;
1067
+ index.updated_at = context.now();
1068
+ const project = requireProjectByRoot(context, run.project_root);
1069
+ persistRunState(context, project, run, index);
1070
+ }
1071
+ /** Reconciliation repairs projections under the same revision CAS as normal
1072
+ * lifecycle writes. Unknown physical ownership never closes child records. */
1073
+ export function reconcileStoredFlowRun(context, expected, health, transition, childrenSettled = false) {
1074
+ const run = resolveRun(context, expected.project_id, expected.id);
1075
+ if (run.index_json !== expected.index_json || run.status !== expected.status)
1076
+ throw new AppError("run_revision_changed", "RUN changed during health observation", 1);
1077
+ const index = authoritativeIndex(run), now = context.now();
1078
+ index.health = health;
1079
+ if (transition && !["done", "failed", "cancelled", "discarded"].includes(run.status)) {
1080
+ index.status = transition;
1081
+ if (transition === "failed") {
1082
+ index.verdict = "failed";
1083
+ // Observation time is not the historical exit time. Without a retained
1084
+ // finish receipt, keep timing unknown rather than manufacturing days of work.
1085
+ delete index.completed_at;
1086
+ delete index.finished_at;
1087
+ index.duration_ms = null;
1088
+ index.timing_status = 'legacy_incomplete';
1089
+ closeOpenStagesForOverride(index, "failed", now, false);
1090
+ closeOpenWorksForOverride(context, run.project_id, run.id, "failed", now, false);
1091
+ }
1092
+ else if (childrenSettled && health.state === "owner_lost") {
1093
+ for (const stage of index.stage_runs)
1094
+ if (stage.status === "running") {
1095
+ stage.status = "paused";
1096
+ stage.updated_at = now;
1097
+ }
1098
+ }
1099
+ }
1100
+ if (childrenSettled && ["failed", "cancelled", "discarded"].includes(index.status)) {
1101
+ closeOpenStagesForOverride(index, index.status === "failed" ? "failed" : "cancelled", now, false);
1102
+ closeOpenWorksForOverride(context, run.project_id, run.id, index.status === "failed" ? "failed" : "cancelled", now, false);
1103
+ context.db.run("UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND status IN ('pending','active','waiting_user','blocked','stopping')", [`run_${index.status}`, now, run.project_id, run.id]);
1104
+ context.db.run("UPDATE check_receipts SET status = 'aborted' WHERE project_id = ? AND run_id = ? AND status = 'running'", [run.project_id, run.id]);
1105
+ }
1106
+ // Health observation is not semantic progress or a new completion timestamp.
1107
+ const project = requireProjectByRoot(context, run.project_root);
1108
+ persistRunState(context, project, run, index, { healthOnly: true });
1109
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.health_reconciled", payload: { run_id: run.id, previous_status: run.status, status: index.status, health } });
1110
+ }
1111
+ export function prepareFlowRunCompletion(context, input) {
1112
+ if (input.manualOverrideReason !== undefined) {
1113
+ requiredPlain(input.manualOverrideReason, "override reason");
1114
+ if (!["cancelled", "failed"].includes(input.status))
1115
+ throw new AppError("validation", "Manual RUN override may only cancel or fail a RUN", 2);
1116
+ }
1117
+ if (input.verdict !== undefined)
1118
+ requiredPlain(input.verdict, "verdict");
1119
+ if (input.nextAction !== undefined)
1120
+ requiredPlain(input.nextAction, "next action");
1121
+ const project = requireProjectByRoot(context, input.manualOverrideReason ? canonicalPath(input.projectRoot, false) : resolveProjectRoot(input.projectRoot));
692
1122
  const run = resolveRun(context, project.id, input.runId);
1123
+ assertRunMutationAllowed(context, project.id, run.id);
693
1124
  const index = authoritativeIndex(run);
694
- const now = context.now();
695
1125
  const status = parseRunStatus(input.status);
696
1126
  if (!['done', 'cancelled', 'failed'].includes(status)) {
697
1127
  throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
@@ -701,6 +1131,11 @@ export function completeFlowRun(context, input) {
701
1131
  if (activeWork)
702
1132
  throw new AppError("active_work", "RUN cannot complete while Work remains active", 2, { run_id: run.id, work_id: activeWork.work_id, status: activeWork.status });
703
1133
  }
1134
+ return { project, run, index, status };
1135
+ }
1136
+ export function completeFlowRun(context, input) {
1137
+ const { project, run, index, status } = prepareFlowRunCompletion(context, input);
1138
+ const now = context.now();
704
1139
  if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
705
1140
  closeOpenStagesForOverride(index, status, now);
706
1141
  closeOpenWorksForOverride(context, project.id, run.id, status, now);
@@ -734,25 +1169,65 @@ export function completeFlowRun(context, input) {
734
1169
  eventType: input.manualOverrideReason ? "flow_run.manual_override" : "flow_run.completed",
735
1170
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) }
736
1171
  });
737
- appendRunTimeline(runArtifactRoot(run), { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
1172
+ appendRunTimeline(context, project.id, run.id, { at: now, type: input.manualOverrideReason ? "run_manual_override" : "run_completed", run_id: run.id, status, ...(input.manualOverrideReason ? { reason: input.manualOverrideReason } : {}) });
738
1173
  const updatedRun = requireRunById(context, project.id, run.id);
739
1174
  return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
740
1175
  }
1176
+ /** SQL-only terminal writer for an independently prepared descriptor handler.
1177
+ * This closes runtime state, not semantic or Git acceptance. The caller owns
1178
+ * the outer transaction, root settlement and the handler-specific proof fence.
1179
+ */
1180
+ export function completeDescriptorRunState(context, scope, bundle, git) {
1181
+ assertPreparedRunBundleSqlAuthority(context, scope, bundle);
1182
+ assertRunMutationAllowed(context, scope.projectId, scope.runId);
1183
+ const run = requireRunById(context, scope.projectId, scope.runId);
1184
+ const index = authoritativeIndex(run);
1185
+ const terminal = index.stage_runs.at(-1);
1186
+ if (run.status !== "running" || !terminal || terminal.status !== "done"
1187
+ || !bundle.descriptor.descriptor.terminal_stages.includes(terminal.stage)
1188
+ || index.stage_runs.some(stage => ["running", "paused", "waiting_for_user", "blocked", "pending"].includes(stage.status))
1189
+ || context.db.get("SELECT 1 FROM works WHERE project_id=? AND run_id=? AND status IN ('created','running','paused') LIMIT 1", [scope.projectId, scope.runId])
1190
+ || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id WHERE w.project_id=? AND w.run_id=? AND ws.status='running' LIMIT 1", [scope.projectId, scope.runId])) {
1191
+ throw new AppError("descriptor_run_settlement_invalid", "Descriptor RUN is not ready for terminal runtime settlement", 1, { effect: "no_effect" });
1192
+ }
1193
+ const project = context.db.get("SELECT * FROM projects WHERE id=?", [scope.projectId]);
1194
+ if (!project)
1195
+ throw new AppError("not_found", "Project is not registered", 1);
1196
+ const now = context.now();
1197
+ index.status = "done";
1198
+ index.verdict = "accepted";
1199
+ index.next_action = null;
1200
+ index.updated_at = now;
1201
+ index.completed_at = now;
1202
+ index.finished_at = now;
1203
+ index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
1204
+ index.execution = { project_root: run.project_root, workspace_root: run.workspace_root, git: { ...git } };
1205
+ persistRunState(context, project, run, index);
1206
+ closeRunOrchestrators(context, scope.projectId, run, "done", now);
1207
+ refreshRunSessionProjection(context, scope.projectId, scope.runId);
1208
+ appendAudit(context, { projectId: scope.projectId, eventType: "flow_run.completed", payload: { run_id: scope.runId, status: "done", verdict: "accepted", next_action: null } });
1209
+ appendRunTimeline(context, scope.projectId, scope.runId, { at: now, type: "run_completed", run_id: scope.runId, status: "done" });
1210
+ }
741
1211
  /** Update a live RUN between stages without manufacturing terminal evidence. */
742
1212
  export function advanceFlowRun(context, input) {
743
1213
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
744
1214
  const run = resolveRun(context, project.id, input.runId);
745
1215
  const index = authoritativeIndex(run);
1216
+ if (input.settlementStage && input.status === "running" && index.current_stage === input.settlementStage && index.stage_runs.some(stage => stage.stage === input.settlementStage && ["done", "skipped"].includes(stage.status)))
1217
+ assertStageSettlementAllowed(context, project.id, run.id, input.settlementStage);
1218
+ else
1219
+ assertRunMutationAllowed(context, project.id, run.id);
1220
+ const control = runControlDirective(context, project.id, run.id);
746
1221
  const now = context.now();
747
1222
  index.status = input.status;
748
1223
  index.verdict = input.verdict;
749
- index.next_action = input.nextAction ?? null;
1224
+ index.next_action = control ? null : input.nextAction ?? null;
750
1225
  index.updated_at = now;
751
1226
  persistRunState(context, project, run, index);
752
- appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: input.status, verdict: input.verdict, next_action: index.next_action } });
753
- appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_progressed", run_id: run.id, status: input.status, next_action: index.next_action });
1227
+ appendAudit(context, { projectId: project.id, eventType: "flow_run.progressed", payload: { run_id: run.id, status: index.status, verdict: input.verdict, next_action: index.next_action } });
1228
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "run_progressed", run_id: run.id, status: index.status, next_action: index.next_action });
754
1229
  const updatedRun = requireRunById(context, project.id, run.id);
755
- return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
1230
+ return { ok: true, ...(control ? { control } : {}), run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
756
1231
  }
757
1232
  /**
758
1233
  * The only legal backward edge in vNext: an integration gate exposed a
@@ -762,7 +1237,11 @@ export function advanceFlowRun(context, input) {
762
1237
  export function prepareVnextMergeSourceRepairAttempt(context, input) {
763
1238
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
764
1239
  const run = resolveRun(context, project.id, input.runId);
1240
+ assertRunMutationAllowed(context, project.id, run.id);
765
1241
  const index = authoritativeIndex(run);
1242
+ const priorRepair = index.variables?.["merge.source_repair"];
1243
+ if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
1244
+ return { run_id: run.id, cycle: priorRepair.cycle };
766
1245
  const code = index.stage_runs.find((item) => item.stage === "code");
767
1246
  const review = index.stage_runs.find((item) => item.stage === "code-review");
768
1247
  const merge = index.stage_runs.find((item) => item.stage === "merge");
@@ -776,7 +1255,7 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
776
1255
  }
777
1256
  const root = runArtifactRoot(run);
778
1257
  for (const stage of ["code", "code-review", "merge"])
779
- archiveExistingStageAttempt(root, vnextStageDirectory(stage));
1258
+ archiveExistingStageAttempt(context, project.id, run.id, root, vnextStageDirectory(stage));
780
1259
  const now = context.now();
781
1260
  const restarted = {
782
1261
  ...code,
@@ -811,18 +1290,24 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
811
1290
  }));
812
1291
  persistRunState(context, project, run, index);
813
1292
  appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
814
- appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
1293
+ appendRunTimeline(context, project.id, run.id, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
815
1294
  return { run_id: run.id, cycle };
816
1295
  }
817
- function closeOpenStagesForOverride(index, status, now) {
1296
+ function closeOpenStagesForOverride(index, status, now, timingKnown = true) {
818
1297
  const stageStatus = status === "cancelled" ? "skipped" : "failed";
819
1298
  for (const stage of index.stage_runs) {
820
- if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
1299
+ if (stage.status !== "running" && stage.status !== "paused" && stage.status !== "waiting_for_user" && stage.status !== "blocked")
821
1300
  continue;
822
1301
  stage.status = stageStatus;
823
1302
  stage.updated_at = now;
824
- stage.completed_at = now;
825
- stage.duration_ms = durationMs(stage.started_at ?? null, now);
1303
+ if (timingKnown) {
1304
+ stage.completed_at = now;
1305
+ stage.duration_ms = durationMs(stage.started_at ?? null, now);
1306
+ }
1307
+ else {
1308
+ delete stage.completed_at;
1309
+ stage.duration_ms = null;
1310
+ }
826
1311
  }
827
1312
  index.attempts = index.stage_runs.map((stage) => ({
828
1313
  stage: stage.stage,
@@ -830,20 +1315,20 @@ function closeOpenStagesForOverride(index, status, now) {
830
1315
  root: stage.dir,
831
1316
  archive: null,
832
1317
  status: stage.status,
833
- started_at: stage.started_at ?? now,
1318
+ started_at: stage.started_at ?? null,
834
1319
  finished_at: stage.completed_at ?? null
835
1320
  }));
836
1321
  }
837
- function closeOpenWorksForOverride(context, projectId, runId, status, now) {
1322
+ function closeOpenWorksForOverride(context, projectId, runId, status, now, timingKnown = true) {
838
1323
  const workStatus = status === "cancelled" ? "cancelled" : "failed";
839
1324
  context.db.run(`UPDATE works
840
- SET status = ?, updated_at = ?, completed_at = ?
1325
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
841
1326
  WHERE project_id = ? AND run_id = ?
842
- AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
1327
+ AND status IN ('created', 'running', 'paused')`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
843
1328
  context.db.run(`UPDATE work_sessions
844
- SET status = ?, updated_at = ?, completed_at = ?
1329
+ SET status = ?, updated_at = ?, completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END
845
1330
  WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
846
- AND status <> 'completed'`, [workStatus, now, now, projectId, runId]);
1331
+ AND status <> 'completed'`, [workStatus, now, timingKnown ? 1 : 0, now, projectId, runId]);
847
1332
  }
848
1333
  function closeRunOrchestrators(context, projectId, run, status, now) {
849
1334
  if (!["done", "blocked", "cancelled", "failed"].includes(status))
@@ -860,7 +1345,7 @@ function closeRunOrchestrators(context, projectId, run, status, now) {
860
1345
  context.db.run(`UPDATE flow_session_segments SET ended_at = ?
861
1346
  WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
862
1347
  for (const session of active) {
863
- appendRunTimeline(runArtifactRoot(run), {
1348
+ appendRunTimeline(context, projectId, run.id, {
864
1349
  at: now,
865
1350
  type: "session_stopped",
866
1351
  run_id: run.id,
@@ -983,7 +1468,7 @@ export function getFlowRunSessions(context, input) {
983
1468
  }
984
1469
  export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
985
1470
  const run = requireRunById(context, projectId, runId);
986
- appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
1471
+ appendRunTimeline(context, projectId, run.id, { run_id: run.id, ...event });
987
1472
  }
988
1473
  function guidanceForRun(context, run, index) {
989
1474
  if (run.flow_kind === "vnext_specify")
@@ -1078,32 +1563,33 @@ function vnextSpecifyGuidance(run, index) {
1078
1563
  };
1079
1564
  }
1080
1565
  function persistRunState(context, project, run, index, options = {}) {
1566
+ // Fenced productive admission is a logical pause, even while physical work
1567
+ // drains. Late admitted Stage results must not revive a stopped RUN.
1568
+ if (index.status === "running" && (!options.healthOnly || context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_recovery_guards'")) && ["draining", "sealed", "resuming"].includes(recoveryGuard(context, project.id, run.id)?.status ?? ""))
1569
+ index.status = "paused";
1570
+ if (!options.healthOnly)
1571
+ delete index.health;
1081
1572
  const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
1082
- const previousRuntime = readRuntimeSnapshot(runtimePath);
1083
- const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
1084
- index.runtime_revision = runtimeRevision;
1085
- const history = [...(previousRuntime?.flag_revision_history ?? [])];
1086
- if (options.flagRevision) {
1087
- history.push(options.flagRevision);
1088
- }
1089
- const persistedIndex = persistedVnextIndex(index);
1090
- ensureDir(path.dirname(runtimePath));
1091
- writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, runtimeRevision, history));
1092
- context.db.run(`UPDATE runs
1093
- SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
1094
- updated_at = ?, completed_at = ?
1095
- WHERE project_id = ? AND id = ?`, [
1096
- index.status,
1097
- index.verdict,
1098
- index.next_action,
1099
- JSON.stringify(persistedIndex),
1100
- runtimePath,
1101
- runtimePath,
1102
- index.updated_at,
1103
- index.completed_at ?? null,
1104
- project.id,
1105
- run.id
1106
- ]);
1573
+ context.db.writeTransaction(() => {
1574
+ const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]);
1575
+ if (!stored)
1576
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: run.id });
1577
+ const previous = JSON.parse(stored.index_json);
1578
+ const actualRevision = previous.runtime_revision ?? 0;
1579
+ const expectedRevision = index.runtime_revision ?? 0;
1580
+ if (actualRevision !== expectedRevision)
1581
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id, expected_revision: expectedRevision, actual_revision: actualRevision });
1582
+ index.runtime_revision = actualRevision + 1;
1583
+ const history = [...(previous.flag_revision_history ?? [])];
1584
+ if (options.flagRevision)
1585
+ history.push(options.flagRevision);
1586
+ index.flag_revision_history = history.slice(-32);
1587
+ const updated = options.healthOnly ? context.db.run("UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, updated_at = ?, completed_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]) : context.db.run(`UPDATE runs SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?, updated_at = ?, completed_at = ?
1588
+ WHERE project_id = ? AND id = ? AND index_json = ?`, [index.status, index.verdict, index.next_action, JSON.stringify(persistedVnextIndex(index)), runtimePath, runtimePath, index.updated_at, index.completed_at ?? null, project.id, run.id, stored.index_json]);
1589
+ if (updated.changes !== 1)
1590
+ throw new AppError("run_revision_changed", "RUN changed during preparation", 1, { run_id: run.id });
1591
+ context.db.afterCommit(() => publishRunProjections(context, project.id, run.id));
1592
+ }, "runs.persistRunState");
1107
1593
  }
1108
1594
  /** vNext stores derived stage/work state only once: stage_runs, Works and Sessions. */
1109
1595
  function persistedVnextIndex(index) {
@@ -1145,35 +1631,75 @@ function materializedRunVariables(index) {
1145
1631
  ...(index.variables ?? {})
1146
1632
  };
1147
1633
  }
1148
- function setRunVariable(context, input, value) {
1634
+ function prepareRunVariable(context, input) {
1149
1635
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1150
1636
  const run = resolveRun(context, project.id, input.runId);
1637
+ assertRunMutationAllowed(context, project.id, run.id);
1151
1638
  const index = authoritativeIndex(run);
1639
+ return { project, run, index };
1640
+ }
1641
+ function setRunVariable(context, input, value, prepared = prepareRunVariable(context, input)) {
1642
+ const { project, run, index } = prepared;
1152
1643
  if (index.variables?.[input.key] === value)
1153
1644
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: true };
1154
1645
  index.variables = { ...(index.variables ?? {}), [input.key]: value };
1155
1646
  index.updated_at = context.now();
1156
1647
  persistRunState(context, project, run, index);
1157
- appendRunTimeline(runArtifactRoot(run), { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1648
+ appendRunTimeline(context, project.id, run.id, { at: index.updated_at, type: "run_variable_set", run_id: run.id, key: input.key });
1158
1649
  return { ok: true, run_id: run.id, key: input.key, value, idempotent: false };
1159
1650
  }
1160
- function appendRunTimeline(runHome, event) {
1161
- ensureDir(runHome);
1162
- const timelinePath = path.join(runHome, "timeline.jsonl");
1163
- let sequence = 0;
1164
- if (fs.existsSync(timelinePath)) {
1165
- const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
1166
- const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
1167
- sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
1168
- }
1169
- const safeEvent = sanitizeTimelineValue(event);
1170
- const enriched = {
1171
- event_id: crypto.randomUUID(),
1172
- sequence: sequence + 1,
1173
- at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
1174
- ...safeEvent
1175
- };
1176
- fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
1651
+ function appendRunTimeline(context, projectId, runId, event) {
1652
+ context.db.writeTransaction(() => {
1653
+ const last = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id DESC LIMIT 1", [projectId, runId]);
1654
+ const sequence = last ? Number(JSON.parse(last.payload_json).sequence) + 1 : 1;
1655
+ const safeEvent = sanitizeTimelineValue(event);
1656
+ const enriched = { event_id: crypto.randomUUID(), sequence, at: typeof safeEvent.at === "string" ? safeEvent.at : context.now(), ...safeEvent };
1657
+ appendAudit(context, { projectId, eventType: "flow_run.timeline", payload: { run_id: runId, sequence, event: enriched } });
1658
+ context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
1659
+ }, "runs.appendRunTimeline");
1660
+ }
1661
+ /** Rebuild projections from committed SQL after taking a per-RUN filesystem lock. */
1662
+ export function publishRunProjections(context, projectId, runId) {
1663
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1664
+ if (!run)
1665
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1666
+ const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, projectId, runId);
1667
+ ensureDir(path.dirname(runtimePath));
1668
+ withProjectionLock(`${runtimePath}.publish-lock`, () => {
1669
+ // A delayed publisher must observe the latest commit after acquiring its lock.
1670
+ const latest = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
1671
+ if (!latest)
1672
+ throw new AppError("not_found", "RUN is not registered", 1, { run_id: runId });
1673
+ const index = authoritativeIndex(latest);
1674
+ if (index.workspace_route) {
1675
+ const route = retainedWorkspaceRoute(context, latest);
1676
+ const file = path.join(runArtifactRoot(latest), "02-protocolize", "workspace-route.json");
1677
+ ensureDir(path.dirname(file));
1678
+ try {
1679
+ writeJsonFile(file, route.receipt);
1680
+ }
1681
+ catch (error) {
1682
+ throw new AppError("workspace_route_publication_failed", "Committed route projection could not be published", 1, { effect: "committed", run_id: runId, route_sha256: index.workspace_route.sha256, cause: String(error) });
1683
+ }
1684
+ }
1685
+ const mutations = context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'flow_run_flag_mutations'") ? context.db.all("SELECT resulting_revision, result_json, created_at, idempotency_key FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? ORDER BY resulting_revision DESC LIMIT 32", [projectId, runId]) : [];
1686
+ const history = mutations.reverse().map((row) => ({
1687
+ revision: row.resulting_revision,
1688
+ checksum: String(JSON.parse(row.result_json).snapshot_checksum ?? ""),
1689
+ at: row.created_at,
1690
+ idempotency_key: row.idempotency_key
1691
+ }));
1692
+ writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, index.runtime_revision ?? 1, history.length ? history : index.flag_revision_history ?? []));
1693
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.timeline' AND json_extract(payload_json, '$.run_id') = ? ORDER BY id", [projectId, runId]);
1694
+ if (latest.run_root) {
1695
+ const timelinePath = path.join(latest.run_root, "timeline.jsonl");
1696
+ ensureDir(path.dirname(timelinePath));
1697
+ const bytes = rows.map((row) => JSON.stringify(JSON.parse(row.payload_json).event)).join("\n");
1698
+ const temporary = `${timelinePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
1699
+ fs.writeFileSync(temporary, bytes ? `${bytes}\n` : "");
1700
+ fs.renameSync(temporary, timelinePath);
1701
+ }
1702
+ });
1177
1703
  }
1178
1704
  function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
1179
1705
  const rest = { ...index };
@@ -1199,11 +1725,188 @@ function readRuntimeSnapshot(file) {
1199
1725
  function authoritativeIndex(run) {
1200
1726
  try {
1201
1727
  const state = JSON.parse(run.index_json);
1202
- return { ...state, schema_id: runSchemaId };
1728
+ if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1729
+ throw new Error("RUN index must contain stage_runs");
1730
+ const derivedStage = ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind)
1731
+ ? [...state.stage_runs].sort((left, right) => right.order - left.order)[0]?.stage : undefined;
1732
+ return { ...state, schema_id: runSchemaId, ...(state.current_stage === undefined && derivedStage ? { current_stage: derivedStage } : {}) };
1203
1733
  }
1204
1734
  catch (error) {
1205
- throw new AppError("validation", "RUN mechanical state in SQLite is missing or invalid", 2, { run_id: run.id, cause: String(error) });
1735
+ throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1736
+ }
1737
+ }
1738
+ function assertBundleWorkspaceIdentity(context, run, bundle) {
1739
+ const scope = currentBundleScope(context, run, bundle);
1740
+ const route = authoritativeIndex(run).workspace_route ? retainedWorkspaceRoute(context, run) : undefined;
1741
+ if (scope.workspace === run.workspace_root)
1742
+ return true;
1743
+ if (bundle.descriptor.descriptor.id !== "sdlc" || !["vnext_specify", "vnext_protocolize"].includes(run.flow_kind) || scope.workspace !== run.project_root)
1744
+ return false;
1745
+ if (!route)
1746
+ return false;
1747
+ return route.previous_workspace === scope.workspace && route.receipt.policy.route === "feature_worktree"
1748
+ && route.receipt.policy.base_ref !== null && route.receipt.policy.feature_branch !== null;
1749
+ }
1750
+ function currentBundleScope(context, run, bundle) {
1751
+ return JSON.parse(projectPacketLocators(JSON.stringify(bundle.execution.snapshot().scope), runContextRelocations(context, run.project_id, run.id)));
1752
+ }
1753
+ function assertBundleRunIdentity(context, run, bundle) {
1754
+ const scope = currentBundleScope(context, run, bundle);
1755
+ const flowId = bundle.descriptor.descriptor.id;
1756
+ const matchesFlow = flowId === "sdlc" ? ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind) : flowId === run.flow_kind;
1757
+ if (bundle.manifest.run_id !== run.id || scope.projectRoot !== run.project_root || !assertBundleWorkspaceIdentity(context, run, bundle) || !matchesFlow) {
1758
+ throw new AppError("run_bundle_identity_invalid", "Execution bundle belongs to another RUN or workspace", 1, { phase: "prepare", effect: "no_effect", run_id: run.id });
1206
1759
  }
1760
+ const blobs = new Map(bundle.manifest.inventory.map(ref => [ref.path, bundle.blob(ref.path)]));
1761
+ // File-owned handler inventories can never authorize executable code.
1762
+ admitBundledFlow(bundle.blob(bundle.manifest.descriptor.path), bundle.manifest.descriptor.sha256, blobs);
1763
+ }
1764
+ function bundleAuthority(admission) {
1765
+ const authority = { runId: admission.runId, executionScope: structuredClone(admission.executionScope),
1766
+ receipts: structuredClone([...(admission.receipts ?? [])].sort(([left], [right]) => left.localeCompare(right, "en"))) };
1767
+ for (const key of ["descriptorSha256", "engineChecksum", "engineBindingSha256", "engineManifestSha256", "bundleProjectRoot", "bundleProvenance", "bootstrapIdentity"]) {
1768
+ if (admission[key] !== undefined)
1769
+ Object.assign(authority, { [key]: structuredClone(admission[key]) });
1770
+ }
1771
+ return authority;
1772
+ }
1773
+ /** Copies the same SQL authority representation used by legacy bundle binding. */
1774
+ export function runBundleAuthorityMetadata(bundle) {
1775
+ if (!(bundle instanceof RetainedExecutionBundle))
1776
+ throw new AppError("run_bundle_anchor_invalid", "Bundle must be admitted before RUN allocation", 1);
1777
+ const admissionJson = JSON.stringify(bundleAuthority(bundle.admission())), admissionSha256 = hybridDigest(JSON.parse(admissionJson));
1778
+ if (Buffer.byteLength(admissionJson) > 1024 * 1024)
1779
+ throw new AppError("run_bundle_authority_invalid", "RUN admission exceeds its bound", 1);
1780
+ return { admissionJson, anchor: { schema_id: "dd-flow/run-execution-bundle@1",
1781
+ manifest_sha256: bundle.sha256, descriptor_sha256: bundle.manifest.descriptor.sha256,
1782
+ admission_sha256: admissionSha256, flow_id: bundle.manifest.flow_id } };
1783
+ }
1784
+ /** Recheck SQL authority and compiled descriptor admission. Schema loading
1785
+ * belongs outside SQL writers; use the explicit SQL-only fence inside them.
1786
+ */
1787
+ export function assertPreparedRunBundleAuthority(context, scope, bundle) {
1788
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle);
1789
+ assertBundleRunIdentity(context, run, bundle);
1790
+ }
1791
+ /** SQL-only fence after the caller has admitted compiled handlers outside the
1792
+ * writer. Descriptor schema loading is deliberately excluded from this fence.
1793
+ */
1794
+ export function assertPreparedRunBundleSqlAuthority(context, scope, bundle) {
1795
+ if (!(bundle instanceof RetainedExecutionBundle))
1796
+ throw new AppError("run_bundle_anchor_invalid", "Prepared bundle is not admitted", 1);
1797
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [scope.projectId, scope.runId]);
1798
+ if (!run)
1799
+ throw new AppError("not_found", "Prepared bundle RUN is unavailable", 1);
1800
+ const index = authoritativeIndex(run), anchor = index.execution_bundle;
1801
+ if (!assertRunBundleSqlAuthority(context, scope, anchor) || !anchor || anchor.manifest_sha256 !== bundle.sha256
1802
+ || anchor.descriptor_sha256 !== bundle.manifest.descriptor.sha256 || anchor.flow_id !== bundle.manifest.flow_id
1803
+ || anchor.admission_sha256 !== hybridDigest(bundleAuthority(bundle.admission())))
1804
+ throw new AppError("run_bundle_anchor_invalid", "Prepared bundle differs from authoritative RUN", 1);
1805
+ const execution = currentBundleScope(context, run, bundle);
1806
+ const flow = bundle.descriptor.descriptor.id;
1807
+ if (bundle.manifest.run_id !== run.id || execution.projectRoot !== run.project_root || !assertBundleWorkspaceIdentity(context, run, bundle)
1808
+ || !(flow === "sdlc" ? ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind) : flow === run.flow_kind)) {
1809
+ throw new AppError("run_bundle_identity_invalid", "Execution bundle belongs to another RUN or workspace", 1, { effect: "no_effect", run_id: run.id });
1810
+ }
1811
+ return run;
1812
+ }
1813
+ export function readFlowRunExecutionBundle(context, input) {
1814
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1815
+ const run = resolveRun(context, project.id, input.runId), anchor = authoritativeIndex(run).execution_bundle;
1816
+ if (!anchor) {
1817
+ // Genuine historical RUNs have neither authority nor anchor. Lost new-RUN
1818
+ // metadata must not silently switch their execution to the legacy engine path.
1819
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
1820
+ if (hasAuthority && context.db.get("SELECT 1 FROM run_bundle_authorities WHERE project_id = ? AND run_id = ?", [project.id, run.id])) {
1821
+ throw new AppError("run_bundle_anchor_invalid", "Retained RUN authority exists without its execution anchor", 1);
1822
+ }
1823
+ return null;
1824
+ }
1825
+ if (anchor.schema_id !== "dd-flow/run-execution-bundle@1" || !/^[a-f0-9]{64}$/.test(anchor.manifest_sha256)
1826
+ || !/^[a-f0-9]{64}$/.test(anchor.descriptor_sha256) || !/^[a-f0-9]{64}$/.test(anchor.admission_sha256))
1827
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor is invalid", 1);
1828
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
1829
+ const row = hasAuthority ? context.db.get("SELECT admission_json, manifest_sha256 FROM run_bundle_authorities WHERE project_id = ? AND run_id = ?", [project.id, run.id]) : undefined;
1830
+ if (!row || row.manifest_sha256 !== anchor.manifest_sha256)
1831
+ throw new AppError("run_bundle_authority_missing", "RUN bundle admission authority is unavailable", 1);
1832
+ if (Buffer.byteLength(row.admission_json) > 1024 * 1024)
1833
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority exceeds its bound", 1);
1834
+ let retained;
1835
+ try {
1836
+ retained = JSON.parse(row.admission_json);
1837
+ }
1838
+ catch {
1839
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority is invalid", 1);
1840
+ }
1841
+ if (hybridDigest(retained) !== anchor.admission_sha256 || !Array.isArray(retained.receipts)
1842
+ || input.admission && hybridDigest(bundleAuthority(input.admission)) !== anchor.admission_sha256) {
1843
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority changed", 1);
1844
+ }
1845
+ const admission = { ...retained, receipts: new Map(retained.receipts), descriptorContext: bundledHandlerContext(new Map()) };
1846
+ const bundle = RetainedExecutionBundle.read(path.join(runArtifactRoot(run), "execution-bundle"), anchor.manifest_sha256, admission);
1847
+ assertBundleRunIdentity(context, run, bundle);
1848
+ if (bundle.manifest.descriptor.sha256 !== anchor.descriptor_sha256 || bundle.manifest.flow_id !== anchor.flow_id) {
1849
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle descriptor identity changed", 1);
1850
+ }
1851
+ return bundle;
1852
+ }
1853
+ /** Freeze once before physical owners exist. Publication precedes the fenced SQLite anchor. */
1854
+ export function bindFlowRunExecutionBundle(context, input) {
1855
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1856
+ const run = resolveRun(context, project.id, input.runId), index = authoritativeIndex(run);
1857
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
1858
+ assertBundleRunIdentity(context, run, input.bundle);
1859
+ if (input.bundle.manifest.flow_id !== "sdlc") {
1860
+ throw new AppError("run_bundle_start_required", "Memory RUN and its bundle authority must be allocated together through the request-bound start", 1, { phase: "prepare", effect: "no_effect", run_id: run.id });
1861
+ }
1862
+ if (index.execution_bundle) {
1863
+ if (index.execution_bundle.manifest_sha256 !== input.bundle.sha256)
1864
+ throw new AppError("run_bundle_immutable", "RUN bundle cannot be replaced", 1);
1865
+ readFlowRunExecutionBundle(context, { ...input, admission: input.bundle.admission() });
1866
+ publishRunProjections(context, project.id, run.id);
1867
+ return { changed: false, sha256: input.bundle.sha256 };
1868
+ }
1869
+ const assertUnstarted = (state) => {
1870
+ if (state.status !== "running" || state.stage_runs.length || state.sessions.length
1871
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id])
1872
+ || context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id])) {
1873
+ throw new AppError("run_bundle_active", "Execution bundle must be frozen before stages, Works or Sessions exist", 1);
1874
+ }
1875
+ };
1876
+ assertUnstarted(index);
1877
+ const authority = bundleAuthority(input.bundle.admission());
1878
+ // Hash the JSON representation actually retained, not optional undefined fields.
1879
+ const authorityJson = JSON.stringify(authority), authoritySha256 = hybridDigest(JSON.parse(authorityJson));
1880
+ if (Buffer.byteLength(authorityJson) > 1024 * 1024)
1881
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority exceeds its bound", 1);
1882
+ input.bundle.materialize(runArtifactRoot(run));
1883
+ try {
1884
+ context.db.writeTransaction(() => {
1885
+ assertRunMutationAllowed(context, project.id, run.id, generation);
1886
+ const latest = resolveRun(context, project.id, run.id), state = authoritativeIndex(latest);
1887
+ assertUnstarted(state);
1888
+ if (state.runtime_revision !== index.runtime_revision || state.execution_bundle || latest.workspace_root !== run.workspace_root
1889
+ || latest.project_root !== run.project_root || latest.flow_kind !== run.flow_kind || latest.run_root !== run.run_root) {
1890
+ throw new AppError("run_revision_changed", "RUN changed during bundle publication", 1);
1891
+ }
1892
+ state.execution_bundle = { schema_id: "dd-flow/run-execution-bundle@1", manifest_sha256: input.bundle.sha256,
1893
+ descriptor_sha256: input.bundle.manifest.descriptor.sha256, admission_sha256: authoritySha256, flow_id: input.bundle.manifest.flow_id };
1894
+ context.db.execSchema(`CREATE TABLE IF NOT EXISTS run_bundle_authorities (
1895
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL, manifest_sha256 TEXT NOT NULL, admission_json TEXT NOT NULL,
1896
+ PRIMARY KEY(project_id, run_id), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
1897
+ )`);
1898
+ context.db.run("INSERT INTO run_bundle_authorities (project_id, run_id, manifest_sha256, admission_json) VALUES (?, ?, ?, ?)", [project.id, run.id, input.bundle.sha256, authorityJson]);
1899
+ state.updated_at = context.now();
1900
+ persistRunState(context, project, latest, state);
1901
+ }, "runs.bindExecutionBundle");
1902
+ }
1903
+ catch (error) {
1904
+ throw new AppError("run_bundle_binding_incomplete", "Bundle retained but RUN binding requires recovery", 1, {
1905
+ phase: "publication", effect: "partial", run_id: run.id, sha256: input.bundle.sha256,
1906
+ reason: error instanceof AppError ? error.code : "binding_io_failure",
1907
+ });
1908
+ }
1909
+ return { changed: true, sha256: input.bundle.sha256 };
1207
1910
  }
1208
1911
  function sanitizeTimelineValue(value, key) {
1209
1912
  if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
@@ -1231,6 +1934,24 @@ function nextAttempt(existing) {
1231
1934
  const number = Number(existing.attempt.replace("try-", ""));
1232
1935
  return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
1233
1936
  }
1937
+ function bundledNextStage(flow, index) {
1938
+ const completed = index.stage_runs.filter(stage => ["done", "skipped"].includes(stage.status)).sort((left, right) => right.order - left.order)[0];
1939
+ if (!completed)
1940
+ return flow.descriptor.entry_stage;
1941
+ // Optional SDLC review routing remains a flow-specific policy, not a generic stage list.
1942
+ const outcome = flow.descriptor.id === "sdlc" && completed.stage === "plan" && index.settings?.plan_review?.mode === "off" ? "not-applicable" : "accepted";
1943
+ return flow.successor(completed.stage, outcome);
1944
+ }
1945
+ function assertBundledStageStart(flow, index, stage, dir, status, existing) {
1946
+ const selected = flow.stage(stage);
1947
+ if (selected.directory !== dir)
1948
+ throw new AppError("flow_stage_directory_invalid", "Stage directory differs from the frozen descriptor", 1, { stage });
1949
+ if (status !== "running")
1950
+ return;
1951
+ if (existing || index.stage_runs.some(candidate => !["done", "skipped"].includes(candidate.status)) || bundledNextStage(flow, index) !== stage) {
1952
+ throw new AppError("illegal_stage_transition", "Stage does not follow the frozen descriptor or an attempt already exists", 2, { run_id: index.run_id, stage });
1953
+ }
1954
+ }
1234
1955
  function assertVnextStageStart(run, index, stage, status, existing) {
1235
1956
  if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
1236
1957
  return;
@@ -1263,7 +1984,7 @@ function upsertStage(index, stageRun) {
1263
1984
  }
1264
1985
  index.stage_runs.sort((a, b) => a.order - b.order);
1265
1986
  }
1266
- function resolveRun(context, projectId, idOrAlias) {
1987
+ export function resolveRun(context, projectId, idOrAlias) {
1267
1988
  if (isFullEntityId(idOrAlias)) {
1268
1989
  return requireRunById(context, projectId, idOrAlias);
1269
1990
  }
@@ -1289,19 +2010,6 @@ function requireRunById(context, projectId, runId) {
1289
2010
  }
1290
2011
  return run;
1291
2012
  }
1292
- function nextRunId(context, projectId, slug) {
1293
- const rows = context.db.all("SELECT id FROM runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
1294
- const max = rows.reduce((value, row) => {
1295
- try {
1296
- const parsed = parseFullEntityId(row.id);
1297
- return parsed.type === runIdType ? Math.max(value, Number(parsed.shortId.slice(4))) : value;
1298
- }
1299
- catch {
1300
- return value;
1301
- }
1302
- }, 0);
1303
- return formatFullId(runIdType, max + 1, slug);
1304
- }
1305
2013
  function flowRunSummary(run) {
1306
2014
  return {
1307
2015
  id: run.id,
@@ -1357,7 +2065,7 @@ function parseStageStatus(value) {
1357
2065
  }
1358
2066
  return value;
1359
2067
  }
1360
- function requiredStageDir(value) {
2068
+ export function prepareStageDirectory(value) {
1361
2069
  const dir = requiredPlain(value, "dir");
1362
2070
  if (!/^\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(dir)) {
1363
2071
  throw new AppError("validation", "--dir must use NN-stage-slug format", 2, { dir });
@@ -1365,6 +2073,8 @@ function requiredStageDir(value) {
1365
2073
  return dir;
1366
2074
  }
1367
2075
  function requiredPlain(value, label) {
2076
+ if (typeof value !== "string")
2077
+ throw new AppError("validation", `${label} must be a string`, 2);
1368
2078
  const trimmed = value.trim();
1369
2079
  if (!trimmed) {
1370
2080
  throw new AppError("validation", `${label} must not be empty`, 2);
@@ -1372,6 +2082,8 @@ function requiredPlain(value, label) {
1372
2082
  return trimmed;
1373
2083
  }
1374
2084
  function normalizeSlug(value) {
2085
+ if (typeof value !== "string")
2086
+ throw new AppError("validation", "--slug must be a string", 2);
1375
2087
  const slug = value
1376
2088
  .normalize("NFKD")
1377
2089
  .replace(/[\u0300-\u036f]/g, "")
@@ -1389,26 +2101,84 @@ function resolveWorkspaceRoot(workspaceRoot) {
1389
2101
  if (!fs.existsSync(absolute)) {
1390
2102
  throw new AppError("not_found", `Workspace root does not exist: ${absolute}`, 1);
1391
2103
  }
2104
+ if (!fs.statSync(absolute).isDirectory())
2105
+ throw new AppError("validation", "Workspace root must be a directory", 2, { workspace_root: absolute });
1392
2106
  return fs.realpathSync(absolute);
1393
2107
  }
1394
- function archiveExistingStageAttempt(runRoot, stageDir) {
2108
+ /** Move one completed stage attempt and update only known durable path columns.
2109
+ * Receipt bytes remain immutable; readers resolve their new location through the DB. */
2110
+ export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
1395
2111
  const currentStageDir = path.join(runRoot, stageDir);
1396
2112
  if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
1397
2113
  return;
1398
2114
  }
1399
- const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
1400
- if (entries.length === 0) {
2115
+ const journal = path.join(runRoot, `.archive-${stageDir}.json`);
2116
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
2117
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
2118
+ const prior = row ? JSON.parse(row.index_json) : null;
2119
+ const live = prior?.stage_runs.find((stage) => stage.dir === stageDir);
2120
+ const generation = row ? assertRunMutationAllowed(context, projectId, runId) : 0;
2121
+ if (pending && (pending.project_id && pending.project_id !== projectId || pending.run_id && pending.run_id !== runId))
2122
+ throw new AppError("archive_owner_changed", "Archive journal belongs to another RUN", 1, { journal });
2123
+ if (pending?.generation !== undefined && pending.generation !== generation)
2124
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal, expected_generation: pending.generation, generation });
2125
+ if (pending?.prior_attempt && live?.attempt !== pending.prior_attempt && live?.attempt !== pending.next_attempt)
2126
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal, prior_attempt: pending.prior_attempt, actual_attempt: live?.attempt ?? null });
2127
+ const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
2128
+ if (entries.length === 0 && !pending) {
1401
2129
  return;
1402
2130
  }
1403
2131
  let attempt = 1;
1404
2132
  while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
1405
2133
  attempt += 1;
1406
2134
  }
1407
- const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
2135
+ const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
2136
+ const intent = pending ?? { archiveDir, entries, stage_run: live ?? null, project_id: projectId, run_id: runId, prior_attempt: live?.attempt ?? null, next_attempt: nextAttempt(live), runtime_revision: prior?.runtime_revision ?? 0, generation };
2137
+ if (!pending) {
2138
+ writeJsonFile(journal, intent);
2139
+ }
1408
2140
  ensureDir(archiveDir);
1409
2141
  for (const entry of entries) {
1410
- fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
2142
+ const source = path.join(currentStageDir, entry);
2143
+ const target = path.join(archiveDir, entry);
2144
+ if (fs.existsSync(source)) {
2145
+ if (fs.existsSync(target))
2146
+ throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
2147
+ fs.renameSync(source, target);
2148
+ }
2149
+ else if (!fs.existsSync(target))
2150
+ throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
1411
2151
  }
2152
+ context.db.writeTransaction(() => {
2153
+ const current = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
2154
+ const currentIndex = current ? JSON.parse(current.index_json) : null;
2155
+ const currentAttempt = currentIndex?.stage_runs.find((stage) => stage.dir === stageDir)?.attempt;
2156
+ if (currentIndex && intent.runtime_revision !== undefined && (currentIndex.runtime_revision ?? 0) !== intent.runtime_revision && currentAttempt !== intent.next_attempt)
2157
+ throw new AppError("archive_owner_changed", "RUN revision changed while an archive was pending", 1, { journal });
2158
+ if (intent.prior_attempt && currentAttempt !== intent.prior_attempt && currentAttempt !== intent.next_attempt)
2159
+ throw new AppError("archive_owner_changed", "Stage attempt changed while an archive was pending", 1, { journal });
2160
+ if (row && generation !== assertRunMutationAllowed(context, projectId, runId))
2161
+ throw new AppError("archive_owner_changed", "RUN generation changed while an archive was pending", 1, { journal });
2162
+ if (intent.next_attempt === undefined || currentAttempt !== intent.next_attempt) {
2163
+ const oldPrefix = `${currentStageDir}${path.sep}`;
2164
+ const newPrefix = `${archiveDir}${path.sep}`;
2165
+ const rewrite = (table, column, where, values) => {
2166
+ context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
2167
+ };
2168
+ const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
2169
+ rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
2170
+ rewrite("work_sessions", "result_path", workScope, [projectId, runId]);
2171
+ const receiptScope = "project_id = ? AND run_id = ?";
2172
+ for (const column of ["stdout_path", "stderr_path", "receipt_path"])
2173
+ rewrite("check_receipts", column, receiptScope, [projectId, runId]);
2174
+ }
2175
+ context.db.afterCommit(() => {
2176
+ if (!fs.existsSync(journal))
2177
+ return;
2178
+ // Retain original Stage facts with the immutable attempt after SQL paths commit.
2179
+ fs.renameSync(journal, path.join(archiveDir, "archive.json"));
2180
+ });
2181
+ });
1412
2182
  }
1413
2183
  function writeJsonFile(file, value) {
1414
2184
  const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
@@ -1433,3 +2203,5 @@ export function gitFacts(workspaceRoot) {
1433
2203
  status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
1434
2204
  };
1435
2205
  }
2206
+ import { assertRunMutationAllowed, assertStageSettlementAllowed, recoveryGuard } from "./run-recovery.js";
2207
+ import { runControlDirective } from "./run-control.js";