@deksden-com/dd-flow-cli 0.8.0 → 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.
@@ -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,11 +85,24 @@ 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
107
  objective: input.objective
94
108
  });
@@ -101,15 +115,17 @@ export async function finishVnextCodeReview(context, input) {
101
115
  const root = path.join(home, stageDir);
102
116
  const mode = effectiveMode(run, changedPaths(run.workspace_root));
103
117
  const reportPath = path.join(root, "stage-report.json");
104
- if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath))
105
- 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
+ }
106
122
  const rootWork = requireRootWork(context, project.id, run.id);
107
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]);
108
124
  if (!coordinator || path.resolve(coordinator.result_path ?? "") !== path.resolve(reportPath))
109
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 });
110
126
  const decisionFile = input.decisionFile ?? path.join(root, "decision.json");
111
127
  if (!fs.existsSync(decisionFile) && mode === "off")
112
- 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`);
113
129
  validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id });
114
130
  const decision = readJson(decisionFile);
115
131
  const reviewers = reviewerWorks(context, project.id, run.id);
@@ -120,6 +136,7 @@ export async function finishVnextCodeReview(context, input) {
120
136
  for (const work of reviewers)
121
137
  validateReviewerResult(context, work, run);
122
138
  const canonical = reviewDecisionForRepair(decision, reviewers);
139
+ const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
123
140
  let repairs = reviewRepairWorks(context, project.id, run.id, root);
124
141
  const decisionSha = sha256File(decisionFile);
125
142
  const frozenShaFile = path.join(root, ".decision-sha256");
@@ -127,8 +144,8 @@ export async function finishVnextCodeReview(context, input) {
127
144
  throw new AppError("review_decision_changed", "The accepted CODE-REVIEW decision cannot change during repair", 2, { decision_file: decisionFile });
128
145
  const fixIds = canonical.fix_ids;
129
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(", ")}` });
130
148
  fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
131
- const repair = addVnextCodeReviewRepair(context, { projectRoot, runId: run.id, findingIds: fixIds, objective: `Resolve accepted CODE-REVIEW findings: ${fixIds.join(", ")}` });
132
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) } };
133
150
  }
134
151
  repairs = reviewRepairWorks(context, project.id, run.id, root);
@@ -144,20 +161,33 @@ export async function finishVnextCodeReview(context, input) {
144
161
  if (unchangedFailures.length)
145
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 });
146
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 } : {}) }) : [];
147
- const failed = receipts.filter((receipt) => receipt.status === "failed");
164
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
148
165
  if (failed.length)
149
- 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";
150
175
  const now = context.now();
151
176
  const startedAt = stageStartedAt(run, now);
152
- writeProtocolFlowStatus(run.workspace_root, home, run.id, "CODE-REVIEW complete; this RUN reached its configured terminal boundary.");
153
- 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" } };
154
179
  writeReport(root, report);
155
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" });
156
- await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
157
181
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
158
- 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
+ }
159
188
  refreshRunWorkProjection(context, project.id, run.id);
160
- 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` } } : {}) };
161
191
  }
162
192
  function reviewGroups(run, home, mode) {
163
193
  const aspectMapFiles = findFiles(path.join(home, "03-plan"), "aspect-map.json");
@@ -173,7 +203,7 @@ function reviewGroups(run, home, mode) {
173
203
  return Array.from({ length: Math.ceil(all.length / chunk) }, (_, index) => ({ key: `group-${index + 1}`, aspect_ids: all.slice(index * chunk, (index + 1) * chunk) }));
174
204
  }
175
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.`; }
176
- 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"); }
177
207
  function canonicalReviewEvidence(decision, reviewers) {
178
208
  const known = new Set(canonicalCodeFindings(reviewers).map((item) => item.finding_ref));
179
209
  const items = new Map(decision.findings.map((item) => [item.finding_ref, item]));
@@ -210,6 +240,8 @@ export function reviewDecisionForRepair(decision, reviewers) {
210
240
  if (["p0", "p1"].includes(finding.priority) && item.disposition !== "fix") {
211
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 });
212
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 });
213
245
  }
214
246
  return { ...canonical, fix_ids: canonical.decision.findings.filter((item) => item.disposition === "fix").map((item) => item.finding_ref) };
215
247
  }
