@deksden-com/dd-flow-cli 0.2.0 → 0.3.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 (47) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +76 -5
  3. package/dist/build-info.json +10 -6
  4. package/dist/cli/help.js +165 -20
  5. package/dist/cli/run-cli.js +427 -17
  6. package/dist/schemas/compatibility.schema.json +105 -0
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +90 -0
  9. package/dist/schemas/flow-run-index.schema.json +30 -4
  10. package/dist/schemas/global-dashboard-data.schema.json +126 -0
  11. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  12. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  13. package/dist/schemas/plan-stage-report.schema.json +83 -0
  14. package/dist/schemas/project-dashboard-data.schema.json +122 -0
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +5 -1
  16. package/dist/schemas/project-summary.schema.json +73 -0
  17. package/dist/schemas/protocol-dashboard-data.schema.json +112 -0
  18. package/dist/schemas/status-report.schema.json +38 -2
  19. package/dist/schemas/version-report.schema.json +22 -0
  20. package/dist/services/build-info.js +26 -3
  21. package/dist/services/canon.js +93 -22
  22. package/dist/services/cleanup.js +45 -1
  23. package/dist/services/cli-operation-classifier.js +104 -0
  24. package/dist/services/compatibility-preflight.js +124 -0
  25. package/dist/services/config.js +31 -0
  26. package/dist/services/dashboard-targets.js +95 -0
  27. package/dist/services/dashboard.js +972 -10
  28. package/dist/services/engines.js +532 -0
  29. package/dist/services/flow-guidance.js +221 -0
  30. package/dist/services/hooks.js +1 -1
  31. package/dist/services/ids.js +106 -0
  32. package/dist/services/lanes.js +333 -1
  33. package/dist/services/merge-queue.js +106 -16
  34. package/dist/services/merge-worker.js +67 -4
  35. package/dist/services/migrations.js +231 -0
  36. package/dist/services/project-summary.js +122 -0
  37. package/dist/services/projects.js +44 -3
  38. package/dist/services/protocol-lifecycle.js +144 -0
  39. package/dist/services/protocols.js +660 -7
  40. package/dist/services/runs.js +98 -21
  41. package/dist/services/schema-validation.js +84 -4
  42. package/dist/services/sessions.js +21 -4
  43. package/dist/services/status.js +199 -1
  44. package/dist/services/version-status.js +59 -9
  45. package/dist/storage/database.js +31 -0
  46. package/dist/storage/paths.js +33 -0
  47. package/package.json +3 -2
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
+ import { parse as parseYaml } from "yaml";
4
5
  import { canTransition } from "../domain/contracts.js";
5
6
  import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
6
7
  import { requireStage } from "../domain/validation.js";
@@ -12,6 +13,8 @@ import { appendAudit, getAuditEvents } from "./audit.js";
12
13
  import { requireProjectByRoot } from "./projects.js";
13
14
  import { ensureRuntimeProtocolFiles, readPlanFile, readStateFile, writeState } from "../protocol/local-files.js";
14
15
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
16
+ import { buildProtocolFlowGuidance } from "./flow-guidance.js";
17
+ import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
15
18
  export function registerProtocol(context, input) {
16
19
  const projectRoot = resolveProjectRoot(input.projectRoot);
17
20
  const project = requireProjectByRoot(context, projectRoot);
@@ -84,13 +87,19 @@ export function getProtocolStatus(context, input) {
84
87
  const runtime = readProtocolRuntimeState(context, protocol);
85
88
  const state = runtime.state;
86
89
  const plan = readPlanFile(protocol.plan_path);
90
+ const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
91
+ const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
92
+ const lifecycle = normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null });
87
93
  return {
88
94
  ok: true,
89
- protocol: protocolStatusPayload(protocol),
90
- diagnostics: runtime.diagnostics,
95
+ protocol: { ...protocolStatusPayload(protocol), lifecycle },
96
+ lifecycle,
97
+ diagnostics: [...runtime.diagnostics, ...runDiagnostics.diagnostics, ...lifecycle.diagnostics],
98
+ latest_run: runDiagnostics.latest_run,
91
99
  state,
92
100
  plan: plan ? summarizePlan(plan) : state.plan,
93
- merge_queue: context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]),
101
+ merge_queue: queue,
102
+ flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null }),
94
103
  worktree: context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocol.id]),
95
104
  hook_status: hookStatusForProject(context, protocol.project_id),
96
105
  codex_home_profiles: codexHomeProfilesForProject(context, protocol.project_id),
@@ -100,6 +109,135 @@ export function getProtocolStatus(context, input) {
100
109
  audit: getAuditEvents(context, protocol.id)
101
110
  };
102
111
  }
