@deksden-com/dd-flow-cli 0.9.0-beta.49 → 0.9.0-beta.51

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.
@@ -10,15 +10,17 @@ import { validateSchema } from "./schema-validation.js";
10
10
  import { flowCommand } from "./stage-pause.js";
11
11
  import { assertStageStartHookEvent } from "./hooks.js";
12
12
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
13
- import { addVnextCodeRepair } from "./vnext-code.js";
14
- import { finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
15
- import { addWorkBatch, bindStageCoordinatorWork, finishWork, refreshRunWorkProjection } from "./work-registry.js";
13
+ import { addVnextCodeRepair, resumeAcceptedCodeStage } from "./vnext-code.js";
14
+ import { finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
15
+ import { addWorkBatch, bindStageCoordinatorWork, finishStageCoordinatorWork, refreshRunWorkProjection } from "./work-registry.js";
16
+ import { withStageSettlement } from "./runs.js";
16
17
  import { vnextStageDirectory } from "../domain/stage-catalog.js";
17
18
  import { writeStageReport } from "./stage-report-renderer.js";
18
19
  import { applyExternalStageContext } from "./stage-context.js";
19
20
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
20
21
  import { ensureVnextMergeRequest } from "./vnext-merge.js";
21
22
  import { validateVnextCodeHandoff } from "./vnext-plan.js";
23
+ import { assertRunMutationAllowed } from "./run-recovery.js";
22
24
  const stage = "code-review";
23
25
  const stageDir = vnextStageDirectory(stage);
24
26
  const baselineAspects = ["goal_traceability", "coding_standards_design_review", "verification_evidence_review"];
@@ -123,6 +125,7 @@ export async function finishVnextCodeReview(context, input) {
123
125
  const mode = reviewContext.mode;
124
126
  if (!mode)
125
127
  throw new AppError("runtime_missing", "CODE-REVIEW effective mode was not frozen at stage start", 1);
128
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
126
129
  if (reviewContext.code_report_sha256 !== sha256File(path.join(home, "05-code", "stage-report.json")))
127
130
  throw new AppError("code_review_input_changed", "Accepted CODE report changed after CODE-REVIEW start", 2);
128
131
  const handoffFailures = validateVnextCodeHandoff(context, { projectRoot, workspaceRoot: run.workspace_root, runId: run.id, home });
@@ -131,7 +134,9 @@ export async function finishVnextCodeReview(context, input) {
131
134
  const reportPath = path.join(root, "stage-report.json");
132
135
  if (stageStatus(run, stage) === "done" && fs.existsSync(reportPath)) {
133
136
  const report = readJson(reportPath);
134
- return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: report.semantic?.next_action ?? "code_review_completed" };
137
+ const next = report.semantic?.next_action ?? "code_review_completed";
138
+ resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next);
139
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: reportPath, next_action: next };
135
140
  }
136
141
  const rootWork = requireRootWork(context, project.id, run.id);
137
142
  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]);
@@ -145,6 +150,7 @@ export async function finishVnextCodeReview(context, input) {
145
150
  const cycle = reviewContext.cycle ?? "initial";
146
151
  const reviewers = reviewerWorks(context, project.id, run.id, cycle);
147
152
  if (mode !== "off") {
153
+ assertCodeReviewCoverage(reviewGroups(home), reviewers);
148
154
  const incomplete = reviewers.filter((work) => work.status !== "completed");
149
155
  if (incomplete.length)
150
156
  throw new AppError("reviewer_jobs_incomplete", "CODE-REVIEW cannot finish while reviewer Work is unsettled", 2, { works: incomplete.map((work) => ({ work_id: work.work_id, status: work.status })) });
@@ -173,13 +179,10 @@ export async function finishVnextCodeReview(context, input) {
173
179
  const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
174
180
  const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
175
181
  if (input.retryCheckId) {
176
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
182
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, reason: input.retryReason });
177
183
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
178
184
  }
179
- const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
180
- if (unchangedFailures.length)
181
- throw new AppError("code_review_gate_repair_required", "CODE-REVIEW final gate already failed for the unchanged workspace; choose source repair or restore the environment and retry that receipt", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures, retry_command: `${finishCommand(context, run.id, projectRoot, decisionFile)} --retry-check ${unchangedFailures[0].id} --reason "<environment recovery evidence>"` });
182
- const receipts = 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 } : {}) });
185
+ const receipts = await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, artifactDir: stageDir, scope: "aggregate", checks: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}), ...(input.progress ? { progress: input.progress } : {}) });
183
186
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
184
187
  if (failed.length)
