@deksden-com/dd-flow-cli 0.6.0 → 0.8.0-beta.135

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 (78) hide show
  1. package/CHANGELOG.md +636 -0
  2. package/README.md +13 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +108 -18
  5. package/dist/cli/run-cli.js +626 -49
  6. package/dist/domain/flow-contract.js +11 -0
  7. package/dist/domain/stage-catalog.js +22 -0
  8. package/dist/runtime/context.js +8 -2
  9. package/dist/schemas/code-review-decision.schema.json +26 -0
  10. package/dist/schemas/code-review-result.schema.json +14 -0
  11. package/dist/schemas/code-stage-report.schema.json +7 -2
  12. package/dist/schemas/code-verification.schema.json +14 -0
  13. package/dist/schemas/code-work-batch.schema.json +24 -0
  14. package/dist/schemas/code-work-result.schema.json +16 -0
  15. package/dist/schemas/engine-manifest.schema.json +22 -0
  16. package/dist/schemas/flow-contract.schema.json +6 -3
  17. package/dist/schemas/flow-run.schema.json +16 -122
  18. package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
  19. package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
  20. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  21. package/dist/schemas/plan-review-decision.schema.json +14 -0
  22. package/dist/schemas/plan-review-result.schema.json +42 -0
  23. package/dist/schemas/protocol-plan.schema.json +15 -182
  24. package/dist/schemas/run-engine-binding.schema.json +37 -0
  25. package/dist/schemas/stage-finish-input.schema.json +16 -2
  26. package/dist/schemas/stage-prompt.schema.json +4 -4
  27. package/dist/schemas/stage-report.schema.json +8 -7
  28. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  29. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  30. package/dist/schemas/vnext-specify.schema.json +45 -0
  31. package/dist/services/branch-context.js +1 -1
  32. package/dist/services/canon.js +15 -1
  33. package/dist/services/cleanup.js +8 -8
  34. package/dist/services/cli-operation-classifier.js +60 -8
  35. package/dist/services/code-checks.js +244 -0
  36. package/dist/services/compatibility-preflight.js +1 -1
  37. package/dist/services/config.js +7 -1
  38. package/dist/services/dashboard.js +14 -14
  39. package/dist/services/engines.js +408 -30
  40. package/dist/services/eval-snapshots.js +404 -0
  41. package/dist/services/hooks.js +775 -23
  42. package/dist/services/ids.js +16 -6
  43. package/dist/services/lanes.js +1 -5
  44. package/dist/services/merge-queue.js +53 -5
  45. package/dist/services/merge-worker.js +5 -6
  46. package/dist/services/migrations.js +307 -44
  47. package/dist/services/plan-runtime.js +5 -5
  48. package/dist/services/plans.js +5 -3
  49. package/dist/services/projects.js +4 -4
  50. package/dist/services/prompts.js +1 -1
  51. package/dist/services/protocols.js +31 -10
  52. package/dist/services/run-engine-bindings.js +157 -0
  53. package/dist/services/run-projection.js +49 -13
  54. package/dist/services/runs.js +525 -58
  55. package/dist/services/schema-validation.js +116 -2
  56. package/dist/services/sessions.js +51 -12
  57. package/dist/services/stage-blocker.js +57 -0
  58. package/dist/services/stage-context.js +90 -0
  59. package/dist/services/stage-lifecycle.js +288 -77
  60. package/dist/services/stage-pause.js +175 -0
  61. package/dist/services/stage-report-renderer.js +65 -0
  62. package/dist/services/status.js +8 -3
  63. package/dist/services/usage.js +526 -18
  64. package/dist/services/vnext-code-review.js +308 -0
  65. package/dist/services/vnext-code.js +616 -0
  66. package/dist/services/vnext-contracts.js +1 -0
  67. package/dist/services/vnext-execution-profile.js +27 -0
  68. package/dist/services/vnext-fanout.js +79 -0
  69. package/dist/services/vnext-plan-review.js +499 -0
  70. package/dist/services/vnext-plan.js +576 -0
  71. package/dist/services/vnext-protocolize.js +542 -0
  72. package/dist/services/vnext-specify.js +595 -0
  73. package/dist/services/vnext-workspace-policy.js +87 -0
  74. package/dist/services/work-registry.js +499 -0
  75. package/dist/services/worktrees.js +58 -37
  76. package/dist/storage/database.js +292 -42
  77. package/dist/storage/paths.js +47 -1
  78. package/package.json +12 -12
@@ -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 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 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,
@@ -98,7 +98,7 @@ export function updatePlanProgress(context, input) {
98
98
  refreshProtocolRuns(context, input.projectId, input.protocolId);
99
99
  }
