@deksden-com/dd-flow-cli 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +58 -32
  5. package/dist/cli/run-cli.js +282 -50
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +266 -0
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +8 -77
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +123 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +240 -20
  29. package/dist/services/merge-worker.js +12 -4
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +78 -42
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { persistProtocolState, protocolRunDiagnostics, readProtocolRuntimeState,
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
7
  import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner, waitAcquireLaneLock } from "./lanes.js";
8
8
  import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
9
+ import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
9
10
  const mergeLockTtlSeconds = 300;
10
11
  export function getMergeQueueStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -14,6 +15,9 @@ export function getMergeQueueStatus(context, input) {
14
15
  queue: queueForProject(context, project.id)
15
16
  };
16
17
  }
18
+ export function getMergeBundleStatus(context, input) {
19
+ return getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath });
20
+ }
17
21
  export function claimNextMergeJob(context, input) {
18
22
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
23
  const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
@@ -55,7 +59,7 @@ export function claimNextMergeJob(context, input) {
55
59
  eventType: "merge_queue.claimed",
56
60
  payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
57
61
  });
58
- transitionClaimedProtocolToIntegration(context, job.protocol_id, input.workerId, now);
62
+ transitionClaimedProtocolToIntegration(context, project.id, job.protocol_id, input.workerId, now);
59
63
  claimedProtocolId = job.protocol_id;
60
64
  context.db.exec("COMMIT");
61
65
  }
@@ -63,12 +67,73 @@ export function claimNextMergeJob(context, input) {
63
67
  context.db.exec("ROLLBACK");
64
68
  throw error;
65
69
  }
66
- return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null, {
70
+ return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
67
71
  ok: true,
68
72
  outcome: claimedProtocolId ? "claimed" : "empty",
69
73
  claimed: Boolean(claimedProtocolId)
70
74
  });
71
75
  }
