@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.8

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 (41) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/build-info.json +5 -5
  3. package/dist/cli/help.js +3 -3
  4. package/dist/cli/run-cli.js +127 -8
  5. package/dist/runtime/context.js +3 -1
  6. package/dist/schemas/code-work-batch.schema.json +3 -3
  7. package/dist/schemas/harness-config.schema.json +23 -0
  8. package/dist/schemas/plan-review-decision.schema.json +1 -1
  9. package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
  10. package/dist/services/cleanup.js +18 -8
  11. package/dist/services/code-checks.js +194 -44
  12. package/dist/services/engines.js +4 -4
  13. package/dist/services/eval-snapshots.js +10 -5
  14. package/dist/services/harness-config.js +66 -0
  15. package/dist/services/hooks.js +25 -22
  16. package/dist/services/lanes.js +1 -0
  17. package/dist/services/managed-processes.js +169 -0
  18. package/dist/services/merge-server.js +8 -2
  19. package/dist/services/portable-refs.js +57 -0
  20. package/dist/services/prompts.js +4 -2
  21. package/dist/services/run-engine-bindings.js +19 -61
  22. package/dist/services/run-projection.js +10 -8
  23. package/dist/services/runs.js +71 -9
  24. package/dist/services/schema-validation.js +11 -11
  25. package/dist/services/session-identity.js +19 -0
  26. package/dist/services/sessions.js +26 -11
  27. package/dist/services/stage-lifecycle.js +15 -8
  28. package/dist/services/stage-pause.js +35 -20
  29. package/dist/services/usage.js +74 -42
  30. package/dist/services/vnext-code-review.js +73 -33
  31. package/dist/services/vnext-code.js +98 -34
  32. package/dist/services/vnext-fanout.js +4 -4
  33. package/dist/services/vnext-merge.js +144 -65
  34. package/dist/services/vnext-plan-review.js +36 -18
  35. package/dist/services/vnext-plan.js +66 -18
  36. package/dist/services/vnext-protocolize.js +6 -6
  37. package/dist/services/vnext-specify.js +6 -6
  38. package/dist/services/work-registry.js +137 -34
  39. package/dist/storage/database.js +128 -2
  40. package/package.json +1 -1
  41. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { execFileSync, spawnSync } from "node:child_process";
