@deksden-com/dd-flow-cli 0.3.0 → 0.4.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 (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +116 -18
  5. package/dist/cli/run-cli.js +361 -29
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/branch-context.js +254 -0
  16. package/dist/services/cleanup.js +31 -0
  17. package/dist/services/cli-operation-classifier.js +104 -0
  18. package/dist/services/compatibility-preflight.js +127 -0
  19. package/dist/services/config.js +6 -0
  20. package/dist/services/dashboard-targets.js +95 -0
  21. package/dist/services/dashboard.js +376 -59
  22. package/dist/services/engines.js +532 -0
  23. package/dist/services/flow-guidance.js +8 -1
  24. package/dist/services/hooks.js +1 -1
  25. package/dist/services/lanes.js +333 -1
  26. package/dist/services/merge-queue.js +310 -15
  27. package/dist/services/merge-worker.js +44 -3
  28. package/dist/services/migrations.js +231 -0
  29. package/dist/services/project-summary.js +122 -0
  30. package/dist/services/projects.js +41 -6
  31. package/dist/services/protocol-lifecycle.js +144 -0
  32. package/dist/services/protocols.js +34 -7
  33. package/dist/services/sessions.js +21 -4
  34. package/dist/services/status.js +10 -0
  35. package/dist/services/version-status.js +39 -15
  36. package/dist/storage/database.js +25 -0
  37. package/dist/storage/paths.js +12 -0
  38. package/package.json +3 -2
@@ -4,11 +4,13 @@ import { spawnSync } from "node:child_process";
4
4
  import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } from "../domain/contracts.js";
5
5
  import { AppError } from "../shared/errors.js";
6
6
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
7
- import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
7
+ import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
8
8
  import { queueForProject } from "./merge-queue.js";
9
+ import { publishProjectSummary } from "./project-summary.js";
9
10
  import { requireProjectByRoot } from "./projects.js";
10
11
  import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsForProject, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
11
12
  import { activeFlowSessionsForProject } from "./sessions.js";
