@deksden-com/dd-flow-cli 0.4.2 → 0.6.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 (53) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/README.md +2 -2
  3. package/dist/build-info.json +6 -6
  4. package/dist/cli/help.js +16 -4
  5. package/dist/cli/run-cli.js +42 -17
  6. package/dist/domain/flow-contract.js +82 -3
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/domain/validation.js +56 -28
  9. package/dist/protocol/local-files.js +1 -16
  10. package/dist/schemas/code-stage-report.schema.json +2 -2
  11. package/dist/schemas/flow-contract.schema.json +152 -94
  12. package/dist/schemas/flow-run.schema.json +129 -23
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  15. package/dist/schemas/merge-stage-report.schema.json +2 -2
  16. package/dist/schemas/plan-stage-report.schema.json +38 -335
  17. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  18. package/dist/schemas/protocol-plan.schema.json +197 -0
  19. package/dist/schemas/release-impact.schema.json +9 -5
  20. package/dist/schemas/session-usage.schema.json +16 -0
  21. package/dist/schemas/stage-finish-input.schema.json +20 -0
  22. package/dist/schemas/stage-prompt.schema.json +35 -0
  23. package/dist/schemas/stage-report.schema.json +20 -0
  24. package/dist/schemas/stage-start-response.schema.json +30 -0
  25. package/dist/schemas/timeline-event.schema.json +29 -0
  26. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  27. package/dist/services/branch-context.js +9 -4
  28. package/dist/services/cleanup.js +77 -0
  29. package/dist/services/dashboard.js +51 -26
  30. package/dist/services/engines.js +84 -18
  31. package/dist/services/hooks.js +101 -250
  32. package/dist/services/memory-permissions.js +77 -69
  33. package/dist/services/migrations.js +1 -1
  34. package/dist/services/plan-runtime.js +124 -0
  35. package/dist/services/plans.js +22 -84
  36. package/dist/services/projects.js +2 -1
  37. package/dist/services/prompts.js +26 -21
  38. package/dist/services/protocols.js +29 -25
  39. package/dist/services/run-projection.js +93 -13
  40. package/dist/services/runs.js +128 -61
  41. package/dist/services/schema-validation.js +168 -7
  42. package/dist/services/sessions.js +97 -73
  43. package/dist/services/stage-lifecycle.js +737 -0
  44. package/dist/services/tooling.js +285 -0
  45. package/dist/services/usage.js +183 -30
  46. package/dist/services/version-status.js +1 -1
  47. package/dist/services/worktrees.js +88 -39
  48. package/dist/storage/database.js +72 -30
  49. package/dist/storage/paths.js +0 -9
  50. package/package.json +14 -13
  51. package/tools/worktrunk-manifest.json +34 -0
  52. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  53. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -12,6 +12,7 @@ import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsF
12
12
  import { activeFlowSessionsForProject } from "./sessions.js";
13
13
  import { usageForRun } from "./usage.js";
14
14
  import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
