@deksden-com/dd-flow-cli 0.8.0-beta.135 → 0.9.0-beta.0

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 (40) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +31 -4
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +15 -63
  5. package/dist/cli/run-cli.js +55 -26
  6. package/dist/domain/stage-catalog.js +2 -2
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/agent-profile.schema.json +17 -0
  9. package/dist/schemas/code-review-decision.schema.json +5 -3
  10. package/dist/schemas/code-work-batch.schema.json +3 -3
  11. package/dist/schemas/compatibility.schema.json +32 -0
  12. package/dist/schemas/flow-contract.schema.json +3 -2
  13. package/dist/schemas/merge-result.schema.json +15 -0
  14. package/dist/schemas/protocol-plan.schema.json +1 -1
  15. package/dist/schemas/stage-start-response.schema.json +4 -2
  16. package/dist/schemas/status-report.schema.json +76 -0
  17. package/dist/schemas/vnext-protocol-plan.schema.json +4 -4
  18. package/dist/services/cli-operation-classifier.js +1 -1
  19. package/dist/services/code-checks.js +125 -208
  20. package/dist/services/eval-snapshots.js +10 -1
  21. package/dist/services/harness-adapter.js +59 -0
  22. package/dist/services/hooks.js +87 -237
  23. package/dist/services/ids.js +18 -1
  24. package/dist/services/lifecycle-command.js +288 -0
  25. package/dist/services/merge-server.js +124 -0
  26. package/dist/services/prompts.js +16 -10
  27. package/dist/services/runs.js +7 -2
  28. package/dist/services/sessions.js +18 -27
  29. package/dist/services/stage-pause.js +13 -0
  30. package/dist/services/vnext-code-review.js +71 -20
  31. package/dist/services/vnext-code.js +131 -34
  32. package/dist/services/vnext-execution-profile.js +6 -3
  33. package/dist/services/vnext-merge.js +330 -0
  34. package/dist/services/vnext-plan-review.js +2 -2
  35. package/dist/services/vnext-plan.js +22 -38
  36. package/dist/services/vnext-specify.js +5 -2
  37. package/dist/services/vnext-workspace-policy.js +8 -2
  38. package/dist/services/work-registry.js +85 -34
  39. package/dist/storage/database.js +66 -0
  40. package/package.json +2 -1
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { requireProjectByRoot } from "./projects.js";
8
- import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
8
+ import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
9
9
  import { validateSchema } from "./schema-validation.js";
10
10
  import { flowCommand } from "./stage-pause.js";
11
11
  import { assertStageStartHookEvent } from "./hooks.js";
@@ -17,6 +17,7 @@ import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
17
  import { writeStageReport } from "./stage-report-renderer.js";
18
18
  import { applyExternalStageContext } from "./stage-context.js";
19
19
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
20
+ import { ensureVnextMergeRequest } from "./vnext-merge.js";
20
21
  const stage = "code-review";
21
22
  const stageDir = vnextStageDirectory(stage);
22
23
  const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
@@ -84,13 +85,25 @@ export function addVnextCodeReviewRepair(context, input) {
84
85
  const origins = codeWorks(context, project.id, run.id).map((work) => work.work_id);
85
86
  if (!origins.length)
86
87
  throw new AppError("runtime_missing", "CODE-REVIEW repair requires completed CODE Work", 1);
88
+ const checks = new Map(codeWorks(context, project.id, run.id).flatMap((work) => {
89
+ const payload = readJsonString(work.payload_json);
90
+ return Array.isArray(payload.checks) ? payload.checks : [];
91
+ }).map((check) => [check.id, check]));
92
+ const requestedCheckRefs = [...new Set(selected.flatMap(({ finding_ref }) => input.checkRefsByFinding[finding_ref] ?? []))];
93
+ if (!requestedCheckRefs.length)
94
+ throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ids: input.findingIds });
95
+ const unknown = requestedCheckRefs.filter((ref) => !checks.has(ref));
96
+ if (unknown.length)
97
+ throw new AppError("review_check_reference_unknown", "CODE-REVIEW finding cites a check absent from the accepted CODE handoff", 2, { check_refs: unknown });
98
+ const reviewChecks = requestedCheckRefs.map((ref) => checks.get(ref)).filter((check) => check.run_at !== "external");
87
99
  return addVnextCodeRepair(context, {
88
100
  projectRoot,
89
101
  runId: run.id,
90
102
  reviewFindingIds: selected.map((finding) => finding.finding_ref),
91
103
  reviewEvidenceRefs: selected.flatMap(({ finding }) => finding.evidence_refs),
104
+ reviewChecks,
105
+ reviewCheckRefs: requestedCheckRefs,
92
106
  originWorkIds: origins,
93
- writeScope: input.writeScope,
94
107
  objective: input.objective
95
108
  });
96
109
  }
@@ -102,15 +115,17 @@ export async function finishVnextCodeReview(context, input) {
102
115
  const root = path.join(home, stageDir);
103
116
  const mode = effectiveMode(run, changedPaths(run.workspace_root));
104
117
  const reportPath = path.join(root, "stage-report.json");
105
- if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath))
106
- return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: "code_review_completed" };
118
+ if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
119
+ const report = readJson(reportPath);
120
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: report.semantic?.next_action ?? "code_review_completed" };
121
+ }
107
122
  const rootWork = requireRootWork(context, project.id, run.id);
