@deksden-com/dd-flow-cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +10 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +24 -11
  5. package/dist/cli/run-cli.js +117 -28
  6. package/dist/domain/flow-contract.js +15 -4
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-stage-report.schema.json +7 -2
  10. package/dist/schemas/engine-manifest.schema.json +22 -0
  11. package/dist/schemas/flow-contract.schema.json +15 -13
  12. package/dist/schemas/flow-run.schema.json +1 -0
  13. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  14. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  15. package/dist/schemas/run-engine-binding.schema.json +37 -0
  16. package/dist/schemas/stage-prompt.schema.json +9 -5
  17. package/dist/schemas/stage-start-response.schema.json +6 -5
  18. package/dist/services/canon.js +15 -1
  19. package/dist/services/cleanup.js +77 -0
  20. package/dist/services/cli-operation-classifier.js +52 -8
  21. package/dist/services/compatibility-preflight.js +1 -1
  22. package/dist/services/dashboard.js +2 -2
  23. package/dist/services/engines.js +408 -30
  24. package/dist/services/hooks.js +28 -15
  25. package/dist/services/lanes.js +0 -4
  26. package/dist/services/merge-queue.js +48 -0
  27. package/dist/services/merge-worker.js +3 -4
  28. package/dist/services/migrations.js +307 -44
  29. package/dist/services/plan-runtime.js +4 -4
  30. package/dist/services/plans.js +5 -3
  31. package/dist/services/protocols.js +23 -2
  32. package/dist/services/run-engine-bindings.js +157 -0
  33. package/dist/services/run-projection.js +18 -4
  34. package/dist/services/runs.js +54 -7
  35. package/dist/services/schema-validation.js +96 -0
  36. package/dist/services/sessions.js +33 -73
  37. package/dist/services/stage-lifecycle.js +298 -45
  38. package/dist/services/status.js +8 -3
  39. package/dist/storage/database.js +32 -11
  40. package/package.json +1 -1
@@ -8,6 +8,7 @@ import { appendAudit } from "./audit.js";
8
8
  import { cancelLaneWaitersForWorker } from "./lanes.js";
9
9
  import { checkpointSessionUsage } from "./usage.js";
10
10
  import { refreshRunSessionProjection } from "./run-projection.js";
11
+ import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
11
12
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
13
  export function recordFlowSessionObservation(context, input) {
13
14
  const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
@@ -99,68 +100,7 @@ export function getFlowSessionStatus(context, input) {
99
100
  };
100
101
  }
