@deksden-com/dd-flow-cli 0.6.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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -1
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +20 -8
  5. package/dist/cli/run-cli.js +104 -24
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/runtime/context.js +8 -2
  8. package/dist/schemas/code-stage-report.schema.json +7 -2
  9. package/dist/schemas/engine-manifest.schema.json +22 -0
  10. package/dist/schemas/flow-run.schema.json +1 -0
  11. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  12. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  13. package/dist/schemas/run-engine-binding.schema.json +37 -0
  14. package/dist/schemas/stage-prompt.schema.json +4 -4
  15. package/dist/services/canon.js +15 -1
  16. package/dist/services/cli-operation-classifier.js +52 -8
  17. package/dist/services/compatibility-preflight.js +1 -1
  18. package/dist/services/dashboard.js +2 -2
  19. package/dist/services/engines.js +408 -30
  20. package/dist/services/hooks.js +1 -5
  21. package/dist/services/lanes.js +0 -4
  22. package/dist/services/merge-queue.js +48 -0
  23. package/dist/services/merge-worker.js +3 -4
  24. package/dist/services/migrations.js +307 -44
  25. package/dist/services/plan-runtime.js +4 -4
  26. package/dist/services/plans.js +5 -3
  27. package/dist/services/protocols.js +23 -2
  28. package/dist/services/run-engine-bindings.js +157 -0
  29. package/dist/services/runs.js +21 -7
  30. package/dist/services/schema-validation.js +96 -0
  31. package/dist/services/stage-lifecycle.js +98 -10
  32. package/dist/services/status.js +8 -3
  33. package/dist/storage/database.js +32 -11
  34. package/package.json +1 -1
@@ -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 = 'running' 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) {
@@ -54,8 +54,8 @@ export function boundCanonicalPlan(context, input) {
54
54
  refreshProtocolRuns(context, input.projectId, input.protocolId);
55
55
  return canonical;
56
56
  }
57
- export function planWithProgress(context, input) {
58
- const canonical = boundCanonicalPlan(context, input);
57
+ export function planWithProgress(context, input, options = {}) {
58
+ const canonical = options.bind === false ? readCanonicalPlan(input.planPath, input.protocolId) : boundCanonicalPlan(context, input);
59
59
  const rows = context.db.all(`SELECT item_id, plan_revision, plan_sha256, status, summary, evidence_json, block_reason, user_required
60
60
  FROM plan_progress WHERE project_id = ? AND protocol_id = ?`, [input.projectId, input.protocolId]);
61
61
  const progress = new Map(rows.map((row) => [row.item_id, row]));
@@ -67,8 +67,8 @@ export function planWithProgress(context, input) {
67
67
  }
68
68
  };
69
69
  }
70
- export function planSummary(context, input) {
71
- const { canonical, plan } = planWithProgress(context, input);
70
+ export function planSummary(context, input, options = {}) {
71
+ const { canonical, plan } = planWithProgress(context, input, options);
72
72
  return {
73
73
  plan_id: plan.plan_id,
74
74
  revision: canonical.revision,
@@ -7,11 +7,11 @@ import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
8
8
  export function getPlanStatus(context, input) {
9
9
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
10
- const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
10
+ const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
11
11
  return {
12
12
  ok: true,
13
13
  protocol_id: protocol.id,
14
- plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }),
14
+ plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }),
15
15
  binding: { revision: current.canonical.revision, sha256: current.canonical.sha256, path: protocol.plan_path },
16
16
  blocked_items: current.plan.items
17
17
  .filter((item) => item.status === "blocked")
@@ -69,7 +69,9 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
69
69
  }
70
70
  function scopedProtocol(context, projectRoot, protocolId) {
71
71
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
72
- return requireProtocol(context, protocolId, project.id);
72
+ const protocol = requireProtocol(context, protocolId, project.id);
73
+ readProtocolRuntimeState(context, protocol);
74
+ return protocol;
73
75
  }
74
76
  function assertDependenciesClosed(plan, item) {
75
77
  const openDependencies = item.depends_on
@@ -100,7 +100,7 @@ export function getProtocolStatus(context, input) {
100
100
  diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
101
101
  latest_run: runDiagnostics.latest_run,
102
102
  state,
103
- plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) : state.plan,
103
+ plan: plan ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false }) : state.plan,
104
104
  merge_queue: queue,
105
105
  branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
106
106
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
@@ -734,6 +734,7 @@ function releaseRelatedMergeLocks(context, protocol, reason) {
734
734
  }
735
735
  export function readProtocolRuntimeState(context, protocol) {
736
736
  const diagnostics = [];
737
+ normalizeProtocolPlanPath(context, protocol, diagnostics);
737
738
  try {
738
739
  return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
739
740
  }
@@ -764,7 +765,7 @@ export function readProtocolRuntimeState(context, protocol) {
764
765
  }
765
766
  const plan = canonicalPlanIfPresent(context, protocol);
766
767
  const summary = plan
767
- ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path })
768
+ ? planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false })
768
769
  : { plan_id: null, total: 0, done: 0, blocked: 0 };
769
770
  const flowContract = loadProjectFlowContract(protocol.project_root);
770
771
  const state = {
@@ -782,6 +783,9 @@ export function readProtocolRuntimeState(context, protocol) {
782
783
  flow_contract: flowContract,
783
784
  updated_at: protocol.updated_at
784
785
  };
786
+ if (!context.db.writable) {
787
+ return { state, diagnostics };
788
+ }
785
789
  const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
786
790
  const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
787
791
  ensureDir(path.dirname(stableStatePath));
@@ -798,6 +802,23 @@ export function readProtocolRuntimeState(context, protocol) {
798
802
  });
799
803
  return { state, diagnostics };
800
804
  }
805
+ function normalizeProtocolPlanPath(context, protocol, diagnostics) {
806
+ const canonicalPlanPath = planJsonPath(protocol.project_root, protocol.id);
807
+ if (protocol.plan_path === canonicalPlanPath || !fs.existsSync(canonicalPlanPath))
808
+ return;
809
+ diagnostics.push({
810
+ code: "protocol_plan_relocated",
811
+ severity: "warning",
812
+ old_path: protocol.plan_path,
813
+ path: canonicalPlanPath,
814
+ source: "project_canonical_plan",
815
+ recommended_action: "use the project canonical plan as the sole semantic plan source"
816
+ });
817
+ if (context.db.writable) {
818
+ context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
819
+ }
820
+ protocol.plan_path = canonicalPlanPath;
821
+ }
801
822
  export function protocolRunDiagnostics(context, protocol, state) {
802
823
  const runs = linkedRunsForProtocol(context, protocol, 5);
803
824
  const latest = runs[0];