185
188
  throw new AppError("code_review_gate_failed", "CODE-REVIEW repair changed the project but the aggregate gate failed", 2, {
@@ -200,18 +203,21 @@ export async function finishVnextCodeReview(context, input) {
200
203
  const acceptedPaths = changedPaths(run.workspace_root);
201
204
  const mergeRequest = nextAction === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths }) : null;
202
205
  writeReport(root, report);
203
- 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" });
204
- appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
205
- if (nextAction === "start_merge")
206
- advanceFlowRun(context, { settlementStage: "code-review", projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
207
- else {
208
- await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
209
- completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
210
- }
211
- refreshRunWorkProjection(context, project.id, run.id);
206
+ withStageSettlement(context, () => {
207
+ assertRunMutationAllowed(context, project.id, run.id, generation);
208
+ 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" });
209
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_completed", work_id: rootWork.work_id, outcome });
210
+ if (nextAction === "start_merge")
211
+ advanceFlowRun(context, { settlementStage: "code-review", projectRoot, runId: run.id, status: "running", verdict: "code_review_completed", nextAction });
212
+ else {
213
+ finishStageCoordinatorWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
214
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_review_completed", nextAction: undefined });
215
+ }
216
+ refreshRunWorkProjection(context, project.id, run.id);
217
+ });
212
218
  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` } } : {}) };
213
219
  }
214
- function reviewGroups(home) {
220
+ export function reviewGroups(home) {
215
221
  const expected = new Set(baselineAspects);
216
222
  const declared = [];
217
223
  for (const file of findFiles(path.join(home, "03-plan"), "aspect-map.json")) {
@@ -226,15 +232,33 @@ function reviewGroups(home) {
226
232
  if (!declared.length)
227
233
  return [{ key: "baseline", aspect_ids: [...expected].sort() }];
228
234
  const assigned = new Set();
235
+ const scoped = new Set();
229
236
  for (const group of declared)
230
237
  for (const aspectId of group.aspect_ids) {
231
- if (assigned.has(aspectId))
232
- throw new AppError("code_review_groups_invalid", "A CODE-REVIEW aspect appears in more than one accepted group", 2, { aspect_id: aspectId });
238
+ const assignment = `${group.key.slice(0, group.key.lastIndexOf("/"))}/${aspectId}`;
239
+ if (scoped.has(assignment))
240
+ throw new AppError("code_review_groups_invalid", "A CODE-REVIEW aspect appears in more than one group of the same protocol", 2, { aspect_id: aspectId });
241
+ scoped.add(assignment);
233
242
  assigned.add(aspectId);
234
243
  }
235
244
  const missing = [...expected].filter((aspectId) => !assigned.has(aspectId)).sort();
236
245
  return [...declared, ...(missing.length ? [{ key: "baseline", aspect_ids: missing }] : [])];
237
246
  }
247
+ export function assertCodeReviewCoverage(groups, reviewers) {
248
+ const expected = new Map(groups.map(group => [group.key, [...group.aspect_ids].sort()]));
249
+ if (!expected.size || expected.size !== groups.length)
250
+ throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW expected group identities must be nonempty and unique", 2);
251
+ const seen = new Set();
252
+ for (const work of reviewers) {
253
+ const group = readJsonString(work.payload_json).group;
254
+ if (!group?.key || seen.has(group.key) || !expected.has(group.key) || !Array.isArray(group.aspect_ids) || JSON.stringify([...group.aspect_ids].sort()) !== JSON.stringify(expected.get(group.key)))
255
+ throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW Work does not match its expected group", 2, { work_id: work.work_id });
256
+ seen.add(group.key);
257
+ }
258
+ const missing = [...expected.keys()].filter(key => !seen.has(key));
259
+ if (missing.length || !expected.size)
260
+ throw new AppError("reviewer_coverage_invalid", "CODE-REVIEW requires every expected reviewer group", 2, { missing_groups: missing });
261
+ }
238
262
  export 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")}. Verify that universal and exclusive constraints (only, any, all, never, whole) and stated exceptions were not narrowed. For every changed mutation guarded by membership, ownership, authorization or parent lifecycle state, trace the decision to the write boundary: the predicate must remain in the write statement or the guard and write must share one explicit transaction with the needed lock. A separate earlier read is not proof of current authority or lifecycle state; report a material finding when this invariant is broken. Evidence closes a claim only when it exercises the named failure mechanism; a sequential negative test does not prove a concurrency race, and proof limits cannot waive an accepted obligation. 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. Use local finding ids FIND-001, FIND-002, and so on; dd-flow adds the Work-qualified canonical reference. Return dd-flow/code-review-result@1.`; }
239
263
  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_root, "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"); }
240
264
  function canonicalReviewEvidence(decision, reviewers) {
@@ -4,21 +4,22 @@ 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, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
7
+ import { aggregateCheckDeclarations, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths } from "./code-checks.js";
8
8
  import { requireProjectByRoot } from "./projects.js";
9
- import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
9
+ import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, withStageSettlement, getFlowRunVariables, gitFacts } from "./runs.js";
10
10
  import { validateSchema } from "./schema-validation.js";
11
11
  import { flowCommand } from "./stage-pause.js";
12
12
  import { validateVnextCodeHandoff } from "./vnext-plan.js";
13
13
  import { requireVnextWorkspaceRoute } from "./vnext-workspace-policy.js";
14
14
  import { readVnextSpecifyResult } from "./vnext-specify.js";
15
- import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishWork, refreshRunWorkProjection, validateWorkBatchFile, workStartCommand } from "./work-registry.js";
15
+ import { addWorkBatch, bindStageCoordinatorWork, codeWorkGraph, finishStageCoordinatorWork, 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
18
  import { ensureVnextMergeRequest } from "./vnext-merge.js";
19
19
  import { assertStageStartHookEvent } from "./hooks.js";
20
20
  import { applyExternalStageContext } from "./stage-context.js";
21
21
  import { readFanoutDescriptor, writeFanoutDescriptor } from "./vnext-fanout.js";
22
+ import { assertRunMutationAllowed } from "./run-recovery.js";
22
23
  const stage = "code";
23
24
  const stageDir = vnextStageDirectory("code");
24
25
  export async function startVnextCode(context, input) {
@@ -36,6 +37,7 @@ export async function startVnextCode(context, input) {
36
37
  const externalContext = applyExternalStageContext({ stageRoot: existingRoot, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
37
38
  const prompt = fs.readFileSync(existingPrompt, "utf8");
38
39
  const rootWork = requireRootWork(context, project.id, input.runId);
40
+ bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath: existingPrompt, resultPath: path.join(existingRoot, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
39
41
  const orchestration = readFanoutDescriptor(existingRoot) ?? writeFanoutDescriptor(existingRoot, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
40
42
  return { ok: true, resumed: true, run_id: run.id, stage, stage_status: existingStage, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), orchestration, graph: codeWorkGraph(context, project.id, run.id), next: { refresh_graph_command: `${flowCommand(context)} work ls --run ${run.id} --ready --project-root ${JSON.stringify(projectRoot)} --json`, finish_command: finishCommand(context, input.runId, projectRoot, path.join(existingRoot, "code-verification.json")) } };
41
43
  }
@@ -108,8 +110,10 @@ export async function finishVnextCode(context, input) {
108
110
  const run = requireRun(context, project.id, input.runId);
109
111
  const home = requireHome(run);
110
112
  const root = path.join(home, stageDir);
113
+ const generation = assertRunMutationAllowed(context, project.id, run.id);
111
114
  requireVnextWorkspaceRoute({ projectRoot, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, stage });
112
115
  if (stageStatus(run, stage) === "done" && fs.existsSync(path.join(root, "stage-report.json"))) {
116
+ resumeAcceptedCodeStage(context, run, projectRoot, stage, JSON.parse(fs.readFileSync(path.join(root, "stage-report.json"), "utf8")), nextAction(run, changedPathsFromReport(path.join(root, "stage-report.json"))));
113
117
  return { ok: true, resumed: true, run_id: run.id, stage, outcome: "accepted", report_path: path.join(root, "stage-report.json"), next_action: nextAction(run, changedPathsFromReport(path.join(root, "stage-report.json"))) };
114
118
  }
115
119
  const handoffFailures = validateVnextCodeHandoff(context, {
@@ -158,32 +162,14 @@ export async function finishVnextCode(context, input) {
158
162
  }
159
163
  const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
160
164
  if (input.retryCheckId) {
161
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
165
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
162
166
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
163
167
  }
164
- const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
165
- if (unchangedFailures.length)
166
- throw new AppError("code_gate_repair_required", "CODE final gate already failed for the unchanged workspace; choose source repair or restore the environment and retry that receipt", 2, { outcome: "repair_required", retry_after_workspace_change: true, workspace_fingerprint: unchangedFailures[0].workspace_fingerprint, failures: unchangedFailures, retry_command: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${unchangedFailures[0].id} --reason "<environment recovery evidence>"` });
167
- let receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks });
168
- if (!receipts) {
169
- const release = acquireAggregateGateLock(root);
170
- try {
171
- // A prior caller may have completed the slow gate while this caller was
172
- // waiting to acquire the lock. Reuse that immutable evidence.
173
- receipts = reusableAggregateGateReceipts(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, checks }) ?? await runCodeChecks(context, {
174
- projectId: project.id,
175
- runId: run.id,
176
- runHome: home,
177
- workspaceRoot: run.workspace_root,
178
- scope: "aggregate",
179
- checks,
180
- ...(input.progress ? { progress: input.progress } : {})
181
- });
182
- }
183
- finally {
184
- release();
185
- }
186
- }
168
+ const receipts = await runCodeChecks(context, {
169
+ projectId: project.id, runId: run.id, runHome: home, workspaceRoot: run.workspace_root, scope: "aggregate", checks,
170
+ ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}),
171
+ ...(input.progress ? { progress: input.progress } : {})
172
+ });
187
173
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
188
174
  if (failed.length) {
189
175
  throw new AppError("code_gate_failed", "CODE remains running because the aggregate project gate failed", 2, {
@@ -195,9 +181,8 @@ export async function finishVnextCode(context, input) {
195
181
  retry_command: `${finishCommand(context, run.id, projectRoot, input.verificationFile)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"`
196
182
  });
197
183
  }
198
- const allReceipts = checkReceipts(context, { projectId: project.id, runId: run.id });
199
- const finalReceipts = latestReceiptsByCommand(allReceipts);
200
- const projectedVerification = verificationProjection(works, finalReceipts, { historicalReceipts: allReceipts, runId: run.id, runHome: home });
184
+ const finalReceipts = receipts;
185
+ const projectedVerification = verificationProjection(works, finalReceipts, { historicalReceipts: finalReceipts, runId: run.id, runHome: home });
201
186
  const unresolvedAcceptance = (projectedVerification.acceptance ?? []).filter((item) => item.status === "unresolved");
202
187
  if (unresolvedAcceptance.length)
203
188
  throw new AppError("code_acceptance_unresolved", "CODE cannot finish until every due acceptance criterion has current checks and evidence", 2, { unresolved: unresolvedAcceptance });
@@ -249,29 +234,32 @@ export async function finishVnextCode(context, input) {
249
234
  // so an invalid merge policy leaves CODE safely running and retryable.
250
235
  const mergeRequest = next === "start_merge" ? ensureVnextMergeRequest(context, { projectRoot, runId: run.id, acceptedPaths: changedPaths }) : null;
251
236
  writeReport(root, report);
252
- completeFlowRunStage(context, {
253
- projectRoot,
254
- runId: run.id,
255
- stage,
256
- status: "done",
257
- data: "stage-report.json",
258
- dataSchemaId: "dd-flow/stage-report@1",
259
- report: "stage-report.md",
260
- stageReport: "stage-report.html"
261
- });
262
- const rootWork = requireRootWork(context, project.id, run.id);
263
- appendFlowRunTimelineEvent(context, project.id, run.id, {
264
- type: "code_accepted",
265
- work_id: rootWork.work_id,
266
- checks: finalReceipts.map((receipt) => receipt.id)
237
+ withStageSettlement(context, () => {
238
+ assertRunMutationAllowed(context, project.id, run.id, generation);
239
+ completeFlowRunStage(context, {
240
+ projectRoot,
241
+ runId: run.id,
242
+ stage,
243
+ status: "done",
244
+ data: "stage-report.json",
245
+ dataSchemaId: "dd-flow/stage-report@1",
246
+ report: "stage-report.md",
247
+ stageReport: "stage-report.html"
248
+ });
249
+ const rootWork = requireRootWork(context, project.id, run.id);
250
+ appendFlowRunTimelineEvent(context, project.id, run.id, {
251
+ type: "code_accepted",
252
+ work_id: rootWork.work_id,
253
+ checks: finalReceipts.map((receipt) => receipt.id)
254
+ });
255
+ if (next === "code_completed") {
256
+ finishStageCoordinatorWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
257
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
258
+ }
259
+ else {
260
+ advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
261
+ }
267
262
  });
268
- if (next === "code_completed") {
269
- await finishWork(context, rootWork.work_id, JSON.stringify(report, null, 2));
270
- completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "code_completed", nextAction: undefined });
271
- }
272
- else {
273
- advanceFlowRun(context, { settlementStage: "code", projectRoot, runId: run.id, status: "running", verdict: "code_completed", nextAction: next });
274
- }
275
263
  return {
276
264
  ok: true,
277
265
  run_id: run.id,
@@ -477,76 +465,6 @@ function coordinatorPrompt(context, input) {
477
465
  export function profileCommands(workspaceRoot) {
478
466
  return aggregateCheckDeclarations(workspaceRoot).map((check) => check.command);
479
467
  }
480
- function latestReceiptsByCommand(receipts) {
481
- const latest = new Map();
482
- for (const receipt of receipts)
483
- for (const ref of receipt.check_refs)
484
- latest.set(ref, receipt);
485
- return [...new Set(latest.values())];
486
- }
487
- /**
488
- * A CODE finish may be retried after a client loses the long command's output.
489
- * Reusing complete, same-workspace receipts is correct; rerunning them is both
490
- * expensive and can race the still-settling original invocation.
491
- */
492
- export function reusableAggregateGateReceipts(context, input) {
493
- const expected = new Set(input.checks.map((check) => check.id));
494
- const current = workspaceFingerprint(input.workspaceRoot);
495
- const latest = new Map();
496
- for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId })) {
497
- if (receipt.scope === "aggregate")
498
- for (const ref of receipt.check_refs)
499
- if (expected.has(ref))
500
- latest.set(ref, receipt);
501
- }
502
- if (latest.size !== expected.size)
503
- return null;
504
- const receipts = input.checks.map((check) => latest.get(check.id));
505
- return receipts.every((receipt) => receipt.status === "passed" && receipt.workspace_fingerprint === current) ? receipts : null;
506
- }
507
- /** One aggregate gate per RUN; a second invocation must wait, never duplicate it. */
508
- function acquireAggregateGateLock(root) {
509
- const directory = path.join(root, "checks");
510
- const lock = path.join(directory, ".aggregate-gate.lock");
511
- fs.mkdirSync(directory, { recursive: true });
512
- try {
513
- const descriptor = fs.openSync(lock, "wx");
514
- fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
515
- fs.closeSync(descriptor);
516
- }
517
- catch (error) {
518
- if (!isExistingLock(error))
519
- throw error;
520
- const activePid = lockPid(lock);
521
- if (activePid && processAlive(activePid)) {
522
- 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 });
523
- }
524
- fs.rmSync(lock, { force: true });
525
- return acquireAggregateGateLock(root);
526
- }
527
- return () => fs.rmSync(lock, { force: true });
528
- }
529
- function isExistingLock(error) {
530
- return Boolean(error && typeof error === "object" && error.code === "EEXIST");
531
- }
532
- function lockPid(lock) {
533
- try {
534
- const parsed = JSON.parse(fs.readFileSync(lock, "utf8"));
535
- return Number.isInteger(parsed.pid) && Number(parsed.pid) > 0 ? Number(parsed.pid) : null;
536
- }
537
- catch {
538
- return null;
539
- }
540
- }
541
- function processAlive(pid) {
542
- try {
543
- process.kill(pid, 0);
544
- return true;
545
- }
546
- catch {
547
- return false;
548
- }
549
- }
550
468
  /**
551
469
  * The immutable Work result is a repair history. The stage report is a current
552
470
  * acceptance projection, so a registered receipt from an older check epoch
@@ -770,6 +688,28 @@ function uniqueBy(values, key) {
770
688
  return true;
771
689
  });
772
690
  }
691
+ /** Repair pre-atomic terminal stages without repeating accepted checks or rewinding a successor. */
692
+ export function resumeAcceptedCodeStage(context, run, projectRoot, stage, report, next) {
693
+ const index = JSON.parse(run.index_json);
694
+ if ((index.current_stage ?? index.stage_runs.at(-1)?.stage) !== stage)
695
+ return;
696
+ const terminal = next === "code_completed" || next === "code_review_completed";
697
+ if (!terminal && index.next_action === next)
698
+ return;
699
+ withStageSettlement(context, () => {
700
+ if (!terminal) {
701
+ advanceFlowRun(context, { settlementStage: stage, projectRoot, runId: run.id, status: "running", verdict: `${stage.replaceAll("-", "_")}_completed`, nextAction: next });
702
+ return;
703
+ }
704
+ const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [run.project_id, run.id]);
705
+ if (!work)
706
+ throw new AppError("runtime_missing", "Accepted stage has no root coordinator", 1);
707
+ if (work.status !== "completed")
708
+ finishStageCoordinatorWork(context, work.work_id, JSON.stringify(report, null, 2));
709
+ if (index.status !== "done")
710
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: next });
711
+ });
712
+ }
773
713
  function writeReport(root, report) {
774
714
  writeStageReport(root, report);
775
715
  }
@@ -15,7 +15,7 @@ import { validateSchema } from "./schema-validation.js";
15
15
  import { writeStageReport } from "./stage-report-renderer.js";
16
16
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
17
17
  import { addVnextCodeRepair } from "./vnext-code.js";
18
- import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
18
+ import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
19
19
  import { applyExternalStageContext } from "./stage-context.js";
20
20
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
21
21
  import { assertRunMutationAllowed } from "./run-recovery.js";
@@ -50,6 +50,7 @@ export function ensureVnextMergeRequest(context, input) {
50
50
  throw new AppError("merge_source_invalid", "MERGE could not determine the target branch", 1, { target_branch: targetBranch });
51
51
  const semantic = planChecks(run.workspace_root, protocols);
52
52
  validateMergeAcceptance(run.workspace_root, protocols, semantic);
53
+ const acceptanceRefs = mergeAcceptanceRefs(run.workspace_root, protocols);
53
54
  const effective = effectiveCheckDeclarations(targetWorkspace, semantic, ["work", "code", "readiness", "merge"]);
54
55
  if (run.workspace_root !== targetWorkspace && effective.length === 0)
55
56
  throw new AppError("merge_gate_missing", "A real source/target integration requires at least one semantic or policy merge check", 2, { run_id: run.id, protocol_ids: protocols });
@@ -59,7 +60,7 @@ export function ensureVnextMergeRequest(context, input) {
59
60
  const freezeFile = path.join(freezeRoot, "source-freeze.json");
60
61
  const gateFile = path.join(freezeRoot, "merge-gate.json");
61
62
  fs.mkdirSync(freezeRoot, { recursive: true });
62
- freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
63
+ freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, acceptanceRefs, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
63
64
  let sourceCommit;
64
65
  let sourcePaths;
65
66
  if (fs.existsSync(freezeFile)) {
@@ -179,10 +180,14 @@ function startPacket(context, run, request, binding, promptPath, resultPath, pro
179
180
  export function applyVnextMerge(context, input) {
180
181
  const existing = requireRequest(context, input.requestId);
181
182
  const receiptFile = applyReceiptPath(context, existing);
182
- if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
183
- return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
184
183
  const request = requireOwnedActiveRequest(context, input);
185
184
  assertRunMutationAllowed(context, request.project_id, request.run_id);
185
+ if (["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(request.checkpoint) && fs.existsSync(receiptFile)) {
186
+ const receipt = JSON.parse(fs.readFileSync(receiptFile, "utf8"));
187
+ if (receipt.merge_request_id !== request.merge_request_id || receipt.work_id !== request.executor_work_id || receipt.source_commit !== request.source_commit)
188
+ throw new AppError("merge_apply_receipt_invalid", "Existing merge apply receipt does not match the active request", 1, { receipt_path: receiptFile });
189
+ return receipt;
190
+ }
186
191
  heartbeatLaneLock(context, { projectRoot: input.projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, ttlSeconds: 7200 });
187
192
  assertExecutionBaseline(request);
188
193
  input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
@@ -214,6 +219,7 @@ export async function finishVnextMerge(context, input) {
214
219
  throw new AppError("merge_request_mismatch", "MRG/Work/RUN/project identity does not match", 2);
215
220
  if (existing.status === "completed") {
216
221
  recoverCompletedMergeRun(context, run, projectRoot, existing);
222
+ releaseMergeLane(context, projectRoot, existing, `MERGE ${existing.merge_request_id} completion recovered`);
217
223
  return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
218
224
  }
219
225
  let request = requireOwnedActiveRequest(context, input);
@@ -236,29 +242,35 @@ export async function finishVnextMerge(context, input) {
236
242
  context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
237
243
  request = requireRequest(context, request.merge_request_id);
238
244
  }
239
- const checks = effectiveMergeChecks(run, request);
245
+ const gate = readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id), checks = gate.checks;
240
246
  if (input.retryCheckId) {
241
- validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
247
+ validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, reason: input.retryReason });
242
248
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
243
249
  }
244
- const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
245
- if (unchanged.length)
246
- throw new AppError("merge_check_repair_required", "MERGE checks already failed for the unchanged integrated tree; choose source repair or restore the environment and retry that receipt", 2, { failures: unchanged, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${unchanged[0].id} --reason "<environment recovery evidence>"` });
247
- const receipts = request.checkpoint === "checks_passed" || request.checkpoint === "delivered" || request.checkpoint === "finalized" ? checkReceipts(context, { projectId: project.id, runId: run.id, workId: request.executor_work_id }).filter((receipt) => receipt.status === "passed" && receipt.before_fingerprint === workspaceFingerprint(request.target_workspace)) : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, ...(input.progress ? { progress: input.progress } : {}) });
250
+ const acceptancePath = path.join(requireHome(run), stageDir, "merge-gate-acceptance.json");
251
+ const recovered = readMergeGateAcceptance(context, acceptancePath, request, gate);
252
+ const resumingGate = ["checks_passed", "integration_committed", "delivered"].includes(request.checkpoint);
253
+ if (resumingGate && !recovered.valid)
254
+ return recovery(context, request, "merge_evidence_missing", { checkpoint: request.checkpoint });
255
+ if (request.checkpoint === "checks_passed" && request.accepted_tree && gitValue(request.target_workspace, ["write-tree"]) !== request.accepted_tree)
256
+ return recovery(context, request, "merge_workspace_drift_after_checks", { accepted_tree: request.accepted_tree });
257
+ const receipts = resumingGate ? recovered.receipts : await runCodeChecks(context, { projectId: project.id, runId: run.id, runHome: requireHome(run), workspaceRoot: request.target_workspace, workId: request.executor_work_id, artifactDir: path.join(stageDir, "works", request.executor_work_id), scope: "aggregate", checks, expectedProfileHash: gate.profileHash, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId, retryReason: input.retryReason } : {}), ...(input.progress ? { progress: input.progress } : {}) });
248
258
  assertRunMutationAllowed(context, project.id, run.id, generation);
249
259
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
250
260
  if (failed.length) {
251
261
  context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
252
262
  throw new AppError("merge_gate_failed", "Integrated target checks failed. Classify retained evidence: source defects use source repair; restored environment uses retry.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json`, retry_command: `${finishCommand(context, run.id, request, projectRoot)} --retry-check ${failed[0].id} --reason "<environment recovery evidence>"` });
253
263
  }
254
- const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
255
264
  const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
256
- const missingRefs = requiredRefs.filter((ref) => !passedRefs.has(ref));
265
+ const missingRefs = frozenGateRefs(gate).filter((ref) => !passedRefs.has(ref));
257
266
  if (missingRefs.length)
258
- throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every merge acceptance reference", 2, { missing_check_refs: missingRefs });
259
- const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
260
- context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
261
- request = requireRequest(context, request.merge_request_id);
267
+ throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every frozen merge-gate reference", 2, { missing_check_refs: missingRefs });
268
+ if (!request.integration_commit) {
269
+ const acceptedTree = gitValue(request.target_workspace, ["write-tree"]);
270
+ writeMergeGateAcceptance(context, acceptancePath, request, gate, acceptedTree, receipts);
271
+ context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', accepted_tree = ?, status = 'active', updated_at = ? WHERE merge_request_id = ?", [acceptedTree, context.now(), request.merge_request_id]);
272
+ request = requireRequest(context, request.merge_request_id);
273
+ }
262
274
  if (!request.integration_commit) {
263
275
  commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
264
276
  const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
@@ -269,11 +281,20 @@ export async function finishVnextMerge(context, input) {
269
281
  context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
270
282
  request = requireRequest(context, request.merge_request_id);
271
283
  }
284
+ if (request.integration_commit && request.accepted_tree && gitValue(request.target_workspace, ["rev-parse", `${request.integration_commit}^{tree}`]) !== request.accepted_tree)
285
+ return recovery(context, request, "merge_committed_tree_drift", { accepted_tree: request.accepted_tree, integration_commit: request.integration_commit });
272
286
  verifyLocalDelivery(request);
273
287
  context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
274
288
  request = requireRequest(context, request.merge_request_id);
275
289
  performConfiguredCleanup(run, request);
276
- await finishWork(context, request.executor_work_id, fs.readFileSync(resultPath, "utf8"));
290
+ const acceptedResult = fs.readFileSync(resultPath, "utf8");
291
+ const executor = context.db.get("SELECT status, result FROM works WHERE work_id = ?", [request.executor_work_id]);
292
+ if (!executor)
293
+ return recovery(context, request, "merge_executor_missing", { work_id: request.executor_work_id });
294
+ if (executor.status === "running")
295
+ await finishWork(context, request.executor_work_id, acceptedResult);
296
+ else if (executor.status !== "completed" || executor.result !== acceptedResult)
297
+ return recovery(context, request, "merge_executor_settlement_mismatch", { work_id: request.executor_work_id, status: executor.status });
277
298
  assertRunMutationAllowed(context, project.id, run.id, generation);
278
299
  const now = context.now();
279
300
  context.db.run("UPDATE merge_requests SET status = 'completed', checkpoint = 'finalized', result_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify(semantic), now, now, request.merge_request_id]);
@@ -299,14 +320,24 @@ function ensureMergeStageAttached(context, run, projectRoot) {
299
320
  function recoverCompletedMergeRun(context, run, projectRoot, request) {
300
321
  const root = path.join(requireHome(run), stageDir);
301
322
  const reportPath = path.join(root, "stage-report.json");
302
- if (!fs.existsSync(reportPath))
303
- throw new AppError("merge_report_missing", "Completed MERGE request has no stage report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
323
+ if (!fs.existsSync(reportPath)) {
324
+ const semantic = request.result_json ? JSON.parse(request.result_json) : null;
325
+ const gate = readFrozenMergeGate(path.join(root, "merge-gate.json"), request.merge_request_id);
326
+ const accepted = readMergeGateAcceptance(context, path.join(root, "merge-gate-acceptance.json"), request, gate);
327
+ if (!semantic || typeof semantic.summary !== "string" || typeof semantic.verification_summary !== "string" || !Array.isArray(semantic.residual_risks) || !semantic.residual_risks.every(item => typeof item === "string") || !accepted.valid)
328
+ throw new AppError("merge_report_missing", "Completed MERGE has insufficient authoritative evidence to rebuild its report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
329
+ verifyLocalDelivery(request);
330
+ performConfiguredCleanup(run, request);
331
+ writeStageReport(root, mergeReport(context, run, request, semantic, accepted.receipts));
332
+ }
304
333
  ensureMergeStageAttached(context, run, projectRoot);
305
334
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage, status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@2", report: "stage-report.md", stageReport: "stage-report.html" });
306
335
  const rootWork = findRootWork(context, run.project_id, run.id);
307
336
  const rootStatus = rootWork.status;
308
337
  if (rootStatus === "running")
309
338
  finishFanInWork(context, rootWork.work_id, fs.readFileSync(reportPath, "utf8"));
339
+ else if (rootStatus !== "completed")
340
+ throw new AppError("merge_root_settlement_mismatch", "Completed MERGE cannot recover a non-completed root Work", 1, { merge_request_id: request.merge_request_id, work_id: rootWork.work_id, status: rootStatus });
310
341
  const runStatus = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id])?.status;
311
342
  if (runStatus !== "done")
312
343
  completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
@@ -374,21 +405,39 @@ function planChecks(workspace, protocols) { return protocols.flatMap((protocol)
374
405
  return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.availability === "available").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
375
406
  function validateMergeAcceptance(workspace, protocols, checks) { const known = new Set(checks.map((check) => check.canonical_ref)); const missing = mergeAcceptanceRefs(workspace, protocols).filter((ref) => !known.has(ref)); if (missing.length)
376
407
  throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a missing check", 2, { check_refs: missing }); }
377
- function freezeMergeGate(file, input) { const canonical = JSON.stringify(input.checks); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
408
+ function freezeMergeGate(file, input) { const canonical = JSON.stringify({ checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort() }); const hash = cryptoHash(canonical); if (fs.existsSync(file)) {
378
409
  const existing = JSON.parse(fs.readFileSync(file, "utf8"));
379
- if (existing.run_id !== input.runId || existing.checks_hash !== hash)
410
+ if (existing.run_id !== input.runId || existing.gate_hash !== hash)
380
411
  throw new AppError("merge_gate_freeze_conflict", "MERGE gate is already frozen with different checks", 2, { file });
381
412
  return;
382
- } fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@1", run_id: input.runId, protocols: input.protocols, checks: input.checks, checks_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
413
+ } fs.writeFileSync(file, `${JSON.stringify({ schema_id: "dd-flow/merge-gate@2", run_id: input.runId, protocols: input.protocols, checks: input.checks, acceptance_refs: [...input.acceptanceRefs].sort(), gate_hash: hash, profile_hash: input.profileHash, frozen_at: input.now }, null, 2)}\n`); }
383
414
  function readFrozenMergeGate(file, requestId) { try {
384
415
  const value = JSON.parse(fs.readFileSync(file, "utf8"));
385
- if (value.schema_id !== "dd-flow/merge-gate@1" || !Array.isArray(value.checks) || value.checks_hash !== cryptoHash(JSON.stringify(value.checks)))
416
+ if (value.schema_id !== "dd-flow/merge-gate@2" || !Array.isArray(value.checks) || !Array.isArray(value.acceptance_refs) || !value.acceptance_refs.every(ref => typeof ref === "string") || typeof value.gate_hash !== "string" || value.gate_hash !== cryptoHash(JSON.stringify({ checks: value.checks, acceptance_refs: [...value.acceptance_refs].sort() })))
386
417
  throw new Error("invalid gate");
387
- return { checks: value.checks };
418
+ return { checks: value.checks, acceptanceRefs: [...value.acceptance_refs].sort(), profileHash: value.profile_hash ?? null, gateHash: value.gate_hash };
388
419
  }
389
420
  catch (cause) {
390
421
  throw new AppError("merge_gate_missing", "MERGE gate is missing or invalid", 1, { merge_request_id: requestId, file, cause: String(cause) });
391
422
  } }
423
+ function frozenGateRefs(gate) { return [...new Set([...gate.checks.map(check => check.canonical_ref ?? check.id), ...gate.acceptanceRefs])].sort(); }
424
+ function writeMergeGateAcceptance(context, file, request, gate, acceptedTree, receipts) { const persisted = new Map(checkReceipts(context, { projectId: request.project_id, runId: request.run_id }).map(receipt => [receipt.id, receipt])); const value = { schema_id: "dd-flow/merge-gate-acceptance@2", merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, gate_hash: gate.gateHash, profile_hash: gate.profileHash, accepted_tree: acceptedTree, receipts: receipts.map(receipt => ({ id: receipt.id, execution_refs: [...(persisted.get(receipt.id)?.check_refs ?? receipt.check_refs)].sort(), binding_refs: [...receipt.check_refs].sort(), input_hash: receipt.input_hash })) }; const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`; fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`); fs.renameSync(temporary, file); }
425
+ function readMergeGateAcceptance(context, file, request, gate) { try {
426
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
427
+ if (value.schema_id !== "dd-flow/merge-gate-acceptance@2" || value.merge_request_id !== request.merge_request_id || value.run_id !== request.run_id || value.work_id !== request.executor_work_id || value.gate_hash !== gate.gateHash || value.profile_hash !== gate.profileHash || value.accepted_tree !== request.accepted_tree || !Array.isArray(value.receipts) || !value.receipts.every(receipt => receipt && typeof receipt.id === "string" && typeof receipt.input_hash === "string" && Array.isArray(receipt.execution_refs) && receipt.execution_refs.every(ref => typeof ref === "string") && Array.isArray(receipt.binding_refs) && receipt.binding_refs.every(ref => typeof ref === "string")))
428
+ return { valid: false, receipts: [] };
429
+ const all = checkReceipts(context, { projectId: request.project_id, runId: request.run_id });
430
+ const receipts = value.receipts.map(saved => all.find(receipt => receipt.id === saved.id && receipt.input_hash === saved.input_hash && JSON.stringify([...receipt.check_refs].sort()) === JSON.stringify(saved.execution_refs))).filter((receipt) => Boolean(receipt));
431
+ const bindingRefs = value.receipts.flatMap(receipt => receipt.binding_refs);
432
+ const bindings = new Set(bindingRefs);
433
+ const expected = frozenGateRefs(gate);
434
+ if (receipts.length !== value.receipts.length || bindingRefs.length !== bindings.size || !receipts.every(checkReceiptEvidenceValid) || JSON.stringify([...bindings].sort()) !== JSON.stringify(expected) || gitValue(request.target_workspace, ["write-tree"]) !== value.accepted_tree)
435
+ return { valid: false, receipts: [] };
436
+ return { valid: true, receipts: receipts.map((receipt, index) => ({ ...receipt, check_refs: value.receipts[index].binding_refs })) };
437
+ }
438
+ catch {
439
+ return { valid: false, receipts: [] };
440
+ } }
392
441
  function cryptoHash(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
393
442
  function mergeAcceptanceRefs(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
394
443
  return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.acceptance ?? []).filter((entry) => entry.gate === "merge").flatMap((entry) => (entry.check_refs ?? []).map((ref) => `${protocol}/${ref}`)); }); }