@@ -14,6 +15,7 @@ import { writeStageReport } from "./stage-report-renderer.js";
14
15
  import { bindStageCoordinatorWork, createChildWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
15
16
  import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
16
17
  import { applyExternalStageContext } from "./stage-context.js";
18
+ import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
17
19
  const stage = "merge";
18
20
  const stageDir = vnextStageDirectory(stage);
19
21
  export function isVnextMergeRun(context, input) {
@@ -35,18 +37,51 @@ export function ensureVnextMergeRequest(context, input) {
35
37
  const sourceBranch = gitValue(run.workspace_root, ["branch", "--show-current"]);
36
38
  if (!sourceBranch)
37
39
  throw new AppError("merge_source_invalid", "MERGE requires a named source branch", 1, { workspace_root: run.workspace_root });
38
- commitPendingSource(run.workspace_root, run.id, ignoredGitPaths(context, project.id));
39
- const sourceCommit = gitValue(run.workspace_root, ["rev-parse", "HEAD"]);
40
40
  const targetBranch = workspaceRoute(home).integration_branch;
41
- if (!sourceCommit || !targetBranch)
42
- throw new AppError("merge_source_invalid", "MERGE could not freeze source commit or target branch", 1, { source_commit: sourceCommit, target_branch: targetBranch });
43
41
  const targetWorkspace = projectRoot;
44
- const enqueueTarget = gitValue(targetWorkspace, ["rev-parse", targetBranch], true);
42
+ // Validate every immutable prerequisite before touching the source tree. A
43
+ // rejected MERGE handoff must not leave a new commit behind or let a caller
44
+ // subsequently mark CODE/CODE-REVIEW complete without a queue request.
45
+ if (!targetBranch)
46
+ throw new AppError("merge_source_invalid", "MERGE could not determine the target branch", 1, { target_branch: targetBranch });
45
47
  const semantic = planChecks(run.workspace_root, protocols);
46
48
  validateMergeAcceptance(run.workspace_root, protocols, semantic);
47
- const effective = effectiveCheckDeclarations(run.workspace_root, semantic, ["merge"]);
49
+ const effective = effectiveCheckDeclarations(targetWorkspace, semantic, ["work", "code", "readiness", "merge"]);
48
50
  if (run.workspace_root !== targetWorkspace && effective.length === 0)
49
51
  throw new AppError("merge_gate_missing", "A real source/target integration requires at least one semantic or policy merge check", 2, { run_id: run.id, protocol_ids: protocols });
52
+ const ignored = ignoredGitPaths(context, project.id);
53
+ const accepted = input.acceptedPaths ? [...new Set(input.acceptedPaths.filter((item) => !ignored.includes(item)))].sort() : null;
54
+ const freezeRoot = path.join(home, stageDir);
55
+ const freezeFile = path.join(freezeRoot, "source-freeze.json");
56
+ const gateFile = path.join(freezeRoot, "merge-gate.json");
57
+ fs.mkdirSync(freezeRoot, { recursive: true });
58
+ freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
59
+ let sourceCommit;
60
+ let sourcePaths;
61
+ if (fs.existsSync(freezeFile)) {
62
+ const frozen = JSON.parse(fs.readFileSync(freezeFile, "utf8"));
63
+ sourceCommit = frozen.run_id === run.id && typeof frozen.source_commit === "string" ? frozen.source_commit : "";
64
+ sourcePaths = Array.isArray(frozen.paths) ? frozen.paths : [];
65
+ if (!sourceCommit || gitValue(run.workspace_root, ["rev-parse", "HEAD"]) !== sourceCommit || (accepted && JSON.stringify(accepted) !== JSON.stringify(sourcePaths)))
66
+ throw new AppError("merge_source_freeze_conflict", "Existing MERGE source freeze does not match the accepted source", 2, { freeze_file: freezeFile });
67
+ }
68
+ else {
69
+ sourcePaths = [...new Set(meaningfulStatus(run.workspace_root, ignored).map(statusPath))].sort();
70
+ if (accepted) {
71
+ const unexpected = sourcePaths.filter((item) => !accepted.includes(item));
72
+ const absent = accepted.filter((item) => !sourcePaths.includes(item));
73
+ if (unexpected.length || absent.length)
74
+ throw new AppError("merge_source_drift", "MERGE source differs from the accepted terminal-stage file set", 2, { unexpected, absent });
75
+ }
76
+ const sourceFingerprint = workspaceFingerprint(run.workspace_root);
77
+ commitPendingSource(run.workspace_root, run.id, ignored);
78
+ sourceCommit = gitValue(run.workspace_root, ["rev-parse", "HEAD"]);
79
+ if (sourceCommit)
80
+ fs.writeFileSync(freezeFile, `${JSON.stringify({ schema_id: "dd-flow/merge-source-freeze@1", run_id: run.id, source_workspace: run.workspace_root, source_branch: sourceBranch, source_commit: sourceCommit, precommit_workspace_fingerprint: sourceFingerprint, paths: sourcePaths, at: context.now() }, null, 2)}\n`);
81
+ }
82
+ if (!sourceCommit)
83
+ throw new AppError("merge_source_invalid", "MERGE could not freeze source commit", 1, { source_commit: sourceCommit, target_branch: targetBranch });
84
+ const enqueueTarget = gitValue(targetWorkspace, ["rev-parse", targetBranch], true);
50
85
  const root = requireRootWork(context, project.id, run.id);
51
86
  let child;
52
87
  let requestId;
@@ -83,38 +118,50 @@ export async function startVnextMerge(context, input) {
83
118
  fs.mkdirSync(workRoot, { recursive: true });
84
119
  const resultPath = path.join(workRoot, "result.json");
85
120
  const promptPath = path.join(workRoot, "prompt.md");
86
- if (["active", "action_required"].includes(request.status)) {
87
- const pendingWork = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
88
- const binding = pendingWork?.status === "created"
89
- ? startStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) })
90
- : bindStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
91
- const prompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : mergePrompt(context, { run, request, resultPath, projectRoot });
92
- if (!fs.existsSync(promptPath))
93
- fs.writeFileSync(promptPath, prompt);
121
+ ensureLaneWorkspace(context, { projectRoot, lane: "merge", workspacePath: request.target_workspace, branch: request.target_branch });
122
+ 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 } : {}) });
123
+ if (!lane.acquired)
124
+ throw new AppError("merge_lane_wait_failed", "MERGE could not acquire the project integration lane", 1, { merge_request_id: request.merge_request_id, status: lane.status ?? "unknown" });
125
+ try {
126
+ if (["active", "action_required"].includes(request.status)) {
127
+ const pendingWork = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
128
+ const binding = pendingWork?.status === "created"
129
+ ? startStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) })
130
+ : bindStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
131
+ const prompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : mergePrompt(context, { run, request, resultPath, projectRoot });
132
+ if (!fs.existsSync(promptPath))
133
+ fs.writeFileSync(promptPath, prompt);
134
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
135
+ ensureMergeStageAttached(context, run, projectRoot);
136
+ return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext);
137
+ }
138
+ const current = requireRequest(context, request.merge_request_id);
139
+ const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
140
+ const checkedOutTarget = gitValue(current.target_workspace, ["branch", "--show-current"], true);
141
+ if (checkedOutTarget !== current.target_branch)
142
+ throw new AppError("merge_target_branch_mismatch", "Integration workspace is not on the configured target branch", 1, { expected: current.target_branch, actual: checkedOutTarget, target_workspace: current.target_workspace });
143
+ const targetStatus = meaningfulStatus(current.target_workspace, ignoredGitPaths(context, project.id));
144
+ if (targetStatus.length)
145
+ throw new AppError("merge_target_dirty", "Integration workspace must be clean before MERGE starts", 1, { target_workspace: current.target_workspace, status: targetStatus });
146
+ const claimed = context.db.run("UPDATE merge_requests SET status = 'active', execution_target_head = ?, lock_acquired_at = ?, checkpoint = 'baseline_locked', updated_at = ? WHERE merge_request_id = ? AND status IN ('queued','dispatching','action_required')", [targetHead, context.now(), context.now(), current.merge_request_id]);
147
+ if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
148
+ throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
149
+ const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
150
+ const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
151
+ fs.writeFileSync(promptPath, prompt);
94
152
  const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
