@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79

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 (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
@@ -10,16 +10,17 @@ import { resolveProjectRoot } from "../storage/paths.js";
10
10
  import { requireProjectByRoot } from "./projects.js";
11
11
  import { nextMergeRequestId } from "./ids.js";
12
12
  import { readProjectConfig } from "./config.js";
13
- import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
13
+ import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareFlowRunStageAttachment, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
14
14
  import { flowCommand, stagePauseCommandTemplate } from "./stage-pause.js";
15
- import { validateSchema } from "./schema-validation.js";
16
15
  import { writeStageReport } from "./stage-report-renderer.js";
17
16
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
18
17
  import { addVnextCodeRepair } from "./vnext-code.js";
19
18
  import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
20
- import { applyExternalStageContext } from "./stage-context.js";
21
- import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
19
+ import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
20
+ import { ensureLaneWorkspace, prepareLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
22
21
  import { assertRunMutationAllowed } from "./run-recovery.js";
22
+ import { assertStageStartHookEvent } from "./hooks.js";
23
+ import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
23
24
  const stage = "merge";
24
25
  const stageDir = vnextStageDirectory(stage);
25
26
  export function isVnextMergeRun(context, input) {
@@ -108,20 +109,43 @@ export function prepareVnextMergeRequest(context, input) {
108
109
  return requestView(context, requireRequest(context, requestId));
109
110
  });
110
111
  }
111
- export async function startVnextMerge(context, input) {
112
+ export function prepareVnextMergeStart(context, input) {
112
113
  const projectRoot = resolveProjectRoot(input.projectRoot);
113
114
  const project = requireProjectByRoot(context, projectRoot);
114
115
  const run = requireRun(context, project.id, input.runId);
115
116
  const home = requireHome(run);
116
117
  const request = requestForRun(context, project.id, run.id);
117
118
  const generation = assertRunMutationAllowed(context, project.id, run.id);
119
+ const common = { projectRoot, project, run, request, generation };
118
120
  if (request.status === "completed")
121
+ return { ...common, terminal: true };
122
+ if (!["queued", "dispatching", "active", "action_required"].includes(request.status))
123
+ throw new AppError("merge_request_state_invalid", "MERGE request cannot be started in its current state", 1, { status: request.status, merge_request_id: request.merge_request_id });
124
+ const root = path.join(home, stageDir), workRoot = path.join(root, "works", request.executor_work_id);
125
+ const resultPath = path.join(workRoot, "result.json"), promptPath = path.join(workRoot, "prompt.md");
126
+ const work = context.db.get("SELECT status, project_id, run_id FROM works WHERE work_id = ?", [request.executor_work_id]);
127
+ if (!work || work.project_id !== project.id || work.run_id !== run.id || !["created", "running"].includes(work.status))
128
+ throw new AppError("runtime_artifact_invalid", "MERGE executor Work is missing or not startable", 1, { work_id: request.executor_work_id });
129
+ const index = JSON.parse(run.index_json);
130
+ if (!index.stage_runs?.some(entry => entry.stage === stage))
131
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
132
+ prepareLaneWorkspace(context, { projectRoot, lane: "merge", workspacePath: request.target_workspace, branch: request.target_branch });
133
+ const checks = effectiveMergeChecks(run, request), settings = executionSettings(run);
134
+ const existingPrompt = ["active", "action_required"].includes(request.status) && fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : null;
135
+ if (existingPrompt === null || input.externalContext)
136
+ prepareOutputFile(promptPath);
137
+ if (input.externalContext)
138
+ prepareExternalStageContext({ stageRoot: root, loaded: input.externalContext });
139
+ return { ...common, terminal: false, root, workRoot, resultPath, promptPath, checks, settings, existingPrompt };
140
+ }
141
+ export async function startVnextMerge(context, input, prepared = prepareVnextMergeStart(context, input)) {
142
+ const { projectRoot, project, run, request, generation } = prepared;
143
+ if (prepared.terminal)
119
144
  return requestView(context, request);
120
- const root = path.join(home, stageDir);
121
- const workRoot = path.join(root, "works", request.executor_work_id);
145
+ const { root, workRoot, resultPath, promptPath, checks, settings, existingPrompt } = prepared;
146
+ assertRunMutationAllowed(context, project.id, run.id, generation);
147
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
122
148
  fs.mkdirSync(workRoot, { recursive: true });
123
- const resultPath = path.join(workRoot, "result.json");
124
- const promptPath = path.join(workRoot, "prompt.md");
125
149
  ensureLaneWorkspace(context, { projectRoot, lane: "merge", workspacePath: request.target_workspace, branch: request.target_branch });
126
150
  const lane = await waitAcquireLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, timeoutSeconds: 3600, pollIntervalSeconds: 15, ttlSeconds: 7200, reason: `MERGE ${request.merge_request_id}`, ...(input.progress ? { progress: input.progress } : {}) });
127
151
  if (!lane.acquired)
@@ -133,12 +157,12 @@ export async function startVnextMerge(context, input) {
133
157
  const binding = pendingWork?.status === "created"
134
158
  ? startStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) })
