@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
@@ -15,6 +15,7 @@ import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } f
15
15
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
16
16
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
17
17
  import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
18
+ import { getProtocolBranchContext } from "./branch-context.js";
18
19
  export function registerProtocol(context, input) {
19
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
20
21
  const project = requireProjectByRoot(context, projectRoot);
@@ -31,7 +32,7 @@ export function registerProtocol(context, input) {
31
32
  workspacePath,
32
33
  now
33
34
  });
34
- const existing = findProtocol(context, protocolId);
35
+ const existing = findProtocol(context, protocolId, project.id);
35
36
  const params = [
36
37
  protocolId,
37
38
  input.handshakeId,
@@ -54,7 +55,7 @@ export function registerProtocol(context, input) {
54
55
  route_json, workspace_json, blockers_json, active_def_json, state_path, plan_path,
55
56
  created_at, updated_at)
56
57
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
57
- ON CONFLICT(id) DO UPDATE SET
58
+ ON CONFLICT(project_id, id) DO UPDATE SET
58
59
  handshake_id = excluded.handshake_id,
59
60
  project_id = excluded.project_id,
60
61
  project_root = excluded.project_root,
@@ -80,15 +81,16 @@ export function registerProtocol(context, input) {
80
81
  runtime_dir: runtimeDir
81
82
  }
82
83
  });
83
- return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId)), state };
84
+ return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId, project.id)), state };
84
85
  }
85
86
  export function getProtocolStatus(context, input) {
86
- const protocol = requireProtocol(context, input.protocolId);
87
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
88
+ const protocol = requireProtocol(context, input.protocolId, project.id);
87
89
  const runtime = readProtocolRuntimeState(context, protocol);
88
90
  const state = runtime.state;
89
91
  const plan = readPlanFile(protocol.plan_path);
90
92
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
91
- 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]);
92
94
  const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
93
95
  return {
94
96
  ok: true,
@@ -99,14 +101,15 @@ export function getProtocolStatus(context, input) {
99
101
  state,
100
102
  plan: plan ? summarizePlan(plan) : state.plan,
101
103
  merge_queue: queue,
104
+ branch_context: getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context,
102
105
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
103
- 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]),
104
107
  hook_status: hookStatusForProject(context, protocol.project_id),
105
108
  codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
106
109
  flow_sessions: activeFlowSessionBindingsForProject(context, protocol.project_id).filter((session) => session.protocol_id === protocol.id),
107
110
  codex_session_bindings: activeCodexSessionBindingsForProject(context, protocol.project_id).filter((binding) => binding.protocol_id === protocol.id),
108
111
  codex_hook_events: codexHookEventsForProject(context, protocol.project_id).filter((event) => event.protocol_id === protocol.id),
109
- audit: getAuditEvents(context, protocol.id)
112
+ audit: getAuditEvents(context, protocol.project_id, protocol.id)
110
113
  };
111
114
  }
