@deksden-com/dd-flow-cli 0.3.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 (37) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +103 -15
  5. package/dist/cli/run-cli.js +307 -27
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/cleanup.js +31 -0
  16. package/dist/services/cli-operation-classifier.js +104 -0
  17. package/dist/services/compatibility-preflight.js +124 -0
  18. package/dist/services/config.js +6 -0
  19. package/dist/services/dashboard-targets.js +95 -0
  20. package/dist/services/dashboard.js +376 -59
  21. package/dist/services/engines.js +532 -0
  22. package/dist/services/flow-guidance.js +8 -1
  23. package/dist/services/hooks.js +1 -1
  24. package/dist/services/lanes.js +333 -1
  25. package/dist/services/merge-queue.js +97 -15
  26. package/dist/services/merge-worker.js +36 -2
  27. package/dist/services/migrations.js +231 -0
  28. package/dist/services/project-summary.js +122 -0
  29. package/dist/services/projects.js +41 -6
  30. package/dist/services/protocol-lifecycle.js +144 -0
  31. package/dist/services/protocols.js +29 -7
  32. package/dist/services/sessions.js +21 -4
  33. package/dist/services/status.js +10 -0
  34. package/dist/services/version-status.js +39 -15
  35. package/dist/storage/database.js +25 -0
  36. package/dist/storage/paths.js +12 -0
  37. package/package.json +3 -2
@@ -14,6 +14,7 @@ import { requireProjectByRoot } from "./projects.js";
14
14
  import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
15
15
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
16
16
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
17
+ import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
17
18
  export function registerProtocol(context, input) {
18
19
  const projectRoot = resolveProjectRoot(input.projectRoot);
19
20
  const project = requireProjectByRoot(context, projectRoot);
@@ -88,10 +89,12 @@ export function getProtocolStatus(context, input) {
88
89
  const plan = readPlanFile(protocol.plan_path);
89
90
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
90
91
  const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
92
+ const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
91
93
  return {
92
94
  ok: true,
93
- protocol: protocolStatusPayload(protocol),
94
- diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics],
95
+ protocol: { ...protocolStatusPayload(protocol), lifecycle },
96
+ lifecycle,
97
+ diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
95
98
  latest_run: runDiagnostics.latest_run,
96
99
  state,
97
100
  plan: plan ? summarizePlan(plan) : state.plan,
@@ -173,6 +176,9 @@ export function implementProtocol(context, input) {
173
176
  if (input.force && (!input.reason || input.reason.trim().length === 0)) {
174
177
  throw new AppError("validation", "Forced protocol implement preflight requires --reason", 2);
175
178
  }
179
+ const lifecycle = protocol
180
+ ? normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null })
181
+ : normalizeProtocolLifecycle({ rawStage: "unregistered", rawStatus: "missing" });
176
182
  const currentStage = state?.stage ?? "unregistered";
177
183
  const recommendedPrompt = promptForImplementationStage(currentStage);
178
184
  return {
@@ -197,6 +203,7 @@ export function implementProtocol(context, input) {
197
203
  blockers,
198
204
  active_sessions: activeSessions,
199
205
  merge_queue: queue ?? null,
206
+ lifecycle,
200
207
  terminal,
201
208
  related_context: document.related_context,
202
209
  coding_standards_sources: document.coding_standards_sources,
@@ -274,6 +281,7 @@ export function transitionProtocol(context, input) {
274
281
  to,
275
282
  forced: input.force,
276
283
  state: nextState,
284
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: queue?.status ?? null }),
277
285
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null })
278
286
  };
279
287
  }
@@ -298,6 +306,7 @@ export function readyForMerge(context, input) {
298
306
  protocol_id: protocol.id,
299
307
  queue_status: existingQueueJob.status,
300
308
  state,
309
+ lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
301
310
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
302
311
  };
303
312
  }
@@ -349,6 +358,7 @@ export function readyForMerge(context, input) {
349
358
  protocol_id: protocol.id,
350
359
  queue_status: "ready",
351
360
  state: nextState,
361
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
352
362
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
353
363
  };
354
364
  }
