@deksden-com/dd-flow-cli 0.2.0 → 0.3.0

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