112
+ export function getReadyProtocols(context, input) {
113
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
114
+ const protocols = protocolDocumentsForProject(project.root).map((document) => protocolSetMemberStatus(context, project.id, document));
115
+ const boards = protocolSetBoardsForProject(context, project.id, project.root);
116
+ return {
117
+ ok: true,
118
+ project_root: project.root,
119
+ protocols,
120
+ ready: protocols.filter((item) => item.set_status === "ready").map((item) => item.id),
121
+ blocked: protocols.filter((item) => item.set_status === "blocked").map((item) => item.id),
122
+ running: protocols.filter((item) => item.set_status === "running" || item.set_status === "claimed").map((item) => item.id),
123
+ done: protocols.filter((item) => item.set_status === "done").map((item) => item.id),
124
+ protocol_set_boards: boards
125
+ };
126
+ }
127
+ export function getProtocolBlockers(context, input) {
128
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
129
+ const document = requireProtocolDocument(project.root, input.protocolId);
130
+ const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
131
+ return {
132
+ ok: true,
133
+ project_root: project.root,
134
+ protocol_id: document.id,
135
+ protocol_path: document.path,
136
+ protocol_set: document.protocol_set,
137
+ blocked_by_protocols: document.blocked_by_protocols,
138
+ blockers,
139
+ blocked: blockers.some((blocker) => !blocker.resolved)
140
+ };
141
+ }
142
+ export function implementProtocol(context, input) {
143
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
144
+ const document = requireProtocolDocument(project.root, input.protocolId);
145
+ const protocol = findProtocol(context, document.id);
146
+ const runtime = protocol ? readProtocolRuntimeState(context, protocol) : null;
147
+ const state = runtime?.state ?? null;
148
+ const terminal = protocolTerminalStatus(context, project.id, project.root, document.id, state);
149
+ 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]);
151
+ const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, project.id, project.root, id));
152
+ const unresolved = blockers.filter((blocker) => !blocker.resolved);
153
+ const diagnostics = [
154
+ ...(runtime?.diagnostics ?? []),
155
+ ...(protocol ? protocolRunDiagnostics(context, protocol, state ?? undefined).diagnostics : []),
156
+ ...protocolDocumentDiagnostics(document)
157
+ ];
158
+ if (terminal.terminal) {
159
+ throw new AppError("protocol_terminal", "Protocol is terminal and must not be implemented again", 1, {
160
+ protocol_id: document.id,
161
+ terminal
162
+ });
163
+ }
164
+ if (unresolved.length > 0 && !input.force) {
165
+ throw new AppError("protocol_blocked", "Protocol is blocked by unresolved protocols; use --force --reason to override", 1, {
166
+ protocol_id: document.id,
167
+ blockers: unresolved
168
+ });
169
+ }
170
+ if (activeSessions.length > 0 && !input.force) {
171
+ throw new AppError("protocol_claimed", "Protocol already has an active flow session; use --force --reason to override", 1, {
172
+ protocol_id: document.id,
173
+ active_sessions: activeSessions
174
+ });
175
+ }
176
+ if (input.force && (!input.reason || input.reason.trim().length === 0)) {
177
+ throw new AppError("validation", "Forced protocol implement preflight requires --reason", 2);
178
+ }
179
+ const lifecycle = protocol
180
+ ? normalizeProtocolLifecycle({ state, queueStatus: queue?.status ?? null })
181
+ : normalizeProtocolLifecycle({ rawStage: "unregistered", rawStatus: "missing" });
182
+ const currentStage = state?.stage ?? "unregistered";
183
+ const recommendedPrompt = promptForImplementationStage(currentStage);
184
+ return {
185
+ ok: true,
186
+ mode: "preflight",
187
+ protocol_id: document.id,
188
+ project_root: project.root,
189
+ protocol_path: document.path,
190
+ protocol_set: document.protocol_set,
191
+ force: input.force,
192
+ reason: input.reason ?? null,
193
+ runtime: protocol
194
+ ? {
195
+ registered: true,
196
+ status: protocol.status,
197
+ stage: protocol.stage,
198
+ next_action: protocol.next_action,
199
+ state_path: protocol.state_path,
200
+ plan_path: protocol.plan_path
201
+ }
202
+ : { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
203
+ blockers,
204
+ active_sessions: activeSessions,
205
+ merge_queue: queue ?? null,
206
+ lifecycle,
207
+ terminal,
208
+ related_context: document.related_context,
209
+ coding_standards_sources: document.coding_standards_sources,
210
+ diagnostics,
211
+ flow_guidance: {
212
+ current_stage: currentStage,
213
+ recommended_next_action: protocol ? recommendedNextActionForImplementationStage(currentStage) : "register_protocol",
214
+ recommended_prompt: recommendedPrompt,
215
+ required_predecessor_evidence: currentStage === "unregistered" ? [`dd-flow protocol register ${document.id} --project-root ${JSON.stringify(project.root)} --json`] : [],
216
+ guards: [
217
+ {
218
+ id: "protocol_blockers_resolved",
219
+ status: unresolved.length === 0 || input.force ? "pass" : "fail",
220
+ summary: unresolved.length === 0
221
+ ? "No unresolved blocked_by_protocols dependencies."
222
+ : input.force
223
+ ? "Unresolved blockers were force-overridden."
224
+ : "Protocol has unresolved blocked_by_protocols dependencies."
225
+ },
226
+ {
227
+ id: "protocol_not_terminal",
228
+ status: terminal.terminal ? "fail" : "pass",
229
+ summary: terminal.terminal ? `Protocol is terminal via ${terminal.source}.` : "Protocol is not terminal."
230
+ },
231
+ {
232
+ id: "protocol_not_claimed",
233
+ status: activeSessions.length === 0 || input.force ? "pass" : "fail",
234
+ summary: activeSessions.length === 0 ? "No active flow sessions claim this protocol." : "Active flow session already claims this protocol."
235
+ }
236
+ ],
237
+ blocked_if_missing: protocol ? [] : ["runtime protocol registration"]
238
+ }
239
+ };
240
+ }
103
241
  export function transitionProtocol(context, input) {
104
242
  const protocol = requireProtocol(context, input.protocolId);
105
243
  const state = readProtocolRuntimeState(context, protocol).state;
@@ -134,13 +272,25 @@ export function transitionProtocol(context, input) {
134
272
  ...(input.reason ? { reason: input.reason } : {}),
135
273
  payload: { from, to, payload, flow_contract_id: flowContract.id, flow_contract_version: flowContract.version }
136
274
  });
137
- return { ok: true, protocol_id: protocol.id, from, to, forced: input.force, state: nextState };
275
+ const runDiagnostics = protocolRunDiagnostics(context, protocol, nextState);
276
+ const queue = context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocol.id]);
277
+ return {
278
+ ok: true,
279
+ protocol_id: protocol.id,
280
+ from,
281
+ to,
282
+ forced: input.force,
283
+ state: nextState,
284
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: queue?.status ?? null }),
285
+ flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: queue?.status ?? null })
286
+ };
138
287
  }
139
288
  export function readyForMerge(context, input) {
140
289
  const protocol = requireProtocol(context, input.protocolId);
141
290
  const state = readProtocolRuntimeState(context, protocol).state;
142
291
  const flowContract = flowContractForState(state);
143
292
  const stage = requireStage(state.stage, flowContract);
293
+ const runDiagnostics = protocolRunDiagnostics(context, protocol, state);
144
294
  const existingQueueJob = context.db.get("SELECT status FROM merge_queue WHERE protocol_id = ?", [
145
295
  protocol.id
146
296
  ]);
@@ -151,13 +301,23 @@ export function readyForMerge(context, input) {
151
301
  eventType: "protocol.ready_for_merge_unchanged",
152
302
  payload: { protocol_id: protocol.id, queue_status: existingQueueJob.status, flow_contract_id: flowContract.id }
153
303
  });
154
- return { ok: true, protocol_id: protocol.id, queue_status: existingQueueJob.status, state };
304
+ return {
305
+ ok: true,
306
+ protocol_id: protocol.id,
307
+ queue_status: existingQueueJob.status,
308
+ state,
309
+ lifecycle: normalizeProtocolLifecycle({ state, queueStatus: existingQueueJob.status }),
310
+ flow_guidance: buildProtocolFlowGuidance({ state, latestRun: runDiagnostics.latest_run, queueStatus: existingQueueJob.status })
311
+ };
155
312
  }
156
313
  if (!flowContract.readiness.allowed_from.includes(stage)) {
157
314
  throw new AppError("readiness_invalid_stage", "Protocol state does not allow ready-for-merge", 1, {
158
315
  stage: state.stage,
159
316
  allowed: flowContract.readiness.allowed_from,
160
- flow_contract_id: flowContract.id
317
+ flow_contract_id: flowContract.id,
318
+ latest_run: runDiagnostics.latest_run,
319
+ diagnostics: runDiagnostics.diagnostics,
320
+ recommended_commands: recommendedSyncCommands(protocol, runDiagnostics.latest_run)
161
321
  });
162
322
  }
163
323
  const missing = readinessMissingFields(state);
@@ -193,7 +353,103 @@ export function readyForMerge(context, input) {
193
353
  eventType: "protocol.ready_for_merge",
194
354
  payload: { protocol_id: protocol.id, queue_status: "ready", flow_contract_id: flowContract.id }
195
355
  });
