@deksden-com/dd-flow-cli 0.2.0 → 0.3.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 (33) hide show
  1. package/README.md +2 -0
  2. package/dist/build-info.json +10 -6
  3. package/dist/cli/help.js +70 -13
  4. package/dist/cli/run-cli.js +140 -10
  5. package/dist/schemas/compatibility.schema.json +26 -0
  6. package/dist/schemas/flow-guidance.schema.json +73 -0
  7. package/dist/schemas/flow-run-index.schema.json +30 -4
  8. package/dist/schemas/global-dashboard-data.schema.json +68 -0
  9. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  10. package/dist/schemas/plan-stage-report.schema.json +83 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +98 -0
  12. package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
  13. package/dist/schemas/protocol-dashboard-data.schema.json +89 -0
  14. package/dist/schemas/status-report.schema.json +34 -0
  15. package/dist/schemas/version-report.schema.json +22 -0
  16. package/dist/services/build-info.js +26 -3
  17. package/dist/services/canon.js +93 -22
  18. package/dist/services/cleanup.js +14 -1
  19. package/dist/services/config.js +25 -0
  20. package/dist/services/dashboard.js +655 -10
  21. package/dist/services/flow-guidance.js +214 -0
  22. package/dist/services/ids.js +106 -0
  23. package/dist/services/merge-queue.js +9 -1
  24. package/dist/services/merge-worker.js +31 -2
  25. package/dist/services/projects.js +7 -1
  26. package/dist/services/protocols.js +637 -6
  27. package/dist/services/runs.js +98 -21
  28. package/dist/services/schema-validation.js +84 -4
  29. package/dist/services/status.js +189 -1
  30. package/dist/services/version-status.js +28 -2
  31. package/dist/storage/database.js +6 -0
  32. package/dist/storage/paths.js +21 -0
  33. package/package.json +1 -1
@@ -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
  }
@@ -1,3 +1,6 @@
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";
@@ -16,12 +19,19 @@ export function getRuntimeStatus(context, input = {}) {
16
19
  canon: resolvedCanonForProject
17
20
  });
18
21
  const cli = getCliBuildInfo();