@@ -909,11 +919,17 @@ function protocolSetMemberStatus(context, projectId, document) {
909
919
  const projectRoot = projectRootFromProtocolFile(document.path);
910
920
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
911
921
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, document.id, state);
922
+ const lifecycle = normalizeProtocolLifecycle({
923
+ state,
924
+ rawStage: runtime?.stage ?? "unregistered",
925
+ rawStatus: runtime?.status ?? "missing",
926
+ queueStatus: queue?.status ?? null
927
+ });
912
928
  const setStatus = terminal.success
913
929
  ? "done"
914
930
  : queue?.status === "claimed" || activeSessions.length > 0
915
931
  ? "claimed"
916
- : state && ["implementation", "readiness", "plan", "specify", "integration", "queued_for_merge", "ready_for_merge"].includes(state.stage)
932
+ : state && ["specify", "plan", "code", "merge"].includes(lifecycle.stage)
917
933
  ? "running"
918
934
  : blockers.some((blocker) => !blocker.resolved)
919
935
  ? "blocked"
@@ -926,8 +942,9 @@ function protocolSetMemberStatus(context, projectId, document) {
926
942
  blocked_by_protocols: document.blocked_by_protocols,
927
943
  blockers,
928
944
  runtime: runtime
929
- ? { registered: true, status: runtime.status, stage: runtime.stage, next_action: runtime.next_action, updated_at: runtime.updated_at }
945
+ ? { registered: true, status: runtime.status, stage: runtime.stage, lifecycle, next_action: runtime.next_action, updated_at: runtime.updated_at }
930
946
  : { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
947
+ lifecycle,
931
948
  active_sessions: activeSessions,
932
949
  merge_queue: queue ?? null,
933
950
  terminal
@@ -964,22 +981,25 @@ function blockerStatus(context, projectId, projectRoot, blockerId) {
964
981
  const runtime = findProtocol(context, blockerId);
965
982
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
966
983
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
984
+ const lifecycle = normalizeProtocolLifecycle({ state, rawStage: runtime?.stage, rawStatus: runtime?.status });
967
985
  return {
968
986
  id: blockerId,
969
987
  resolved: terminal.success,
970
988
  source: terminal.source,
971
989
  status: runtime?.status ?? terminal.status ?? "unknown",
972
990
  stage: runtime?.stage ?? terminal.stage ?? "unknown",
973
- lifecycle: terminal.lifecycle,
991
+ lifecycle,
992
+ document_lifecycle: terminal.lifecycle,
974
993
  reason: terminal.reason
975
994
  };
976
995
  }
977
996
  function protocolTerminalStatus(context, projectId, projectRoot, protocolId, state) {
978
997
  if (state) {
979
- if (state.stage === "closed" || state.status === "closed") {
998
+ const lifecycle = normalizeProtocolLifecycle({ state });
999
+ if (lifecycleIsTerminalSuccess(lifecycle)) {
980
1000
  return { terminal: true, success: true, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is closed" };
981
1001
  }
982
- if (state.stage === "cancelled" || state.status === "cancelled") {
1002
+ if (lifecycleIsTerminalFailure(lifecycle)) {
983
1003
  return { terminal: true, success: false, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is cancelled" };
984
1004
  }
985
1005
  }
@@ -1089,6 +1109,7 @@ function objectOrExisting(value, existing) {
1089
1109
  return value && typeof value === "object" && !Array.isArray(value) ? value : existing;
1090
1110
  }
1091
1111
  function protocolStatusPayload(protocol) {
1112
+ const lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
1092
1113
  return {
1093
1114
  id: protocol.id,
1094
1115
  handshake_id: protocol.handshake_id,
@@ -1096,6 +1117,7 @@ function protocolStatusPayload(protocol) {
1096
1117
  project_root: protocol.project_root,
1097
1118
  status: protocol.status,
1098
1119
  stage: protocol.stage,
1120
+ lifecycle,
1099
1121
  next_action: protocol.next_action,
1100
1122
  route: JSON.parse(protocol.route_json),
1101
1123
  workspace: JSON.parse(protocol.workspace_json),
@@ -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));
@@ -5,6 +5,9 @@ import { getCanonStatus } from "./canon.js";
5
5
  import { getCliBuildInfo } from "./build-info.js";
6
6
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
7
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";
8
11
  export function getRuntimeStatus(context, input = {}) {
9
12
  const cwd = process.cwd();
10
13
  const projectRootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd });
@@ -21,6 +24,7 @@ export function getRuntimeStatus(context, input = {}) {
21
24
  const cli = getCliBuildInfo();
22
25
  const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
23
26
  const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
27
+ const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
24
28
  const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
25
29
  return {
26
30
  ok: true,
@@ -43,6 +47,12 @@ export function getRuntimeStatus(context, input = {}) {
43
47
  flow_pack: projectVersionStatus?.flow_pack ?? null,
44
48
  drift: projectVersionStatus?.drift ?? null
45
49
  },
50
+ engine: engineSelection
51
+ ? {
52
+ selection: engineSelection,
53
+ compatibility: compatibilityReport(engineSelection, classifyCliOperation(["status"]))
54
+ }
55
+ : null,
46
56
  canon: {
47
57
  ...(asRecord(canonStatus) ?? {}),
48
58
  resolved: canon ?? null,
@@ -22,7 +22,8 @@ export function getProjectVersionStatus(input) {
22
22
  const flowPack = readFlowPackMetadata(input.projectRoot, memoryBank.root);
23
23
  const memoryBankDrift = compareMemoryBankVersion(memoryBank.version, input.canon?.metadata.version ?? null, memoryBank.status);
24
24
  const flowPackDrift = compareFlowPackCommit(flowPack.source_commit, input.canon?.metadata.commit ?? null, input.canon?.root ?? null, flowPack.status);
25
- const overall = overallDrift(memoryBankDrift, flowPackDrift, memoryBank.status, flowPack.status);
25
+ const flowContractDrift = compareFlowContract(flowPack.flow_contract, input.canon?.metadata.flow_contract ?? null, flowPack.status);
26
+ const overall = overallDrift(memoryBankDrift, flowPackDrift, flowContractDrift, memoryBank.status, flowPack.status);
26
27
  return {
27
28
  root: input.projectRoot,
28
29
  root_source: input.rootSource,
@@ -31,8 +32,9 @@ export function getProjectVersionStatus(input) {
31
32
  drift: {
32
33
  memory_bank_version: memoryBankDrift,
33
34
  flow_pack_commit: flowPackDrift,
35
+ flow_contract: flowContractDrift,
34
36
  overall,
35
- next_action: nextAction(overall, memoryBank.status, flowPack.status)
37
+ next_action: nextAction(overall, memoryBank.status, flowPack.status, flowContractDrift.status)
36
38
  }
37
39
  };
38
40
  }
@@ -67,6 +69,7 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
67
69
  canon_flow_root: null,
68
70
  source_commit: null,
69
71
  canon_version_at_source_commit: null,
72
+ flow_contract: null,
70
73
  status: "missing",
71
74
  reason: "no_memory_bank",
72
75
  canonical_only_files: []
@@ -85,6 +88,7 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
85
88
  canon_flow_root: null,
86
89
  source_commit: null,
87
90
  canon_version_at_source_commit: null,
91
+ flow_contract: null,
88
92
  status: "missing",
89
93
  reason: "flow_pack_manifest_missing",
90
94
  canonical_only_files: canonicalOnlyFiles
@@ -107,6 +111,7 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
107
111
  canon_flow_root: null,
108
112
  source_commit: null,
109
113
  canon_version_at_source_commit: null,
114
+ flow_contract: null,
110
115
  status: "invalid",
111
116
  reason: "flow_pack_manifest_invalid_json",
112
117
  canonical_only_files: canonicalOnlyFiles
@@ -118,19 +123,22 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
118
123
  const canonRoot = stringOrNull(manifest.canon_root);
119
124
  const canonMemoryBankRoot = stringOrNull(manifest.canon_memory_bank_root);
120
125
  const canonFlowRoot = stringOrNull(manifest.canon_flow_root);
121
- const status = schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && canonMemoryBankRoot && canonFlowRoot
126
+ const flowContract = stringOrNull(manifest.flow_contract);
127
+ const status = schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && canonMemoryBankRoot && canonFlowRoot && flowContract
122
128
  ? "present"
123
129
  : schemaId === "dd-flow/project-flow-pack-manifest@1" ||
124
- (schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && (!canonMemoryBankRoot || !canonFlowRoot))
130
+ (schemaId === "dd-flow/project-flow-pack-manifest@2" && canonVersion && (!canonMemoryBankRoot || !canonFlowRoot || !flowContract))
125
131
  ? "degraded"
126
132
  : "invalid";
127
- const reason = status === "degraded" && schemaId === "dd-flow/project-flow-pack-manifest@2"
133
+ const reason = status === "degraded" && schemaId === "dd-flow/project-flow-pack-manifest@2" && (!canonMemoryBankRoot || !canonFlowRoot)
128
134
  ? "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;
135
+ : status === "degraded" && schemaId === "dd-flow/project-flow-pack-manifest@2" && !flowContract
136
+ ? "flow_pack_manifest_missing_flow_contract"
137
+ : status === "degraded"
138
+ ? "legacy_flow_pack_manifest"
139
+ : status === "invalid"
140
+ ? "flow_pack_manifest_schema_unknown"
141
+ : undefined;
134
142
  return {
135
143
  manifest_path: path.relative(projectRoot, manifestPath),
136
144
  schema_id: schemaId,
@@ -140,6 +148,7 @@ function readFlowPackMetadata(projectRoot, memoryBankRoot) {
140
148
  canon_flow_root: canonFlowRoot,
141
149
  source_commit: sourceCommit,
142
150
  canon_version_at_source_commit: canonVersion,
151
+ flow_contract: flowContract,
143
152
  status,
144
153
  ...(reason ? { reason } : {}),
145
154
  canonical_only_files: canonicalOnlyFiles
@@ -216,28 +225,43 @@ function compareFlowPackCommit(current, target, canonRoot, artifactStatus) {
216
225
  target
217
226
  };
218
227
  }
219
- function overallDrift(memoryBank, flowPack, memoryStatus, flowStatus) {
228
+ function compareFlowContract(current, target, artifactStatus) {
229
+ if (artifactStatus === "missing")
230
+ return { status: "missing", confidence: "none", reason: "flow_pack_manifest_missing", current, target };
231
+ if (artifactStatus === "invalid")
232
+ return { status: "invalid", confidence: "none", reason: "flow_pack_manifest_invalid", current, target };
233
+ if (!current)
234
+ return { status: "unknown", confidence: "none", reason: "flow_pack_flow_contract_missing", current, target };
235
+ if (!target)
236
+ return { status: "unknown", confidence: "none", reason: "canon_flow_contract_unknown", current, target };
237
+ if (current === target)
238
+ return { status: "same", confidence: "exact", reason: "flow_contract_equal", current, target };
239
+ return { status: "diverged", confidence: "declared", reason: "flow_contract_mismatch", current, target };
240
+ }
241
+ function overallDrift(memoryBank, flowPack, flowContract, memoryStatus, flowStatus) {
220
242
  if (memoryStatus === "missing")
221
243
  return "not_applicable";
222
- if (memoryBank.status === "invalid" || flowPack.status === "invalid")
244
+ if (memoryBank.status === "invalid" || flowPack.status === "invalid" || flowContract.status === "invalid")
223
245
  return "invalid";
224
- if (memoryBank.status === "missing" || flowPack.status === "missing")
246
+ if (memoryBank.status === "missing" || flowPack.status === "missing" || flowContract.status === "missing")
225
247
  return "missing";
226
248
  if (memoryBank.status === "ahead" || flowPack.status === "ahead")
227
249
  return "ahead";
228
250
  if (memoryBank.status === "behind" || flowPack.status === "behind")
229
251
  return "behind";
230
- if (memoryBank.status === "diverged" || flowPack.status === "diverged")
252
+ if (memoryBank.status === "diverged" || flowPack.status === "diverged" || flowContract.status === "diverged")
231
253
  return "diverged";
232
254
  if (memoryBank.status === "same" && (flowPack.status === "same" || flowStatus === "degraded"))
233
255
  return flowStatus === "degraded" ? "unknown" : "same";
234
256
  return "unknown";
235
257
  }
236
- function nextAction(overall, memoryStatus, flowStatus) {
258
+ function nextAction(overall, memoryStatus, flowStatus, flowContractStatus) {
237
259
  if (overall === "same")
238
260
  return "no_action";
239
261
  if (overall === "not_applicable" && memoryStatus === "missing")
240
262
  return "run_mb_init_if_project_needs_memory_bank";
263
+ if (flowContractStatus === "diverged" || flowContractStatus === "unknown")
264
+ return "run_canonical_mb_upgrade_before_lifecycle_mutations";
241
265
  if (overall === "behind" || flowStatus === "degraded")
242
266
  return "run_canonical_mb_upgrade";
243
267
  if (overall === "missing")
@@ -134,10 +134,34 @@ function migrate(db) {
134
134
  FOREIGN KEY(project_id) REFERENCES projects(id)
135
135
  );
136
136
 
137
+ CREATE TABLE IF NOT EXISTS lane_waiters (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ project_id TEXT NOT NULL,
140
+ lane TEXT NOT NULL,
141
+ worker_id TEXT NOT NULL,
142
+ status TEXT NOT NULL,
143
+ queued_at TEXT NOT NULL,
144
+ acquired_at TEXT,
145
+ cancelled_at TEXT,
146
+ expires_at TEXT,
147
+ reason TEXT,
148
+ metadata_json TEXT NOT NULL,
149
+ created_at TEXT NOT NULL,
150
+ updated_at TEXT NOT NULL,
151
+ FOREIGN KEY(project_id) REFERENCES projects(id)
152
+ );
153
+
137
154
  CREATE UNIQUE INDEX IF NOT EXISTS idx_lane_locks_one_active
138
155
  ON lane_locks(project_id, lane)
139
156
  WHERE status = 'active';
140
157
 
158
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_lane_waiters_one_queued_worker
159
+ ON lane_waiters(project_id, lane, worker_id)
160
+ WHERE status = 'queued';
161
+
162
+ CREATE INDEX IF NOT EXISTS idx_lane_waiters_fifo
163
+ ON lane_waiters(project_id, lane, status, queued_at, id);
164
+
141
165
  CREATE TABLE IF NOT EXISTS hook_installations (
142
166
  project_id TEXT NOT NULL,
143
167
  scope TEXT NOT NULL,
@@ -324,6 +348,7 @@ function migrate(db) {
324
348
  ensureColumn(db, "flow_runs", "run_home_path", "ALTER TABLE flow_runs ADD COLUMN run_home_path TEXT");
325
349
  ensureColumn(db, "flow_runs", "layout_version", "ALTER TABLE flow_runs ADD COLUMN layout_version TEXT");
326
350
  ensureColumn(db, "flow_runs", "artifact_root_kind", "ALTER TABLE flow_runs ADD COLUMN artifact_root_kind TEXT");
351
+ ensureColumn(db, "lane_waiters", "expires_at", "ALTER TABLE lane_waiters ADD COLUMN expires_at TEXT");
327
352
  }
328
353
  function ensureColumn(db, table, column, sql) {
329
354
  const columns = db.prepare(`PRAGMA table_info(${table})`).all();
@@ -75,3 +75,15 @@ export function projectCheckoutRoot(ddFlowHome, projectId) {
75
75
  export function projectFeatureWorktreePath(ddFlowHome, projectId, runOrProtocolId, repoName) {
76
76
  return path.join(projectCheckoutRoot(ddFlowHome, projectId), "worktrees", runOrProtocolId, repoName);
77
77
  }
78
+ export function engineStoreRoot(ddFlowHome) {
79
+ return path.join(ddFlowHome, "engines");
80
+ }
81
+ export function packageEngineRoot(ddFlowHome, packageName) {
82
+ return path.join(engineStoreRoot(ddFlowHome), safePathSegment(packageName));
83
+ }
84
+ export function engineVersionRoot(ddFlowHome, packageName, version) {
85
+ return path.join(packageEngineRoot(ddFlowHome, packageName), safePathSegment(version));
86
+ }
87
+ function safePathSegment(value) {
88
+ return value.replace(/[^a-zA-Z0-9_.@-]+/g, "_");
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
- "README.md"
11
+ "README.md",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "engines": {
14
15
  "node": ">=26.0.0",