196
- return { ok: true, protocol_id: protocol.id, queue_status: "ready", state: nextState };
356
+ return {
357
+ ok: true,
358
+ protocol_id: protocol.id,
359
+ queue_status: "ready",
360
+ state: nextState,
361
+ lifecycle: normalizeProtocolLifecycle({ state: nextState, queueStatus: "ready" }),
362
+ flow_guidance: buildProtocolFlowGuidance({ state: nextState, latestRun: runDiagnostics.latest_run, queueStatus: "ready" })
363
+ };
364
+ }
365
+ export function syncProtocolFromRun(context, input) {
366
+ const protocol = requireProtocol(context, input.protocolId);
367
+ const state = readProtocolRuntimeState(context, protocol).state;
368
+ const run = resolveLinkedRun(context, protocol, input.runId);
369
+ if (run.subject_type !== "protocol" || run.subject_id !== protocol.id) {
370
+ throw new AppError("protocol_run_subject_mismatch", "Run subject does not match protocol", 1, {
371
+ protocol_id: protocol.id,
372
+ run_id: run.id,
373
+ subject: { type: run.subject_type, id: run.subject_id }
374
+ });
375
+ }
376
+ const index = parseLinkedRunIndex(run.index_json, run.id);
377
+ const flowContract = flowContractForState(state);
378
+ const targetStage = input.target === "auto" ? inferSyncTarget(run, index) : requireStage(input.target, flowContract);
379
+ if (!targetStage) {
380
+ throw new AppError("sync_target_ambiguous", "Cannot infer protocol sync target from run evidence", 1, {
381
+ protocol_id: protocol.id,
382
+ run: linkedRunSummary(run),
383
+ stage_chain: stageChain(index),
384
+ allowed_targets: Object.keys(flowContract.stages)
385
+ });
386
+ }
387
+ requireStage(targetStage, flowContract);
388
+ const now = context.now();
389
+ const nextState = {
390
+ ...state,
391
+ stage: targetStage,
392
+ status: statusForStage(targetStage, flowContract),
393
+ next_action: nextActionForSyncedTarget(targetStage),
394
+ updated_at: now
395
+ };
396
+ context.db.exec("BEGIN IMMEDIATE");
397
+ try {
398
+ persistProtocolState(context, protocol, nextState);
399
+ if (targetStage === "ready_for_merge") {
400
+ context.db.run(`INSERT INTO merge_queue
401
+ (protocol_id, project_id, status, claimed_by_session_id, claimed_at, completed_at, created_at, updated_at)
402
+ VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?)
403
+ ON CONFLICT(protocol_id) DO UPDATE SET
404
+ status = CASE WHEN merge_queue.status IN ('merged', 'cancelled', 'failed') THEN merge_queue.status ELSE excluded.status END,
405
+ updated_at = excluded.updated_at`, [protocol.id, protocol.project_id, "ready", now, now]);
406
+ }
407
+ if (targetStage === "closed") {
408
+ context.db.run(`UPDATE merge_queue
409
+ SET status = CASE WHEN status IN ('merged', 'cancelled', 'failed') THEN status ELSE 'merged' END,
410
+ last_reason = COALESCE(last_reason, ?),
411
+ completed_at = COALESCE(completed_at, ?),
412
+ updated_at = ?
413
+ WHERE protocol_id = ?`, [`synced from ${run.id}`, now, now, protocol.id]);
414
+ }
415
+ appendAudit(context, {
416
+ protocolId: protocol.id,
417
+ projectId: protocol.project_id,
418
+ eventType: "protocol.synced_from_run",
419
+ payload: {
420
+ protocol_id: protocol.id,
421
+ run_id: run.id,
422
+ from: state.stage,
423
+ to: targetStage,
424
+ target: input.target,
425
+ run: linkedRunSummary(run),
426
+ stage_chain: stageChain(index)
427
+ }
428
+ });
429
+ context.db.exec("COMMIT");
430
+ }
431
+ catch (error) {
432
+ context.db.exec("ROLLBACK");
433
+ throw error;
434
+ }
435
+ return {
436
+ ok: true,
437
+ protocol_id: protocol.id,
438
+ run_id: run.id,
439
+ from: state.stage,
440
+ to: targetStage,
441
+ state: nextState,
442
+ flow_guidance: buildProtocolFlowGuidance({
443
+ state: nextState,
444
+ latestRun: { ...linkedRunSummary(run), stage_chain: stageChain(index) },
445
+ queueStatus: targetStage === "ready_for_merge" ? "ready" : null
446
+ }),
447
+ evidence_used: {
448
+ run: linkedRunSummary(run),
449
+ stage_chain: stageChain(index)
450
+ },
451
+ diagnostics: protocolRunDiagnostics(context, protocol, nextState).diagnostics
452
+ };
197
453
  }
198
454
  export function cancelProtocol(context, input) {
199
455
  const protocol = requireProtocol(context, input.protocolId);
@@ -508,6 +764,33 @@ export function readProtocolRuntimeState(context, protocol) {
508
764
  });
509
765
  return { state, diagnostics };
510
766
  }