108
123
  const coordinator = context.db.get("SELECT session_id, result_path FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [rootWork.work_id]);
109
124
  if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
110
125
  throw new AppError("code_review_session_binding_conflict", "CODE-REVIEW can finish only after its successful stage start bound the coordinator Session and report path", 1, { run_id: run.id, expected_result_path: reportPath, actual_result_path: coordinator?.result_path ?? null });
111
126
  const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
112
127
  if (!fs.existsSync(decisionFile) && mode === "off")
113
- fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@2", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
128
+ fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
114
129
  validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id });
115
130
  const decision = readJson(decisionFile);
116
131
  const reviewers = reviewerWorks(context, project.id, run.id);
@@ -121,6 +136,7 @@ export async function finishVnextCodeReview(context, input) {
121
136
  for (const work of reviewers)
122
137
  validateReviewerResult(context, work, run);
123
138
  const canonical = reviewDecisionForRepair(decision, reviewers);
139
+ const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
124
140
  let repairs = reviewRepairWorks(context, project.id, run.id, root);
125
141
  const decisionSha = sha256File(decisionFile);
126
142
  const frozenShaFile = path.join(root, ".decision-sha256");
@@ -128,9 +144,8 @@ export async function finishVnextCodeReview(context, input) {
128
144
  throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
129
145
  const fixIds = canonical.fix_ids;
130
146
  if (fixIds.length && repairs.length === 0) {
147
+ const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, checkRefsByFinding, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
131
148
  fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
132
- const writeScope = uniqueStrings(codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.write_scope) ? payload.write_scope.filter((value) => typeof value === "string") : []; }));
133
- const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, writeScope, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
134
149
  return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke the same stage finish command again with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
135
150
  }
136
151
  repairs = reviewRepairWorks(context, project.id, run.id, root);
@@ -146,20 +161,33 @@ export async function finishVnextCodeReview(context, input) {
146
161
  if (unchangedFailures.length)
147
162
  throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
148
163
  const receipts = repairs.length ? await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.progress ? { progress: input.progress } : {}) }) : [];
149
- const failed = receipts.filter((receipt) => receipt.status === "failed");
164
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
150
165
  if (failed.length)
151
- throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, { failures: failed });
166
+ throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
167
+ failures: failed,
168
+ outcome: "repair_required",
169
+ retry_after_workspace_change: true,
170
+ workspace_fingerprint: failed[0].workspace_fingerprint,
171
+ repair_command: `${flowCommand(context)} work repair add --run ${run.id} --from-check ${failed[0].id} --origin-work <WORK-ID> --task-stdin --project-root ${JSON.stringify(projectRoot)} --json`
172
+ });
173
+ const stopTarget = executionStopTarget(run);
174
+ const nextAction = stopTarget === "merge_completed" ? "start_merge" : "code_review_completed";
152
175
  const now = context.now();
153
176
  const startedAt = stageStartedAt(run, now);
154
- writeProtocolFlowStatus(run.workspace_root, home, run.id, "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
155
- const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: decision.summary, acceptance: ["all_selected_review_groups_completed", "all_material_findings_classified", ...(repairs.length ? ["all_accepted_repairs_completed", "post_repair_final_gate_passed"] : [])], run_changed_files: changedPaths(run.workspace_root), checks: receipts.map((receipt) => receipt.command), evidence: [...reviewers.flatMap((work) => readJsonString(work.result).findings.flatMap((finding) => finding.evidence_refs)), ...receipts.map((receipt) => runRef(run.id, home, receipt.receipt_path))], next_action: "code_review_completed", code_review: { mode, reviewer_work_ids: reviewers.map((work) => work.work_id), repair_work_ids: repairs.map((work) => work.work_id), decision } }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json` }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } };
177
+ writeProtocolFlowStatus(run.workspace_root, home, run.id, nextAction === "start_merge" ? "CODE-REVIEW complete; MERGE is queued." : "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
178
+ const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage, generated_at: now, verdict: "done", semantic: { result: `Independent CODE review completed: ${reviewers.length} reviewer Work(s), ${repairs.length} repair Work(s).`, acceptance: ["all_selected_review_groups_completed", "all_material_findings_classified", ...(repairs.length ? ["all_accepted_repairs_completed", "post_repair_final_gate_passed"] : [])], run_changed_files: changedPaths(run.workspace_root), checks: receipts.map((receipt) => receipt.command), evidence: [...reviewers.flatMap((work) => readJsonString(work.result).findings.flatMap((finding) => finding.evidence_refs)), ...receipts.map((receipt) => runRef(run.id, home, receipt.receipt_path))], next_action: nextAction, code_review: { mode, reviewer_work_ids: reviewers.map((work) => work.work_id), repair_work_ids: repairs.map((work) => work.work_id), decision } }, mechanical: { started_at: startedAt, finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(startedAt)), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json` }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } };
156
179
  writeReport(root, report);
157
180
  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" });
158
- await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
159
181
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
160
- completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
182
+ if (nextAction === "start_merge")
183
+ advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
184
+ else {
185
+ await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
186
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
187
+ }
161
188
  refreshRunWorkProjection(context, project.id, run.id);
162
- return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: "code_review_completed" };
189
+ const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
190
+ return { ok: true, run_id: run.id, stage, outcome, report_path: path.join(root, "stage-report.json"), next_action: nextAction, ...(mergeRequest ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {}) };
163
191
  }
164
192
  function reviewGroups(run, home, mode) {
165
193
  const aspectMapFiles = findFiles(path.join(home, "03-plan"), "aspect-map.json");
@@ -175,7 +203,7 @@ function reviewGroups(run, home, mode) {
175
203
  return Array.from({ length: Math.ceil(all.length / chunk) }, (_, index) => ({ key: `group-${index + 1}`, aspect_ids: all.slice(index * chunk, (index + 1) * chunk) }));
176
204
  }
177
205
  function reviewerTask(home, group) { return `Read-only independent CODE review for ${group.key}. Assess every assigned aspect exactly once: ${group.aspect_ids.join(", ")}. Read the accepted PLAN, ${path.join(home, "05-code", "stage-report.json")}, and any files needed under the bounded CODE evidence root ${path.join(home, "05-code")}. Report only material, evidenced defects: a violated obligation or rule, direct evidence, impact, and minimum required outcome. Do not report taste, cosmetics, or untargeted refactoring. Finding ids must use the unique prefix FIND-${group.key}-. Return dd-flow/code-review-result@1.`; }
178
- function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_home_path, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. The first Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@2", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
206
+ function orchestratorPrompt(context, input) { const template = read(path.join(input.run.workspace_root, ".memory-bank", "dd-flow", "vnext", "code-review.md")); const decision = path.join(input.root, "decision.json"); const checks = acceptedCodeChecks(context, input.run.project_id, input.run.id).map(({ id, purpose }) => ({ id, purpose })); return ["<stage_identity>", `- RUN: ${input.run.id}`, `- root Work: ${input.rootWork.work_id}`, `- stage: ${stage}`, `- mode: ${input.mode}`, "</stage_identity>", "", "<trusted_runtime_context>", `- project root: ${input.projectRoot}`, `- immutable write workspace: ${input.run.workspace_root}`, `- stage workspace: ${input.root}`, `- bounded CODE evidence root: ${path.join(input.run.run_home_path, "05-code")}`, "CODE is already semantically verified and all declared checks passed. Do not redo CODE verification; conduct independent quality review.", "</trusted_runtime_context>", "", "<review_groups>", ...input.groups.map((group) => `- ${group.key}: ${group.aspect_ids.join(", ")}`), "</review_groups>", "", "<execution_commands>", "Each reviewer Work must run in one fresh child session. The coordinator must never claim a reviewer Work itself. Start each ready Work with its exact start_command from the work graph; it receives the task and result schema. Reviewers are read-only and must not create subagents. Do not interrupt a quiet worker.", `Ready reviewer Works: ${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`, `Finish only after every reviewer Work settles and you have written ${decision}: ${finishCommand(context, input.run.id, input.projectRoot, decision)}`, "Reviewer results use local FIND-NNN ids. Classify them by the canonical WRK-.../FIND-NNN finding_ref returned by dd-flow. Fix P0/P1. Fix bounded safe P2 by default; defer only a legitimate P2 with a named DEF. P3 is an observation or a reasoned rejection, not automatic repair/DEF. For every disposition fix, check_refs is mandatory: choose the one or more causal checks from the accepted list below that the repair must rerun. Do not guess a CHK id or copy every check. The first successful Finish freezes this decision and creates one repair Work when needed. Run it, then call the same Finish command again. Review is not repeated.", "If the post-repair aggregate gate fails, do not stop after `code_review_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use the returned repair command with the failed receipt, a relevant completed origin Work ID, and a concise repair objective; then stop so the runner can dispatch the new repair. Do not edit invisibly in the root orchestrator.", `Accepted repair checks: ${JSON.stringify(checks)}`, "```json", JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "Evidence-backed conclusion.", findings: [{ finding_ref: "WRK-001-review/FIND-001", disposition: "fix | defer | reject | duplicate", reason: "Why this classification is correct.", check_refs: ["CHK-CAUSAL-CHECK only when disposition is fix"], def_id: "DEF-0001 only for an allowed P2 deferral", duplicate_of: "canonical finding_ref only for duplicate" }] }, null, 2), "```", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n"); }
179
207
  function canonicalReviewEvidence(decision, reviewers) {
180
208
  const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
181
209
  const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
@@ -212,6 +240,8 @@ export function reviewDecisionForRepair(decision, reviewers) {
212
240
  if (["p0", "p1"].includes(finding.priority) && item.disposition !== "fix") {
213
241
  throw new AppError("review_material_disposition_invalid", "P0/P1 CODE-REVIEW findings must be fixed, not rejected or deferred", 2, { finding_ref: findingRef, disposition: item.disposition });
214
242
  }
243
+ if (item.disposition === "fix" && !item.check_refs?.length)
244
+ throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ref: findingRef });
215
245
  }
216
246
  return { ...canonical, fix_ids: canonical.decision.findings.filter((item) => item.disposition === "fix").map((item) => item.finding_ref) };
217
247
  }
