@deksden-com/dd-flow-cli 0.9.0-beta.136 → 0.9.0-beta.138

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 (230) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -0
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/argument-values.js +5 -5
  5. package/dist/cli/command-inputs.js +89 -20
  6. package/dist/cli/command-metadata.js +56 -3
  7. package/dist/cli/command-option-descriptions.js +5 -0
  8. package/dist/cli/executor-onboarding.js +8 -3
  9. package/dist/cli/flows.js +29 -0
  10. package/dist/cli/help.js +240 -6
  11. package/dist/cli/input-preparation.js +54 -5
  12. package/dist/cli/memory-flow-admission.js +44 -0
  13. package/dist/cli/memory-init-invocation.js +78 -0
  14. package/dist/cli/memory-init.js +240 -0
  15. package/dist/cli/memory-storage-admission.js +53 -0
  16. package/dist/cli/native-hook-ingress.js +1 -1
  17. package/dist/cli/response-file.js +2 -0
  18. package/dist/cli/run-cli.js +283 -160
  19. package/dist/cli/setup.js +9 -7
  20. package/dist/cli.js +54 -2
  21. package/dist/domain/stage-handler-identities.js +20 -0
  22. package/dist/harness-runtime/DD-ZCODE.md +13 -0
  23. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -3
  24. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  25. package/dist/harness-runtime/lib/daemon-operations.mjs +4 -3
  26. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +4 -2
  27. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +11 -5
  28. package/dist/harness-runtime/lib/dd-codex.mjs +22 -10
  29. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +2 -1
  30. package/dist/harness-runtime/lib/dd-droid.mjs +2 -1
  31. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -2
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +20 -5
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +45 -10
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +7 -3
  35. package/dist/harness-runtime/lib/delegation-instructions.mjs +5 -3
  36. package/dist/harness-runtime/lib/lifecycle-command.mjs +1 -0
  37. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/managed-daemon.mjs +50 -15
  39. package/dist/harness-runtime/lib/managed-flow-env.mjs +15 -0
  40. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -1
  41. package/dist/harness-runtime/lib/native-request-proof.d.mts +2 -0
  42. package/dist/harness-runtime/lib/native-request-proof.mjs +65 -0
  43. package/dist/harness-runtime/lib/opencode-plugin.mjs +5 -1
  44. package/dist/harness-runtime/lib/operation-progress.mjs +3 -1
  45. package/dist/runtime/context.js +1 -1
  46. package/dist/schemas/discovery-result.schema.json +184 -0
  47. package/dist/schemas/execution-bundle.schema.json +205 -0
  48. package/dist/schemas/execution-settings.schema.json +162 -0
  49. package/dist/schemas/flow-descriptor.schema.json +54 -0
  50. package/dist/schemas/flow-run.schema.json +22 -0
  51. package/dist/schemas/harness-catalog.schema.json +80 -0
  52. package/dist/schemas/mb-init-compose-result.schema.json +457 -0
  53. package/dist/schemas/mb-init-discover-result.schema.json +500 -0
  54. package/dist/schemas/mb-init-finish-result.schema.json +422 -0
  55. package/dist/schemas/mb-init-preflight-result.schema.json +367 -0
  56. package/dist/schemas/mb-init-review-result.schema.json +396 -0
  57. package/dist/schemas/mb-init-verify-result.schema.json +391 -0
  58. package/dist/schemas/memory-bootstrap-receipt-v2.schema.json +67 -0
  59. package/dist/schemas/memory-bootstrap-receipt.schema.json +354 -0
  60. package/dist/schemas/memory-composition-work-result.schema.json +38 -0
  61. package/dist/schemas/memory-repair-work-result.schema.json +49 -0
  62. package/dist/schemas/memory-review-correction-plan.schema.json +30 -0
  63. package/dist/schemas/memory-review-work-result.schema.json +43 -0
  64. package/dist/schemas/model-catalog.schema.json +153 -0
  65. package/dist/schemas/profile-settings.schema.json +122 -0
  66. package/dist/schemas/release-manifest.schema.json +291 -0
  67. package/dist/schemas/run-engine-binding.schema.json +1 -1
  68. package/dist/services/check-runner-evidence.js +51 -4
  69. package/dist/services/cli-operation-classifier.js +8 -47
  70. package/dist/services/code-checks.js +387 -34
  71. package/dist/services/controller-child-observer.js +99 -0
  72. package/dist/services/controller-execution.js +88 -0
  73. package/dist/services/controller-fanout.js +79 -13
  74. package/dist/services/dashboard.js +8 -2
  75. package/dist/services/descriptor-coordinator-session.js +35 -0
  76. package/dist/services/descriptor-root-work.js +53 -0
  77. package/dist/services/descriptor-run-settlement.js +105 -0
  78. package/dist/services/descriptor-run-start.js +449 -0
  79. package/dist/services/descriptor-stage-coordinator.js +197 -0
  80. package/dist/services/descriptor-stage-settlement.js +222 -0
  81. package/dist/services/descriptor-stage-start.js +139 -0
  82. package/dist/services/engines.js +39 -22
  83. package/dist/services/eval-snapshots.js +83 -25
  84. package/dist/services/execution-bundles.js +263 -0
  85. package/dist/services/execution-policy.js +20 -16
  86. package/dist/services/external-work-launch.js +91 -11
  87. package/dist/services/flow-catalog.js +21 -0
  88. package/dist/services/flow-descriptors.js +82 -0
  89. package/dist/services/frozen-engine.js +242 -0
  90. package/dist/services/generic-execution.js +164 -0
  91. package/dist/services/harness-adapter.js +28 -2
  92. package/dist/services/hooks.js +24 -6
  93. package/dist/services/hybrid-contracts.js +31 -3
  94. package/dist/services/ids.js +29 -1
  95. package/dist/services/installation-lock.js +7 -4
  96. package/dist/services/installed-flow-source.js +183 -0
  97. package/dist/services/lifecycle-invocations.js +257 -78
  98. package/dist/services/memory-accepted-composition.js +77 -0
  99. package/dist/services/memory-baseline-preservation.js +65 -0
  100. package/dist/services/memory-completed-correction-wave.js +320 -0
  101. package/dist/services/memory-compose-handler.js +258 -0
  102. package/dist/services/memory-composition-acceptance.js +199 -0
  103. package/dist/services/memory-composition-graph.js +134 -0
  104. package/dist/services/memory-composition-registration.js +146 -0
  105. package/dist/services/memory-composition-work-custody.js +151 -0
  106. package/dist/services/memory-composition-work-results.js +96 -0
  107. package/dist/services/memory-corrected-review-acceptance.js +129 -0
  108. package/dist/services/memory-corrected-review-batch.js +30 -0
  109. package/dist/services/memory-corrected-review-findings.js +106 -0
  110. package/dist/services/memory-corrected-review-inputs.js +210 -0
  111. package/dist/services/memory-corrected-review-projection.js +78 -0
  112. package/dist/services/memory-corrected-review-receipt.js +170 -0
  113. package/dist/services/memory-corrected-review-registration.js +191 -0
  114. package/dist/services/memory-correction-check-receipt.js +219 -0
  115. package/dist/services/memory-correction-input-owner.js +6 -0
  116. package/dist/services/memory-discover-handler.js +104 -0
  117. package/dist/services/memory-finish-evidence.js +435 -0
  118. package/dist/services/memory-finish-handler.js +215 -0
  119. package/dist/services/memory-finish-integration.js +280 -0
  120. package/dist/services/memory-finish-readiness.js +122 -0
  121. package/dist/services/memory-git-integration.js +151 -0
  122. package/dist/services/memory-historical-corrected-review.js +206 -0
  123. package/dist/services/memory-historical-original-review.js +130 -0
  124. package/dist/services/memory-historical-repair-registration.js +99 -0
  125. package/dist/services/memory-historical-second-review.js +128 -0
  126. package/dist/services/memory-init-bootstrap-readiness.js +210 -0
  127. package/dist/services/memory-init-contexts.js +89 -0
  128. package/dist/services/memory-init-controller-start.js +84 -0
  129. package/dist/services/memory-init-engine-release.js +223 -0
  130. package/dist/services/memory-init-git-policy.js +136 -0
  131. package/dist/services/memory-init-lint-capability.js +185 -0
  132. package/dist/services/memory-init-native-capacity.js +241 -0
  133. package/dist/services/memory-init-preflight.js +312 -0
  134. package/dist/services/memory-init-project-bootstrap.js +599 -0
  135. package/dist/services/memory-init-request-candidate.js +157 -0
  136. package/dist/services/memory-init-request-input.js +66 -0
  137. package/dist/services/memory-init-resource-storage.js +125 -0
  138. package/dist/services/memory-init-run-allocation.js +81 -0
  139. package/dist/services/memory-init-run-bundle.js +98 -0
  140. package/dist/services/memory-init-start.js +175 -0
  141. package/dist/services/memory-init-startup-custody.js +80 -0
  142. package/dist/services/memory-init-startup-input.js +240 -0
  143. package/dist/services/memory-init-storage.js +81 -0
  144. package/dist/services/memory-init-stores.js +359 -0
  145. package/dist/services/memory-init-worktrunk-capability.js +172 -0
  146. package/dist/services/memory-layer-publication.js +611 -0
  147. package/dist/services/memory-layer.js +156 -0
  148. package/dist/services/memory-lint-config.js +85 -0
  149. package/dist/services/memory-native-review-copy.js +165 -0
  150. package/dist/services/memory-normal-flow-entry.js +75 -0
  151. package/dist/services/memory-ordinary-defaults.js +112 -0
  152. package/dist/services/memory-preflight-bootstrap-custody.js +115 -0
  153. package/dist/services/memory-preflight-handler.js +259 -0
  154. package/dist/services/memory-repair-current-view.js +299 -0
  155. package/dist/services/memory-repair-document-delta.js +451 -0
  156. package/dist/services/memory-repair-history.js +491 -0
  157. package/dist/services/memory-repair-physical-binding.js +312 -0
  158. package/dist/services/memory-repair-publication-files.js +152 -0
  159. package/dist/services/memory-repair-publication-intent.js +27 -0
  160. package/dist/services/memory-repair-publication-plan.js +160 -0
  161. package/dist/services/memory-repair-publication-source.js +225 -0
  162. package/dist/services/memory-repair-publication.js +794 -0
  163. package/dist/services/memory-repair-registration.js +239 -0
  164. package/dist/services/memory-repair-work-baseline.js +413 -0
  165. package/dist/services/memory-repair-work-completion.js +97 -0
  166. package/dist/services/memory-repair-work-custody.js +73 -0
  167. package/dist/services/memory-repair-work-results.js +89 -0
  168. package/dist/services/memory-repair-workspace.js +252 -0
  169. package/dist/services/memory-repair-writer-copy.js +735 -0
  170. package/dist/services/memory-result-binding.js +173 -0
  171. package/dist/services/memory-retained-review-authority.js +129 -0
  172. package/dist/services/memory-retained-review-results.js +70 -0
  173. package/dist/services/memory-retained-second-baseline.js +102 -0
  174. package/dist/services/memory-retained-second-repairs.js +156 -0
  175. package/dist/services/memory-review-acceptance.js +214 -0
  176. package/dist/services/memory-review-correction-plan.js +171 -0
  177. package/dist/services/memory-review-handler.js +585 -0
  178. package/dist/services/memory-review-inputs.js +200 -0
  179. package/dist/services/memory-review-receipt.js +180 -0
  180. package/dist/services/memory-review-registration.js +108 -0
  181. package/dist/services/memory-review-work-custody.js +124 -0
  182. package/dist/services/memory-review-work-input.js +72 -0
  183. package/dist/services/memory-review-work-results.js +73 -0
  184. package/dist/services/memory-second-corrected-review-inputs.js +165 -0
  185. package/dist/services/memory-second-correction-plan.js +76 -0
  186. package/dist/services/memory-second-repair-baseline.js +91 -0
  187. package/dist/services/memory-second-repair-launch.js +614 -0
  188. package/dist/services/memory-second-repair-registration.js +88 -0
  189. package/dist/services/memory-second-repair-source.js +187 -0
  190. package/dist/services/memory-second-review-acceptance.js +115 -0
  191. package/dist/services/memory-source-observations.js +117 -0
  192. package/dist/services/memory-stage-dispatch.js +28 -0
  193. package/dist/services/memory-stage-results.js +199 -0
  194. package/dist/services/memory-verification-acceptance.js +135 -0
  195. package/dist/services/memory-verification-checks.js +252 -0
  196. package/dist/services/memory-verification-receipt.js +159 -0
  197. package/dist/services/memory-verify-handler.js +135 -0
  198. package/dist/services/memory-work-result-preparation.js +63 -0
  199. package/dist/services/projects.js +17 -2
  200. package/dist/services/recovery-snapshot-database.js +416 -1
  201. package/dist/services/repair-intents.js +1 -1
  202. package/dist/services/review-copy.js +10 -2
  203. package/dist/services/run-control.js +80 -9
  204. package/dist/services/run-controller-capture.js +10 -3
  205. package/dist/services/run-controller-process.js +21 -0
  206. package/dist/services/run-controller-recovery.js +3 -1
  207. package/dist/services/run-controller-state.js +1 -1
  208. package/dist/services/run-controller.js +184 -48
  209. package/dist/services/run-recovery-guard.js +2 -0
  210. package/dist/services/run-recovery.js +145 -5
  211. package/dist/services/run-start-custody.js +177 -0
  212. package/dist/services/runs.js +319 -25
  213. package/dist/services/stage-context.js +9 -1
  214. package/dist/services/stage-handlers.js +74 -0
  215. package/dist/services/stage-lifecycle.js +2 -1
  216. package/dist/services/stage-report-renderer.js +76 -0
  217. package/dist/services/stage-work-graph.js +17 -0
  218. package/dist/services/vnext-code.js +3 -3
  219. package/dist/services/vnext-execution-profile.js +2 -2
  220. package/dist/services/vnext-fanout.js +136 -10
  221. package/dist/services/vnext-merge.js +8 -3
  222. package/dist/services/vnext-plan-review.js +6 -4
  223. package/dist/services/vnext-protocolize.js +60 -2
  224. package/dist/services/work-registry.js +616 -71
  225. package/dist/services/workspace-files.js +1 -1
  226. package/dist/storage/database.js +84 -6
  227. package/dist/storage/writer-contract.js +11 -7
  228. package/package.json +13 -3
  229. package/tools/create-canon-release.mjs +33 -0
  230. package/tools/produce-hybrid-release.mjs +41 -0
@@ -4,9 +4,10 @@ import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { withProjectionLock } from "../storage/projection-lock.js";
6
6
  import { reconcileRunStageReports } from "./stage-report-renderer.js";
7
- import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
7
+ import { isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
8
8
  import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
9
9
  import { AppError } from "../shared/errors.js";
10
+ import { nextRunId } from "./ids.js";
10
11
  import { ensureDir, canonicalPath, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
11
12
  import { appendAudit } from "./audit.js";
12
13
  import { registerProject, requireProjectByRoot } from "./projects.js";
@@ -22,9 +23,11 @@ import { assertSameSessionMerge, loadExecutionRoutingInput, resolveStageTransiti
22
23
  import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
23
24
  import { publicSessionIdentity } from "./session-identity.js";
24
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";
25
29
  const runSchemaId = "dd-flow/flow-run@3";
26
30
  const runtimeSchemaId = "dd-flow/flow-run@3";
27
- const runIdType = "RUN";
28
31
  const allowedRunFlowKinds = [
29
32
  "mb_sdlc",
30
33
  "coding",
@@ -231,7 +234,7 @@ function snapshotVnextExecutionProfile(projectRoot, ddFlowHome, routingInput, no
231
234
  merge_delivery: profile.merge_delivery,
232
235
  merge_cleanup: profile.merge_cleanup,
233
236
  stop_target: profile.stop_target,
234
- code_bootstrap: profile.code_bootstrap,
237
+ ...(profile.code_bootstrap ? { code_bootstrap: profile.code_bootstrap } : {}),
235
238
  ...(profile.execution ? { execution: profile.execution } : {})
236
239
  }
237
240
  }, routingInput);
@@ -306,17 +309,19 @@ function loadUpgradeFlowContract(context, projectRoot) {
306
309
  }
307
310
  return loadCanonicalFlowContract(canon.canon.flow_root);
308
311
  }
309
- export function getFlowRunStatus(context, input) {
312
+ export function getFlowRunStatus(context, input, preparedBundle) {
310
313
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
311
314
  const run = resolveRun(context, project.id, input.runId);
315
+ if (preparedBundle)
316
+ assertPreparedRunBundleSqlAuthority(context, { projectId: project.id, runId: run.id }, preparedBundle);
312
317
  const index = authoritativeIndex(run);
313
318
  const runtime = readRuntimeSnapshot(run.runtime_path);
314
- return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
319
+ return { ok: true, run: flowRunSummary(run), index, continuation: runContinuation(context, run, index, preparedBundle), ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
315
320
  }
316
321
  /** A read-only projection of the next legal lifecycle action.
317
322
  * It is derived from the persisted RUN/Work facts so controllers never infer a
318
323
  * transition from a model's prose or a hard-coded stage array. */
319
- function runContinuation(context, run, index) {
324
+ function runContinuation(context, run, index, preparedBundle) {
320
325
  if (["done", "failed", "cancelled"].includes(run.status))
321
326
  return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: run.status, command: null };
322
327
  const control = runControlDirective(context, run.project_id, run.id);
@@ -332,6 +337,15 @@ function runContinuation(context, run, index) {
332
337
  return { kind: "paused", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
333
338
  if (run.status === "blocked")
334
339
  return { kind: "blocked", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: run.status, command: null };
340
+ if (index.execution_bundle) {
341
+ const bundle = preparedBundle ?? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id });
342
+ const next = bundledNextStage(bundle.descriptor, index);
343
+ if (!next)
344
+ return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
345
+ const command = bundle.engine().command(context.ddFlowHome, ["stage", "start", run.id, "--stage", next, "--project-root", run.project_root, "--json"]);
346
+ return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command,
347
+ execution: { status: "resolved", ...bundle.execution.stage(next) } };
348
+ }
335
349
  if (run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize") {
336
350
  const completed = index.stage_runs.filter((stage) => stage.status === "done" || stage.status === "skipped").sort((left, right) => right.order - left.order)[0];
337
351
  const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
@@ -587,14 +601,18 @@ export function prepareFlowRunStageAttachment(context, input, archive = false) {
587
601
  const index = authoritativeIndex(run);
588
602
  const status = parseStageStatus(input.status);
589
603
  const existing = index.stage_runs.find((item) => item.stage === stage);
590
- assertVnextStageStart(run, index, stage, status, existing);
604
+ const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id }) : null;
605
+ if (bundle)
606
+ assertBundledStageStart(bundle.descriptor, index, stage, dir, status, existing);
607
+ else
608
+ assertVnextStageStart(run, index, stage, status, existing);
591
609
  if (archive) {
592
610
  const runRoot = runArtifactRoot(run);
593
611
  if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
594
612
  archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
595
613
  }
596
614
  }
597
- return { project, run, index, stage, dir, status, existing, generation, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
615
+ 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 };
598
616
  }
599
617
  export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
600
618
  // Filesystem archive and Git inspection are preparation, never writer work.
@@ -607,7 +625,13 @@ export function attachFlowRunStage(context, input, prepared = prepareFlowRunStag
607
625
  if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
608
626
  throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
609
627
  }
610
- assertVnextStageStart(latest, index, stage, status, existing);
628
+ if (prepared.bundle) {
629
+ if (index.execution_bundle?.manifest_sha256 !== prepared.bundle.sha256)
630
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle changed during stage preparation", 1);
631
+ assertBundledStageStart(prepared.bundle.descriptor, index, stage, dir, status, existing);
632
+ }
633
+ else
634
+ assertVnextStageStart(latest, index, stage, status, existing);
611
635
  const now = context.now();
612
636
  const stageRun = {
613
637
  ...(existing ?? { order: index.stage_runs.length + 1 }),
@@ -652,7 +676,7 @@ export function attachFlowRunStage(context, input, prepared = prepareFlowRunStag
652
676
  export function withStageSettlement(context, operation) {
653
677
  return context.db.writeTransaction(operation, "stage.settle");
654
678
  }
655
- export function prepareFlowRunStageCompletion(context, input) {
679
+ export function prepareFlowRunStageCompletion(context, input, admittedBundle) {
656
680
  for (const [name, value] of Object.entries({ stageReport: input.stageReport, data: input.data, dataSchemaId: input.dataSchemaId, report: input.report })) {
657
681
  if (value !== undefined)
658
682
  requiredPlain(value, name);
@@ -676,11 +700,37 @@ export function prepareFlowRunStageCompletion(context, input) {
676
700
  if (!existing) {
677
701
  throw new AppError("not_found", `Run stage is not attached: ${stage}`, 1, { run_id: run.id, stage });
678
702
  }
703
+ // Bound completion must admit the same immutable descriptor/engine authority
704
+ // as stage entry. Keep filesystem admission outside the SQLite writer.
705
+ const bundle = admittedBundle === undefined
706
+ ? readFlowRunExecutionBundle(context, { projectRoot: run.project_root, runId: run.id }) : admittedBundle;
707
+ if (bundle && !index.execution_bundle)
708
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor disappeared after finish admission", 1);
709
+ if (bundle)
710
+ assertCompletionBundleAuthority(context, run, index);
711
+ if (index.execution_bundle && (!bundle || bundle.sha256 !== index.execution_bundle.manifest_sha256
712
+ || bundle.manifest.descriptor.sha256 !== index.execution_bundle.descriptor_sha256
713
+ || hybridDigest(bundleAuthority(bundle.admission())) !== index.execution_bundle.admission_sha256)) {
714
+ throw new AppError("run_bundle_anchor_invalid", "Prepared finish bundle does not match authoritative RUN admission", 1);
715
+ }
716
+ if (bundle) {
717
+ const declared = bundle.descriptor.stage(stage);
718
+ if (declared.directory !== existing.dir || index.current_stage !== stage) {
719
+ 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 });
720
+ }
721
+ }
679
722
  const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
680
723
  const dataSha256 = input.dataSha256 ?? (dataPath && fs.existsSync(dataPath) ? crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") : null);
681
724
  if (dataSha256 && !/^[a-f0-9]{64}$/.test(dataSha256))
682
725
  throw new AppError("validation", "Stage data SHA-256 is invalid", 2);
683
- return { project, run, index, stage, status, existing, 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) };
726
+ 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) };
727
+ }
728
+ /** Raw historical control is not a finish-sealing bypass for a descriptor-bound RUN. */
729
+ export function prepareRawFlowRunStageCompletion(context, input) {
730
+ const prepared = prepareFlowRunStageCompletion(context, input);
731
+ if (prepared.bundle)
732
+ 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 });
733
+ return prepared;
684
734
  }