767
+ export function protocolRunDiagnostics(context, protocol, state) {
768
+ const runs = linkedRunsForProtocol(context, protocol, 5);
769
+ const latest = runs[0];
770
+ if (!latest) {
771
+ return { latest_run: null, diagnostics: [] };
772
+ }
773
+ const warnings = [];
774
+ const latestRun = linkedRunSummary(latest);
775
+ const latestIndex = safeLinkedRunIndex(latest.index_json, latest.id, warnings);
776
+ const latestWithStages = {
777
+ ...latestRun,
778
+ stage_chain: latestIndex ? stageChain(latestIndex) : []
779
+ };
780
+ const diagnostics = [...warnings];
781
+ if (state && isProtocolRunStageMismatch(state.stage, latest, latestIndex)) {
782
+ diagnostics.push({
783
+ code: "protocol_run_stage_mismatch",
784
+ severity: mismatchSeverity(latest, latestIndex),
785
+ protocol_id: protocol.id,
786
+ protocol_stage: state.stage,
787
+ protocol_status: state.status,
788
+ latest_run: latestWithStages,
789
+ recommended_commands: recommendedSyncCommands(protocol, latestWithStages)
790
+ });
791
+ }
792
+ return { latest_run: latestWithStages, diagnostics };
793
+ }
511
794
  export function persistProtocolState(context, protocol, state) {
512
795
  ensureDir(path.dirname(protocol.state_path));
513
796
  writeState(protocol.state_path, state);
@@ -537,6 +820,270 @@ export function persistProtocolState(context, protocol, state) {
537
820
  function findProtocol(context, protocolId) {
538
821
  return context.db.get("SELECT * FROM protocols WHERE id = ?", [protocolId]);
539
822
  }
823
+ function protocolDocumentsForProject(projectRoot) {
824
+ const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
825
+ if (!fs.existsSync(protocolRoot))
826
+ return [];
827
+ return fs
828
+ .readdirSync(protocolRoot, { withFileTypes: true })
829
+ .filter((entry) => entry.isFile() && entry.name.startsWith("PRT-") && entry.name.endsWith(".md"))
830
+ .map((entry) => readProtocolDocument(path.join(protocolRoot, entry.name)))
831
+ .filter((document) => Boolean(document))
832
+ .sort((a, b) => a.id.localeCompare(b.id));
833
+ }
834
+ function requireProtocolDocument(projectRoot, protocolId) {
835
+ const file = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
836
+ if (!fs.existsSync(file)) {
837
+ throw new AppError("protocol_file_not_found", `Protocol markdown is not found: ${protocolId}`, 1, {
838
+ protocol_id: protocolId,
839
+ expected_path: file
840
+ });
841
+ }
842
+ const document = readProtocolDocument(file);
843
+ if (!document) {
844
+ throw new AppError("protocol_frontmatter_invalid", `Protocol markdown frontmatter is invalid: ${protocolId}`, 1, {
845
+ protocol_id: protocolId,
846
+ path: file
847
+ });
848
+ }
849
+ return document;
850
+ }
851
+ function readProtocolDocument(file) {
852
+ const text = fs.readFileSync(file, "utf8");
853
+ const match = text.match(/^---\n([\s\S]*?)\n---/);
854
+ let frontmatter = {};
855
+ if (match?.[1]) {
856
+ try {
857
+ const parsed = parseYaml(match[1]);
858
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
859
+ frontmatter = parsed;
860
+ }
861
+ }
862
+ catch {
863
+ return null;
864
+ }
865
+ }
866
+ const basename = path.basename(file, ".md");
867
+ const fileField = stringValue(frontmatter.file);
868
+ const id = basename;
869
+ const protocolSet = stringValue(frontmatter.protocol_set);
870
+ const relatedContext = {};
871
+ for (const key of [
872
+ "related_epics",
873
+ "related_features",
874
+ "related_specs",
875
+ "related_adrs",
876
+ "related_scenarios",
877
+ "source_user_input",
878
+ "related_files"
879
+ ]) {
880
+ const value = frontmatter[key];
881
+ if (Array.isArray(value))
882
+ relatedContext[key] = value.filter((item) => typeof item === "string");
883
+ }
884
+ if (fileField)
885
+ relatedContext.file = fileField;
886
+ return {
887
+ id,
888
+ path: file,
889
+ frontmatter,
890
+ protocol_set: protocolSet,
891
+ blocked_by_protocols: stringArray(frontmatter.blocked_by_protocols),
892
+ related_context: relatedContext,
893
+ coding_standards_sources: codingStandardsSources(projectRootFromProtocolFile(file), frontmatter)
894
+ };
895
+ }
896
+ function protocolDocumentDiagnostics(document) {
897
+ const diagnostics = [];
898
+ if (document.protocol_set) {
899
+ const setPath = path.isAbsolute(document.protocol_set)
900
+ ? document.protocol_set
901
+ : path.join(projectRootFromProtocolFile(document.path), document.protocol_set);
902
+ if (!fs.existsSync(setPath)) {
903
+ diagnostics.push({
904
+ code: "protocol_set_file_missing",
905
+ severity: "warning",
906
+ protocol_id: document.id,
907
+ protocol_set: document.protocol_set,
908
+ expected_path: setPath
909
+ });
910
+ }
911
+ }
912
+ return diagnostics;
913
+ }
914
+ function protocolSetMemberStatus(context, projectId, document) {
915
+ const runtime = findProtocol(context, document.id);
916
+ 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]);
918
+ const activeSessions = activeFlowSessionBindingsForProject(context, projectId).filter((session) => session.protocol_id === document.id);
919
+ const projectRoot = projectRootFromProtocolFile(document.path);
920
+ const blockers = document.blocked_by_protocols.map((id) => blockerStatus(context, projectId, projectRoot, id));
921
+ const terminal = protocolTerminalStatus(context, projectId, projectRoot, document.id, state);
922
+ const lifecycle = normalizeProtocolLifecycle({
923
+ state,
924
+ rawStage: runtime?.stage ?? "unregistered",
925
+ rawStatus: runtime?.status ?? "missing",
926
+ queueStatus: queue?.status ?? null
927
+ });
928
+ const setStatus = terminal.success
929
+ ? "done"
930
+ : queue?.status === "claimed" || activeSessions.length > 0
931
+ ? "claimed"
932
+ : state && ["specify", "plan", "code", "merge"].includes(lifecycle.stage)
933
+ ? "running"
934
+ : blockers.some((blocker) => !blocker.resolved)
935
+ ? "blocked"
936
+ : "ready";
937
+ return {
938
+ id: document.id,
939
+ path: document.path,
940
+ protocol_set: document.protocol_set,
941
+ set_status: setStatus,
942
+ blocked_by_protocols: document.blocked_by_protocols,
943
+ blockers,
944
+ runtime: runtime
945
+ ? { registered: true, status: runtime.status, stage: runtime.stage, lifecycle, next_action: runtime.next_action, updated_at: runtime.updated_at }
946
+ : { registered: false, status: "missing", stage: "unregistered", next_action: "register_protocol" },
947
+ lifecycle,
948
+ active_sessions: activeSessions,
949
+ merge_queue: queue ?? null,
950
+ terminal
951
+ };
952
+ }
953
+ export function protocolSetBoardsForProject(context, projectId, projectRoot) {
954
+ const members = protocolDocumentsForProject(projectRoot)
955
+ .filter((document) => document.protocol_set)
956
+ .map((document) => protocolSetMemberStatus(context, projectId, document));
957
+ const setIds = [...new Set(members.map((member) => member.protocol_set).filter((value) => typeof value === "string"))];
958
+ return setIds.map((setId) => {
959
+ const setMembers = members.filter((member) => member.protocol_set === setId);
960
+ return {
961
+ protocol_set: setId,
962
+ summary: {
963
+ ready: setMembers.filter((member) => member.set_status === "ready").length,
964
+ blocked: setMembers.filter((member) => member.set_status === "blocked").length,
965
+ running: setMembers.filter((member) => member.set_status === "running" || member.set_status === "claimed").length,
966
+ done: setMembers.filter((member) => member.set_status === "done").length,
967
+ total: setMembers.length
968
+ },
969
+ members: setMembers
970
+ };
971
+ });
972
+ }
973
+ export function protocolSetBoardForProtocol(context, projectId, projectRoot, protocolId) {
974
+ const protocolFile = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
975
+ const document = fs.existsSync(protocolFile) ? readProtocolDocument(protocolFile) : null;
976
+ if (!document?.protocol_set)
977
+ return null;
978
+ return protocolSetBoardsForProject(context, projectId, projectRoot).find((board) => board.protocol_set === document.protocol_set) ?? null;
979
+ }
980
+ function blockerStatus(context, projectId, projectRoot, blockerId) {
981
+ const runtime = findProtocol(context, blockerId);
982
+ const state = runtime ? readProtocolRuntimeState(context, runtime).state : null;
983
+ const terminal = protocolTerminalStatus(context, projectId, projectRoot, blockerId, state);
984
+ const lifecycle = normalizeProtocolLifecycle({ state, rawStage: runtime?.stage, rawStatus: runtime?.status });
985
+ return {
986
+ id: blockerId,
987
+ resolved: terminal.success,
988
+ source: terminal.source,
989
+ status: runtime?.status ?? terminal.status ?? "unknown",
990
+ stage: runtime?.stage ?? terminal.stage ?? "unknown",
991
+ lifecycle,
992
+ document_lifecycle: terminal.lifecycle,
993
+ reason: terminal.reason
994
+ };
995
+ }
996
+ function protocolTerminalStatus(context, projectId, projectRoot, protocolId, state) {
997
+ if (state) {
998
+ const lifecycle = normalizeProtocolLifecycle({ state });
999
+ if (lifecycleIsTerminalSuccess(lifecycle)) {
1000
+ return { terminal: true, success: true, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is closed" };
1001
+ }
1002
+ if (lifecycleIsTerminalFailure(lifecycle)) {
1003
+ return { terminal: true, success: false, source: "runtime_state", status: state.status, stage: state.stage, reason: "runtime protocol is cancelled" };
1004
+ }
1005
+ }
1006
+ const queue = context.db.get("SELECT status FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [
1007
+ projectId,
1008
+ protocolId
1009
+ ]);
1010
+ if (queue && ["merged"].includes(queue.status)) {
1011
+ return {
1012
+ terminal: true,
1013
+ success: true,
1014
+ source: "merge_queue",
1015
+ status: queue.status,
1016
+ ...(state?.stage ? { stage: state.stage } : {}),
1017
+ reason: "merge queue is merged"
1018
+ };
1019
+ }
1020
+ const protocolFile = path.join(projectRoot, ".memory-bank", "protocol", `${protocolId}.md`);
1021
+ const document = fs.existsSync(protocolFile) ? readProtocolDocument(protocolFile) : null;
1022
+ const lifecycle = document ? stringValue(document.frontmatter.protocol_lifecycle).toUpperCase() : "";
1023
+ if (["MERGED", "CLOSED", "DONE"].includes(lifecycle)) {
1024
+ return { terminal: true, success: true, source: "frontmatter", lifecycle, reason: `protocol_lifecycle is ${lifecycle}` };
1025
+ }
1026
+ if (["CANCELLED"].includes(lifecycle)) {
1027
+ return { terminal: true, success: false, source: "frontmatter", lifecycle, reason: `protocol_lifecycle is ${lifecycle}` };
1028
+ }
1029
+ return {
1030
+ terminal: false,
1031
+ success: false,
1032
+ source: state ? "runtime_state" : document ? "frontmatter" : "missing",
1033
+ ...(state?.status ? { status: state.status } : {}),
1034
+ ...(state?.stage ? { stage: state.stage } : {}),
1035
+ ...(lifecycle ? { lifecycle } : {}),
1036
+ reason: state ? "protocol is not terminal" : document ? "protocol_lifecycle is not terminal success" : "protocol document/runtime not found"
1037
+ };
1038
+ }
1039
+ function promptForImplementationStage(stage) {
1040
+ if (stage === "unregistered" || stage === "registered" || stage === "priming" || stage === "specify" || stage === "waiting_for_user") {
1041
+ return ".memory-bank/dd-flow/protocol-implement.md";
1042
+ }
1043
+ if (stage === "plan")
1044
+ return ".memory-bank/dd-flow/plan.md";
1045
+ if (["implementation", "readiness"].includes(stage))
1046
+ return ".memory-bank/dd-flow/code.md";
1047
+ if (["ready_for_merge", "queued_for_merge", "integration"].includes(stage))
1048
+ return ".memory-bank/dd-flow/merge.md";
1049
+ return ".memory-bank/dd-flow/protocol-implement.md";
1050
+ }
1051
+ function recommendedNextActionForImplementationStage(stage) {
1052
+ if (stage === "registered" || stage === "priming" || stage === "specify" || stage === "waiting_for_user")
1053
+ return "continue_specify";
1054
+ if (stage === "plan")
1055
+ return "run_plan_or_code_handoff";
1056
+ if (["implementation", "readiness"].includes(stage))
1057
+ return "run_code_flow";
1058
+ if (["ready_for_merge", "queued_for_merge", "integration"].includes(stage))
1059
+ return "run_merge_flow";
1060
+ if (stage === "unregistered")
1061
+ return "register_protocol";
1062
+ return `continue_${stage}`;
1063
+ }
1064
+ function codingStandardsSources(projectRoot, frontmatter) {
1065
+ const candidates = [
1066
+ ".memory-bank/spec/engineering/coding-standards.md",
1067
+ ".memory-bank/mbb/coding-standards-guide.md",
1068
+ ".memory-bank/mbb/code-contracts-guide.md",
1069
+ "CONTRIBUTING.md",
1070
+ "README.md"
1071
+ ];
1072
+ const related = [
1073
+ ...stringArray(frontmatter.related_specs),
1074
+ ...stringArray(frontmatter.related_files)
1075
+ ].filter((item) => item.includes("coding") || item.includes("code-contract"));
1076
+ return [...new Set([...related, ...candidates])].filter((candidate) => fs.existsSync(path.isAbsolute(candidate) ? candidate : path.join(projectRoot, candidate)));
1077
+ }
1078
+ function projectRootFromProtocolFile(file) {
1079
+ return path.dirname(path.dirname(path.dirname(file)));
1080
+ }
1081
+ function stringValue(value) {
1082
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : "";
1083
+ }
1084
+ function stringArray(value) {
1085
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
1086
+ }
540
1087
  function inferProtocolId(projectRoot, handshakeId) {
541
1088
  const directDir = path.join(projectRoot, ".memory-bank", "protocol", handshakeId);
542
1089
  if (fs.existsSync(directDir)) {
@@ -562,6 +1109,7 @@ function objectOrExisting(value, existing) {
562
1109
  return value && typeof value === "object" && !Array.isArray(value) ? value : existing;
563
1110
  }
564
1111
  function protocolStatusPayload(protocol) {
1112
+ const lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
565
1113
  return {
566
1114
  id: protocol.id,
567
1115
  handshake_id: protocol.handshake_id,
@@ -569,6 +1117,7 @@ function protocolStatusPayload(protocol) {
569
1117
  project_root: protocol.project_root,
570
1118
  status: protocol.status,
571
1119
  stage: protocol.stage,
1120
+ lifecycle,
572
1121
  next_action: protocol.next_action,
573
1122
  route: JSON.parse(protocol.route_json),
574
1123
  workspace: JSON.parse(protocol.workspace_json),
@@ -604,3 +1153,107 @@ function readinessMissingFields(state) {
604
1153
  }
605
1154
  return missing;
606
1155
  }
1156
+ function linkedRunsForProtocol(context, protocol, limit) {
1157
+ return context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1158
+ FROM flow_runs
1159
+ WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1160
+ ORDER BY updated_at DESC, id DESC
1161
+ LIMIT ?`, [protocol.project_id, protocol.id, limit]);
1162
+ }
1163
+ function resolveLinkedRun(context, protocol, runIdOrAlias) {
1164
+ const matches = context.db.all(`SELECT id, short_id, flow_kind, subject_type, subject_id, status, verdict, next_action, run_index_path, index_json, created_at, updated_at, completed_at
1165
+ FROM flow_runs
1166
+ WHERE project_id = ? AND (id = ? OR short_id = ?)
1167
+ ORDER BY updated_at DESC, id DESC`, [protocol.project_id, runIdOrAlias, runIdOrAlias]);
1168
+ if (matches.length === 1)
1169
+ return matches[0];
1170
+ if (matches.length > 1) {
1171
+ throw new AppError("ambiguous_alias", `Run alias is ambiguous: ${runIdOrAlias}`, 1, {
1172
+ candidates: matches.map(linkedRunSummary)
1173
+ });
1174
+ }
1175
+ throw new AppError("not_found", `Run is not registered: ${runIdOrAlias}`, 1, { run_id: runIdOrAlias });
1176
+ }
1177
+ function parseLinkedRunIndex(text, runId) {
1178
+ const value = JSON.parse(text);
1179
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1180
+ throw new AppError("validation", "Run index is invalid", 2, { run_id: runId });
1181
+ }
1182
+ return value;
1183
+ }
1184
+ function safeLinkedRunIndex(text, runId, diagnostics) {
1185
+ try {
1186
+ return parseLinkedRunIndex(text, runId);
1187
+ }
1188
+ catch (error) {
1189
+ diagnostics.push({
1190
+ code: "run_index_invalid",
1191
+ severity: "warning",
1192
+ run_id: runId,
1193
+ error: error instanceof Error ? error.message : String(error)
1194
+ });
1195
+ return null;
1196
+ }
1197
+ }
1198
+ function linkedRunSummary(run) {
1199
+ return {
1200
+ id: run.id,
1201
+ short_id: run.short_id,
1202
+ flow_kind: run.flow_kind,
1203
+ status: run.status,
1204
+ verdict: run.verdict,
1205
+ next_action: run.next_action,
1206
+ run_index_path: run.run_index_path,
1207
+ updated_at: run.updated_at,
1208
+ completed_at: run.completed_at
1209
+ };
1210
+ }
1211
+ function stageChain(index) {
1212
+ return (Array.isArray(index.stage_runs) ? index.stage_runs : [])
1213
+ .map((stage) => `${String(stage.stage ?? "unknown")}:${String(stage.status ?? "unknown")}`);
1214
+ }
1215
+ function hasDoneStage(index, names) {
1216
+ return Boolean(index?.stage_runs?.some((stage) => typeof stage.stage === "string" && names.includes(stage.stage) && stage.status === "done"));
1217
+ }
1218
+ function inferSyncTarget(run, index) {
1219
+ if (hasDoneStage(index, ["merge"]) || run.verdict === "merged")
1220
+ return "closed";
1221
+ if (hasDoneStage(index, ["code", "readiness", "implementation"]))
1222
+ return "ready_for_merge";
1223
+ if (hasDoneStage(index, ["plan"]))
1224
+ return "implementation";
1225
+ return null;
1226
+ }
1227
+ function nextActionForSyncedTarget(targetStage) {
1228
+ if (targetStage === "closed")
1229
+ return "none";
1230
+ if (targetStage === "ready_for_merge")
1231
+ return "run_merge_flow";
1232
+ if (targetStage === "implementation")
1233
+ return "run_code_flow";
1234
+ if (targetStage === "readiness")
1235
+ return "run_readiness_gate";
1236
+ return `continue_${targetStage}`;
1237
+ }
1238
+ function isProtocolRunStageMismatch(stage, run, index) {
1239
+ if (["closed", "cancelled"].includes(stage))
1240
+ return false;
1241
+ const mergeDone = hasDoneStage(index, ["merge"]) || run.verdict === "merged";
1242
+ if (mergeDone && stage !== "closed")
1243
+ return true;
1244
+ const codeDone = hasDoneStage(index, ["code", "readiness", "implementation"]);
1245
+ if (codeDone && ["registered", "priming", "prime", "specify", "plan", "implementation"].includes(stage))
1246
+ return true;
1247
+ const planDone = hasDoneStage(index, ["plan"]);
1248
+ return planDone && ["registered", "priming", "prime", "specify"].includes(stage);
1249
+ }
1250
+ function mismatchSeverity(run, index) {
1251
+ return hasDoneStage(index, ["merge"]) || run.verdict === "merged" ? "error" : "warning";
1252
+ }
1253
+ function recommendedSyncCommands(protocol, latestRun) {
1254
+ if (!latestRun || typeof latestRun.id !== "string")
1255
+ return [];
1256
+ return [
1257
+ `dd-flow protocol sync-from-run ${protocol.id} --run ${latestRun.id} --target auto --project-root ${JSON.stringify(protocol.project_root)} --json`
1258
+ ];
1259
+ }