@@ -219,15 +249,24 @@ function validateDecision(context, input) {
219
249
  const findings = canonicalCodeFindings(input.reviewers);
220
250
  const known = new Map(findings.map((item) => [item.finding_ref, item.finding]));
221
251
  const decisions = new Map(input.decision.findings.map((item) => [item.finding_ref, item]));
222
- const repaired = new Set(input.repairs.flatMap((work) => { const result = readJsonString(work.result); return Array.isArray(result.resolved_finding_refs) ? result.resolved_finding_refs.filter((value) => typeof value === "string") : []; }));
252
+ const repairedBy = new Map();
253
+ for (const work of input.repairs) {
254
+ const result = readJsonString(work.result);
255
+ for (const ref of Array.isArray(result.resolved_finding_refs) ? result.resolved_finding_refs.filter((value) => typeof value === "string") : [])
256
+ repairedBy.set(ref, [...(repairedBy.get(ref) ?? []), work]);
257
+ }
223
258
  for (const { finding_ref: findingRef, finding } of findings) {
224
259
  const item = decisions.get(findingRef);
225
260
  if (!item)
226
261
  throw new AppError("review_evidence_invalid", "Every material CODE review finding needs one decision", 2, { finding_ref: findingRef });
227
- if (["p0", "p1"].includes(finding.priority) && !(item.disposition === "fix" && repaired.has(findingRef)))
262
+ const repaired = repairedBy.get(findingRef) ?? [];
263
+ if (["p0", "p1"].includes(finding.priority) && !(item.disposition === "fix" && repaired.length))
228
264
  throw new AppError("review_unresolved_material", "P0/P1 findings must be explicitly resolved by a completed clean repair Work", 2, { finding_ref: findingRef });
229
- if (item.disposition === "fix" && !repaired.has(findingRef))
265
+ if (item.disposition === "fix" && !repaired.length)
230
266
  throw new AppError("review_repair_missing", "A fixed CODE-REVIEW finding needs a completed clean repair Work that explicitly resolves it", 2, { finding_ref: findingRef });
267
+ const requiredChecks = item.check_refs ?? [];
268
+ if (item.disposition === "fix" && !repaired.some((work) => requiredChecks.every((ref) => reviewCheckRefs(work).includes(ref))))
269
+ throw new AppError("review_repair_check_missing", "A fixed CODE-REVIEW finding needs a repair Work that retains every selected causal check", 2, { finding_ref: findingRef, check_refs: requiredChecks });
231
270
  if (finding.priority === "p2" && item.disposition === "defer") {
232
271
  if (!item.def_id || !fs.existsSync(path.join(input.workspaceRoot, ".memory-bank", "defs", `${item.def_id}.md`)))
233
272
  throw new AppError("deferral_invalid", "A deferred P2 requires a named durable DEF", 2, { finding_ref: findingRef, def_id: item.def_id ?? null });
@@ -242,7 +281,17 @@ function validateDecision(context, input) {
242
281
  function validateReviewerResult(context, work, run) { if (!work.result)
243
282
  throw new AppError("review_evidence_invalid", "Reviewer Work has no result", 2, { work_id: work.work_id }); const file = path.join(requireHome(run), stageDir, `${work.work_id}.result.json`); fs.writeFileSync(file, work.result); validateSchema({ schemaName: "code-review-result", file, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id }); }
244
283
  function reviewerWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const p = readJsonString(work.payload_json); return Boolean(p && p.kind === "code-review"); }); }
245
- function codeWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).filter((work) => readJsonString(work.payload_json).schema_id === "dd-flow/code-work-packet@4" && !reviewFindingIds(work).length); }
284
+ function codeWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).filter((work) => readJsonString(work.payload_json).schema_id === "dd-flow/code-work-packet@5" && !reviewFindingIds(work).length); }
285
+ function acceptedCodeChecks(context, projectId, runId) { return codeWorks(context, projectId, runId).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }); }
286
+ function validateRepairChecks(context, input) { const known = new Set(acceptedCodeChecks(context, input.projectId, input.runId).map((check) => check.id)); const selected = {}; for (const item of input.decision.findings.filter((item) => item.disposition === "fix")) {
287
+ const refs = [...new Set(item.check_refs ?? [])];
288
+ if (!refs.length)
289
+ throw new AppError("review_repair_check_required", "Each accepted CODE-REVIEW repair needs an explicit causal CHK-* check reference", 2, { finding_ref: item.finding_ref });
290
+ const unknown = refs.filter((ref) => !known.has(ref));
291
+ if (unknown.length)
292
+ throw new AppError("review_check_reference_unknown", "CODE-REVIEW repair selects a check absent from the accepted CODE handoff", 2, { finding_ref: item.finding_ref, check_refs: unknown });
293
+ selected[item.finding_ref] = refs;
294
+ } return selected; }
246
295
  function reviewRepairWorks(context, projectId, runId, root) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => isCodeReviewStageRepair(work, root)); }