135
159
  : bindStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
136
- const prompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : mergePrompt(context, { run, request, resultPath, projectRoot });
137
- if (!fs.existsSync(promptPath))
160
+ const prompt = existingPrompt ?? mergePrompt(context, { run, request, resultPath, projectRoot }, checks, settings);
161
+ if (existingPrompt === null)
138
162
  fs.writeFileSync(promptPath, prompt);
139
163
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
140
164
  ensureMergeStageAttached(context, run, projectRoot);
141
- return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext);
165
+ return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, checks, externalContext);
142
166
  }
143
167
  const current = requireRequest(context, request.merge_request_id);
144
168
  const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
@@ -152,13 +176,13 @@ export async function startVnextMerge(context, input) {
152
176
  if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
153
177
  throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
154
178
  const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
155
- const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
179
+ const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot }, checks, settings);
156
180
  fs.writeFileSync(promptPath, prompt);
157
181
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
158
182
  context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [promptPath, resultPath, context.now(), current.executor_work_id]);
159
183
  attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
160
184
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_started", merge_request_id: current.merge_request_id, work_id: current.executor_work_id, target_head: targetHead });
161
- return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
185
+ return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, checks, externalContext);
162
186
  }
163
187
  catch (error) {
164
188
  const latest = requireRequest(context, request.merge_request_id);
@@ -172,8 +196,8 @@ export async function startVnextMerge(context, input) {
172
196
  throw error;
173
197
  }
174
198
  }
175
- function startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext) {
176
- return { ok: true, run_id: run.id, stage, merge_request_id: request.merge_request_id, work_id: request.executor_work_id, session_binding: binding.session_binding ?? binding, prompt_path: promptPath, result_path: resultPath, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), queue: queueStatus(context, request), effective_checks: effectiveMergeChecks(run, request), next: { apply_command: applyCommand(context, request, projectRoot), finish_command: finishCommand(context, run.id, request, projectRoot) } };
199
+ function startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, checks, externalContext) {
200
+ return { ok: true, run_id: run.id, stage, merge_request_id: request.merge_request_id, work_id: request.executor_work_id, session_binding: binding.session_binding ?? binding, prompt_path: promptPath, result_path: resultPath, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), queue: queueStatus(context, request), effective_checks: checks, next: { apply_command: applyCommand(context, request, projectRoot), finish_command: finishCommand(context, run.id, request, projectRoot) } };
177
201
  }