101
102
  export function reconcileSessionCoverage(sessions) {
102
- const sessionCounts = countValues(sessions.map((session) => session.session_id));
103
- const duplicateSessionIds = [...sessionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
104
- const invalidSessionIds = [];
105
- const parsed = sessions.map((session) => {
106
- try {
107
- return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
108
- }
109
- catch {
110
- invalidSessionIds.push(session.session_id);
111
- return { session, units: [] };
112
- }
113
- });
114
- const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
115
- const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
116
- const expectedUnitIds = [...expectedCounts.keys()].sort();
117
- const duplicateExpectedUnitIds = [...expectedCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
118
- const observedOwners = new Map();
119
- for (const { session, units } of parsed) {
120
- if (session.session_kind === "orchestrator")
121
- continue;
122
- for (const unit of units) {
123
- const owners = observedOwners.get(unit.unit_id) ?? [];
124
- owners.push(session.session_id);
125
- observedOwners.set(unit.unit_id, owners);
126
- }
127
- }
128
- const observedUnitIds = [...observedOwners.keys()].sort();
129
- const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
130
- const duplicateUnitIds = [...observedOwners.entries()].filter(([, owners]) => new Set(owners).size > 1).map(([id]) => id).sort();
131
- const diagnostics = [];
132
- if (orchestrators.length === 0)
133
- diagnostics.push("orchestrator_session_missing");
134
- if (orchestrators.length > 1)
135
- diagnostics.push("multiple_orchestrator_sessions");
136
- if (expectedUnitIds.length === 0)
137
- diagnostics.push("expected_worker_units_missing");
138
- if (missingUnitIds.length > 0)
139
- diagnostics.push("expected_worker_units_unobserved");
140
- if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
141
- diagnostics.push("duplicate_coverage_binding");
142
- if (duplicateSessionIds.length > 0)
143
- diagnostics.push("duplicate_session_binding");
144
- if (invalidSessionIds.length > 0)
145
- diagnostics.push("invalid_coverage_units");
146
- const unavailable = orchestrators.length === 0 || expectedUnitIds.length === 0 || invalidSessionIds.length > 0;
147
- const partial = orchestrators.length !== 1
148
- || missingUnitIds.length > 0
149
- || duplicateUnitIds.length > 0
150
- || duplicateExpectedUnitIds.length > 0
151
- || duplicateSessionIds.length > 0;
152
- return {
153
- status: unavailable ? "unavailable" : partial ? "partial" : "complete",
154
- expected_unit_ids: expectedUnitIds,
155
- observed_unit_ids: observedUnitIds,
156
- missing_unit_ids: missingUnitIds,
157
- duplicate_unit_ids: duplicateUnitIds,
158
- duplicate_expected_unit_ids: duplicateExpectedUnitIds,
159
- duplicate_session_ids: duplicateSessionIds,
160
- orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
161
- invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
162
- diagnostics
163
- };
103
+ return reconcileSessionCoverageRows(sessions);
164
104
  }
165
105
  export function stopFlowSession(context, input) {
166
106
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -245,9 +185,10 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
245
185
  return nextCount;
246
186
  }
247
187
  export function flowSessionPayloadFromRegisterCommand(command) {
248
- if (!/\bdd-flow\s+session\s+register\b/.test(command)) {
188
+ if (/\bdd-flow\s+stage\s+start\b/.test(command))
189
+ return flowSessionPayloadFromStageStartCommand(command);
190
+ if (!/\bdd-flow\s+session\s+register\b/.test(command))
249
191
  return undefined;
250
- }
251
192
  const payloadBase64 = optionFromCommand(command, "payload-base64");
252
193
  const payloadJson = optionFromCommand(command, "payload-json");
253
194
  const payloadFile = optionFromCommand(command, "payload-file");
@@ -256,6 +197,25 @@ export function flowSessionPayloadFromRegisterCommand(command) {
256
197
  }
257
198
  return decodeFlowSessionPayload({ payloadBase64, payloadJson, payloadFile });
258
199
  }
200
+ function flowSessionPayloadFromStageStartCommand(command) {
201
+ const projectRoot = optionFromCommand(command, "project-root");
202
+ const stage = optionFromCommand(command, "stage");
203
+ const run = command.match(/\bdd-flow\s+stage\s+start\s+([^\s]+)/)?.[1];
204
+ if (!projectRoot || !stage || !run || run.startsWith("--"))
205
+ return undefined;
206
+ return {
207
+ project_root: projectRoot,
208
+ flow_kind: ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning",
209
+ run_id: run,
210
+ protocol_id: null,
211
+ worker_id: null,
212
+ workspace_path: null,
213
+ continuation_policy: "go_router",
214
+ session_kind: "orchestrator",
215
+ current_stage: stage,
216
+ coverage_units: []
217
+ };
218
+ }
259
219
  function decodeFlowSessionPayload(input) {
260
220
  if (input.payloadFile) {
261
221
  return normalizeFlowSessionPayload(parseJsonObject(fs.readFileSync(input.payloadFile, "utf8"), "session payload"));
@@ -485,9 +445,8 @@ function sessionKind(payload) {
485
445
  function normalizeCoverageUnits(value) {
486
446
  if (value === undefined || value === null)
487
447
  return [];
488
- if (!Array.isArray(value)) {
448
+ if (!Array.isArray(value))
489
449
  throw new AppError("validation", "coverage_units must be an array", 2);
490
- }
491
450
  return value.map((item, index) => {
492
451
  if (!item || typeof item !== "object" || Array.isArray(item)) {
493
452
  throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
@@ -496,16 +455,17 @@ function normalizeCoverageUnits(value) {
496
455
  if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
497
456
  throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
498
457
  }
499
- const optional = (key) => object[key] === undefined || object[key] === null ? null : typeof object[key] === "string" ? object[key] : (() => { throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2); })();
458
+ const optional = (key) => {
459
+ const field = object[key];
460
+ if (field === undefined || field === null)
461
+ return null;
462
+ if (typeof field === "string")
463
+ return field;
464
+ throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2);
465
+ };
500
466
  return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
501
467
  });
502
468
  }
503
- function countValues(values) {
504
- const counts = new Map();
505
- for (const value of values)
506
- counts.set(value, (counts.get(value) ?? 0) + 1);
507
- return counts;
508
- }
509
469
  function requiredStringField(payload, key) {
510
470
  const value = stringField(payload, key, true);
511
471
  if (!value) {
@@ -5,20 +5,111 @@ import { spawnSync } from "node:child_process";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { appendAudit } from "./audit.js";
7
7
  import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from "./protocols.js";
8
- import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus } from "./runs.js";
8
+ import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
9
9
  import { validateSchema } from "./schema-validation.js";
10
- import { reconcileSessionCoverage } from "./sessions.js";
10
+ import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
11
11
  import { usageForRun } from "./usage.js";
12
12
  import { boundCanonicalPlan } from "./plan-runtime.js";
13
13
  import { AppError } from "../shared/errors.js";
14
14
  import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
15
+ import { resolveCanonRoot } from "./canon.js";
16
+ import { preflightMemoryPermissions } from "./memory-permissions.js";
17
+ import { registerProject } from "./projects.js";
18
+ import { registerProtocol } from "./protocols.js";
19
+ import { previewNextEntityId } from "./ids.js";
20
+ const stagePromptSections = [
21
+ "stage_identity",
22
+ "authoritative_runtime_facts",
23
+ "preflight",
24
+ "task_intake",
25
+ "applicable_instructions",
26
+ "required_context",
27
+ "work_contract",
28
+ "completion_contract"
29
+ ];
30
+ function bootstrapStageRun(context, projectRoot, input) {
31
+ if (input.stage !== "specify") {
32
+ throw new AppError("validation", "--bootstrap currently starts the initial specify stage only", 2, { stage: input.stage });
33
+ }
34
+ const subject = input.bootstrap?.subject.trim();
35
+ if (!subject)
36
+ throw new AppError("usage", "--bootstrap requires --subject", 2);
37
+ registerProject(context, { root: projectRoot });
38
+ const preview = previewNextEntityId(context, { projectRoot, type: "protocol", slug: subject });
39
+ const protocolId = preview.entity.id;
40
+ registerProtocol(context, { handshakeId: protocolId, projectRoot });
41
+ const started = startFlowRun(context, {
42
+ projectRoot,
43
+ flowKind: "mb_sdlc",
44
+ subjectType: "protocol",
45
+ subjectId: protocolId,
46
+ slug: subject,
47
+ nextAction: "run_specify"
48
+ });
49
+ const intakeFile = input.bootstrap?.intakeFile;
50
+ if (intakeFile) {
51
+ const source = path.resolve(intakeFile);
52
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
53
+ throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
54
+ }
55
+ const intakeDir = path.join(started.run.run_home_path ?? path.dirname(started.run.id), "intake");
56
+ fs.mkdirSync(intakeDir, { recursive: true });
57
+ fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
58
+ }
59
+ return started.run.id;
60
+ }
61
+ function stagePreflight(projectRoot, stageRoot) {
62
+ const memoryBank = preflightMemoryPermissions({
63
+ root: projectRoot,
64
+ memoryBank: ".memory-bank",
65
+ flow: "custom",
66
+ mode: "write",
67
+ targets: [{ path: ".memory-bank", mode: "read" }]
68
+ });
69
+ const probe = path.join(stageRoot, `.dd-flow-permission-${crypto.randomUUID()}`);
70
+ try {
71
+ fs.writeFileSync(probe, "");
72
+ fs.renameSync(probe, `${probe}.renamed`);
73
+ fs.rmSync(`${probe}.renamed`);
74
+ return { ...memoryBank, ok: memoryBank.ok === true, stage_workspace: { path: stageRoot, status: "passed" } };
75
+ }
76
+ catch (error) {
77
+ for (const candidate of [probe, `${probe}.renamed`]) {
78
+ try {
79
+ fs.rmSync(candidate, { force: true });
80
+ }
81
+ catch { /* best effort */ }
82
+ }
83
+ return { ...memoryBank, ok: false, stage_workspace: { path: stageRoot, status: "failed", message: String(error) } };
84
+ }
85
+ }
86
+ function flowKindForStage(stage) {
87
+ return ["code", "implementation", "readiness", "merge"].includes(stage) ? "implementation" : "planning";
88
+ }
89
+ function runtimeFacts(view, projectRoot) {
90
+ return {
91
+ project_root: projectRoot,
92
+ workspace_root: view.run.workspace_root,
93
+ run_id: view.run.id,
94
+ protocol_id: view.run.subject.id,
95
+ runtime: {
96
+ source: "dd-flow",
97
+ state: "trusted",
98
+ flow_kind: view.run.flow_kind
99
+ },
100
+ git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
101
+ };
102
+ }
15
103
  export function startStage(context, input) {
16
104
  const projectRoot = resolveProjectRoot(input.projectRoot);
17
- const before = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
18
- const dir = input.dir ?? defaultStageDir(input.stage);
105
+ const runId = input.bootstrap ? bootstrapStageRun(context, projectRoot, input) : input.runId;
106
+ if (!runId)
107
+ throw new AppError("usage", "stage start requires a RUN id or --bootstrap", 2);
108
+ const before = runView(getFlowRunStatus(context, { projectRoot, runId }));
109
+ const dir = input.dir ?? defaultStageDir(input.stage, before.run.flow_kind);
19
110
  const attached = runView(attachFlowRunStage(context, {
20
111
  projectRoot,
21
- runId: input.runId,
112
+ runId,
22
113
  stage: input.stage,
23
114
  dir,
24
115
  status: "running",
@@ -28,22 +119,45 @@ export function startStage(context, input) {
28
119
  const stageRoot = path.join(runHome, dir);
29
120
  ensureWithin(runHome, stageRoot, "stage root");
30
121
  fs.mkdirSync(stageRoot, { recursive: true });
31
- syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
122
+ const preflight = stagePreflight(projectRoot, stageRoot);
123
+ if (preflight.ok !== true)
124
+ throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
125
+ if (attached.run.subject.type === "protocol") {
126
+ syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
127
+ }
128
+ if (input.sessionId) {
129
+ registerFlowSession(context, {
130
+ sessionId: input.sessionId,
131
+ payloadJson: JSON.stringify({
132
+ project_root: projectRoot,
133
+ flow_kind: flowKindForStage(input.stage),
134
+ run_id: attached.run.id,
135
+ protocol_id: attached.run.subject.id,
136
+ worker_id: null,
137
+ workspace_path: attached.run.workspace_root,
138
+ continuation_policy: "go_router",
139
+ session_kind: "orchestrator",
140
+ current_stage: input.stage,
141
+ cwd: attached.run.workspace_root
142
+ })
143
+ });
144
+ }
32
145
  const promptPath = path.join(stageRoot, "stage-prompt.md");
33
- const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir);
146
+ const prompt = composeStagePrompt(context, projectRoot, before, attached, input.stage, dir, preflight);
34
147
  atomicWrite(promptPath, prompt);
35
148
  const promptDataPath = path.join(stageRoot, "stage-prompt.json");
36
149
  const planPath = planJsonPath(projectRoot, attached.run.subject.id);
37
150
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
38
151
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
39
152
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
153
+ const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
40
154
  atomicWrite(promptDataPath, {
41
- schema_id: "dd-flow/stage-prompt@1",
155
+ schema_id: "dd-flow/stage-prompt@2",
42
156
  run_id: attached.run.id,
43
157
  stage: input.stage,
44
158
  generated_at: context.now(),
45
159
  prompt_path: promptPath,
46
- sections: ["stage_identity", "runtime_context", "intake", "applicable_instructions", "stage_cli", "file_boundaries", "completion_contract"],
160
+ sections: stagePromptSections,
47
161
  aliases: {
48
162
  project: projectRoot,
49
163
  workspace: attached.run.workspace_root,
@@ -54,11 +168,30 @@ export function startStage(context, input) {
54
168
  ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
55
169
  },
56
170
  write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
57
- source_fragments: input.stage === "plan" ? [".memory-bank/dd-flow/plan.md"] : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"]
171
+ source_fragments: sources.map((source) => source.label),
172
+ authoritative_facts: runtimeFacts(attached, projectRoot),
173
+ preflight: {
174
+ compatibility: {
175
+ status: "checked",
176
+ operation: `stage.${input.stage}`,
177
+ project_root: projectRoot
178
+ },
179
+ permissions: preflight,
180
+ session_binding: {
181
+ status: input.sessionId ? "bound" : "not_bound",
182
+ session_id: input.sessionId ?? null
183
+ }
184
+ },
185
+ required_context: sources.map((source) => ({
186
+ path: source.label,
187
+ reason: "Stage-specific canonical instruction source",
188
+ stop_condition: "Stop when the source no longer contains unresolved requirements for this stage"
189
+ })),
190
+ worker_prompt_markdown: prompt
58
191
  });
59
192
  validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
60
193
  atomicWrite(path.join(stageRoot, "stage-start.json"), {
61
- schema_id: "dd-flow/stage-start@1",
194
+ schema_id: "dd-flow/stage-start@2",
62
195
  run_id: attached.run.id,
63
196
  stage: input.stage,
64
197
  dir,
@@ -68,7 +201,7 @@ export function startStage(context, input) {
68
201
  });
69
202
  return {
70
203
  ok: true,
71
- schema_id: "dd-flow/stage-start-response@1",
204
+ schema_id: "dd-flow/stage-start-response@2",
72
205
  run_id: attached.run.id,
73
206
  stage: input.stage,
74
207
  attempt_number: attemptNumber,
@@ -86,32 +219,33 @@ export function startStage(context, input) {
86
219
  },
87
220
  ...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
88
221
  resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
89
- next_command: "dd-flow stage finish",
90
- permission_probe: { status: "known_targets_only", project_root: projectRoot },
222
+ next_command: stageFinishCommand(attached.run, projectRoot, input.stage),
223
+ permission_probe: preflight,
91
224
  run: attached.run,
92
225
  prompt: { path: promptPath, data_path: promptDataPath },
226
+ worker_prompt_markdown: prompt,
93
227
  lifecycle: "stage start -> semantic work -> stage finish"
94
228
  };
95
229
  }
96
230
  export function finishStage(context, input) {
97
231
  const projectRoot = resolveProjectRoot(input.projectRoot);
98
- const view = runView(getFlowRunStatus(context, { projectRoot, runId: input.runId }));
232
+ if (!input.runId)
233
+ throw new AppError("usage", "stage finish requires a RUN id", 2);
234
+ const runId = input.runId;
235
+ const view = runView(getFlowRunStatus(context, { projectRoot, runId }));
99
236
  const existing = view.index.stage_runs?.find((stage) => stage.stage === input.stage);
100
237
  if (!existing) {
101
238
  throw new AppError("not_found", `Run stage is not attached: ${input.stage}`, 1, { run_id: view.run.id, stage: input.stage });
102
239
  }
103
- const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage);
240
+ const dir = existing.dir ?? input.dir ?? defaultStageDir(input.stage, view.run.flow_kind);
104
241
  const runHome = runHomePath(view.run);
105
242
  const stageRoot = path.join(runHome, dir);
106
243
  ensureWithin(runHome, stageRoot, "stage root");
107
244
  fs.mkdirSync(stageRoot, { recursive: true });
108
- if (!input.semanticFile) {
109
- throw new AppError("stage_finish_input_required", "Stage finish requires --semantic-file with dd-flow/stage-finish-input@1", 2);
110
- }
111
- const semanticFile = resolveStageFile(input.semanticFile, stageRoot, runHome);
245
+ const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
112
246
  validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot });
113
247
  const semantic = readSemanticFile(semanticFile, runHome);
114
- const status = input.status ?? "done";
248
+ const status = input.outcome ?? "done";
115
249
  if (!["done", "blocked", "failed"].includes(status)) {
116
250
  throw new AppError("validation", "Stage finish status must be done, blocked, or failed", 2, { status });
117
251
  }
@@ -128,14 +262,16 @@ export function finishStage(context, input) {
128
262
  const reportPath = path.join(stageRoot, "stage-report.md");
129
263
  const htmlPath = path.join(stageRoot, "stage-report.html");
130
264
  atomicWrite(dataPath, report);
131
- validateSchema({ schemaName: planFinish ? "plan-stage-report" : "stage-report", file: dataPath, projectRoot });
265
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot });
132
266
  atomicWrite(reportPath, renderMarkdown(report));
133
267
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
134
- syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
268
+ if (view.run.subject.type === "protocol") {
269
+ syncProtocolLifecycle(context, view.run.project_id, view.run.subject.id, input.stage, status, stringValue(semantic.next_action));
270
+ }
135
271
  const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
136
272
  const completed = completeFlowRunStage(context, {
137
273
  projectRoot,
138
- runId: input.runId,
274
+ runId,
139
275
  stage: input.stage,
140
276
  status,
141
277
  stageReport: htmlPath,
@@ -223,8 +359,20 @@ function nextActionForProtocolStage(stage) {
223
359
  function runHomePath(run) {
224
360
  return run.run_home_path ?? path.dirname(run.run_index_path);
225
361
  }
226
- function defaultStageDir(stage) {
362
+ export function defaultStageDir(stage, flowKind) {
227
363
  const normalized = stage === "implementation" ? "code" : stage;
364
+ if (flowKind === "mb-upgrade") {
365
+ const upgrade = {
366
+ preflight: "01-preflight",
367
+ "diff-analysis": "02-diff-analysis",
368
+ upgrade: "03-upgrade",
369
+ lint: "04-lint",
370
+ review: "05-review",
371
+ merge: "06-merge"
372
+ };
373
+ if (upgrade[normalized])
374
+ return upgrade[normalized];
375
+ }
228
376
  const known = { specify: "01-specify", plan: "02-plan", code: "03-code", readiness: "03-code", merge: "04-merge" };
229
377
  return known[normalized] ?? `03-${normalized.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
230
378
  }
@@ -237,54 +385,109 @@ function dataSchemaForStage(stage) {
237
385
  return "dd-flow/merge-stage-report@2";
238
386
  return "dd-flow/code-stage-report@2";
239
387
  }
240
- function composeStagePrompt(context, projectRoot, before, current, stage, dir) {
388
+ function stageReportSchemaName(stage, planFinish) {
389
+ if (planFinish)
390
+ return "plan-stage-report";
391
+ if (stage === "code" || stage === "implementation")
392
+ return "code-stage-report";
393
+ if (stage === "merge")
394
+ return "merge-stage-report";
395
+ return "stage-report";
396
+ }
397
+ function stageFinishCommand(run, projectRoot, stage) {
398
+ const compatibilityMode = run.flow_kind === "mb-upgrade" ? " --compatibility-mode mb-upgrade" : "";
399
+ return `dd-flow stage finish ${run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)}${compatibilityMode} --json`;
400
+ }
401
+ function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
241
402
  const project = requireProjectByRoot(context, projectRoot);
242
403
  const runHome = runHomePath(current.run);
243
- const flowFiles = stage === "plan"
244
- ? [".memory-bank/dd-flow/plan.md"]
245
- : [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"];
246
- const instructions = flowFiles.map((file) => {
247
- const absolute = path.join(projectRoot, file);
248
- return fs.existsSync(absolute) ? `### ${file}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${file}\n\n(unavailable)`;
404
+ const instructions = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage).map(({ label, absolute }) => {
405
+ return fs.existsSync(absolute) ? `### ${label}\n\n${fs.readFileSync(absolute, "utf8").trim()}` : `### ${label}\n\n(unavailable)`;
249
406
  }).join("\n\n");
250
407
  const protocol = safeProtocolState(context, current.run.project_id, current.run.subject.id);
408
+ const requiredContext = stageInstructionSources(context, projectRoot, current.run.flow_kind, stage)
409
+ .map(({ label }) => `- Read: \`${label}\``).join("\n");
251
410
  return [
252
- "# Stage Prompt",
253
- "",
254
- "## Identity",
411
+ "<stage_identity>",
412
+ "# Stage work packet",
255
413
  `- Project: ${project.id}`,
256
414
  `- Run: ${current.run.id}`,
257
415
  `- Protocol: ${current.run.subject.id}`,
258
416
  `- Stage: ${stage}`,
259
417
  `- Attempt: ${current.index.stage_runs?.find((item) => item.stage === stage)?.attempt ?? "try-001"}`,
418
+ "</stage_identity>",
260
419
  "",
261
- "## Runtime",
420
+ "<authoritative_runtime_facts>",
421
+ "These facts were collected by dd-flow. Trust them; do not re-run CLI, Git, compatibility, or permission discovery unless the semantic work changes the relevant state.",
262
422
  `- Project root: ${projectRoot}`,
263
423
  `- Workspace root: ${current.run.workspace_root}`,
264
424
  `- Run home: ${runHome}`,
265
425
  `- Stage root: ${path.join(runHome, dir)}`,
266
426
  `- Git branch: ${current.index.execution?.git?.branch ?? "unknown"}`,
267
427
  `- Git head: ${current.index.execution?.git?.head ?? "unknown"}`,
428
+ "</authoritative_runtime_facts>",
429
+ "",
430
+ "<preflight>",
431
+ `- Permission probe: ${String(preflight.ok === true ? "passed" : "failed")}.`,
432
+ "- CLI compatibility was checked before this state-changing command.",
433
+ "</preflight>",
268
434
  "",
269
- "## Intake",
435
+ "<task_intake>",
270
436
  "Raw intake and provider telemetry stay under the RUN home. Promote only durable decisions to the protocol summary.",
271
437
  `- Protocol next action: ${protocol?.next_action ?? "not recorded"}`,
272
438
  `- Previous stage: ${before.index.stage_runs?.map((item) => `${item.stage}:${item.status}`).join(", ") || "none"}`,
439
+ "</task_intake>",
273
440
  "",
274
- "## Instructions",
441
+ "<applicable_instructions>",
275
442
  instructions,
443
+ "</applicable_instructions>",
276
444
  "",
277
- "## CLI",
278
- "Use the project-compatible dd-flow CLI for state transitions and artifact registration. Keep paths inside the selected workspace and RUN home.",
445
+ "<required_context>",
446
+ requiredContext || "- No additional stage-specific source is required.",
447
+ "</required_context>",
279
448
  "",
280
- "## Boundaries",
281
- "Do not use raw Git as a workspace manager, unverified tools, PATH/latest fallbacks, recursive unrelated validation, or model-supplied session identity.",
449
+ "<work_contract>",
450
+ "Perform only the semantic work for this stage. Write current-stage artifacts to `@stage`; `@stage/try-NNN` is read-only archive history.",
451
+ "Do not repeat deterministic preflight, global CLI help, runtime/Git inspection, session registration, report rendering, summary generation, or state transition work.",
452
+ "</work_contract>",
282
453
  "",
283
- "## Completion Contract",
284
- `Finish with: dd-flow stage finish ${current.run.id} --stage ${stage} --project-root <project-root> --semantic-file @stage/stage-input.json --json`,
454
+ "<completion_contract>",
455
+ `Write semantic output to \`@stage/stage-input.json\`, then run: \`${stageFinishCommand(current.run, projectRoot, stage)}\`.`,
285
456
  "The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
457
+ "</completion_contract>",
286
458
  ].join("\n");
287
459
  }
460
+ function stageInstructionSources(context, projectRoot, flowKind, stage) {
461
+ if (flowKind === "mb-upgrade") {
462
+ const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
463
+ if (!canon.ok || !canon.canon) {
464
+ throw new AppError("canon_unavailable", "mb-upgrade stage prompt requires the canonical Memory Bank", 1, {
465
+ blockers: canon.blockers,
466
+ bootstrap: canon.bootstrap
467
+ });
468
+ }
469
+ const flowRoot = canon.canon.flow_root;
470
+ const stageSupport = {
471
+ "diff-analysis": "mb-upgrade/diff-analysis.md",
472
+ lint: "mb-upgrade/lint-verification.md",
473
+ review: "mb-upgrade/review/index.md",
474
+ merge: "mb-upgrade/merge.md"
475
+ };
476
+ return ["mb-upgrade.md", "mb-upgrade/index.md", stageSupport[stage]]
477
+ .filter((file) => Boolean(file))
478
+ .map((file) => ({ label: `canonical:${file}`, absolute: path.join(flowRoot, file) }));
479
+ }
480
+ const filesByStage = {
481
+ specify: [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"],
482
+ plan: [".memory-bank/dd-flow/plan.md"],
483
+ code: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
484
+ implementation: [".memory-bank/dd-flow/code.md", ".memory-bank/dd-flow/mb-sdlc/code/implement.md"],
485
+ readiness: [".memory-bank/dd-flow/mb-sdlc/code/readiness.md"],
486
+ merge: [".memory-bank/dd-flow/merge.md"]
487
+ };
488
+ const files = filesByStage[stage] ?? [".memory-bank/dd-flow/mb-sdlc/specify/stage.md"];
489
+ return files.map((file) => ({ label: file, absolute: path.join(projectRoot, file) }));
490
+ }
288
491
  function safeProtocolState(context, projectId, protocolId) {
289
492
  try {
290
493
  const protocol = requireProtocol(context, protocolId, projectId);
@@ -376,6 +579,34 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
376
579
  }
377
580
  };
378
581
  }
582
+ if (stage === "code" || stage === "implementation") {
583
+ return {
584
+ schema_id: "dd-flow/code-stage-report@2",
585
+ run: { run_id: view.run.id, run_state: view.run.run_index_path },
586
+ stage: { name: stage, dir: path.basename(stageRoot), status },
587
+ project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
588
+ subject: { id: view.run.subject.id, title: view.run.subject.id },
589
+ flow_flags: flowFlagsReportProjection(view.index.flow_flags),
590
+ overall: {
591
+ verdict: status === "done" ? "accepted" : status,
592
+ summary: result,
593
+ next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
594
+ },
595
+ breadcrumbs: [{ label: "RUN", href: view.run.run_index_path, status: "available" }],
596
+ implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
597
+ acceptance_scenarios: acceptance.map((summary, index) => ({
598
+ id: `SCN-${stage}-${index + 1}`,
599
+ title: `Acceptance ${index + 1}`,
600
+ verdict: "accepted",
601
+ steps: [{ title: "Stage finish", summary }],
602
+ evidence: evidence.map((item) => ({ path: item, label: item }))
603
+ })),
604
+ changed_files: changedFiles.map((file) => ({ path: file, label: file })),
605
+ checks: checks.map((name) => ({ name, status: "passed" })),
606
+ review: { verdict: status === "done" ? "accepted" : status, findings: [] },
607
+ defs: Array.isArray(semantic.def_outcomes) ? semantic.def_outcomes : []
608
+ };
609
+ }
379
610
  return {
380
611
  schema_id: "dd-flow/stage-report@1",
381
612
  run_id: view.run.id,
@@ -446,6 +677,28 @@ function stringValue(value) {
446
677
  function stringArray(value, fallback) {
447
678
  return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
448
679
  }
680
+ function flowFlagsReportProjection(value) {
681
+ if (!value) {
682
+ return {
683
+ flow_kind: "unknown",
684
+ snapshot_revision: 1,
685
+ resolution_status: "legacy_incomplete",
686
+ values: {},
687
+ snapshot_checksum: "0".repeat(64)
688
+ };
689
+ }
690
+ return {
691
+ ...(value.contract ? { contract: value.contract } : {}),
692
+ flow_kind: value.flow_kind ?? "unknown",
693
+ ...(value.preset ? { preset: value.preset } : {}),
694
+ snapshot_revision: value.snapshot_revision ?? 1,
695
+ resolution_status: value.resolution_status ?? "legacy_incomplete",
696
+ values: value.values ?? {},
697
+ ...(value.snapshot_checksum ? { snapshot_checksum: value.snapshot_checksum } : { snapshot_checksum: "0".repeat(64) }),
698
+ ...(Array.isArray(value.floors_applied) ? { floors_applied: value.floors_applied } : {}),
699
+ ...(value.resolved_at ? { resolved_at: value.resolved_at } : {})
700
+ };
701
+ }
449
702
  function gitChangedFiles(workspaceRoot) {
450
703
  const result = spawnSync("git", ["-C", workspaceRoot, "status", "--short"], { encoding: "utf8" });
451
704
  if (result.status !== 0)
@@ -469,7 +722,7 @@ function workerCoverage(context, projectId, runId) {
469
722
  }
470
723
  function runTargetedMemoryBankLint(projectRoot, semantic) {
471
724
  const declared = stringArray(semantic.changed_files, []);
472
- const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
725
+ const files = [...new Set(declared.filter((file) => file.startsWith(".memory-bank/") && file.toLowerCase().endsWith(".md") && !file.split(/[\\/]/u).some((part) => part === ".." || part.startsWith(".env"))))];
473
726
  for (const file of files)
474
727
  ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
475
728
  if (files.length === 0) {