685
735
  function stageUsageProjection(context, run, stage, startedAt) {
686
736
  if (!startedAt)
@@ -705,6 +755,14 @@ export function completeFlowRunStage(context, input, prepared = prepareFlowRunSt
705
755
  if (!existing || existing.attempt !== prepared.existing.attempt || index.runtime_revision !== prepared.index.runtime_revision) {
706
756
  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 });
707
757
  }
758
+ if (hybridDigest(index.execution_bundle ?? null) !== hybridDigest(prepared.index.execution_bundle ?? null)) {
759
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle authority changed during finish preparation", 1);
760
+ }
761
+ if (prepared.bundle && (index.current_stage !== stage || existing.dir !== prepared.bundle.descriptor.stage(stage).directory)) {
762
+ throw new AppError("flow_stage_completion_invalid", "Active descriptor stage changed during finish preparation", 1);
763
+ }
764
+ if (prepared.bundle)
765
+ assertCompletionBundleAuthority(context, latest, index);
708
766
  const now = context.now();
709
767
  const stageRun = {
710
768
  ...existing,
@@ -755,6 +813,42 @@ export function completeFlowRunStage(context, input, prepared = prepareFlowRunSt
755
813
  const updatedRun = requireRunById(context, project.id, run.id);
756
814
  return { ok: true, run: flowRunSummary(updatedRun), stage_run: completed.stageRun, index: completed.index, flow_guidance: guidanceForRun(context, updatedRun, completed.index) };
757
815
  }
816
+ /** Writer-safe readback of independently retained SQL authority; no filesystem admission. */
817
+ function assertCompletionBundleAuthority(context, run, index) {
818
+ const anchor = index.execution_bundle;
819
+ if (!anchor)
820
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor disappeared during finish", 1);
821
+ assertRunBundleSqlAuthority(context, { projectId: run.project_id, runId: run.id }, anchor);
822
+ }
823
+ /** Metadata-only fencing shared by lifecycle ownership and the settlement writer.
824
+ * False means genuine historical authority, never a lost/orphaned bundle anchor.
825
+ * Physical descriptor/engine admission remains an independent outside-writer check.
826
+ */
827
+ export function assertRunBundleSqlAuthority(context, scope, anchor) {
828
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
829
+ 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;
830
+ if (!anchor) {
831
+ if (row)
832
+ throw new AppError("run_bundle_anchor_invalid", "Retained RUN authority exists without its execution anchor", 1);
833
+ return false;
834
+ }
835
+ if (anchor.schema_id !== "dd-flow/run-execution-bundle@1" || typeof anchor.flow_id !== "string"
836
+ || [anchor.manifest_sha256, anchor.descriptor_sha256, anchor.admission_sha256].some(hash => typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash)))
837
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor is invalid", 1);
838
+ if (!row || row.manifest_sha256 !== anchor.manifest_sha256)
839
+ throw new AppError("run_bundle_authority_missing", "RUN bundle admission authority disappeared during finish", 1);
840
+ let valid = false;
841
+ try {
842
+ if (Buffer.byteLength(row.admission_json, "utf8") <= 1024 * 1024) {
843
+ const retained = JSON.parse(row.admission_json);
844
+ valid = retained.runId === scope.runId && hybridDigest(retained) === anchor.admission_sha256;
845
+ }
846
+ }
847
+ catch { /* Corrupt authority cannot be replaced by a prepared object. */ }
848
+ if (!valid)
849
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority changed during finish", 1);
850
+ return true;
851
+ }
758
852
  /** Pause one live stage without completing it or creating a new attempt. */
759
853
  export function pauseFlowRunStage(context, input) {
760
854
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -1016,6 +1110,41 @@ export function completeFlowRun(context, input) {
1016
1110
  const updatedRun = requireRunById(context, project.id, run.id);
1017
1111
  return { ok: true, run: flowRunSummary(updatedRun), index, usage, flow_guidance: guidanceForRun(context, updatedRun, index) };
1018
1112
  }
1113
+ /** SQL-only terminal writer for an independently prepared descriptor handler.
1114
+ * This closes runtime state, not semantic or Git acceptance. The caller owns
1115
+ * the outer transaction, root settlement and the handler-specific proof fence.
1116
+ */
1117
+ export function completeDescriptorRunState(context, scope, bundle, git) {
1118
+ assertPreparedRunBundleSqlAuthority(context, scope, bundle);
1119
+ assertRunMutationAllowed(context, scope.projectId, scope.runId);
1120
+ const run = requireRunById(context, scope.projectId, scope.runId);
1121
+ const index = authoritativeIndex(run);
1122
+ const terminal = index.stage_runs.at(-1);
1123
+ if (run.status !== "running" || !terminal || terminal.status !== "done"
1124
+ || !bundle.descriptor.descriptor.terminal_stages.includes(terminal.stage)
1125
+ || index.stage_runs.some(stage => ["running", "paused", "waiting_for_user", "blocked", "pending"].includes(stage.status))
1126
+ || 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])
1127
+ || 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])) {
1128
+ throw new AppError("descriptor_run_settlement_invalid", "Descriptor RUN is not ready for terminal runtime settlement", 1, { effect: "no_effect" });
1129
+ }
1130
+ const project = context.db.get("SELECT * FROM projects WHERE id=?", [scope.projectId]);
1131
+ if (!project)
1132
+ throw new AppError("not_found", "Project is not registered", 1);
1133
+ const now = context.now();
1134
+ index.status = "done";
1135
+ index.verdict = "accepted";
1136
+ index.next_action = null;
1137
+ index.updated_at = now;
1138
+ index.completed_at = now;
1139
+ index.finished_at = now;
1140
+ index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
1141
+ index.execution = { project_root: run.project_root, workspace_root: run.workspace_root, git: { ...git } };
1142
+ persistRunState(context, project, run, index);
1143
+ closeRunOrchestrators(context, scope.projectId, run, "done", now);
1144
+ refreshRunSessionProjection(context, scope.projectId, scope.runId);
1145
+ appendAudit(context, { projectId: scope.projectId, eventType: "flow_run.completed", payload: { run_id: scope.runId, status: "done", verdict: "accepted", next_action: null } });
1146
+ appendRunTimeline(context, scope.projectId, scope.runId, { at: now, type: "run_completed", run_id: scope.runId, status: "done" });
1147
+ }
1019
1148
  /** Update a live RUN between stages without manufacturing terminal evidence. */
