@deksden-com/dd-flow-cli 0.7.0 → 0.8.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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -68,7 +68,7 @@ export function cleanupScan(context, input) {
68
68
  action: { kind: "cancel_queue_job", project_id: project.id, protocol_id: job.protocol_id }
69
69
  });
70
70
  }
71
- const staleSessions = context.db.all(`SELECT session_id, protocol_id, workspace_path FROM flow_sessions
71
+ const staleSessions = context.db.all(`SELECT session_id, protocol_id, workspace_path FROM sessions
72
72
  WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
73
73
  ORDER BY updated_at ASC, session_id ASC`, [project.id]);
74
74
  for (const session of staleSessions) {
@@ -87,7 +87,7 @@ export function cleanupScan(context, input) {
87
87
  });
88
88
  }
89
89
  }
90
- const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM flow_runs
90
+ const staleRuns = context.db.all(`SELECT id, runtime_path, run_dir, workspace_root FROM runs
91
91
  WHERE project_id = ? AND status = 'running'
92
92
  ORDER BY updated_at ASC, id ASC`, [project.id]);
93
93
  for (const run of staleRuns) {
@@ -306,7 +306,7 @@ function applyAction(context, action, reason, force, now) {
306
306
  return { kind: action.kind, changed: true, protocol_id: action.protocol_id };
307
307
  }
308
308
  if (action.kind === "close_flow_session" && action.session_id) {
309
- const result = context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
309
+ const result = context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
310
310
  WHERE project_id = ? AND session_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.session_id]);
311
311
  if (result.changes !== 1) {
312
312
  return { kind: action.kind, changed: false, skipped: true, reason: "already_stopped", session_id: action.session_id };
@@ -321,13 +321,13 @@ function applyAction(context, action, reason, force, now) {
321
321
  return { kind: action.kind, changed: true, session_id: action.session_id };
322
322
  }
323
323
  if (action.kind === "discard_stale_run" && action.run_id) {
324
- const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
324
+ const run = context.db.get("SELECT status, runtime_path, run_dir, workspace_root, index_json FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
325
325
  if (!run) {
326
326
  return { kind: action.kind, changed: false, skipped: true, reason: "run_not_found", run_id: action.run_id };
327
327
  }
328
328
  if (run.status === "discarded") {
329
329
  const index = discardedRunIndex(run.index_json, reason, now);
330
- context.db.run("UPDATE flow_runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
330
+ context.db.run("UPDATE runs SET verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?) WHERE project_id = ? AND id = ?", [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
331
331
  if (fs.existsSync(run.runtime_path))
332
332
  fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
333
333
  return { kind: action.kind, changed: true, run_id: action.run_id };
@@ -339,11 +339,11 @@ function applyAction(context, action, reason, force, now) {
339
339
  return { kind: action.kind, changed: false, skipped: true, reason: "runtime_or_workspace_reappeared", run_id: action.run_id };
340
340
  }
341
341
  const index = discardedRunIndex(run.index_json, reason, now);
342
- context.db.run(`UPDATE flow_runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
342
+ context.db.run(`UPDATE runs SET status = 'discarded', verdict = 'discarded', next_action = ?, index_json = ?, updated_at = ?, completed_at = ?
343
343
  WHERE project_id = ? AND id = ? AND status = 'running'`, [reason, JSON.stringify(index), now, now, action.project_id, action.run_id]);
344
344
  fs.mkdirSync(run.run_dir, { recursive: true });
345
345
  fs.writeFileSync(run.runtime_path, `${JSON.stringify(index, null, 2)}\n`);
346
- context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
346
+ context.db.run(`UPDATE sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
347
347
  WHERE project_id = ? AND run_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, action.project_id, action.run_id]);
348
348
  context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE project_id = ? AND run_id = ? AND ended_at IS NULL", [now, action.project_id, action.run_id]);
349
349
  appendAudit(context, {
@@ -398,7 +398,7 @@ function applyAction(context, action, reason, force, now) {
398
398
  }
399
399
  function discardedRunIndex(indexJson, reason, now) {
400
400
  const index = JSON.parse(indexJson);
401
- index.schema_id = "dd-flow/flow-run@2";
401
+ index.schema_id = "dd-flow/flow-run@3";
402
402
  index.status = "discarded";
403
403
  index.verdict = "discarded";
404
404
  index.next_action = reason;
@@ -70,7 +70,7 @@ function isUpgradeAllowlisted(args) {
70
70
  return true;
71
71
  if (family === "run" && command === "start")
72
72
  return optionValue(args, "flow-kind") === "mb-upgrade";
73
- if (family === "stage" && ["start", "finish"].includes(command ?? ""))
73
+ if (family === "stage" && ["start", "pause", "resume", "finish"].includes(command ?? ""))
74
74
  return hasRunBinding(args);
75
75
  if (family === "session" && ["status", "register", "stop", "usage"].includes(command ?? ""))
76
76
  return hasRunBinding(args);
@@ -101,7 +101,15 @@ function isRouterNative(args) {
101
101
  return true;
102
102
  if (["engine", "version", "schema"].includes(first))
103
103
  return true;
104
- return first === "codex" && args[1] === "hook" && args[2] === "handle";
104
+ return (first === "codex" && args[1] === "hook" && args[2] === "handle")
105
+ || (first === "zcode" && args[1] === "event" && args[2] === "handle")
106
+ || (first === "zcode" && args[1] === "usage" && args[2] === "ingest")
107
+ || (first === "grok" && args[1] === "event" && args[2] === "handle")
108
+ || (first === "grok" && args[1] === "usage" && args[2] === "ingest")
109
+ || (first === "opencode" && args[1] === "event" && args[2] === "handle")
110
+ || (first === "opencode" && args[1] === "usage" && args[2] === "ingest")
111
+ || (first === "agy" && args[1] === "event" && args[2] === "handle")
112
+ || (first === "agy" && args[1] === "usage" && args[2] === "ingest");
105
113
  }
106
114
  function isReadOnlyDiagnostic(family, command, action) {
107
115
  if (!family)
@@ -0,0 +1,244 @@
1
+ import crypto from "node:crypto";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { AppError } from "../shared/errors.js";
6
+ /**
7
+ * A local CODE gate must not share a mutable database with another checkout.
8
+ * The project command consumes this opaque suffix only for its local/test
9
+ * database target; preview and production-like targets remain unchanged.
10
+ */
11
+ export function codeExecutionEnvironment(workspaceRoot) {
12
+ const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
13
+ return { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
14
+ }
15
+ export function validateCodeCheckCommands(workspaceRoot, commands) {
16
+ const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
17
+ // Keep one resolved command for every declared check. Two declarations may
18
+ // deliberately share a command while carrying different acceptance roles.
19
+ if (!fs.existsSync(file))
20
+ return [...commands];
21
+ const profile = JSON.parse(fs.readFileSync(file, "utf8"));
22
+ if (profile.schema_id !== "dd-flow/code-check-profile@4")
23
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
24
+ const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
25
+ ? profile.aliases
26
+ : {};
27
+ const required = profile.require_alias_for && typeof profile.require_alias_for === "object" && !Array.isArray(profile.require_alias_for)
28
+ ? profile.require_alias_for
29
+ : {};
30
+ return commands.map((command) => {
31
+ if (command.startsWith("@check/")) {
32
+ const template = aliases[command];
33
+ if (typeof template !== "string" || !template.trim())
34
+ throw new AppError("unknown_code_check_alias", "CODE check alias is not defined by the project profile", 2, { alias: command, file });
35
+ return command;
36
+ }
37
+ for (const [prefix, alias] of Object.entries(required)) {
38
+ if (command === prefix || command.startsWith(`${prefix} `)) {
39
+ throw new AppError("raw_code_check_forbidden", "CODE check must use the project alias instead of a raw guarded command", 2, { command, required_alias: String(alias), file });
40
+ }
41
+ }
42
+ return command;
43
+ });
44
+ }
45
+ export function resolveCodeCheckCommands(workspaceRoot, runId, commands) {
46
+ const validated = validateCodeCheckCommands(workspaceRoot, commands);
47
+ const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
48
+ if (!fs.existsSync(file))
49
+ return validated;
50
+ const profile = JSON.parse(fs.readFileSync(file, "utf8"));
51
+ const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases)
52
+ ? profile.aliases
53
+ : {};
54
+ return validated.map((command) => command.startsWith("@check/")
55
+ ? String(aliases[command]).replaceAll("{run_id}", runId)
56
+ : command);
57
+ }
58
+ export async function runCodeChecks(context, input) {
59
+ const receipts = [];
60
+ for (const check of input.checks)
61
+ validateCheckDeclaration(input.workspaceRoot, check);
62
+ const commands = resolveCodeCheckCommands(input.workspaceRoot, input.runId, input.checks.map((check) => check.command));
63
+ for (let index = 0; index < commands.length; index += 1) {
64
+ const declaration = input.checks[index];
65
+ const command = commands[index];
66
+ const ordinal = (context.db.get("SELECT COUNT(*) AS count FROM check_receipts WHERE work_id IS ? AND run_id = ?", [input.workId ?? null, input.runId])?.count ?? 0) + 1;
67
+ const localId = `RCP-${String(ordinal).padStart(3, "0")}`;
68
+ const id = `${input.workId ?? input.runId}/${localId}`;
69
+ const directory = path.join(input.runHome, input.artifactDir ?? "05-code", "checks", localId);
70
+ fs.mkdirSync(directory, { recursive: true });
71
+ const stdoutPath = path.join(directory, "stdout.log");
72
+ const stderrPath = path.join(directory, "stderr.log");
73
+ const receiptPath = path.join(directory, "receipt.json");
74
+ const evidenceDir = path.join(directory, "artifacts");
75
+ fs.mkdirSync(evidenceDir, { recursive: true });
76
+ const startedAt = context.now();
77
+ input.progress?.(`check ${index + 1}/${commands.length} started: ${command}`);
78
+ const stdout = fs.createWriteStream(stdoutPath);
79
+ const stderr = fs.createWriteStream(stderrPath);
80
+ const result = await runCheck(command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir }, stdout, stderr, (elapsed) => {
81
+ input.progress?.(`check ${index + 1}/${commands.length} still running (${elapsed}s): ${command}`);
82
+ });
83
+ await Promise.all([closeStream(stdout), closeStream(stderr)]);
84
+ const finishedAt = context.now();
85
+ const exitCode = result.exitCode;
86
+ const artifacts = collectRequiredArtifacts(evidenceDir, declaration.required_artifacts ?? []);
87
+ const status = exitCode === 0 && !result.error && artifacts.complete ? "passed" : "failed";
88
+ if (!artifacts.complete)
89
+ fs.appendFileSync(stderrPath, `\nmissing required evidence artifacts: ${artifacts.missing.join(", ")}\n`);
90
+ if (result.error)
91
+ fs.appendFileSync(stderrPath, `\n${result.error}\n`);
92
+ const receipt = {
93
+ id,
94
+ local_id: localId,
95
+ declaration_id: declaration.id,
96
+ scope: input.scope,
97
+ work_id: input.workId ?? null,
98
+ command,
99
+ status,
100
+ exit_code: exitCode,
101
+ stdout_path: stdoutPath,
102
+ stderr_path: stderrPath,
103
+ receipt_path: receiptPath,
104
+ workspace_fingerprint: workspaceFingerprint(input.workspaceRoot),
105
+ artifacts: artifacts.items,
106
+ started_at: startedAt,
107
+ finished_at: finishedAt
108
+ };
109
+ fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
110
+ context.db.run("INSERT INTO check_receipts (id, project_id, run_id, work_id, scope, declaration_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, input.projectId, input.runId, input.workId ?? null, input.scope, declaration.id, command, status, exitCode, stdoutPath, stderrPath, receiptPath, receipt.workspace_fingerprint, JSON.stringify(receipt.artifacts), startedAt, finishedAt]);
111
+ receipts.push(receipt);
112
+ input.progress?.(`check ${index + 1}/${commands.length} ${status}: ${command}`);
113
+ }
114
+ return receipts;
115
+ }
116
+ export function aggregateCheckDeclarations(workspaceRoot) {
117
+ const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
118
+ if (!fs.existsSync(file))
119
+ return [];
120
+ const profile = JSON.parse(fs.readFileSync(file, "utf8"));
121
+ if (profile.schema_id !== "dd-flow/code-check-profile@4")
122
+ throw new AppError("invalid_code_check_profile", "CODE check profile has an unsupported schema", 2, { file, schema_id: profile.schema_id ?? null });
123
+ if (!Array.isArray(profile.aggregate_commands) || !profile.aggregate_commands.every((command) => typeof command === "string" && command.trim()))
124
+ throw new AppError("invalid_code_check_profile", "aggregate_commands must be an array of commands", 2, { file });
125
+ return profile.aggregate_commands.map((command, index) => ({ id: `CHK-POLICY-CODE-${String(index + 1).padStart(3, "0")}`, command, purpose: "Mandatory project-wide policy gate.", run_at: "code", availability: "available" }));
126
+ }
127
+ /**
128
+ * A project-wide gate can observe files owned by more than one CODE Work.
129
+ * Keep it at the CODE/readiness fan-in, where the engine can create a repair
130
+ * Work with the failed receipt and the right project-local repair context.
131
+ */
132
+ export function validateCheckPlacement(workspaceRoot, checks) {
133
+ const aggregate = new Set(aggregateCheckDeclarations(workspaceRoot).map((check) => check.command));
134
+ for (const check of checks) {
135
+ if (check.run_at === "work" && check.availability === "available" && aggregate.has(check.command)) {
136
+ throw new AppError("aggregate_check_requires_code_gate", "A project aggregate check must run at CODE or readiness, not inside one scoped Work", 2, {
137
+ check_id: check.id,
138
+ command: check.command,
139
+ run_at: check.run_at
140
+ });
141
+ }
142
+ }
143
+ }
144
+ export function checksForRunAt(checks, runAt) {
145
+ return checks.filter((check) => check.run_at === runAt);
146
+ }
147
+ /** One SSOT for the final CODE gate, reused after CODE-REVIEW repairs. */
148
+ export function finalCodeCheckDeclarations(workspaceRoot, declared) {
149
+ const selected = [...aggregateCheckDeclarations(workspaceRoot), ...declared.filter((check) => check.run_at === "code" || check.run_at === "readiness")];
150
+ const unique = new Map();
151
+ for (const check of selected)
152
+ if (!unique.has(check.id))
153
+ unique.set(check.id, check);
154
+ return [...unique.values()];
155
+ }
156
+ /** Prevent an unchanged failed stage gate from rerunning expensive commands. */
157
+ export function unchangedFinalGateFailures(context, input) {
158
+ const wanted = new Set(input.declarations.map((check) => check.id));
159
+ const current = workspaceFingerprint(input.workspaceRoot);
160
+ const latest = new Map();
161
+ for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && wanted.has(item.declaration_id)))
162
+ latest.set(receipt.declaration_id, receipt);
163
+ return [...latest.values()].filter((receipt) => receipt.status === "failed" && receipt.workspace_fingerprint === current);
164
+ }
165
+ export function validateCheckDeclaration(workspaceRoot, check) {
166
+ if (check.availability !== "planned") {
167
+ validateCodeCheckCommands(workspaceRoot, [check.command]);
168
+ return;
169
+ }
170
+ if (!check.command.startsWith("@check/")) {
171
+ throw new AppError("planned_check_requires_alias", "A planned CODE check must declare a new @check/... alias", 2, { check_id: check.id, command: check.command });
172
+ }
173
+ if (!check.definition?.trim()) {
174
+ throw new AppError("planned_check_definition_missing", "A planned CODE check must declare its exact alias definition", 2, { check_id: check.id, command: check.command });
175
+ }
176
+ const file = path.join(workspaceRoot, ".memory-bank", "spec", "engineering", "code-check-profile.json");
177
+ const profile = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
178
+ const aliases = profile.aliases && typeof profile.aliases === "object" && !Array.isArray(profile.aliases) ? profile.aliases : {};
179
+ const actual = aliases[check.command];
180
+ if (typeof actual !== "string")
181
+ throw new AppError("planned_check_not_materialized", "A planned check alias was not materialized by its provider Work", 2, { check_id: check.id, command: check.command, provider: check.provided_by ?? null });
182
+ if (check.definition && actual !== check.definition)
183
+ throw new AppError("planned_check_definition_mismatch", "A planned check alias does not match its declared definition", 2, { check_id: check.id, command: check.command, expected: check.definition, actual });
184
+ }
185
+ function collectRequiredArtifacts(root, required) {
186
+ const missing = [];
187
+ const items = required.map((relative) => {
188
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes(".."))
189
+ throw new AppError("invalid_evidence_path", "Required evidence paths must be relative to DD_FLOW_EVIDENCE_DIR", 2, { path: relative });
190
+ const file = path.join(root, relative);
191
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
192
+ missing.push(relative);
193
+ return null;
194
+ }
195
+ return { path: relative, sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") };
196
+ }).filter((item) => item !== null);
197
+ return { complete: missing.length === 0, missing, items };
198
+ }
199
+ export function workspaceFingerprint(root) {
200
+ const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
201
+ const listing = git.status === 0 ? git.stdout.split("\0").filter(Boolean).sort() : listFiles(root);
202
+ const hash = crypto.createHash("sha256");
203
+ for (const relative of listing) {
204
+ const file = path.join(root, relative);
205
+ hash.update(relative).update("\0");
206
+ hash.update(fs.existsSync(file) && fs.statSync(file).isFile() ? fs.readFileSync(file) : "<missing>");
207
+ hash.update("\0");
208
+ }
209
+ return hash.digest("hex");
210
+ }
211
+ function listFiles(root, current = root) {
212
+ return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
213
+ if ([".git", "node_modules"].includes(entry.name))
214
+ return [];
215
+ const absolute = path.join(current, entry.name);
216
+ return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)];
217
+ }).sort();
218
+ }
219
+ function runCheck(command, cwd, environment, stdout, stderr, heartbeat) {
220
+ return new Promise((resolve) => {
221
+ const started = Date.now();
222
+ const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
223
+ child.stdout.pipe(stdout, { end: false });
224
+ child.stderr.pipe(stderr, { end: false });
225
+ const progress = setInterval(() => heartbeat(Math.floor((Date.now() - started) / 1000)), 15_000);
226
+ let timedOut = false;
227
+ const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, 15 * 60 * 1000);
228
+ let error = null;
229
+ child.once("error", (value) => { error = value.message; });
230
+ child.once("close", (exitCode, signal) => {
231
+ clearInterval(progress);
232
+ clearTimeout(timeout);
233
+ resolve({ exitCode, error: error ?? (timedOut ? "timed out after 900 seconds" : signal ? `terminated by ${signal}` : null) });
234
+ });
235
+ });
236
+ }
237
+ function closeStream(stream) {
238
+ return new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
239
+ }
240
+ export function checkReceipts(context, input) {
241
+ const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
242
+ const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
243
+ return context.db.all(`SELECT id, declaration_id, scope, work_id, command, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((receipt) => ({ ...receipt, local_id: receipt.id.slice(receipt.id.lastIndexOf("/") + 1), artifacts: JSON.parse(receipt.artifacts_json) }));
244
+ }
@@ -38,7 +38,10 @@ export function setProjectConfigValue(context, project, input) {
38
38
  return getProjectConfigStatus(context, project);
39
39
  }
40
40
  export function readProjectConfig(context, projectId) {
41
- migrateLegacyConfig(context, projectId);
41
+ // Status and dashboard reads intentionally open SQLite read-only. Legacy
42
+ // cleanup is a write-side concern and must not make those commands fail.
43
+ if (context.db.writable)
44
+ migrateLegacyConfig(context, projectId);
42
45
  const config = structuredClone(defaultProjectConfig);
43
46
  for (const row of configOverrides(context, projectId)) {
44
47
  applyConfigValue(config, row.key, JSON.parse(row.value_json));
@@ -139,6 +142,9 @@ function applyConfigValue(config, key, value) {
139
142
  }
140
143
  }
141
144
  function migrateLegacyConfig(context, projectId) {
145
+ // Handoff is now frozen from project-workspace.json for vNext. Delete the
146
+ // old runtime override rather than leaving a misleading inert setting.
147
+ context.db.run("DELETE FROM project_config WHERE project_id = ? AND key = 'execution.stage_handoff'", [projectId]);
142
148
  const legacyRows = context.db.all(`SELECT key, value_json, updated_at FROM project_config
143
149
  WHERE project_id = ? AND key IN (
144
150
  'integrations.cmux.dashboard',
@@ -380,7 +380,7 @@ function buildGlobalDashboardData(context, htmlPath) {
380
380
  metric("total_projects", activeProjects.length, "known", "projects.status=active"),
381
381
  metric("unsupported_projects", unsupportedProjects.filter((project) => project.status === "active").length, "known", "project summaries"),
382
382
  metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
383
- metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
383
+ metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "sessions"),
384
384
  metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
385
385
  metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
386
386
  ],
@@ -578,8 +578,8 @@ function buildProjectDashboardData(context, project, htmlPath) {
578
578
  metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
579
579
  metric("queued_waiters", waiters.filter((waiter) => waiter.status === "queued").length, "known", "lane_waiters"),
580
580
  metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
581
- metric("running_sessions", sessions.length, "known", "flow_sessions"),
582
- metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
581
+ metric("running_sessions", sessions.length, "known", "sessions"),
582
+ metric("review_runs", reviewRuns.length, "known", "runs.flow_kind=mb-sdlc-review|review")
583
583
  ],
584
584
  lifecycle_summary: lifecycleSummary(activeProtocols),
585
585
  resource_summary: resourceSummary(queue, locks, waiters),
@@ -625,7 +625,7 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
625
625
  const queueItem = queueForProject(context, project.id).find((item) => item.protocol_id === protocolId) ?? null;
626
626
  const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
627
627
  const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
628
- FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
628
+ FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
629
629
  ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
630
630
  const warnings = [...runDiagnostics.diagnostics];
631
631
  const canonicalRuns = runs.filter((run) => {
@@ -714,11 +714,11 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
714
714
  run_history: runHistory,
715
715
  stage_pipeline: latestStages(runHistory),
716
716
  metrics: [
717
- metric("runs", canonicalRuns.length, "known", "flow_runs"),
717
+ metric("runs", canonicalRuns.length, "known", "runs"),
718
718
  metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
719
719
  metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
720
- metric("completed_runs", canonicalRuns.filter((run) => run.status === "done").length, "known", "flow_runs"),
721
- metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
720
+ metric("completed_runs", canonicalRuns.filter((run) => run.status === "done").length, "known", "runs"),
721
+ metric("review_runs", reviewRuns.length, "known", "runs.flow_kind=mb-sdlc-review|review")
722
722
  ],
723
723
  warnings
724
724
  };
@@ -734,7 +734,7 @@ function renderProjectDashboardMarkdown(context, project, output) {
734
734
  const worktrees = context.db.all(`SELECT protocol_id, feature_branch, worktree_path, bootstrap_status, status, updated_at
735
735
  FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC LIMIT 10`, [project.id]);
736
736
  const hookEvents = context.db.all(`SELECT protocol_id, session_id, event_name, tool_name, status, sanitized_summary, created_at
737
- FROM codex_hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 10`, [project.id]);
737
+ FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 10`, [project.id]);
738
738
  const activeDefs = protocols.flatMap((protocol) => jsonArray(protocol.active_def_json).map((entry) => ({ protocol: protocol.id, entry })));
739
739
  const blockers = protocols.flatMap((protocol) => jsonArray(protocol.blockers_json).map((entry) => ({ protocol: protocol.id, entry })));
740
740
  const activeLocks = locks.filter((lock) => lock.status === "active");
@@ -1053,7 +1053,7 @@ function buildProtocolCard(context, project, protocol, generatePage) {
1053
1053
  const activeDefCount = jsonArray(protocol.active_def_json).length;
1054
1054
  const blockerCount = jsonArray(protocol.blockers_json).length;
1055
1055
  const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1056
- FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1056
+ FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1057
1057
  ORDER BY updated_at DESC, id DESC`, [project.id, protocol.id]);
1058
1058
  const latestRun = runs.find((run) => isCanonicalRun(run));
1059
1059
  let diagnostics = [];
@@ -1102,7 +1102,7 @@ function buildProtocolCard(context, project, protocol, generatePage) {
1102
1102
  }
1103
1103
  function recentReviewRunsForProject(context, projectId, limit) {
1104
1104
  const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1105
- FROM flow_runs
1105
+ FROM runs
1106
1106
  WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
1107
1107
  ORDER BY updated_at DESC, id DESC
1108
1108
  LIMIT ?`, [projectId, limit]);
@@ -1110,7 +1110,7 @@ function recentReviewRunsForProject(context, projectId, limit) {
1110
1110
  }
1111
1111
  function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
1112
1112
  const runs = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1113
- FROM flow_runs
1113
+ FROM runs
1114
1114
  WHERE project_id = ?
1115
1115
  AND flow_kind IN ('mb-sdlc-review', 'review')
1116
1116
  AND subject_type = 'protocol'
@@ -1258,7 +1258,7 @@ function safeRunIndex(text, warnings, runId) {
1258
1258
  function isCanonicalRun(run) {
1259
1259
  try {
1260
1260
  const parsed = JSON.parse(run.index_json);
1261
- return parsed?.schema_id === "dd-flow/flow-run@2";
1261
+ return parsed?.schema_id === "dd-flow/flow-run@3";
1262
1262
  }
1263
1263
  catch {
1264
1264
  return false;
@@ -164,7 +164,7 @@ export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedPr
164
164
  const entrypoint = resolveManifestEntrypoint(bound.manifest);
165
165
  return spawnEngine(bound.manifest, entrypoint, args, io, stdin, env, getCliBuildInfo().version);
166
166
  }
167
- const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveExplicitProjectRoot(args) ?? undefined }, { allowCurrentInProcess: true }, classification);
167
+ const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveExplicitProjectRoot(args) ?? undefined, env }, { allowCurrentInProcess: true }, classification);
168
168
  if (selection.status !== "selected") {
169
169
  if (classification.mode === "read_only_diagnostics" || (classification.mode === "mb_upgrade" && isUpgradeDiagnostic(args))) {
170
170
  return Promise.resolve(null);