178
202
  export function applyVnextMerge(context, input) {
179
203
  const existing = requireRequest(context, input.requestId);
@@ -207,7 +231,7 @@ export function applyVnextMerge(context, input) {
207
231
  context.db.run("UPDATE merge_requests SET checkpoint = 'apply_recorded', result_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ apply_receipt: receiptFile }), context.now(), request.merge_request_id]);
208
232
  return { ok: true, ...receipt };
209
233
  }
210
- export async function finishVnextMerge(context, input) {
234
+ export function prepareVnextMergeFinish(context, input) {
211
235
  const projectRoot = resolveProjectRoot(input.projectRoot);
212
236
  const project = requireProjectByRoot(context, projectRoot);
213
237
  const run = requireRun(context, project.id, input.runId);
@@ -215,24 +239,35 @@ export async function finishVnextMerge(context, input) {
215
239
  const generation = assertRunMutationAllowed(context, project.id, run.id);
216
240
  if (existing.project_id !== project.id || existing.run_id !== run.id || existing.executor_work_id !== input.workId)
217
241
  throw new AppError("merge_request_mismatch", "MRG/Work/RUN/project identity does not match", 2);
218
- if (existing.status === "completed") {
219
- recoverCompletedMergeRun(context, run, projectRoot, existing);
220
- releaseMergeLane(context, projectRoot, existing, `MERGE ${existing.merge_request_id} completion recovered`);
221
- return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
222
- }
223
- let request = requireOwnedActiveRequest(context, input);
224
- heartbeatLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
242
+ if (existing.status === "completed")
243
+ return { projectRoot, project, run, existing, generation, terminal: true };
244
+ const request = requireOwnedActiveRequest(context, input);
225
245
  if (request.run_id !== run.id)
226
246
  throw new AppError("merge_request_mismatch", "MRG does not belong to RUN", 2);
227
247
  if (request.checkpoint === "baseline_locked" || !fs.existsSync(applyReceiptPath(context, request)))
228
248
  throw new AppError("merge_apply_required", "Run the exact merge apply command before MERGE finish", 2, { merge_request_id: request.merge_request_id, checkpoint: request.checkpoint });
229
- if (unmerged(request.target_workspace).length)
230
- throw new AppError("merge_conflicts_unresolved", "Resolve every unmerged path before finishing MERGE", 2, { paths: unmerged(request.target_workspace) });
249
+ const conflicts = unmerged(request.target_workspace);
250
+ if (conflicts.length)
251
+ throw new AppError("merge_conflicts_unresolved", "Resolve every unmerged path before finishing MERGE", 2, { paths: conflicts });
231
252
  const resultPath = path.join(requireHome(run), stageDir, "works", request.executor_work_id, "result.json");
232
- validateSchema({ schemaName: "merge-result", file: resultPath, projectRoot: request.target_workspace, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireHome(run) });
233
- const semantic = JSON.parse(fs.readFileSync(resultPath, "utf8"));
234
- if (semantic.outcome !== "completed")
253
+ const result = prepareJsonSchema(resultPath, "MERGE result", { schemaName: "merge-result", projectRoot: request.target_workspace, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireHome(run) });
254
+ if (result.value.outcome !== "completed")
235
255
  throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
256
+ const gate = readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id);
257
+ if (input.retryCheckId)
258
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: gate.checks, reason: input.retryReason });
259
+ return { projectRoot, project, run, existing, generation, terminal: false, request, resultPath, result, gate };
260
+ }
261
+ export async function finishVnextMerge(context, input, prepared = prepareVnextMergeFinish(context, input)) {
262
+ const { projectRoot, project, run, existing, generation } = prepared;
263
+ if (prepared.terminal) {
264
+ recoverCompletedMergeRun(context, run, projectRoot, existing);
265
+ releaseMergeLane(context, projectRoot, existing, `MERGE ${existing.merge_request_id} completion recovered`);
266
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
267
+ }
268
+ let request = prepared.request;
269
+ heartbeatLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
270
+ const semantic = prepared.result.value;
236
271
  if (request.checkpoint === "apply_recorded") {
237
272
  input.progress?.("bootstrapping integrated target");
238
273
  await runBootstrap(context, run, request.target_workspace, request.merge_request_id, input.progress);
@@ -240,11 +275,9 @@ export async function finishVnextMerge(context, input) {
240
275
  context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
241
276
  request = requireRequest(context, request.merge_request_id);
242
277
  }
243
- const gate = readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id), checks = gate.checks;
244
- if (input.retryCheckId) {
245
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
278
+ const gate = prepared.gate, checks = gate.checks;
279
+ if (input.retryCheckId)
246
280
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
247
- }
248
281
  const acceptancePath = path.join(requireHome(run), stageDir, "merge-gate-acceptance.json");
249
282
  const recovered = readMergeGateAcceptance(context, acceptancePath, request, gate);
250
283
  const resumingGate = ["checks_passed", "integration_committed", "delivered"].includes(request.checkpoint);
@@ -285,7 +318,7 @@ export async function finishVnextMerge(context, input) {
285
318
  context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
286
319
  request = requireRequest(context, request.merge_request_id);
287
320
  performConfiguredCleanup(run, request);
288
- const acceptedResult = fs.readFileSync(resultPath, "utf8");
321
+ const acceptedResult = prepared.result.text;
289
322
  const executor = context.db.get("SELECT status, result FROM works WHERE work_id = ?", [request.executor_work_id]);
290
323
  if (!executor)
291
324
  return recovery(context, request, "merge_executor_missing", { work_id: request.executor_work_id });
@@ -396,7 +429,7 @@ export async function repairVnextMerge(context, input) {
396
429
  appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
397
430
  return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`) } };
398
431
  }
399
- function mergePrompt(context, input) { const pause = managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`); const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
432
+ function mergePrompt(context, input, checks, settings) { const pause = managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`); const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(settings.merge_delivery)}`, `- cleanup: ${JSON.stringify(settings.merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...checks.map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
400
433
  function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
401
434
  function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
402
435
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
@@ -1,13 +1,13 @@
1
1
  import { managedLifecycleCommand } from "./lifecycle-invocations.js";
2
2
  import crypto from "node:crypto";
3
- import { withStageSettlement } from "./runs.js";
3
+ import { withStageSettlement, prepareFlowRunStageAttachment } 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";
7
7
  import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, getFlowRunVariables, gitFacts, setRuntimeRunVariable } from "./runs.js";
10
- import { addWorkBatch, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, workStartCommand } from "./work-registry.js";
10
+ import { addWorkBatch, prepareWorkBatch, bindStageCoordinatorWork, ensureWorkRegistry, refreshRunWorkProjection, workStartCommand } from "./work-registry.js";
11
11
  import { validateSchema } from "./schema-validation.js";
12
12
  import { flowCommand, stagePauseCommand, stagePauseCommandTemplate } from "./stage-pause.js";
13
13
  import { assertStageStartHookEvent } from "./hooks.js";
@@ -17,7 +17,8 @@ import { nextWorkId } from "./ids.js";
17
17
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
18
18
  import { writeStageReport } from "./stage-report-renderer.js";
19
19
  import { assertPortableArtifactRef } from "./portable-refs.js";
20
- import { applyExternalStageContext } from "./stage-context.js";
20
+ import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
21
+ import { prepareJsonSchema, prepareOutputFile } from "../cli/input-preparation.js";
21
22
  import { getStageFanoutCapacity, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
22
23
  const stage = "plan-review";
23
24
  const stageDir = vnextStageDirectory(stage);
@@ -26,54 +27,95 @@ export function isVnextPlanReviewRun(context, input) {
26
27
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
27
28
  return Boolean(context.db.get("SELECT id FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
28
29
  }
29
- export function startVnextPlanReview(context, input) {
30
- ensureWorkRegistry(context);
30
+ export function prepareVnextPlanReviewStart(context, input) {
31
31
  const projectRoot = resolveProjectRoot(input.projectRoot);
32
32
  const project = requireProjectByRoot(context, projectRoot);
33
33
  const run = requireRun(context, projectRoot, input.runId);
34
34
  const home = requireHome(run);
35
- assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
36
- const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan-review" });
37
35
  const planRoot = path.join(home, "03-plan");
38
36
  const root = path.join(home, stageDir);
39
37
  const batch = path.join(planRoot, "code-work-batch.json");
38
+ const common = { projectRoot, project, run, home, planRoot, root, batch };
39
+ const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
40
+ if (priorStage?.status === "done") {
41
+ const report = readJson(path.join(root, "stage-report.json"));
42
+ const outcome = report.semantic?.plan_review?.outcome;
43
+ if (outcome === "off" || outcome === "accepted")
44
+ return { ...common, state: "terminal", report, outcome };
45
+ throw new AppError("invalid_work_state", "PLAN-REVIEW is already terminal; corrections are applied in the same review Work and are not re-reviewed automatically", 2, { run_id: run.id, outcome });
46
+ }
47
+ const workspaceRoute = requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage: "plan-review" });
40
48
  if (!fs.existsSync(path.join(planRoot, "stage-report.json")) || !fs.existsSync(batch))
41
- throw new AppError("not_found", "PLAN-REVIEW requires the accepted PLAN report and proposed CODE batch", 1);
49
+ throw new AppError("runtime_artifact_missing", "PLAN-REVIEW requires the accepted PLAN report and proposed CODE batch", 1);
42
50
  const requested = requestedMode(run);
43
51
  const protocolIds = protocolIdsForReview(home);
44
52
  assertReviewWorkspace(run.workspace_root, protocolIds);
45
53
  const groups = reviewGroups(home, run.workspace_root);
46
54
  const effective = resolveMode(requested, groups, home, run.workspace_root);
47
55
  const existing = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND task = ? ORDER BY created_at DESC LIMIT 1", [project.id, run.id, reviewTask]);
56
+ const batchChecksum = checksum(batch), planChecksum = planSetChecksum(home, run.workspace_root);
57
+ if (input.externalContext)
58
+ prepareExternalStageContext({ stageRoot: root, loaded: input.externalContext });
59
+ prepareOutputFile(path.join(root, "stage-prompt.md"));
60
+ const active = { ...common, requested, protocolIds, groups, effective, workspaceRoute, batchChecksum, planChecksum };
48
61
  if (existing?.status === "running") {
49
- const promptPath = path.join(root, "stage-prompt.md");
50
- const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
51
- return { ...preparedExisting(context, { projectRoot, projectId: project.id, run, root, workId: existing.work_id, hookEventId: input.hookEventId, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}), requested, effective, groups, batchChecksum: checksum(batch), planChecksum: planSetChecksum(home, run.workspace_root) }), ...(externalContext ? { external_context: externalContext } : {}) };
52
- }
53
- const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
54
- if (priorStage?.status === "done") {
55
- const report = readJson(path.join(root, "stage-report.json"));
56
- const outcome = report.semantic?.plan_review?.outcome;
57
- if (outcome === "off" || outcome === "accepted")
58
- return { ok: true, run_id: run.id, stage, outcome: outcome === "off" ? "review_off" : "accepted", ...(report.semantic?.code ? { code: report.semantic.code } : {}), next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) }, idempotent: true };
59
- throw new AppError("invalid_work_state", "PLAN-REVIEW is already terminal; corrections are applied in the same review Work and are not re-reviewed automatically", 2, { run_id: run.id, outcome });
62
+ const orchestration = readFanoutDescriptor(root);
63
+ if (!orchestration)
64
+ prepareOutputFile(path.join(root, "fanout.json"));
65
+ return { ...active, state: "resumed", workId: existing.work_id, prompt: fs.readFileSync(path.join(root, "stage-prompt.md"), "utf8"), orchestration };
60
66
  }
61
- fs.mkdirSync(root, { recursive: true });
62
- const now = context.now();
63
- const batchChecksum = checksum(batch);
64
- const planChecksum = planSetChecksum(home, run.workspace_root);
67
+ prepareFlowRunStageAttachment(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
68
+ const rootWork = rootWorkId(context, project.id, run.id);
65
69
  if (effective === "off") {
66
70
  const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIds });
67
71
  if (failures.length)
68
- throw new AppError("validation", "PLAN artifacts have validation errors", 2, { errors: failures });
69
- const rootWork = rootWorkId(context, project.id, run.id);
72
+ throw new AppError("runtime_artifact_invalid", "Accepted PLAN artifacts have validation errors", 1, { errors: failures });
73
+ // This is retained runtime data, not a newly supplied --file argument.
74
+ const codeBatch = readJson(batch);
75
+ let codeItems;
76
+ try {
77
+ codeItems = prepareWorkBatch(context, { parentWorkId: rootWork, value: codeBatch });
78
+ }
79
+ catch (error) {
80
+ if (error instanceof AppError && error.exitCode === 2)
81
+ throw new AppError("runtime_artifact_invalid", "Accepted CODE batch cannot be registered", 1, { file: batch, cause_code: error.code });
82
+ throw error;
83
+ }
84
+ for (const name of ["stage-report.json", "stage-report.md", "stage-report.html"])
85
+ prepareOutputFile(path.join(root, name));
86
+ return { ...active, state: "off", rootWork, codeItems };
87
+ }
88
+ const template = read(path.join(projectRoot, ".memory-bank", "dd-flow", "vnext", "plan-review.md"));
89
+ const revision = currentPlanRevision(home, run.workspace_root);
90
+ const capacity = getStageFanoutCapacity(context, { projectRoot, runId: run.id, stage });
91
+ for (const name of ["work-context.json", "fanout.json"])
92
+ prepareOutputFile(path.join(root, name));
93
+ return { ...active, state: "fresh", rootWork, template, revision, capacity };
94
+ }
95
+ export function startVnextPlanReview(context, input, prepared = prepareVnextPlanReviewStart(context, input)) {
96
+ const { projectRoot, project, run, home, root, planRoot } = prepared;
97
+ assertStageStartHookEvent(context, { projectId: project.id, eventKey: input.hookEventId, runId: run.id, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
98
+ if (prepared.state === "terminal") {
99
+ const { report, outcome } = prepared;
100
+ return { ok: true, run_id: run.id, stage, outcome: outcome === "off" ? "review_off" : "accepted", ...(report.semantic?.code ? { code: report.semantic.code } : {}), next: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) }, idempotent: true };
101
+ }
102
+ const { requested, effective, groups, batchChecksum, planChecksum, workspaceRoute } = prepared;
103
+ if (prepared.state === "resumed") {
104
+ const promptPath = path.join(root, "stage-prompt.md");
105
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
106
+ return { ...preparedExisting(context, { projectRoot, projectId: project.id, run, root, workId: prepared.workId, hookEventId: input.hookEventId, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}), requested, effective, groups, batchChecksum, planChecksum, orchestration: prepared.orchestration }), ...(externalContext ? { external_context: externalContext } : {}) };
107
+ }
108
+ ensureWorkRegistry(context);
109
+ fs.mkdirSync(root, { recursive: true });
110
+ const now = context.now(), rootWork = prepared.rootWork;
111
+ if (prepared.state === "off") {
70
112
  const promptPath = path.join(root, "stage-prompt.md");
71
113
  fs.writeFileSync(promptPath, "PLAN-REVIEW is disabled by the frozen RUN configuration.\n");
72
114
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
73
115
  bindStageCoordinatorWork(context, { workId: rootWork, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
74
116
  attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/plan-review-decision@3" });
75
117
  return withStageSettlement(context, () => {
76
- const registered = registerCode(context, project.id, run.id, batch);
118
+ const registered = addWorkBatch(context, { parentWorkId: rootWork, prepared: prepared.codeItems });
77
119
  const report = reportFor(context, { run, mode: effective, requested, outcome: "off", groups: [], batchChecksum, planChecksum, code: registered, now, projectRoot, flow: flowCommand(context) });
78
120
  writeReport(root, report);
79
121
  completeFlowRunStage(context, { 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" });
@@ -82,11 +124,10 @@ export function startVnextPlanReview(context, input) {
82
124
  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: { kind: "start_stage", stage: "code", command: codeCommand(context, run.id, projectRoot) } };
83
125
  });
84
126
  }
85
- const rootWork = rootWorkId(context, project.id, run.id);
86
127
  const workId = nextWorkId(context, project.id, "plan-review");
87
128
  context.db.run("INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, depends_on_json, status, result, started_at, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, 'reuse_allowed', NULL, '[]', 'running', NULL, ?, ?, ?, NULL)", [workId, project.id, run.id, rootWork, reviewTask, now, now, now]);
88
129
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId, projectRoot });
89
- const prompt = orchestratorPrompt(context, { projectRoot, run, home, root, workId, requested, effective, groups, batchChecksum, planChecksum, workspaceRoute, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand) });
130
+ const prompt = orchestratorPrompt(context, { projectRoot, run, home, root, workId, requested, effective, groups, batchChecksum, planChecksum, workspaceRoute, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), template: prepared.template, revision: prepared.revision, capacity: prepared.capacity });
90
131
  const promptPath = path.join(root, "stage-prompt.md");
91
132
  fs.writeFileSync(promptPath, prompt);
92
133
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
@@ -104,7 +145,7 @@ export function startVnextPlanReview(context, input) {
104
145
  const workSessionId = String(binding.work_session_id);
105
146
  const sessionId = String(binding.session_id);
106
147
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true });
107
- writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: currentPlanRevision(home, run.workspace_root), plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
148
+ writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: prepared.revision, plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
108
149
  refreshRunWorkProjection(context, project.id, run.id);
109
150
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
110
151
  return { ok: true, run_id: run.id, stage, outcome: "review_required", work_id: workId, id: workSessionId, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), groups }, next: { dispatch_command: dispatchCommand(context, run.id, projectRoot), finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
@@ -187,7 +228,8 @@ function finishPlanReviewOwned(context, input) {
187
228
  if (!parent || parent.status !== "running")
188
229
  throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
189
230
  const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
190
- const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
231
+ const decision = input.decision ?? readDecision(context, projectRoot, run.id, home, decisionPath);
232
+ validateSchema({ schemaName: "plan-review-decision", file: decisionPath, data: decision, projectRoot, runId: run.id, runRoot: home, ddFlowHome: context.ddFlowHome });
191
233
  const children = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ?", [project.id, run.id, parent.work_id]);
192
234
  const contextFile = readJson(path.join(root, "work-context.json"));
193
235
  const groups = contextFile.groups ?? [];
@@ -200,7 +242,7 @@ function finishPlanReviewOwned(context, input) {
200
242
  throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
201
243
  }
202
244
  if (["failed", "cancelled"].includes(decision.outcome)) {
203
- return withStageSettlement(context, () => { preserveDecisionReceipt(root, decisionPath); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
245
+ return withStageSettlement(context, () => { preserveDecisionReceipt(root, decision, input.decisionText); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
204
246
  }
205
247
  if (children.some((child) => child.status === "created" || child.status === "running"))
206
248
  throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
@@ -247,7 +289,7 @@ function finishPlanReviewOwned(context, input) {
247
289
  const finalBatchChecksum = prepared.batchChecksum;
248
290
  return withStageSettlement(context, () => {
249
291
  prepared.publish();
250
- preserveDecisionReceipt(root, decisionPath);
292
+ preserveDecisionReceipt(root, decision, input.decisionText);
251
293
  const registered = registerCode(context, project.id, run.id, batch);
252
294
  const now = context.now();
253
295
  settleReviewParent(context, { projectRoot, projectId: project.id, runId: run.id, parentWorkId: parent.work_id, status: "completed", result: decision.summary, reason: "plan_review_completed", resultPath: path.join(root, "stage-report.json") });
@@ -264,15 +306,13 @@ function finishPlanReviewOwned(context, input) {
264
306
  function preparedExisting(context, input) {
265
307
  const promptPath = path.join(input.root, "stage-prompt.md");
266
308
  const binding = bindStageCoordinatorWork(context, { workId: input.workId, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(input.root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
267
- const orchestration = readFanoutDescriptor(input.root) ?? writeFanoutDescriptor(input.root, { stage, parent_work_id: input.workId, dispatch: "plan-review", capacity_required: true });
309
+ const orchestration = input.orchestration ?? writeFanoutDescriptor(input.root, { stage, parent_work_id: input.workId, dispatch: "plan-review", capacity_required: true });
268
310
  return { ok: true, resumed: true, run_id: input.run.id, stage, outcome: "review_required", work_id: input.workId, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), orchestration, plan_review: { requested_mode: input.requested, effective_mode: input.effective, policy_source: policySource(input.run), groups: input.groups }, next: { dispatch_command: dispatchCommand(context, input.run.id, input.projectRoot), finish_command: finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "decision.json")) } };
269
311
  }
270
312
  function orchestratorPrompt(context, input) {
271
- const template = read(path.join(input.projectRoot, ".memory-bank", "dd-flow", "vnext", "plan-review.md"));
313
+ const { template, revision, capacity } = input;
272
314
  const decision = path.join(input.root, "decision.json");
273
- const revision = currentPlanRevision(input.home, input.run.workspace_root);
274
315
  const workspaceContract = ["<workspace_contract>", `- route: ${input.workspaceRoute.route}`, `- feature branch: ${input.workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${input.workspaceRoute.base_ref ?? "not applicable"}`, `- read/write workspace: ${input.run.workspace_root}`, "The CLI verified this frozen route. All plan and correction writes belong in the named workspace; project root remains only the stable lifecycle identity. Do not create, switch, merge or delete branches/worktrees.", "</workspace_contract>"].join("\n");
275
- const capacity = getStageFanoutCapacity(context, { projectRoot: input.projectRoot, runId: input.run.id, stage });
276
316
  const reviewerLaunch = capacity.source === "external_policy"
277
317
  ? `After dispatch, return at the Work-graph boundary. The shared runtime launches the queued reviewer Works as separate external Sessions, at most ${capacity.available_slots} in parallel under the RUN's frozen profiles. Do not launch native children, create provider roots, run a capacity probe, or record this external limit as native capacity. Reviewers are read-only leaf workers. Continue the semantic decision only after their Work receipts settle; do not substitute a missing result or relaunch a settled reviewer.`
278
318
  : "After dispatch, stop at the Work-graph boundary. The shared controller supplies the selected adapter's native tool, exact parameters, child task, and wait contract; do not choose a provider tool from this stage prompt or substitute a shell command. It launches at most the qualified capacity at once and starts unchanged queued Works only after the current wave settles. A launch rejected before it starts is not review evidence: do not create a replacement. Each reviewer must be a genuinely fresh harness child Session; the lifecycle adapter binds that observed Session, so do not bind or supply a Session ID manually. Reviewers are read-only and must not create children. As soon as a reviewer result is accepted, release that reviewer Session when the harness permits.";
@@ -393,10 +433,13 @@ catch {
393
433
  function rootWorkId(context, projectId, runId) { const root = context.db.get("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!root)
394
434
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
395
435
  function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
396
- function readDecision(context, projectRoot, runId, runRoot, file) { const data = readJson(file); validateSchema({ schemaName: "plan-review-decision", file, data, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return data; }
397
- function preserveDecisionReceipt(root, source) {
436
+ function readDecision(context, projectRoot, runId, runRoot, file) { return prepareJsonSchema(file, "PLAN-REVIEW decision", { schemaName: "plan-review-decision", projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }).value; }
437
+ function preserveDecisionReceipt(root, decision, preparedText) {
398
438
  const receipt = path.join(root, "decision.receipt.json");
399
- const bytes = fs.readFileSync(source);
439
+ // The CLI has already parsed and validated this decision during prepare.
440
+ // Persist that immutable value rather than re-opening a caller-controlled
441
+ // filename after lifecycle admission.
442
+ const bytes = Buffer.from(preparedText ?? `${JSON.stringify(decision, null, 2)}\n`);
400
443
  if (fs.existsSync(receipt)) {
401
444
  if (!bytes.equals(fs.readFileSync(receipt)))
402
445
  throw new AppError("decision_receipt_conflict", "The immutable PLAN-REVIEW decision receipt already contains a different payload", 2, { receipt });
@@ -516,12 +559,12 @@ function requireRun(context, projectRoot, runId) { const project = requireProjec
516
559
  function requireHome(run) { if (!run.run_root)
517
560
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
518
561
  function read(file) { if (!fs.existsSync(file))
519
- throw new AppError("not_found", "Required vNext prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
562
+ throw new AppError("runtime_artifact_missing", "Required vNext prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
520
563
  function readJson(file) { try {
521
564
  return JSON.parse(fs.readFileSync(file, "utf8"));
522
565
  }
523
566
  catch {
524
- throw new AppError("validation", "Expected valid JSON artifact", 2, { file });
567
+ throw new AppError("runtime_artifact_invalid", "Expected valid retained JSON artifact", 1, { file });
525
568
  } }
526
569
  function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
527
570
  function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }