@deksden-com/dd-flow-cli 0.6.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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -1
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +20 -8
  5. package/dist/cli/run-cli.js +104 -24
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/runtime/context.js +8 -2
  8. package/dist/schemas/code-stage-report.schema.json +7 -2
  9. package/dist/schemas/engine-manifest.schema.json +22 -0
  10. package/dist/schemas/flow-run.schema.json +1 -0
  11. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  12. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  13. package/dist/schemas/run-engine-binding.schema.json +37 -0
  14. package/dist/schemas/stage-prompt.schema.json +4 -4
  15. package/dist/services/canon.js +15 -1
  16. package/dist/services/cli-operation-classifier.js +52 -8
  17. package/dist/services/compatibility-preflight.js +1 -1
  18. package/dist/services/dashboard.js +2 -2
  19. package/dist/services/engines.js +408 -30
  20. package/dist/services/hooks.js +1 -5
  21. package/dist/services/lanes.js +0 -4
  22. package/dist/services/merge-queue.js +48 -0
  23. package/dist/services/merge-worker.js +3 -4
  24. package/dist/services/migrations.js +307 -44
  25. package/dist/services/plan-runtime.js +4 -4
  26. package/dist/services/plans.js +5 -3
  27. package/dist/services/protocols.js +23 -2
  28. package/dist/services/run-engine-bindings.js +157 -0
  29. package/dist/services/runs.js +21 -7
  30. package/dist/services/schema-validation.js +96 -0
  31. package/dist/services/stage-lifecycle.js +98 -10
  32. package/dist/services/status.js +8 -3
  33. package/dist/storage/database.js +32 -11
  34. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ export const runEngineBindingSchemaId = "dd-flow/run-engine-binding@1";