22
+ const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
23
+ const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
24
+ const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
19
25
  return {
20
26
  ok: true,
21
27
  schema_id: "dd-flow/status-report@1",
22
28
  dd_flow_home: context.ddFlowHome,
23
29
  cwd,
24
- cli,
30
+ cli: {
31
+ ...cli,
32
+ compatibility: cliCompatibility,
33
+ ...(registry ? { registry } : {})
34
+ },
25
35
  project: {
26
36
  requested_root: projectRootResolution.requested_root,
27
37
  root: projectRoot,
@@ -46,6 +56,184 @@ function asRecord(value) {
46
56
  function stringValue(value) {
47
57
  return typeof value === "string" && value.length > 0 ? value : null;
48
58
  }
59
+ function readCompatibilityManifest(canon) {
60
+ const record = asRecord(canon);
61
+ const memorybankRoot = stringValue(record?.memorybank_root);
62
+ if (!memorybankRoot)
63
+ return null;
64
+ const file = path.join(memorybankRoot, "dd-flow", "compatibility.json");
65
+ try {
66
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
67
+ const root = asRecord(parsed);
68
+ const cli = asRecord(root?.dd_flow_cli);
69
+ if (!root || !cli)
70
+ return null;
71
+ return {
72
+ schema_id: stringValue(root.schema_id),
73
+ memory_bank_version: stringValue(root.memory_bank_version),
74
+ dd_flow_cli: {
75
+ package_name: stringValue(cli.package_name),
76
+ min_version: stringValue(cli.min_version),
77
+ recommended_version: stringValue(cli.recommended_version),
78
+ status_contract: stringValue(cli.status_contract),
79
+ version_contract: stringValue(cli.version_contract),
80
+ flow_contract: stringValue(cli.flow_contract)
81
+ }
82
+ };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function cliCompatibilityVerdict(cli, manifest) {
89
+ if (!manifest) {
90
+ return {
91
+ verdict: "unknown",
92
+ reason: "compatibility_manifest_missing",
93
+ package_name: cli.package_name,
94
+ installed_version: cli.version,
95
+ update_command: null
96
+ };
97
+ }
98
+ const expectedPackage = manifest.dd_flow_cli.package_name;
99
+ if (expectedPackage && expectedPackage !== cli.package_name) {
100
+ return {
101
+ verdict: "incompatible",
102
+ reason: "package_name_mismatch",
103
+ package_name: cli.package_name,
104
+ expected_package_name: expectedPackage,
105
+ installed_version: cli.version,
106
+ memory_bank_version: manifest.memory_bank_version,
107
+ min_version: manifest.dd_flow_cli.min_version,
108
+ recommended_version: manifest.dd_flow_cli.recommended_version,
109
+ update_command: updateCommand(expectedPackage, manifest.dd_flow_cli.recommended_version ?? manifest.dd_flow_cli.min_version)
110
+ };
111
+ }
112
+ const minVersion = manifest.dd_flow_cli.min_version;
113
+ const recommendedVersion = manifest.dd_flow_cli.recommended_version;
114
+ const installed = parseSemver(cli.version);
115
+ const min = minVersion ? parseSemver(minVersion) : null;
116
+ const recommended = recommendedVersion ? parseSemver(recommendedVersion) : null;
117
+ if (!installed || (minVersion && !min) || (recommendedVersion && !recommended)) {
118
+ return {
119
+ verdict: "unknown",
120
+ reason: "invalid_semver",
121
+ package_name: cli.package_name,
122
+ installed_version: cli.version,
123
+ memory_bank_version: manifest.memory_bank_version,
124
+ min_version: minVersion,
125
+ recommended_version: recommendedVersion,
126
+ update_command: null
127
+ };
128
+ }
129
+ if (min && compareSemver(installed, min) < 0) {
130
+ return {
131
+ verdict: "incompatible",
132
+ reason: "installed_below_min_version",
133
+ package_name: cli.package_name,
134
+ installed_version: cli.version,
135
+ memory_bank_version: manifest.memory_bank_version,
136
+ min_version: minVersion,
137
+ recommended_version: recommendedVersion,
138
+ update_command: updateCommand(cli.package_name, recommendedVersion ?? minVersion)
139
+ };
140
+ }
141
+ if (recommended && compareSemver(installed, recommended) < 0) {
142
+ return {
143
+ verdict: "outdated",
144
+ reason: "installed_below_recommended_version",
145
+ package_name: cli.package_name,
146
+ installed_version: cli.version,
147
+ memory_bank_version: manifest.memory_bank_version,
148
+ min_version: minVersion,
149
+ recommended_version: recommendedVersion,
150
+ update_command: updateCommand(cli.package_name, recommendedVersion)
151
+ };
152
+ }
153
+ return {
154
+ verdict: "ok",
155
+ reason: "installed_satisfies_memory_bank_compatibility",
156
+ package_name: cli.package_name,
157
+ installed_version: cli.version,
158
+ memory_bank_version: manifest.memory_bank_version,
159
+ min_version: minVersion,
160
+ recommended_version: recommendedVersion,
161
+ status_contract: manifest.dd_flow_cli.status_contract,
162
+ version_contract: manifest.dd_flow_cli.version_contract,
163
+ flow_contract: manifest.dd_flow_cli.flow_contract,
164
+ update_command: null
165
+ };
166
+ }
167
+ function updateCommand(packageName, version) {
168
+ return `pnpm add -g ${packageName}${version ? `@${version}` : "@latest"}`;
169
+ }
170
+ function checkNpmRegistry(context, packageName) {
171
+ const cachePath = path.join(context.ddFlowHome, "cache", "npm", `${safeCacheName(packageName)}.json`);
172
+ const cached = readRegistryCache(cachePath);
173
+ const now = new Date().toISOString();
174
+ if (cached && Date.now() - cached.checkedAtMs < 15 * 60 * 1000) {
175
+ return { ...cached.payload, source: "npm_cache" };
176
+ }
177
+ const result = spawnSync("npm", ["view", packageName, "version", "--json"], { encoding: "utf8", timeout: 5000 });
178
+ if (result.status !== 0) {
179
+ return {
180
+ package_name: packageName,
181
+ latest: null,
182
+ checked_at: now,
183
+ source: "npm",
184
+ status: "degraded",
185
+ reason: result.error ? String(result.error) : result.stderr.trim() || "npm_view_failed"
186
+ };
187
+ }
188
+ const latest = parseRegistryVersion(result.stdout);
189
+ const payload = {
190
+ package_name: packageName,
191
+ latest,
192
+ checked_at: now,
193
+ source: "npm",
194
+ status: latest ? "ok" : "degraded",
195
+ ...(latest ? {} : { reason: "npm_view_returned_no_version" })
196
+ };
197
+ writeRegistryCache(cachePath, payload);
198
+ return payload;
199
+ }
200
+ function readRegistryCache(file) {
201
+ try {
202
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
203
+ const record = asRecord(parsed);
204
+ const payload = asRecord(record?.payload);
205
+ const checkedAt = stringValue(record?.checked_at);
206
+ if (!payload || !checkedAt)
207
+ return null;
208
+ const checkedAtMs = Date.parse(checkedAt);
209
+ return Number.isNaN(checkedAtMs) ? null : { checkedAtMs, payload };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function writeRegistryCache(file, payload) {
216
+ try {
217
+ fs.mkdirSync(path.dirname(file), { recursive: true });
218
+ fs.writeFileSync(file, `${JSON.stringify({ checked_at: payload.checked_at, payload }, null, 2)}\n`);
219
+ }
220
+ catch {
221
+ // Registry cache is best effort; status remains valid without it.
222
+ }
223
+ }
224
+ function parseRegistryVersion(value) {
225
+ try {
226
+ const parsed = JSON.parse(value);
227
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
228
+ }
229
+ catch {
230
+ const trimmed = value.trim().replace(/^"|"$/g, "");
231
+ return trimmed.length > 0 ? trimmed : null;
232
+ }
233
+ }
234
+ function safeCacheName(value) {
235
+ return value.replace(/[^a-zA-Z0-9_.-]+/g, "_");
236
+ }
49
237
  function canonForProjectStatus(value) {
50
238
  const record = asRecord(value);
51
239
  if (!record || typeof record.root !== "string")
@@ -62,6 +62,9 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
62
62
  manifest_path: null,
63
63
  schema_id: null,
64
64
  pack_version: null,
65
+ canon_root: null,
66
+ canon_memory_bank_root: null,
67
+ canon_flow_root: null,
65
68
  source_commit: null,
66
69
  canon_version_at_source_commit: null,
67
70
  status: "missing",
@@ -77,6 +80,9 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
77
80
  manifest_path: path.relative(projectRoot, manifestPath),
78
81
  schema_id: null,
79
82
  pack_version: null,
83
+ canon_root: null,
84
+ canon_memory_bank_root: null,
85
+ canon_flow_root: null,
80
86
  source_commit: null,
81
87
  canon_version_at_source_commit: null,
82
88
  status: "missing",
@@ -96,6 +102,9 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
96
102
  manifest_path: path.relative(projectRoot, manifestPath),
97
103
  schema_id: null,
98
104
  pack_version: null,
105
+ canon_root: null,
106
+ canon_memory_bank_root: null,
107
+ canon_flow_root: null,
99
108
  source_commit: null,
100
109
  canon_version_at_source_commit: null,
101
110
  status: "invalid",
@@ -106,12 +115,29 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
106
115
  const schemaId = stringOrNull(manifest.schema_id);
107
116
  const canonVersion = stringOrNull(manifest.canon_version_at_source_commit) ?? stringOrNull(manifest.canon_version);
108
117
  const sourceCommit = stringOrNull(manifest.source_commit);
109
- const status = schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion ? "present" : schemaId === "dd-flow/project-flow-pack-manifest@1" ? "degraded" : "invalid";
110
- const reason = status === "degraded" ? "legacy_flow_pack_manifest" : status === "invalid" ? "flow_pack_manifest_schema_unknown" : undefined;
118
+ const canonRoot = stringOrNull(manifest.canon_root);
119
+ const canonMemoryBankRoot = stringOrNull(manifest.canon_memory_bank_root);
120
+ const canonFlowRoot = stringOrNull(manifest.canon_flow_root);
121
+ const status = schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && canonMemoryBankRoot && canonFlowRoot
122
+ ? "present"
123
+ : schemaId === "dd-flow/project-flow-pack-manifest@1" ||
124
+ (schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && (!canonMemoryBankRoot || !canonFlowRoot))
125
+ ? "degraded"
126
+ : "invalid";
127
+ const reason = status === "degraded" && schemaId === "dd-flow/project-flow-pack-manifest@2"
128
+ ? "flow_pack_manifest_missing_explicit_canon_roots"
129
+ : status === "degraded"
130
+ ? "legacy_flow_pack_manifest"
131
+ : status === "invalid"
132
+ ? "flow_pack_manifest_schema_unknown"
133
+ : undefined;
111
134
  return {
112
135
  manifest_path: path.relative(projectRoot, manifestPath),
113
136
  schema_id: schemaId,
114
137
  pack_version: stringOrNull(manifest.pack_version),
138
+ canon_root: canonRoot,
139
+ canon_memory_bank_root: canonMemoryBankRoot,
140
+ canon_flow_root: canonFlowRoot,
115
141
  source_commit: sourceCommit,
116
142
  canon_version_at_source_commit: canonVersion,
117
143
  status,
@@ -243,6 +243,9 @@ function migrate(db) {
243
243
  runtime_path TEXT NOT NULL,
244
244
  run_dir TEXT NOT NULL,
245
245
  run_index_path TEXT NOT NULL,
246
+ run_home_path TEXT,
247
+ layout_version TEXT,
248
+ artifact_root_kind TEXT,
246
249
  index_json TEXT NOT NULL,
247
250
  created_at TEXT NOT NULL,
248
251
  updated_at TEXT NOT NULL,
@@ -318,6 +321,9 @@ function migrate(db) {
318
321
  ensureColumn(db, "flow_sessions", "next_action", "ALTER TABLE flow_sessions ADD COLUMN next_action TEXT");
319
322
  ensureColumn(db, "flow_sessions", "metadata_json", "ALTER TABLE flow_sessions ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
320
323
  ensureColumn(db, "flow_sessions", "run_id", "ALTER TABLE flow_sessions ADD COLUMN run_id TEXT");
324
+ ensureColumn(db, "flow_runs", "run_home_path", "ALTER TABLE flow_runs ADD COLUMN run_home_path TEXT");
325
+ ensureColumn(db, "flow_runs", "layout_version", "ALTER TABLE flow_runs ADD COLUMN layout_version TEXT");
326
+ ensureColumn(db, "flow_runs", "artifact_root_kind", "ALTER TABLE flow_runs ADD COLUMN artifact_root_kind TEXT");
321
327
  }
322
328
  function ensureColumn(db, table, column, sql) {
323
329
  const columns = db.prepare(`PRAGMA table_info(${table})`).all();