@deksden-com/dd-flow-cli 0.9.0-beta.15 → 0.9.0-beta.151

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