5
+ export function findRunHome(ddFlowHome, projectRoot, runIdOrAlias) {
6
+ const projectsRoot = path.join(ddFlowHome, "projects");
7
+ if (!fs.existsSync(projectsRoot))
8
+ return null;
9
+ const expectedRoot = realpathOrResolve(projectRoot);
10
+ const matches = [];
11
+ for (const projectId of fs.readdirSync(projectsRoot)) {
12
+ const runsRoot = path.join(projectsRoot, projectId, "runs");
13
+ if (!isDirectory(runsRoot))
14
+ continue;
15
+ for (const runId of fs.readdirSync(runsRoot)) {
16
+ if (!runIdMatches(runId, runIdOrAlias))
17
+ continue;
18
+ const runHome = path.join(runsRoot, runId);
19
+ const authorityPath = path.join(runHome, "run.json");
20
+ const bindingPath = path.join(runHome, "engine-binding.json");
21
+ const binding = readRunEngineBinding(bindingPath, { allowMissing: true });
22
+ const authorityRoot = binding?.project_root ?? projectRootFromAuthority(authorityPath);
23
+ if (!authorityRoot || realpathOrResolve(authorityRoot) !== expectedRoot)
24
+ continue;
25
+ matches.push({ project_id: projectId, run_id: runId, run_home: runHome, authority_path: authorityPath, binding_path: bindingPath });
26
+ }
27
+ }
28
+ if (matches.length > 1) {
29
+ throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, {
30
+ project_root: expectedRoot,
31
+ candidates: matches.map((match) => match.run_id)
32
+ });
33
+ }
34
+ return matches[0] ?? null;
35
+ }
36
+ export function readRunEngineBinding(file, options = {}) {
37
+ if (!fs.existsSync(file)) {
38
+ if (options.allowMissing)
39
+ return null;
40
+ throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: file });
41
+ }
42
+ let value;
43
+ try {
44
+ value = JSON.parse(fs.readFileSync(file, "utf8"));
45
+ }
46
+ catch (error) {
47
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding is not valid JSON", 2, { path: file, cause: String(error) });
48
+ }
49
+ if (!isRunEngineBinding(value)) {
50
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding is invalid", 2, { path: file });
51
+ }
52
+ return value;
53
+ }
54
+ export function writeRunEngineBinding(file, binding) {
55
+ const existing = readRunEngineBinding(file, { allowMissing: true });
56
+ if (existing) {
57
+ if (sameEngine(existing.engine, binding.engine) && existing.run_id === binding.run_id && realpathOrResolve(existing.project_root) === realpathOrResolve(binding.project_root)) {
58
+ return { changed: false, binding: existing };
59
+ }
60
+ throw new AppError("run_engine_binding_immutable", "RUN engine binding cannot be changed", 1, {
61
+ path: file,
62
+ current: existing.engine,
63
+ requested: binding.engine
64
+ });
65
+ }
66
+ fs.mkdirSync(path.dirname(file), { recursive: true });
67
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
68
+ fs.writeFileSync(temporary, `${JSON.stringify(binding, null, 2)}\n`);
69
+ fs.renameSync(temporary, file);
70
+ return { changed: true, binding };
71
+ }
72
+ export function allRunEngineBindings(ddFlowHome) {
73
+ const projectsRoot = path.join(ddFlowHome, "projects");
74
+ if (!fs.existsSync(projectsRoot))
75
+ return [];
76
+ const bindings = [];
77
+ for (const projectId of fs.readdirSync(projectsRoot)) {
78
+ const runsRoot = path.join(projectsRoot, projectId, "runs");
79
+ if (!isDirectory(runsRoot))
80
+ continue;
81
+ for (const runId of fs.readdirSync(runsRoot)) {
82
+ const binding = readRunEngineBinding(path.join(runsRoot, runId, "engine-binding.json"), { allowMissing: true });
83
+ if (binding)
84
+ bindings.push(binding);
85
+ }
86
+ }
87
+ return bindings;
88
+ }
89
+ function isRunEngineBinding(value) {
90
+ if (!value || typeof value !== "object" || Array.isArray(value))
91
+ return false;
92
+ const record = value;
93
+ const engine = record.engine;
94
+ const probe = record.probe;
95
+ return record.schema_id === runEngineBindingSchemaId
96
+ && typeof record.run_id === "string"
97
+ && typeof record.project_root === "string"
98
+ && typeof record.bound_at === "string"
99
+ && ["run_creation", "legacy_recovery"].includes(String(record.source))
100
+ && typeof record.reason === "string"
101
+ && Boolean(engine && typeof engine === "object" && !Array.isArray(engine)
102
+ && typeof engine.package_name === "string"
103
+ && typeof engine.package_version === "string"
104
+ && typeof engine.engine_version === "string"
105
+ && /^[a-f0-9]{64}$/.test(String(engine.integrity_checksum))
106
+ && typeof engine.snapshot_root === "string")
107
+ && Boolean(probe && typeof probe === "object" && !Array.isArray(probe)
108
+ && ["not_required", "passed"].includes(String(probe.status))
109
+ && (typeof probe.command === "string" || probe.command === null));
110
+ }
111
+ function sameEngine(left, right) {
112
+ return left.package_name === right.package_name
113
+ && left.package_version === right.package_version
114
+ && left.engine_version === right.engine_version
115
+ && left.integrity_checksum === right.integrity_checksum
116
+ && left.snapshot_root === right.snapshot_root;
117
+ }
118
+ function projectRootFromAuthority(file) {
119
+ try {
120
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
121
+ const execution = recordValue(value.execution);
122
+ const workspace = recordValue(value.workspace);
123
+ const project = recordValue(value.project);
124
+ return stringValue(execution?.project_root)
125
+ ?? stringValue(workspace?.project_root)
126
+ ?? stringValue(project?.root)
127
+ ?? stringValue(value.project_root);
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function runIdMatches(fullId, idOrAlias) {
134
+ return fullId === idOrAlias || /^RUN-[0-9]+$/.test(idOrAlias) && fullId.startsWith(`${idOrAlias}-`);
135
+ }
136
+ function isDirectory(value) {
137
+ try {
138
+ return fs.statSync(value).isDirectory();
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ function realpathOrResolve(value) {
145
+ try {
146
+ return fs.realpathSync(value);
147
+ }
148
+ catch {
149
+ return path.resolve(value);
150
+ }
151
+ }
152
+ function recordValue(value) {
153
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
154
+ }
155
+ function stringValue(value) {
156
+ return typeof value === "string" && value.length > 0 ? value : null;
157
+ }
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
6
- import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
6
+ import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadCanonicalFlowContract, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
8
  import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
9
9
  import { appendAudit } from "./audit.js";
@@ -12,6 +12,8 @@ import { buildRunFlowGuidance } from "./flow-guidance.js";
12
12
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
13
13
  import { checkpointRunUsage, usageForRun } from "./usage.js";
14
14
  import { refreshRunSessionProjection } from "./run-projection.js";
15
+ import { resolveCanonRoot } from "./canon.js";
16
+ import { bindCurrentEngineToRun } from "./engines.js";
15
17
  const runSchemaId = "dd-flow/flow-run@2";
16
18
  const runtimeSchemaId = "dd-flow/flow-run@2";
17
19
  const runIdType = "RUN";
@@ -30,7 +32,9 @@ const allowedRunFlowKinds = [
30
32
  ];
31
33
  export function startFlowRun(context, input) {
32
34
  const projectRoot = resolveProjectRoot(input.projectRoot);
33
- const flowContract = loadProjectFlowContract(projectRoot);
35
+ const flowContract = input.flowKind === "mb-upgrade"
36
+ ? loadUpgradeFlowContract(context, projectRoot)
37
+ : loadProjectFlowContract(projectRoot);
34
38
  registerProject(context, { root: projectRoot });
35
39
  const project = requireProjectByRoot(context, projectRoot);
36
40
  const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
@@ -107,6 +111,7 @@ export function startFlowRun(context, input) {
107
111
  };
108
112
  ensureDir(path.dirname(runtimePath));
109
113
  ensureDir(runHome);
114
+ bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
110
115
  writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
111
116
  context.db.run(`INSERT INTO flow_runs
112
117
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
@@ -151,14 +156,23 @@ export function startFlowRun(context, input) {
151
156
  });
152
157
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
153
158
  }
159
+ function loadUpgradeFlowContract(context, projectRoot) {
160
+ const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
161
+ if (!canon.ok || !canon.canon) {
162
+ throw new AppError("canon_unavailable", "mb-upgrade RUN requires the pinned canonical Memory Bank", 1, {
163
+ project_root: projectRoot,
164
+ blockers: canon.blockers,
165
+ bootstrap: canon.bootstrap
166
+ });
167
+ }
168
+ return loadCanonicalFlowContract(canon.canon.flow_root);
169
+ }
154
170
  export function getFlowRunStatus(context, input) {
155
171
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
156
172
  const run = resolveRun(context, project.id, input.runId);
157
- refreshRunSessionProjection(context, project.id, run.id);
158
- const refreshedRun = requireRunById(context, project.id, run.id);
159
- const index = authoritativeIndex(refreshedRun);
160
- const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
161
- return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
173
+ const index = authoritativeIndex(run);
174
+ const runtime = readRuntimeSnapshot(run.runtime_path);
175
+ return { ok: true, run: flowRunSummary(run), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, run, index) };
162
176
  }
163
177
  export function getFlowRunFlagsStatus(context, input) {
164
178
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Ajv } from "ajv/dist/ajv.js";
6
6
  import { normalizeFlowContract } from "../domain/flow-contract.js";
7
7
  import { AppError } from "../shared/errors.js";
8
+ import { findRunHome, readRunEngineBinding } from "./run-engine-bindings.js";
8
9
  export function captureMemoryBankBaseline(input) {
9
10
  const projectRoot = path.resolve(input.projectRoot);
10
11
  const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
@@ -116,6 +117,9 @@ export function validateSchema(options) {
116
117
  };
117
118
  }
118
119
  function resolveSchema(options) {
120
+ const bound = resolveRunBoundSchema(options);
121
+ if (bound)
122
+ return bound;
119
123
  const fileNames = [`${options.schemaName}.schema.json`];
120
124
  const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
121
125
  const roots = [
@@ -145,6 +149,98 @@ function resolveSchema(options) {
145
149
  source: found.source
146
150
  };
147
151
  }
152
+ const historicalSchemaRegistry = {
153
+ "294fd5f24ec6a4f938f15dfd98a4b303c2e026753062c3c6c8b15e09029a1128": [{
154
+ name: "merge-stage-report",
155
+ id: "dd-flow/merge-stage-report@2/engine-0.4.2",
156
+ path: "dist/schemas/merge-stage-report-legacy-0.4.2.schema.json",
157
+ checksum: "d861630cafd51e71e5440dadac2628413501e8b063ff19bef7d16966dc7077e3"
158
+ }],
159
+ "0fc717e17c359242662b76d8c2518bf4c4237366221e69bb979d2959d5ae8b60": [{
160
+ name: "merge-stage-report",
161
+ id: "dd-flow/stage-report@1",
162
+ path: "dist/schemas/stage-report.schema.json",
163
+ checksum: "d9889af7f2c0afb47e2abab330aea3e1750879d0738d87d38d2d20e3fe0403f3"
164
+ }]
165
+ };
166
+ function resolveRunBoundSchema(options) {
167
+ if (!options.projectRoot || options.schemaDir)
168
+ return null;
169
+ const data = readJson(path.resolve(options.file), "input file");
170
+ const root = asRecord(data);
171
+ const run = root ? asRecord(objectValue(root, "run")) : undefined;
172
+ const runId = run ? objectValue(run, "run_id") : objectValue(root ?? {}, "run_id");
173
+ if (typeof runId !== "string")
174
+ return null;
175
+ const projectRoot = path.resolve(options.projectRoot);
176
+ const located = findRunHome(options.ddFlowHome ?? process.env.DD_FLOW_HOME ?? path.join(path.dirname(projectRoot), ".dd-flow"), projectRoot, runId);
177
+ if (!located || !isInside(located.run_home, path.resolve(options.file)))
178
+ return null;
179
+ const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
180
+ if (!binding)
181
+ return null;
182
+ const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
183
+ const manifest = readJson(manifestPath, "bound engine manifest");
184
+ const integrity = asRecord(objectValue(manifest, "integrity"));
185
+ if (manifest.package_version !== binding.engine.package_version || manifest.engine_version !== binding.engine.engine_version || integrity?.checksum !== binding.engine.integrity_checksum) {
186
+ throw new AppError("run_engine_binding_invalid", "RUN engine binding does not match its engine manifest", 1, { binding: binding.engine, manifest: manifestPath });
187
+ }
188
+ const registry = asRecord(objectValue(manifest, "schema_registry"));
189
+ const entries = Array.isArray(registry?.entries) ? registry.entries.filter((entry) => Boolean(asRecord(entry))) : [];
190
+ const registered = entries.map(asSchemaRegistryEntry).find((entry) => entry?.name === options.schemaName);
191
+ const historical = !registered ? historicalSchemaRegistry[binding.engine.integrity_checksum]?.find((entry) => entry.name === options.schemaName) : undefined;
192
+ const profile = registered ?? historical;
193
+ if (registry && !registered) {
194
+ throw new AppError("schema_not_found", `Schema not registered for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine });
195
+ }
196
+ if (!profile) {
197
+ throw new AppError("schema_not_found", `Schema not found for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine });
198
+ }
199
+ const relativePath = profile.path;
200
+ const schemaPath = historical
201
+ ? path.join(bundledSchemaDir(), path.basename(relativePath))
202
+ : resolveInside(binding.engine.snapshot_root, relativePath);
203
+ if (!schemaPath || !fs.existsSync(schemaPath)) {
204
+ throw new AppError("schema_not_found", `Schema not found for RUN-bound engine: ${options.schemaName}`, 2, { run_id: runId, engine: binding.engine, path: relativePath });
205
+ }
206
+ if (checksum(schemaPath) !== profile.checksum) {
207
+ throw new AppError("schema_registry_invalid", "RUN-bound engine schema does not match its registry checksum", 1, { run_id: runId, schema: options.schemaName, path: schemaPath });
208
+ }
209
+ const schema = readJson(schemaPath, "schema");
210
+ const id = objectValue(asRecord(schema) ?? {}, "$id");
211
+ if (id !== profile.id) {
212
+ throw new AppError("schema_registry_invalid", "RUN-bound engine schema does not match its registry id", 1, { run_id: runId, schema: options.schemaName, path: schemaPath });
213
+ }
214
+ return {
215
+ name: options.schemaName,
216
+ id: typeof id === "string" ? id : options.schemaName,
217
+ path: schemaPath,
218
+ source: historical ? "engine_legacy_registry" : "run_bound_engine",
219
+ engine: { package_version: binding.engine.package_version, engine_version: binding.engine.engine_version, integrity_checksum: binding.engine.integrity_checksum, snapshot_root: binding.engine.snapshot_root }
220
+ };
221
+ }
222
+ function asSchemaRegistryEntry(value) {
223
+ const name = objectValue(value, "name");
224
+ const id = objectValue(value, "id");
225
+ const schemaPath = objectValue(value, "path");
226
+ const entryChecksum = objectValue(value, "checksum");
227
+ return typeof name === "string" && typeof id === "string" && typeof schemaPath === "string" && typeof entryChecksum === "string"
228
+ ? { name, id, path: schemaPath, checksum: entryChecksum }
229
+ : null;
230
+ }
231
+ function resolveInside(root, relativePath) {
232
+ if (path.isAbsolute(relativePath))
233
+ return null;
234
+ const candidate = path.resolve(root, relativePath);
235
+ return isInside(root, candidate) ? candidate : null;
236
+ }
237
+ function isInside(root, candidate) {
238
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
239
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
240
+ }
241
+ function checksum(file) {
242
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
243
+ }
148
244
  function bundledSchemaDir() {
149
245
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas");
150
246
  }
@@ -92,6 +92,11 @@ function runtimeFacts(view, projectRoot) {
92
92
  workspace_root: view.run.workspace_root,
93
93
  run_id: view.run.id,
94
94
  protocol_id: view.run.subject.id,
95
+ runtime: {
96
+ source: "dd-flow",
97
+ state: "trusted",
98
+ flow_kind: view.run.flow_kind
99
+ },
95
100
  git: view.index.execution?.git ?? { branch: null, head: null, status: "unavailable" }
96
101
  };
97
102
  }
@@ -117,7 +122,9 @@ export function startStage(context, input) {
117
122
  const preflight = stagePreflight(projectRoot, stageRoot);
118
123
  if (preflight.ok !== true)
119
124
  throw new AppError("permission_preflight_failed", "Stage workspace is not writable", 1, { preflight });
120
- syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
125
+ if (attached.run.subject.type === "protocol") {
126
+ syncProtocolLifecycle(context, attached.run.project_id, attached.run.subject.id, input.stage, "running");
127
+ }
121
128
  if (input.sessionId) {
122
129
  registerFlowSession(context, {
123
130
  sessionId: input.sessionId,
@@ -143,6 +150,7 @@ export function startStage(context, input) {
143
150
  const aspectMapPath = path.join(stageRoot, "aspect-map.json");
144
151
  const attempt = attached.index.stage_runs?.find((stage) => stage.stage === input.stage)?.attempt ?? "try-001";
145
152
  const attemptNumber = Number(attempt.replace("try-", "")) || 1;
153
+ const sources = stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage);
146
154
  atomicWrite(promptDataPath, {
147
155
  schema_id: "dd-flow/stage-prompt@2",
148
156
  run_id: attached.run.id,
@@ -160,10 +168,25 @@ export function startStage(context, input) {
160
168
  ...(input.stage === "plan" ? { plan: planPath, "aspect-map": aspectMapPath } : {})
161
169
  },
162
170
  write_boundary: { current: "@stage", archive: attempt, archive_writable: false },
163
- source_fragments: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
171
+ source_fragments: sources.map((source) => source.label),
164
172
  authoritative_facts: runtimeFacts(attached, projectRoot),
165
- preflight,
166
- required_context: stageInstructionSources(context, projectRoot, attached.run.flow_kind, input.stage).map((source) => source.label),
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
+ })),
167
190
  worker_prompt_markdown: prompt
168
191
  });
169
192
  validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot });
@@ -196,7 +219,7 @@ export function startStage(context, input) {
196
219
  },
197
220
  ...(input.stage === "plan" ? { plan_ref: planPath, aspect_map_ref: aspectMapPath } : {}),
198
221
  resolved_context: { run: attached.run, stage: { name: input.stage, dir, status: "running" } },
199
- next_command: `dd-flow stage finish ${attached.run.id} --stage ${input.stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json`,
222
+ next_command: stageFinishCommand(attached.run, projectRoot, input.stage),
200
223
  permission_probe: preflight,
201
224
  run: attached.run,
202
225
  prompt: { path: promptPath, data_path: promptDataPath },
@@ -239,10 +262,12 @@ export function finishStage(context, input) {
239
262
  const reportPath = path.join(stageRoot, "stage-report.md");
240
263
  const htmlPath = path.join(stageRoot, "stage-report.html");
241
264
  atomicWrite(dataPath, report);
242
- validateSchema({ schemaName: planFinish ? "plan-stage-report" : "stage-report", file: dataPath, projectRoot });
265
+ validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot });
243
266
  atomicWrite(reportPath, renderMarkdown(report));
244
267
  atomicWrite(htmlPath, renderHtml(projectRoot, report));
245
- 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
+ }
246
271
  const summaryPath = updateProtocolSummary(context, projectRoot, view, report, dataPath, htmlPath, reportPath);
247
272
  const completed = completeFlowRunStage(context, {
248
273
  projectRoot,
@@ -360,6 +385,19 @@ function dataSchemaForStage(stage) {
360
385
  return "dd-flow/merge-stage-report@2";
361
386
  return "dd-flow/code-stage-report@2";
362
387
  }
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
+ }
363
401
  function composeStagePrompt(context, projectRoot, before, current, stage, dir, preflight) {
364
402
  const project = requireProjectByRoot(context, projectRoot);
365
403
  const runHome = runHomePath(current.run);
@@ -414,14 +452,14 @@ function composeStagePrompt(context, projectRoot, before, current, stage, dir, p
414
452
  "</work_contract>",
415
453
  "",
416
454
  "<completion_contract>",
417
- `Write semantic output to \`@stage/stage-input.json\`, then run: \`dd-flow stage finish ${current.run.id} --stage ${stage} --outcome done --project-root ${JSON.stringify(projectRoot)} --json\`.`,
455
+ `Write semantic output to \`@stage/stage-input.json\`, then run: \`${stageFinishCommand(current.run, projectRoot, stage)}\`.`,
418
456
  "The CLI must generate validated JSON, Markdown, HTML and protocol-summary evidence.",
419
457
  "</completion_contract>",
420
458
  ].join("\n");
421
459
  }
422
460
  function stageInstructionSources(context, projectRoot, flowKind, stage) {
423
461
  if (flowKind === "mb-upgrade") {
424
- const canon = resolveCanonRoot(context);
462
+ const canon = resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
425
463
  if (!canon.ok || !canon.canon) {
426
464
  throw new AppError("canon_unavailable", "mb-upgrade stage prompt requires the canonical Memory Bank", 1, {
427
465
  blockers: canon.blockers,
@@ -541,6 +579,34 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
541
579
  }
542
580
  };
543
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
+ }
544
610
  return {
545
611
  schema_id: "dd-flow/stage-report@1",
546
612
  run_id: view.run.id,
@@ -611,6 +677,28 @@ function stringValue(value) {
611
677
  function stringArray(value, fallback) {
612
678
  return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
613
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
+ }
614
702
  function gitChangedFiles(workspaceRoot) {
615
703
  const result = spawnSync("git", ["-C", workspaceRoot, "status", "--short"], { encoding: "utf8" });
616
704
  if (result.status !== 0)
@@ -634,7 +722,7 @@ function workerCoverage(context, projectId, runId) {
634
722
  }
635
723
  function runTargetedMemoryBankLint(projectRoot, semantic) {
636
724
  const declared = stringArray(semantic.changed_files, []);
637
- 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"))))];
638
726
  for (const file of files)
639
727
  ensureWithin(projectRoot, path.resolve(projectRoot, file), "lint target");
640
728
  if (files.length === 0) {
@@ -9,7 +9,7 @@ import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-sta
9
9
  import { findProjectByRoot } from "./projects.js";
10
10
  import { classifyCliOperation } from "./cli-operation-classifier.js";
11
11
  import { compatibilityReport } from "./compatibility-preflight.js";
12
- import { selectEngine } from "./engines.js";
12
+ import { compatibilityContexts, selectEngine } from "./engines.js";
13
13
  export function getRuntimeStatus(context, input = {}) {
14
14
  const cwd = process.cwd();
15
15
  const projectRootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd });
@@ -26,9 +26,13 @@ export function getRuntimeStatus(context, input = {}) {
26
26
  const cli = getCliBuildInfo();
27
27
  const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
28
28
  const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
29
- const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
29
+ const classification = classifyCliOperation(["status"], context.env);
30
+ const engineSelection = projectRoot
31
+ ? selectEngine(context, { projectRoot, env: context.env }, { allowCurrentInProcess: true }, classification)
32
+ : null;
30
33
  const flowContract = projectRoot ? projectFlowContractStatus(projectRoot) : null;
31
34
  const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
35
+ const contexts = projectRoot ? compatibilityContexts(context, { projectRoot, env: context.env, classification }) : null;
32
36
  return {
33
37
  ok: true,
34
38
  schema_id: "dd-flow/status-report@1",
@@ -54,7 +58,8 @@ export function getRuntimeStatus(context, input = {}) {
54
58
  engine: engineSelection
55
59
  ? {
56
60
  selection: engineSelection,
57
- compatibility: compatibilityReport(engineSelection, classifyCliOperation(["status"]))
61
+ compatibility: compatibilityReport(engineSelection, classification),
62
+ contexts
58
63
  }
59
64
  : null,
60
65
  canon: {
@@ -5,27 +5,48 @@ import { ensureDir } from "./paths.js";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  const require = createRequire(import.meta.url);
7
7
  const { DatabaseSync } = require("node:sqlite");
8
- export function getDatabase(ddFlowHome) {
9
- ensureDir(ddFlowHome);
8
+ export function getDatabase(ddFlowHome, mode = "initialize") {
10
9
  const dbPath = path.join(ddFlowHome, "db.sqlite");
11
- const db = new DatabaseSync(dbPath);
12
- configureDatabase(db);
13
- migrate(db);
10
+ const exists = fs.existsSync(dbPath);
11
+ if (mode === "read_existing" && !exists) {
12
+ return emptyReadOnlyDatabase(dbPath);
13
+ }
14
+ if (!exists || mode === "migrate")
15
+ ensureDir(ddFlowHome);
16
+ const db = mode === "read_existing" ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
17
+ configureDatabase(db, mode);
18
+ // Existing storage is never schema-migrated by an ordinary initialize open.
19
+ // Only the explicit migrate handle is allowed to execute DDL on it.
20
+ if (!exists || mode === "migrate")
21
+ migrate(db);
22
+ if (mode === "read_existing")
23
+ db.exec("PRAGMA query_only = ON");
14
24
  db.exec("PRAGMA foreign_keys = ON");
15
25
  return {
16
26
  path: dbPath,
27
+ writable: mode !== "read_existing",
17
28
  exec: (sql) => db.exec(sql),
18
29
  run: (sql, params = []) => db.prepare(sql).run(...params),
19
30
  get: (sql, params = []) => db.prepare(sql).get(...params),
20
31
  all: (sql, params = []) => db.prepare(sql).all(...params)
21
32
  };
22
33
  }
23
- function configureDatabase(db) {
24
- db.exec(`
25
- PRAGMA busy_timeout = 4000;
26
- PRAGMA journal_mode = WAL;
27
- PRAGMA synchronous = NORMAL;
28
- `);
34
+ function emptyReadOnlyDatabase(dbPath) {
35
+ return {
36
+ path: dbPath,
37
+ writable: false,
38
+ exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
39
+ run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
40
+ get: () => undefined,
41
+ all: () => []
42
+ };
43
+ }
44
+ function configureDatabase(db, mode) {
45
+ db.exec("PRAGMA busy_timeout = 4000");
46
+ if (mode !== "read_existing") {
47
+ db.exec("PRAGMA journal_mode = WAL");
48
+ db.exec("PRAGMA synchronous = NORMAL");
49
+ }
29
50
  }
30
51
  function migrate(db) {
31
52
  db.exec(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {