@deksden-com/dd-flow-cli 0.2.0 → 0.3.1

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 (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +76 -5
  3. package/dist/build-info.json +10 -6
  4. package/dist/cli/help.js +165 -20
  5. package/dist/cli/run-cli.js +427 -17
  6. package/dist/schemas/compatibility.schema.json +105 -0
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +90 -0
  9. package/dist/schemas/flow-run-index.schema.json +30 -4
  10. package/dist/schemas/global-dashboard-data.schema.json +126 -0
  11. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  12. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  13. package/dist/schemas/plan-stage-report.schema.json +83 -0
  14. package/dist/schemas/project-dashboard-data.schema.json +122 -0
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
  16. package/dist/schemas/project-summary.schema.json +73 -0
  17. package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
  18. package/dist/schemas/status-report.schema.json +38 -2
  19. package/dist/schemas/version-report.schema.json +22 -0
  20. package/dist/services/build-info.js +26 -3
  21. package/dist/services/canon.js +93 -22
  22. package/dist/services/cleanup.js +45 -1
  23. package/dist/services/cli-operation-classifier.js +104 -0
  24. package/dist/services/compatibility-preflight.js +124 -0
  25. package/dist/services/config.js +31 -0
  26. package/dist/services/dashboard-targets.js +95 -0
  27. package/dist/services/dashboard.js +972 -10
  28. package/dist/services/engines.js +532 -0
  29. package/dist/services/flow-guidance.js +221 -0
  30. package/dist/services/hooks.js +1 -1
  31. package/dist/services/ids.js +106 -0
  32. package/dist/services/lanes.js +333 -1
  33. package/dist/services/merge-queue.js +106 -16
  34. package/dist/services/merge-worker.js +67 -4
  35. package/dist/services/migrations.js +231 -0
  36. package/dist/services/project-summary.js +122 -0
  37. package/dist/services/projects.js +44 -3
  38. package/dist/services/protocol-lifecycle.js +144 -0
  39. package/dist/services/protocols.js +660 -7
  40. package/dist/services/runs.js +98 -21
  41. package/dist/services/schema-validation.js +84 -4
  42. package/dist/services/sessions.js +21 -4
  43. package/dist/services/status.js +199 -1
  44. package/dist/services/version-status.js +59 -9
  45. package/dist/storage/database.js +31 -0
  46. package/dist/storage/paths.js +33 -0
  47. package/package.json +3 -2
@@ -2,12 +2,16 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { ensureDir, runtimeRunJsonPath, userFacingRunDir, userFacingRunIndexPath, resolveProjectRoot } from "../storage/paths.js";
5
+ import { ensureDir, projectRunHome, projectRunIndexPath, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
6
6
  import { appendAudit } from "./audit.js";
7
7
  import { registerProject, requireProjectByRoot } from "./projects.js";
8
- const runSchemaId = "dd-flow/flow-run-index@1";
8
+ import { buildRunFlowGuidance } from "./flow-guidance.js";
9
+ import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
+ const runSchemaId = "dd-flow/flow-run-index@2";
11
+ const legacyRunSchemaId = "dd-flow/flow-run-index@1";
9
12
  const runIdType = "RUN";
10
13
  const allowedRunFlowKinds = [
14
+ "mb_sdlc",
11
15
  "coding",
12
16
  "experiment",
13
17
  "mb-init",
@@ -15,6 +19,8 @@ const allowedRunFlowKinds = [
15
19
  "mb-audit",
16
20
  "mb-distill",
17
21
  "mb-upgrade-review",
22
+ "mb-sdlc-review",
23
+ "review",
18
24
  "custom"
19
25
  ];
20
26
  export function startFlowRun(context, input) {
@@ -27,10 +33,10 @@ export function startFlowRun(context, input) {
27
33
  const runId = nextRunId(context, slug);
28
34
  const { shortId } = parseFullEntityId(runId);
29
35
  const now = context.now();
30
- const runDirAbsolute = userFacingRunDir(workspaceRoot, runId);
31
- const runIndexAbsolute = userFacingRunIndexPath(workspaceRoot, runId);
32
- const runDirRelative = path.relative(workspaceRoot, runDirAbsolute);
33
- const runtimePath = runtimeRunJsonPath(context.ddFlowHome, project.id, runId);
36
+ const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
37
+ const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
38
+ const runDirRelative = path.posix.join("runs", runId);
39
+ const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
34
40
  const index = {
35
41
  schema_id: runSchemaId,
36
42
  run_id: runId,
@@ -48,6 +54,18 @@ export function startFlowRun(context, input) {
48
54
  root: workspaceRoot,
49
55
  run_dir: runDirRelative
50
56
  },
57
+ run_home: {
58
+ storage: "dd_flow_home",
59
+ path: runHome,
60
+ relative_path: runDirRelative
61
+ },
62
+ execution: {
63
+ project_root: project.root,
64
+ workspace_root: workspaceRoot
65
+ },
66
+ legacy: {
67
+ project_tasks_run_dir: null
68
+ },
51
69
  stage_runs: [],
52
70
  sessions: [],
53
71
  artifacts: [],
@@ -58,13 +76,14 @@ export function startFlowRun(context, input) {
58
76
  updated_at: now
59
77
  };
60
78
  ensureDir(path.dirname(runtimePath));
61
- ensureDir(runDirAbsolute);
79
+ ensureDir(runHome);
62
80
  writeJsonFile(runtimePath, index);
63
81
  writeJsonFile(runIndexAbsolute, index);
64
82
  context.db.run(`INSERT INTO flow_runs
65
83
  (id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
66
- status, verdict, next_action, runtime_path, run_dir, run_index_path, index_json, created_at, updated_at, completed_at)
67
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
84
+ status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
85
+ index_json, created_at, updated_at, completed_at)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, [
68
87
  runId,
69
88
  shortId,
70
89
  slug,
@@ -78,8 +97,11 @@ export function startFlowRun(context, input) {
78
97
  index.verdict,
79
98
  index.next_action,
80
99
  runtimePath,
81
- runDirRelative,
100
+ runHome,
82
101
  runIndexAbsolute,
102
+ runHome,
103
+ "home_run_v2",
104
+ "dd_flow_home",
83
105
  JSON.stringify(index),
84
106
  now,
85
107
  now
@@ -87,14 +109,15 @@ export function startFlowRun(context, input) {
87
109
  appendAudit(context, {
88
110
  projectId: project.id,
89
111
  eventType: "flow_run.started",
90
- payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot }
112
+ payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
91
113
  });
92
114
  return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
93
115
  }
94
116
  export function getFlowRunStatus(context, input) {
95
117
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
96
118
  const run = resolveRun(context, project.id, input.runId);
97
- return { ok: true, run: flowRunSummary(run), index: parseRunIndex(run.index_json) };
119
+ const index = parseRunIndex(run.index_json);
120
+ return { ok: true, run: flowRunSummary(run), index, flow_guidance: guidanceForRun(context, run, index) };
98
121
  }
99
122
  export function listFlowRuns(context, input) {
100
123
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -114,6 +137,9 @@ export function attachFlowRunStage(context, input) {
114
137
  const dir = requiredStageDir(input.dir);
115
138
  const status = parseStageStatus(input.status);
116
139
  const existing = index.stage_runs.find((item) => item.stage === stage);
140
+ if (status === "running" && existing) {
141
+ archiveExistingStageAttempt(runArtifactRoot(run), dir);
142
+ }
117
143
  const stageRun = {
118
144
  ...(existing ?? { order: index.stage_runs.length + 1 }),
119
145
  stage,
@@ -130,7 +156,8 @@ export function attachFlowRunStage(context, input) {
130
156
  eventType: "flow_run.stage_attached",
131
157
  payload: { run_id: run.id, stage, dir, status }
132
158
  });
133
- return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
159
+ const updatedRun = requireRunById(context, project.id, run.id);
160
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
134
161
  }
135
162
  export function completeFlowRunStage(context, input) {
136
163
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -161,7 +188,8 @@ export function completeFlowRunStage(context, input) {
161
188
  eventType: "flow_run.stage_completed",
162
189
  payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
163
190
  });
164
- return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), stage_run: stageRun, index };
191
+ const updatedRun = requireRunById(context, project.id, run.id);
192
+ return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
165
193
  }
166
194
  export function completeFlowRun(context, input) {
167
195
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -182,11 +210,32 @@ export function completeFlowRun(context, input) {
182
210
  eventType: "flow_run.completed",
183
211
  payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
184
212
  });
185
- return { ok: true, run: flowRunSummary(requireRunById(context, project.id, run.id)), index };
213
+ const updatedRun = requireRunById(context, project.id, run.id);
214
+ return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
215
+ }
216
+ function guidanceForRun(context, run, index) {
217
+ if (run.subject_type === "protocol") {
218
+ try {
219
+ const protocol = requireProtocol(context, run.subject_id);
220
+ const state = readProtocolRuntimeState(context, protocol).state;
221
+ return buildRunFlowGuidance({
222
+ stageRuns: index.stage_runs,
223
+ protocolStage: state.stage,
224
+ ...(state.flow_contract ? { contract: state.flow_contract } : {}),
225
+ runId: run.id,
226
+ runDir: index.run_home?.relative_path ?? index.workspace.run_dir
227
+ });
228
+ }
229
+ catch (error) {
230
+ if (!(error instanceof AppError && error.code === "not_found"))
231
+ throw error;
232
+ }
233
+ }
234
+ return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
186
235
  }
187
236
  function persistRunIndex(context, project, run, index) {
188
- const runtimePath = runtimeRunJsonPath(context.ddFlowHome, project.id, run.id);
189
- const runIndexPath = userFacingRunIndexPath(run.workspace_root, run.id);
237
+ const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
238
+ const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
190
239
  ensureDir(path.dirname(runtimePath));
191
240
  ensureDir(path.dirname(runIndexPath));
192
241
  writeJsonFile(runtimePath, index);
@@ -281,6 +330,9 @@ function flowRunSummary(run) {
281
330
  runtime_path: run.runtime_path,
282
331
  run_dir: run.run_dir,
283
332
  run_index_path: run.run_index_path,
333
+ run_home_path: run.run_home_path ?? null,
334
+ layout_version: run.layout_version ?? null,
335
+ artifact_root_kind: run.artifact_root_kind ?? null,
284
336
  created_at: run.created_at,
285
337
  updated_at: run.updated_at,
286
338
  completed_at: run.completed_at
@@ -288,19 +340,20 @@ function flowRunSummary(run) {
288
340
  }
289
341
  function parseRunIndex(text) {
290
342
  const index = JSON.parse(text);
291
- if (index.schema_id !== runSchemaId) {
343
+ if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId) {
292
344
  throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
293
345
  }
294
346
  return index;
295
347
  }
296
348
  function parseRunFlowKind(value) {
297
- if (!allowedRunFlowKinds.includes(value)) {
349
+ const normalized = value === "mb-sdlc" ? "mb_sdlc" : value;
350
+ if (!allowedRunFlowKinds.includes(normalized)) {
298
351
  throw new AppError("validation", "flow-kind is not supported for run start", 2, {
299
352
  flow_kind: value,
300
353
  allowed: allowedRunFlowKinds
301
354
  });
302
355
  }
303
- return value;
356
+ return normalized;
304
357
  }
305
358
  function parseRunStatus(value) {
306
359
  if (!["running", "done", "blocked", "cancelled", "failed"].includes(value)) {
@@ -354,6 +407,30 @@ function resolveWorkspaceRoot(workspaceRoot) {
354
407
  }
355
408
  return fs.realpathSync(absolute);
356
409
  }
410
+ function archiveExistingStageAttempt(runRoot, stageDir) {
411
+ const currentStageDir = path.join(runRoot, stageDir);
412
+ if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
413
+ return;
414
+ }
415
+ const entries = fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
416
+ if (entries.length === 0) {
417
+ return;
418
+ }
419
+ let attempt = 1;
420
+ while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
421
+ attempt += 1;
422
+ }
423
+ const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
424
+ ensureDir(archiveDir);
425
+ for (const entry of entries) {
426
+ fs.renameSync(path.join(currentStageDir, entry), path.join(archiveDir, entry));
427
+ }
428
+ }
357
429
  function writeJsonFile(file, value) {
358
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
430
+ const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
431
+ fs.writeFileSync(tmpFile, `${JSON.stringify(value, null, 2)}\n`);
432
+ fs.renameSync(tmpFile, file);
433
+ }
434
+ function runArtifactRoot(run) {
435
+ return path.dirname(run.run_index_path);
359
436
  }
@@ -16,6 +16,20 @@ const mandatoryMbUpgradeReviewAspectIds = [
16
16
  "09-report-quality",
17
17
  "10-agent-process-quality"
18
18
  ];
19
+ const mandatoryMbSdlcReviewAspectIds = [
20
+ "structure_navigation_review",
21
+ "feature_epic_layer_review",
22
+ "spec_conformance_review",
23
+ "architecture_harmonization_review",
24
+ "adr_decision_review",
25
+ "scenario_evidence_review",
26
+ "contract_traceability_review",
27
+ "frontmatter_crosslink_review",
28
+ "engineering_standards_review",
29
+ "operations_policy_review",
30
+ "protocol_delivery_trace_review",
31
+ "def_followup_review"
32
+ ];
19
33
  export function validateSchema(options) {
20
34
  if (!schemaNamePattern.test(options.schemaName)) {
21
35
  throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
@@ -97,14 +111,17 @@ function formatAjvError(error) {
97
111
  };
98
112
  }
99
113
  function validateSemanticSchema(schemaName, data) {
100
- if (schemaName !== "mb-upgrade-review-data") {
101
- return [];
102
- }
103
114
  const root = asRecord(data);
104
115
  if (!root) {
105
116
  return [];
106
117
  }
107
- return validateMbUpgradeReviewData(root);
118
+ if (schemaName === "mb-upgrade-review-data") {
119
+ return validateMbUpgradeReviewData(root);
120
+ }
121
+ if (schemaName === "mb-sdlc-review-report") {
122
+ return validateMbSdlcReviewReport(root);
123
+ }
124
+ return [];
108
125
  }
109
126
  function validateMbUpgradeReviewData(root) {
110
127
  const errors = [];
@@ -159,6 +176,69 @@ function validateMbUpgradeReviewData(root) {
159
176
  });
160
177
  return errors;
161
178
  }
179
+ function validateMbSdlcReviewReport(root) {
180
+ const errors = [];
181
+ const aspects = arrayValue(root, "aspect_coverage").filter(isRecord);
182
+ const findings = arrayValue(root, "findings_register").filter(isRecord);
183
+ const conformance = recordValue(root, "conformance_summary");
184
+ const verdict = conformance ? stringValue(conformance, "overall_verdict") : undefined;
185
+ const aspectIds = aspects.map((aspect) => stringValue(aspect, "aspect_id")).filter(isString);
186
+ for (const id of mandatoryMbSdlcReviewAspectIds) {
187
+ const count = aspectIds.filter((value) => value === id).length;
188
+ if (count !== 1) {
189
+ errors.push({
190
+ path: "/aspect_coverage",
191
+ message: `mandatory aspect ${id} must be present exactly once`,
192
+ keyword: "dd-flow/aspect-id"
193
+ });
194
+ }
195
+ }
196
+ const findingIds = new Set(findings.map((finding) => stringValue(finding, "id")).filter(isString));
197
+ aspects.forEach((aspect, aspectIndex) => {
198
+ const refs = arrayValue(aspect, "findings").filter(isString);
199
+ refs.forEach((ref, refIndex) => {
200
+ if (!findingIds.has(ref)) {
201
+ errors.push({
202
+ path: `/aspect_coverage/${aspectIndex}/findings/${refIndex}`,
203
+ message: `finding reference does not exist: ${ref}`,
204
+ keyword: "dd-flow/finding-ref"
205
+ });
206
+ }
207
+ });
208
+ });
209
+ const decisions = recordValue(root, "critic_pass")
210
+ ? arrayValue(recordValue(root, "critic_pass"), "decisions").filter(isRecord)
211
+ : [];
212
+ decisions.forEach((decision, decisionIndex) => {
213
+ const findingId = stringValue(decision, "finding_id");
214
+ const disposition = stringValue(decision, "disposition");
215
+ if (findingId && !findingIds.has(findingId)) {
216
+ errors.push({
217
+ path: `/critic_pass/decisions/${decisionIndex}/finding_id`,
218
+ message: `critic decision finding_id does not exist: ${findingId}`,
219
+ keyword: "dd-flow/finding-ref"
220
+ });
221
+ }
222
+ if (disposition === "accepted" && !findingId) {
223
+ errors.push({
224
+ path: `/critic_pass/decisions/${decisionIndex}/finding_id`,
225
+ message: "accepted critic decision must reference a final finding_id",
226
+ keyword: "dd-flow/accepted-finding-ref"
227
+ });
228
+ }
229
+ });
230
+ findings.forEach((finding, findingIndex) => {
231
+ const severity = stringValue(finding, "severity");
232
+ if ((verdict === "accepted" || verdict === "accepted_with_findings") && severity === "blocking") {
233
+ errors.push({
234
+ path: `/findings_register/${findingIndex}/severity`,
235
+ message: "accepted review verdict cannot coexist with blocking findings",
236
+ keyword: "dd-flow/accepted-blocking"
237
+ });
238
+ }
239
+ });
240
+ return errors;
241
+ }
162
242
  function asRecord(value) {
163
243
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
164
244
  }
@@ -5,6 +5,7 @@ import { AppError } from "../shared/errors.js";
5
5
  import { parseJsonObject } from "../shared/json.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { appendAudit } from "./audit.js";
8
+ import { cancelLaneWaitersForWorker } from "./lanes.js";
8
9
  export function registerFlowSession(context, input) {
9
10
  const payload = decodeFlowSessionPayload(input);
10
11
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
@@ -44,16 +45,21 @@ export function stopMergeWorker(context, input) {
44
45
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
45
46
  const sessions = flowSessionsForProject(context, project.id, { workerId: input.workerId }).filter((session) => session.flow_kind === "merge_worker" && ["active", "pending", "stopping"].includes(session.status));
46
47
  let lock_release = { ok: true, released: false, reason: "no_active_session" };
48
+ let waiter_cancellation = { ok: true, cancelled: 0 };
47
49
  for (const session of sessions) {
48
- lock_release = markSessionStopped(context, project, session, input.reason) ?? lock_release;
50
+ const stopped = markSessionStopped(context, project, session, input.reason);
51
+ if (isStopResult(stopped)) {
52
+ lock_release = stopped.lock_release ?? lock_release;
53
+ waiter_cancellation = stopped.waiter_cancellation ?? waiter_cancellation;
54
+ }
49
55
  }
50
56
  appendAudit(context, {
51
57
  projectId: project.id,
52
58
  eventType: "flow_session.merge_worker_stopped",
53
59
  reason: input.reason,
54
- payload: { project_id: project.id, worker_id: input.workerId, stopped_sessions: sessions.length, lock_release }
60
+ payload: { project_id: project.id, worker_id: input.workerId, stopped_sessions: sessions.length, lock_release, waiter_cancellation }
55
61
  });
56
- return { ok: true, stopped_sessions: sessions.length, lock_release };
62
+ return { ok: true, stopped_sessions: sessions.length, lock_release, waiter_cancellation };
57
63
  }
58
64
  export function stoppedMergeWorkerState(context, projectId, workerId) {
59
65
  const latest = context.db.get(`SELECT status, stop_reason FROM flow_sessions
@@ -268,6 +274,14 @@ function markSessionStopped(context, project, session, reason) {
268
274
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
269
275
  WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
270
276
  let lockRelease = undefined;
277
+ let waiterCancellation = undefined;
278
+ if (session.worker_id) {
279
+ waiterCancellation = cancelLaneWaitersForWorker(context, {
280
+ projectRoot: project.root,
281
+ workerId: session.worker_id,
282
+ reason
283
+ });
284
+ }
271
285
  if ((session.flow_kind === "merge_worker" || session.flow_kind === "merge_job") && session.worker_id) {
272
286
  lockRelease = releaseMergeLockIfOwned(context, project.root, session.worker_id, reason);
273
287
  }
@@ -278,7 +292,10 @@ function markSessionStopped(context, project, session, reason) {
278
292
  payload: { project_id: project.id, session_id: session.session_id, flow_kind: session.flow_kind },
279
293
  ...(session.protocol_id ? { protocolId: session.protocol_id } : {})
280
294
  });
281
- return lockRelease;
295
+ return { lock_release: lockRelease, waiter_cancellation: waiterCancellation };
296
+ }
297
+ function isStopResult(value) {
298
+ return Boolean(value && typeof value === "object" && ("lock_release" in value || "waiter_cancellation" in value));
282
299
  }
283
300
  function releaseMergeLockIfOwned(context, projectRoot, workerId, reason) {
284
301
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
@@ -1,7 +1,13 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
1
4
  import { getCanonStatus } from "./canon.js";
2
5
  import { getCliBuildInfo } from "./build-info.js";
3
6
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
4
7
  import { findProjectByRoot } from "./projects.js";
8
+ import { classifyCliOperation } from "./cli-operation-classifier.js";
9
+ import { compatibilityReport } from "./compatibility-preflight.js";
10
+ import { selectEngine } from "./engines.js";
5
11
  export function getRuntimeStatus(context, input = {}) {
6
12
  const cwd = process.cwd();
7
13
  const projectRootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd });
@@ -16,12 +22,20 @@ export function getRuntimeStatus(context, input = {}) {
16
22
  canon: resolvedCanonForProject
17
23
  });
18
24
  const cli = getCliBuildInfo();
25
+ const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
26
+ const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
27
+ const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
28
+ const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
19
29
  return {
20
30
  ok: true,
21
31
  schema_id: "dd-flow/status-report@1",
22
32
  dd_flow_home: context.ddFlowHome,
23
33
  cwd,
24
- cli,
34
+ cli: {
35
+ ...cli,
36
+ compatibility: cliCompatibility,
37
+ ...(registry ? { registry } : {})
38
+ },
25
39
  project: {
26
40
  requested_root: projectRootResolution.requested_root,
27
41
  root: projectRoot,
@@ -33,6 +47,12 @@ export function getRuntimeStatus(context, input = {}) {
33
47
  flow_pack: projectVersionStatus?.flow_pack ?? null,
34
48
  drift: projectVersionStatus?.drift ?? null
35
49
  },
50
+ engine: engineSelection
51
+ ? {
52
+ selection: engineSelection,
53
+ compatibility: compatibilityReport(engineSelection, classifyCliOperation(["status"]))
54
+ }
55
+ : null,
36
56
  canon: {
37
57
  ...(asRecord(canonStatus) ?? {}),
38
58
  resolved: canon ?? null,
@@ -46,6 +66,184 @@ function asRecord(value) {
46
66
  function stringValue(value) {
47
67
  return typeof value === "string" && value.length > 0 ? value : null;
48
68
  }
69
+ function readCompatibilityManifest(canon) {
70
+ const record = asRecord(canon);
71
+ const memorybankRoot = stringValue(record?.memorybank_root);
72
+ if (!memorybankRoot)
73
+ return null;
74
+ const file = path.join(memorybankRoot, "dd-flow", "compatibility.json");
75
+ try {
76
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
77
+ const root = asRecord(parsed);
78
+ const cli = asRecord(root?.dd_flow_cli);
79
+ if (!root || !cli)
80
+ return null;
81
+ return {
82
+ schema_id: stringValue(root.schema_id),
83
+ memory_bank_version: stringValue(root.memory_bank_version),
84
+ dd_flow_cli: {
85
+ package_name: stringValue(cli.package_name),
86
+ min_version: stringValue(cli.min_version),
87
+ recommended_version: stringValue(cli.recommended_version),
88
+ status_contract: stringValue(cli.status_contract),
89
+ version_contract: stringValue(cli.version_contract),
90
+ flow_contract: stringValue(cli.flow_contract)
91
+ }
92
+ };
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function cliCompatibilityVerdict(cli, manifest) {
99
+ if (!manifest) {
100
+ return {
101
+ verdict: "unknown",
102
+ reason: "compatibility_manifest_missing",
103
+ package_name: cli.package_name,
104
+ installed_version: cli.version,
105
+ update_command: null
106
+ };
107
+ }
108
+ const expectedPackage = manifest.dd_flow_cli.package_name;
109
+ if (expectedPackage && expectedPackage !== cli.package_name) {
110
+ return {
111
+ verdict: "incompatible",
112
+ reason: "package_name_mismatch",
113
+ package_name: cli.package_name,
114
+ expected_package_name: expectedPackage,
115
+ installed_version: cli.version,
116
+ memory_bank_version: manifest.memory_bank_version,
117
+ min_version: manifest.dd_flow_cli.min_version,
118
+ recommended_version: manifest.dd_flow_cli.recommended_version,
119
+ update_command: updateCommand(expectedPackage, manifest.dd_flow_cli.recommended_version ?? manifest.dd_flow_cli.min_version)
120
+ };
121
+ }
122
+ const minVersion = manifest.dd_flow_cli.min_version;
123
+ const recommendedVersion = manifest.dd_flow_cli.recommended_version;
124
+ const installed = parseSemver(cli.version);
125
+ const min = minVersion ? parseSemver(minVersion) : null;
126
+ const recommended = recommendedVersion ? parseSemver(recommendedVersion) : null;
127
+ if (!installed || (minVersion && !min) || (recommendedVersion && !recommended)) {
128
+ return {
129
+ verdict: "unknown",
130
+ reason: "invalid_semver",
131
+ package_name: cli.package_name,
132
+ installed_version: cli.version,
133
+ memory_bank_version: manifest.memory_bank_version,
134
+ min_version: minVersion,
135
+ recommended_version: recommendedVersion,
136
+ update_command: null
137
+ };
138
+ }
139
+ if (min && compareSemver(installed, min) < 0) {
140
+ return {
141
+ verdict: "incompatible",
142
+ reason: "installed_below_min_version",
143
+ package_name: cli.package_name,
144
+ installed_version: cli.version,
145
+ memory_bank_version: manifest.memory_bank_version,
146
+ min_version: minVersion,
147
+ recommended_version: recommendedVersion,
148
+ update_command: updateCommand(cli.package_name, recommendedVersion ?? minVersion)
149
+ };
150
+ }
151
+ if (recommended && compareSemver(installed, recommended) < 0) {
152
+ return {
153
+ verdict: "outdated",
154
+ reason: "installed_below_recommended_version",
155
+ package_name: cli.package_name,
156
+ installed_version: cli.version,
157
+ memory_bank_version: manifest.memory_bank_version,
158
+ min_version: minVersion,
159
+ recommended_version: recommendedVersion,
160
+ update_command: updateCommand(cli.package_name, recommendedVersion)
161
+ };
162
+ }
163
+ return {
164
+ verdict: "ok",
165
+ reason: "installed_satisfies_memory_bank_compatibility",
166
+ package_name: cli.package_name,
167
+ installed_version: cli.version,
168
+ memory_bank_version: manifest.memory_bank_version,
169
+ min_version: minVersion,
170
+ recommended_version: recommendedVersion,
171
+ status_contract: manifest.dd_flow_cli.status_contract,
172
+ version_contract: manifest.dd_flow_cli.version_contract,
173
+ flow_contract: manifest.dd_flow_cli.flow_contract,
174
+ update_command: null
175
+ };
176
+ }
177
+ function updateCommand(packageName, version) {
178
+ return `pnpm add -g ${packageName}${version ? `@${version}` : "@latest"}`;
179
+ }
180
+ function checkNpmRegistry(context, packageName) {
181
+ const cachePath = path.join(context.ddFlowHome, "cache", "npm", `${safeCacheName(packageName)}.json`);
182
+ const cached = readRegistryCache(cachePath);
183
+ const now = new Date().toISOString();
184
+ if (cached && Date.now() - cached.checkedAtMs < 15 * 60 * 1000) {
185
+ return { ...cached.payload, source: "npm_cache" };
186
+ }
187
+ const result = spawnSync("npm", ["view", packageName, "version", "--json"], { encoding: "utf8", timeout: 5000 });
188
+ if (result.status !== 0) {
189
+ return {
190
+ package_name: packageName,
191
+ latest: null,
192
+ checked_at: now,
193
+ source: "npm",
194
+ status: "degraded",
195
+ reason: result.error ? String(result.error) : result.stderr.trim() || "npm_view_failed"
196
+ };
197
+ }
198
+ const latest = parseRegistryVersion(result.stdout);
199
+ const payload = {
200
+ package_name: packageName,
201
+ latest,
202
+ checked_at: now,
203
+ source: "npm",
204
+ status: latest ? "ok" : "degraded",
205
+ ...(latest ? {} : { reason: "npm_view_returned_no_version" })
206
+ };
207
+ writeRegistryCache(cachePath, payload);
208
+ return payload;
209
+ }
210
+ function readRegistryCache(file) {
211
+ try {
212
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
213
+ const record = asRecord(parsed);
214
+ const payload = asRecord(record?.payload);
215
+ const checkedAt = stringValue(record?.checked_at);
216
+ if (!payload || !checkedAt)
217
+ return null;
218
+ const checkedAtMs = Date.parse(checkedAt);
219
+ return Number.isNaN(checkedAtMs) ? null : { checkedAtMs, payload };
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ function writeRegistryCache(file, payload) {
226
+ try {
227
+ fs.mkdirSync(path.dirname(file), { recursive: true });
228
+ fs.writeFileSync(file, `${JSON.stringify({ checked_at: payload.checked_at, payload }, null, 2)}\n`);
229
+ }
230
+ catch {
231
+ // Registry cache is best effort; status remains valid without it.
232
+ }
233
+ }
234
+ function parseRegistryVersion(value) {
235
+ try {
236
+ const parsed = JSON.parse(value);
237
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
238
+ }
239
+ catch {
240
+ const trimmed = value.trim().replace(/^"|"$/g, "");
241
+ return trimmed.length > 0 ? trimmed : null;
242
+ }
243
+ }
244
+ function safeCacheName(value) {
245
+ return value.replace(/[^a-zA-Z0-9_.-]+/g, "_");
246
+ }
49
247
  function canonForProjectStatus(value) {
50
248
  const record = asRecord(value);
51
249
  if (!record || typeof record.root !== "string")