@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,6 +4,69 @@ import crypto from "node:crypto";
4
4
  import { AppError } from "../shared/errors.js";
5
5
  import { withProjectionLock } from "../storage/projection-lock.js";
6
6
  import { writeJsonAtomic } from "../storage/paths.js";
7
+ import { assertRunBundleSqlAuthority } from "./runs.js";
8
+ import { hybridDigest } from "./hybrid-contracts.js";
9
+ /** Accepted bytes come from SQL receipts, never from the mutable publication.
10
+ * Stage completion and its independent committed checksum must agree. This is
11
+ * byte custody, not a substitute for the registered handler's semantic gates.
12
+ */
13
+ export function readAcceptedStageData(context, scope) {
14
+ function refuse() { throw new AppError("stage_data_custody_invalid", "Accepted stage data lacks matching committed SQL byte custody", 1, { run_id: scope.runId, stage: scope.stage }); }
15
+ const run = context.db.get("SELECT index_json, run_root, run_dir FROM runs WHERE project_id = ? AND id = ?", [scope.projectId, scope.runId]);
16
+ if (!run)
17
+ refuse();
18
+ const index = JSON.parse(run.index_json);
19
+ assertRunBundleSqlAuthority(context, scope, index.execution_bundle);
20
+ const stage = index.stage_runs?.find((item) => item.stage === scope.stage);
21
+ const portable = (value) => typeof value === "string" && Boolean(value) && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value) && !value.includes("\\") && !value.includes("\0") && value.split("/").every(part => Boolean(part) && part !== "." && part !== "..");
22
+ if (!stage || stage.status !== "done" || !portable(stage.dir) || !portable(stage.data)
23
+ || typeof stage.attempt !== "string" || !/^try-[0-9]{3,}$/.test(stage.attempt)
24
+ || typeof stage.data_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(stage.data_sha256))
25
+ refuse();
26
+ const root = run.run_root ?? run.run_dir;
27
+ if (!path.isAbsolute(root))
28
+ refuse();
29
+ const relative = `${stage.dir}/${stage.data}`, destination = path.resolve(root, relative);
30
+ const rows = context.db.all("SELECT event_type, payload_json FROM audit_events WHERE project_id = ? AND event_type IN ('flow_run.stage_packet', 'flow_run.stage_report') AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.stage') = ? AND json_extract(payload_json, '$.attempt') = ? LIMIT 33", [scope.projectId, scope.runId, scope.stage, stage.attempt]);
31
+ if (rows.length > 32)
32
+ refuse();
33
+ let accepted;
34
+ for (const row of rows) {
35
+ if (Buffer.byteLength(row.payload_json) > 8 * 1024 * 1024)
36
+ refuse();
37
+ const receipt = JSON.parse(row.payload_json);
38
+ if (receipt.sha256 !== stage.data_sha256)
39
+ continue;
40
+ const locator = row.event_type === "flow_run.stage_packet" ? receipt.file : typeof receipt.root === "string" ? path.join(receipt.root, "stage-report.json") : null;
41
+ if (typeof locator !== "string" || resolveStagePublicationPath(context, scope.projectId, scope.runId, locator) !== destination)
42
+ continue;
43
+ let bytes;
44
+ if (row.event_type === "flow_run.stage_packet") {
45
+ if (typeof receipt.base64 !== "string")
46
+ refuse();
47
+ bytes = Buffer.from(receipt.base64, "base64");
48
+ if (bytes.toString("base64") !== receipt.base64)
49
+ refuse();
50
+ }
51
+ else {
52
+ if (typeof receipt.bytes?.json !== "string")
53
+ refuse();
54
+ bytes = Buffer.from(receipt.bytes.json);
55
+ }
56
+ if (!bytes.length || bytes.length > 1024 * 1024 || crypto.createHash("sha256").update(bytes).digest("hex") !== stage.data_sha256)
57
+ refuse();
58
+ if (accepted && !accepted.equals(bytes))
59
+ refuse();
60
+ accepted = bytes;
61
+ }
62
+ if (!accepted)
63
+ refuse();
64
+ return { bytes: Buffer.from(accepted), result: { path: relative, sha256: stage.data_sha256 },
65
+ input_sha256: stage.input_sha256, data_schema_id: stage.data_schema_id,
66
+ authority_sha256: hybridDigest({ scope, attempt: stage.attempt, directory: stage.dir, data: stage.data,
67
+ input_sha256: stage.input_sha256 ?? null, data_schema_id: stage.data_schema_id ?? null,
68
+ anchor: index.execution_bundle ?? null, data_sha256: stage.data_sha256 }) };
69
+ }
7
70
  /** Deterministic JSON -> Markdown/HTML projection shared by every stage. */
8
71
  export function writeStageReport(root, report) {
9
72
  const json = path.join(root, "stage-report.json");
@@ -164,6 +227,19 @@ export function reconcileRunStageReports(context, projectId, runId, exceptStage)
164
227
  }
165
228
  function normalizeStageReport(report) {
166
229
  const semantic = object(report.semantic);
230
+ if (semantic.materialization !== undefined) {
231
+ const value = object(semantic.materialization);
232
+ const authored = object(value.authored_result);
233
+ const relative = (item) => typeof item === "string" && item.length > 0 && !path.isAbsolute(item) && !item.split(/[\\/]/).includes("..");
234
+ if (value.schema_id !== "dd-flow/protocolize-materialization@1" || value.run_id !== report.run_id || report.stage !== "protocolize" || value.stage !== "protocolize"
235
+ || !["create", "link", "update", "not_applicable"].includes(String(value.action)) || value.status !== "passed"
236
+ || authored.role !== "authored_input" || !relative(authored.run_relative_path) || !/^[a-f0-9]{64}$/.test(String(authored.sha256))
237
+ || !(value.feature_path === null || relative(value.feature_path)) || !Array.isArray(value.protocol_ids) || value.protocol_ids.some(id => typeof id !== "string")
238
+ || !Array.isArray(value.documents) || value.documents.some(item => { const ref = object(item); return !relative(ref.path) || !/^[a-f0-9]{64}$/.test(String(ref.sha256)); })
239
+ || !Array.isArray(value.owned_links) || value.owned_links.some(item => { const edge = object(item); return !relative(edge.feature_path) || !relative(edge.protocol_path) || !value.protocol_ids.includes(edge.protocol_id); })) {
240
+ throw new AppError("stage_report_materialization_invalid", "PROTOCOLIZE materialization receipt is malformed or out of scope", 1);
241
+ }
242
+ }
167
243
  const sourceArtifacts = object(report.artifacts);
168
244
  return {
169
245
  ...report,
@@ -7,6 +7,23 @@ export function stageAttempt(context, projectId, runId, stage) {
7
7
  return [...stages].reverse().find(item => item.stage === stage)?.attempt ?? "try-001";
8
8
  }
9
9
  export function bindStageWork(context, input) {
10
+ context.db.writeTransaction(() => bindStageWorkOwned(context, input), "stage-work.bind");
11
+ }
12
+ function bindStageWorkOwned(context, input) {
13
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
14
+ const index = run ? JSON.parse(run.index_json) : null;
15
+ if (index?.execution_bundle) {
16
+ // Stage attachment already admitted the frozen descriptor outside the writer.
17
+ // Membership checks here consume only authoritative SQLite facts.
18
+ const stage = index.stage_runs.find(item => item.stage === input.stage && item.status === "running");
19
+ const work = context.db.get("SELECT parent_work_id FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [input.projectId, input.runId, input.workId]);
20
+ const parentOwned = !work?.parent_work_id || context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND work_id = ?", [input.projectId, input.runId, work.parent_work_id]);
21
+ const currentStage = index.current_stage ?? (index.execution_bundle.flow_id === "sdlc" ? index.stage_runs.at(-1)?.stage : undefined);
22
+ if (!stage || currentStage !== input.stage || !work || !parentOwned || input.attempt !== undefined && input.attempt !== (stage.attempt ?? "try-001")
23
+ || input.cycle !== undefined && input.cycle !== stageCycle(context, input.projectId, input.runId, input.stage)) {
24
+ throw new AppError("stage_work_scope_invalid", "Work membership does not belong to the active frozen stage scope", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
25
+ }
26
+ }
10
27
  context.db.run("INSERT OR IGNORE INTO stage_work_memberships (project_id, run_id, work_id, stage, attempt, cycle) VALUES (?, ?, ?, ?, ?, ?)", [input.projectId, input.runId, input.workId, input.stage, input.attempt ?? stageAttempt(context, input.projectId, input.runId, input.stage), input.cycle ?? stageCycle(context, input.projectId, input.runId, input.stage)]);
11
28
  }
12
29
  export function stageCycle(context, projectId, runId, stage) {
@@ -498,7 +498,7 @@ export function prepareVnextCodeRepair(context, input) {
498
498
  export function addVnextCodeRepair(context, input, prepared = prepareVnextCodeRepair(context, input)) {
499
499
  const { project, run } = prepared;
500
500
  if (prepared.kind === "replay")
501
- return { ok: true, run_id: run.id, repair_work_id: prepared.previous.work_id, start_command: workStartCommand(context, prepared.previous), reused: true };
501
+ return { ok: true, run_id: run.id, repair_work_id: prepared.previous.work_id, start_command: workStartCommand(context, prepared.previous, { preview: true }), reused: true };
502
502
  const { parentWorkId, items, key, receipt } = prepared;
503
503
  const create = () => String(addWorkBatch(context, { parentWorkId, prepared: items, stage: input.stage ?? (input.reviewCycle ? "code-review" : "code"), ...(input.reviewCycle ? { cycle: input.reviewCycle } : {}) }).work_ids[key]);
504
504
  const id = input.registrationOwned ? create() : ensureRepairIntent(context, {
@@ -513,7 +513,7 @@ export function addVnextCodeRepair(context, input, prepared = prepareVnextCodeRe
513
513
  ...(receipt ? { check_receipt_id: receipt.id } : prepared.input.reviewFindings?.length ? { review_finding_ids: prepared.input.reviewFindings.map((finding) => finding.finding_ref) } : { semantic_unresolved: prepared.input.semanticUnresolved })
514
514
  });
515
515
  refreshRunWorkProjection(context, project.id, run.id);
516
- return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
516
+ return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work, { preview: true }) };
517
517
  }
518
518
  /** Retain the causal declaration for worker context; run_at still records its original gate. */
519
519
  export function selectRepairChecks(input) {
@@ -730,7 +730,7 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
730
730
  const profile = JSON.parse(run.index_json);
731
731
  const bootstrap = profile.execution_profile?.settings?.code_bootstrap;
732
732
  if (!bootstrap || typeof bootstrap.command !== "string" || typeof bootstrap.policy_ref !== "string") {
733
- throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
733
+ throw new AppError("execution_profile_invalid", "CODE requires an evidenced bootstrap command and policy in the frozen RUN profile; configure code_bootstrap before starting a code-ready RUN", 1, { run_id: run.id });
734
734
  }
735
735
  const receiptPath = path.join(root, "workspace-readiness.json");
736
736
  const ownerId = context.env.DD_FLOW_CODE_READINESS_OWNER;
@@ -93,8 +93,8 @@ export function validateVnextExecutionProfile(value) {
93
93
  || profile.merge_delivery?.strategy !== "local"
94
94
  || !["retain", "delete_after_success"].includes(String(profile.merge_cleanup?.source))
95
95
  || !["code_completed", "code_review_completed", "merge_completed"].includes(String(profile.stop_target))
96
- || !profile.code_bootstrap || typeof profile.code_bootstrap.command !== "string" || !profile.code_bootstrap.command.trim()
97
- || typeof profile.code_bootstrap.policy_ref !== "string" || !profile.code_bootstrap.policy_ref.trim()
96
+ || (profile.code_bootstrap !== undefined && (!profile.code_bootstrap || typeof profile.code_bootstrap.command !== "string" || !profile.code_bootstrap.command.trim()
97
+ || typeof profile.code_bootstrap.policy_ref !== "string" || !profile.code_bootstrap.policy_ref.trim()))
98
98
  || (profile.execution === undefined ? profile.schema_id === "dd-flow/project-execution@3" : !isExecutionRouting(profile.execution))) {
99
99
  throw new AppError("execution_profile_invalid", "Project execution profile does not match its supported dd-flow schema", 1);
100
100
  }
@@ -3,14 +3,16 @@ import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
- import { getFlowRunVariables, appendFlowRunTimelineEvent } from "./runs.js";
6
+ import { getFlowRunVariables, appendFlowRunTimelineEvent, readFlowRunExecutionBundle } from "./runs.js";
7
7
  import { resolveExecutionPolicy } from "./execution-policy.js";
8
- import { listWorks, failWork, cancelWork } from "./work-registry.js";
8
+ import { listWorks, failWork, cancelWork, readWorkBatchCustody } from "./work-registry.js";
9
9
  import { storageSessionId } from "./session-identity.js";
10
10
  import { reconcileUnfinishedChecks } from "./code-checks.js";
11
11
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
12
12
  import { stageWorkIds, stageGraphScope } from "./stage-work-graph.js";
13
13
  import { eligibleCodexChildOverload } from "./codex-capacity.js";
14
+ import { readDescriptorRootWork } from "./descriptor-root-work.js";
15
+ import { selectMemoryCompositionRegistration } from "./memory-composition-registration.js";
14
16
  import { safeNativeFailure } from "../harness-runtime/lib/native-children.mjs";
15
17
  export const subagentCapacityKey = "runtime.subagent.available_slots";
16
18
  /** External concurrency is policy, never evidence of native child qualification. */
@@ -20,17 +22,85 @@ export function getStageFanoutCapacity(context, input) {
20
22
  const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
21
23
  if (!run)
22
24
  throw new AppError("not_found", "RUN is unavailable", 1);
23
- const frozen = JSON.parse(run.index_json).execution_profile;
24
- if (frozen?.settings.execution) {
25
+ const index = JSON.parse(run.index_json);
26
+ const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot, runId: input.runId }) : null;
27
+ const generic = bundle?.execution.work(input.stage).delegation;
28
+ if (generic?.mode === "external")
29
+ return input.nativeOnly
30
+ ? { run_key: null, available_slots: null, source: "harness_qualification" }
31
+ : { run_key: null, available_slots: generic.max_parallel, source: "external_policy" };
32
+ // Generic native admission already requires a scoped capacity receipt whose
33
+ // observed count covers this frozen limit. Mutable legacy variables cannot
34
+ // replace that receipt or silently prevent its productive dispatch.
35
+ if (generic?.mode === "native")
36
+ return { run_key: null, available_slots: generic.max_parallel, source: "harness_qualification" };
37
+ const frozen = index.execution_profile;
38
+ if (!bundle && frozen?.settings.execution) {
25
39
  if (!frozen.agent_profiles)
26
40
  throw new AppError("execution_profile_not_frozen", "Fan-out needs the RUN's frozen profiles", 1);
27
41
  const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: frozen.settings.execution, profiles: frozen.agent_profiles, stage: input.stage });
28
- if (resolved.delegation.mode === "external")
42
+ if (resolved.delegation.mode === "external" && !input.nativeOnly)
29
43
  return { run_key: null, available_slots: resolved.delegation.max_parallel, source: "external_policy" };
30
44
  }
31
45
  const variables = getFlowRunVariables(context, { projectRoot, runId: input.runId });
32
46
  const available = variables.variables[subagentCapacityKey];
33
- return { run_key: subagentCapacityKey, available_slots: typeof available === "number" && Number.isInteger(available) && available >= 0 ? available : null, source: "harness_qualification" };
47
+ return { run_key: subagentCapacityKey, available_slots: typeof available === "number" && Number.isInteger(available) && available >= 0 ? Math.min(available, generic?.max_parallel ?? available) : null, source: "harness_qualification" };
48
+ }
49
+ function nativeFailureController(context, projectId, runId, stage, parent) {
50
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type='table' AND name='run_controllers'"))
51
+ return null;
52
+ const rows = context.db.all("SELECT controller_id,generation,state_json FROM run_controllers WHERE project_id=? AND run_id=? AND status NOT IN ('completed','cancelled','superseded')", [projectId, runId]);
53
+ const matching = rows.filter(row => {
54
+ try {
55
+ const state = JSON.parse(row.state_json);
56
+ return state.stage === stage && state.sessions?.[state.current_session]?.id === parent;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ });
62
+ return matching.length === 1 ? matching[0] : null;
63
+ }
64
+ /** Failure metadata authored by a Work is not native evidence. Require the
65
+ * independently recorded reconciliation and its exact current registry links. */
66
+ export function verifiedNativeWorkFailure(context, input) {
67
+ const b = input.receipt.native_binding;
68
+ if (!b || b.schema_id !== "dd-flow/native-work-failure@1")
69
+ return;
70
+ const project = requireProjectByRoot(context, input.projectRoot);
71
+ const scope = stageGraphScope(context, project.id, input.runId, input.stage);
72
+ const controller = nativeFailureController(context, project.id, input.runId, input.stage, input.parentSessionId);
73
+ if (!controller || b.controller_id !== controller.controller_id || b.generation !== controller.generation
74
+ || b.project_id !== project.id || b.run_id !== input.runId || b.stage !== input.stage
75
+ || b.stage_attempt_id !== scope.attempt || b.stage_cycle !== scope.cycle
76
+ || b.work_id !== input.workId || b.parent_work_id !== input.parentWorkId
77
+ || b.parent_provider_session_id !== input.parentSessionId || b.terminal_turn_id !== input.receipt.terminal_turn_id
78
+ || input.receipt.source !== "native_child_observation" || input.receipt.outcome !== "failed")
79
+ return;
80
+ const link = context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id=ws.work_id JOIN sessions s ON s.project_id=w.project_id AND s.session_id=ws.session_id
81
+ WHERE ws.id=? AND w.project_id=? AND w.run_id=? AND w.work_id=? AND w.parent_work_id=? AND ws.status='failed' AND w.status='failed' AND s.parent_session_id=? AND s.provider_session_id=? AND s.harness=?`, [b.work_session_id, project.id, input.runId, input.workId, input.parentWorkId, storageSessionId({ harness_id: b.harness_id, session_id: input.parentSessionId }), b.provider_session_id, b.harness_id]);
82
+ if (!link)
83
+ return;
84
+ const run = context.db.get("SELECT run_root FROM runs WHERE project_id=? AND id=?", [project.id, input.runId]);
85
+ try {
86
+ const events = fs.readFileSync(path.join(run.run_root, "timeline.jsonl"), "utf8").trim().split("\n");
87
+ const proof = events.some(line => {
88
+ const event = JSON.parse(line);
89
+ if (event.type !== "native_child_reconciliation" || event.stage !== input.stage)
90
+ return false;
91
+ const terminal = event.observations?.children?.find((child) => child.session_id === b.provider_session_id && child.terminal_turn_id === b.terminal_turn_id);
92
+ if (event.observations?.parent_session_id !== b.parent_provider_session_id || event.observations?.harness_id !== b.harness_id
93
+ || terminal?.status !== "failed" || terminal.terminal_observed_at !== input.receipt.terminal_observed_at
94
+ || JSON.stringify(safeNativeFailure(terminal.provider_error)) !== JSON.stringify(safeNativeFailure(input.receipt.provider_error)))
95
+ return false;
96
+ return event.reconciled?.some((entry) => entry.outcome === "failed"
97
+ && entry.native_binding && Object.keys(b).every(key => b[key] === entry.native_binding[key]));
98
+ });
99
+ return proof ? b : undefined;
100
+ }
101
+ catch {
102
+ return;
103
+ }
34
104
  }
35
105
  export function fanoutState(input) {
36
106
  if (!input.hasWork)
@@ -57,17 +127,64 @@ export function readFanoutDescriptor(stageRoot) {
57
127
  }
58
128
  return value;
59
129
  }
130
+ /** Memory graphs are SQL-owned; a mutable/missing fanout.json is not their
131
+ * dispatch authority. Domain registrations and Work launch owners still fence
132
+ * productive assignments, including reviewer/correction waves.
133
+ */
134
+ export function memoryStageFanoutDescriptor(context, input) {
135
+ if (!["compose", "review"].includes(input.stage))
136
+ return null;
137
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
138
+ const run = context.db.get("SELECT flow_kind,index_json FROM runs WHERE project_id=? AND id=?", [project.id, input.runId]);
139
+ if (run?.flow_kind !== "mb-init")
140
+ return null;
141
+ const bundle = readFlowRunExecutionBundle(context, input);
142
+ if (!bundle || bundle.descriptor.descriptor.id !== "mb-init")
143
+ throw new AppError("stage_graph_compatibility_required", "Memory graph requires its admitted RUN bundle", 1);
144
+ const index = JSON.parse(run.index_json);
145
+ if (index.current_stage !== input.stage || !index.stage_runs.some(stage => stage.stage === input.stage && stage.status === "running"))
146
+ return null;
147
+ const root = readDescriptorRootWork(context, { projectId: project.id, runId: input.runId }, bundle);
148
+ const scope = stageGraphScope(context, project.id, input.runId, input.stage), members = stageWorkIds(context, project.id, input.runId, input.stage);
149
+ if (root.status !== "running" || !members.has(root.workId))
150
+ throw new AppError("stage_graph_conflict", "Memory graph lost its active coordinator", 1);
151
+ for (const id of members) {
152
+ if (id === root.workId)
153
+ continue;
154
+ const work = context.db.get("SELECT parent_work_id,result_schema FROM works WHERE project_id=? AND run_id=? AND work_id=?", [project.id, input.runId, id]);
155
+ const schemas = input.stage === "compose" ? ["dd-flow/memory-composition-work-result@1"] : ["dd-flow/memory-review-work-result@1", "dd-flow/memory-repair-work-result@1"];
156
+ if (!work || work.parent_work_id !== root.workId || !schemas.includes(work.result_schema ?? ""))
157
+ throw new AppError("stage_graph_conflict", "Memory graph contains an unassigned Work", 1);
158
+ const custody = readWorkBatchCustody(context, id);
159
+ if (custody.scope.project_id !== scope.project_id || custody.scope.run_id !== scope.run_id || custody.scope.attempt !== scope.attempt
160
+ || custody.scope.cycle !== scope.cycle || custody.scope.stage !== input.stage || custody.scope.parent_work_id !== root.workId)
161
+ throw new AppError("stage_graph_conflict", "Memory graph contains foreign allocation custody", 1);
162
+ }
163
+ if (input.stage === "compose") {
164
+ const children = context.db.all("SELECT work_id FROM works WHERE project_id=? AND run_id=? AND parent_work_id=?", [project.id, input.runId, root.workId]).filter(work => members.has(work.work_id));
165
+ const registered = context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type='memory.composition_registration' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=? AND json_extract(payload_json,'$.scope.cycle')=? LIMIT 1", [project.id, input.runId, scope.attempt, scope.cycle]);
166
+ if (children.length || registered) {
167
+ const registration = selectMemoryCompositionRegistration(context, { ...scope, stage: "compose", parent_work_id: root.workId });
168
+ const ids = new Set(Object.values(registration.work_ids));
169
+ if (ids.size !== children.length || children.some(work => !ids.has(work.work_id)))
170
+ throw new AppError("stage_graph_conflict", "Memory composition differs from its registered graph", 1);
171
+ }
172
+ }
173
+ return { schema_id: "dd-flow/work-fanout@2", kind: "work_fanout", scope, stage: input.stage, parent_work_id: root.workId, dispatch: "none", capacity_required: true };
174
+ }
60
175
  /** Read-only projection for an external orchestrator. dd-flow keeps the graph authority. */
61
176
  export function getVnextFanoutStatus(context, input) {
62
177
  const projectRoot = resolveProjectRoot(input.projectRoot);
63
178
  const project = requireProjectByRoot(context, projectRoot);
64
- const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
179
+ const run = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
65
180
  if (!run)
66
181
  throw new AppError("not_found", "RUN is not registered", 1, { run_id: input.runId });
67
182
  if (!run.run_root)
68
183
  throw new AppError("runtime_artifact_missing", "RUN artifact root is unavailable", 1, { run_id: input.runId });
69
- const stageRoot = path.join(run.run_root, vnextStageDirectory(input.stage));
70
- let descriptor = readFanoutDescriptor(stageRoot);
184
+ const index = JSON.parse(run.index_json);
185
+ const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot, runId: input.runId }) : null;
186
+ const stageRoot = path.join(run.run_root, bundle ? bundle.descriptor.stage(input.stage).directory : vnextStageDirectory(input.stage));
187
+ let descriptor = memoryStageFanoutDescriptor(context, input) ?? readFanoutDescriptor(stageRoot);
71
188
  // Registration is authoritative even if projection publication was interrupted,
72
189
  // or an otherwise review-off stage acquired an aggregate repair graph.
73
190
  if (!descriptor && context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'repair_intents'")) {
@@ -177,12 +294,21 @@ export async function reconcileVnextFanout(context, input) {
177
294
  continue;
178
295
  }
179
296
  const providerError = safeNativeFailure(child.provider_error);
297
+ const controller = nativeFailureController(context, project.id, input.runId, input.stage, value.parent_session_id);
298
+ const scope = stageGraphScope(context, project.id, input.runId, input.stage);
299
+ const terminalTime = typeof child.terminal_observed_at === "string" ? Date.parse(child.terminal_observed_at) : NaN;
300
+ const nativeBinding = child.status === "failed" && controller && typeof child.terminal_turn_id === "string" && child.terminal_turn_id.length > 0 && child.terminal_turn_id.length <= 256 && Number.isFinite(terminalTime)
301
+ ? { schema_id: "dd-flow/native-work-failure@1", project_id: project.id, run_id: input.runId, stage: input.stage, stage_attempt_id: scope.attempt, stage_cycle: scope.cycle,
302
+ parent_work_id: status.orchestration.parent_work_id, work_id: link.work_id, work_session_id: link.id, harness_id: value.harness_id,
303
+ parent_provider_session_id: value.parent_session_id, provider_session_id: String(child.session_id), terminal_turn_id: child.terminal_turn_id,
304
+ controller_id: controller.controller_id, generation: controller.generation } : undefined;
180
305
  const reason = JSON.stringify({ source: "native_child_observation", harness_id: value.harness_id, session_id: child.session_id, work_session_id: link.id, outcome: child.status,
306
+ ...(nativeBinding ? { native_binding: nativeBinding } : {}),
181
307
  ...(typeof child.terminal_turn_id === "string" ? { terminal_turn_id: child.terminal_turn_id } : {}),
182
308
  ...(typeof child.terminal_observed_at === "string" ? { terminal_observed_at: child.terminal_observed_at } : {}),
183
309
  ...(providerError ? { provider_error: providerError } : {}) });
184
310
  const result = await (child.status === "failed" ? failWork : cancelWork)(context, link.work_id, reason);
185
- reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, result });
311
+ reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, ...(nativeBinding ? { native_binding: nativeBinding } : {}), result });
186
312
  }
187
313
  appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
188
314
  return { ok: true, issues, reconciled };
@@ -232,6 +232,7 @@ export function applyVnextMerge(context, input) {
232
232
  throw new AppError("merge_apply_receipt_invalid", "Existing merge apply receipt does not match the active request", 1, { receipt_path: receiptFile });
233
233
  return receipt;
234
234
  }
235
+ requireBootstrapCommand(requireRun(context, request.project_id, request.run_id));
235
236
  heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
236
237
  assertExecutionBaseline(request);
237
238
  input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
@@ -316,7 +317,7 @@ export async function finishVnextMerge(context, input, prepared = prepareVnextMe
316
317
  if (failed.length) {
317
318
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
318
319
  const sourceRepair = failed.every(receipt => checkRepairDisposition(receipt) === "source_repair");
319
- throw new AppError("merge_gate_failed", "Integrated target checks failed. Only assertion-backed failures permit source repair; tooling or unknown evidence requires diagnosis/settlement first.", 2, { failures: failed, ...(sourceRepair ? { repair_command: repairCommand(context, request, projectRoot) } : { outcome: "blocked", recoverable: false }), continuation: { ...recoveryEvidenceInput(run.run_root, failed[0].id), command_after_input: finishCommand(context, run.id, request, projectRoot, failed[0].id) } });
320
+ throw new AppError("merge_gate_failed", "Integrated target checks failed. Only candidate-source-backed failures permit source repair; tooling or unknown evidence requires diagnosis/settlement first.", 2, { failures: failed, ...(sourceRepair ? { repair_command: repairCommand(context, request, projectRoot), continuation: { ...recoveryEvidenceInput(run.run_root, failed[0].id), command_after_input: finishCommand(context, run.id, request, projectRoot, failed[0].id) } } : { outcome: "blocked", recoverable: false }) });
320
321
  }
321
322
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
322
323
  const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
@@ -567,10 +568,14 @@ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.ex
567
568
  function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
568
569
  throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
569
570
  function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
570
- async function runBootstrap(context, run, cwd, mergeId, progress) {
571
+ function requireBootstrapCommand(run) {
571
572
  const command = executionSettings(run).code_bootstrap?.command;
572
573
  if (!command)
573
- throw new AppError("execution_profile_invalid", "MERGE requires a frozen workspace bootstrap command", 2);
574
+ throw new AppError("execution_profile_invalid", "MERGE requires an evidenced bootstrap command in the frozen RUN profile; configure code_bootstrap before starting a code-ready RUN", 2);
575
+ return command;
576
+ }
577
+ async function runBootstrap(context, run, cwd, mergeId, progress) {
578
+ const command = requireBootstrapCommand(run);
574
579
  const result = await runWorkspaceBootstrap(context, { projectId: run.project_id, runId: run.id, runHome: requireHome(run), workspaceRoot: cwd, command, artifactDir: `${stageDir}/${mergeId}/readiness`, progress });
575
580
  if (result.exit_code !== 0 || result.error)
576
581
  throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed; inspect its retained logs", 2, { command, ...result });
@@ -1,6 +1,6 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
- import { withStageSettlement, prepareFlowRunStageAttachment, prepareFlowRunStageCompletion } from "./runs.js";
3
+ import { withStageSettlement, prepareFlowRunStageAttachment, prepareFlowRunStageCompletion, readFlowRunExecutionBundle } from "./runs.js";
4
4
  import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import { AppError } from "../shared/errors.js";
@@ -132,6 +132,7 @@ export function startVnextPlanReview(context, input, prepared = prepareVnextPlan
132
132
  commitVerificationMatrixStart(context, prepared.matrix, () => attachFlowRunStage(context, attachmentInput, attachment));
133
133
  const reportGit = gitFacts(run.workspace_root);
134
134
  const next = codeSuccessor(context, run.id, projectRoot);
135
+ const completionBundle = readFlowRunExecutionBundle(context, { projectRoot, runId: run.id });
135
136
  return withStageSettlement(context, () => {
136
137
  const registered = addWorkBatch(context, { parentWorkId: rootWork, prepared: prepared.codeItems, stage: "code" });
137
138
  const report = reportFor(context, { run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context), git: reportGit, ...(next.command ? { nextCommand: next.command } : {}) });
@@ -141,7 +142,7 @@ export function startVnextPlanReview(context, input, prepared = prepareVnextPlan
141
142
  const preparedReport = prepareStageReport(report);
142
143
  acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
143
144
  const completionInput = { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html", dataSha256: preparedReport.sha256, git: reportGit };
144
- completeFlowRunStage(context, completionInput, prepareFlowRunStageCompletion(context, completionInput));
145
+ completeFlowRunStage(context, completionInput, prepareFlowRunStageCompletion(context, completionInput, completionBundle));
145
146
  advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_off", nextAction: "start_code" });
146
147
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_opened", mode: effective, outcome: "off" });
147
148
  return { ok: true, run_id: run.id, stage, outcome: "review_off", ...(externalContext ? { external_context: externalContext } : {}), plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), reason: "No applicable independent review group." }, next };
@@ -209,7 +210,7 @@ export function dispatchVnextPlanReview(context, input) {
209
210
  }
210
211
  const created = context.db.all("SELECT work_id, task, status, launch_policy, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
211
212
  const reviews = groups.map((group) => { const work = created.find((item) => item.task === group.task); if (!work)
212
- throw new AppError("runtime_missing", "Reviewer Work was not created", 1, { group: group.key }); const ready = work.status === "created" && isReviewWorkReady(context, work.work_id); return { group: group.key, work_id: work.work_id, status: work.status, depends_on: group.depends_on, ready, ...(ready ? { start_command: workStartCommand(context, { ...work, project_id: project.id, run_id: run.id }) } : {}) }; });
213
+ throw new AppError("runtime_missing", "Reviewer Work was not created", 1, { group: group.key }); const ready = work.status === "created" && isReviewWorkReady(context, work.work_id); return { group: group.key, work_id: work.work_id, status: work.status, depends_on: group.depends_on, ready, ...(ready ? { start_command: workStartCommand(context, { ...work, project_id: project.id, run_id: run.id }, { preview: true }) } : {}) }; });
213
214
  refreshRunWorkProjection(context, project.id, run.id);
214
215
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_dispatched", work_id: parent.work_id, groups: reviews.map((item) => ({ group: item.group, work_id: item.work_id })) });
215
216
  const waves = plannedReviewWaves(groups, capacity.availableSlots);
@@ -329,6 +330,7 @@ function finishPlanReviewOwned(context, input) {
329
330
  const reportGit = gitFacts(run.workspace_root);
330
331
  const next = codeSuccessor(context, run.id, projectRoot);
331
332
  const matrix = prepareRunVerificationMatrix(context, { projectId: project.id, runId: run.id, stage, role: "output", semanticFile: decisionPath });
333
+ const completionBundle = readFlowRunExecutionBundle(context, { projectRoot, runId: run.id });
332
334
  return withStageSettlement(context, () => {
333
335
  const registered = addWorkBatch(context, { parentWorkId: codeParentId, prepared: codeItems, stage: "code" });
334
336
  const now = context.now();
@@ -343,7 +345,7 @@ function finishPlanReviewOwned(context, input) {
343
345
  acceptStagePacket(context, { projectId: project.id, runId: run.id, stage, prepared: decisionReceipt });
344
346
  acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
345
347
  const completionInput = { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html", dataSha256: preparedReport.sha256, git: reportGit };
346
- completeFlowRunStage(context, completionInput, prepareFlowRunStageCompletion(context, completionInput));
348
+ completeFlowRunStage(context, completionInput, prepareFlowRunStageCompletion(context, completionInput, completionBundle));
347
349
  refreshRunWorkProjection(context, project.id, run.id);
348
350
  advanceFlowRun(context, { settlementStage: "plan-review", projectRoot, runId: run.id, status: "running", verdict: "plan_review_accepted", nextAction: "start_code" });
349
351
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "plan_review_accepted", work_id: parent.work_id, code: registered });
@@ -4,6 +4,7 @@ import crypto from "node:crypto";
4
4
  import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { parse as parseYaml } from "yaml";
7
8
  import { AppError } from "../shared/errors.js";
8
9
  import { projectFeatureWorktreePath, resolveProjectRoot } from "../storage/paths.js";
9
10
  import { previewNextEntityId } from "./ids.js";
@@ -189,6 +190,29 @@ export async function finishVnextProtocolize(context, input) {
189
190
  const resultFile = prepareProtocolizeResultPath(requireRunHome(run), input.resultFile);
190
191
  const result = input.result ?? readResult(resultFile, { projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
191
192
  validateSchema({ schemaName: "vnext-protocolize-result", file: resultFile, data: result, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireRunHome(run) });
193
+ const acceptedIndex = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, run.id]).index_json);
194
+ if (acceptedIndex.stage_runs.some(item => item.stage === stage && item.status === "done")) {
195
+ publishStageReport(context, project.id, run.id, stage, root);
196
+ const file = path.join(root, "stage-report.json");
197
+ if (!fs.existsSync(file))
198
+ throw new AppError("protocolize_report_missing", "Completed PROTOCOLIZE has no accepted report to replay", 1, { run_id: run.id });
199
+ const accepted = JSON.parse(fs.readFileSync(file, "utf8"));
200
+ const authoredSha = accepted.semantic.materialization?.authored_result?.sha256;
201
+ if (authoredSha && authoredSha !== sha256(fs.readFileSync(resultFile)))
202
+ throw new AppError("protocolize_intent_conflict", "Completed PROTOCOLIZE result differs from accepted input", 1, { run_id: run.id });
203
+ const currentStage = acceptedIndex.current_stage ?? [...acceptedIndex.stage_runs].reverse().find(item => item.status === "running" || item.status === "done")?.stage;
204
+ const activeReplay = currentStage === stage && acceptedIndex.status === "running";
205
+ const resolvedPolicy = activeReplay ? requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" }) : accepted.mechanical?.workspace_route;
206
+ if (activeReplay) {
207
+ if (acceptedIndex.verdict !== "protocolized")
208
+ advanceFlowRun(context, { settlementStage: "protocolize", projectRoot, runId: run.id, status: "running", verdict: "protocolized", nextAction: "start_plan" });
209
+ refreshRunWorkProjection(context, project.id, run.id);
210
+ }
211
+ const next = activeReplay ? stageSuccessor(context, { projectRoot, runId: run.id, stage: "plan", command: `${flowCommand(context)} stage start ${run.id} --stage plan --project-root ${JSON.stringify(projectRoot)} --json`, metadata: { cwd: run.workspace_root, session_mode: resolvedPolicy.stage_session_mode } }) : { kind: "complete", outcome: "already_completed" };
212
+ return { ok: true, outcome: "protocolized", run_id: run.id, protocol_ids: accepted.semantic.acceptance, workspace_root: run.workspace_root, git_policy: resolvedPolicy, next_action: activeReplay ? "await_plan" : acceptedIndex.next_action, ...("command" in next && next.command ? { next_command: next.command } : {}), next,
213
+ artifacts: { result: resultFile, report_json: file, report_markdown: path.join(root, "stage-report.md"), report_html: path.join(root, "stage-report.html"), protocols: accepted.semantic.acceptance.map(id => path.join(run.workspace_root, ".memory-bank/protocol", id, "summary.md")) } };
214
+ }
215
+ const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
192
216
  const acceptedObligations = acceptedSpecifyObligations(context, projectRoot, run.id, path.join(requireRunHome(run), "01-specify", "specify.json"));
193
217
  let obligations;
194
218
  try {
@@ -202,11 +226,11 @@ export async function finishVnextProtocolize(context, input) {
202
226
  throw error;
203
227
  throw new AppError(error.code, error.message, error.exitCode, { ...error.details, phase: "prepare", effect: "no_effect", recoverable: true });
204
228
  }
205
- const resolvedPolicy = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, stage: "protocolize" });
206
229
  const receipt = readWorkspaceRouteReceipt(root);
207
230
  const workspaceRoot = run.workspace_root;
208
231
  const intent = await prepareProtocolizeIntent(context, run, resultFile, result, obligations);
209
232
  publishProtocolizeIntent(context, project.id, intent);
233
+ const materialization = verifyProtocolizeMaterialization(intent, result, path.relative(requireRunHome(run), resultFile));
210
234
  const protocolIds = intent.protocol_ids;
211
235
  const changedFiles = intent.changed_files;
212
236
  for (const protocolId of protocolIds)
@@ -216,9 +240,10 @@ export async function finishVnextProtocolize(context, input) {
216
240
  ensureManagedWorktreeRecorded(context, { protocolId, projectRoot, branch: requiredPolicyValue(resolvedPolicy.feature_branch, "feature branch"), base: requiredPolicyValue(resolvedPolicy.base_ref, "base ref"), created: receipt.created, bootstrap: receipt.bootstrap });
217
241
  }
218
242
  const now = context.now();
219
- const workSession = completeWorkSession(context, work.work_id, resultFile, now);
243
+ const workSession = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
220
244
  const routedRun = requireRun(context, projectRoot, run.id);
221
245
  const report = stageReport(context, routedRun, resultFile, protocolIds, changedFiles, workSession?.session_id ?? null, now, resolvedPolicy);
246
+ Object.assign(report.semantic, { materialization });
222
247
  const reportJson = path.join(root, "stage-report.json");
223
248
  const reportMarkdown = path.join(root, "stage-report.md");
224
249
  const reportHtml = path.join(root, "stage-report.html");
@@ -235,6 +260,7 @@ export async function finishVnextProtocolize(context, input) {
235
260
  const completion = { projectRoot, runId: run.id, stage, status: "done", data: "protocolize-result.json", dataSchemaId: "dd-flow/vnext-protocolize-result@3", report: "stage-report.md", stageReport: "stage-report.html", dataSha256: crypto.createHash("sha256").update(fs.readFileSync(resultFile)).digest("hex") };
236
261
  const preparedCompletion = prepareFlowRunStageCompletion(context, completion);
237
262
  withStageSettlement(context, () => {
263
+ completeWorkSession(context, work.work_id, resultFile, now);
238
264
  acceptStageReport(context, { projectId: project.id, runId: run.id, stage, root, prepared: preparedReport });
239
265
  completeFlowRunStage(context, completion, preparedCompletion);
240
266
  });
@@ -367,6 +393,38 @@ function ensureManagedWorktreeRecorded(context, input) {
367
393
  recordManagedWorktree(context, input);
368
394
  }
369
395
  function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
396
+ export function verifyProtocolizeMaterialization(intent, result, authoredPath) {
397
+ const documents = intent.files.map(file => {
398
+ const target = path.resolve(intent.workspace_root, file.relative);
399
+ inside(path.join(intent.workspace_root, ".memory-bank"), target);
400
+ assertNoSymlinkPath(path.join(intent.workspace_root, ".memory-bank"), target);
401
+ const bytes = fs.readFileSync(target);
402
+ if (sha256(bytes) !== file.after_sha256)
403
+ throw new AppError("protocolize_materialization_invalid", "Published document differs from accepted intent", 1, { path: file.relative });
404
+ return { path: file.relative, sha256: file.after_sha256 };
405
+ });
406
+ const featurePaths = intent.changed_files.filter(file => /\/features\/FT-[^/]+\/index\.md$/.test(file));
407
+ const featurePath = result.feature.action === "not_applicable" ? null : featurePaths.length === 1 ? featurePaths[0] : null;
408
+ if (result.feature.action !== "not_applicable" && !featurePath)
409
+ throw new AppError("protocolize_materialization_invalid", "Accepted feature output is ambiguous or missing", 1);
410
+ const frontmatter = (relative) => {
411
+ const text = fs.readFileSync(path.join(intent.workspace_root, relative), "utf8");
412
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
413
+ const value = match ? parseYaml(match[1]) : null;
414
+ if (!value || typeof value !== "object" || Array.isArray(value))
415
+ throw new AppError("protocolize_materialization_invalid", "Published document lacks frontmatter", 1, { path: relative });
416
+ return value;
417
+ };
418
+ const edges = featurePath ? intent.protocol_ids.map(protocolId => ({ feature_path: featurePath, protocol_id: protocolId, protocol_path: `.memory-bank/protocol/${protocolId}/summary.md` })) : [];
419
+ for (const edge of edges) {
420
+ const protocols = frontmatter(edge.feature_path).related_protocols;
421
+ const features = frontmatter(edge.protocol_path).related_features;
422
+ if (!Array.isArray(protocols) || !protocols.includes(edge.protocol_id) || !Array.isArray(features) || !features.includes(edge.feature_path))
423
+ throw new AppError("protocolize_materialization_invalid", "Owned feature/protocol links are not reciprocal", 1, edge);
424
+ }
425
+ return { schema_id: "dd-flow/protocolize-materialization@1", run_id: intent.run_id, stage: "protocolize", action: result.feature.action, feature_path: featurePath,
426
+ protocol_ids: intent.protocol_ids, authored_result: { role: "authored_input", run_relative_path: authoredPath, sha256: intent.result_sha256 }, documents, owned_links: edges, status: "passed" };
427
+ }
370
428
  async function materialize(projectRoot, runId, runHome, result, obligations, ids, psetId) {
371
429
  const psetPath = psetId ? path.join(projectRoot, ".memory-bank", "protocol", "_set", `${psetId}.md`) : null;
372
430
  const created = [];