112
115
  export function getReadyProtocols(context, input) {
@@ -126,7 +129,7 @@ export function getReadyProtocols(context, input) {
126
129
  }
127
130
  export function getProtocolBlockers(context, input) {
128
131
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
129
- const document = requireProtocolDocument(project.root, input.protocolId);
132
+ const document = requireProtocolDocument(context, project.root, input.protocolId);
130
133
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
131
134
  return {
132
135
  ok: true,
@@ -141,13 +144,13 @@ export function getProtocolBlockers(context, input) {
141
144
  }
142
145
  export function implementProtocol(context, input) {
143
146
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
144
- const document = requireProtocolDocument(project.root, input.protocolId);
145
- const protocol = findProtocol(context, document.id);
147
+ const document = requireProtocolDocument(context, project.root, input.protocolId);
148
+ const protocol = findProtocol(context, document.id, project.id);
146
149
  const runtime = protocol ? readProtocolRuntimeState(context, protocol) : null;
147
150
  const state = runtime?.state ?? null;
148
151
  const terminal = protocolTerminalStatus(context, project.id, project.root, document.id, state);
149
152
  const activeSessions = activeFlowSessionBindingsForProject(context, project.id).filter((session) => session.protocol_id === document.id);
150
- 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]);
151
154
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
152
155
  const unresolved = blockers.filter((blocker) => !blocker.resolved);
153
156
  const diagnostics = [
@@ -203,6 +206,7 @@ export function implementProtocol(context, input) {
203
206
  blockers,
204
207
  active_sessions: activeSessions,
205
208
  merge_queue: queue ?? null,
209
+ branch_context: protocol ? getProtocolBranchContext(context, { protocolId: protocol.id, projectRoot: project.root }).branch_context : null,
206
210
  lifecycle,
207
211
  terminal,
208
212
  related_context: document.related_context,
@@ -239,7 +243,8 @@ export function implementProtocol(context, input) {
239
243
  };
240
244
  }
241
245
  export function transitionProtocol(context, input) {
242
- const protocol = requireProtocol(context, input.protocolId);
246
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
247
+ const protocol = requireProtocol(context, input.protocolId, project.id);
243
248
  const state = readProtocolRuntimeState(context, protocol).state;
244
249
  const flowContract = flowContractForState(state);
245
250
  const from = requireStage(state.stage, flowContract);
@@ -273,10 +278,11 @@ export function transitionProtocol(context, input) {
273
278
  payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
274
279
  });
275
280
  const runDiagnostics = protocolRunDiagnostics(context, protocol, nextState);
276
- 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]);
277
282
  return {
278
283
  ok: true,
279
284
  protocol_id: protocol.id,
285
+ project_root: project.root,
280
286
  from,
281
287
  to,
282
288
  forced: input.force,
@@ -286,13 +292,15 @@ export function transitionProtocol(context, input) {
286
292
  };
287
293
  }
288
294
  export function readyForMerge(context, input) {
289
- const protocol = requireProtocol(context, input.protocolId);
295
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
296
+ const protocol = requireProtocol(context, input.protocolId, project.id);
290
297
  const state = readProtocolRuntimeState(context, protocol).state;
291
298
  const flowContract = flowContractForState(state);
292
299
  const stage = requireStage(state.stage, flowContract);
293
300
  const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
294
- const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
295
- 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
296
304
  ]);