100
100
  function refreshProtocolRuns(context, projectId, protocolId) {
101
- for (const run of context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
101
+ for (const run of context.db.all("SELECT id FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?", [projectId, protocolId]))
102
102
  refreshRunSessionProjection(context, projectId, run.id);
103
103
  }
104
104
  function mergeProgress(item, row, canonical) {
@@ -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
@@ -218,9 +218,9 @@ export function getProjectStatus(context, input) {
218
218
  merge_queue: mergeQueue,
219
219
  hook_status: hookStatusForProject(context, project.id),
220
220
  codex_home_profiles: codexHomeProfilesForProject(context, project.id),
221
- flow_sessions: activeFlowSessionBindingsForProject(context, project.id),
221
+ sessions: activeFlowSessionBindingsForProject(context, project.id),
222
222
  codex_session_bindings: activeCodexSessionBindingsForProject(context, project.id),
223
- codex_hook_events: codexHookEventsForProject(context, project.id),
223
+ hook_events: codexHookEventsForProject(context, project.id),
224
224
  worktrees,
225
225
  lane_status: {
226
226
  lanes: context.db.all("SELECT * FROM lanes WHERE project_id = ? ORDER BY name ASC", [project.id]),
@@ -319,10 +319,10 @@ function projectReferenceTables() {
319
319
  "codex_home_profiles",
320
320
  "codex_session_bindings",
321
321
  "project_config",
322
- "flow_sessions",
322
+ "sessions",
323
323
  "flow_session_segments",
324
324
  "flow_jobs",
325
- "codex_hook_events",
325
+ "hook_events",
326
326
  "worktree_records"
327
327
  ];
328
328
  }
@@ -204,7 +204,7 @@ function protocolIdForRun(context, projectId, runId) {
204
204
  return run.subject_id;
205
205
  }
206
206
  function requireRun(context, projectId, runId) {
207
- const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM flow_runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
207
+ const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_index_path, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
208
208
  if (!row)
209
209
  throw new AppError("not_found", `Run is not found: ${runId}`, 1);
210
210
  return row;
@@ -47,7 +47,7 @@ export function registerProtocol(context, input) {
47
47
  JSON.stringify(state.blockers),
48
48
  JSON.stringify(state.active_def),
49
49
  runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
50
- planJsonPath(projectRoot, protocolId),
50
+ planJsonPath(workspacePath ?? projectRoot, protocolId),
51
51
  existing?.created_at ?? now,
52
52
  now
53
53
  ];
@@ -100,16 +100,16 @@ 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 }),
107
107
  worktree: context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]),
108
108
  hook_status: hookStatusForProject(context, protocol.project_id),
109
109
  codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
110
- flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
110
+ sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
111
111
  codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
112
- codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
112
+ hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
113
113
  audit: getAuditEvents(context, protocol.project_id, protocol.id)
114
114
  };
115
115
  }
@@ -502,7 +502,7 @@ export function cancelProtocol(context, input) {
502
502
  }
503
503
  let closedSessions = 0;
504
504
  if (input.closeSessions) {
505
- const result = context.db.run(`UPDATE flow_sessions
505
+ const result = context.db.run(`UPDATE sessions
506
506
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
507
507
  WHERE project_id = ?
508
508
  AND protocol_id = ?
@@ -705,7 +705,7 @@ function removeLocalFeatureBranch(projectRoot, branch, force) {
705
705
  return { ok: true, skipped: false, reason: force ? "branch_force_deleted" : "branch_deleted", branch };
706
706
  }
707
707
  function releaseRelatedMergeLocks(context, protocol, reason) {
708
- const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
708
+ const workers = context.db.all(`SELECT DISTINCT worker_id FROM sessions
709
709
  WHERE project_id = ? AND protocol_id = ? AND worker_id IS NOT NULL`, [protocol.project_id, protocol.id]).map((row) => row.worker_id).filter((workerId) => Boolean(workerId));
710
710
  const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
711
711
  if (job?.claimed_by_session_id) {
@@ -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];
@@ -845,7 +866,7 @@ export function persistProtocolState(context, protocol, state) {
845
866
  ]);
846
867
  if (state.status === "closed" || state.stage === "closed") {
847
868
  const now = context.now();
848
- context.db.run(`UPDATE flow_sessions
869
+ context.db.run(`UPDATE sessions
849
870
  SET status = 'stopped', stop_reason = 'protocol closed', updated_at = ?, stopped_at = ?
850
871
  WHERE project_id = ?
851
872
  AND protocol_id = ?
@@ -1195,14 +1216,14 @@ function readinessMissingFields(state) {
1195
1216
  }
1196
1217
  function linkedRunsForProtocol(context, protocol, limit) {
1197
1218
  return context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1198
- FROM flow_runs
1219
+ FROM runs
1199
1220
  WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1200
1221
  ORDER BY updated_at DESC, id DESC
1201
1222
  LIMIT ?`, [protocol.project_id, protocol.id, limit]);
1202
1223
  }
1203
1224
  function resolveLinkedRun(context, protocol, runIdOrAlias) {
1204
1225
  const matches = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1205
- FROM flow_runs
1226
+ FROM runs
1206
1227
  WHERE project_id = ? AND (id = ? OR short_id = ?)
1207
1228
  ORDER BY updated_at DESC, id DESC`, [protocol.project_id, runIdOrAlias, runIdOrAlias]);
1208
1229
  if (matches.length === 1)