95
- ensureMergeStageAttached(context, run, projectRoot);
96
- return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext);
153
+ 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]);
154
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
155
+ 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 });
156
+ return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
157
+ }
158
+ catch (error) {
159
+ const latest = requireRequest(context, request.merge_request_id);
160
+ if (["active", "dispatching"].includes(latest.status))
161
+ context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_start_failed", error: error instanceof Error ? error.message : String(error) }), context.now(), latest.merge_request_id]);
162
+ releaseMergeLane(context, projectRoot, latest, `MERGE ${latest.merge_request_id} start failed`);
163
+ throw error;
97
164
  }
98
- await waitForTurn(context, request, input.progress);
99
- const current = requireRequest(context, request.merge_request_id);
100
- const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
101
- const checkedOutTarget = gitValue(current.target_workspace, ["branch", "--show-current"], true);
102
- if (checkedOutTarget !== current.target_branch)
103
- throw new AppError("merge_target_branch_mismatch", "Integration workspace is not on the configured target branch", 1, { expected: current.target_branch, actual: checkedOutTarget, target_workspace: current.target_workspace });
104
- const targetStatus = meaningfulStatus(current.target_workspace, ignoredGitPaths(context, project.id));
105
- if (targetStatus.length)
106
- throw new AppError("merge_target_dirty", "Integration workspace must be clean before MERGE starts", 1, { target_workspace: current.target_workspace, status: targetStatus });
107
- const claimed = context.db.run("UPDATE merge_requests SET status = 'active', execution_target_head = ?, lock_acquired_at = ?, checkpoint = 'baseline_locked', updated_at = ? WHERE merge_request_id = ? AND status IN ('queued','dispatching','action_required')", [targetHead, context.now(), context.now(), current.merge_request_id]);
108
- if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
109
- throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
110
- const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
111
- const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
112
- fs.writeFileSync(promptPath, prompt);
113
- const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
114
- 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]);
115
- attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
116
- 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 });
117
- return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
118
165
  }
119
166
  function startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext) {
120
167
  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) } };