297
305
  if (existingQueueJob && ["claimed", "merged", "cancelled", "failed"].includes(existingQueueJob.status)) {
298
306
  appendAudit(context, {
@@ -304,8 +312,10 @@ export function readyForMerge(context, input) {
304
312
  return {
305
313
  ok: true,
306
314
  protocol_id: protocol.id,
315
+ project_root: project.root,
307
316
  queue_status: existingQueueJob.status,
308
317
  state,
318
+ branch_context: branchContext,
309
319
  lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
310
320
  flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
311
321
  };
@@ -341,7 +351,7 @@ export function readyForMerge(context, input) {
341
351
  context.db.run(`INSERT INTO merge_queue
342
352
  (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
343
353
  VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
344
- ON CONFLICT(protocol_id) DO UPDATE SET
354
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET
345
355
  status = excluded.status,
346
356
  claimed_by_session_id = NULL,
347
357
  claimed_at = NULL,
@@ -356,14 +366,17 @@ export function readyForMerge(context, input) {
356
366
  return {
357
367
  ok: true,
358
368
  protocol_id: protocol.id,
369
+ project_root: project.root,
359
370
  queue_status: "ready",
360
371
  state: nextState,
372
+ branch_context: branchContext,
361
373
  lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
362
374
  flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
363
375
  };
364
376
  }
365
377
  export function syncProtocolFromRun(context, input) {
366
- const protocol = requireProtocol(context, input.protocolId);
378
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
379
+ const protocol = requireProtocol(context, input.protocolId, project.id);
367
380
  const state = readProtocolRuntimeState(context, protocol).state;
368
381
  const run = resolveLinkedRun(context, protocol, input.runId);
369
382
  if (run.subject_type !== "protocol" || run.subject_id !== protocol.id) {
@@ -400,7 +413,7 @@ export function syncProtocolFromRun(context, input) {
400
413
  context.db.run(`INSERT INTO merge_queue
401
414
  (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
402
415
  VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
403
- ON CONFLICT(protocol_id) DO UPDATE SET
416
+ ON CONFLICT(project_id, protocol_id) DO UPDATE SET
404
417
  status = CASE WHEN merge_queue.status IN ('merged', 'cancelled', 'failed') THEN merge_queue.status ELSE excluded.status END,
405
418
  updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
406
419
  }
@@ -410,7 +423,7 @@ export function syncProtocolFromRun(context, input) {
410
423
  last_reason = COALESCE(last_reason, ?),
411
424
  completed_at = COALESCE(completed_at, ?),
412
425
  updated_at = ?
413
- 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]);
414
427
  }
415
428
  appendAudit(context, {
416
429
  protocolId: protocol.id,
@@ -435,6 +448,7 @@ export function syncProtocolFromRun(context, input) {
435
448
  return {
436
449
  ok: true,
437
450
  protocol_id: protocol.id,
451
+ project_root: project.root,
438
452
  run_id: run.id,
439
453
  from: state.stage,
440
454
  to: targetStage,
@@ -452,14 +466,15 @@ export function syncProtocolFromRun(context, input) {
452
466
  };
453
467
  }
454
468
  export function cancelProtocol(context, input) {
455
- const protocol = requireProtocol(context, input.protocolId);
469
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
470
+ const protocol = requireProtocol(context, input.protocolId, project.id);
456
471
  const reason = input.reason.trim();
457
472
  if (!reason) {
458
473
  throw new AppError("validation", "protocol cancel requires --reason", 2);
459
474
  }
460
475
  const state = readProtocolRuntimeState(context, protocol).state;
461
476
  const now = context.now();
462
- 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]);
463
478
  const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
464
479
  context.db.exec("BEGIN IMMEDIATE");
465
480
  try {
@@ -492,7 +507,7 @@ export function cancelProtocol(context, input) {
492
507
  const nextWorktreeStatus = worktreeRemoval?.status ?? "removed";
493
508
  const result = worktree
494
509
  ? context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ?
495
- 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])
496
511
  : { changes: 0 };
497
512
  worktreeOutcome = {
498
513
  ok: true,
@@ -527,12 +542,13 @@ export function cancelProtocol(context, input) {
527
542
  return {
528
543
  ok: true,
529
544
  protocol_id: protocol.id,
545
+ project_root: project.root,
530
546
  state: nextState,
531
547
  queue: cancelledQueue,
532
548
  closed_sessions: closedSessions,
533
549
  lock_release: lockRelease,
534
550
  worktree_outcome: worktreeOutcome,
535
- 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
536
552
  };
537
553
  }
538
554
  catch (error) {
@@ -540,15 +556,23 @@ export function cancelProtocol(context, input) {
540
556
  throw error;
541
557
  }
542
558
  }
543
- export function requireProtocol(context, protocolId) {
544
- const protocol = findProtocol(context, protocolId);
545
- 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) {
546
564
  throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1);
547
565
  }
548
- 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];
549
573
  }
550
574
  function cancelQueueForProtocol(context, protocol, reason, force) {
551
- 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]);
552
576
  if (!job) {
553
577
  return { ok: true, skipped: true, reason: "no_queue_job" };
554
578
  }
@@ -673,7 +697,7 @@ function removeLocalFeatureBranch(projectRoot, branch, force) {
673
697
  function releaseRelatedMergeLocks(context, protocol, reason) {
674
698
  const workers = context.db.all(`SELECT DISTINCT worker_id FROM flow_sessions
675
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));
676
- 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]);
677
701
  if (job?.claimed_by_session_id) {
678
702
  workers.push(job.claimed_by_session_id);
679
703
  }
@@ -715,7 +739,7 @@ export function readProtocolRuntimeState(context, protocol) {
715
739
  recommended_action: "legacy protocol state_path was moved to stable runtime storage"
716
740
  });
717
741
  const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
718
- 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]);
719
743
  protocol.state_path = stableStatePath;
720
744
  protocol.plan_path = stablePlanPath;
721
745
  return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
@@ -728,7 +752,7 @@ export function readProtocolRuntimeState(context, protocol) {
728
752
  recommended_action: "runtime state reconstructed from SQLite; run cleanup scan/apply if this persists"
729
753
  });
730
754
  }
731
- 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]);
732
756
  const planSummary = plan
733
757
  ? planSummaryForState(JSON.parse(plan.plan_json))
734
758
  : { plan_id: null, total: 0, done: 0, blocked: 0 };
@@ -752,7 +776,7 @@ export function readProtocolRuntimeState(context, protocol) {
752
776
  const stablePlanPath = runtimePlanJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
753
777
  ensureDir(path.dirname(stableStatePath));
754
778
  writeState(stableStatePath, state);
755
- 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]);
756
780
  const repairedFrom = protocol.state_path;
757
781
  protocol.state_path = stableStatePath;
758
782
  protocol.plan_path = stablePlanPath;
@@ -797,7 +821,7 @@ export function persistProtocolState(context, protocol, state) {
797
821
  context.db.run(`UPDATE protocols SET
798
822
  status = ?, stage = ?, next_action = ?, route_json = ?, workspace_json = ?,
799
823
  blockers_json = ?, active_def_json = ?, updated_at = ?
800
- WHERE id = ?`, [
824
+ WHERE project_id = ? AND id = ?`, [
801
825
  state.status,
802
826
  state.stage,
803
827
  state.next_action,
@@ -806,6 +830,7 @@ export function persistProtocolState(context, protocol, state) {
806
830
  JSON.stringify(state.blockers),
807
831
  JSON.stringify(state.active_def),
808
832
  state.updated_at,
833
+ protocol.project_id,
809
834
  protocol.id
810
835
  ]);
811
836
  if (state.status === "closed" || state.stage === "closed") {
@@ -817,8 +842,12 @@ export function persistProtocolState(context, protocol, state) {
817
842
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [now, now, protocol.project_id, protocol.id]);
818
843
  }
819
844
  }
820
- function findProtocol(context, protocolId) {
821
- 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;
822
851
  }
823
852
  function protocolDocumentsForProject(projectRoot) {
824
853
  const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
@@ -831,12 +860,19 @@ function protocolDocumentsForProject(projectRoot) {
831
860
  .filter((document) => Boolean(document))
832
861
  .sort((a, b) => a.id.localeCompare(b.id));
833
862
  }
834
- function requireProtocolDocument(projectRoot, protocolId) {
835
- 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`);
836
871
  if (!fs.existsSync(file)) {
837
872
  throw new AppError("protocol_file_not_found", `Protocol markdown is not found: ${protocolId}`, 1, {
838
873
  protocol_id: protocolId,
839
- expected_path: file
874
+ expected_path: file,
875
+ workspace_path: workspace
840
876
  });
841
877
  }
842
878
  const document = readProtocolDocument(file);
@@ -912,9 +948,9 @@ function protocolDocumentDiagnostics(document) {
912
948
  return diagnostics;
913
949
  }
914
950
  function protocolSetMemberStatus(context, projectId, document) {
915
- const runtime = findProtocol(context, document.id);
951
+ const runtime = findProtocol(context, document.id, projectId);
916
952
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
917
- 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]);
918
954
  const activeSessions = activeFlowSessionBindingsForProject(context, projectId).filter((session) => session.protocol_id === document.id);
919
955
  const projectRoot = projectRootFromProtocolFile(document.path);
920
956
  const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
@@ -978,7 +1014,7 @@ export function protocolSetBoardForProtocol(context, projectId, projectRoot, pro
978
1014
  return protocolSetBoardsForProject(context, projectId, projectRoot).find((board) => board.protocol_set === document.protocol_set) ?? null;
979
1015
  }
980
1016
  function blockerStatus(context, projectId, projectRoot, blockerId) {
981
- const runtime = findProtocol(context, blockerId);
1017
+ const runtime = findProtocol(context, blockerId, projectId);
982
1018
  const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
983
1019
  const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
984
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
+ }