13
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
12
14
  export function getCmuxStatus(context, input) {
13
15
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
14
16
  return { ok: true, project_root: project.root, cmux: detectCmux(context) };
@@ -45,12 +47,49 @@ export function renderGlobalDashboard(context, input = {}) {
45
47
  export function openDashboard(context, input) {
46
48
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
47
49
  const config = readProjectConfig(context, project.id);
48
- const format = parseDashboardFormat(input.format);
50
+ const format = parseDashboardFormat(input.format, "html");
49
51
  if (input.viewer && input.viewer !== "cmux") {
50
52
  throw new AppError("validation", "--viewer must be cmux", 2);
51
53
  }
52
54
  const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
53
- return openCmuxDashboard(context, project.root, dashboardPath, format);
55
+ return {
56
+ ok: true,
57
+ project_root: project.root,
58
+ dashboard: { path: dashboardPath },
59
+ open: openCmuxDashboard(context, project.root, dashboardPath, format)
60
+ };
61
+ }
62
+ export function openGlobalDashboard(context, input = {}) {
63
+ if (input.viewer && input.viewer !== "cmux") {
64
+ throw new AppError("validation", "--viewer must be cmux", 2);
65
+ }
66
+ const format = parseDashboardFormat(input.format, "html");
67
+ const rendered = renderGlobalDashboard(context, { format });
68
+ return {
69
+ ok: true,
70
+ dashboard: rendered.dashboard,
71
+ open: openDashboardPath(context, rendered.dashboard.path, format)
72
+ };
73
+ }
74
+ export function openProtocolDashboard(context, input) {
75
+ if (input.viewer && input.viewer !== "cmux") {
76
+ throw new AppError("validation", "--viewer must be cmux", 2);
77
+ }
78
+ const format = parseDashboardFormat(input.format, "html");
79
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
80
+ if (format !== "html") {
81
+ throw new AppError("validation", "Protocol dashboard open supports --format html", 2, {
82
+ protocol: input.protocol
83
+ });
84
+ }
85
+ const rendered = renderProtocolDashboardHtml(context, project, input.protocol);
86
+ return {
87
+ ok: true,
88
+ project_root: project.root,
89
+ protocol_id: input.protocol,
90
+ dashboard: rendered.dashboard,
91
+ open: openCmuxDashboard(context, project.root, rendered.dashboard.path, format)
92
+ };
54
93
  }
55
94
  export function refreshDashboard(context, input) {
56
95
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -69,6 +108,7 @@ export function refreshDashboard(context, input) {
69
108
  };
70
109
  }
71
110
  const rendered = renderDashboard(context, { projectRoot: project.root, format, ...(input.protocol ? { protocol: input.protocol } : {}) });
111
+ const projectSummary = input.protocol ? null : publishProjectSummary(context, { project });
72
112
  const global = config.dashboard.global
73
113
  ? renderGlobalDashboard(context, {
74
114
  output: format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config),
@@ -80,6 +120,7 @@ export function refreshDashboard(context, input) {
80
120
  ok: true,
81
121
  project_root: project.root,
82
122
  dashboard: rendered.dashboard,
123
+ project_summary: projectSummary,
83
124
  global_dashboard: global.dashboard,
84
125
  open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path, format) : { ok: true, skipped: true, reason: "open_disabled" }
85
126
  };
@@ -87,6 +128,44 @@ export function refreshDashboard(context, input) {
87
128
  export function refreshGlobalDashboard(context, input = {}) {
88
129
  return renderGlobalDashboard(context, input);
89
130
  }
131
+ export function refreshAllDashboards(context, input = {}) {
132
+ const format = parseDashboardFormat(input.format, "html");
133
+ const global = renderGlobalDashboard(context, { format });
134
+ const projects = context.db.all("SELECT * FROM projects WHERE status = 'active' ORDER BY id ASC");
135
+ const results = projects.map((project) => {
136
+ if (!fs.existsSync(project.root)) {
137
+ return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "project_root_missing" };
138
+ }
139
+ const config = readProjectConfig(context, project.id);
140
+ if (!config.dashboard.project) {
141
+ return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "dashboard_project_disabled" };
142
+ }
143
+ try {
144
+ const projectSummary = publishProjectSummary(context, { project });
145
+ const rendered = renderDashboard(context, { projectRoot: project.root, format });
146
+ return { ok: true, project_id: project.id, project_root: project.root, dashboard: rendered.dashboard, project_summary: projectSummary };
147
+ }
148
+ catch (error) {
149
+ return {
150
+ ok: false,
151
+ project_id: project.id,
152
+ project_root: project.root,
153
+ error: error instanceof Error ? error.message : String(error)
154
+ };
155
+ }
156
+ });
157
+ return {
158
+ ok: true,
159
+ dashboard: global.dashboard,
160
+ projects: results,
161
+ summary: {
162
+ total: results.length,
163
+ refreshed: results.filter((item) => item.ok && !("skipped" in item)).length,
164
+ skipped: results.filter((item) => "skipped" in item).length,
165
+ failed: results.filter((item) => !item.ok).length
166
+ }
167
+ };
168
+ }
90
169
  export function autoRefreshDashboards(context, input) {
91
170
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
92
171
  const config = readProjectConfig(context, project.id);
@@ -95,6 +174,7 @@ export function autoRefreshDashboards(context, input) {
95
174
  }
96
175
  const result = { ok: true, project_root: project.root };
97
176
  if (config.dashboard.project) {
177
+ publishProjectSummary(context, { project });
98
178
  const output = dashboardMarkdownPath(project.root, config);
99
179
  const markdown = renderProjectDashboardMarkdown(context, project, output);
100
180
  writeMarkdown(output, markdown);
@@ -168,6 +248,21 @@ function openCmuxDashboard(context, projectRoot, dashboardPath, format = "markdo
168
248
  }
169
249
  return { ok: true, opened: true, command };
170
250
  }
251
+ function openDashboardPath(context, dashboardPath, format) {
252
+ const cmux = detectCmux(context);
253
+ if (!cmux.available) {
254
+ return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
255
+ }
256
+ if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
257
+ return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
258
+ }
259
+ const command = cmuxOpenCommand(format, dashboardPath);
260
+ const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
261
+ if (result.status !== 0) {
262
+ return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
263
+ }
264
+ return { ok: true, opened: true, command };
265
+ }
171
266
  function cmuxOpenCommand(format, dashboardPath) {
172
267
  return format === "html"
173
268
  ? ["cmux", "browser", "open", pathToFileUrl(dashboardPath)]
@@ -185,8 +280,8 @@ function detectCmux(context) {
185
280
  ? { available: true, source: "path", version: result.stdout.trim() }
186
281
  : { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
187
282
  }
188
- function parseDashboardFormat(value) {
189
- const format = value ?? "markdown";
283
+ function parseDashboardFormat(value, defaultFormat = "markdown") {
284
+ const format = value ?? defaultFormat;
190
285
  if (format !== "markdown" && format !== "html") {
191
286
  throw new AppError("validation", "--format must be markdown or html", 2, { format });
192
287
  }
@@ -264,35 +359,11 @@ function renderGlobalDashboardHtml(context, output) {
264
359
  function buildGlobalDashboardData(context, htmlPath) {
265
360
  const projects = context.db.all("SELECT * FROM projects ORDER BY status ASC, updated_at DESC, root ASC");
266
361
  const activeProjects = projects.filter((project) => project.status === "active");
267
- const projectCards = projects.map((project) => {
268
- const protocols = protocolsForProject(context, project.id);
269
- const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
270
- const queue = queueForProject(context, project.id);
271
- const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? AND status = 'active'", [project.id]);
272
- const sessions = activeFlowSessionsForProject(context, project.id);
273
- const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
274
- return {
275
- id: project.id,
276
- title: path.basename(project.root) || project.id,
277
- root: project.root,
278
- short_root: compactPath(project.root),
279
- status: project.status,
280
- root_exists: fs.existsSync(project.root),
281
- display_status: fs.existsSync(project.root) ? normalizeDisplayStatus(project.status) : "stale",
282
- action_level: openDefs > 0 || locks.length > 0 ? "watch" : "none",
283
- updated_at: project.updated_at,
284
- href: projectDashboardHtmlPath(context, project.id),
285
- active_protocols: activeProtocols.slice(0, 6).map(protocolCardMini),
286
- metrics: {
287
- active_protocols: activeProtocols.length,
288
- queue: queue.filter((job) => isActiveQueueStatus(job.status)).length,
289
- locks: locks.length,
290
- sessions: sessions.length,
291
- open_defs: openDefs
292
- },
293
- warnings: fs.existsSync(project.root) ? [] : ["project_root_missing"]
294
- };
295
- });
362
+ const summaryPolicy = globalDashboardSummaryPolicy();
363
+ const classified = projects.map((project) => classifyGlobalProjectSummary(context, project, summaryPolicy));
364
+ const projectCards = classified.flatMap((entry) => (entry.card ? [entry.card] : []));
365
+ const unsupportedProjects = classified.flatMap((entry) => (entry.unsupported ? [entry.unsupported] : []));
366
+ const summaryVersionGroups = buildSummaryVersionGroups(projectCards, unsupportedProjects);
296
367
  return {
297
368
  schema_id: "dd-flow/global-dashboard-data@1",
298
369
  generated_at: context.now(),
@@ -303,14 +374,170 @@ function buildGlobalDashboardData(context, htmlPath) {
303
374
  markdown_fallback: link("Markdown fallback", globalDashboardMarkdownPath(context), "available")
304
375
  },
305
376
  metrics: [
306
- metric("projects", activeProjects.length, "known", "projects.status=active"),
377
+ metric("projects", projectCards.filter((card) => card.status === "active").length, "known", "compatible project summaries"),
378
+ metric("total_projects", activeProjects.length, "known", "projects.status=active"),
379
+ metric("unsupported_projects", unsupportedProjects.filter((project) => project.status === "active").length, "known", "project summaries"),
307
380
  metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
308
381
  metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
309
382
  metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
310
383
  metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
311
384
  ],
385
+ supported_summary_versions: summaryPolicy.supported.map((contract) => ({
386
+ schema_id: contract.schema_id,
387
+ schema_version: contract.schema_version,
388
+ label: contract.label,
389
+ status: "supported"
390
+ })),
391
+ summary_version_groups: summaryVersionGroups,
392
+ unsupported_projects: unsupportedProjects,
312
393
  project_cards: projectCards,
313
- warnings: projectCards.filter((card) => !card.root_exists).map((card) => ({ code: "project_root_missing", project_id: card.id }))
394
+ warnings: [
395
+ ...projectCards.filter((card) => !card.root_exists).map((card) => ({ code: "project_root_missing", project_id: card.id })),
396
+ ...unsupportedProjects.map((project) => ({ code: project.reason, project_id: project.id, summary_path: project.summary_path }))
397
+ ]
398
+ };
399
+ }
400
+ function globalDashboardSummaryPolicy() {
401
+ return {
402
+ supported: [{ schema_id: "dd-flow/project-summary@1", schema_version: null, label: "project-summary@1" }],
403
+ install_hint: "Run the project-compatible dd-flow engine and refresh the project summary."
404
+ };
405
+ }
406
+ function classifyGlobalProjectSummary(context, project, policy) {
407
+ const summaryPath = projectSummaryJsonPath(context, project.id);
408
+ const parsed = readSummaryFile(summaryPath);
409
+ if (!parsed.ok) {
410
+ return {
411
+ card: null,
412
+ unsupported: unsupportedProject(project, {
413
+ reason: parsed.reason,
414
+ detail: parsed.detail,
415
+ summary_path: summaryPath,
416
+ install_hint: policy.install_hint
417
+ })
418
+ };
419
+ }
420
+ const summary = parsed.summary;
421
+ const schemaId = stringValue(summary.schema_id);
422
+ const schemaVersion = stringValue(summary.schema_version);
423
+ const supported = policy.supported.some((contract) => contract.schema_id === schemaId && (!contract.schema_version || contract.schema_version === schemaVersion));
424
+ if (!supported) {
425
+ return {
426
+ card: null,
427
+ unsupported: unsupportedProject(project, {
428
+ reason: "unsupported_summary_version",
429
+ detail: `Unsupported project summary contract: ${schemaId ?? "unknown"} ${schemaVersion ?? ""}`.trim(),
430
+ summary_path: summaryPath,
431
+ summary
432
+ })
433
+ };
434
+ }
435
+ return { card: projectCardFromSummary(context, project, summary), unsupported: null };
436
+ }
437
+ function readSummaryFile(file) {
438
+ if (!fs.existsSync(file)) {
439
+ return { ok: false, reason: "missing_summary", detail: "Published project summary is missing." };
440
+ }
441
+ try {
442
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
443
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
444
+ return { ok: false, reason: "invalid_summary", detail: "Published project summary is not a JSON object." };
445
+ }
446
+ return { ok: true, summary: parsed };
447
+ }
448
+ catch (error) {
449
+ return { ok: false, reason: "unreadable_summary", detail: error instanceof Error ? error.message : "Failed to read project summary." };
450
+ }
451
+ }
452
+ function unsupportedProject(project, input) {
453
+ const summary = input.summary;
454
+ return {
455
+ id: project.id,
456
+ title: stringValue(summary?.name) ?? (path.basename(project.root) || project.id),
457
+ root: project.root,
458
+ short_root: compactPath(project.root),
459
+ status: project.status,
460
+ root_exists: fs.existsSync(project.root),
461
+ display_status: "unsupported",
462
+ action_level: "watch",
463
+ reason: input.reason,
464
+ detail: input.detail,
465
+ memorybank_version: summary?.memorybank_version ?? null,
466
+ summary_schema_id: stringValue(summary?.schema_id),
467
+ summary_schema_version: stringValue(summary?.schema_version),
468
+ summary_path: input.summary_path,
469
+ required_engine_range: summary?.required_engine_range ?? null,
470
+ install_hint: input.install_hint ?? summary?.install_hint ?? null,
471
+ updated_at: stringValue(summary?.last_activity_at) ?? project.updated_at
472
+ };
473
+ }
474
+ function buildSummaryVersionGroups(projectCards, unsupportedProjects) {
475
+ const groups = new Map();
476
+ const add = (entry, compatible) => {
477
+ const schemaId = stringValue(entry.summary_schema_id) ?? "missing";
478
+ const schemaVersion = stringValue(entry.summary_schema_version) ?? "unknown";
479
+ const key = `${schemaId}:${schemaVersion}:${compatible ? "compatible" : "unsupported"}`;
480
+ const existing = groups.get(key) ??
481
+ {
482
+ schema_id: schemaId,
483
+ schema_version: schemaVersion,
484
+ compatible,
485
+ count: 0,
486
+ project_ids: [],
487
+ project_names: []
488
+ };
489
+ existing.count = numberValue(existing.count) + 1;
490
+ existing.project_ids.push(String(entry.id));
491
+ existing.project_names.push(String(entry.title));
492
+ groups.set(key, existing);
493
+ };
494
+ projectCards.forEach((card) => add(card, true));
495
+ unsupportedProjects.forEach((project) => add(project, false));
496
+ return [...groups.values()].sort((a, b) => String(a.schema_id).localeCompare(String(b.schema_id)) || String(a.schema_version).localeCompare(String(b.schema_version)));
497
+ }
498
+ function projectCardFromSummary(context, project, summary) {
499
+ const metrics = recordValue(summary.resource_summary);
500
+ const protocolCounts = recordValue(summary.protocol_counts);
501
+ const rootExists = Boolean(summary.root_exists);
502
+ const openDefs = numberValue(metrics?.open_defs);
503
+ const locks = numberValue(metrics?.locks);
504
+ return {
505
+ id: project.id,
506
+ title: stringValue(summary.name) ?? (path.basename(project.root) || project.id),
507
+ root: project.root,
508
+ short_root: compactPath(project.root),
509
+ status: project.status,
510
+ root_exists: rootExists,
511
+ display_status: rootExists ? normalizeDisplayStatus(project.status) : "stale",
512
+ action_level: openDefs > 0 || locks > 0 ? "watch" : "none",
513
+ updated_at: stringValue(summary.last_activity_at) ?? project.updated_at,
514
+ href: projectDashboardHtmlPath(context, project.id),
515
+ summary_source: "project_summary",
516
+ summary_schema_id: stringValue(summary.schema_id),
517
+ summary_schema_version: stringValue(summary.schema_version),
518
+ summary_path: stringValue(summary.summary_path),
519
+ memorybank_version: summary.memorybank_version ?? null,
520
+ cli_version: summary.cli_version ?? null,
521
+ engine_version: summary.engine_version ?? null,
522
+ active_protocols: Array.isArray(summary.active_protocols) ? summary.active_protocols : [],
523
+ lifecycle_summary: {
524
+ total: numberValue(protocolCounts?.active),
525
+ by_lifecycle: []
526
+ },
527
+ resource_summary: {
528
+ queued_protocols: numberValue(metrics?.queue),
529
+ active_locks: locks,
530
+ queued_waiters: numberValue(metrics?.waiters)
531
+ },
532
+ metrics: {
533
+ active_protocols: numberValue(protocolCounts?.active),
534
+ queue: numberValue(metrics?.queue),
535
+ locks,
536
+ waiters: numberValue(metrics?.waiters),
537
+ sessions: numberValue(metrics?.sessions),
538
+ open_defs: openDefs
539
+ },
540
+ warnings: Array.isArray(summary.warnings) ? summary.warnings : []
314
541
  };
315
542
  }
316
543
  function buildProjectDashboardData(context, project, htmlPath) {
@@ -318,11 +545,13 @@ function buildProjectDashboardData(context, project, htmlPath) {
318
545
  const queue = queueForProject(context, project.id);
319
546
  const sessions = activeFlowSessionsForProject(context, project.id);
320
547
  const locks = context.db.all("SELECT lane, worker_id, status, expires_at FROM lane_locks WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 20", [project.id]);
548
+ const waiters = context.db.all("SELECT id, lane, worker_id, status, queued_at, expires_at, reason, updated_at FROM lane_waiters WHERE project_id = ? ORDER BY updated_at DESC, id DESC LIMIT 20", [project.id]);
321
549
  const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
322
550
  const protocolCards = protocols.slice(0, 48).map((protocol, index) => buildProtocolCard(context, project, protocol, index < activeProtocols.length + 12));
323
551
  const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
324
552
  const protocolSetBoards = protocolSetBoardsForProject(context, project.id, project.root);
325
553
  const reviewRuns = recentReviewRunsForProject(context, project.id, 8);
554
+ const queuedProtocols = queue.slice(0, 12).map(queueItemDashboard);
326
555
  return {
327
556
  schema_id: "dd-flow/project-dashboard-data@1",
328
557
  generated_at: context.now(),
@@ -345,22 +574,18 @@ function buildProjectDashboardData(context, project, htmlPath) {
345
574
  metric("completed_protocols", protocols.filter((protocol) => !isActiveProtocolStatus(protocol.status)).length, "known", "protocols"),
346
575
  metric("merge_queue", queue.filter((job) => isActiveQueueStatus(job.status)).length, "known", "merge_queue"),
347
576
  metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
577
+ metric("queued_waiters", waiters.filter((waiter) => waiter.status === "queued").length, "known", "lane_waiters"),
348
578
  metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
349
579
  metric("running_sessions", sessions.length, "known", "flow_sessions"),
350
580
  metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
351
581
  ],
582
+ lifecycle_summary: lifecycleSummary(activeProtocols),
583
+ resource_summary: resourceSummary(queue, locks, waiters),
352
584
  protocol_cards: protocolCards,
353
585
  protocol_set_boards: protocolSetBoards,
354
586
  review_runs: reviewRuns,
355
- merge_queue: queue.slice(0, 12).map((job) => ({
356
- protocol_id: job.protocol_id,
357
- status: job.status,
358
- display_status: normalizeDisplayStatus(job.status),
359
- action_level: ["ready", "requeued"].includes(job.status) ? "actionable" : job.status === "claimed" ? "watch" : "none",
360
- owner: job.claimed_by_session_id,
361
- note: job.last_reason,
362
- updated_at: job.updated_at
363
- })),
587
+ queued_protocols: queuedProtocols,
588
+ merge_queue: queuedProtocols,
364
589
  sessions: sessions.slice(0, 12).map((session) => ({
365
590
  id: session.session_id,
366
591
  flow_kind: session.flow_kind,
@@ -374,6 +599,16 @@ function buildProjectDashboardData(context, project, htmlPath) {
374
599
  updated_at: session.updated_at
375
600
  })),
376
601
  locks: locks.slice(0, 12).map((lock) => ({ lane: lock.lane, worker_id: lock.worker_id, status: lock.status, expires_at: lock.expires_at })),
602
+ waiters: waiters.slice(0, 12).map((waiter) => ({
603
+ id: waiter.id,
604
+ lane: waiter.lane,
605
+ worker_id: waiter.worker_id,
606
+ status: waiter.status,
607
+ queued_at: waiter.queued_at,
608
+ expires_at: waiter.expires_at,
609
+ reason: waiter.reason,
610
+ updated_at: waiter.updated_at
611
+ })),
377
612
  warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }]
378
613
  };
379
614
  }
@@ -384,6 +619,8 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
384
619
  }
385
620
  const runtimeProtocol = requireProtocol(context, protocol.id);
386
621
  const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
622
+ const lifecycle = normalizeProtocolLifecycle({ state: runtimeState });
623
+ const queueItem = queueForProject(context, project.id).find((item) => item.protocol_id === protocolId) ?? null;
387
624
  const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
388
625
  const runs = 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
389
626
  FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
@@ -425,6 +662,9 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
425
662
  title: protocol.id,
426
663
  raw_status: protocol.status,
427
664
  raw_stage: protocol.stage,
665
+ lifecycle,
666
+ lifecycle_stage: lifecycle.stage,
667
+ lifecycle_status: lifecycle.status,
428
668
  display_status: normalizeDisplayStatus(protocol.status),
429
669
  action_level: actionLevel(protocol.status, jsonArray(protocol.blockers_json).length + jsonArray(protocol.active_def_json).length),
430
670
  next_action: protocol.next_action,
@@ -435,6 +675,18 @@ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
435
675
  source_path: path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`),
436
676
  summary_path: path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")
437
677
  },
678
+ resource: {
679
+ kind: "protocol",
680
+ lane: queueItem ? "merge" : null,
681
+ queue_status: queueItem?.status ?? null,
682
+ claim: queueItem?.claimed_by_session_id
683
+ ? { protocol_id: protocol.id, worker_id: queueItem.claimed_by_session_id, claimed_at: queueItem.claimed_at, status: queueItem.status }
684
+ : null
685
+ },
686
+ queue_item: queueItem ? queueItemDashboard(queueItem) : null,
687
+ claim: queueItem?.claimed_by_session_id
688
+ ? { protocol_id: protocol.id, worker_id: queueItem.claimed_by_session_id, claimed_at: queueItem.claimed_at, status: queueItem.status }
689
+ : null,
438
690
  links: {
439
691
  self: link("Protocol page", htmlPath, "available"),
440
692
  project_dashboard: link("Project dashboard", projectDashboardHtmlPath(context, project.id), "available"),
@@ -680,6 +932,45 @@ function queueStatusSummary(queue) {
680
932
  }
681
933
  return [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(", ");
682
934
  }
935
+ function lifecycleSummary(protocols) {
936
+ const counts = new Map();
937
+ for (const protocol of protocols) {
938
+ const lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
939
+ const key = [lifecycle.stage, lifecycle.substage, lifecycle.status].filter(Boolean).join("/");
940
+ counts.set(key, (counts.get(key) ?? 0) + 1);
941
+ }
942
+ return {
943
+ total: protocols.length,
944
+ by_lifecycle: [...counts.entries()].map(([lifecycle, count]) => ({ lifecycle, count }))
945
+ };
946
+ }
947
+ function resourceSummary(queue, locks, waiters) {
948
+ return {
949
+ queued_protocols: queue.filter((item) => isActiveQueueStatus(item.status)).length,
950
+ active_locks: locks.filter((lock) => lock.status === "active").length,
951
+ queued_waiters: waiters.filter((waiter) => waiter.status === "queued").length
952
+ };
953
+ }
954
+ function queueItemDashboard(queueItem) {
955
+ return {
956
+ protocol_id: queueItem.protocol_id,
957
+ queue_item_id: queueItem.id,
958
+ status: queueItem.status,
959
+ display_status: normalizeDisplayStatus(queueItem.status),
960
+ action_level: ["ready", "requeued"].includes(queueItem.status) ? "actionable" : queueItem.status === "claimed" ? "watch" : "none",
961
+ claim: queueItem.claimed_by_session_id
962
+ ? {
963
+ protocol_id: queueItem.protocol_id,
964
+ worker_id: queueItem.claimed_by_session_id,
965
+ claimed_at: queueItem.claimed_at,
966
+ status: queueItem.status
967
+ }
968
+ : null,
969
+ owner: queueItem.claimed_by_session_id,
970
+ note: queueItem.last_reason,
971
+ updated_at: queueItem.updated_at
972
+ };
973
+ }
683
974
  function workspaceForProtocol(worktrees, protocolId) {
684
975
  return worktrees.find((worktree) => worktree.protocol_id === protocolId)?.worktree_path;
685
976
  }
@@ -699,6 +990,15 @@ function compactPath(value) {
699
990
  function truncate(value, maxLength) {
700
991
  return value.length > maxLength ? `${value.slice(0, Math.max(0, maxLength - 3))}...` : value;
701
992
  }
993
+ function recordValue(value) {
994
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
995
+ }
996
+ function stringValue(value) {
997
+ return typeof value === "string" && value.length > 0 ? value : null;
998
+ }
999
+ function numberValue(value) {
1000
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1001
+ }
702
1002
  function statusIcon(status) {
703
1003
  if (["active", "running", "ready", "ready_for_merge", "claimed", "in_progress"].includes(status))
704
1004
  return "🟢";
@@ -730,15 +1030,6 @@ function link(label, href, status, reason) {
730
1030
  ...(reason ? { degraded_reason: reason } : {})
731
1031
  };
732
1032
  }
733
- function protocolCardMini(protocol) {
734
- return {
735
- id: protocol.id,
736
- short_id: shortId(protocol.id),
737
- stage: protocol.stage,
738
- status: protocol.status,
739
- display_status: normalizeDisplayStatus(protocol.status)
740
- };
741
- }
742
1033
  function buildProtocolCard(context, project, protocol, generatePage) {
743
1034
  const summary = planSummaryForProtocol(context, protocol.id);
744
1035
  const activeDefCount = jsonArray(protocol.active_def_json).length;
@@ -747,10 +1038,12 @@ function buildProtocolCard(context, project, protocol, generatePage) {
747
1038
  FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
748
1039
  ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
749
1040
  let diagnostics = [];
1041
+ let lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
750
1042
  try {
751
1043
  const runtimeProtocol = requireProtocol(context, protocol.id);
752
1044
  const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
753
- diagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics;
1045
+ lifecycle = normalizeProtocolLifecycle({ state: runtimeState });
1046
+ diagnostics = [...protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics, ...lifecycle.diagnostics];
754
1047
  }
755
1048
  catch {
756
1049
  diagnostics = [];
@@ -762,6 +1055,15 @@ function buildProtocolCard(context, project, protocol, generatePage) {
762
1055
  href: protocolDashboardHtmlPath(context, project.id, protocol.id),
763
1056
  raw_status: protocol.status,
764
1057
  raw_stage: protocol.stage,
1058
+ lifecycle,
1059
+ lifecycle_stage: lifecycle.stage,
1060
+ lifecycle_status: lifecycle.status,
1061
+ resource: {
1062
+ kind: "protocol",
1063
+ lane: null,
1064
+ queue_status: null,
1065
+ claim: null
1066
+ },
765
1067
  display_status: normalizeDisplayStatus(protocol.status),
766
1068
  action_level: actionLevel(protocol.status, activeDefCount + blockerCount),
767
1069
  flow_kind: latestRun?.flow_kind ?? "unknown",
@@ -978,7 +1280,9 @@ function renderDashboardHtmlPage(data) {
978
1280
  <aside class="card verdict"><div class="verdictLabel">dd-flow dashboard</div><div class="verdictText" id="verdictText"></div><div class="sub" id="verdictNote"></div></aside>
979
1281
  </section>
980
1282
  <section class="metricGrid" id="metrics" data-testid="metric-strip"></section>
1283
+ <section class="card panel hidden" id="summaryVersionsPanel"><div class="sectionHead"><h2>Summary versions</h2><span class="pill" id="summaryVersionCount"></span></div><div class="list" id="summaryVersions"></div></section>
981
1284
  <section class="card panel"><div class="sectionHead"><h2 id="cardsTitle"></h2><span class="pill" id="focusCount"></span></div><div class="grid" id="cards" data-testid="card-grid"></div></section>
1285
+ <section class="card panel hidden" id="unsupportedProjectsPanel"><div class="sectionHead"><h2>Unsupported projects</h2><span class="pill" id="unsupportedProjectCount"></span></div><div class="list" id="unsupportedProjects"></div></section>
982
1286
  <section class="card panel hidden" id="setBoardPanel"><div class="sectionHead"><h2>Protocol set</h2><span class="pill" id="setBoardCount"></span></div><div class="list" id="setBoard"></div></section>
983
1287
  <section class="card panel hidden" id="reviewRunsPanel"><div class="sectionHead"><h2>Review runs</h2><span class="pill" id="reviewRunCount"></span></div><div class="list" id="reviewRuns"></div></section>
984
1288
  <section class="card panel"><div class="sectionHead"><h2>Навигация и артефакты</h2></div><div class="list" id="links"></div></section>
@@ -998,7 +1302,8 @@ function renderDashboardHtmlPage(data) {
998
1302
  function metricValue(metric){ return metric && metric.value !== null && metric.value !== undefined ? metric.value : "N/A"; }
999
1303
  function pageKind(){ return page.kind || "dashboard"; }
1000
1304
  function cards(){ if(pageKind()==="global") return data.project_cards || []; if(pageKind()==="project") return data.protocol_cards || []; return data.run_history || []; }
1001
- function focusable(card){ return ["actionable","blocked","watch"].includes(card.action_level) || ["blocked","failed","stale","unknown","waiting_for_user","ready_for_merge","queued"].includes(card.display_status) || Number(card.active_def_count || card.metrics?.open_defs || 0) > 0; }
1305
+ function lifecycleText(card){ const lc = card.lifecycle || {}; return [lc.stage || card.lifecycle_stage || card.current_stage, lc.substage, lc.status || card.lifecycle_status || card.status].filter(Boolean).join(" / "); }
1306
+ function focusable(card){ const lc = card.lifecycle || {}; return ["actionable","blocked","watch"].includes(card.action_level) || ["blocked","failed","stale","unknown","waiting_for_user","ready_for_merge","queued"].includes(card.display_status) || ["blocked","waiting_for_user"].includes(lc.status) || Number(card.active_def_count || card.metrics?.open_defs || 0) > 0; }
1002
1307
  function setMode(kind, value){ if(kind==="theme"){ root.dataset.theme = value; if(value === "system") document.documentElement.removeAttribute("data-theme"); else document.documentElement.dataset.theme = value; localStorage.setItem(storagePrefix+"theme", value); document.querySelectorAll("[data-theme-button]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.themeButton === value))); } else { root.dataset.view = value; localStorage.setItem(storagePrefix+"view", value); document.querySelectorAll("[data-view-button]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.viewButton === value))); renderCards(); } }
1003
1308
  document.querySelectorAll("[data-theme-button]").forEach(b => b.addEventListener("click", () => setMode("theme", b.dataset.themeButton)));
1004
1309
  document.querySelectorAll("[data-view-button]").forEach(b => b.addEventListener("click", () => setMode("view", b.dataset.viewButton)));
@@ -1012,21 +1317,33 @@ function renderDashboardHtmlPage(data) {
1012
1317
  [["generated", data.generated_at],["schema", data.schema_id],["language", data.target_language]].forEach(([k,v]) => { const p=el("span","pill"); const b=el("b","",k); p.append(b, document.createTextNode(" "+String(v||""))); meta.append(p); });
1013
1318
  }
1014
1319
  function renderMetrics(){ const box=document.getElementById("metrics"); box.replaceChildren(); (data.metrics || []).forEach(m => { const item=el("article","metric"); item.title = [m.source,m.reason].filter(Boolean).join(" / "); item.append(el("strong","",metricValue(m)), el("span","",m.label || m.id)); box.append(item); }); }
1320
+ function renderSummaryVersions(){
1321
+ const panel=document.getElementById("summaryVersionsPanel"); const box=document.getElementById("summaryVersions"); box.replaceChildren();
1322
+ const groups = data.summary_version_groups || []; if(pageKind()!=="global" || !groups.length){ panel.classList.add("hidden"); return; }
1323
+ panel.classList.remove("hidden"); text("summaryVersionCount", groups.length + " groups");
1324
+ groups.forEach(group => { const row=el("div","row"); const names = (group.project_names || []).join(", "); const label = [group.schema_id || "missing", group.schema_version || "unknown", group.compatible ? "compatible" : "unsupported"].join(" / "); const body=el("div",""); body.append(el("div","",label), el("div","sub",names)); row.append(body, statusBadge(group.compatible ? "done" : "unsupported")); box.append(row); });
1325
+ }
1015
1326
  function renderCards(){
1016
1327
  const mode = root.dataset.view || "compact"; const all = cards(); const visible = mode === "focus" ? all.filter(focusable) : all; text("focusCount", mode === "focus" ? visible.length + " focus" : all.length + " total");
1017
1328
  text("cardsTitle", pageKind()==="global" ? "Проекты" : pageKind()==="project" ? "Протоколы" : "Run history");
1018
1329
  const box=document.getElementById("cards"); box.replaceChildren();
1019
1330
  visible.forEach(card => { const c=el("article","card entity"); c.dataset.testid = pageKind()==="global" ? "project-card" : pageKind()==="project" ? "protocol-card" : "run-card"; c.dataset.status = card.display_status || card.status || "unknown";
1020
1331
  const href = card.href || card.run_index_path || card.report?.href || ""; const primary = linkNode(card.short_id || card.title || card.id, href, "primary"); primary.setAttribute("aria-label", card.title || card.id || "card"); c.append(primary);
1021
- const sub = el("div","sub", card.root || card.current_stage || card.next_action || card.run_index_path || ""); sub.title = sub.textContent; c.append(sub);
1022
- const badges=el("div","badges"); [card.display_status || card.status, card.display_flow_kind || card.flow_kind, card.action_level].filter(Boolean).forEach(v => badges.append(statusBadge(v))); c.append(badges);
1332
+ const sub = el("div","sub", card.root || lifecycleText(card) || card.next_action || card.run_index_path || ""); sub.title = sub.textContent; c.append(sub);
1333
+ const badges=el("div","badges"); [card.display_status || card.status, lifecycleText(card), card.display_flow_kind || card.flow_kind, card.action_level].filter(Boolean).forEach(v => badges.append(statusBadge(v))); c.append(badges);
1023
1334
  const mini=el("div","miniMetrics expandedOnly"); const metrics = card.metrics || {}; [["queue",metrics.queue],["locks",metrics.locks],["defs",metrics.open_defs ?? card.active_def_count]].forEach(([k,v]) => { const m=el("div",""); m.append(el("b","",v ?? "0"), document.createTextNode(k)); mini.append(m); }); c.append(mini); box.append(c); });
1024
1335
  }
1025
1336
  function renderLinks(){ const box=document.getElementById("links"); box.replaceChildren(); Object.entries(data.links || {}).forEach(([key,value]) => { const row=el("div","row"); row.append(linkNode(value.label || key, value.href, value.status === "available" ? "" : "disabled"), statusBadge(value.status || "unknown")); box.append(row); }); if(pageKind()==="protocol"){ (data.stage_pipeline || []).forEach(stage => { const row=el("div","row"); row.append(linkNode(stage.stage + " / " + stage.status, stage.report?.href, stage.report?.status === "available" ? "" : "disabled"), statusBadge(stage.display_status)); box.append(row); }); } }
1026
1337
  function renderSetBoard(){ const panel=document.getElementById("setBoardPanel"); const box=document.getElementById("setBoard"); box.replaceChildren(); const boards = pageKind()==="protocol" ? [data.protocol_set_board].filter(Boolean) : (data.protocol_set_boards || []); if(!boards.length){ panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); const total = boards.reduce((sum,b)=>sum + ((b.members || []).length),0); text("setBoardCount", total + " members"); boards.forEach(board => { const summary = board.summary || {}; const head=el("div","row"); head.append(el("div","", board.protocol_set || "protocol set"), el("span","badge", ["ready "+(summary.ready||0),"blocked "+(summary.blocked||0),"running "+(summary.running||0),"done "+(summary.done||0)].join(" / "))); box.append(head); (board.members || []).forEach(member => { const row=el("div","row"); const href = data.project?.root ? "file://" + String(member.path || "").split("/").map(encodeURIComponent).join("/") : ""; row.append(linkNode(member.id || "protocol", href, ""), statusBadge(member.set_status || "unknown")); box.append(row); }); }); }
1027
1338
  function renderReviewRuns(){ const panel=document.getElementById("reviewRunsPanel"); const box=document.getElementById("reviewRuns"); box.replaceChildren(); const runs = data.review_runs || []; if(!runs.length){ panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); text("reviewRunCount", runs.length + " recent"); runs.forEach(run => { const row=el("div","row"); const href = run.report?.href || run.run_index_path || ""; const label = [run.short_id || run.id, run.subject_id].filter(Boolean).join(" / "); row.append(linkNode(label, href, ""), statusBadge(run.display_status || run.status)); box.append(row); }); }
1339
+ function renderUnsupportedProjects(){
1340
+ const panel=document.getElementById("unsupportedProjectsPanel"); const box=document.getElementById("unsupportedProjects"); box.replaceChildren();
1341
+ const projects = data.unsupported_projects || []; if(pageKind()!=="global" || !projects.length){ panel.classList.add("hidden"); return; }
1342
+ panel.classList.remove("hidden"); text("unsupportedProjectCount", projects.length + " projects");
1343
+ projects.forEach(project => { const row=el("div","row"); const body=el("div",""); body.append(el("div","",project.title || project.id), el("div","sub",[project.reason, project.detail, project.root].filter(Boolean).join(" / "))); row.append(body, statusBadge(project.reason || "unsupported")); box.append(row); });
1344
+ }
1028
1345
  function renderWarnings(){ const box=document.getElementById("warnings"); box.replaceChildren(); const warnings = data.warnings || []; if(!warnings.length){ box.append(el("div","sub","No degraded dashboard data.")); return; } warnings.forEach(w => box.append(el("div","warn", typeof w === "string" ? w : JSON.stringify(w)))); }
1029
- renderHeader(); renderMetrics(); renderLinks(); renderSetBoard(); renderReviewRuns(); renderWarnings();
1346
+ renderHeader(); renderMetrics(); renderSummaryVersions(); renderLinks(); renderSetBoard(); renderReviewRuns(); renderUnsupportedProjects(); renderWarnings();
1030
1347
  const savedTheme = ["system","light","dark"].includes(localStorage.getItem(storagePrefix+"theme")) ? localStorage.getItem(storagePrefix+"theme") : "system";
1031
1348
  const savedView = ["compact","expanded","focus"].includes(localStorage.getItem(storagePrefix+"view")) ? localStorage.getItem(storagePrefix+"view") : "compact";
1032
1349
  setMode("theme", savedTheme); setMode("view", savedView);