@deksden-com/dd-flow-cli 0.5.0 → 0.7.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 (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +10 -3
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +24 -11
  5. package/dist/cli/run-cli.js +117 -28
  6. package/dist/domain/flow-contract.js +15 -4
  7. package/dist/domain/session-coverage.js +88 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-stage-report.schema.json +7 -2
  10. package/dist/schemas/engine-manifest.schema.json +22 -0
  11. package/dist/schemas/flow-contract.schema.json +15 -13
  12. package/dist/schemas/flow-run.schema.json +1 -0
  13. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  14. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  15. package/dist/schemas/run-engine-binding.schema.json +37 -0
  16. package/dist/schemas/stage-prompt.schema.json +9 -5
  17. package/dist/schemas/stage-start-response.schema.json +6 -5
  18. package/dist/services/canon.js +15 -1
  19. package/dist/services/cleanup.js +77 -0
  20. package/dist/services/cli-operation-classifier.js +52 -8
  21. package/dist/services/compatibility-preflight.js +1 -1
  22. package/dist/services/dashboard.js +2 -2
  23. package/dist/services/engines.js +408 -30
  24. package/dist/services/hooks.js +28 -15
  25. package/dist/services/lanes.js +0 -4
  26. package/dist/services/merge-queue.js +48 -0
  27. package/dist/services/merge-worker.js +3 -4
  28. package/dist/services/migrations.js +307 -44
  29. package/dist/services/plan-runtime.js +4 -4
  30. package/dist/services/plans.js +5 -3
  31. package/dist/services/protocols.js +23 -2
  32. package/dist/services/run-engine-bindings.js +157 -0
  33. package/dist/services/run-projection.js +18 -4
  34. package/dist/services/runs.js +54 -7
  35. package/dist/services/schema-validation.js +96 -0
  36. package/dist/services/sessions.js +33 -73
  37. package/dist/services/stage-lifecycle.js +298 -45
  38. package/dist/services/status.js +8 -3
  39. package/dist/storage/database.js +32 -11
  40. package/package.json +1 -1
@@ -107,15 +107,11 @@ export function getCodexHomeStatus(context, input) {
107
107
  const configStatus = managedConfigStatus(record);
108
108
  const hooksStatus = hookFileStatus(record.hooks_path, project.root);
109
109
  const drift = configStatus.drift_status === "none" && hooksStatus.drift_status === "none" ? "none" : "drifted";
110
- const now = context.now();
111
- context.db.run(`UPDATE codex_home_profiles
112
- SET last_checked_at = ?, last_drift_status = ?, updated_at = ?
113
- WHERE project_id = ? AND profile = ?`, [now, drift, now, project.id, profile]);
114
110
  return {
115
111
  ok: true,
116
112
  installed: fs.existsSync(record.target_home),
117
113
  profile,
118
- home: homeProfile(context, project.id, profile),
114
+ home: { ...record, last_drift_status: drift },
119
115
  config: configStatus,
120
116
  hooks: hooksStatus,
121
117
  shared_entries: sharedEntryStatus(record.source_home, record.target_home)
@@ -196,7 +192,7 @@ export function getCodexHooksStatus(context, input) {
196
192
  };
197
193
  }
198
194
  const status = hookFileStatus(location.hooksPath, project.root);
199
- const recorded = hookInstallation(context, project.id, installationScope(target, location.profile));
195
+ const recorded = hookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath));
200
196
  const driftStatus = status.installed ? "none" : recorded?.installed === 1 ? "drifted" : status.drift_status;
