@deksden-com/dd-flow-cli 0.3.0 → 0.4.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 (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +116 -18
  5. package/dist/cli/run-cli.js +361 -29
  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/branch-context.js +254 -0
  16. package/dist/services/cleanup.js +31 -0
  17. package/dist/services/cli-operation-classifier.js +104 -0
  18. package/dist/services/compatibility-preflight.js +127 -0
  19. package/dist/services/config.js +6 -0
  20. package/dist/services/dashboard-targets.js +95 -0
  21. package/dist/services/dashboard.js +376 -59
  22. package/dist/services/engines.js +532 -0
  23. package/dist/services/flow-guidance.js +8 -1
  24. package/dist/services/hooks.js +1 -1
  25. package/dist/services/lanes.js +333 -1
  26. package/dist/services/merge-queue.js +310 -15
  27. package/dist/services/merge-worker.js +44 -3
  28. package/dist/services/migrations.js +231 -0
  29. package/dist/services/project-summary.js +122 -0
  30. package/dist/services/projects.js +41 -6
  31. package/dist/services/protocol-lifecycle.js +144 -0
  32. package/dist/services/protocols.js +34 -7
  33. package/dist/services/sessions.js +21 -4
  34. package/dist/services/status.js +10 -0
  35. package/dist/services/version-status.js +39 -15
  36. package/dist/storage/database.js +25 -0
  37. package/dist/storage/paths.js +12 -0
  38. package/package.json +3 -2
@@ -14,6 +14,8 @@ 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";
18
+ import { getProtocolBranchContext } from "./branch-context.js";
17
19
  export function registerProtocol(context, input) {
18
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
19
21
  const project = requireProjectByRoot(context, projectRoot);
@@ -88,14 +90,17 @@ export function getProtocolStatus(context, input) {
88
90
  const plan = readPlanFile(protocol.plan_path);
89
91
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
90
92
  const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
93
+ const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
91
94
  return {
92
95
  ok: true,
93
- protocol: protocolStatusPayload(protocol),
94
- diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics],
96
+ protocol: { ...protocolStatusPayload(protocol), lifecycle },
97
+ lifecycle,
98
+ diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
95
99
  latest_run: runDiagnostics.latest_run,
96
100
  state,
97
101
  plan: plan ? summarizePlan(plan) : state.plan,
98
102
  merge_queue: queue,
103
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
99
104
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
100
105
  worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
101
106
  hook_status: hookStatusForProject(context, protocol.project_id),
@@ -173,6 +178,9 @@ export function implementProtocol(context, input) {
173
178
  if (input.force && (!input.reason || input.reason.trim().length === 0)) {
174
179
  throw new AppError("validation", "Forced protocol implement preflight requires --reason", 2);
175
180
  }
181
+ const lifecycle = protocol
182
+ ? normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null })
183
+ : normalizeProtocolLifecycle({ rawStage: "unregistered", rawStatus: "missing" });
176
184
  const currentStage = state?.stage ?? "unregistered";
177
185
  const recommendedPrompt = promptForImplementationStage(currentStage);
178
186
  return {
@@ -197,6 +205,8 @@ export function implementProtocol(context, input) {
197
205
  blockers,
198
206
  active_sessions: activeSessions,
199
207
  merge_queue: queue ?? null,
208
+ branch_context: protocol ? getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context : null,
209
+ lifecycle,
200
210
  terminal,
201
211
  related_context: document.related_context,
202
212
  coding_standards_sources: document.coding_standards_sources,
@@ -274,6 +284,7 @@ export function transitionProtocol(context, input) {
274
284
  to,
275
285
  forced: input.force,
276
286
  state: nextState,
287
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: queue?.status ?? null }),
277
288
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null })
278
289
  };
279
290
  }
@@ -298,6 +309,8 @@ export function readyForMerge(context, input) {
298
309
  protocol_id: protocol.id,
299
310
  queue_status: existingQueueJob.status,
300
311
  state,
312
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
313
+ lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
301
314
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
302
315
  };
303
316
  }
@@ -349,6 +362,8 @@ export function readyForMerge(context, input) {
349
362
  protocol_id: protocol.id,
350
363
  queue_status: "ready",
351
364
  state: nextState,
365
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
366
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
352
367
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
353
368
  };
