@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.
@@ -0,0 +1,330 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
+ import { vnextStageDirectory } from "../domain/stage-catalog.js";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { requireProjectByRoot } from "./projects.js";
8
+ import { nextMergeRequestId } from "./ids.js";
9
+ import { readProjectConfig } from "./config.js";
10
+ import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
11
+ import { flowCommand, stagePauseCommandTemplate } from "./stage-pause.js";
12
+ import { validateSchema } from "./schema-validation.js";
13
+ import { writeStageReport } from "./stage-report-renderer.js";
14
+ import { bindStageCoordinatorWork, createChildWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
15
+ import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
16
+ import { applyExternalStageContext } from "./stage-context.js";
17
+ const stage = "merge";
18
+ const stageDir = vnextStageDirectory(stage);
19
+ export function isVnextMergeRun(context, input) {
20
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
21
+ return Boolean(context.db.get("SELECT 1 FROM runs WHERE project_id = ? AND id = ? AND flow_kind = 'vnext_protocolize'", [project.id, input.runId]));
22
+ }
23
+ /** Terminal CODE/CODE-REVIEW calls this once; it freezes source and creates the child MERGE Work. */
24
+ export function ensureVnextMergeRequest(context, input) {
25
+ const projectRoot = resolveProjectRoot(input.projectRoot);
26
+ const project = requireProjectByRoot(context, projectRoot);
27
+ const run = requireRun(context, project.id, input.runId);
28
+ const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [project.id, run.id]);
29
+ if (existing)
30
+ return requestView(context, existing);
31
+ const settings = executionSettings(run);
32
+ const route = settings.merge_mode ?? "same_session";
33
+ const home = requireHome(run);
34
+ const protocols = protocolIds(home);
35
+ const sourceBranch = gitValue(run.workspace_root, ["branch", "--show-current"]);
36
+ if (!sourceBranch)
37
+ throw new AppError("merge_source_invalid", "MERGE requires a named source branch", 1, { workspace_root: run.workspace_root });
38
+ commitPendingSource(run.workspace_root, run.id, ignoredGitPaths(context, project.id));
39
+ const sourceCommit = gitValue(run.workspace_root, ["rev-parse", "HEAD"]);
40
+ const targetBranch = workspaceRoute(home).integration_branch;
41
+ if (!sourceCommit || !targetBranch)
42
+ throw new AppError("merge_source_invalid", "MERGE could not freeze source commit or target branch", 1, { source_commit: sourceCommit, target_branch: targetBranch });
43
+ const targetWorkspace = projectRoot;
44
+ const enqueueTarget = gitValue(targetWorkspace, ["rev-parse", targetBranch], true);
45
+ const semantic = planChecks(run.workspace_root, protocols);
46
+ validateMergeAcceptance(run.workspace_root, protocols, semantic);
47
+ const effective = effectiveCheckDeclarations(run.workspace_root, semantic, ["merge"]);
48
+ if (run.workspace_root !== targetWorkspace && effective.length === 0)
49
+ 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 });
50
+ const root = requireRootWork(context, project.id, run.id);
51
+ let child;
52
+ let requestId;
53
+ const now = context.now();
54
+ context.db.exec("BEGIN IMMEDIATE");
55
+ try {
56
+ const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ?", [project.id, run.id]);
57
+ if (raced) {
58
+ context.db.exec("COMMIT");
59
+ return requestView(context, raced);
60
+ }
61
+ child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
62
+ requestId = nextMergeRequestId(context);
63
+ context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, now, now]);
64
+ context.db.exec("COMMIT");
65
+ }
66
+ catch (error) {
67
+ context.db.exec("ROLLBACK");
68
+ throw error;
69
+ }
70
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_request_queued", merge_request_id: requestId, work_id: child.work_id, route, source_commit: sourceCommit, target_branch: targetBranch });
71
+ return requestView(context, requireRequest(context, requestId));
72
+ }
73
+ export async function startVnextMerge(context, input) {
74
+ const projectRoot = resolveProjectRoot(input.projectRoot);
75
+ const project = requireProjectByRoot(context, projectRoot);
76
+ const run = requireRun(context, project.id, input.runId);
77
+ const home = requireHome(run);
78
+ const request = requestForRun(context, project.id, run.id);
79
+ if (request.status === "completed")
80
+ return requestView(context, request);
81
+ const root = path.join(home, stageDir);
82
+ const workRoot = path.join(root, "works", request.executor_work_id);
83
+ fs.mkdirSync(workRoot, { recursive: true });
84
+ const resultPath = path.join(workRoot, "result.json");
85
+ const promptPath = path.join(workRoot, "prompt.md");
86
+ if (["active", "action_required"].includes(request.status)) {
87
+ const pendingWork = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
88
+ const binding = pendingWork?.status === "created"
89
+ ? startStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) })
90
+ : bindStageCoordinatorWork(context, { workId: request.executor_work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
91
+ const prompt = fs.existsSync(promptPath) ? fs.readFileSync(promptPath, "utf8") : mergePrompt(context, { run, request, resultPath, projectRoot });
92
+ if (!fs.existsSync(promptPath))
93
+ fs.writeFileSync(promptPath, prompt);
94
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
95
+ ensureMergeStageAttached(context, run, projectRoot);
96
+ return startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext);
97
+ }
98
+ await waitForTurn(context, request, input.progress);
99
+ const current = requireRequest(context, request.merge_request_id);
100
+ const targetHead = gitValue(current.target_workspace, ["rev-parse", current.target_branch], true);
101
+ const checkedOutTarget = gitValue(current.target_workspace, ["branch", "--show-current"], true);
102
+ if (checkedOutTarget !== current.target_branch)
103
+ throw new AppError("merge_target_branch_mismatch", "Integration workspace is not on the configured target branch", 1, { expected: current.target_branch, actual: checkedOutTarget, target_workspace: current.target_workspace });
104
+ const targetStatus = meaningfulStatus(current.target_workspace, ignoredGitPaths(context, project.id));
105
+ if (targetStatus.length)
106
+ throw new AppError("merge_target_dirty", "Integration workspace must be clean before MERGE starts", 1, { target_workspace: current.target_workspace, status: targetStatus });
107
+ const claimed = context.db.run("UPDATE merge_requests SET status = 'active', execution_target_head = ?, lock_acquired_at = ?, checkpoint = 'baseline_locked', updated_at = ? WHERE merge_request_id = ? AND status IN ('queued','dispatching','action_required')", [targetHead, context.now(), context.now(), current.merge_request_id]);
108
+ if (claimed.changes !== 1 && requireRequest(context, current.merge_request_id).status !== "active")
109
+ throw new AppError("merge_claim_conflict", "MERGE request could not acquire the integration lane", 1, { merge_request_id: current.merge_request_id });
110
+ const started = startStageCoordinatorWork(context, { workId: current.executor_work_id, hookEventId: input.hookEventId, stage, projectRoot, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
111
+ const prompt = mergePrompt(context, { run, request: requireRequest(context, current.merge_request_id), resultPath, projectRoot });
112
+ fs.writeFileSync(promptPath, prompt);
113
+ const externalContext = applyExternalStageContext({ stageRoot: root, promptPath, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
114
+ context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [promptPath, resultPath, context.now(), current.executor_work_id]);
115
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
116
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_started", merge_request_id: current.merge_request_id, work_id: current.executor_work_id, target_head: targetHead });
117
+ return startPacket(context, run, current, started, promptPath, resultPath, prompt, projectRoot, externalContext);
118
+ }
119
+ function startPacket(context, run, request, binding, promptPath, resultPath, prompt, projectRoot, externalContext) {
120
+ return { ok: true, run_id: run.id, stage, merge_request_id: request.merge_request_id, work_id: request.executor_work_id, session_binding: binding.session_binding ?? binding, prompt_path: promptPath, result_path: resultPath, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), queue: queueStatus(context, request), effective_checks: effectiveMergeChecks(run, request), next: { apply_command: applyCommand(context, request, projectRoot), finish_command: finishCommand(context, run.id, request, projectRoot) } };
121
+ }
122
+ export function applyVnextMerge(context, input) {
123
+ const existing = requireRequest(context, input.requestId);
124
+ const receiptFile = applyReceiptPath(context, existing);
125
+ if (existing.executor_work_id === input.workId && ["apply_recorded", "integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(existing.checkpoint) && fs.existsSync(receiptFile))
126
+ return JSON.parse(fs.readFileSync(receiptFile, "utf8"));
127
+ const request = requireOwnedActiveRequest(context, input);
128
+ assertExecutionBaseline(request);
129
+ input.progress?.(`applying ${request.source_commit} to ${request.target_branch}`);
130
+ let outcome = "applied_clean";
131
+ let conflicts = [];
132
+ if (request.source_commit === request.execution_target_head)
133
+ outcome = "no_op";
134
+ else {
135
+ const result = spawnSync("git", ["merge", "--no-ff", "--no-commit", request.source_commit], { cwd: request.target_workspace, encoding: "utf8" });
136
+ conflicts = unmerged(request.target_workspace);
137
+ if (result.status !== 0 && conflicts.length === 0)
138
+ return recovery(context, request, "merge_apply_failed", { stdout: result.stdout, stderr: result.stderr, status: result.status });
139
+ if (conflicts.length)
140
+ outcome = "conflicts";
141
+ }
142
+ const receipt = { schema_id: "dd-flow/merge-apply-receipt@1", merge_request_id: request.merge_request_id, work_id: request.executor_work_id, source_commit: request.source_commit, target_baseline: request.execution_target_head, outcome, conflicts, at: context.now() };
143
+ fs.mkdirSync(path.dirname(receiptFile), { recursive: true });
144
+ fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`);
145
+ context.db.run("UPDATE merge_requests SET checkpoint = 'apply_recorded', result_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ apply_receipt: receiptFile }), context.now(), request.merge_request_id]);
146
+ return { ok: true, ...receipt };
147
+ }
148
+ export async function finishVnextMerge(context, input) {
149
+ const projectRoot = resolveProjectRoot(input.projectRoot);
150
+ const project = requireProjectByRoot(context, projectRoot);
151
+ const run = requireRun(context, project.id, input.runId);
152
+ const existing = requireRequest(context, input.requestId);
153
+ if (existing.project_id !== project.id || existing.run_id !== run.id || existing.executor_work_id !== input.workId)
154
+ throw new AppError("merge_request_mismatch", "MRG/Work/RUN/project identity does not match", 2);
155
+ if (existing.status === "completed") {
156
+ recoverCompletedMergeRun(context, run, projectRoot, existing);
157
+ return { ok: true, resumed: true, run_id: run.id, stage, outcome: "completed", merge_request_id: existing.merge_request_id, work_id: existing.executor_work_id, integration_commit: existing.integration_commit, report_path: path.join(requireHome(run), stageDir, "stage-report.json"), next_action: "merge_completed" };
158
+ }
159
+ let request = requireOwnedActiveRequest(context, input);
160
+ if (request.run_id !== run.id)
161
+ throw new AppError("merge_request_mismatch", "MRG does not belong to RUN", 2);
162
+ if (request.checkpoint === "baseline_locked" || !fs.existsSync(applyReceiptPath(context, request)))
163
+ throw new AppError("merge_apply_required", "Run the exact merge apply command before MERGE finish", 2, { merge_request_id: request.merge_request_id, checkpoint: request.checkpoint });
164
+ if (unmerged(request.target_workspace).length)
165
+ throw new AppError("merge_conflicts_unresolved", "Resolve every unmerged path before finishing MERGE", 2, { paths: unmerged(request.target_workspace) });
166
+ const resultPath = path.join(requireHome(run), stageDir, "works", request.executor_work_id, "result.json");
167
+ validateSchema({ schemaName: "merge-result", file: resultPath, projectRoot: request.target_workspace, ddFlowHome: context.ddFlowHome, runId: run.id });
168
+ const semantic = JSON.parse(fs.readFileSync(resultPath, "utf8"));
169
+ if (semantic.outcome !== "completed")
170
+ throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
171
+ if (!["integration_committed", "bootstrap_ready", "checks_passed", "delivered", "finalized"].includes(request.checkpoint)) {
172
+ commitIntegration(request.target_workspace, run.id, ignoredGitPaths(context, project.id));
173
+ const commit = gitValue(request.target_workspace, ["rev-parse", "HEAD"]);
174
+ context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [commit, context.now(), request.merge_request_id]);
175
+ request = requireRequest(context, request.merge_request_id);
176
+ }
177
+ else if (meaningfulStatus(request.target_workspace, ignoredGitPaths(context, project.id)).length) {
178
+ commitIntegration(request.target_workspace, `${run.id}-fix`, ignoredGitPaths(context, project.id));
179
+ context.db.run("UPDATE merge_requests SET integration_commit = ?, checkpoint = 'integration_committed', updated_at = ? WHERE merge_request_id = ?", [gitValue(request.target_workspace, ["rev-parse", "HEAD"]), context.now(), request.merge_request_id]);
180
+ request = requireRequest(context, request.merge_request_id);
181
+ }
182
+ if (request.checkpoint === "integration_committed") {
183
+ input.progress?.("bootstrapping integrated target");
184
+ runBootstrap(run, request.target_workspace);
185
+ context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
186
+ request = requireRequest(context, request.merge_request_id);
187
+ }
188
+ const checks = effectiveMergeChecks(run, request);
189
+ const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks });
190
+ if (unchanged.length)
191
+ throw new AppError("merge_check_repair_required", "MERGE checks already failed for the unchanged integrated tree", 2, { failures: unchanged });
192
+ 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 } : {}) });
193
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
194
+ if (failed.length) {
195
+ 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]);
196
+ throw new AppError("merge_gate_failed", "Integrated target checks failed; repair in the same MERGE Work and retry finish", 2, { failures: failed });
197
+ }
198
+ const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
199
+ const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
200
+ const missingRefs = requiredRefs.filter((ref) => !passedRefs.has(ref));
201
+ if (missingRefs.length)
202
+ throw new AppError("merge_acceptance_unproven", "Current MERGE receipts do not cover every merge acceptance reference", 2, { missing_check_refs: missingRefs });
203
+ context.db.run("UPDATE merge_requests SET checkpoint = 'checks_passed', status = 'active', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
204
+ request = requireRequest(context, request.merge_request_id);
205
+ verifyLocalDelivery(request);
206
+ context.db.run("UPDATE merge_requests SET checkpoint = 'delivered', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
207
+ request = requireRequest(context, request.merge_request_id);
208
+ performConfiguredCleanup(run, request);
209
+ await finishWork(context, request.executor_work_id, fs.readFileSync(resultPath, "utf8"));
210
+ const now = context.now();
211
+ 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]);
212
+ const report = mergeReport(context, run, requireRequest(context, request.merge_request_id), semantic, receipts);
213
+ const root = path.join(requireHome(run), stageDir);
214
+ writeStageReport(root, report);
215
+ 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" });
216
+ const rootWork = requireRootWork(context, project.id, run.id);
217
+ finishFanInWork(context, rootWork.work_id, JSON.stringify(report));
218
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
219
+ refreshRunWorkProjection(context, project.id, run.id);
220
+ appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_completed", merge_request_id: request.merge_request_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit });
221
+ return { ok: true, run_id: run.id, stage, outcome: "completed", merge_request_id: request.merge_request_id, work_id: request.executor_work_id, integration_commit: requireRequest(context, request.merge_request_id).integration_commit, report_path: path.join(root, "stage-report.json"), next_action: "merge_completed" };
222
+ }
223
+ export function getVnextMergeRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = input.requestId ? requireRequest(context, input.requestId) : input.runId ? requestForRun(context, project.id, input.runId) : null; if (!request || request.project_id !== project.id)
224
+ throw new AppError("not_found", "MERGE request is not registered", 1); return requestView(context, request); }
225
+ function ensureMergeStageAttached(context, run, projectRoot) {
226
+ const index = JSON.parse(run.index_json);
227
+ if (!index.stage_runs?.some((entry) => entry.stage === stage))
228
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@2" });
229
+ }
230
+ function recoverCompletedMergeRun(context, run, projectRoot, request) {
231
+ const root = path.join(requireHome(run), stageDir);
232
+ const reportPath = path.join(root, "stage-report.json");
233
+ if (!fs.existsSync(reportPath))
234
+ throw new AppError("merge_report_missing", "Completed MERGE request has no stage report", 1, { merge_request_id: request.merge_request_id, report_path: reportPath });
235
+ ensureMergeStageAttached(context, run, projectRoot);
236
+ 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" });
237
+ const rootWork = findRootWork(context, run.project_id, run.id);
238
+ const rootStatus = rootWork.status;
239
+ if (rootStatus === "running")
240
+ finishFanInWork(context, rootWork.work_id, fs.readFileSync(reportPath, "utf8"));
241
+ const runStatus = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [run.project_id, run.id])?.status;
242
+ if (runStatus !== "done")
243
+ completeFlowRun(context, { projectRoot, runId: run.id, status: "done", verdict: "merge_completed", nextAction: undefined });
244
+ refreshRunWorkProjection(context, run.project_id, run.id);
245
+ }
246
+ export function routeVnextMergeRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.status !== "queued")
247
+ throw new AppError("invalid_merge_state", "Only a queued request without an active dispatch may change route", 2, { status: request.status }); context.db.exec("BEGIN IMMEDIATE"); try {
248
+ context.db.run("UPDATE merge_requests SET execution_route = ?, last_error_json = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.mode, JSON.stringify({ route_reason: input.reason }), context.now(), request.merge_request_id]);
249
+ context.db.run("UPDATE works SET launch_policy = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [input.mode === "server" ? "fresh_agent_required" : "reuse_allowed", context.now(), request.executor_work_id]);
250
+ context.db.exec("COMMIT");
251
+ }
252
+ catch (error) {
253
+ context.db.exec("ROLLBACK");
254
+ throw error;
255
+ } refreshRunWorkProjection(context, request.project_id, request.run_id); return requestView(context, requireRequest(context, request.merge_request_id)); }
256
+ function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual integration conflicts in the integration workspace. Do not repeat merge apply.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, "If a check fails, inspect only the returned receipt/logs, repair the integrated target in this same Work, update the semantic result, and repeat the same finish command. Do not create a repair Work or rerun independent review.", "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
257
+ function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
258
+ function effectiveMergeChecks(run, request) { return effectiveCheckDeclarations(request.target_workspace, planChecks(run.workspace_root, JSON.parse(request.protocol_ids_json)), ["merge"]); }
259
+ function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
260
+ return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.checks ?? []).filter((check) => check.run_at === "merge").map((check) => ({ ...check, canonical_ref: `${protocol}/${check.id}` })); }); }
261
+ function validateMergeAcceptance(workspace, protocols, checks) { const known = new Set(checks.map((check) => check.canonical_ref)); const missing = mergeAcceptanceRefs(workspace, protocols).filter((ref) => !known.has(ref)); if (missing.length)
262
+ throw new AppError("merge_acceptance_invalid", "A merge acceptance criterion references a non-merge check", 2, { check_refs: missing }); }
263
+ function mergeAcceptanceRefs(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
264
+ return []; const plan = JSON.parse(fs.readFileSync(file, "utf8")); return (plan.acceptance ?? []).filter((entry) => entry.gate === "merge").flatMap((entry) => (entry.check_refs ?? []).map((ref) => `${protocol}/${ref}`)); }); }
265
+ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.existsSync(root))
266
+ return []; return fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.startsWith("PRT-")).map((entry) => entry.name).sort(); }
267
+ function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
268
+ throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
269
+ function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
270
+ function runBootstrap(run, cwd) { const command = executionSettings(run).code_bootstrap?.command; if (!command)
271
+ return; const result = spawnSync("/bin/sh", ["-lc", command], { cwd, encoding: "utf8" }); if (result.status !== 0)
272
+ throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed", 2, { command, stdout: result.stdout, stderr: result.stderr, status: result.status }); }
273
+ function verifyLocalDelivery(request) { const head = gitValue(request.target_workspace, ["rev-parse", request.target_branch]); if (!request.integration_commit || head !== request.integration_commit)
274
+ throw new AppError("merge_delivery_unproven", "Local integration branch does not resolve to the accepted integration commit", 1, { target_branch: request.target_branch, expected: request.integration_commit, actual: head }); }
275
+ function cleanupReceiptPath(run) { return path.join(requireHome(run), stageDir, "cleanup-receipt.json"); }
276
+ function performConfiguredCleanup(run, request) {
277
+ const policy = executionSettings(run).merge_cleanup?.source ?? "retain";
278
+ const receipt = cleanupReceiptPath(run);
279
+ let action = "retained";
280
+ if (policy === "delete_after_success" && path.resolve(request.source_workspace) !== path.resolve(request.target_workspace)) {
281
+ git(request.target_workspace, ["worktree", "remove", request.source_workspace]);
282
+ git(request.target_workspace, ["branch", "-d", request.source_branch]);
283
+ action = "deleted";
284
+ }
285
+ fs.writeFileSync(receipt, `${JSON.stringify({ schema_id: "dd-flow/merge-cleanup-receipt@1", merge_request_id: request.merge_request_id, policy, action, source_workspace: request.source_workspace, source_branch: request.source_branch, at: new Date().toISOString() }, null, 2)}\n`);
286
+ }
287
+ function waitForTurn(context, request, progress) { return new Promise((resolve) => { const poll = () => { const current = requireRequest(context, request.merge_request_id); const ahead = queueAhead(context, current); const active = context.db.get("SELECT merge_request_id FROM merge_requests WHERE project_id = ? AND status IN ('active','action_required','recovery_required') AND merge_request_id <> ? LIMIT 1", [current.project_id, current.merge_request_id]); if (ahead === 0 && !active)
288
+ return resolve(); progress?.(`MERGE ${current.merge_request_id} is waiting: ${ahead} request(s) ahead; next update in 15 seconds`); setTimeout(poll, 15_000); }; poll(); }); }
289
+ function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
290
+ function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
291
+ function requireOwnedActiveRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.executor_work_id !== input.workId || !["active", "action_required"].includes(request.status))
292
+ throw new AppError("invalid_merge_state", "MRG/Work/project is not the active integration owner", 2, { merge_request_id: input.requestId, work_id: input.workId, status: request.status }); return request; }
293
+ function assertExecutionBaseline(request) { const head = gitValue(request.target_workspace, ["rev-parse", "HEAD"], true); if (request.checkpoint === "baseline_locked" && head !== request.execution_target_head)
294
+ throw new AppError("merge_target_changed", "Integration target changed after the lane baseline was locked", 1, { expected: request.execution_target_head, actual: head }); }
295
+ function recovery(context, request, code, detail) { context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code, detail }), context.now(), request.merge_request_id]); throw new AppError("merge_recovery_required", "MERGE mutation could not be reconciled automatically", 1, { merge_request_id: request.merge_request_id, code, detail }); }
296
+ function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStatus(cwd, ignored).map(statusPath); if (!paths.length)
297
+ return; git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `chore(${runId.toLowerCase()}): freeze accepted source`]); }
298
+ function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored).map(statusPath); if (!mergeHead && !paths.length)
299
+ return; if (paths.length)
300
+ git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `merge(${runId.toLowerCase()}): integrate accepted source`]); }
301
+ function ignoredGitPaths(context, projectId) { return [readProjectConfig(context, projectId).dashboard.markdown_path.replaceAll("\\", "/")]; }
302
+ function meaningfulStatus(cwd, ignored) { const result = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { cwd, encoding: "utf8" }); if (result.status !== 0)
303
+ return []; return result.stdout.split(/\r?\n/).filter(Boolean).filter((line) => !ignored.includes(statusPath(line))); }
304
+ function statusPath(line) { const value = line.slice(3); const renamed = value.includes(" -> ") ? value.slice(value.lastIndexOf(" -> ") + 4) : value; return renamed.replace(/^"|"$/g, ""); }
305
+ function unmerged(cwd) { const value = gitValue(cwd, ["diff", "--name-only", "--diff-filter=U"], true); return value.split(/\r?\n/).filter(Boolean); }
306
+ function gitDirectory(cwd) { const value = gitValue(cwd, ["rev-parse", "--git-dir"]); return path.resolve(cwd, value); }
307
+ function git(cwd, args) { try {
308
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
309
+ }
310
+ catch (error) {
311
+ throw new AppError("git_operation_failed", `git ${args[0]} failed`, 1, { cwd, args, cause: String(error) });
312
+ } }
313
+ function gitValue(cwd, args, allowFailure = false) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); if (result.status !== 0 && !allowFailure)
314
+ throw new AppError("git_operation_failed", `git ${args[0]} failed`, 1, { cwd, args, stderr: result.stderr }); return result.status === 0 ? result.stdout.trim() : ""; }
315
+ function applyReceiptPath(context, request) { const run = requireRun(context, request.project_id, request.run_id); return path.join(requireHome(run), stageDir, "works", request.executor_work_id, "apply-receipt.json"); }
316
+ function requestForRun(context, projectId, runId) { const request = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [projectId, runId]); if (!request)
317
+ throw new AppError("merge_request_missing", "MERGE request was not materialized by the prior terminal stage", 1, { run_id: runId }); return request; }
318
+ function requireRequest(context, id) { const request = context.db.get("SELECT * FROM merge_requests WHERE merge_request_id = ?", [id]); if (!request)
319
+ throw new AppError("not_found", "MERGE request is not registered", 1, { merge_request_id: id }); return request; }
320
+ function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
321
+ function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_home_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
322
+ throw new AppError("not_found", "RUN is not registered", 1); return run; }
323
+ function requireHome(run) { if (!run.run_home_path)
324
+ throw new AppError("runtime_missing", "RUN workspace is unavailable", 1); return run.run_home_path; }
325
+ function findRootWork(context, projectId, runId) { const work = context.db.get("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NULL ORDER BY created_at LIMIT 1", [projectId, runId]); if (!work)
326
+ throw new AppError("runtime_missing", "vNext RUN has no root Work", 1); return work; }
327
+ function requireRootWork(context, projectId, runId) { const work = findRootWork(context, projectId, runId); if (work.status !== "running")
328
+ throw new AppError("runtime_missing", "vNext RUN has no running root Work", 1); return work; }
329
+ function applyCommand(context, request, projectRoot) { return `${flowCommand(context)} merge apply ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
330
+ function finishCommand(context, runId, request, projectRoot) { return `${flowCommand(context)} stage finish ${runId} --stage merge --request ${request.merge_request_id} --work ${request.executor_work_id} --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl`; }
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
5
+ import { validateCheckDeclaration, validateCheckPlacement, validateCodeCheckCommands } from "./code-checks.js";
6
6
  import { requireProjectByRoot } from "./projects.js";
7
7
  import { resolveProjectRoot } from "../storage/paths.js";
8
8
  import { advanceFlowRun, appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRunStage, getFlowRunVariables, gitFacts } from "./runs.js";
@@ -81,7 +81,7 @@ export function startVnextPlan(context, input) {
81
81
  : "- Reviewer capacity is not measured yet. PLAN must not probe or launch reviewers; PLAN-REVIEW will measure it once if review is enabled.";
82
82
  const reviewGroupingRule = "Group only semantically compatible applicable aspects, preserving real trust, irreversible, high-risk and hard-dependency boundaries. Prefer the fewest groups that retain independent review value, normally one review wave. Put two or three compatible aspects in a group; do not create one group per aspect merely for convenience. A later PLAN-REVIEW dispatch measures current capacity once and schedules these semantic groups into waves; do not invent a capacity value here.";
83
83
  const checkProfile = path.join(run.workspace_root, ".memory-bank", "spec", "engineering", "code-check-profile.json");
84
- const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Existing @check aliases use the profile and do not repeat a definition. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an honestly limited external/manual proof. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
84
+ const prompt = ["<stage_identity>", `- RUN: ${run.id}`, `- Work: ${planWorkId}`, "- stage: plan", "</stage_identity>", "", "<trusted_runtime_context>", "These facts were collected by dd-flow. Trust them; do not repeat CLI, Git, compatibility or permission discovery.", `- Project root: ${projectRoot}`, `- Workspace: ${run.workspace_root}`, `- Stage workspace: ${root}`, `- Git: ${JSON.stringify(gitFacts(run.workspace_root))}`, capacityContext, "</trusted_runtime_context>", "", "<workspace_contract>", `- route: ${workspaceRoute.route}`, `- feature branch: ${workspaceRoute.feature_branch ?? "not applicable"}`, `- base commit: ${workspaceRoute.base_ref ?? "not applicable"}`, `- write workspace: ${run.workspace_root}`, "The CLI has verified this frozen route. All project reads and writes for PLAN and later CODE happen in the write workspace; project root is only the stable runtime identity for lifecycle commands. Do not create, switch, merge or delete branches/worktrees.", "Keep the task runner's current cwd. Use the absolute paths in this packet instead of trying to set the provisioned workspace as a tool workdir.", "</workspace_contract>", "", "<accepted_inputs>", `- ${path.join(home, "01-specify", "specify.json")}`, `- ${path.join(home, "02-protocolize", "protocolize-result.json")}`, ...protocols.map((id) => `- ${path.join(run.workspace_root, ".memory-bank", "protocol", id, "summary.md")}`), "</accepted_inputs>", "", ...(fs.existsSync(checkProfile) ? ["<code_check_policy>", "You, not the CLI, select evidence for every accepted requirement and acceptance criterion. The profile only lists reusable aliases, mandatory project policy gates and guarded raw command prefixes. Inspect relevant package/test manifests before choosing a check. Do not classify checks by weight and do not omit a needed check because it looks expensive.", fs.readFileSync(checkProfile, "utf8").trim(), "</code_check_policy>", ""] : []), "<artifacts>", "The CLI has already materialized every artifact below as a partially filled draft. Edit these files in place; do not create replacements elsewhere.", "Prefilled and CLI-owned plan fields: schema_id, plan_id, protocol_id, initial revision and source_refs.", "Prefilled and CLI-owned aspect-map fields: schema_id, protocol_id, plan_id, plan revision, catalog_ref and every catalog aspect_id.", "You own the remaining semantic fields. Empty or missing semantic values are intentional draft markers and must be completed before validation.", ...planPaths.map((value) => `- partially filled plan: ${value}`), ...mapPaths.map((value) => `- partially filled aspect map: ${value}`), "</artifacts>", "", "<output_contract>", "Complete every named plan and aspect map in place. Do not create or edit code-work-batch.json: dd-flow derives it after validation.", "The CLI owns schema_id, plan_id, protocol_id, revision and source_refs. Preserve them exactly.", "Use protocol-plan@6. Its top-level checks[] is the single check catalog. Every check has id, command, purpose, run_at and availability. available means executable now. planned means one named P* Work first creates a NEW @check/... alias: planned therefore always needs provided_by and the exact alias definition. Every semantic @check alias, including an existing one, repeats its exact accepted profile command in definition so later stages can detect drift. Items and acceptance entries use check_refs only; never duplicate command declarations.", "For each R-* and AC-*, choose an actually relevant proof: an existing focused test, a new planned alias plus its provider Work, a project policy gate, or an honestly limited external/manual proof. Every plan item needs at least one check_ref. The CLI validates ids, provider ordering, materialization and guarded command policy; it never chooses a check for you. A provider Work may verify itself with the alias it has just created. A consumer must depend on that provider.", "Each plan item must name concrete existing source/test paths in required_read. planned_write_areas is optional: use stable component directories or files only when they help coordinate parallel Work; it is never a write allowlist. Reference every owned R-* and AC-* in one or more items; every AC-* needs an observable acceptance proof.", "For every selected check, inspect its command's launch path and the runtime entrypoints it starts. The fixture/reset process, service process and client process must observe one intended environment and data world. If a required runtime entrypoint needs a code change, make that change explicit in the Work task and its verification. Use planned_write_areas only to advertise likely concurrent overlap; do not treat it as ownership or assume another Work will repair an omitted change. If an independent infrastructure Work is clearer, plan that Work explicitly and order consumers after it.", reviewGroupingRule, "Complete compact contract and schema paths:", `- protocol plan schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "vnext-protocol-plan.schema.json")}`, `- aspect map schema: ${path.join(run.workspace_root, ".memory-bank", "dd-flow", "schemas", "plan-aspect-map.schema.json")}`, "Minimal valid protocol-plan shape:", "```json", JSON.stringify(planExample(protocols[0]), null, 2), "```", "Minimal valid aspect-map shape:", "```json", JSON.stringify(aspectMapExample(protocols[0]), null, 2), "```", "</output_contract>", "", "<execution_commands>", "PLAN never launches independent reviewers or registers CODE Work.", "If PLAN needs a material user decision with no reasonable default, run this exact one-command heredoc, replacing only its placeholder body. The heredoc is the permitted stdin form; do not use cat, a pipe, a temporary file or a second shell command:", "```sh", pauseCommandTemplate, "```", "Ask the returned user_message, stop, and resume this same PLAN Work with the exact returned command.", "Validate both partially filled drafts after completing their semantic fields:", ...validationCommands.map((command) => `- ${command}`), "Finish PLAN only after all questions are resolved and both validation commands pass:", finishCommand, "The response returns the only PLAN-REVIEW start command. Follow it; do not start CODE directly.", "</execution_commands>", "", "<stage_instructions>", template, "</stage_instructions>", ""].join("\n");
85
85
  const artifactMaterialization = { status: "materialized", completeness: "partially_filled", plan_paths: planPaths, aspect_map_paths: mapPaths, cli_owned_plan_fields: ["schema_id", "plan_id", "protocol_id", "revision", "source_refs"], cli_owned_aspect_map_fields: ["schema_id", "protocol_id", "plan_id", "plan_revision", "catalog_ref", "aspects[].aspect_id"], validation_commands: validationCommands };
86
86
  const promptPath = path.join(root, "stage-prompt.md");
87
87
  fs.writeFileSync(promptPath, prompt);
@@ -154,6 +154,8 @@ export function validateVnextPlanArtifacts(context, input) {
154
154
  assertPlanIdentity(value, planIdentity(input.home, input.runId, protocolId, ownership.get(protocolId) ?? []), file);
155
155
  validatePlanSemantics(file, new Set(ownership.get(protocolId) ?? []), obligations);
156
156
  validateCodeCheckCommands(workspaceRoot, value.checks.filter((check) => check.availability === "available").map((check) => check.command));
157
+ for (const check of value.checks.filter((item) => item.availability === "available"))
158
+ validateCheckDeclaration(workspaceRoot, check);
157
159
  // Placement is a PLAN-time rule. A Work may legitimately materialize a
158
160
  // new aggregate alias later, so rechecking against the changed profile
159
161
  // at CODE entry would retroactively invalidate an accepted plan.
@@ -381,7 +383,7 @@ function projectCodeWorkBatch(input) {
381
383
  key: workKey,
382
384
  launch_policy: "fresh_agent_required",
383
385
  source: { plan_id: value.plan_id, protocol_id: protocolId, plan_item_id: item.id, revision: value.revision, sha256: checksum(file) },
384
- task: renderCodeTask(item, value.checks),
386
+ task: renderCodeTask(item, value.checks, value.acceptance),
385
387
  semantic_spine: item.semantic_spine,
386
388
  requirements: item.requirement_refs.map((id) => ({ id, statement: obligationMap.get(id) ?? "" })),
387
389
  acceptance: value.acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).map(({ plan_item_ids: _ids, changed_surfaces: _surfaces, ...entry }) => entry),
@@ -402,7 +404,10 @@ function projectCodeWorkBatch(input) {
402
404
  ])],
403
405
  discovery_boundary: item.execution_context.discovery_boundary,
404
406
  planned_write_areas: [...new Set([...item.execution_context.planned_write_areas, ...documentPaths])],
405
- checks: value.checks.filter((check) => item.verification.check_refs.includes(check.id)),
407
+ // A criterion belongs to every listed Work. Its proof must travel with
408
+ // those Work packets as well as item-local verification; otherwise CODE
409
+ // silently loses readiness/code checks declared only by acceptance.
410
+ checks: value.checks.filter((check) => itemCheckRefs(item, value.acceptance).includes(check.id)),
406
411
  provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
407
412
  stop_conditions: item.execution_context.stop_conditions,
408
413
  depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
@@ -431,7 +436,10 @@ function projectCodeWorkBatch(input) {
431
436
  const ordered = [...works].sort((a, b) => a.key.localeCompare(b.key));
432
437
  return { schema_id: "dd-flow/code-work-batch@5", sources: input.plans.map(({ protocolId, file, value }) => ({ plan_id: value.plan_id, protocol_id: protocolId, revision: value.revision, sha256: checksum(file) })), works: ordered };
433
438
  }
434
- function renderCodeTask(item, catalog) { const checks = catalog.filter((check) => item.verification.check_refs.includes(check.id)); return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Checks: ${checks.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n"); }
439
+ function itemCheckRefs(item, acceptance) {
440
+ return [...new Set([...item.verification.check_refs, ...acceptance.filter((entry) => entry.plan_item_ids.includes(item.id)).flatMap((entry) => entry.check_refs)])];
441
+ }
442
+ function renderCodeTask(item, catalog, acceptance = []) { const checks = catalog.filter((check) => itemCheckRefs(item, acceptance).includes(check.id)); return [item.title, item.summary, item.details, `Preserve: ${item.semantic_spine.must_preserve.join("; ")}`, `Checks: ${checks.map((check) => `${check.id}:${check.command}`).join("; ")}`, `Stop: ${item.execution_context.stop_conditions.join("; ")}`].join("\n\n"); }
435
443
  function validateProjectedPaths(batch, workspaceRoot, home, runId) {
436
444
  for (const work of batch.works) {
437
445
  for (const write of work.planned_write_areas)
@@ -17,6 +17,7 @@ import { writeStageReport } from "./stage-report-renderer.js";
17
17
  const flowId = "mb-sdlc-vnext-specify";
18
18
  const protocolizeFlowId = "mb-sdlc-vnext-protocolize";
19
19
  const flowVersion = 4;
20
+ const protocolizeFlowVersion = 5;
20
21
  const stageId = "specify";
21
22
  const entryId = "default";
22
23
  export function isVnextSpecifyFlow(projectRoot) {
@@ -553,8 +554,10 @@ export function readVnextFlowDefinition(projectRoot) {
553
554
  ? definition.stages.specify?.entries?.default
554
555
  : undefined;
555
556
  const ordered = Array.isArray(actions?.actions) ? actions.actions : [];
556
- if ((definition.id === flowId || definition.id === protocolizeFlowId) && definition.version === flowVersion && ordered.length === 3) {
557
- return { id: definition.id };
557
+ const definitionId = typeof definition.id === "string" ? definition.id : null;
558
+ const supportedVersion = definitionId === flowId ? flowVersion : definitionId === protocolizeFlowId ? protocolizeFlowVersion : null;
559
+ if (definitionId && supportedVersion !== null && definition.version === supportedVersion && ordered.length === 3) {
560
+ return { id: definitionId };
558
561
  }
559
562
  }
560
563
  return null;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
  import { AppError } from "../shared/errors.js";
4
5
  import { gitFacts } from "./runs.js";
5
6
  const configRelativePath = path.join(".memory-bank", "dd-flow", "project-workspace.json");
@@ -74,13 +75,18 @@ export function requireVnextWorkspaceRoute(input) {
74
75
  throw new AppError("workspace_route_missing", `${input.stage} requires a provisioned feature worktree`, 1, { run_id: input.runId, workspace_root: workspaceRoot, policy });
75
76
  }
76
77
  const facts = gitFacts(workspaceRoot);
77
- if (facts.status === "unavailable" || facts.branch !== policy.feature_branch || facts.head !== policy.base_ref) {
78
- throw new AppError("workspace_route_invalid", `${input.stage} workspace no longer matches its frozen feature branch and base`, 1, { run_id: input.runId, workspace_root: workspaceRoot, expected: policy, actual: facts });
78
+ const ancestry = facts.head ? gitAncestry(workspaceRoot, policy.base_ref, facts.head) : { ok: false, status: null, error: "missing HEAD" };
79
+ if (facts.status === "unavailable" || facts.branch !== policy.feature_branch || !ancestry.ok) {
80
+ throw new AppError("workspace_route_invalid", `${input.stage} workspace no longer matches its frozen feature branch and base`, 1, { run_id: input.runId, workspace_root: workspaceRoot, expected: policy, actual: facts, ancestry });
79
81
  }
80
82
  }
81
83
  return policy;
82
84
  }
83
85
  function realPath(value) { return fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value); }
86
+ function gitAncestry(workspaceRoot, ancestor, descendant) {
87
+ const result = spawnSync("git", ["-C", workspaceRoot, "merge-base", "--is-ancestor", ancestor, descendant], { encoding: "utf8" });
88
+ return { ok: result.status === 0, status: result.status, error: result.error ? String(result.error) : null };
89
+ }
84
90
  function safeSlug(value) {
85
91
  const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
86
92
  return slug || "work";