76
+ export function claimMergeBundle(context, input) {
77
+ const { project, protocolIds } = requireClaimableBranchBundle(context, {
78
+ projectRoot: input.projectRoot,
79
+ workspacePath: input.workspacePath
80
+ });
81
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
82
+ if (stopState.stopped) {
83
+ throw new AppError("merge_worker_stopped", "Stopped or stopping merge worker cannot claim a merge bundle", 1, {
84
+ worker_id: input.workerId,
85
+ status: stopState.status,
86
+ reason: stopState.reason
87
+ });
88
+ }
89
+ requireLaneLockOwner(context, {
90
+ projectRoot: project.root,
91
+ lane: "merge",
92
+ workerId: input.workerId,
93
+ workspacePath: input.workspacePath
94
+ });
95
+ const now = context.now();
96
+ context.db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ for (const protocolId of protocolIds) {
99
+ const update = context.db.run(`UPDATE merge_queue
100
+ SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
101
+ WHERE protocol_id = ? AND project_id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, protocolId, project.id]);
102
+ if (update.changes !== 1) {
103
+ throw new AppError("merge_bundle_claim_race", "Branch bundle changed while claiming", 1, {
104
+ protocol_id: protocolId,
105
+ protocol_ids: protocolIds
106
+ });
107
+ }
108
+ transitionClaimedProtocolToIntegration(context, project.id, protocolId, input.workerId, now);
109
+ appendAudit(context, {
110
+ protocolId,
111
+ projectId: project.id,
112
+ eventType: "merge_bundle.claimed_protocol",
113
+ payload: { protocol_id: protocolId, worker_id: input.workerId, protocol_ids: protocolIds }
114
+ });
115
+ }
116
+ appendAudit(context, {
117
+ projectId: project.id,
118
+ eventType: "merge_bundle.claimed",
119
+ payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
120
+ });
121
+ context.db.exec("COMMIT");
122
+ }
123
+ catch (error) {
124
+ context.db.exec("ROLLBACK");
125
+ throw error;
126
+ }
127
+ return mergeBundleResult(context, {
128
+ ok: true,
129
+ outcome: "claimed",
130
+ claimed: true,
131
+ projectRoot: project.root,
132
+ workspacePath: input.workspacePath,
133
+ workerId: input.workerId,
134
+ protocolIds
135
+ });
136
+ }
72
137
  export async function waitNextMergeJob(context, input) {
73
138
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
74
139
  const timeoutSeconds = input.timeoutSeconds ?? 0;
@@ -213,8 +278,9 @@ export async function waitNextMergeJob(context, input) {
213
278
  }
214
279
  }
215
280
  export function completeMergeJob(context, input) {
216
- const protocol = requireProtocol(context, input.protocolId);
217
- const job = requireClaimedJob(context, input.protocolId, input.workerId);
281
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
282
+ const protocol = requireProtocol(context, input.protocolId, project.id);
283
+ const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
218
284
  requireLaneLockOwner(context, {
219
285
  projectRoot: protocol.project_root,
220
286
  lane: "merge",
@@ -243,7 +309,7 @@ export function completeMergeJob(context, input) {
243
309
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
244
310
  });
245
311
  const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge complete after stop request");
246
- return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
312
+ return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
247
313
  ok: true,
248
314
  outcome: "merged",
249
315
  merged: true,
@@ -251,9 +317,75 @@ export function completeMergeJob(context, input) {
251
317
  stop_after_current
252
318
  });
253
319
  }
320
+ export function completeMergeBundle(context, input) {
321
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
322
+ requireLaneLockOwner(context, {
323
+ projectRoot: project.root,
324
+ lane: "merge",
325
+ workerId: input.workerId,
326
+ workspacePath: input.workspacePath
327
+ });
328
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
329
+ if (protocolIds.length === 0) {
330
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
331
+ worker_id: input.workerId,
332
+ workspace_path: input.workspacePath
333
+ });
334
+ }
335
+ const now = context.now();
336
+ context.db.exec("BEGIN IMMEDIATE");
337
+ try {
338
+ for (const protocolId of protocolIds) {
339
+ const protocol = requireProtocol(context, protocolId, project.id);
340
+ const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
341
+ context.db.run(`UPDATE merge_queue
342
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
343
+ WHERE id = ?`, [input.summary, now, now, job.id]);
344
+ const state = readProtocolRuntimeState(context, protocol).state;
345
+ const flowContract = loadProjectFlowContract(protocol.project_root);
346
+ const targetStage = flowContract.merge_queue.complete.target_stage;
347
+ const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
348
+ persistProtocolState(context, protocol, {
349
+ ...state,
350
+ stage: targetStage,
351
+ status: targetStatus,
352
+ next_action: flowContract.merge_queue.complete.next_action,
353
+ updated_at: now
354
+ });
355
+ appendAudit(context, {
356
+ protocolId,
357
+ projectId: project.id,
358
+ eventType: "merge_bundle.completed_protocol",
359
+ payload: { protocol_id: protocolId, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
360
+ });
361
+ }
362
+ appendAudit(context, {
363
+ projectId: project.id,
364
+ eventType: "merge_bundle.completed",
365
+ payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
366
+ });
367
+ context.db.exec("COMMIT");
368
+ }
369
+ catch (error) {
370
+ context.db.exec("ROLLBACK");
371
+ throw error;
372
+ }
373
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
374
+ return mergeBundleResult(context, {
375
+ ok: true,
376
+ outcome: "merged",
377
+ merged: true,
378
+ projectRoot: project.root,
379
+ workspacePath: input.workspacePath,
380
+ workerId: input.workerId,
381
+ protocolIds,
382
+ stop_after_current
383
+ });
384
+ }
254
385
  export function noteMergeJob(context, input) {
255
- const protocol = requireProtocol(context, input.protocolId);
256
- const job = queueJobByProtocol(context, protocol.id);
386
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
387
+ const protocol = requireProtocol(context, input.protocolId, project.id);
388
+ const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
257
389
  if (!job) {
258
390
  throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
259
391
  }
@@ -279,11 +411,12 @@ export function noteMergeJob(context, input) {
279
411
  eventType: "merge_queue.note",
280
412
  payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
281
413
  });
282
- return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "noted" });
414
+ return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "noted" });
283
415
  }
284
416
  export function failMergeJob(context, input) {
285
- const protocol = requireProtocol(context, input.protocolId);
286
- const job = requireClaimedJob(context, input.protocolId, input.workerId);
417
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
418
+ const protocol = requireProtocol(context, input.protocolId, project.id);
419
+ const job = requireClaimedJob(context, protocol.project_id, input.protocolId, input.workerId);
287
420
  requireLaneLockOwner(context, {
288
421
  projectRoot: protocol.project_root,
289
422
  lane: "merge",
@@ -313,14 +446,81 @@ export function failMergeJob(context, input) {
313
446
  payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
314
447
  });
315
448
  const stop_after_current = stopAfterCurrentIfRequested(context, protocol.project_id, protocol.project_root, input.workerId, "merge fail after stop request");
316
- return mergeQueueResult(queueJobByProtocol(context, protocol.id), {
449
+ return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), {
317
450
  ok: true,
318
451
  outcome: input.requeue ? "requeued" : "failed",
319
452
  stop_after_current
320
453
  });
321
454
  }
455
+ export function failMergeBundle(context, input) {
456
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
457
+ requireLaneLockOwner(context, {
458
+ projectRoot: project.root,
459
+ lane: "merge",
460
+ workerId: input.workerId,
461
+ workspacePath: input.workspacePath
462
+ });
463
+ const protocolIds = claimedBundleProtocolIds(context, { projectRoot: project.root, workspacePath: input.workspacePath, workerId: input.workerId });
464
+ if (protocolIds.length === 0) {
465
+ throw new AppError("merge_bundle_not_claimed", "No claimed protocols found for this branch bundle and worker", 1, {
466
+ worker_id: input.workerId,
467
+ workspace_path: input.workspacePath
468
+ });
469
+ }
470
+ const now = context.now();
471
+ const status = input.requeue ? "requeued" : "failed";
472
+ context.db.exec("BEGIN IMMEDIATE");
473
+ try {
474
+ for (const protocolId of protocolIds) {
475
+ const protocol = requireProtocol(context, protocolId, project.id);
476
+ const job = requireClaimedJob(context, project.id, protocolId, input.workerId);
477
+ context.db.run(`UPDATE merge_queue
478
+ SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
479
+ WHERE id = ?`, [status, input.reason, now, job.id]);
480
+ if (input.requeue) {
481
+ const state = readProtocolRuntimeState(context, protocol).state;
482
+ persistProtocolState(context, protocol, {
483
+ ...state,
484
+ stage: "ready_for_merge",
485
+ status: "running",
486
+ next_action: "merge_requeued",
487
+ updated_at: now
488
+ });
489
+ }
490
+ appendAudit(context, {
491
+ protocolId,
492
+ projectId: project.id,
493
+ eventType: "merge_bundle.failed_protocol",
494
+ reason: input.reason,
495
+ payload: { protocol_id: protocolId, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
496
+ });
497
+ }
498
+ appendAudit(context, {
499
+ projectId: project.id,
500
+ eventType: "merge_bundle.failed",
501
+ reason: input.reason,
502
+ payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
503
+ });
504
+ context.db.exec("COMMIT");
505
+ }
506
+ catch (error) {
507
+ context.db.exec("ROLLBACK");
508
+ throw error;
509
+ }
510
+ const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
511
+ return mergeBundleResult(context, {
512
+ ok: true,
513
+ outcome: input.requeue ? "requeued" : "failed",
514
+ projectRoot: project.root,
515
+ workspacePath: input.workspacePath,
516
+ workerId: input.workerId,
517
+ protocolIds,
518
+ stop_after_current
519
+ });
520
+ }
322
521
  export function cancelMergeQueueJob(context, input) {
323
- const protocol = requireProtocol(context, input.protocolId);
522
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
523
+ const protocol = requireProtocol(context, input.protocolId, project.id);
324
524
  const reason = input.reason.trim();
325
525
  if (!reason) {
326
526
  throw new AppError("validation", "merge-queue cancel requires --reason", 2);
@@ -328,7 +528,7 @@ export function cancelMergeQueueJob(context, input) {
328
528
  const now = context.now();
329
529
  context.db.exec("BEGIN IMMEDIATE");
330
530
  try {
331
- const job = queueJobByProtocol(context, protocol.id);
531
+ const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
332
532
  if (!job) {
333
533
  throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
334
534
  }
@@ -376,13 +576,13 @@ export function cancelMergeQueueJob(context, input) {
376
576
  context.db.exec("ROLLBACK");
377
577
  throw error;
378
578
  }
379
- return mergeQueueResult(queueJobByProtocol(context, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
579
+ return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
380
580
  }
381
581
  export function queueForProject(context, projectId) {
382
582
  return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
383
583
  }
384
- function queueJobByProtocol(context, protocolId) {
385
- return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
584
+ function queueJobByProtocol(context, projectId, protocolId) {
585
+ return context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
386
586
  }
387
587
  function mergeQueueResult(job, extra) {
388
588
  const queueItem = job ?? null;
@@ -407,8 +607,28 @@ function mergeQueueResult(job, extra) {
407
607
  job: queueItem
408
608
  };
409
609
  }
410
- function requireClaimedJob(context, protocolId, sessionId) {
411
- const job = queueJobByProtocol(context, protocolId);
610
+ function mergeBundleResult(context, input) {
611
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
612
+ return {
613
+ ...input,
614
+ protocol_ids: input.protocolIds,
615
+ branch_context: branchContext,
616
+ queue_items: input.protocolIds.map((protocolId) => {
617
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
618
+ return queueJobByProtocol(context, project.id, protocolId) ?? null;
619
+ })
620
+ };
621
+ }
622
+ function claimedBundleProtocolIds(context, input) {
623
+ const branchContext = getProtocolBranchContext(context, { projectRoot: input.projectRoot, workspacePath: input.workspacePath }).branch_context;
624
+ const protocols = Array.isArray(branchContext.protocols) ? branchContext.protocols : [];
625
+ return protocols
626
+ .filter((protocol) => protocol.queue_status === "claimed" && protocol.queue_claimed_by === input.workerId)
627
+ .map((protocol) => String(protocol.id))
628
+ .filter(Boolean);
629
+ }
630
+ function requireClaimedJob(context, projectId, protocolId, sessionId) {
631
+ const job = queueJobByProtocol(context, projectId, protocolId);
412
632
  if (!job || job.status !== "claimed") {
413
633
  throw new AppError("merge_job_not_claimed", "Merge job is not claimed", 1, { protocol_id: protocolId });
414
634
  }
@@ -420,8 +640,8 @@ function requireClaimedJob(context, protocolId, sessionId) {
420
640
  }
421
641
  return job;
422
642
  }
423
- function transitionClaimedProtocolToIntegration(context, protocolId, workerId, now) {
424
- const protocol = requireProtocol(context, protocolId);
643
+ function transitionClaimedProtocolToIntegration(context, projectId, protocolId, workerId, now) {
644
+ const protocol = requireProtocol(context, protocolId, projectId);
425
645
  const state = readProtocolRuntimeState(context, protocol).state;
426
646
  const flowContract = loadProjectFlowContract(protocol.project_root);
427
647
  if (!["ready_for_merge", "queued_for_merge"].includes(state.stage)) {
@@ -7,13 +7,17 @@ import { requireProjectByRoot } from "./projects.js";
7
7
  import { buildStaticFlowGuidance } from "./flow-guidance.js";
8
8
  import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
9
  import { registerFlowSession, stopMergeWorker } from "./sessions.js";
10
+ import { getProtocolBranchContext } from "./branch-context.js";
10
11
  export function getMergeWorkerStatus(context, input) {
11
12
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
12
13
  return {
13
14
  ok: true,
14
15
  project: { id: project.id, root: project.root },
15
16
  merge_worker: detectMergeWorkerState(context, project.id),
16
- queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job }))
17
+ queue: queueForProject(context, project.id).map((job) => withJobGuidance(context, { ...job })),
18
+ branch_context: input.workspacePath
19
+ ? getProtocolBranchContext(context, { projectRoot: project.root, workspacePath: input.workspacePath }).branch_context
20
+ : null
17
21
  };
18
22
  }
19
23
  export function startMergeWorker(context, input) {
@@ -29,7 +33,8 @@ export function startMergeWorker(context, input) {
29
33
  outcome: "status_only",
30
34
  reason: "merge_worker_already_active",
31
35
  merge_worker: detection,
32
- queue: queueForProject(context, project.id)
36
+ queue: queueForProject(context, project.id),
37
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
33
38
  };
34
39
  }
35
40
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath, branch: input.branch });
@@ -98,7 +103,8 @@ export function oneShotMergeClaim(context, input) {
98
103
  outcome: "status_only",
99
104
  reason: "active_merge_worker_or_lock",
100
105
  merge_worker: detection,
101
- queue: queueForProject(context, project.id)
106
+ queue: queueForProject(context, project.id),
107
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context
102
108
  };
103
109
  }
104
110
  ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath });
@@ -143,6 +149,7 @@ export function oneShotMergeClaim(context, input) {
143
149
  : null,
144
150
  job: claimed.job ? withJobGuidance(context, { ...claimed.job }) : null,
145
151
  lock: claimed.job ? lock.lock : null,
152
+ branch_context: getProtocolBranchContext(context, { projectRoot: project.root, workspacePath }).branch_context,
146
153
  flow_guidance: claimed.job ? withJobGuidance(context, { ...claimed.job }).flow_guidance : undefined
147
154
  };
148
155
  }
@@ -151,7 +158,8 @@ function withJobGuidance(context, job) {
151
158
  if (!protocolId)
152
159
  return job;
153
160
  try {
154
- const protocol = requireProtocol(context, protocolId);
161
+ const projectId = typeof job.project_id === "string" ? job.project_id : undefined;
162
+ const protocol = requireProtocol(context, protocolId, projectId);
155
163
  const state = readProtocolRuntimeState(context, protocol).state;
156
164
  return {
157
165
  ...job,
@@ -3,6 +3,70 @@ import path from "node:path";
3
3
  import { AppError } from "../shared/errors.js";
4
4
  import { requireProjectByRoot } from "./projects.js";
5
5
  import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
6
+ import { resolveCanonRoot } from "./canon.js";
7
+ export function assessMigrationImpact(context, input) {
8
+ const root = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
9
+ if (!root.root)
10
+ throw new AppError("not_found", `Project root does not exist: ${input.projectRoot}`, 1);
11
+ const canon = resolveCanonRoot(context);
12
+ if (!canon.ok || !canon.canon)
13
+ throw new AppError("canon_unavailable", "Cannot resolve canonical Memory Bank for upgrade impact assessment", 1, { blockers: canon.blockers, bootstrap: canon.bootstrap });
14
+ const status = getProjectVersionStatus({ projectRoot: root.root, rootSource: root.root_source, canon: { root: canon.canon.root, metadata: canon.canon.metadata } });
15
+ const from = status?.memory_bank.version ?? null;
16
+ const to = input.targetVersion ?? canon.canon.version;
17
+ const forced = input.mode === "full_migration";
18
+ const impacts = readImpacts(path.join(canon.canon.memorybank_root, "release-impact"));
19
+ const chain = [];
20
+ const domains = new Set();
21
+ const blockers = [];
22
+ let mode = "patch";
23
+ if (!from || !to)
24
+ blockers.push("project or canonical Memory Bank version is unavailable");
25
+ let current = from;
26
+ while (current && to && current !== to) {
27
+ const impact = impacts.find((item) => item.from_version === current);
28
+ if (!impact) {
29
+ blockers.push(`missing adjacent release impact from ${current}`);
30
+ break;
31
+ }
32
+ chain.push(impact);
33
+ for (const domain of impact.domains)
34
+ domains.add(domain);
35
+ mode = stricterMode(mode, impact.minimum_upgrade_mode);
36
+ if (impact.path_migration || impact.runtime_migration)
37
+ mode = "full_migration";
38
+ current = impact.to_version;
39
+ }
40
+ if (current && to && current !== to)
41
+ blockers.push(`release impact chain does not reach ${to}`);
42
+ if (status?.flow_pack.status !== "present" && from !== to)
43
+ blockers.push("project flow-pack provenance is not present");
44
+ if (blockers.length > 0)
45
+ mode = "full_migration";
46
+ if (forced)
47
+ mode = "full_migration";
48
+ return { ok: true, schema_id: "dd-flow/mb-upgrade-impact-assessment@1", project_root: root.root, source_version: from, target_version: to, mode, forced_full_migration: forced, chain, domains: [...domains].sort(), blockers, required_checks: [...new Set(chain.flatMap((item) => item.required_checks))], next_action: mode === "full_migration" ? "run full mb-upgrade contour" : "run selected mb-upgrade contour" };
49
+ }
50
+ function readImpacts(dir) {
51
+ if (!fs.existsSync(dir))
52
+ return [];
53
+ return fs.readdirSync(dir).filter((file) => file.endsWith(".json")).flatMap((file) => {
54
+ try {
55
+ const value = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
56
+ if (value.schema_id !== "dd-flow/release-impact@1")
57
+ return [];
58
+ if (typeof value.from_version !== "string" || typeof value.to_version !== "string" || !Array.isArray(value.domains) || !Array.isArray(value.required_checks))
59
+ return [];
60
+ if (!["patch", "targeted_minor", "full_migration"].includes(String(value.minimum_upgrade_mode)))
61
+ return [];
62
+ return [{ from_version: value.from_version, to_version: value.to_version, minimum_upgrade_mode: value.minimum_upgrade_mode, domains: value.domains.filter((item) => typeof item === "string"), path_migration: value.path_migration === true, runtime_migration: value.runtime_migration === true, required_checks: value.required_checks.filter((item) => typeof item === "string") }];
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ });
68
+ }
69
+ function stricterMode(left, right) { return ["patch", "targeted_minor", "full_migration"][Math.max(["patch", "targeted_minor", "full_migration"].indexOf(left), ["patch", "targeted_minor", "full_migration"].indexOf(right))]; }
6
70
  export function planMigration(context, input) {
7
71
  const rootResolution = resolveStatusProjectRoot({ requestedRoot: input.projectRoot, cwd: process.cwd() });
8
72
  if (!rootResolution.root) {
@@ -6,15 +6,17 @@ import { parseJsonObject } from "../shared/json.js";
6
6
  import { ensureReadableFile } from "../storage/database.js";
7
7
  import { appendAudit } from "./audit.js";
8
8
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
+ import { requireProjectByRoot } from "./projects.js";
10
+ import { resolveProjectRoot } from "../storage/paths.js";
9
11
  import { writePlanFile } from "../protocol/local-files.js";
10
12
  export function setProtocolPlan(context, input) {
11
- const protocol = requireProtocol(context, input.protocolId);
13
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
12
14
  ensureReadableFile(input.file);
13
15
  const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
14
16
  writePlanFile(protocol.plan_path, plan);
15
- context.db.run(`INSERT INTO plans (protocol_id, plan_json, updated_at)
16
- VALUES (?, ?, ?)
17
- ON CONFLICT(protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.id, JSON.stringify(plan), context.now()]);
17
+ context.db.run(`INSERT INTO plans (project_id, protocol_id, plan_json, updated_at)
18
+ VALUES (?, ?, ?, ?)
19
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.project_id, protocol.id, JSON.stringify(plan), context.now()]);
18
20
  updateStatePlanSummary(context, protocol, plan);
19
21
  appendAudit(context, {
20
22
  protocolId: protocol.id,
@@ -25,8 +27,8 @@ export function setProtocolPlan(context, input) {
25
27
  return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
26
28
  }
27
29
  export function getPlanStatus(context, input) {
28
- const protocol = requireProtocol(context, input.protocolId);
29
- const plan = requireStoredPlan(context, protocol.id);
30
+ const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
31
+ const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
30
32
  return {
31
33
  ok: true,
32
34
  protocol_id: protocol.id,
@@ -43,7 +45,7 @@ export function getPlanStatus(context, input) {
43
45
  };
44
46
  }
45
47
  export function startPlanItem(context, input) {
46
- return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
48
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
47
49
  assertDependenciesClosed(plan, item);
48
50
  return {
49
51
  ...item,
@@ -53,7 +55,7 @@ export function startPlanItem(context, input) {
53
55
  });
54
56
  }
55
57
  export function completePlanItem(context, input) {
56
- return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
58
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
57
59
  assertDependenciesClosed(plan, item);
58
60
  return {
59
61
  ...item,
@@ -64,7 +66,7 @@ export function completePlanItem(context, input) {
64
66
  });
65
67
  }
66
68
  export function blockPlanItem(context, input) {
67
- return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
69
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
68
70
  ...item,
69
71
  status: "blocked",
70
72
  summary: input.reason,
@@ -73,15 +75,15 @@ export function blockPlanItem(context, input) {
73
75
  }));
74
76
  }
75
77
  export function skipPlanItem(context, input) {
76
- return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
78
+ return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
77
79
  ...item,
78
80
  status: "skipped",
79
81
  summary: input.reason
80
82
  }));
81
83
  }
82
- function updatePlanItem(context, protocolId, itemId, transform) {
83
- const protocol = requireProtocol(context, protocolId);
84
- const plan = requireStoredPlan(context, protocol.id);
84
+ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
85
+ const protocol = scopedProtocol(context, projectRoot, protocolId);
86
+ const plan = requireStoredPlan(context, protocol.project_id, protocol.id);
85
87
  const item = plan.items.find((candidate) => candidate.id === itemId);
86
88
  if (!item) {
87
89
  throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
@@ -92,9 +94,10 @@ function updatePlanItem(context, protocolId, itemId, transform) {
92
94
  };
93
95
  const validated = validatePlan(nextPlan, protocol.id);
94
96
  writePlanFile(protocol.plan_path, validated);
95
- context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE protocol_id = ?", [
97
+ context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ?", [
96
98
  JSON.stringify(validated),
97
99
  context.now(),
100
+ protocol.project_id,
98
101
  protocol.id
99
102
  ]);
100
103
  updateStatePlanSummary(context, protocol, validated);
@@ -111,8 +114,12 @@ function updatePlanItem(context, protocolId, itemId, transform) {
111
114
  });
112
115
  return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
113
116
  }
114
- function requireStoredPlan(context, protocolId) {
115
- const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocolId]);
117
+ function scopedProtocol(context, projectRoot, protocolId) {
118
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
119
+ return requireProtocol(context, protocolId, project.id);
120
+ }
121
+ function requireStoredPlan(context, projectId, protocolId) {
122
+ const row = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
116
123
  if (!row) {
117
124
  throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
118
125
  }
@@ -91,7 +91,7 @@ export function resolveProject(context, input) {
91
91
  }
92
92
  const details = { input: input.idOrAlias };
93
93
  if (!isFullEntityId(input.idOrAlias) && !isShortEntityId(input.idOrAlias)) {
94
- details.expected = "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, root path, or --root for root-based commands.";
94
+ details.expected = "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, root path, or --root for root-based commands.";
95
95
  }
96
96
  throw new AppError("not_found", `Project is not registered: ${input.idOrAlias}`, 1, details);
97
97
  }
@@ -108,7 +108,7 @@ export function requireProjectByReference(context, reference) {
108
108
  }
109
109
  throw new AppError("not_found", `Project is not registered: ${reference}`, 1, {
110
110
  input: reference,
111
- expected: "Use full id PRJ-NNN-slug, short alias PRJ-NNN, slug, or project root path."
111
+ expected: "Use full id PRJ-<sequence>-slug, short alias PRJ-<sequence>, slug, or project root path."
112
112
  });
113
113
  }
114
114
  export function migrateProjectIds(context, input) {