@deksden-com/dd-flow-cli 0.4.0 → 0.4.2

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 +22 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +45 -29
  5. package/dist/cli/run-cli.js +229 -49
  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 +17 -5
  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 +3 -75
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +124 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +33 -26
  29. package/dist/services/merge-worker.js +2 -1
  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 +77 -46
  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
@@ -32,7 +32,7 @@ export function registerProtocol(context, input) {
32
32
  workspacePath,
33
33
  now
34
34
  });
35
- const existing = findProtocol(context, protocolId);
35
+ const existing = findProtocol(context, protocolId, project.id);
36
36
  const params = [
37
37
  protocolId,
38
38
  input.handshakeId,
@@ -55,7 +55,7 @@ export function registerProtocol(context, input) {
55
55
  route_json, workspace_json, blockers_json, active_def_json, state_path, plan_path,
56
56
  created_at, updated_at)
57
57
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
58
- ON CONFLICT(id) DO UPDATE SET
58
+ ON CONFLICT(project_id, id) DO UPDATE SET
59
59
  handshake_id = excluded.handshake_id,
60
60
  project_id = excluded.project_id,
61
61
  project_root = excluded.project_root,
@@ -81,15 +81,16 @@ export function registerProtocol(context, input) {
81
81
  runtime_dir: runtimeDir
82
82
  }
83
83
  });
84
- return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId)), state };
84
+ return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId, project.id)), state };
85
85
  }
86
86
  export function getProtocolStatus(context, input) {
87
- const protocol = requireProtocol(context, input.protocolId);
87
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
88
+ const protocol = requireProtocol(context, input.protocolId, project.id);
88
89
  const runtime = readProtocolRuntimeState(context, protocol);
89
90
  const state = runtime.state;
90
91
  const plan = readPlanFile(protocol.plan_path);
91
92
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
92
- const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
93
+ const queue = context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
93
94
  const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
94
95
  return {
95
96
  ok: true,
@@ -100,15 +101,15 @@ export function getProtocolStatus(context, input) {
100
101
  state,
101
102
  plan: plan ? summarizePlan(plan) : state.plan,
102
103
  merge_queue: queue,
103
- branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
104
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
104
105
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
105
- worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
106
+ worktree: context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]),
106
107
  hook_status: hookStatusForProject(context, protocol.project_id),
107
108
  codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
108
109
  flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
109
110
  codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
110
111
  codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
111
- audit: getAuditEvents(context, protocol.id)
112
+ audit: getAuditEvents(context, protocol.project_id, protocol.id)
112
113
  };
113
114
  }