247
296
  export function isCodeReviewStageRepair(work, root) {
248
297
  if (reviewFindingIds(work).length)
@@ -258,8 +307,11 @@ export function isCodeReviewStageRepair(work, root) {
258
307
  }
259
308
  function reviewFindingIds(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
260
309
  return []; const ids = repair.review_finding_ids; return Array.isArray(ids) ? ids.filter((value) => typeof value === "string") : []; }
310
+ function reviewCheckRefs(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
311
+ return []; const refs = repair.review_check_refs; return Array.isArray(refs) ? refs.filter((value) => typeof value === "string") : []; }
261
312
  function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
262
313
  function effectiveMode(run, paths) { const index = JSON.parse(run.index_json); const requested = index.settings?.code_review?.mode ?? "auto"; return requested === "auto" ? (paths.length ? "standard" : "off") : requested; }
314
+ function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
263
315
  function changedPaths(workspaceRoot) { try {
264
316
  return execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd: workspaceRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split(/\r?\n/).filter(Boolean).map((line) => line.slice(3).replace(/^.* -> /, ""));
265
317
  }
@@ -267,7 +319,6 @@ catch {
267
319
  return [];
268
320
  } }
269
321
  function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
270
- function uniqueStrings(values) { return [...new Set(values)]; }
271
322
  function stageStatus(run, name) { try {
272
323
  return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === name)?.status ?? null;
273
324
  }
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, checkReceipts, codeExecutionEnvironment, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
9
  import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
10
10
  import { validateSchema } from "./schema-validation.js";
@@ -15,6 +15,7 @@ import { readVnextSpecifyResult } from "./vnext-specify.js";
15
15
  import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
16
16
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
17
  import { writeStageReport } from "./stage-report-renderer.js";
18
+ import { ensureVnextMergeRequest } from "./vnext-merge.js";
18
19
  import { assertStageStartHookEvent } from "./hooks.js";
19
20
  import { applyExternalStageContext } from "./stage-context.js";
20
21
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
@@ -141,16 +142,27 @@ export async function finishVnextCode(context, input) {
141
142
  const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks });
142
143
  if (unchangedFailures.length)
143
144
  throw new AppError("code_gate_repair_required", "CODE final gate already failed for the unchanged workspace; repair the evidenced failure before retrying", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures });
144
- const receipts = await runCodeChecks(context, {
145
- projectId: project.id,
146
- runId: run.id,
147
- runHome: home,
148
- workspaceRoot: run.workspace_root,
149
- scope: "aggregate",
150
- checks,
151
- ...(input.progress ? { progress: input.progress } : {})
152
- });
153
- const failed = receipts.filter((receipt) => receipt.status === "failed");
145
+ let receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks });
146
+ if (!receipts) {
147
+ const release = acquireAggregateGateLock(root);
148
+ try {
149
+ // A prior caller may have completed the slow gate while this caller was
150
+ // waiting to acquire the lock. Reuse that immutable evidence.
151
+ receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks }) ?? await runCodeChecks(context, {
152
+ projectId: project.id,
153
+ runId: run.id,
154
+ runHome: home,
155
+ workspaceRoot: run.workspace_root,
156
+ scope: "aggregate",
157
+ checks,
158
+ ...(input.progress ? { progress: input.progress } : {})
159
+ });
160
+ }
161
+ finally {
162
+ release();
163
+ }
164
+ }
165
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
154
166
  if (failed.length) {
155
167
  throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
156
168
  failures: failed,
@@ -166,7 +178,7 @@ export async function finishVnextCode(context, input) {
166
178
  const timing = stageTiming(home, stage, now);
167
179
  const changedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
168
180
  const next = nextAction(run, changedPaths);
169
- writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : "CODE complete; this RUN reached its configured terminal boundary.");
181
+ writeProtocolFlowStatus(run.workspace_root, home, run.id, next === "start_code_review" ? "CODE complete; CODE-REVIEW is next." : next === "start_merge" ? "CODE complete; MERGE is queued." : "CODE complete; this RUN reached its configured terminal boundary.");
170
182
  const report = {
171
183
  schema_id: "dd-flow/stage-report@1",
172
184
  run_id: run.id,
@@ -223,8 +235,9 @@ export async function finishVnextCode(context, input) {
223
235
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
224
236
  }
225
237
  else {
226
- advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: "start_code_review" });
238
+ advanceFlowRun(context, { projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
227
239
  }
240
+ const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
228
241
  return {
229
242
  ok: true,
230
243
  run_id: run.id,
@@ -232,7 +245,7 @@ export async function finishVnextCode(context, input) {
232
245
  outcome: "accepted",
233
246
  report_path: path.join(root, "stage-report.json"),
234
247
  next_action: next,
235
- ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : {})
248
+ ...(next === "start_code_review" ? { next: { kind: "start_stage", stage: "code-review", command: `${flowCommand(context)} stage start ${run.id} --stage code-review --project-root ${JSON.stringify(projectRoot)} --json` } } : next === "start_merge" ? { merge_request: mergeRequest, next: { kind: "start_stage", stage: "merge", command: `${flowCommand(context)} stage start ${run.id} --stage merge --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } } : {})
236
249
  };
237
250
  }
238
251
  export function addVnextCodeRepair(context, input) {
@@ -271,14 +284,14 @@ export function addVnextCodeRepair(context, input) {
271
284
  const key = input.reviewFindingIds?.length ? "code-review-repair" : "code-gate-repair";
272
285
  const receiptWriteScope = receipt ? receiptRepairPaths(run.workspace_root, receipt) : [];
273
286
  const repair = {
274
- schema_id: "dd-flow/code-work-packet@4",
287
+ schema_id: "dd-flow/code-work-packet@5",
275
288
  key,
276
289
  launch_policy: "fresh_agent_required",
277
290
  source: first.source,
278
291
  repair: {
279
292
  origin_work_ids: origins.map((work) => work.work_id),
280
293
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
281
- ...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []) } : {})
294
+ ...(input.reviewFindingIds?.length ? { review_finding_ids: unique(input.reviewFindingIds), review_evidence_refs: unique(input.reviewEvidenceRefs ?? []), review_check_refs: unique(input.reviewCheckRefs ?? (input.reviewChecks ?? []).map((check) => check.id)) } : {})
282
295
  },
283
296
  task: input.objective,
284
297
  semantic_spine: {
@@ -290,22 +303,22 @@ export function addVnextCodeRepair(context, input) {
290
303
  },
291
304
  requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
292
305
  acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
293
- document_updates: receipt ? [] : uniqueBy(packets.flatMap((value) => value.document_updates).filter((update) => (input.reviewEvidenceRefs ?? []).some((reference) => reference === update.path || reference.startsWith(`${update.path}:`) || reference.endsWith(`/${update.path}`))), (value) => JSON.stringify(value)),
306
+ // A CODE-REVIEW repair changes delivered code/evidence, never the
307
+ // already accepted PLAN or its ownership declaration.
308
+ document_updates: [],
294
309
  required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...packets.flatMap((value) => value.required_read)]),
295
310
  discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
296
- // A failed deterministic check can name an exact existing project file
297
- // which was not owned by its origin Work (for example a formatter's
298
- // project-level configuration). Admit only those normalized file paths;
299
- // never infer a directory or accept a path outside the workspace.
300
- write_scope: unique([...packets.flatMap((value) => value.write_scope), ...input.writeScope, ...receiptWriteScope]),
311
+ // These are collision-avoidance hints. Receipt paths enrich the coordinator
312
+ // picture but never restrict the repair's project-local edits.
313
+ planned_write_areas: unique([...packets.flatMap((value) => value.planned_write_areas), ...receiptWriteScope]),
301
314
  // Receipts record the resolved shell command. A repair must retain the
302
315
  // accepted declaration (including its immutable @check alias) and only
303
316
  // change when it runs, so work finish can validate it again.
304
- checks: uniqueBy([...(failedCheck ? [{ ...failedCheck, purpose: "Re-run the failed accepted check.", run_at: "work" }] : []), ...packets.flatMap((value) => value.checks)], (check) => check.id),
317
+ checks: selectRepairChecks({ ...(failedCheck ? { failedCheck } : {}), ...(input.reviewChecks ? { reviewChecks: input.reviewChecks } : {}) }),
305
318
  provides_checks: [],
306
319
  stop_conditions: unique([
307
320
  ...invariantPackets.flatMap((value) => value.stop_conditions),
308
- ...(receipt ? ["Stop and report the blocker if the evidenced root cause is outside write_scope or a proposed repair would contradict an accepted requirement."] : [])
321
+ ...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : ["Do not modify accepted PLAN artifacts, code-work-batch.json, or review decisions. Stop and report if the finding cannot be repaired within accepted scope."])
309
322
  ]),
310
323
  depends_on: origins.map((work) => work.work_id),
311
324
  result_schema: "dd-flow/code-work-result@2"
@@ -331,6 +344,13 @@ export function addVnextCodeRepair(context, input) {
331
344
  refreshRunWorkProjection(context, project.id, run.id);
332
345
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
333
346
  }
347
+ /** The repair Work runs only the check that motivated it; the stage retains the aggregate gate. */
348
+ export function selectRepairChecks(input) {
349
+ const checks = input.failedCheck
350
+ ? [{ ...input.failedCheck, purpose: "Re-run the failed accepted check.", run_at: "work" }]
351
+ : (input.reviewChecks ?? []).map((check) => ({ ...check, purpose: `Prove the CODE-REVIEW repair: ${check.purpose}`, run_at: "work" }));
352
+ return uniqueBy(checks, (check) => check.id);
353
+ }
334
354
  function receiptRepairPaths(workspaceRoot, receipt) {
335
355
  const root = path.resolve(workspaceRoot);
336
356
  const text = [receipt.stdout_path, receipt.stderr_path]
@@ -382,10 +402,10 @@ function coordinatorPrompt(context, input) {
382
402
  "",
383
403
  "<execution_commands>",
384
404
  "Launch only entries listed in graph.ready. Use at most the known available slot count. Every registered CODE Work runs in a fresh child Session, including a serial dependency chain; the coordinator owns dispatch and the stage conclusion, not implementation Work. Every child starts with its exact start_command and receives its complete packet from dd-flow. After a Work finishes, use the graph returned by work finish to launch newly ready Work. To refresh the parent graph yourself use the exact command: " + `${flowCommand(context)} work ls --run ${input.run.id} --ready --project-root ${JSON.stringify(input.projectRoot)} --json`,
385
- "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
405
+ "A quiet child is still running until the harness reports its turn completed, failed, cancelled or explicitly needs attention. An elapsed nominal wait, silence, or no new artifact is not an unresponsive-worker failure. Never interrupt, replace, relaunch, or stage-block a still-running child for that reason, even if an external controller asks. Long work finish and stage finish commands emit check progress on stderr. After you issue the exact CODE stage finish command, wait for that same command to return: do not inspect its PID, start a second finish command, or infer failure from quiet output. Close a disposable child only after its Work is accepted or explicitly failed/cancelled and the harness reports the turn settled.",
386
406
  `A repairable engine, harness, or environment failure is not a user question. Record it without finishing CODE: ${flowCommand(context)} stage block ${input.run.id} --stage code --work ${input.rootWork.work_id} --kind <engine|harness|environment> --code <stable-code> --summary-stdin --retryable --project-root ${JSON.stringify(input.projectRoot)} --json. Repair it externally, then run the exact unblock_command returned by dd-flow and continue this same stage.`,
387
407
  `When every CODE and repair Work is completed, write ${path.join(input.root, "code-verification.json")} using the exact contract below. Mark passed only when all accepted requirements and current-gate acceptance criteria are implemented or explicitly evidenced; list every remaining issue in unresolved. Every evidence_refs item must already exist as a relative workspace path or run://${input.run.id}/ path. Do not claim a browser or other check receipt that was not retained. Then finish: ${finishCommand(context, input.run.id, input.projectRoot, path.join(input.root, "code-verification.json"))}`,
388
- "If the aggregate gate fails, use the returned repair command with the relevant completed origin Work IDs and a concise repair objective. Do not edit invisibly in the root orchestrator.",
408
+ "If the aggregate gate fails, do not stop after `code_gate_failed`: that rejected finish does not create a repair Work. In the same coordinator Turn, use its returned repair command with the relevant completed origin Work IDs and a concise repair objective, then stop so the runner can dispatch the newly declared repair. Do not edit invisibly in the root orchestrator.",
389
409
  "</execution_commands>",
390
410
  "",
391
411
  "<verification_contract>",
@@ -407,13 +427,77 @@ export function profileCommands(workspaceRoot) {
407
427
  function latestReceiptsByCommand(receipts) {
408
428
  const latest = new Map();
409
429
  for (const receipt of receipts)
410
- latest.set(receipt.declaration_id, receipt);
411
- return [...latest.values()];
430
+ for (const ref of receipt.check_refs)
431
+ latest.set(ref, receipt);
432
+ return [...new Set(latest.values())];
433
+ }
434
+ /**
435
+ * A CODE finish may be retried after a client loses the long command's output.
436
+ * Reusing complete, same-workspace receipts is correct; rerunning them is both
437
+ * expensive and can race the still-settling original invocation.
438
+ */
439
+ export function reusableAggregateGateReceipts(context, input) {
440
+ const expected = new Set(input.checks.map((check) => check.id));
441
+ const current = workspaceFingerprint(input.workspaceRoot);
442
+ const latest = new Map();
443
+ for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
444
+ if (receipt.scope === "aggregate")
445
+ for (const ref of receipt.check_refs)
446
+ if (expected.has(ref))
447
+ latest.set(ref, receipt);
448
+ }
449
+ if (latest.size !== expected.size)
450
+ return null;
451
+ const receipts = input.checks.map((check) => latest.get(check.id));
452
+ return receipts.every((receipt) => receipt.status === "passed" && receipt.workspace_fingerprint === current) ? receipts : null;
453
+ }
454
+ /** One aggregate gate per RUN; a second invocation must wait, never duplicate it. */
455
+ function acquireAggregateGateLock(root) {
456
+ const directory = path.join(root, "checks");
457
+ const lock = path.join(directory, ".aggregate-gate.lock");
458
+ fs.mkdirSync(directory, { recursive: true });
459
+ try {
460
+ const descriptor = fs.openSync(lock, "wx");
461
+ fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
462
+ fs.closeSync(descriptor);
463
+ }
464
+ catch (error) {
465
+ if (!isExistingLock(error))
466
+ throw error;
467
+ const activePid = lockPid(lock);
468
+ if (activePid && processAlive(activePid)) {
469
+ throw new AppError("code_gate_in_progress", "CODE aggregate gate is already running; wait for that exact stage finish command to return", 1, { pid: activePid, lock });
470
+ }
471
+ fs.rmSync(lock, { force: true });
472
+ return acquireAggregateGateLock(root);
473
+ }
474
+ return () => fs.rmSync(lock, { force: true });
475
+ }
476
+ function isExistingLock(error) {
477
+ return Boolean(error && typeof error === "object" && error.code === "EEXIST");
478
+ }
479
+ function lockPid(lock) {
480
+ try {
481
+ const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
482
+ return Number.isInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : null;
483
+ }
484
+ catch {
485
+ return null;
486
+ }
487
+ }
488
+ function processAlive(pid) {
489
+ try {
490
+ process.kill(pid, 0);
491
+ return true;
492
+ }
493
+ catch {
494
+ return false;
495
+ }
412
496
  }
413
- function verificationProjection(works, receipts) {
497
+ export function verificationProjection(works, receipts) {
414
498
  const packets = works.map((work) => packet(work)).filter((value) => value !== null);
415
499
  const declarations = uniqueBy(packets.flatMap((value) => value.checks), (value) => value.id);
416
- const receiptByDeclaration = new Map(receipts.map((receipt) => [receipt.declaration_id, receipt]));
500
+ const receiptByDeclaration = new Map(receipts.flatMap((receipt) => receipt.check_refs.map((ref) => [ref, receipt])));
417
501
  const evidence = new Map();
418
502
  for (const work of works) {
419
503
  const result = readJsonResult(work.result);
@@ -422,9 +506,19 @@ function verificationProjection(works, receipts) {
422
506
  evidence.set(item.criterion_id, unique([...(evidence.get(item.criterion_id) ?? []), ...(item.refs ?? [])]));
423
507
  }
424
508
  const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
509
+ const hasEvidence = (criterionId) => (evidence.get(criterionId)?.length ?? 0) > 0;
510
+ const isPassed = (checkId, criterionId) => receiptByDeclaration.get(checkId)?.status === "passed" || (declarations.find((check) => check.id === checkId)?.run_at === "external" && hasEvidence(criterionId));
425
511
  return {
426
- checks: declarations.map((check) => { const receipt = receiptByDeclaration.get(check.id); return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null }; }),
427
- acceptance: acceptance.map((item) => ({ criterion_id: item.criterion_id, gate: item.gate, status: ["work", "code", "readiness"].includes(item.gate) ? ((item.check_refs.every((id) => receiptByDeclaration.get(id)?.status === "passed") && (evidence.get(item.criterion_id)?.length ?? 0) > 0) ? "confirmed" : "unresolved") : "not_due", check_refs: item.check_refs, evidence_refs: evidence.get(item.criterion_id) ?? [] }))
512
+ checks: declarations.map((check) => {
513
+ const receipt = receiptByDeclaration.get(check.id);
514
+ const externallyEvidenced = check.run_at === "external" && acceptance.some((item) => item.check_refs.includes(check.id) && hasEvidence(item.criterion_id));
515
+ return { id: check.id, run_at: check.run_at, status: receipt?.status ?? (externallyEvidenced ? "evidence_recorded" : ["work", "code", "readiness"].includes(check.run_at) ? "failed" : "not_due"), receipt_ref: receipt?.receipt_path ?? null };
516
+ }),
517
+ acceptance: acceptance.map((item) => {
518
+ const external = item.check_refs.some((id) => declarations.find((check) => check.id === id)?.run_at === "external");
519
+ const confirmed = item.check_refs.every((id) => isPassed(id, item.criterion_id)) && hasEvidence(item.criterion_id);
520
+ return { criterion_id: item.criterion_id, gate: item.gate, status: confirmed ? (external ? "confirmed_with_external_evidence" : "confirmed") : ["work", "code", "readiness"].includes(item.gate) ? "unresolved" : "not_due", check_refs: item.check_refs, evidence_refs: evidence.get(item.criterion_id) ?? [] };
521
+ })
428
522
  };
429
523
  }
430
524
  function readJsonResult(value) { try {
@@ -454,7 +548,7 @@ function packet(work) {
454
548
  return null;
455
549
  try {
456
550
  const value = JSON.parse(work.payload_json);
457
- return value.schema_id === "dd-flow/code-work-packet@4" ? value : null;
551
+ return value.schema_id === "dd-flow/code-work-packet@5" ? value : null;
458
552
  }
459
553
  catch {
460
554
  return null;
@@ -570,7 +664,10 @@ function nextAction(run, changedPaths) {
570
664
  const index = JSON.parse(run.index_json);
571
665
  const requested = index.settings?.code_review?.mode ?? "auto";
572
666
  const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
573
- return enabled && index.execution_profile?.settings?.stop_target === "code_review_completed" ? "start_code_review" : "code_completed";
667
+ const stopTarget = index.execution_profile?.settings?.stop_target;
668
+ if (enabled && (stopTarget === "code_review_completed" || stopTarget === "merge_completed"))
669
+ return "start_code_review";
670
+ return stopTarget === "merge_completed" ? "start_merge" : "code_completed";
574
671
  }
575
672
  function stageStatus(run, name) {
576
673
  try {
@@ -13,14 +13,17 @@ export function loadVnextExecutionProfile(projectRoot) {
13
13
  throw new AppError("execution_profile_missing", "vNext flow requires .memory-bank/dd-flow/project-execution.json", 1, { file, cause: String(error) });
14
14
  }
15
15
  const profile = value;
16
- if (profile.schema_id !== "dd-flow/project-execution@1"
16
+ if (profile.schema_id !== "dd-flow/project-execution@2"
17
17
  || (profile.stage_session_mode !== undefined && profile.stage_session_mode !== "same_session" && profile.stage_session_mode !== "new_session")
18
18
  || !["auto", "off", "standard", "deep"].includes(String(profile.plan_review_mode))
19
19
  || (profile.code_review_mode !== undefined && !["auto", "off", "standard", "deep"].includes(String(profile.code_review_mode)))
20
- || !["code_completed", "code_review_completed"].includes(String(profile.stop_target))
20
+ || !["same_session", "server"].includes(String(profile.merge_mode))
21
+ || profile.merge_delivery?.strategy !== "local"
22
+ || !["retain", "delete_after_success"].includes(String(profile.merge_cleanup?.source))
23
+ || !["code_completed", "code_review_completed", "merge_completed"].includes(String(profile.stop_target))
21
24
  || !profile.code_bootstrap || typeof profile.code_bootstrap.command !== "string" || !profile.code_bootstrap.command.trim()
22
25
  || typeof profile.code_bootstrap.policy_ref !== "string" || !profile.code_bootstrap.policy_ref.trim()) {
23
- throw new AppError("execution_profile_invalid", "Project execution profile does not match dd-flow/project-execution@1", 1, { file });
26
+ throw new AppError("execution_profile_invalid", "Project execution profile does not match dd-flow/project-execution@2", 1, { file });
24
27
  }
25
28
  return { ...profile, stage_session_mode: profile.stage_session_mode ?? "same_session", code_review_mode: profile.code_review_mode ?? "off" };
26
29
  }