201
197
  return {
202
198
  ok: true,
@@ -224,7 +220,7 @@ export function installCodexHooks(context, input) {
224
220
  const nextConfig = mergeHooksConfig(existing, project.root);
225
221
  ensureDir(path.dirname(location.hooksPath));
226
222
  writeJsonFile(location.hooksPath, nextConfig);
227
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, true, "none");
223
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
228
224
  appendAudit(context, {
229
225
  projectId: project.id,
230
226
  eventType: "codex_hooks.installed",
@@ -256,7 +252,7 @@ export function removeCodexHooks(context, input) {
256
252
  const nextConfig = removeManagedHooks(existing, project.root);
257
253
  ensureDir(path.dirname(location.hooksPath));
258
254
  writeJsonFile(location.hooksPath, nextConfig);
259
- recordHookInstallation(context, project.id, installationScope(target, location.profile), location.hooksPath, false, "not_installed");
255
+ recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
260
256
  appendAudit(context, {
261
257
  projectId: project.id,
262
258
  eventType: "codex_hooks.removed",
@@ -277,13 +273,16 @@ export function handleCodexHook(context, input) {
277
273
  if (!command)
278
274
  return { ok: true, observed: false, reason: "non_bash_tool" };
279
275
  const flowPayload = flowSessionPayloadFromRegisterCommand(command);
280
- if (!flowPayload)
276
+ const bootstrapStageStart = /\bdd-flow\s+stage\s+start\b/.test(command) && /(?:^|\s)--bootstrap(?:\s|$)/.test(command);
277
+ if (!flowPayload && !bootstrapStageStart)
281
278
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
282
279
  const project = projectForHook(context, input.projectRoot, stringValue(payload.cwd));
283
280
  if (!project)
284
281
  return { ok: true, observed: false, reason: "unrelated_cwd" };
285
282
  const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
286
- const observedSession = bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined);
283
+ const observedSession = flowPayload
284
+ ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
285
+ : undefined;
287
286
  const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
288
287
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
289
288
  const eventKey = hookEventKey(payload, eventName, toolName, command);
@@ -315,9 +314,23 @@ export function handleCodexHook(context, input) {
315
314
  duplicate: !inserted,
316
315
  event_key: eventKey,
317
316
  session_id: effectiveSessionId,
318
- protocol_id: protocolId
317
+ protocol_id: protocolId,
318
+ ...(sessionId
319
+ ? {
320
+ hookSpecificOutput: {
321
+ hookEventName: "PreToolUse",
322
+ permissionDecision: "allow",
323
+ updatedInput: { command: commandWithSessionId(command, sessionId) }
324
+ }
325
+ }
326
+ : {})
319
327
  };
320
328
  }
329
+ function commandWithSessionId(command, sessionId) {
330
+ if (/(?:^|\s)--session-id(?:=|\s)/.test(command))
331
+ return command;
332
+ return `${command} --session-id ${JSON.stringify(sessionId)}`;
333
+ }
321
334
  export function hookStatusForProject(context, projectId) {
322
335
  return context.db.all(`SELECT scope, config_path, content_hash, installed, drift_status, updated_at
323
336
  FROM hook_installations WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
@@ -460,7 +473,7 @@ function commandHook(command, statusMessage) {
460
473
  return { type: "command", command, timeout: 5, statusMessage };
461
474
  }
462
475
  function hookCommand(event) {
463
- return `dd-flow codex hook handle --event ${event} --json`;
476
+ return `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
464
477
  }
465
478
  function resolveHookTarget(target) {
466
479
  if (!target || target === "isolated") {
@@ -632,8 +645,8 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
632
645
  drift_status = excluded.drift_status,
633
646
  updated_at = excluded.updated_at`, [projectId, scope, configPath, hashObject(readJsonIfExists(configPath) ?? {}), installed ? 1 : 0, driftStatus, now, now]);
634
647
  }
635
- function installationScope(target, profile) {
636
- return target === "isolated" ? `isolated:${profile}` : "default";
648
+ function installationScope(target, profile, hooksPath) {
649
+ return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
637
650
  }
638
651
  function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
639
652
  const existing = sessionBinding(context, project.id, sessionId);
@@ -688,7 +701,7 @@ function projectForHook(context, explicitRoot, cwd) {
688
701
  .sort((left, right) => right.root.length - left.root.length)[0];
689
702
  }
690
703
  function hookEventKey(payload, eventName, toolName, command) {
691
- const explicit = stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
704
+ const explicit = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
692
705
  if (explicit)
693
706
  return explicit;
694
707
  return crypto.createHash("sha256").update(JSON.stringify({
@@ -10,8 +10,6 @@ const defaultTtlSeconds = 300;
10
10
  const defaultPollIntervalSeconds = 10;
11
11
  export function getLaneStatus(context, input) {
12
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
13
- expireStaleLocks(context, project.id);
14
- expireStaleWaiters(context, project.id);
15
13
  return {
16
14
  ok: true,
17
15
  lanes: lanesForProject(context, project.id, input.lane),
@@ -179,8 +177,6 @@ export async function waitForLaneLock(context, input) {
179
177
  }
180
178
  export function getLaneWaiters(context, input) {
181
179
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
182
- expireStaleLocks(context, project.id);
183
- expireStaleWaiters(context, project.id);
184
180
  return {
185
181
  ok: true,
186
182
  lane: input.lane ? normalizeLane(input.lane) : null,
@@ -73,6 +73,54 @@ export function claimNextMergeJob(context, input) {
73
73
  claimed: Boolean(claimedProtocolId)
74
74
  });
75
75
  }
76
+ export function claimMergeJob(context, input) {
77
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
78
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
79
+ if (stopState.stopped) {
80
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim another merge job", 1, {
81
+ worker_id: input.workerId,
82
+ status: stopState.status,
83
+ reason: stopState.reason
84
+ });
85
+ }
86
+ requireLaneLockOwner(context, {
87
+ projectRoot: project.root,
88
+ lane: "merge",
89
+ workerId: input.workerId,
90
+ workspacePath: input.workspacePath
91
+ });
92
+ const now = context.now();
93
+ context.db.exec("BEGIN IMMEDIATE");
94
+ try {
95
+ const job = queueJobByProtocol(context, project.id, input.protocolId);
96
+ if (!job || !["ready", "requeued"].includes(job.status)) {
97
+ throw new AppError("merge_job_not_claimable", "Requested merge job is not ready to claim", 1, {
98
+ protocol_id: input.protocolId,
99
+ status: job?.status ?? "missing",
100
+ allowed: ["ready", "requeued"]
101
+ });
102
+ }
103
+ const update = context.db.run(`UPDATE merge_queue
104
+ SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
105
+ WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
106
+ if (update.changes !== 1) {
107
+ throw new AppError("merge_job_claim_race", "Requested merge job changed while claiming", 1, { protocol_id: input.protocolId });
108
+ }
109
+ appendAudit(context, {
110
+ protocolId: job.protocol_id,
111
+ projectId: project.id,
112
+ eventType: "merge_queue.claimed",
113
+ payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
114
+ });
115
+ transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
116
+ context.db.exec("COMMIT");
117
+ return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
118
+ }
119
+ catch (error) {
120
+ context.db.exec("ROLLBACK");
121
+ throw error;
122
+ }
123
+ }
76
124
  export function claimMergeBundle(context, input) {
77
125
  const { project, protocolIds } = requireClaimableBranchBundle(context, {
78
126
  projectRoot: input.projectRoot,
@@ -1,7 +1,7 @@
1
1
  import { AppError } from "../shared/errors.js";
2
2
  import { resolveProjectRoot } from "../storage/paths.js";
3
3
  import { appendAudit } from "./audit.js";
4
- import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock, expireProjectLaneLocks } from "./lanes.js";
4
+ import { ensureLaneWorkspace, acquireLaneLock, releaseLaneLock } from "./lanes.js";
5
5
  import { claimNextMergeJob, queueForProject } from "./merge-queue.js";
6
6
  import { requireProjectByRoot } from "./projects.js";
7
7
  import { buildStaticFlowGuidance } from "./flow-guidance.js";
@@ -175,15 +175,14 @@ function withJobGuidance(context, job) {
175
175
  }
176
176
  }
177
177
  function detectMergeWorkerState(context, projectId) {
178
- expireProjectLaneLocks(context, projectId);
179
178
  const workers = activeMergeWorkerSessions(context, projectId);
180
179
  const workerIds = [...new Set(workers.map((worker) => worker.worker_id).filter((value) => Boolean(value)))];
181
180
  const claimed = context.db.all(`SELECT protocol_id, claimed_by_session_id, status, updated_at FROM merge_queue
182
181
  WHERE project_id = ? AND status = 'claimed'
183
182
  ORDER BY updated_at DESC, id DESC`, [projectId]);
184
183
  const lock = context.db.get(`SELECT worker_id, status, expires_at, reason FROM lane_locks
185
- WHERE project_id = ? AND lane = 'merge' AND status = 'active'
186
- ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId]);
184
+ WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND expires_at > ?
185
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [projectId, context.now()]);
187
186
  if (workerIds.length > 1) {
188
187
  return { state: "blocked", reason: "multiple_active_merge_workers", workers };
189
188
  }
@@ -1,9 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import { AppError } from "../shared/errors.js";
4
5
  import { requireProjectByRoot } from "./projects.js";
5
6
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
6
7
  import { resolveCanonRoot } from "./canon.js";
8
+ import { getDatabase } from "../storage/database.js";
9
+ import { resolveProjectRoot } from "../storage/paths.js";
10
+ import { classifyCliOperation } from "./cli-operation-classifier.js";
11
+ import { compatibilityContexts, selectEngine } from "./engines.js";
12
+ import { getCliBuildInfo } from "./build-info.js";
7
13
  export function assessMigrationImpact(context, input) {
8
14
  const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
9
15
  if (!root.root)
@@ -78,14 +84,19 @@ export function planMigration(context, input) {
78
84
  rootSource: rootResolution.root_source
79
85
  });
80
86
  const sourceVersion = input.sourceVersion ?? versionStatus?.memory_bank.version ?? "unknown";
81
- const targetVersion = input.targetVersion ?? versionStatus?.memory_bank.version ?? "unknown";
82
- const chain = adjacentMigrationChain(sourceVersion, targetVersion);
87
+ const targetVersion = input.targetVersion ?? upgradeCanon(context).canon?.version ?? versionStatus?.memory_bank.version ?? "unknown";
88
+ const canon = upgradeCanon(context);
89
+ const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
90
+ const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
83
91
  const activeState = activeStateSummary(context, project.id);
84
92
  const backup = backupEvidence(input);
93
+ const engine = migrationEngineEvidence(context, rootResolution.root, targetVersion, input.classification);
85
94
  const blockers = [
86
95
  ...(chain.status === "unsupported" ? [`unsupported migration path: ${chain.reason}`] : []),
87
- ...(backup.status === "missing" ? ["backup evidence is required before runtime/home migration"] : []),
88
- ...(input.allowActive ? [] : activeStateBlockers(activeState))
96
+ ...(backup.status !== "present" ? ["verified backup evidence is required before runtime/home migration"] : []),
97
+ ...(engine.target_canon_version !== targetVersion ? ["migration target version does not match the resolved target canon version"] : []),
98
+ ...(engine.target_engine_available !== "selected" ? ["target upgrade engine is not selected"] : []),
99
+ ...activeStateBlockers(activeState)
89
100
  ];
90
101
  const status = blockers.length > 0 ? "blocked" : chain.steps.length === 0 ? "noop" : "ready";
91
102
  return {
@@ -110,12 +121,7 @@ export function planMigration(context, input) {
110
121
  chain: chain.steps,
111
122
  blocked_reasons: blockers
112
123
  },
113
- engine: {
114
- router_version: null,
115
- selected_engine_version: null,
116
- source_engine_available: "unknown",
117
- target_engine_available: "unknown"
118
- },
124
+ engine,
119
125
  backup,
120
126
  active_state: activeState,
121
127
  primary_data: [
@@ -141,35 +147,134 @@ export function planMigration(context, input) {
141
147
  }
142
148
  };
143
149
  }
150
+ export function applyMigration(context, input) {
151
+ const classification = input.classification ?? classifyCliOperation(["migration", "apply"], context.env);
152
+ if (classification.mode !== "mb_upgrade") {
153
+ throw new AppError("upgrade_authorization_required", "Migration apply requires explicit mb-upgrade authorization", 1, {
154
+ required_marker: "DD_FLOW_COMPATIBILITY_MODE=mb-upgrade"
155
+ });
156
+ }
157
+ const report = input.reportFile
158
+ ? readJsonObject(path.resolve(input.reportFile))
159
+ : planMigration(context, input);
160
+ const reportFile = input.reportFile ? path.resolve(input.reportFile) : null;
161
+ const reportValidation = validateMigrationReportObject(report);
162
+ if (reportValidation.errors.length > 0) {
163
+ throw new AppError("migration_report_invalid", "Migration report is not acceptable for apply", 2, { errors: reportValidation.errors, file: reportFile });
164
+ }
165
+ const migration = recordValue(report.migration);
166
+ const backup = recordValue(report.backup);
167
+ const activeState = recordValue(report.active_state);
168
+ const blockers = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
169
+ if (blockers.length > 0 || stringValue(migration?.status) === "blocked") {
170
+ throw new AppError("migration_blocked", "Migration apply is blocked by its verified report", 1, { blockers, file: reportFile });
171
+ }
172
+ const backupPath = stringValue(backup?.path);
173
+ const verifiedBackup = backupPath ? verifyBackupFile(backupPath) : null;
174
+ if (!verifiedBackup) {
175
+ throw new AppError("backup_invalid", "Migration apply requires a readable, non-empty backup file", 1, { path: backupPath });
176
+ }
177
+ if (activeState && hasActiveState(activeState)) {
178
+ throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: activeState });
179
+ }
180
+ const sourceVersion = stringValue(migration?.source_memory_bank_version);
181
+ const targetVersion = stringValue(migration?.target_memory_bank_version);
182
+ if (!sourceVersion || !targetVersion)
183
+ throw new AppError("migration_contract_invalid", "Migration source and target versions are required", 1);
184
+ const canon = upgradeCanon(context);
185
+ const impacts = canon.canon ? readImpacts(path.join(canon.canon.memorybank_root, "release-impact")) : [];
186
+ const chain = adjacentMigrationChain(sourceVersion, targetVersion, impacts);
187
+ if (chain.status === "unsupported")
188
+ throw new AppError("migration_chain_invalid", chain.reason ?? "Unsupported migration chain", 1);
189
+ const expectedEngine = migrationEngineEvidence(context, resolveProjectRoot(input.projectRoot), targetVersion, classification);
190
+ const reportEngine = recordValue(report.engine);
191
+ const engineErrors = validateEngineEvidence(reportEngine, expectedEngine, targetVersion);
192
+ if (engineErrors.length > 0) {
193
+ throw new AppError("migration_contract_invalid", "Migration report engine contract does not match the pinned upgrade target", 1, {
194
+ errors: engineErrors,
195
+ expected: expectedEngine,
196
+ actual: reportEngine
197
+ });
198
+ }
199
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
200
+ const writeDb = getDatabase(context.ddFlowHome, "migrate");
201
+ const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
202
+ if (already) {
203
+ return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "verified_noop", changed: false, backup: verifiedBackup, recovery: JSON.parse(already.value_json) };
204
+ }
205
+ const result = {
206
+ status: "applied",
207
+ source_version: sourceVersion,
208
+ target_version: targetVersion,
209
+ contract: {
210
+ flow_kind: "mb-upgrade",
211
+ mode: "mb-upgrade-only",
212
+ adjacent_only: true
213
+ },
214
+ engine: reportEngine,
215
+ applied_at: context.now(),
216
+ backup: verifiedBackup,
217
+ chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
218
+ };
219
+ try {
220
+ writeDb.exec("BEGIN IMMEDIATE");
221
+ writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
222
+ writeDb.exec("COMMIT");
223
+ }
224
+ catch (error) {
225
+ try {
226
+ writeDb.exec("ROLLBACK");
227
+ }
228
+ catch { /* preserve the original failure */ }
229
+ throw new AppError("migration_apply_failed", "Migration apply failed before upgrade RUN creation or canon sync", 1, {
230
+ recovery: {
231
+ status: "required",
232
+ source_version: sourceVersion,
233
+ target_version: targetVersion,
234
+ backup: verifiedBackup,
235
+ rollback_route: "restore the verified backup before retrying mb-upgrade apply",
236
+ next_action: "inspect the failed apply and retry only with the same pinned target contract"
237
+ },
238
+ cause: String(error)
239
+ });
240
+ }
241
+ return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "applied", changed: true, backup: verifiedBackup, recovery: result };
242
+ }
243
+ export function assertUpgradeRunStorageReady(context, projectRoot) {
244
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(projectRoot)]);
245
+ if (!project)
246
+ throw new AppError("migration_not_applied", "mb-upgrade RUN requires a registered project with explicit migration apply", 1);
247
+ const status = getProjectVersionStatus({ projectRoot: resolveProjectRoot(projectRoot), rootSource: "explicit" });
248
+ const target = upgradeCanon(context).canon?.version;
249
+ const source = status?.memory_bank.version;
250
+ if (!source || !target)
251
+ throw new AppError("migration_not_applied", "mb-upgrade RUN requires source and target Memory Bank versions", 1);
252
+ const applied = context.db.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, source, target)]);
253
+ if (!applied)
254
+ throw new AppError("migration_not_applied", "mb-upgrade RUN requires explicit migration apply before RUN creation", 1, {
255
+ source_version: source,
256
+ target_version: target,
257
+ next_action: "run migration plan, verify and explicit migration apply first"
258
+ });
259
+ const value = recordValue(parseJsonValue(applied.value_json));
260
+ if (!value || !["applied", "verified_noop"].includes(stringValue(value.status) ?? "")) {
261
+ throw new AppError("migration_not_applied", "mb-upgrade RUN has no successful explicit migration apply evidence", 1, {
262
+ source_version: source,
263
+ target_version: target
264
+ });
265
+ }
266
+ }
144
267
  export function verifyMigrationReport(_context, input) {
145
268
  const filePath = path.resolve(input.file);
146
269
  const report = readJsonObject(filePath);
270
+ const validation = validateMigrationReportObject(report);
147
271
  const schemaId = stringValue(report.schema_id);
148
- if (schemaId !== "dd-flow/mb-upgrade-migration-report@1") {
149
- throw new AppError("validation", "Migration report schema_id is not dd-flow/mb-upgrade-migration-report@1", 2, {
150
- file: filePath,
151
- schema_id: schemaId
152
- });
153
- }
154
272
  const migration = recordValue(report.migration);
155
273
  const backup = recordValue(report.backup);
156
274
  const chain = Array.isArray(migration?.chain) ? migration.chain : [];
157
275
  const backupStatus = stringValue(backup?.status);
158
276
  const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
159
- const errors = [];
160
- if (!migration)
161
- errors.push("migration block is required");
162
- if (!backup)
163
- errors.push("backup block is required");
164
- if (backupStatus !== "present" && backupStatus !== "planned")
165
- errors.push("backup.status must be present or planned");
166
- if (migration?.adjacent_only !== true)
167
- errors.push("migration.adjacent_only must be true");
168
- if (chain.some((step) => !isAdjacentStep(step)))
169
- errors.push("all migration.chain entries must be adjacent steps with from/to/status");
170
- if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0) {
171
- errors.push("ready migration report must not contain blocked reasons");
172
- }
277
+ const errors = validation.errors;
173
278
  if (errors.length > 0) {
174
279
  throw new AppError("migration_report_invalid", "Migration report is not acceptable", 2, { file: filePath, errors });
175
280
  }
@@ -180,6 +285,7 @@ export function verifyMigrationReport(_context, input) {
180
285
  verdict: blockedReasons.length > 0 ? "blocked_report_valid" : "accepted",
181
286
  checks: {
182
287
  backup: backupStatus,
288
+ backup_integrity: backupStatus === "present" && stringValue(backup?.path) ? verifyBackupFile(stringValue(backup?.path)) : null,
183
289
  adjacent_chain_steps: chain.length,
184
290
  blocked_reasons: blockedReasons.length
185
291
  }
@@ -191,17 +297,135 @@ function backupEvidence(input) {
191
297
  status: "missing",
192
298
  path: null,
193
299
  created_at: null,
300
+ size_bytes: null,
301
+ sha256: null,
194
302
  rollback_route: "required before applying runtime/home migration"
195
303
  };
196
304
  }
305
+ const file = path.resolve(input.backupPath);
306
+ const verified = verifyBackupFile(file);
197
307
  return {
198
- status: fs.existsSync(path.resolve(input.backupPath)) ? "present" : "planned",
199
- path: path.resolve(input.backupPath),
200
- created_at: input.backupCreatedAt ?? null,
308
+ status: verified ? "present" : "missing",
309
+ path: file,
310
+ created_at: input.backupCreatedAt ?? (verified ? new Date(fs.statSync(file).birthtimeMs).toISOString() : null),
311
+ size_bytes: verified?.size_bytes ?? null,
312
+ sha256: verified?.sha256 ?? null,
201
313
  rollback_route: "restore backup before retrying migration"
202
314
  };
203
315
  }
204
- function adjacentMigrationChain(source, target) {
316
+ function verifyBackupFile(file) {
317
+ try {
318
+ const stat = fs.statSync(file);
319
+ if (!stat.isFile() || stat.size <= 0)
320
+ return null;
321
+ return {
322
+ path: file,
323
+ size_bytes: stat.size,
324
+ sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
325
+ created_at: stat.birthtime.toISOString()
326
+ };
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ function validateMigrationReportObject(report) {
333
+ const migration = recordValue(report.migration);
334
+ const backup = recordValue(report.backup);
335
+ const chain = Array.isArray(migration?.chain) ? migration.chain : [];
336
+ const backupStatus = stringValue(backup?.status);
337
+ const blockedReasons = Array.isArray(migration?.blocked_reasons) ? migration.blocked_reasons.map(String) : [];
338
+ const errors = [];
339
+ if (report.schema_id !== "dd-flow/mb-upgrade-migration-report@1")
340
+ errors.push("schema_id must be dd-flow/mb-upgrade-migration-report@1");
341
+ if (!migration)
342
+ errors.push("migration block is required");
343
+ if (!recordValue(report.engine))
344
+ errors.push("engine block is required");
345
+ if (!backup)
346
+ errors.push("backup block is required");
347
+ if (backupStatus !== "present")
348
+ errors.push("backup.status must be present");
349
+ if (backupStatus === "present") {
350
+ const backupPath = stringValue(backup?.path);
351
+ const verified = backupPath ? verifyBackupFile(backupPath) : null;
352
+ if (!verified)
353
+ errors.push("backup file must be readable and non-empty");
354
+ else {
355
+ if (backup?.size_bytes !== verified.size_bytes)
356
+ errors.push("backup.size_bytes does not match the verified backup");
357
+ if (backup?.sha256 !== verified.sha256)
358
+ errors.push("backup.sha256 does not match the verified backup");
359
+ }
360
+ }
361
+ if (migration?.adjacent_only !== true)
362
+ errors.push("migration.adjacent_only must be true");
363
+ if (chain.some((step) => !isAdjacentStep(step)))
364
+ errors.push("all migration.chain entries must be adjacent steps with from/to/status");
365
+ const engine = recordValue(report.engine);
366
+ if (engine && stringValue(migration?.target_memory_bank_version) !== engine.target_canon_version) {
367
+ errors.push("migration.target_memory_bank_version must match engine.target_canon_version");
368
+ }
369
+ if (stringValue(migration?.status) === "ready" && blockedReasons.length > 0)
370
+ errors.push("ready migration report must not contain blocked reasons");
371
+ return { errors };
372
+ }
373
+ function migrationEngineEvidence(context, projectRoot, targetVersion, classification) {
374
+ const env = context.env;
375
+ const targetClassification = classification ?? classifyCliOperation(["migration", "plan"], env);
376
+ const current = selectEngine(context, { projectRoot, env }, {}, {
377
+ mode: "normal_write", operation: "project.current", family: "project", command: "current", action: null
378
+ });
379
+ const target = selectEngine(context, { projectRoot, env }, { allowCurrentInProcess: true }, targetClassification);
380
+ const contexts = compatibilityContexts(context, { projectRoot, env, classification: targetClassification });
381
+ const targetContext = recordValue(contexts.upgrade_target);
382
+ const canon = upgradeCanon(context).canon;
383
+ const targetCanonVersion = canon?.version ?? null;
384
+ return {
385
+ router_version: getCliBuildInfo().version,
386
+ source_engine_version: current.selected?.package_version ?? null,
387
+ target_engine_version: target.selected?.package_version ?? null,
388
+ selected_engine_version: target.selected?.package_version ?? null,
389
+ source_engine_available: current.status,
390
+ target_engine_available: target.status,
391
+ target_canon_version: targetCanonVersion,
392
+ target_canon_commit: canon?.commit ?? null,
393
+ target_compatibility_sha256: stringValue(targetContext?.canon && recordValue(targetContext.canon)?.compatibility_sha256),
394
+ effective_engine_version: recordValue(contexts.effective_execution)?.engine_version ?? null
395
+ };
396
+ }
397
+ function validateEngineEvidence(actual, expected, targetVersion) {
398
+ const errors = [];
399
+ if (!actual)
400
+ return ["engine block is required"];
401
+ for (const key of ["target_engine_version", "selected_engine_version", "target_canon_version", "target_canon_commit", "target_compatibility_sha256"]) {
402
+ if (actual[key] !== expected[key])
403
+ errors.push(`engine.${key} does not match current target assessment`);
404
+ }
405
+ if (actual.target_canon_version !== targetVersion)
406
+ errors.push("engine.target_canon_version must match migration target version");
407
+ if (actual.target_engine_available !== "selected")
408
+ errors.push("engine.target_engine_available must be selected");
409
+ return errors;
410
+ }
411
+ function parseJsonValue(value) {
412
+ try {
413
+ return JSON.parse(value);
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ function upgradeCanon(context) {
420
+ return resolveCanonRoot(context, context.env.DD_MEMORYBANK ? { explicitRoot: context.env.DD_MEMORYBANK } : {});
421
+ }
422
+ function hasActiveState(active) {
423
+ return ["protocols", "runs", "merge_queue", "lane_locks", "lane_waiters", "merge_sessions", "sessions"].some((key) => Array.isArray(active[key]) && active[key].length > 0);
424
+ }
425
+ function applyKey(projectId, source, target) {
426
+ return `mb-upgrade.apply.${projectId}.${source}.${target}`;
427
+ }
428
+ function adjacentMigrationChain(source, target, impacts = []) {
205
429
  if (source === target)
206
430
  return { status: "ok", steps: [] };
207
431
  const sourceParts = parseSemver(source);
@@ -209,8 +433,38 @@ function adjacentMigrationChain(source, target) {
209
433
  if (!sourceParts || !targetParts) {
210
434
  return { status: "unsupported", reason: "source or target version is not semver", steps: [] };
211
435
  }
436
+ if (impacts.length > 0) {
437
+ const steps = [];
438
+ const visited = new Set();
439
+ let current = source;
440
+ while (current !== target) {
441
+ if (visited.has(current))
442
+ return { status: "unsupported", reason: "release impact chain contains a cycle", steps: [] };
443
+ visited.add(current);
444
+ const impact = impacts.find((item) => item.from_version === current);
445
+ if (!impact)
446
+ return { status: "unsupported", reason: `missing adjacent release impact from ${current}`, steps: [] };
447
+ steps.push({
448
+ id: `${impact.from_version}-to-${impact.to_version}`,
449
+ from: impact.from_version,
450
+ to: impact.to_version,
451
+ status: "planned",
452
+ minimum_upgrade_mode: impact.minimum_upgrade_mode,
453
+ domains: impact.domains,
454
+ path_migration: impact.path_migration,
455
+ runtime_migration: impact.runtime_migration,
456
+ required_checks: impact.required_checks
457
+ });
458
+ current = impact.to_version;
459
+ const next = parseSemver(current);
460
+ if (!next || compareParts(next, targetParts) > 0) {
461
+ return { status: "unsupported", reason: `release impact chain overshoots target ${target}`, steps: [] };
462
+ }
463
+ }
464
+ return { status: "ok", steps };
465
+ }
212
466
  if (sourceParts.major !== targetParts.major) {
213
- return { status: "unsupported", reason: "major version migration requires explicit future migration units", steps: [] };
467
+ return { status: "unsupported", reason: "major version migration requires canonical release-impact units", steps: [] };
214
468
  }
215
469
  if (targetParts.minor < sourceParts.minor || (targetParts.minor === sourceParts.minor && targetParts.patch < sourceParts.patch)) {
216
470
  return { status: "unsupported", reason: "downgrade migrations are not supported", steps: [] };
@@ -229,24 +483,33 @@ function adjacentMigrationChain(source, target) {
229
483
  }
230
484
  return { status: "ok", steps };
231
485
  }
232
- function activeStateSummary(context, projectId) {
486
+ function activeStateSummary(context, _projectId) {
233
487
  return {
234
- protocols: context.db.all("SELECT id, status, stage FROM protocols WHERE project_id = ? AND status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC", [projectId]),
235
- runs: context.db.all("SELECT id, status, verdict FROM flow_runs WHERE project_id = ? AND status NOT IN ('done', 'cancelled', 'failed') ORDER BY updated_at DESC", [projectId]),
236
- merge_queue: context.db.all("SELECT protocol_id, status FROM merge_queue WHERE project_id = ? AND status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC", [projectId]),
237
- lane_locks: context.db.all("SELECT lane, worker_id, status FROM lane_locks WHERE project_id = ? AND status = 'active' ORDER BY updated_at DESC", [projectId])
488
+ protocols: context.db.all("SELECT id, project_id, status, stage FROM protocols WHERE status NOT IN ('closed', 'cancelled') ORDER BY updated_at DESC"),
489
+ runs: context.db.all("SELECT id, project_id, status, verdict FROM flow_runs WHERE status = 'running' ORDER BY updated_at DESC"),
490
+ merge_queue: context.db.all("SELECT protocol_id, project_id, status FROM merge_queue WHERE status IN ('ready', 'claimed', 'requeued') ORDER BY updated_at DESC"),
491
+ lane_locks: context.db.all("SELECT lane, project_id, worker_id, status FROM lane_locks WHERE status = 'active' ORDER BY updated_at DESC"),
492
+ lane_waiters: context.db.all("SELECT id, lane, project_id, worker_id, status FROM lane_waiters WHERE status = 'queued' ORDER BY queued_at ASC, id ASC"),
493
+ merge_sessions: context.db.all("SELECT session_id, project_id, status, current_protocol_id FROM merge_sessions WHERE status IN ('starting', 'active', 'stopping') ORDER BY updated_at DESC"),
494
+ sessions: context.db.all("SELECT session_id, project_id, status FROM flow_sessions WHERE status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping') ORDER BY updated_at DESC")
238
495
  };
239
496
  }
240
497
  function activeStateBlockers(active) {
241
498
  const blockers = [];
242
499
  if (active.protocols.length > 0)
243
- blockers.push(`active protocols: ${active.protocols.map((item) => item.id).join(", ")}`);
500
+ blockers.push(`active protocols: ${active.protocols.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
244
501
  if (active.runs.length > 0)
245
- blockers.push(`active runs: ${active.runs.map((item) => item.id).join(", ")}`);
502
+ blockers.push(`active runs: ${active.runs.map((item) => `${item.project_id}:${item.id}`).join(", ")}`);
246
503
  if (active.merge_queue.length > 0)
247
- blockers.push(`active merge queue items: ${active.merge_queue.map((item) => item.protocol_id).join(", ")}`);
504
+ blockers.push(`active merge queue items: ${active.merge_queue.map((item) => `${item.project_id}:${item.protocol_id}`).join(", ")}`);
248
505
  if (active.lane_locks.length > 0)
249
- blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.lane}:${item.worker_id}`).join(", ")}`);
506
+ blockers.push(`active lane locks: ${active.lane_locks.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
507
+ if (active.lane_waiters.length > 0)
508
+ blockers.push(`queued lane waiters: ${active.lane_waiters.map((item) => `${item.project_id}:${item.lane}:${item.worker_id}`).join(", ")}`);
509
+ if (active.merge_sessions.length > 0)
510
+ blockers.push(`active merge sessions: ${active.merge_sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
511
+ if (active.sessions.length > 0)
512
+ blockers.push(`active sessions: ${active.sessions.map((item) => `${item.project_id}:${item.session_id}`).join(", ")}`);
250
513
  return blockers;
251
514
  }
252
515
  function readJsonObject(filePath) {