1020
1149
  export function advanceFlowRun(context, input) {
1021
1150
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -1524,12 +1653,172 @@ function authoritativeIndex(run) {
1524
1653
  const state = JSON.parse(run.index_json);
1525
1654
  if (!state || typeof state !== "object" || Array.isArray(state) || !Array.isArray(state.stage_runs))
1526
1655
  throw new Error("RUN index must contain stage_runs");
1527
- return { ...state, schema_id: runSchemaId };
1656
+ const derivedStage = ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind)
1657
+ ? [...state.stage_runs].sort((left, right) => right.order - left.order)[0]?.stage : undefined;
1658
+ return { ...state, schema_id: runSchemaId, ...(state.current_stage === undefined && derivedStage ? { current_stage: derivedStage } : {}) };
1528
1659
  }
1529
1660
  catch (error) {
1530
1661
  throw new AppError("runtime_state_invalid", "RUN mechanical state in SQLite is missing or invalid", 1, { run_id: run.id, cause: String(error) });
1531
1662
  }
1532
1663
  }
1664
+ function assertBundleRunIdentity(run, bundle) {
1665
+ const scope = bundle.execution.snapshot().scope;
1666
+ const flowId = bundle.descriptor.descriptor.id;
1667
+ const matchesFlow = flowId === "sdlc" ? ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind) : flowId === run.flow_kind;
1668
+ if (bundle.manifest.run_id !== run.id || scope.projectRoot !== run.project_root || scope.workspace !== run.workspace_root || !matchesFlow) {
1669
+ 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 });
1670
+ }
1671
+ const blobs = new Map(bundle.manifest.inventory.map(ref => [ref.path, bundle.blob(ref.path)]));
1672
+ // File-owned handler inventories can never authorize executable code.
1673
+ admitBundledFlow(bundle.blob(bundle.manifest.descriptor.path), bundle.manifest.descriptor.sha256, blobs);
1674
+ }
1675
+ function bundleAuthority(admission) {
1676
+ const authority = { runId: admission.runId, executionScope: structuredClone(admission.executionScope),
1677
+ receipts: structuredClone([...(admission.receipts ?? [])].sort(([left], [right]) => left.localeCompare(right, "en"))) };
1678
+ for (const key of ["descriptorSha256", "engineChecksum", "engineBindingSha256", "engineManifestSha256", "bundleProjectRoot", "bundleProvenance", "bootstrapIdentity"]) {
1679
+ if (admission[key] !== undefined)
1680
+ Object.assign(authority, { [key]: structuredClone(admission[key]) });
1681
+ }
1682
+ return authority;
1683
+ }
1684
+ /** Copies the same SQL authority representation used by legacy bundle binding. */
1685
+ export function runBundleAuthorityMetadata(bundle) {
1686
+ if (!(bundle instanceof RetainedExecutionBundle))
1687
+ throw new AppError("run_bundle_anchor_invalid", "Bundle must be admitted before RUN allocation", 1);
1688
+ const admissionJson = JSON.stringify(bundleAuthority(bundle.admission())), admissionSha256 = hybridDigest(JSON.parse(admissionJson));
1689
+ if (Buffer.byteLength(admissionJson) > 1024 * 1024)
1690
+ throw new AppError("run_bundle_authority_invalid", "RUN admission exceeds its bound", 1);
1691
+ return { admissionJson, anchor: { schema_id: "dd-flow/run-execution-bundle@1",
1692
+ manifest_sha256: bundle.sha256, descriptor_sha256: bundle.manifest.descriptor.sha256,
1693
+ admission_sha256: admissionSha256, flow_id: bundle.manifest.flow_id } };
1694
+ }
1695
+ /** Recheck SQL authority and compiled descriptor admission. Schema loading
1696
+ * belongs outside SQL writers; use the explicit SQL-only fence inside them.
1697
+ */
1698
+ export function assertPreparedRunBundleAuthority(context, scope, bundle) {
1699
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle);
1700
+ assertBundleRunIdentity(run, bundle);
1701
+ }
1702
+ /** SQL-only fence after the caller has admitted compiled handlers outside the
1703
+ * writer. Descriptor schema loading is deliberately excluded from this fence.
1704
+ */
1705
+ export function assertPreparedRunBundleSqlAuthority(context, scope, bundle) {
1706
+ if (!(bundle instanceof RetainedExecutionBundle))
1707
+ throw new AppError("run_bundle_anchor_invalid", "Prepared bundle is not admitted", 1);
1708
+ const run = context.db.get("SELECT * FROM runs WHERE project_id = ? AND id = ?", [scope.projectId, scope.runId]);
1709
+ if (!run)
1710
+ throw new AppError("not_found", "Prepared bundle RUN is unavailable", 1);
1711
+ const index = authoritativeIndex(run), anchor = index.execution_bundle;
1712
+ if (!assertRunBundleSqlAuthority(context, scope, anchor) || !anchor || anchor.manifest_sha256 !== bundle.sha256
1713
+ || anchor.descriptor_sha256 !== bundle.manifest.descriptor.sha256 || anchor.flow_id !== bundle.manifest.flow_id
1714
+ || anchor.admission_sha256 !== hybridDigest(bundleAuthority(bundle.admission())))
1715
+ throw new AppError("run_bundle_anchor_invalid", "Prepared bundle differs from authoritative RUN", 1);
1716
+ const execution = bundle.execution.snapshot().scope;
1717
+ const flow = bundle.descriptor.descriptor.id;
1718
+ if (bundle.manifest.run_id !== run.id || execution.projectRoot !== run.project_root || execution.workspace !== run.workspace_root
1719
+ || !(flow === "sdlc" ? ["vnext_specify", "vnext_protocolize"].includes(run.flow_kind) : flow === run.flow_kind)) {
1720
+ throw new AppError("run_bundle_identity_invalid", "Execution bundle belongs to another RUN or workspace", 1, { effect: "no_effect", run_id: run.id });
1721
+ }
1722
+ return run;
1723
+ }
1724
+ export function readFlowRunExecutionBundle(context, input) {
1725
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1726
+ const run = resolveRun(context, project.id, input.runId), anchor = authoritativeIndex(run).execution_bundle;
1727
+ if (!anchor) {
1728
+ // Genuine historical RUNs have neither authority nor anchor. Lost new-RUN
1729
+ // metadata must not silently switch their execution to the legacy engine path.
1730
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
1731
+ if (hasAuthority && context.db.get("SELECT 1 FROM run_bundle_authorities WHERE project_id = ? AND run_id = ?", [project.id, run.id])) {
1732
+ throw new AppError("run_bundle_anchor_invalid", "Retained RUN authority exists without its execution anchor", 1);
1733
+ }
1734
+ return null;
1735
+ }
1736
+ if (anchor.schema_id !== "dd-flow/run-execution-bundle@1" || !/^[a-f0-9]{64}$/.test(anchor.manifest_sha256)
1737
+ || !/^[a-f0-9]{64}$/.test(anchor.descriptor_sha256) || !/^[a-f0-9]{64}$/.test(anchor.admission_sha256))
1738
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle anchor is invalid", 1);
1739
+ const hasAuthority = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_bundle_authorities'");
1740
+ 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;
1741
+ if (!row || row.manifest_sha256 !== anchor.manifest_sha256)
1742
+ throw new AppError("run_bundle_authority_missing", "RUN bundle admission authority is unavailable", 1);
1743
+ if (Buffer.byteLength(row.admission_json) > 1024 * 1024)
1744
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority exceeds its bound", 1);
1745
+ let retained;
1746
+ try {
1747
+ retained = JSON.parse(row.admission_json);
1748
+ }
1749
+ catch {
1750
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority is invalid", 1);
1751
+ }
1752
+ if (hybridDigest(retained) !== anchor.admission_sha256 || !Array.isArray(retained.receipts)
1753
+ || input.admission && hybridDigest(bundleAuthority(input.admission)) !== anchor.admission_sha256) {
1754
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority changed", 1);
1755
+ }
1756
+ const admission = { ...retained, receipts: new Map(retained.receipts), descriptorContext: bundledHandlerContext(new Map()) };
1757
+ const bundle = RetainedExecutionBundle.read(path.join(runArtifactRoot(run), "execution-bundle"), anchor.manifest_sha256, admission);
1758
+ assertBundleRunIdentity(run, bundle);
1759
+ if (bundle.manifest.descriptor.sha256 !== anchor.descriptor_sha256 || bundle.manifest.flow_id !== anchor.flow_id) {
1760
+ throw new AppError("run_bundle_anchor_invalid", "RUN bundle descriptor identity changed", 1);
1761
+ }
1762
+ return bundle;
1763
+ }
1764
+ /** Freeze once before physical owners exist. Publication precedes the fenced SQLite anchor. */
1765
+ export function bindFlowRunExecutionBundle(context, input) {
1766
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
1767
+ const run = resolveRun(context, project.id, input.runId), index = authoritativeIndex(run);
1768
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
1769
+ assertBundleRunIdentity(run, input.bundle);
1770
+ if (input.bundle.manifest.flow_id !== "sdlc") {
1771
+ 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 });
1772
+ }
1773
+ if (index.execution_bundle) {
1774
+ if (index.execution_bundle.manifest_sha256 !== input.bundle.sha256)
1775
+ throw new AppError("run_bundle_immutable", "RUN bundle cannot be replaced", 1);
1776
+ readFlowRunExecutionBundle(context, { ...input, admission: input.bundle.admission() });
1777
+ publishRunProjections(context, project.id, run.id);
1778
+ return { changed: false, sha256: input.bundle.sha256 };
1779
+ }
1780
+ const assertUnstarted = (state) => {
1781
+ if (state.status !== "running" || state.stage_runs.length || state.sessions.length
1782
+ || context.db.get("SELECT 1 FROM sessions WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id])
1783
+ || context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? LIMIT 1", [project.id, run.id])) {
1784
+ throw new AppError("run_bundle_active", "Execution bundle must be frozen before stages, Works or Sessions exist", 1);
1785
+ }
1786
+ };
1787
+ assertUnstarted(index);
1788
+ const authority = bundleAuthority(input.bundle.admission());
1789
+ // Hash the JSON representation actually retained, not optional undefined fields.
1790
+ const authorityJson = JSON.stringify(authority), authoritySha256 = hybridDigest(JSON.parse(authorityJson));
1791
+ if (Buffer.byteLength(authorityJson) > 1024 * 1024)
1792
+ throw new AppError("run_bundle_authority_invalid", "RUN bundle admission authority exceeds its bound", 1);
1793
+ input.bundle.materialize(runArtifactRoot(run));
1794
+ try {
1795
+ context.db.writeTransaction(() => {
1796
+ assertRunMutationAllowed(context, project.id, run.id, generation);
1797
+ const latest = resolveRun(context, project.id, run.id), state = authoritativeIndex(latest);
1798
+ assertUnstarted(state);
1799
+ if (state.runtime_revision !== index.runtime_revision || state.execution_bundle || latest.workspace_root !== run.workspace_root
1800
+ || latest.project_root !== run.project_root || latest.flow_kind !== run.flow_kind || latest.run_root !== run.run_root) {
1801
+ throw new AppError("run_revision_changed", "RUN changed during bundle publication", 1);
1802
+ }
1803
+ state.execution_bundle = { schema_id: "dd-flow/run-execution-bundle@1", manifest_sha256: input.bundle.sha256,
1804
+ descriptor_sha256: input.bundle.manifest.descriptor.sha256, admission_sha256: authoritySha256, flow_id: input.bundle.manifest.flow_id };
1805
+ context.db.execSchema(`CREATE TABLE IF NOT EXISTS run_bundle_authorities (
1806
+ project_id TEXT NOT NULL, run_id TEXT NOT NULL, manifest_sha256 TEXT NOT NULL, admission_json TEXT NOT NULL,
1807
+ PRIMARY KEY(project_id, run_id), FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id)
1808
+ )`);
1809
+ 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]);
1810
+ state.updated_at = context.now();
1811
+ persistRunState(context, project, latest, state);
1812
+ }, "runs.bindExecutionBundle");
1813
+ }
1814
+ catch (error) {
1815
+ throw new AppError("run_bundle_binding_incomplete", "Bundle retained but RUN binding requires recovery", 1, {
1816
+ phase: "publication", effect: "partial", run_id: run.id, sha256: input.bundle.sha256,
1817
+ reason: error instanceof AppError ? error.code : "binding_io_failure",
1818
+ });
1819
+ }
1820
+ return { changed: true, sha256: input.bundle.sha256 };
1821
+ }
1533
1822
  function sanitizeTimelineValue(value, key) {
1534
1823
  if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
1535
1824
  return "[REDACTED]";
@@ -1556,6 +1845,24 @@ function nextAttempt(existing) {
1556
1845
  const number = Number(existing.attempt.replace("try-", ""));
1557
1846
  return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
1558
1847
  }
1848
+ function bundledNextStage(flow, index) {
1849
+ const completed = index.stage_runs.filter(stage => ["done", "skipped"].includes(stage.status)).sort((left, right) => right.order - left.order)[0];
1850
+ if (!completed)
1851
+ return flow.descriptor.entry_stage;
1852
+ // Optional SDLC review routing remains a flow-specific policy, not a generic stage list.
1853
+ const outcome = flow.descriptor.id === "sdlc" && completed.stage === "plan" && index.settings?.plan_review?.mode === "off" ? "not-applicable" : "accepted";
1854
+ return flow.successor(completed.stage, outcome);
1855
+ }
1856
+ function assertBundledStageStart(flow, index, stage, dir, status, existing) {
1857
+ const selected = flow.stage(stage);
1858
+ if (selected.directory !== dir)
1859
+ throw new AppError("flow_stage_directory_invalid", "Stage directory differs from the frozen descriptor", 1, { stage });
1860
+ if (status !== "running")
1861
+ return;
1862
+ if (existing || index.stage_runs.some(candidate => !["done", "skipped"].includes(candidate.status)) || bundledNextStage(flow, index) !== stage) {
1863
+ 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 });
1864
+ }
1865
+ }
1559
1866
  function assertVnextStageStart(run, index, stage, status, existing) {
1560
1867
  if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
1561
1868
  return;
@@ -1614,19 +1921,6 @@ function requireRunById(context, projectId, runId) {
1614
1921
  }
1615
1922
  return run;
1616
1923
  }