354
369
  }
@@ -909,11 +924,17 @@ function protocolSetMemberStatus(context, projectId, document) {
909
924
  const projectRoot = projectRootFromProtocolFile(document.path);
910
925
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
911
926
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, document.id, state);
927
+ const lifecycle = normalizeProtocolLifecycle({
928
+ state,
929
+ rawStage: runtime?.stage ?? "unregistered",
930
+ rawStatus: runtime?.status ?? "missing",
931
+ queueStatus: queue?.status ?? null
932
+ });
912
933
  const setStatus = terminal.success
913
934
  ? "done"
914
935
  : queue?.status === "claimed" || activeSessions.length > 0
915
936
  ? "claimed"
916
- : state && ["implementation", "readiness", "plan", "specify", "integration", "queued_for_merge", "ready_for_merge"].includes(state.stage)
937
+ : state && ["specify", "plan", "code", "merge"].includes(lifecycle.stage)
917
938
  ? "running"
918
939
  : blockers.some((blocker) => !blocker.resolved)
919
940
  ? "blocked"
@@ -926,8 +947,9 @@ function protocolSetMemberStatus(context, projectId, document) {
926
947
  blocked_by_protocols: document.blocked_by_protocols,
927
948
  blockers,
928
949
  runtime: runtime
929
- ? { registered: true, status: runtime.status, stage: runtime.stage, next_action: runtime.next_action, updated_at: runtime.updated_at }
950
+ ? { registered: true, status: runtime.status, stage: runtime.stage, lifecycle, next_action: runtime.next_action, updated_at: runtime.updated_at }
930
951
  : { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
952
+ lifecycle,
931
953
  active_sessions: activeSessions,
932
954
  merge_queue: queue ?? null,
933
955
  terminal
@@ -964,22 +986,25 @@ function blockerStatus(context, projectId, projectRoot, blockerId) {
964
986
  const runtime = findProtocol(context, blockerId);
965
987
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
966
988
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
989
+ const lifecycle = normalizeProtocolLifecycle({ state, rawStage: runtime?.stage, rawStatus: runtime?.status });
967
990
  return {
968
991
  id: blockerId,
969
992
  resolved: terminal.success,
970
993
  source: terminal.source,
971
994
  status: runtime?.status ?? terminal.status ?? "unknown",
972
995
  stage: runtime?.stage ?? terminal.stage ?? "unknown",
973
- lifecycle: terminal.lifecycle,
996
+ lifecycle,
997
+ document_lifecycle: terminal.lifecycle,
974
998
  reason: terminal.reason
975
999
  };
976
1000
  }
977
1001
  function protocolTerminalStatus(context, projectId, projectRoot, protocolId, state) {
978
1002
  if (state) {
979
- if (state.stage === "closed" || state.status === "closed") {
1003
+ const lifecycle = normalizeProtocolLifecycle({ state });
1004
+ if (lifecycleIsTerminalSuccess(lifecycle)) {
980
1005
  return { terminal: true, success: true, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is closed" };
981
1006
  }
982
- if (state.stage === "cancelled" || state.status === "cancelled") {
1007
+ if (lifecycleIsTerminalFailure(lifecycle)) {
983
1008
  return { terminal: true, success: false, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is cancelled" };
984
1009
  }
985
1010
  }
@@ -1089,6 +1114,7 @@ function objectOrExisting(value, existing) {
1089
1114
  return value && typeof value === "object" && !Array.isArray(value) ? value : existing;
1090
1115
  }
1091
1116
  function protocolStatusPayload(protocol) {
1117
+ const lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
1092
1118
  return {
1093
1119
  id: protocol.id,
1094
1120
  handshake_id: protocol.handshake_id,
@@ -1096,6 +1122,7 @@ function protocolStatusPayload(protocol) {
1096
1122
  project_root: protocol.project_root,
1097
1123
  status: protocol.status,
1098
1124
  stage: protocol.stage,
1125
+ lifecycle,
1099
1126
  next_action: protocol.next_action,
1100
1127
  route: JSON.parse(protocol.route_json),
1101
1128
  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.4.0",
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",