@@ -125,6 +172,7 @@ export function applyVnextMerge(context, input) {
125
172
  if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
126
173
  return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
127
174
  const request = requireOwnedActiveRequest(context, input);
175
+ heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
128
176
  assertExecutionBaseline(request);
129
177
  input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
130
178
  let outcome = "applied_clean";
@@ -157,6 +205,7 @@ export async function finishVnextMerge(context, input) {
157
205
  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" };
158
206
  }
159
207
  let request = requireOwnedActiveRequest(context, input);
208
+ heartbeatLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
160
209
  if (request.run_id !== run.id)
161
210
  throw new AppError("merge_request_mismatch", "MRG does not belong to RUN", 2);
162
211
  if (request.checkpoint === "baseline_locked" || !fs.existsSync(applyReceiptPath(context, request)))
@@ -164,22 +213,11 @@ export async function finishVnextMerge(context, input) {
164
213
  if (unmerged(request.target_workspace).length)
165
214
  throw new AppError("merge_conflicts_unresolved", "Resolve every unmerged path before finishing MERGE", 2, { paths: unmerged(request.target_workspace) });
166
215
  const resultPath = path.join(requireHome(run), stageDir, "works", request.executor_work_id, "result.json");
167
- validateSchema({ schemaName: "merge-result", file: resultPath, projectRoot: request.target_workspace, ddFlowHome: context.ddFlowHome, runId: run.id });
216
+ validateSchema({ schemaName: "merge-result", file: resultPath, projectRoot: request.target_workspace, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: requireHome(run) });
168
217
  const semantic = JSON.parse(fs.readFileSync(resultPath, "utf8"));
169
218
  if (semantic.outcome !== "completed")
170
219
  throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
171
- if (!["integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(request.checkpoint)) {
172
- commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
173
- const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
174
- context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
175
- request = requireRequest(context, request.merge_request_id);
176
- }
177
- else if (meaningfulStatus(request.target_workspace, ignoredGitPaths(context, project.id)).length) {
178
- commitIntegration(request.target_workspace, `${run.id}-fix`, ignoredGitPaths(context, project.id));
179
- context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [gitValue(request.target_workspace, ["rev-parse", "HEAD"]), context.now(), request.merge_request_id]);
180
- request = requireRequest(context, request.merge_request_id);
181
- }
182
- if (request.checkpoint === "integration_committed") {
220
+ if (request.checkpoint === "apply_recorded") {
183
221
  input.progress?.("bootstrapping integrated target");
184
222
  runBootstrap(run, request.target_workspace);
185
223
  context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
@@ -200,8 +238,19 @@ export async function finishVnextMerge(context, input) {
200
238
  const missingRefs = requiredRefs.filter((ref) => !passedRefs.has(ref));
201
239
  if (missingRefs.length)
202
240
  throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every merge acceptance reference", 2, { missing_check_refs: missingRefs });
203
- context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', status = 'active', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
241
+ const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
242
+ context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
204
243
  request = requireRequest(context, request.merge_request_id);
244
+ if (!request.integration_commit) {
245
+ commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
246
+ const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
247
+ const committedTree = gitValue(request.target_workspace, ["rev-parse", "HEAD^{tree}"]);
248
+ if (!request.accepted_tree || committedTree !== request.accepted_tree) {
249
+ return recovery(context, request, "merge_commit_tree_drift", { accepted_tree: request.accepted_tree, committed_tree: committedTree, integration_commit: commit });
250
+ }
251
+ context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
252
+ request = requireRequest(context, request.merge_request_id);
253
+ }
205
254
  verifyLocalDelivery(request);
206
255
  context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
207
256
  request = requireRequest(context, request.merge_request_id);
@@ -218,6 +267,7 @@ export async function finishVnextMerge(context, input) {
218
267
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
219
268
  refreshRunWorkProjection(context, project.id, run.id);
220
269
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_completed", merge_request_id: request.merge_request_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit });
270
+ releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} completed`);
221
271
  return { ok: true, run_id: run.id, stage, outcome: "completed", merge_request_id: request.merge_request_id, work_id: request.executor_work_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit, report_path: path.join(root, "stage-report.json"), next_action: "merge_completed" };
222
272
  }
223
273
  export function getVnextMergeRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = input.requestId ? requireRequest(context, input.requestId) : input.runId ? requestForRun(context, project.id, input.runId) : null; if (!request || request.project_id !== project.id)
@@ -254,12 +304,28 @@ catch (error) {
254
304
  throw error;
255
305
  } refreshRunWorkProjection(context, request.project_id, request.run_id); return requestView(context, requireRequest(context, request.merge_request_id)); }
256
306
  function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --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 integration conflicts in the integration workspace. Do not repeat merge apply.", `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 check fails, inspect only the returned receipt/logs, repair the integrated target in this same Work, update the semantic result, and repeat the same finish command. Do not create a repair Work or rerun independent review.", "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"); }
257
- 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, 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" } }; }
258
- function effectiveMergeChecks(run, request) { return effectiveCheckDeclarations(request.target_workspace, planChecks(run.workspace_root, JSON.parse(request.protocol_ids_json)), ["merge"]); }
307
+ 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" } }; }
308
+ function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
259
309
  function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
260
- return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.run_at === "merge").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
310
+ return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.availability === "available").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
261
311
  function validateMergeAcceptance(workspace, protocols, checks) { const known = new Set(checks.map((check) => check.canonical_ref)); const missing = mergeAcceptanceRefs(workspace, protocols).filter((ref) => !known.has(ref)); if (missing.length)
262
- throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a non-merge check", 2, { check_refs: missing }); }
312
+ throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a missing check", 2, { check_refs: missing }); }
313
+ function freezeMergeGate(file, input) { const canonical = JSON.stringify(input.checks); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
314
+ const existing = JSON.parse(fs.readFileSync(file, "utf8"));
315
+ if (existing.run_id !== input.runId || existing.checks_hash !== hash)
316
+ throw new AppError("merge_gate_freeze_conflict", "MERGE gate is already frozen with different checks", 2, { file });
317
+ return;
318
+ } fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@1", run_id: input.runId, protocols: input.protocols, checks: input.checks, checks_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
319
+ function readFrozenMergeGate(file, requestId) { try {
320
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
321
+ if (value.schema_id !== "dd-flow/merge-gate@1" || !Array.isArray(value.checks) || value.checks_hash !== cryptoHash(JSON.stringify(value.checks)))
322
+ throw new Error("invalid gate");
323
+ return { checks: value.checks };
324
+ }
325
+ catch (cause) {
326
+ throw new AppError("merge_gate_missing", "MERGE gate is missing or invalid", 1, { merge_request_id: requestId, file, cause: String(cause) });
327
+ } }
328
+ function cryptoHash(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
263
329
  function mergeAcceptanceRefs(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
264
330
  return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.acceptance ?? []).filter((entry) => entry.gate === "merge").flatMap((entry) => (entry.check_refs ?? []).map((ref) => `${protocol}/${ref}`)); }); }
265
331
  function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.existsSync(root))
@@ -277,15 +343,28 @@ function performConfiguredCleanup(run, request) {
277
343
  const policy = executionSettings(run).merge_cleanup?.source ?? "retain";
278
344
  const receipt = cleanupReceiptPath(run);
279
345
  let action = "retained";
280
- if (policy === "delete_after_success" && path.resolve(request.source_workspace) !== path.resolve(request.target_workspace)) {
281
- git(request.target_workspace, ["worktree", "remove", request.source_workspace]);
282
- git(request.target_workspace, ["branch", "-d", request.source_branch]);
283
- action = "deleted";
346
+ let error = null;
347
+ try {
348
+ if (policy === "delete_after_success" && path.resolve(request.source_workspace) !== path.resolve(request.target_workspace)) {
349
+ git(request.target_workspace, ["worktree", "remove", request.source_workspace]);
350
+ git(request.target_workspace, ["branch", "-d", request.source_branch]);
351
+ action = "deleted";
352
+ }
353
+ }
354
+ catch (cause) {
355
+ // Cleanup is post-delivery hygiene. It must never invalidate an integration
356
+ // commit which already passed the merge gate and was delivered.
357
+ action = "action_required";
358
+ error = cause instanceof Error ? cause.message : String(cause);
359
+ }
360
+ fs.writeFileSync(receipt, `${JSON.stringify({ schema_id: "dd-flow/merge-cleanup-receipt@1", merge_request_id: request.merge_request_id, policy, action, source_workspace: request.source_workspace, source_branch: request.source_branch, ...(error ? { error } : {}), at: new Date().toISOString() }, null, 2)}\n`);
361
+ }
362
+ function releaseMergeLane(context, projectRoot, request, reason) {
363
+ try {
364
+ releaseLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, reason });
284
365
  }
285
- fs.writeFileSync(receipt, `${JSON.stringify({ schema_id: "dd-flow/merge-cleanup-receipt@1", merge_request_id: request.merge_request_id, policy, action, source_workspace: request.source_workspace, source_branch: request.source_branch, at: new Date().toISOString() }, null, 2)}\n`);
366
+ catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
286
367
  }