15
+ import { planSummary, planWithProgress } from "./plan-runtime.js";
15
16
  export function getCmuxStatus(context, input) {
16
17
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
17
18
  return { ok: true, project_root: project.root, cmux: detectCmux(context) };
@@ -626,14 +627,20 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
626
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
627
628
  FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
628
629
  ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
629
- const primaryRun = runs.find((run) => run.status === "running") ?? runs[0];
630
+ const warnings = [...runDiagnostics.diagnostics];
631
+ const canonicalRuns = runs.filter((run) => {
632
+ if (isCanonicalRun(run))
633
+ return true;
634
+ warnings.push({ code: "legacy_run_ignored", run_id: run.id });
635
+ return false;
636
+ });
637
+ const primaryRun = lifecycle.terminal ? canonicalRuns[0] : canonicalRuns.find((run) => run.status === "running") ?? canonicalRuns[0];
630
638
  const observability = primaryRun
631
639
  ? usageForRun(context, { projectId: project.id, runId: primaryRun.id, groupBy: "role" })
632
640
  : { status: "not_observable", reason: "no_protocol_run" };
633
- const warnings = [...runDiagnostics.diagnostics];
634
641
  const protocolSetBoard = protocolSetBoardForProtocol(context, project.id, project.root, protocolId);
635
642
  const reviewRuns = recentReviewRunsForProtocol(context, project.id, protocolId, 8);
636
- const runHistory = runs.map((run) => {
643
+ const runHistory = canonicalRuns.map((run) => {
637
644
  const index = safeRunIndex(run.index_json, warnings, run.id);
638
645
  return {
639
646
  id: run.id,
@@ -707,10 +714,10 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
707
714
  run_history: runHistory,
708
715
  stage_pipeline: latestStages(runHistory),
709
716
  metrics: [
710
- metric("runs", runs.length, "known", "flow_runs"),
717
+ metric("runs", canonicalRuns.length, "known", "flow_runs"),
711
718
  metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
712
719
  metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
713
- metric("completed_runs", runs.filter((run) => run.status === "done").length, "known", "flow_runs"),
720
+ metric("completed_runs", canonicalRuns.filter((run) => run.status === "done").length, "known", "flow_runs"),
714
721
  metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
715
722
  ],
716
723
  warnings
@@ -881,34 +888,39 @@ function protocolsForProject(context, projectId) {
881
888
  FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
882
889
  }
883
890
  function planSummaryForProtocol(context, projectId, protocolId) {
884
- const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
885
- if (!row) {
891
+ const protocol = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
892
+ if (!protocol) {
886
893
  return { plan_id: "none", total: 0, done: 0, blocked: 0 };
887
894
  }
888
- const plan = JSON.parse(row.plan_json);
889
- const items = Array.isArray(plan.items) ? plan.items : [];
890
- return {
891
- plan_id: plan.plan_id ?? "unknown",
892
- total: items.length,
893
- done: items.filter((item) => item.status === "done").length,
894
- blocked: items.filter((item) => item.status === "blocked").length
895
- };
895
+ try {
896
+ const summary = planSummary(context, { projectId, protocolId, planPath: protocol.plan_path });
897
+ return { plan_id: summary.plan_id, total: summary.total, done: summary.done, blocked: summary.blocked };
898
+ }
899
+ catch {
900
+ return { plan_id: "stale", total: 0, done: 0, blocked: 0 };
901
+ }
896
902
  }
897
903
  function activePlanItems(context, projectId, protocols) {
898
904
  const items = [];
899
905
  for (const protocol of protocols) {
900
- const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocol.id]);
901
- if (!row) {
906
+ const record = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocol.id]);
907
+ if (!record) {
902
908
  continue;
903
909
  }
904
- const plan = JSON.parse(row.plan_json);
905
- for (const item of Array.isArray(plan.items) ? plan.items : []) {
910
+ let plan;
911
+ try {
912
+ plan = planWithProgress(context, { projectId, protocolId: protocol.id, planPath: record.plan_path }).plan;
913
+ }
914
+ catch {
915
+ continue;
916
+ }
917
+ for (const item of plan.items) {
906
918
  if (["in_progress", "blocked"].includes(item.status ?? "")) {
907
919
  items.push({
908
920
  protocol: protocol.id,
909
- id: item.id ?? "unknown",
910
- status: item.status ?? "unknown",
911
- summary: item.block_reason ?? item.summary ?? item.title ?? ""
921
+ id: item.id,
922
+ status: item.status,
923
+ summary: item.block_reason ?? item.summary ?? item.title
912
924
  });
913
925
  }
914
926
  }
@@ -1040,9 +1052,10 @@ function buildProtocolCard(context, project, protocol, generatePage) {
1040
1052
  const summary = planSummaryForProtocol(context, project.id, protocol.id);
1041
1053
  const activeDefCount = jsonArray(protocol.active_def_json).length;
1042
1054
  const blockerCount = jsonArray(protocol.blockers_json).length;
1043
- const latestRun = context.db.get(`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
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
1044
1056
  FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1045
- ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
1057
+ ORDER BY updated_at DESC, id DESC`, [project.id, protocol.id]);
1058
+ const latestRun = runs.find((run) => isCanonicalRun(run));
1046
1059
  let diagnostics = [];
1047
1060
  let lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
1048
1061
  try {
@@ -1242,10 +1255,19 @@ function safeRunIndex(text, warnings, runId) {
1242
1255
  return null;
1243
1256
  }
1244
1257
  }
1258
+ function isCanonicalRun(run) {
1259
+ try {
1260
+ const parsed = JSON.parse(run.index_json);
1261
+ return parsed?.schema_id === "dd-flow/flow-run@2";
1262
+ }
1263
+ catch {
1264
+ return false;
1265
+ }
1266
+ }
1245
1267
  function stageLink(projectRoot, run, stage) {
1246
1268
  const item = stage && typeof stage === "object" && !Array.isArray(stage) ? stage : {};
1247
1269
  const report = typeof item.stage_report === "string" ? item.stage_report : undefined;
1248
- const href = report ? path.join(path.dirname(run.run_index_path), report) : "";
1270
+ const href = report ? runArtifactPath(run.run_index_path, report) : "";
1249
1271
  return {
1250
1272
  order: typeof item.order === "number" ? item.order : 0,
1251
1273
  stage: String(item.stage ?? "unknown"),
@@ -1253,10 +1275,13 @@ function stageLink(projectRoot, run, stage) {
1253
1275
  status: String(item.status ?? "unknown"),
1254
1276
  display_status: normalizeDisplayStatus(String(item.status ?? "unknown")),
1255
1277
  report: report ? link("Stage report", href, fs.existsSync(href) ? "available" : "missing", fs.existsSync(href) ? undefined : "stage_report_missing") : link("Stage report", "", "pending", "stage_report_not_created_yet"),
1256
- data: typeof item.data === "string" ? path.join(path.dirname(run.run_index_path), item.data) : null,
1278
+ data: typeof item.data === "string" ? runArtifactPath(run.run_index_path, item.data) : null,
1257
1279
  run_index: path.relative(projectRoot, run.run_index_path)
1258
1280
  };
1259
1281
  }
1282
+ function runArtifactPath(runIndexPath, artifact) {
1283
+ return path.isAbsolute(artifact) ? artifact : path.resolve(path.dirname(runIndexPath), artifact);
1284
+ }
1260
1285
  function latestStages(runHistory) {
1261
1286
  const byStage = new Map();
1262
1287
  for (const run of runHistory) {
@@ -1,7 +1,8 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import { spawn } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
5
6
  import { createRequire } from "node:module";
6
7
  import { getCliBuildInfo } from "./build-info.js";
7
8
  import { AppError } from "../shared/errors.js";
@@ -34,12 +35,12 @@ export function installCurrentEngine(context, input = {}) {
34
35
  const target = engineVersionRoot(context.ddFlowHome, build.package_name, build.version);
35
36
  const manifestPath = path.join(target, "engine.json");
36
37
  const existing = readManifest(manifestPath);
37
- if (existing && !input.force && manifestHealthy(existing)) {
38
+ if (existing && !input.force && (input.forRouting || manifestHealthy(existing))) {
38
39
  return {
39
40
  ok: true,
40
41
  action: "engine_install",
41
42
  changed: false,
42
- engine: manifestSummary(existing),
43
+ ...(input.forRouting ? {} : { engine: manifestSummary(existing) }),
43
44
  path: target
44
45
  };
45
46
  }
@@ -47,12 +48,12 @@ export function installCurrentEngine(context, input = {}) {
47
48
  acquireInstallLock(lockDir);
48
49
  try {
49
50
  const afterLock = readManifest(manifestPath);
50
- if (afterLock && !input.force && manifestHealthy(afterLock)) {
51
+ if (afterLock && !input.force && (input.forRouting || manifestHealthy(afterLock))) {
51
52
  return {
52
53
  ok: true,
53
54
  action: "engine_install",
54
55
  changed: false,
55
- engine: manifestSummary(afterLock),
56
+ ...(input.forRouting ? {} : { engine: manifestSummary(afterLock) }),
56
57
  path: target
57
58
  };
58
59
  }
@@ -64,14 +65,21 @@ export function installCurrentEngine(context, input = {}) {
64
65
  copyPackageSnapshot(packageRoot, tmp);
65
66
  const manifest = buildManifest(context, build.package_name, build.version, packageRoot, target, tmp);
66
67
  fs.writeFileSync(path.join(tmp, "engine.json"), `${JSON.stringify(manifest, null, 2)}\n`);
67
- fs.rmSync(target, { recursive: true, force: true });
68
- fs.renameSync(tmp, target);
68
+ const stagedManifest = requireManifest(path.join(tmp, "engine.json"));
69
+ const stagedDiagnostics = manifestDiagnostics({ ...stagedManifest, package_root: tmp, snapshot_root: tmp }, { checkEntrypoint: !input.forRouting });
70
+ if (stagedDiagnostics.length > 0) {
71
+ throw new AppError("engine_install_failed", "Staged dd-flow engine failed its integrity or dependency health check", 1, {
72
+ diagnostics: stagedDiagnostics,
73
+ path: tmp
74
+ });
75
+ }
76
+ activateEngineAtomically(tmp, target);
69
77
  const installed = requireManifest(path.join(target, "engine.json"));
70
78
  return {
71
79
  ok: true,
72
80
  action: "engine_install",
73
81
  changed: true,
74
- engine: manifestSummary(installed),
82
+ ...(input.forRouting ? {} : { engine: manifestSummary(installed) }),
75
83
  path: target
76
84
  };
77
85
  }
@@ -121,8 +129,8 @@ export function doctorEngines(context, input = {}) {
121
129
  export function routeArgsThroughEngine(context, args, io, stdin, env, resolvedProjectRoot) {
122
130
  if (isEngineMode(env) || isRouterNativeCommand(args))
123
131
  return Promise.resolve(null);
124
- installCurrentEngine(context);
125
- const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveOperationProjectRoot(context, args) ?? undefined });
132
+ installCurrentEngine(context, { forRouting: true });
133
+ const selection = selectEngine(context, { projectRoot: resolvedProjectRoot ?? resolveOperationProjectRoot(context, args) ?? undefined }, { allowCurrentInProcess: true });
126
134
  if (selection.status === "missing") {
127
135
  const classification = classifyCliOperation(args, env);
128
136
  if (classification.mode === "read_only_diagnostics" || classification.mode === "mb_upgrade") {
@@ -148,7 +156,7 @@ export function engineRoutingMetadata(env) {
148
156
  routed_from: env.DD_FLOW_ROUTED_FROM ?? null
149
157
  };
150
158
  }
151
- export function selectEngine(context, input) {
159
+ export function selectEngine(context, input, options = {}) {
152
160
  const compatibility = readCompatibilityForProject(input.projectRoot);
153
161
  const build = getCliBuildInfo();
154
162
  const packageName = stringValue(compatibility?.engine?.package_name) ?? build.package_name;
@@ -158,7 +166,11 @@ export function selectEngine(context, input) {
158
166
  const manifests = readInstalledManifests(engineStoreRoot(context.ddFlowHome))
159
167
  .filter((manifest) => manifest.package_name === packageName)
160
168
  .filter((manifest) => satisfiesRange(manifest.package_version, requiredRange))
161
- .filter((manifest) => manifestHealthy(manifest))
169
+ .filter((manifest) => options.allowCurrentInProcess
170
+ && manifest.package_name === build.package_name
171
+ && manifest.package_version === build.version
172
+ ? true
173
+ : manifestHealthy(manifest))
162
174
  .sort((a, b) => compareSemverStrings(b.package_version, a.package_version));
163
175
  const selected = manifests[0] ?? null;
164
176
  return {
@@ -275,13 +287,13 @@ function buildManifest(context, packageName, version, packageRoot, snapshotRoot,
275
287
  },
276
288
  integrity: {
277
289
  source: "package_snapshot",
278
- checksum: fileListChecksum(checksumRoot),
279
- mode: "file_list"
290
+ checksum: engineSnapshotChecksum(checksumRoot),
291
+ mode: "full_content"
280
292
  }
281
293
  };
282
294
  }
283
295
  function copyPackageSnapshot(packageRoot, target) {
284
- for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md"]) {
296
+ for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md", "tools"]) {
285
297
  const from = path.join(packageRoot, item);
286
298
  if (!fs.existsSync(from))
287
299
  continue;
@@ -441,7 +453,7 @@ function readManifest(file) {
441
453
  function manifestHealthy(manifest) {
442
454
  return manifestDiagnostics(manifest).length === 0;
443
455
  }
444
- function manifestDiagnostics(manifest) {
456
+ function manifestDiagnostics(manifest, options = {}) {
445
457
  const diagnostics = [];
446
458
  if (manifest.schema_id !== engineManifestSchemaId)
447
459
  diagnostics.push("schema_id_mismatch");
@@ -455,6 +467,34 @@ function manifestDiagnostics(manifest) {
455
467
  diagnostics.push("entrypoint_outside_snapshot");
456
468
  else if (!fs.existsSync(entrypoint))
457
469
  diagnostics.push("entrypoint_missing");
470
+ if (!manifest.integrity || manifest.integrity.mode !== "full_content" || !manifest.integrity.checksum) {
471
+ diagnostics.push("content_checksum_missing");
472
+ }
473
+ else if (fs.existsSync(snapshotRoot)) {
474
+ try {
475
+ if (engineSnapshotChecksum(snapshotRoot) !== manifest.integrity.checksum)
476
+ diagnostics.push("content_checksum_mismatch");
477
+ }
478
+ catch {
479
+ diagnostics.push("snapshot_unreadable");
480
+ }
481
+ }
482
+ if (options.checkEntrypoint !== false && entrypoint && fs.existsSync(entrypoint) && !diagnostics.includes("entrypoint_outside_snapshot")) {
483
+ const health = spawnSync(process.execPath, [entrypoint, "--version", "--json"], {
484
+ cwd: snapshotRoot,
485
+ env: {
486
+ ...process.env,
487
+ DD_FLOW_ENGINE_MODE: "1",
488
+ DD_FLOW_ENGINE_HOME: snapshotRoot,
489
+ DD_FLOW_ENGINE_HEALTHCHECK: "1"
490
+ },
491
+ encoding: "utf8",
492
+ stdio: ["ignore", "pipe", "pipe"],
493
+ timeout: 15_000
494
+ });
495
+ if (health.status !== 0)
496
+ diagnostics.push("entrypoint_dependency_load_failed");
497
+ }
458
498
  return diagnostics;
459
499
  }
460
500
  function manifestSummary(manifest) {
@@ -592,10 +632,17 @@ function findPackageRoot(start) {
592
632
  current = parent;
593
633
  }
594
634
  }
595
- function fileListChecksum(root) {
635
+ export function engineSnapshotChecksum(root) {
596
636
  const files = [];
597
637
  collectFiles(root, root, files);
598
- return Buffer.from(files.sort().join("\n")).toString("base64url");
638
+ const checksum = crypto.createHash("sha256");
639
+ for (const file of files.sort()) {
640
+ checksum.update(file);
641
+ checksum.update("\0");
642
+ checksum.update(fs.readFileSync(path.join(root, file)));
643
+ checksum.update("\0");
644
+ }
645
+ return checksum.digest("hex");
599
646
  }
600
647
  function collectFiles(root, current, files) {
601
648
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
@@ -608,6 +655,25 @@ function collectFiles(root, current, files) {
608
655
  files.push(path.relative(root, full));
609
656
  }
610
657
  }
658
+ function activateEngineAtomically(staging, target) {
659
+ const previous = `${target}.previous-${process.pid}-${Date.now()}`;
660
+ const hadPrevious = fs.existsSync(target);
661
+ if (hadPrevious)
662
+ fs.renameSync(target, previous);
663
+ try {
664
+ fs.renameSync(staging, target);
665
+ }
666
+ catch (error) {
667
+ if (hadPrevious && !fs.existsSync(target) && fs.existsSync(previous))
668
+ fs.renameSync(previous, target);
669
+ throw new AppError("engine_install_failed", "Atomic engine activation failed; previous engine was preserved", 1, {
670
+ target,
671
+ cause: String(error)
672
+ });
673
+ }
674
+ if (hadPrevious)
675
+ fs.rmSync(previous, { recursive: true, force: true });
676
+ }
611
677
  function stringValue(value) {
612
678
  return typeof value === "string" && value.length > 0 ? value : null;
613
679
  }