114
115
  export function getReadyProtocols(context, input) {
@@ -128,7 +129,7 @@ export function getReadyProtocols(context, input) {
128
129
  }
129
130
  export function getProtocolBlockers(context, input) {
130
131
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
131
- const document = requireProtocolDocument(project.root, input.protocolId);
132
+ const document = requireProtocolDocument(context, project.root, input.protocolId);
132
133
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
133
134
  return {
134
135
  ok: true,
@@ -143,13 +144,13 @@ export function getProtocolBlockers(context, input) {
143
144
  }
144
145
  export function implementProtocol(context, input) {
145
146
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
146
- const document = requireProtocolDocument(project.root, input.protocolId);
147
- const protocol = findProtocol(context, document.id);
147
+ const document = requireProtocolDocument(context, project.root, input.protocolId);
148
+ const protocol = findProtocol(context, document.id, project.id);
148
149
  const runtime = protocol ? readProtocolRuntimeState(context, protocol) : null;
149
150
  const state = runtime?.state ?? null;
150
151
  const terminal = protocolTerminalStatus(context, project.id, project.root, document.id, state);
151
152
  const activeSessions = activeFlowSessionBindingsForProject(context, project.id).filter((session) => session.protocol_id === document.id);
152
- const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [document.id]);
153
+ const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [project.id, document.id]);
153
154
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
154
155
  const unresolved = blockers.filter((blocker) => !blocker.resolved);
155
156
  const diagnostics = [
@@ -205,7 +206,7 @@ export function implementProtocol(context, input) {
205
206
  blockers,
206
207
  active_sessions: activeSessions,
207
208
  merge_queue: queue ?? null,
208
- branch_context: protocol ? getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context : null,
209
+ branch_context: protocol ? getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context : null,
209
210
  lifecycle,
210
211
  terminal,
211
212
  related_context: document.related_context,
@@ -242,7 +243,8 @@ export function implementProtocol(context, input) {
242
243
  };
243
244
  }
244
245
  export function transitionProtocol(context, input) {
245
- const protocol = requireProtocol(context, input.protocolId);
246
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
247
+ const protocol = requireProtocol(context, input.protocolId, project.id);
246
248
  const state = readProtocolRuntimeState(context, protocol).state;
247
249
  const flowContract = flowContractForState(state);
248
250
  const from = requireStage(state.stage, flowContract);
@@ -276,10 +278,11 @@ export function transitionProtocol(context, input) {
276
278
  payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
277
279
  });
278
280
  const runDiagnostics = protocolRunDiagnostics(context, protocol, nextState);
279
- const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
281
+ const queue = context.db.get("SELECT * FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
280
282
  return {
281
283
  ok: true,
282
284
  protocol_id: protocol.id,
285
+ project_root: project.root,
283
286
  from,
284
287
  to,
285
288
  forced: input.force,
@@ -289,13 +292,15 @@ export function transitionProtocol(context, input) {
289
292
  };
290
293
  }
291
294
  export function readyForMerge(context, input) {
292
- const protocol = requireProtocol(context, input.protocolId);
295
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
296
+ const protocol = requireProtocol(context, input.protocolId, project.id);
293
297
  const state = readProtocolRuntimeState(context, protocol).state;
294
298
  const flowContract = flowContractForState(state);
295
299
  const stage = requireStage(state.stage, flowContract);
296
300
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
297
- const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
298
- protocol.id
301
+ const branchContext = getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context;
302
+ const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [
303
+ protocol.project_id, protocol.id
299
304
  ]);
300
305
  if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
301
306
  appendAudit(context, {
@@ -307,9 +312,10 @@ export function readyForMerge(context, input) {
307
312
  return {
308
313
  ok: true,
309
314
  protocol_id: protocol.id,
315
+ project_root: project.root,
310
316
  queue_status: existingQueueJob.status,
311
317
  state,
312
- branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
318
+ branch_context: branchContext,
313
319
  lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
314
320
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
315
321
  };
@@ -345,7 +351,7 @@ export function readyForMerge(context, input) {
345
351
  context.db.run(`INSERT INTO merge_queue
346
352
  (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
347
353
  VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
348
- ON CONFLICT(protocol_id) DO UPDATE SET
354
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET
349
355
  status = excluded.status,
350
356
  claimed_by_session_id = NULL,
351
357
  claimed_at = NULL,
@@ -360,15 +366,17 @@ export function readyForMerge(context, input) {
360
366
  return {
361
367
  ok: true,
362
368
  protocol_id: protocol.id,
369
+ project_root: project.root,
363
370
  queue_status: "ready",
364
371
  state: nextState,
365
- branch_context: getProtocolBranchContext(context, { protocolId: protocol.id }).branch_context,
372
+ branch_context: branchContext,
366
373
  lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
367
374
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
368
375
  };
369
376
  }
370
377
  export function syncProtocolFromRun(context, input) {
371
- const protocol = requireProtocol(context, input.protocolId);
378
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
379
+ const protocol = requireProtocol(context, input.protocolId, project.id);
372
380
  const state = readProtocolRuntimeState(context, protocol).state;
373
381
  const run = resolveLinkedRun(context, protocol, input.runId);
374
382
  if (run.subject_type !== "protocol" || run.subject_id !== protocol.id) {
@@ -405,7 +413,7 @@ export function syncProtocolFromRun(context, input) {
405
413
  context.db.run(`INSERT INTO merge_queue
406
414
  (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
407
415
  VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
408
- ON CONFLICT(protocol_id) DO UPDATE SET
416
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET
409
417
  status = CASE WHEN merge_queue.status IN ('merged', 'cancelled', 'failed') THEN merge_queue.status ELSE excluded.status END,
410
418
  updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
411
419
  }
@@ -415,7 +423,7 @@ export function syncProtocolFromRun(context, input) {
415
423
  last_reason = COALESCE(last_reason, ?),
416
424
  completed_at = COALESCE(completed_at, ?),
417
425
  updated_at = ?
418
- WHERE protocol_id = ?`, [`synced from ${run.id}`, now, now, protocol.id]);
426
+ WHERE project_id = ? AND protocol_id = ?`, [`synced from ${run.id}`, now, now, protocol.project_id, protocol.id]);
419
427
  }
420
428
  appendAudit(context, {
421
429
  protocolId: protocol.id,
@@ -440,6 +448,7 @@ export function syncProtocolFromRun(context, input) {
440
448
  return {
441
449
  ok: true,
442
450
  protocol_id: protocol.id,
451
+ project_root: project.root,
443
452
  run_id: run.id,
444
453
  from: state.stage,
445
454
  to: targetStage,
@@ -457,14 +466,15 @@ export function syncProtocolFromRun(context, input) {
457
466
  };
458
467
  }
459
468
  export function cancelProtocol(context, input) {
460
- const protocol = requireProtocol(context, input.protocolId);
469
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
470
+ const protocol = requireProtocol(context, input.protocolId, project.id);
461
471
  const reason = input.reason.trim();
462
472
  if (!reason) {
463
473
  throw new AppError("validation", "protocol cancel requires --reason", 2);
464
474
  }
465
475
  const state = readProtocolRuntimeState(context, protocol).state;
466
476
  const now = context.now();
467
- const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE protocol_id = ?", [protocol.id]);
477
+ const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
468
478
  const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
469
479
  context.db.exec("BEGIN IMMEDIATE");
470
480
  try {
@@ -497,7 +507,7 @@ export function cancelProtocol(context, input) {
497
507
  const nextWorktreeStatus = worktreeRemoval?.status ?? "removed";
498
508
  const result = worktree
499
509
  ? context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
500
- WHERE protocol_id = ? AND status = 'active'`, [nextWorktreeStatus, now, now, protocol.id])
510
+ WHERE project_id = ? AND protocol_id = ? AND status = 'active'`, [nextWorktreeStatus, now, now, protocol.project_id, protocol.id])
501
511
  : { changes: 0 };
502
512
  worktreeOutcome = {
503
513
  ok: true,
@@ -532,12 +542,13 @@ export function cancelProtocol(context, input) {
532
542
  return {
533
543
  ok: true,
534
544
  protocol_id: protocol.id,
545
+ project_root: project.root,
535
546
  state: nextState,
536
547
  queue: cancelledQueue,
537
548
  closed_sessions: closedSessions,
538
549
  lock_release: lockRelease,
539
550
  worktree_outcome: worktreeOutcome,
540
- worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]) ?? null
551
+ worktree: context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]) ?? null
541
552
  };
542
553
  }
543
554
  catch (error) {
@@ -545,15 +556,23 @@ export function cancelProtocol(context, input) {
545
556
  throw error;
546
557
  }
547
558
  }
548
- export function requireProtocol(context, protocolId) {
549
- const protocol = findProtocol(context, protocolId);
550
- if (!protocol) {
559
+ export function requireProtocol(context, protocolId, projectId) {
560
+ const matches = projectId
561
+ ? context.db.all("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId])
562
+ : context.db.all("SELECT * FROM protocols WHERE id = ? ORDER BY project_id", [protocolId]);
563
+ if (matches.length === 0) {
551
564
  throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1);
552
565
  }
553
- return protocol;
566
+ if (matches.length > 1) {
567
+ throw new AppError("ambiguous_protocol", `Protocol id is ambiguous without project context: ${protocolId}`, 1, {
568
+ protocol_id: protocolId,
569
+ candidates: matches.map((protocol) => ({ project_id: protocol.project_id, project_root: protocol.project_root }))
570
+ });
571
+ }
572
+ return matches[0];
554
573
  }
555
574
  function cancelQueueForProtocol(context, protocol, reason, force) {
556
- const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
575
+ const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
557
576
  if (!job) {
558
577
  return { ok: true, skipped: true, reason: "no_queue_job" };
559
578
  }
@@ -678,7 +697,7 @@ function removeLocalFeatureBranch(projectRoot, branch, force) {
678
697
  function releaseRelatedMergeLocks(context, protocol, reason) {
679
698
  const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
680
699
  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));
681
- const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
700
+ const job = context.db.get("SELECT claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
682
701
  if (job?.claimed_by_session_id) {
683
702
  workers.push(job.claimed_by_session_id);
684
703
  }
@@ -720,7 +739,7 @@ export function readProtocolRuntimeState(context, protocol) {
720
739
  recommended_action: "legacy protocol state_path was moved to stable runtime storage"
721
740
  });
722
741
  const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
723
- context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
742
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
724
743
  protocol.state_path = stableStatePath;
725
744
  protocol.plan_path = stablePlanPath;
726
745
  return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
@@ -733,7 +752,7 @@ export function readProtocolRuntimeState(context, protocol) {
733
752
  recommended_action: "runtime state reconstructed from SQLite; run cleanup scan/apply if this persists"
734
753
  });
735
754
  }
736
- const plan = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocol.id]);
755
+ const plan = context.db.get("SELECT plan_json FROM plans WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
737
756
  const planSummary = plan
738
757
  ? planSummaryForState(JSON.parse(plan.plan_json))
739
758
  : { plan_id: null, total: 0, done: 0, blocked: 0 };
@@ -757,7 +776,7 @@ export function readProtocolRuntimeState(context, protocol) {
757
776
  const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
758
777
  ensureDir(path.dirname(stableStatePath));
759
778
  writeState(stableStatePath, state);
760
- context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE id = ?", [stableStatePath, stablePlanPath, protocol.id]);
779
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
761
780
  const repairedFrom = protocol.state_path;
762
781
  protocol.state_path = stableStatePath;
763
782
  protocol.plan_path = stablePlanPath;
@@ -802,7 +821,7 @@ export function persistProtocolState(context, protocol, state) {
802
821
  context.db.run(`UPDATE protocols SET
803
822
  status = ?, stage = ?, next_action = ?, route_json = ?, workspace_json = ?,
804
823
  blockers_json = ?, active_def_json = ?, updated_at = ?
805
- WHERE id = ?`, [
824
+ WHERE project_id = ? AND id = ?`, [
806
825
  state.status,
807
826
  state.stage,
808
827
  state.next_action,
@@ -811,6 +830,7 @@ export function persistProtocolState(context, protocol, state) {
811
830
  JSON.stringify(state.blockers),
812
831
  JSON.stringify(state.active_def),
813
832
  state.updated_at,
833
+ protocol.project_id,
814
834
  protocol.id
815
835
  ]);
816
836
  if (state.status === "closed" || state.stage === "closed") {
@@ -822,8 +842,12 @@ export function persistProtocolState(context, protocol, state) {
822
842
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [now, now, protocol.project_id, protocol.id]);
823
843
  }
824
844
  }
825
- function findProtocol(context, protocolId) {
826
- return context.db.get("SELECT * FROM protocols WHERE id = ?", [protocolId]);
845
+ function findProtocol(context, protocolId, projectId) {
846
+ if (projectId) {
847
+ return context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
848
+ }
849
+ const matches = context.db.all("SELECT * FROM protocols WHERE id = ? ORDER BY project_id", [protocolId]);
850
+ return matches.length === 1 ? matches[0] : undefined;
827
851
  }
828
852
  function protocolDocumentsForProject(projectRoot) {
829
853
  const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
@@ -836,12 +860,19 @@ function protocolDocumentsForProject(projectRoot) {
836
860
  .filter((document) => Boolean(document))
837
861
  .sort((a, b) => a.id.localeCompare(b.id));
838
862
  }
839
- function requireProtocolDocument(projectRoot, protocolId) {
840
- const file = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
863
+ function requireProtocolDocument(context, projectRoot, protocolId) {
864
+ const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
865
+ const protocol = findProtocol(context, protocolId, project.id);
866
+ const workspace = protocol ? JSON.parse(protocol.workspace_json).worktree_path : null;
867
+ const workspaceFile = typeof workspace === "string" ? path.join(workspace, ".memory-bank", "protocol", `${protocolId}.md`) : null;
868
+ const file = workspaceFile && fs.existsSync(workspaceFile)
869
+ ? workspaceFile
870
+ : path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
841
871
  if (!fs.existsSync(file)) {
842
872
  throw new AppError("protocol_file_not_found", `Protocol markdown is not found: ${protocolId}`, 1, {
843
873
  protocol_id: protocolId,
844
- expected_path: file
874
+ expected_path: file,
875
+ workspace_path: workspace
845
876
  });
846
877
  }
847
878
  const document = readProtocolDocument(file);
@@ -917,9 +948,9 @@ function protocolDocumentDiagnostics(document) {
917
948
  return diagnostics;
918
949
  }
919
950
  function protocolSetMemberStatus(context, projectId, document) {
920
- const runtime = findProtocol(context, document.id);
951
+ const runtime = findProtocol(context, document.id, projectId);
921
952
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
922
- const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE protocol_id = ?", [document.id]);
953
+ const queue = context.db.get("SELECT status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [projectId, document.id]);
923
954
  const activeSessions = activeFlowSessionBindingsForProject(context, projectId).filter((session) => session.protocol_id === document.id);
924
955
  const projectRoot = projectRootFromProtocolFile(document.path);
925
956
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
@@ -983,7 +1014,7 @@ export function protocolSetBoardForProtocol(context, projectId, projectRoot, pro
983
1014
  return protocolSetBoardsForProject(context, projectId, projectRoot).find((board) => board.protocol_set === document.protocol_set) ?? null;
984
1015
  }
985
1016
  function blockerStatus(context, projectId, projectRoot, blockerId) {
986
- const runtime = findProtocol(context, blockerId);
1017
+ const runtime = findProtocol(context, blockerId, projectId);
987
1018
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
988
1019
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
989
1020
  const lifecycle = normalizeProtocolLifecycle({ state, rawStage: runtime?.stage, rawStatus: runtime?.status });
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function refreshRunSessionProjection(context, projectId, runId) {
4
+ const run = context.db.get("SELECT id, runtime_path, run_index_path, index_json FROM flow_runs WHERE project_id = ? AND id = ?", [projectId, runId]);
5
+ if (!run)
6
+ return;
7
+ const index = JSON.parse(run.index_json);
8
+ const sessions = context.db.all(`SELECT session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
9
+ created_at, updated_at, stopped_at, coverage_units_json
10
+ FROM flow_sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]).map(sessionProjection);
11
+ if (JSON.stringify(index.sessions ?? []) === JSON.stringify(sessions))
12
+ return;
13
+ index.sessions = sessions;
14
+ index.updated_at = context.now();
15
+ const runtimeRevision = typeof index.runtime_revision === "number" ? index.runtime_revision + 1 : 1;
16
+ index.runtime_revision = runtimeRevision;
17
+ const runtime = readJson(run.runtime_path);
18
+ if (runtime) {
19
+ runtime.sessions = sessions;
20
+ runtime.updated_at = index.updated_at;
21
+ runtime.runtime_revision = runtimeRevision;
22
+ }
23
+ writeJson(run.run_index_path, index);
24
+ writeJson(run.runtime_path, runtime ?? index);
25
+ context.db.run("UPDATE flow_runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
26
+ }
27
+ function sessionProjection(row) {
28
+ return {
29
+ session_id: row.session_id,
30
+ parent_session_id: row.parent_session_id,
31
+ role: row.role,
32
+ session_kind: row.session_kind,
33
+ worker_id: row.worker_id,
34
+ current_stage: row.current_stage,
35
+ status: row.status,
36
+ created_at: row.created_at,
37
+ updated_at: row.updated_at,
38
+ stopped_at: row.stopped_at,
39
+ coverage_units: parseCoverageUnits(row.coverage_units_json)
40
+ };
41
+ }
42
+ function parseCoverageUnits(value) {
43
+ if (!value)
44
+ return [];
45
+ try {
46
+ const parsed = JSON.parse(value);
47
+ if (!Array.isArray(parsed))
48
+ return [];
49
+ return parsed.flatMap((item) => {
50
+ if (!item || typeof item !== "object" || Array.isArray(item))
51
+ return [];
52
+ const object = item;
53
+ if (typeof object.unit_id !== "string" || object.unit_id.length === 0)
54
+ return [];
55
+ return [{
56
+ unit_id: object.unit_id,
57
+ group_id: typeof object.group_id === "string" ? object.group_id : null,
58
+ job_id: typeof object.job_id === "string" ? object.job_id : null,
59
+ kind: typeof object.kind === "string" ? object.kind : null
60
+ }];
61
+ });
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ function readJson(file) {
68
+ try {
69
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ function writeJson(file, value) {
76
+ fs.mkdirSync(path.dirname(file), { recursive: true });
77
+ const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
78
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
79
+ fs.renameSync(temporary, file);
80
+ }