287
- function waitForTurn(context, request, progress) { return new Promise((resolve) => { const poll = () => { const current = requireRequest(context, request.merge_request_id); const ahead = queueAhead(context, current); const active = context.db.get("SELECT merge_request_id FROM merge_requests WHERE project_id = ? AND status IN ('active','action_required','recovery_required') AND merge_request_id <> ? LIMIT 1", [current.project_id, current.merge_request_id]); if (ahead === 0 && !active)
288
- return resolve(); progress?.(`MERGE ${current.merge_request_id} is waiting: ${ahead} request(s) ahead; next update in 15 seconds`); setTimeout(poll, 15_000); }; poll(); }); }
289
368
  function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
290
369
  function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
291
370
  function requireOwnedActiveRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.executor_work_id !== input.workId || !["active", "action_required"].includes(request.status))
@@ -317,11 +396,11 @@ function requestForRun(context, projectId, runId) { const request = context.db.g
317
396
  throw new AppError("merge_request_missing", "MERGE request was not materialized by the prior terminal stage", 1, { run_id: runId }); return request; }
318
397
  function requireRequest(context, id) { const request = context.db.get("SELECT * FROM merge_requests WHERE merge_request_id = ?", [id]); if (!request)
319
398
  throw new AppError("not_found", "MERGE request is not registered", 1, { merge_request_id: id }); return request; }
320
- function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
321
- function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_home_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
399
+ function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
400
+ function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
322
401
  throw new AppError("not_found", "RUN is not registered", 1); return run; }
323
- function requireHome(run) { if (!run.run_home_path)
324
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
402
+ function requireHome(run) { if (!run.run_root)
403
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
325
404
  function findRootWork(context, projectId, runId) { const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!work)
326
405
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return work; }
327
406
  function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
@@ -14,6 +14,7 @@ import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
14
  import { nextWorkId } from "./ids.js";
15
15
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
16
16
  import { writeStageReport } from "./stage-report-renderer.js";
17
+ import { assertPortableArtifactRef } from "./portable-refs.js";
17
18
  import { applyExternalStageContext } from "./stage-context.js";
18
19
  import { capacityProbe, readFanoutDescriptor, subagentCapacityKey, writeFanoutDescriptor } from "./vnext-fanout.js";
19
20
  const stage = "plan-review";
@@ -48,7 +49,7 @@ export function startVnextPlanReview(context, input) {
48
49
  if (existing?.status === "running") {
49
50
  const promptPath = path.join(root, "stage-prompt.md");
50
51
  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: checksum(path.join(planRoot, "stage-report.json")) }), ...(externalContext ? { external_context: externalContext } : {}) };
52
+ 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
  }
53
54
  const priorStage = readIndex(run).stage_runs?.find((entry) => entry.stage === stage);
54
55
  if (priorStage?.status === "done") {
@@ -61,7 +62,7 @@ export function startVnextPlanReview(context, input) {
61
62
  fs.mkdirSync(root, { recursive: true });
62
63
  const now = context.now();
63
64
  const batchChecksum = checksum(batch);
64
- const planChecksum = checksum(path.join(planRoot, "stage-report.json"));
65
+ const planChecksum = planSetChecksum(home, run.workspace_root);
65
66
  if (effective === "off") {
66
67
  const failures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIds });
67
68
  if (failures.length)
@@ -131,7 +132,7 @@ export function dispatchVnextPlanReview(context, input) {
131
132
  const pending = groups.filter((group) => !latest(group));
132
133
  if (pending.length) {
133
134
  const file = path.join(root, ".dispatch.json");
134
- writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
135
+ writeJson(file, { works: pending.map((group) => ({ key: group.key, task: group.task, depends_on: group.depends_on, launch_policy: "fresh_agent_required", result_schema: "dd-flow/plan-review-result@1", payload: { kind: "plan-review", read_only: true, group: { key: group.key, aspect_ids: group.aspect_ids } } })) });
135
136
  try {
136
137
  addWorkBatch(context, { parentWorkId: parent.work_id, file });
137
138
  }
@@ -182,7 +183,7 @@ export function finishVnextPlanReview(context, input) {
182
183
  if (!parent || parent.status !== "running")
183
184
  throw new AppError("invalid_work_state", "PLAN-REVIEW has no running parent Work", 2);
184
185
  const decisionPath = path.resolve(input.decisionFile ?? path.join(root, "decision.json"));
185
- const decision = readDecision(context, projectRoot, run.id, decisionPath);
186
+ const decision = readDecision(context, projectRoot, run.id, home, decisionPath);
186
187
  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 = ? AND task <> 'Capacity probe: return ready and finish this Work.'", [project.id, run.id, parent.work_id]);
187
188
  const contextFile = readJson(path.join(root, "work-context.json"));
188
189
  const groups = contextFile.groups ?? [];
@@ -190,14 +191,18 @@ export function finishVnextPlanReview(context, input) {
190
191
  if (!Number.isInteger(reviewedPlanRevision) || reviewedPlanRevision < 1)
191
192
  throw new AppError("runtime_missing", "PLAN-REVIEW starting revision is unavailable", 1);
192
193
  const planRevision = currentPlanRevision(home, run.workspace_root);
193
- if (["blocked", "failed", "cancelled"].includes(decision.outcome)) {
194
+ const currentPlanChecksum = planSetChecksum(home, run.workspace_root);
195
+ if (decision.outcome === "blocked") {
196
+ 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 });
197
+ }
198
+ if (["failed", "cancelled"].includes(decision.outcome)) {
194
199
  preserveDecisionReceipt(root, decisionPath);
195
200
  return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision });
196
201
  }
197
202
  if (children.some((child) => child.status === "created" || child.status === "running"))
198
203
  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 })) });