1617
- function nextRunId(context, projectId, slug) {
1618
- const rows = context.db.all("SELECT id FROM runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
1619
- const max = rows.reduce((value, row) => {
1620
- try {
1621
- const parsed = parseFullEntityId(row.id);
1622
- return parsed.type === runIdType ? Math.max(value, Number(parsed.shortId.slice(4))) : value;
1623
- }
1624
- catch {
1625
- return value;
1626
- }
1627
- }, 0);
1628
- return formatFullId(runIdType, max + 1, slug);
1629
- }
1630
1924
  function flowRunSummary(run) {
1631
1925
  return {
1632
1926
  id: run.id,
@@ -136,7 +136,15 @@ export function loadExternalStageContext(input) {
136
136
  const materializedSha256 = sha256(bytes);
137
137
  if (materializedSha256 !== input.expectedSha256)
138
138
  throw new AppError("context_checksum_mismatch", "Stage context checksum does not match --context-sha256", 2, { context_file: sourcePath, expected_sha256: input.expectedSha256, actual_sha256: materializedSha256 });
139
- const value = prepareJsonText(bytes.toString("utf8"), { file: sourcePath, label: "Stage context" }).value;
139
+ return loadExternalStageContextBytes({ bytes, sourcePath, stage: input.stage });
140
+ }
141
+ /** The same context contract for independently retained Stage input bytes;
142
+ * historical readers must not reopen the original mutable source file. */
143
+ export function loadExternalStageContextBytes(input) {
144
+ if (!(input.bytes instanceof Uint8Array) || !input.bytes.length || input.bytes.length > 1024 * 1024)
145
+ throw new AppError("validation", "Stage context exceeds its byte bound", 2);
146
+ const bytes = Buffer.from(input.bytes), sourcePath = input.sourcePath, materializedSha256 = sha256(bytes);
147
+ const value = prepareJsonText(new TextDecoder("utf-8", { fatal: true }).decode(bytes), { file: sourcePath, label: "Stage context" }).value;
140
148
  validateContext(value, input.stage);
141
149
  return { value, sourcePath, materializedSha256, bytes };
142
150
  }
@@ -0,0 +1,74 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { finishVnextSpecifyStage, launchVnextSpecify } from "./vnext-specify.js";
3
+ import { finishVnextProtocolize, startVnextProtocolize } from "./vnext-protocolize.js";
4
+ import { finishVnextPlan, startVnextPlan } from "./vnext-plan.js";
5
+ import { finishVnextPlanReview, startVnextPlanReview } from "./vnext-plan-review.js";
6
+ import { finishVnextCode, startVnextCode } from "./vnext-code.js";
7
+ import { finishVnextCodeReview, startVnextCodeReview } from "./vnext-code-review.js";
8
+ import { finishVnextMerge, startVnextMerge } from "./vnext-merge.js";
9
+ import { ResolvedFlowDescriptor } from "./flow-descriptors.js";
10
+ import { vnextStages } from "../domain/stage-catalog.js";
11
+ import { sdlcHandlerIdentities as ids } from "../domain/stage-handler-identities.js";
12
+ import { memoryInitStageHandlers } from "./memory-stage-dispatch.js";
13
+ /** Engine-owned code, not a plugin registration surface or executable descriptor expression. */
14
+ export const sdlcStageHandlers = Object.freeze({
15
+ specify: Object.freeze({ ...ids.specify, entry: launchVnextSpecify, finish: finishVnextSpecifyStage }),
16
+ protocolize: Object.freeze({ ...ids.protocolize, entry: startVnextProtocolize, finish: finishVnextProtocolize }),
17
+ plan: Object.freeze({ ...ids.plan, entry: startVnextPlan, finish: finishVnextPlan }),
18
+ "plan-review": Object.freeze({ ...ids["plan-review"], entry: startVnextPlanReview, finish: finishVnextPlanReview }),
19
+ code: Object.freeze({ ...ids.code, entry: startVnextCode, finish: finishVnextCode }),
20
+ "code-review": Object.freeze({ ...ids["code-review"], entry: startVnextCodeReview, finish: finishVnextCodeReview }),
21
+ merge: Object.freeze({ ...ids.merge, entry: startVnextMerge, finish: finishVnextMerge })
22
+ });
23
+ /** A fresh inventory cannot mutate the registered handler functions. */
24
+ export function bundledHandlerContext(blobs) {
25
+ return {
26
+ entryHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers)].map(handler => handler.entryId)),
27
+ finishHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers)].map(handler => handler.finishId)), blobs
28
+ };
29
+ }
30
+ /** Handler identity is also stage/flow scoped: a known CODE handler is not a memory handler. */
31
+ export function admitBundledFlow(input, sha256, blobs) {
32
+ const flow = ResolvedFlowDescriptor.admit(input, sha256, bundledHandlerContext(blobs));
33
+ if (flow.descriptor.id === "sdlc") {
34
+ const edges = vnextStages.flatMap(stage => stage.next.map((to, index) => ({ from: stage.id, to, on: index ? "not-applicable" : "accepted" })));
35
+ if (flow.descriptor.entry_stage !== "specify" || flow.descriptor.terminal_stages.length !== 1 || flow.descriptor.terminal_stages[0] !== "merge"
36
+ || flow.descriptor.stages.length !== vnextStages.length
37
+ || flow.descriptor.stages.some((stage, index) => stage.id !== vnextStages[index].id || stage.directory !== vnextStages[index].directory)
38
+ || flow.descriptor.edges.length !== edges.length
39
+ || edges.some(expected => !flow.descriptor.edges.some(edge => edge.from === expected.from && edge.to === expected.to && edge.on === expected.on))) {
40
+ throw new AppError("flow_handler_scope_invalid", "SDLC handlers require the registered seven-stage layout and transitions", 2, { phase: "prepare", effect: "no_effect" });
41
+ }
42
+ }
43
+ if (flow.descriptor.id === "mb-init") {
44
+ const stages = Object.keys(memoryInitStageHandlers);
45
+ const edges = stages.slice(0, -1).map((from, index) => ({ from, to: stages[index + 1], on: "accepted" }));
46
+ // Historical packs have only accepted transitions; diagnostic VERIFY uses
47
+ // the same REVIEW handler without granting another route or stage.
48
+ const requiredEdges = flow.descriptor.edges.filter(edge => !(edge.from === "verify" && edge.to === "review" && edge.on === "needs_repair"));
49
+ if (flow.descriptor.entry_stage !== "preflight" || flow.descriptor.terminal_stages.length !== 1 || flow.descriptor.terminal_stages[0] !== "finish"
50
+ || flow.descriptor.stages.length !== stages.length
51
+ || flow.descriptor.stages.some((stage, index) => stage.id !== stages[index] || stage.directory !== `${String(index + 1).padStart(2, "0")}-${stage.id}`)
52
+ || requiredEdges.length !== edges.length
53
+ || edges.some(expected => !flow.descriptor.edges.some(edge => edge.from === expected.from && edge.to === expected.to && edge.on === expected.on))) {
54
+ throw new AppError("flow_handler_scope_invalid", "Memory init handlers require the registered six-stage layout and transitions", 2, { phase: "prepare", effect: "no_effect" });
55
+ }
56
+ for (const stage of flow.descriptor.stages) {
57
+ let schemaId;
58
+ try {
59
+ schemaId = JSON.parse(flow.stageBlob(stage.id, "result_schema").toString("utf8")).$id;
60
+ }
61
+ catch { /* Invalid schema cannot authorize this stage handler. */ }
62
+ if (schemaId !== `dd-flow/mb-init-${stage.id}-result@1`)
63
+ throw new AppError("flow_handler_scope_invalid", "Memory init stage requires its own registered result schema", 2, { phase: "prepare", effect: "no_effect", stage: stage.id });
64
+ }
65
+ }
66
+ for (const stage of flow.descriptor.stages) {
67
+ const handlers = flow.descriptor.id === "sdlc" ? Object.values(sdlcStageHandlers) : flow.descriptor.id === "mb-init" ? Object.values(memoryInitStageHandlers) : [];
68
+ const handler = handlers.find(candidate => candidate.stage === stage.id);
69
+ if (!handler || stage.entry_handler !== handler.entryId || stage.finish_handler !== handler.finishId) {
70
+ throw new AppError("flow_handler_scope_invalid", "Registered handlers do not belong to this flow stage", 2, { phase: "prepare", effect: "no_effect", flow_id: flow.descriptor.id, stage: stage.id });
71
+ }
72
+ }
73
+ return flow;
74
+ }
@@ -741,7 +741,8 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
741
741
  next_action: stringValue(semantic.next_action) ?? "Resolve the stage result before continuing.",
742
742
  ...(Array.isArray(semantic.questions) ? { questions: semantic.questions } : {}),
743
743
  ...(Array.isArray(semantic.reviewer_findings) ? { reviewer_findings: semantic.reviewer_findings } : {}),
744
- ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {})
744
+ ...(Array.isArray(semantic.def_outcomes) ? { def_outcomes: semantic.def_outcomes } : {}),
745
+ ...(semantic.materialization !== undefined ? { materialization: semantic.materialization } : {})
745
746
  },
746
747
  mechanical: {
747
748
  started_at: startedAt,