@@ -217,15 +249,24 @@ function validateDecision(context, input) {
217
249
  const findings = canonicalCodeFindings(input.reviewers);
218
250
  const known = new Map(findings.map((item) => [item.finding_ref, item.finding]));
219
251
  const decisions = new Map(input.decision.findings.map((item) => [item.finding_ref, item]));
220
- 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
+ }
221
258
  for (const { finding_ref: findingRef, finding } of findings) {
222
259
  const item = decisions.get(findingRef);
223
260
  if (!item)
224
261
  throw new AppError("review_evidence_invalid", "Every material CODE review finding needs one decision", 2, { finding_ref: findingRef });
225
- 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))
226
264
  throw new AppError("review_unresolved_material", "P0/P1 findings must be explicitly resolved by a completed clean repair Work", 2, { finding_ref: findingRef });
227
- if (item.disposition === "fix" && !repaired.has(findingRef))
265
+ if (item.disposition === "fix" && !repaired.length)
228
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 });
229
270
  if (finding.priority === "p2" && item.disposition === "defer") {
230
271
  if (!item.def_id || !fs.existsSync(path.join(input.workspaceRoot, ".memory-bank", "defs", `${item.def_id}.md`)))
231
272
  throw new AppError("deferral_invalid", "A deferred P2 requires a named durable DEF", 2, { finding_ref: findingRef, def_id: item.def_id ?? null });
@@ -241,6 +282,16 @@ function validateReviewerResult(context, work, run) { if (!work.result)
241
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 }); }
242
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"); }); }
243
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; }
244
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)); }
245
296
  export function isCodeReviewStageRepair(work, root) {
246
297
  if (reviewFindingIds(work).length)
@@ -256,8 +307,11 @@ export function isCodeReviewStageRepair(work, root) {
256
307
  }
257
308
  function reviewFindingIds(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
258
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") : []; }
259
312
  function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
260
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"; }
261
315
  function changedPaths(workspaceRoot) { try {
262
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(/^.* -> /, ""));
263
317
  }
@@ -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";
@@ -161,7 +162,7 @@ export async function finishVnextCode(context, input) {
161
162
  release();
162
163
  }
163
164
  }
164
- const failed = receipts.filter((receipt) => receipt.status === "failed");
165
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
165
166
  if (failed.length) {
166
167
  throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
167
168
  failures: failed,
@@ -177,7 +178,7 @@ export async function finishVnextCode(context, input) {
177
178
  const timing = stageTiming(home, stage, now);
178
179
  const changedPaths = [...new Set(works.flatMap((work) => resultPaths(work.result)))];
179
180
  const next = nextAction(run, changedPaths);
180
- 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.");
181
182
  const report = {
182
183
  schema_id: "dd-flow/stage-report@1",
183
184
  run_id: run.id,
@@ -234,8 +235,9 @@ export async function finishVnextCode(context, input) {
234
235
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
235
236
  }
236
237
  else {
237
- 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 });
238
239
  }
240
+ const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id }) : null;
239
241
  return {
240
242
  ok: true,
241
243
  run_id: run.id,
@@ -243,7 +245,7 @@ export async function finishVnextCode(context, input) {
243
245
  outcome: "accepted",
244
246
  report_path: path.join(root, "stage-report.json"),
245
247
  next_action: next,
246
- ...(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` } } : {})
247
249
  };
248
250
  }
249
251
  export function addVnextCodeRepair(context, input) {
@@ -289,7 +291,7 @@ export function addVnextCodeRepair(context, input) {
289
291
  repair: {
290
292
  origin_work_ids: origins.map((work) => work.work_id),
291
293
  ...(receipt ? { check_receipt_id: receipt.id, failure_receipt_path: receipt.receipt_path } : {}),
292
- ...(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)) } : {})
293
295
  },
294
296
  task: input.objective,
295
297
  semantic_spine: {
@@ -301,7 +303,9 @@ export function addVnextCodeRepair(context, input) {
301
303
  },
302
304
  requirements: uniqueBy(invariantPackets.flatMap((value) => value.requirements), (value) => value.id),
303
305
  acceptance: uniqueBy(invariantPackets.flatMap((value) => value.acceptance), (value) => JSON.stringify(value)),
304
- 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: [],
305
309
  required_read: unique([...(receipt ? [receipt.receipt_path] : input.reviewEvidenceRefs ?? []), ...packets.flatMap((value) => value.required_read)]),
306
310
  discovery_boundary: unique(packets.flatMap((value) => value.discovery_boundary)),
307
311
  // These are collision-avoidance hints. Receipt paths enrich the coordinator
@@ -310,11 +314,11 @@ export function addVnextCodeRepair(context, input) {
310
314
  // Receipts record the resolved shell command. A repair must retain the
311
315
  // accepted declaration (including its immutable @check alias) and only
312
316
  // change when it runs, so work finish can validate it again.
313
- 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 } : {}) }),
314
318
  provides_checks: [],
315
319
  stop_conditions: unique([
316
320
  ...invariantPackets.flatMap((value) => value.stop_conditions),
317
- ...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : [])
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."])
318
322
  ]),
319
323
  depends_on: origins.map((work) => work.work_id),
320
324
  result_schema: "dd-flow/code-work-result@2"
@@ -340,6 +344,13 @@ export function addVnextCodeRepair(context, input) {
340
344
  refreshRunWorkProjection(context, project.id, run.id);
341
345
  return { ok: true, run_id: run.id, repair_work_id: id, start_command: workStartCommand(context, work) };
342
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
+ }
343
354
  function receiptRepairPaths(workspaceRoot, receipt) {
344
355
  const root = path.resolve(workspaceRoot);
345
356
  const text = [receipt.stdout_path, receipt.stderr_path]
@@ -394,7 +405,7 @@ function coordinatorPrompt(context, input) {
394
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.",
395
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.`,
396
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"))}`,
397
- "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.",
398
409
  "</execution_commands>",
399
410
  "",
400
411
  "<verification_contract>",
@@ -416,8 +427,9 @@ export function profileCommands(workspaceRoot) {
416
427
  function latestReceiptsByCommand(receipts) {
417
428
  const latest = new Map();
418
429
  for (const receipt of receipts)
419
- latest.set(receipt.declaration_id, receipt);
420
- return [...latest.values()];
430
+ for (const ref of receipt.check_refs)
431
+ latest.set(ref, receipt);
432
+ return [...new Set(latest.values())];
421
433
  }
422
434
  /**
423
435
  * A CODE finish may be retried after a client loses the long command's output.
@@ -429,8 +441,10 @@ export function reusableAggregateGateReceipts(context, input) {
429
441
  const current = workspaceFingerprint(input.workspaceRoot);
430
442
  const latest = new Map();
431
443
  for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
432
- if (receipt.scope === "aggregate" && expected.has(receipt.declaration_id))
433
- latest.set(receipt.declaration_id, receipt);
444
+ if (receipt.scope === "aggregate")
445
+ for (const ref of receipt.check_refs)
446
+ if (expected.has(ref))
447
+ latest.set(ref, receipt);
434
448
  }
435
449
  if (latest.size !== expected.size)
436
450
  return null;
@@ -480,10 +494,10 @@ function processAlive(pid) {
480
494
  return false;
481
495
  }
482
496
  }
483
- function verificationProjection(works, receipts) {
497
+ export function verificationProjection(works, receipts) {
484
498
  const packets = works.map((work) => packet(work)).filter((value) => value !== null);
485
499
  const declarations = uniqueBy(packets.flatMap((value) => value.checks), (value) => value.id);
486
- 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])));
487
501
  const evidence = new Map();
488
502
  for (const work of works) {
489
503
  const result = readJsonResult(work.result);
@@ -492,9 +506,19 @@ function verificationProjection(works, receipts) {
492
506
  evidence.set(item.criterion_id, unique([...(evidence.get(item.criterion_id) ?? []), ...(item.refs ?? [])]));
493
507
  }
494
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));
495
511
  return {
496
- 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 }; }),
497
- 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
+ })
498
522
  };
499
523
  }
500
524
  function readJsonResult(value) { try {
@@ -640,7 +664,10 @@ function nextAction(run, changedPaths) {
640
664
  const index = JSON.parse(run.index_json);
641
665
  const requested = index.settings?.code_review?.mode ?? "auto";
642
666
  const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
643
- 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";
644
671
  }
645
672
  function stageStatus(run, name) {
646
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
  }