199
204
  const latestChildren = latestReviewChildren(children);
200
- const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision });
205
+ const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision, workspaceRoot: run.workspace_root, runHome: home, runId: run.id });
201
206
  if (evidenceFailures.length)
202
207
  throw new AppError("review_evidence_invalid", "PLAN-REVIEW reviewer evidence is incomplete, stale, or not isolated", 2, { errors: evidenceFailures });
203
208
  if (decision.outcome !== "accepted")
@@ -209,17 +214,21 @@ export function finishVnextPlanReview(context, input) {
209
214
  const needsCorrection = latestChildren.some((child) => ["needs_changes", "blocked"].includes(reviewerVerdict(child.result)));
210
215
  if (latestChildren.some((child) => child.status !== "completed"))
211
216
  throw new AppError("blocked", "PLAN-REVIEW cannot accept incomplete reviewer evidence", 2);
212
- const materialFindingIds = canonicalReviewerFindings(latestChildren).filter(({ finding }) => ["blocker", "high", "medium"].includes(finding.severity)).map(({ finding_ref }) => finding_ref);
213
- const decidedFindingIds = new Set(decision.finding_decisions.map((finding) => finding.finding_ref).filter((id) => Boolean(id)));
214
- if (new Set(materialFindingIds).size !== materialFindingIds.length || materialFindingIds.some((id) => !decidedFindingIds.has(id)))
215
- throw new AppError("review_evidence_invalid", "Every material reviewer finding needs one stable decision", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: [...decidedFindingIds] });
217
+ const canonicalFindings = canonicalReviewerFindings(latestChildren);
218
+ const allFindingIds = canonicalFindings.map(({ finding_ref }) => finding_ref);
219
+ const materialFindingIds = canonicalFindings.filter(({ finding }) => ["blocker", "high", "medium"].includes(finding.severity)).map(({ finding_ref }) => finding_ref);
220
+ const decisionFindingIds = decision.finding_decisions.map((finding) => finding.finding_ref).filter((id) => Boolean(id));
221
+ const decidedFindingIds = new Set(decisionFindingIds);
222
+ const unknownDecisions = decisionFindingIds.filter((id) => !allFindingIds.includes(id));
223
+ if (new Set(allFindingIds).size !== allFindingIds.length || decidedFindingIds.size !== decisionFindingIds.length || unknownDecisions.length || materialFindingIds.some((id) => !decidedFindingIds.has(id)))
224
+ throw new AppError("review_evidence_invalid", "Reviewer findings and coordinator decisions must use unique known canonical references, with every material finding decided", 2, { material_finding_ids: materialFindingIds, decided_finding_ids: decisionFindingIds, unknown_decisions: unknownDecisions });
216
225
  if (needsCorrection) {
217
226
  if (decision.correction.status !== "applied")
218
227
  throw new AppError("validation", "Reviewer findings require an in-place correction receipt", 2);
219
228
  if (decision.correction.previous_plan_revision !== reviewedPlanRevision || planRevision <= reviewedPlanRevision || decision.correction.changed_paths.length === 0)
220
229
  throw new AppError("validation", "Applied PLAN correction must advance revision and list changed paths", 2, { reviewed_plan_revision: reviewedPlanRevision, final_plan_revision: planRevision });
221
230
  }
222
- else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision) {
231
+ else if (decision.correction.status !== "not_required" || planRevision !== reviewedPlanRevision || currentPlanChecksum !== contextFile.system?.plan_checksum) {
223
232
  throw new AppError("validation", "A clean PLAN review must not claim an unverified correction", 2);
224
233
  }
225
234
  const planFailures = validateVnextPlanArtifacts(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home, protocols: protocolIdsForReview(home) });
@@ -233,7 +242,7 @@ export function finishVnextPlanReview(context, input) {
233
242
  throw new AppError("validation", "Generated CODE batch must not be listed as an agent-authored correction", 2, { changed_paths: decision.correction.changed_paths });
234
243
  }
235
244
  preserveDecisionReceipt(root, decisionPath);
236
- const finalPlanChecksum = checksum(path.join(run.workspace_root, ".memory-bank", "protocol", protocolIdsForReview(home)[0], "plan.json"));
245
+ const finalPlanChecksum = planSetChecksum(home, run.workspace_root);
237
246
  const finalBatchChecksum = checksum(batch);
238
247
  const registered = registerCode(context, project.id, run.id, batch);
239
248
  const now = context.now();
@@ -258,7 +267,7 @@ function orchestratorPrompt(context, input) {
258
267
  const decision = path.join(input.root, "decision.json");
259
268
  const revision = currentPlanRevision(input.home, input.run.workspace_root);
260
269
  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");
261
- return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch requests capacity, run exactly one concurrent fan-out of ${capacityProbeFanoutSize} probes. This measures the harness limit; it is not a task to obtain ${capacityProbeFanoutSize} successful probes. Start #01…#${capacityProbeFanoutSize} once, all together, using all-settled handling so one rejection does not hide the other outcomes. A rejected launch is expected evidence. Never retry, replace, or add a probe. Each started probe calls no tools, reads no files, creates no children, waits ${capacityProbeHoldSeconds} seconds, then returns exactly AGENT-NN. For cleanup, wait at most ${capacityProbeDeadlineSeconds} seconds from the first launch, terminate every unfinished probe, then release every finished probe session that the harness permits. Only after that cleanup record the number of launches that started successfully, not the number of replacement attempts or late completions: ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<successful-initial-launches>")}. Capacity probes are not Works and are never registered.`, "After dispatch, launch at most the measured capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; 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; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | blocked | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
270
+ return ["<stage_identity>", `- RUN: ${input.run.id}`, `- Work: ${input.workId}`, "- Stage: plan-review", `- Mode: ${input.effective}`, "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility, permission or schema discovery.", `- Project root: ${input.projectRoot}`, `- Stage workspace: ${input.root}`, `- PLAN revision: ${revision}`, `- PLAN report checksum: ${input.planChecksum}`, `- Generated CODE batch checksum: ${input.batchChecksum}`, "</trusted_runtime_context>", "", workspaceContract, "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", `Dispatch fresh reviewers: ${dispatchCommand(context, input.run.id, input.projectRoot)}`, `If dispatch requests capacity, run exactly one concurrent fan-out of ${capacityProbeFanoutSize} probes. This measures the harness limit; it is not a task to obtain ${capacityProbeFanoutSize} successful probes. Start #01…#${capacityProbeFanoutSize} once, all together, using all-settled handling so one rejection does not hide the other outcomes. A rejected launch is expected evidence. Never retry, replace, or add a probe. Each started probe calls no tools, reads no files, creates no children, waits ${capacityProbeHoldSeconds} seconds, then returns exactly AGENT-NN. For cleanup, wait at most ${capacityProbeDeadlineSeconds} seconds from the first launch, terminate every unfinished probe, then release every finished probe session that the harness permits. Only after that cleanup record the number of launches that started successfully, not the number of replacement attempts or late completions: ${capacityRecordCommand(context, input.run.id, input.projectRoot, "<successful-initial-launches>")}. Capacity probes are not Works and are never registered.`, "After dispatch, launch at most the measured capacity at once. If more independent reviewer Works remain, wait for the current wave to settle, then start the unchanged queued Works in the next wave. A reviewer launch rejected before it starts is not review evidence: do not create a replacement; wait for a running wave to settle and start that same queued Work. Each reviewer must be a genuinely fresh harness child Session. The lifecycle adapter binds that observed Session; 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; do not let finished disposable workers occupy slots before the next wave.", "Review the execution environment of every selected check as part of its proof: a reset/fixture process, service process and client process must share the intended data and configuration world. A runtime entrypoint that can break that invariant must be explicit in one Work's task and verification and ordered before its consumer. planned_write_areas may advertise likely overlap, but do not treat them as ownership; required_read alone is not a delivery plan.", "If the final decision needs user input with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", input.pauseCommandTemplate, "```", "Ask the returned user_message, stop, then resume this same PLAN-REVIEW Work. Do not write decision.json or finish first.", `When all reviewer results are complete and every user question is resolved, classify every material finding, fix accepted findings in this same PLAN-REVIEW Work, then write ${decision} and finish: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer findings use local FIND-NNN ids. dd-flow exposes each finding to this coordinator as WRK-.../FIND-NNN; use that canonical finding_ref in the decision.", "A completed reviewer result with needs_changes or blocked is evidence, not the stage outcome. Classify its material findings and apply accepted fixes in this one review pass; do not start a second review automatically. Only a missing, malformed or unfinished reviewer result blocks the stage. For an accepted correction, increment PLAN revision and update only plan.json and the relevant aspect map. Do not edit or list code-work-batch.json: the CLI validates final PLAN and regenerates it. If no material correction is needed, set correction.status=not_required. The CLI checks mechanical handoff coherence; it does not prove semantic correctness.", "```json", JSON.stringify({ schema_id: "dd-flow/plan-review-decision@3", outcome: "accepted | failed | cancelled", summary: "Concise evidence-backed final decision.", finding_decisions: [{ finding_ref: "WRK-001-review/FIND-001", decision: "accepted_fix | rejected | deferred_as_DEF | requires_user | duplicate", reason: "Why." }], correction: { status: "not_required | applied", previous_plan_revision: revision, changed_paths: [], summary: "No material correction was needed, or summarize the applied correction." } }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
262
271
  }
263
272
  function reviewGroups(home, workspaceRoot) {
264
273
  const root = path.join(home, "03-plan");
@@ -376,7 +385,7 @@ catch {
376
385
  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)
377
386
  throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return root.work_id; }
378
387
  function registerCode(context, projectId, runId, batch) { return addWorkBatch(context, { parentWorkId: rootWorkId(context, projectId, runId), file: batch }); }
379
- function readDecision(context, projectRoot, runId, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, ddFlowHome: context.ddFlowHome }); return readJson(file); }
388
+ function readDecision(context, projectRoot, runId, runRoot, file) { validateSchema({ schemaName: "plan-review-decision", file, projectRoot, runId, runRoot, ddFlowHome: context.ddFlowHome }); return readJson(file); }
380
389
  function preserveDecisionReceipt(root, source) {
381
390
  const receipt = path.join(root, "decision.receipt.json");
382
391
  const bytes = fs.readFileSync(source);
@@ -419,6 +428,14 @@ function reviewerEvidenceFailures(context, input) {
419
428
  const actual = result.aspects.map((item) => item.aspect_id);
420
429
  if (actual.length !== group.aspect_ids.length || new Set(actual).size !== actual.length || group.aspect_ids.some((id) => !actual.includes(id)) || result.aspects.some((item) => item.evidence_refs.length === 0))
421
430
  failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_aspect_coverage_invalid", expected_aspects: group.aspect_ids, actual_aspects: actual });
431
+ for (const ref of result.aspects.flatMap((item) => [...item.evidence_refs, ...item.findings.flatMap((finding) => finding.evidence_refs)])) {
432
+ try {
433
+ assertPortableArtifactRef(ref, { workspaceRoot: input.workspaceRoot, runHome: input.runHome, runId: input.runId });
434
+ }
435
+ catch (error) {
436
+ failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_evidence_ref_invalid", ref, detail: error instanceof Error ? error.message : String(error) });
437
+ }
438
+ }
422
439
  const workSession = context.db.get("SELECT session_id, status FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [child.work_id]);
423
440
  if (!workSession?.session_id || workSession.status !== "completed" || workSession.session_id === parent?.session_id)
424
441
  failures.push({ group: group.key, work_id: child.work_id, error: "reviewer_session_not_fresh", parent_session_id: parent?.session_id ?? null, reviewer_session_id: workSession?.session_id ?? null });
@@ -445,7 +462,7 @@ function finishTerminalReview(context, input) {
445
462
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [childStatus, input.decision.summary, now, now, rootWork]);
446
463
  closeRunningWorkSession(context, rootWork, childStatus, now);
447
464
  const settledChildren = context.db.all("SELECT work_id, task, status, result, created_at FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND task <> 'Capacity probe: return ready and finish this Work.'", [input.projectId, input.run.id, input.parentWorkId]);
448
- const report = reportFor({ run: input.run, mode: input.contextFile.system?.effective_mode ?? "standard", requested: input.contextFile.system?.requested_mode ?? "auto", outcome: input.decision.outcome, groups: input.contextFile.groups ?? [], batchChecksum: input.contextFile.system?.batch_checksum ?? checksum(input.batch), planChecksum: input.contextFile.system?.plan_checksum ?? checksum(path.join(input.planRoot, "stage-report.json")), code: {}, now, projectRoot: input.projectRoot, decision: input.decision, children: latestReviewChildren(settledChildren), flow: flowCommand(context) });
465
+ const report = reportFor({ run: input.run, mode: input.contextFile.system?.effective_mode ?? "standard", requested: input.contextFile.system?.requested_mode ?? "auto", outcome: input.decision.outcome, groups: input.contextFile.groups ?? [], batchChecksum: input.contextFile.system?.batch_checksum ?? checksum(input.batch), planChecksum: input.contextFile.system?.plan_checksum ?? planSetChecksum(requireHome(input.run), input.run.workspace_root), code: {}, now, projectRoot: input.projectRoot, decision: input.decision, children: latestReviewChildren(settledChildren), flow: flowCommand(context) });
449
466
  writeReport(input.root, report);
450
467
  refreshRunWorkProjection(context, input.projectId, input.run.id);
451
468
  const stageStatus = input.decision.outcome === "cancelled" ? "skipped" : input.decision.outcome === "failed" ? "failed" : "blocked";
@@ -483,10 +500,10 @@ function codeCommand(context, runId, projectRoot) { return `${flowCommand(contex
483
500
  function dispatchCommand(context, runId, projectRoot) { return `${flowCommand(context)} plan-review dispatch ${runId} --project-root ${JSON.stringify(projectRoot)} --json`; }
484
501
  function finishCommand(context, runId, projectRoot, decision) { return `${flowCommand(context)} stage finish ${runId} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --decision-file ${JSON.stringify(decision)} --json`; }
485
502
  function capacityRecordCommand(context, runId, projectRoot, availableSlots) { return `${flowCommand(context)} run capacity record ${runId} --available-slots ${availableSlots} --project-root ${JSON.stringify(projectRoot)} --json`; }
486
- function requireRun(context, projectRoot, runId) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, project_id, workspace_root, run_home_path, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]); if (!run)
503
+ function requireRun(context, projectRoot, runId) { const project = requireProjectByRoot(context, projectRoot); const run = context.db.get("SELECT id, project_id, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, runId]); if (!run)
487
504
  throw new AppError("not_found", "RUN is not registered", 1); return run; }
488
- function requireHome(run) { if (!run.run_home_path)
489
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
505
+ function requireHome(run) { if (!run.run_root)
506
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1); return run.run_root; }
490
507
  function read(file) { if (!fs.existsSync(file))
491
508
  throw new AppError("not_found", "Required vNext prompt is missing", 1, { file }); return fs.readFileSync(file, "utf8"); }
492
509
  function readJson(file) { try {
@@ -497,3 +514,4 @@ catch {
497
514
  } }
498
515
  function writeJson(file, value) { const temporary = `${file}.${crypto.randomUUID()}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
499
516
  function checksum(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
517
+ function planSetChecksum(home, workspaceRoot) { const entries = protocolIdsForReview(home).sort().map((protocolId) => { const file = path.join(workspaceRoot, ".memory-bank", "protocol", protocolId, "plan.json"); return { protocol_id: protocolId, path: path.relative(workspaceRoot, file).split(path.sep).join("/"), sha256: checksum(file) }; }); return crypto.createHash("sha256").update(JSON.stringify(entries)).digest("hex"); }