@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
@@ -4,16 +4,26 @@ 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, globalDashboardMarkdownPath, 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";
11
+ import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsForProject, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
12
  import { activeFlowSessionsForProject } from "./sessions.js";
13
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
11
14
  export function getCmuxStatus(context, input) {
12
15
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
13
16
  return { ok: true, project_root: project.root, cmux: detectCmux(context) };
14
17
  }
15
18
  export function renderDashboard(context, input) {
16
19
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
20
+ const format = parseDashboardFormat(input.format);
21
+ if (format === "html") {
22
+ if (input.protocol) {
23
+ return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
24
+ }
25
+ return renderProjectDashboardHtml(context, project, input.output);
26
+ }
17
27
  const config = readProjectConfig(context, project.id);
18
28
  const output = input.output
19
29
  ? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
@@ -23,6 +33,10 @@ export function renderDashboard(context, input) {
23
33
  return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
24
34
  }
25
35
  export function renderGlobalDashboard(context, input = {}) {
36
+ const format = parseDashboardFormat(input.format);
37
+ if (format === "html") {
38
+ return renderGlobalDashboardHtml(context, input.output);
39
+ }
26
40
  const output = input.output
27
41
  ? path.resolve(input.output)
28
42
  : globalDashboardMarkdownPath(context);
@@ -33,15 +47,54 @@ export function renderGlobalDashboard(context, input = {}) {
33
47
  export function openDashboard(context, input) {
34
48
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
35
49
  const config = readProjectConfig(context, project.id);
50
+ const format = parseDashboardFormat(input.format, "html");
51
+ if (input.viewer && input.viewer !== "cmux") {
52
+ throw new AppError("validation", "--viewer must be cmux", 2);
53
+ }
54
+ const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
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 = {}) {
36
63
  if (input.viewer && input.viewer !== "cmux") {
37
64
  throw new AppError("validation", "--viewer must be cmux", 2);
38
65
  }
39
- const dashboardPath = dashboardMarkdownPath(project.root, config);
40
- return openCmuxDashboard(context, project.root, dashboardPath);
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
+ };
41
93
  }
42
94
  export function refreshDashboard(context, input) {
43
95
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
44
96
  const config = readProjectConfig(context, project.id);
97
+ const format = parseDashboardFormat(input.format);
45
98
  const openMode = input.open ?? "auto";
46
99
  if (!["auto", "true", "false"].includes(openMode)) {
47
100
  throw new AppError("validation", "--open must be auto, true, or false", 2);
@@ -54,22 +107,65 @@ export function refreshDashboard(context, input) {
54
107
  open: { ok: true, skipped: true, reason: "dashboard_project_disabled" }
55
108
  };
56
109
  }
57
- const rendered = renderDashboard(context, { projectRoot: project.root });
110
+ const rendered = renderDashboard(context, { projectRoot: project.root, format, ...(input.protocol ? { protocol: input.protocol } : {}) });
111
+ const projectSummary = input.protocol ? null : publishProjectSummary(context, { project });
58
112
  const global = config.dashboard.global
59
- ? renderGlobalDashboard(context, { output: globalDashboardMarkdownPath(context, config) })
113
+ ? renderGlobalDashboard(context, {
114
+ output: format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config),
115
+ format
116
+ })
60
117
  : { dashboard: { skipped: true, reason: "dashboard_global_disabled" } };
61
118
  const shouldOpen = openMode === "true" || (openMode === "auto" && config.integrations.cmux.mode !== "off");
62
119
  return {
63
120
  ok: true,
64
121
  project_root: project.root,
65
122
  dashboard: rendered.dashboard,
123
+ project_summary: projectSummary,
66
124
  global_dashboard: global.dashboard,
67
- open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path) : { ok: true, skipped: true, reason: "open_disabled" }
125
+ open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path, format) : { ok: true, skipped: true, reason: "open_disabled" }
68
126
  };
69
127
  }
70
128
  export function refreshGlobalDashboard(context, input = {}) {
71
129
  return renderGlobalDashboard(context, input);
72
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
+ }
73
169
  export function autoRefreshDashboards(context, input) {
74
170
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
75
171
  const config = readProjectConfig(context, project.id);
@@ -78,6 +174,7 @@ export function autoRefreshDashboards(context, input) {
78
174
  }
79
175
  const result = { ok: true, project_root: project.root };
80
176
  if (config.dashboard.project) {
177
+ publishProjectSummary(context, { project });
81
178
  const output = dashboardMarkdownPath(project.root, config);
82
179
  const markdown = renderProjectDashboardMarkdown(context, project, output);
83
180
  writeMarkdown(output, markdown);
@@ -102,13 +199,27 @@ export function dashboardSummary(context, projectId, projectRoot) {
102
199
  return {
103
200
  project_markdown_path: dashboardMarkdownPath(projectRoot, config),
104
201
  global_markdown_path: globalDashboardMarkdownPath(context, config),
202
+ project_html_path: projectDashboardHtmlPath(context, projectId),
203
+ project_json_path: projectDashboardJsonPath(context, projectId),
204
+ global_html_path: globalDashboardHtmlPath(context),
205
+ global_json_path: globalDashboardJsonPath(context),
105
206
  auto_refresh: config.dashboard.auto_refresh,
106
207
  project_enabled: config.dashboard.project,
107
208
  global_enabled: config.dashboard.global,
108
209
  cmux_mode: config.integrations.cmux.mode
109
210
  };
110
211
  }
111
- function openCmuxDashboard(context, projectRoot, dashboardPath) {
212
+ export function dashboardData(context, input) {
213
+ if (input.global) {
214
+ return { ok: true, dashboard: buildGlobalDashboardData(context, globalDashboardHtmlPath(context)) };
215
+ }
216
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot ?? process.cwd()));
217
+ if (input.protocol) {
218
+ return { ok: true, dashboard: buildProtocolDashboardData(context, project, input.protocol, protocolDashboardHtmlPath(context, project.id, input.protocol)) };
219
+ }
220
+ return { ok: true, dashboard: buildProjectDashboardData(context, project, projectDashboardHtmlPath(context, project.id)) };
221
+ }
222
+ function openCmuxDashboard(context, projectRoot, dashboardPath, format = "markdown") {
112
223
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
113
224
  const config = readProjectConfig(context, project.id);
114
225
  if (config.integrations.cmux.mode === "off") {
@@ -122,9 +233,10 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
122
233
  return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
123
234
  }
124
235
  if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
125
- return { ok: true, opened: false, dry_run: true, command: ["cmux", "markdown", "open", dashboardPath] };
236
+ return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
126
237
  }
127
- const result = spawnSync("cmux", ["markdown", "open", dashboardPath], { encoding: "utf8" });
238
+ const command = cmuxOpenCommand(format, dashboardPath);
239
+ const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
128
240
  if (result.status !== 0) {
129
241
  if (config.integrations.cmux.mode === "required") {
130
242
  throw new AppError("cmux_open_failed", "cmux markdown open failed", 1, {
@@ -134,7 +246,27 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
134
246
  }
135
247
  return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
136
248
  }
137
- return { ok: true, opened: true, command: ["cmux", "markdown", "open", dashboardPath] };
249
+ return { ok: true, opened: true, command };
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
+ }
266
+ function cmuxOpenCommand(format, dashboardPath) {
267
+ return format === "html"
268
+ ? ["cmux", "browser", "open", pathToFileUrl(dashboardPath)]
269
+ : ["cmux", "markdown", "open", dashboardPath];
138
270
  }
139
271
  function detectCmux(context) {
140
272
  if (context.env.DD_FLOW_CMUX_AVAILABLE === "true") {
@@ -148,6 +280,436 @@ function detectCmux(context) {
148
280
  ? { available: true, source: "path", version: result.stdout.trim() }
149
281
  : { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
150
282
  }
283
+ function parseDashboardFormat(value, defaultFormat = "markdown") {
284
+ const format = value ?? defaultFormat;
285
+ if (format !== "markdown" && format !== "html") {
286
+ throw new AppError("validation", "--format must be markdown or html", 2, { format });
287
+ }
288
+ return format;
289
+ }
290
+ function renderProjectDashboardHtml(context, project, output) {
291
+ const htmlPath = output
292
+ ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
293
+ : projectDashboardHtmlPath(context, project.id);
294
+ const jsonPath = projectDashboardJsonPath(context, project.id);
295
+ const data = buildProjectDashboardData(context, project, htmlPath);
296
+ const html = renderDashboardHtmlPage(data);
297
+ writeJsonFile(jsonPath, data);
298
+ writeTextFile(htmlPath, html);
299
+ const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
300
+ const protocolPages = protocolCards
301
+ .filter((card) => card.generate_page !== false)
302
+ .slice(0, 24)
303
+ .map((card) => renderProtocolDashboardHtml(context, project, String(card.id)))
304
+ .map((result) => ({
305
+ id: String(result.protocol_id),
306
+ path: result.dashboard.path,
307
+ json_path: result.dashboard.json_path
308
+ }));
309
+ return {
310
+ ok: true,
311
+ project_root: project.root,
312
+ dashboard: {
313
+ path: htmlPath,
314
+ json_path: jsonPath,
315
+ bytes: Buffer.byteLength(html),
316
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
317
+ },
318
+ protocol_pages: protocolPages
319
+ };
320
+ }
321
+ function renderProtocolDashboardHtml(context, project, protocolId, output) {
322
+ const htmlPath = output
323
+ ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
324
+ : protocolDashboardHtmlPath(context, project.id, protocolId);
325
+ const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
326
+ const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
327
+ const html = renderDashboardHtmlPage(data);
328
+ writeJsonFile(jsonPath, data);
329
+ writeTextFile(htmlPath, html);
330
+ return {
331
+ ok: true,
332
+ project_root: project.root,
333
+ protocol_id: protocolId,
334
+ dashboard: {
335
+ path: htmlPath,
336
+ json_path: jsonPath,
337
+ bytes: Buffer.byteLength(html),
338
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
339
+ }
340
+ };
341
+ }
342
+ function renderGlobalDashboardHtml(context, output) {
343
+ const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
344
+ const jsonPath = globalDashboardJsonPath(context);
345
+ const data = buildGlobalDashboardData(context, htmlPath);
346
+ const html = renderDashboardHtmlPage(data);
347
+ writeJsonFile(jsonPath, data);
348
+ writeTextFile(htmlPath, html);
349
+ return {
350
+ ok: true,
351
+ dashboard: {
352
+ path: htmlPath,
353
+ json_path: jsonPath,
354
+ bytes: Buffer.byteLength(html),
355
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
356
+ }
357
+ };
358
+ }
359
+ function buildGlobalDashboardData(context, htmlPath) {
360
+ const projects = context.db.all("SELECT * FROM projects ORDER BY status ASC, updated_at DESC, root ASC");
361
+ const activeProjects = projects.filter((project) => project.status === "active");
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);
367
+ return {
368
+ schema_id: "dd-flow/global-dashboard-data@1",
369
+ generated_at: context.now(),
370
+ target_language: "ru",
371
+ page: { kind: "global", screen_id: "SCR-DD-FLOW-GLOBAL-DASHBOARD", title: "dd-flow global dashboard", html_path: htmlPath, json_path: globalDashboardJsonPath(context) },
372
+ links: {
373
+ self: link("Global dashboard", htmlPath, "available"),
374
+ markdown_fallback: link("Markdown fallback", globalDashboardMarkdownPath(context), "available")
375
+ },
376
+ metrics: [
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"),
380
+ metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
381
+ metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
382
+ metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
383
+ metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
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,
393
+ project_cards: projectCards,
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 : []
541
+ };
542
+ }
543
+ function buildProjectDashboardData(context, project, htmlPath) {
544
+ const protocols = protocolsForProject(context, project.id);
545
+ const queue = queueForProject(context, project.id);
546
+ const sessions = activeFlowSessionsForProject(context, project.id);
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]);
549
+ const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
550
+ const protocolCards = protocols.slice(0, 48).map((protocol, index) => buildProtocolCard(context, project, protocol, index < activeProtocols.length + 12));
551
+ const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
552
+ const protocolSetBoards = protocolSetBoardsForProject(context, project.id, project.root);
553
+ const reviewRuns = recentReviewRunsForProject(context, project.id, 8);
554
+ const queuedProtocols = queue.slice(0, 12).map(queueItemDashboard);
555
+ return {
556
+ schema_id: "dd-flow/project-dashboard-data@1",
557
+ generated_at: context.now(),
558
+ target_language: "ru",
559
+ page: { kind: "project", screen_id: "SCR-DD-FLOW-PROJECT-DASHBOARD", title: `${path.basename(project.root)} dashboard`, html_path: htmlPath, json_path: projectDashboardJsonPath(context, project.id) },
560
+ project: {
561
+ id: project.id,
562
+ title: path.basename(project.root) || project.id,
563
+ root: project.root,
564
+ status: project.status,
565
+ updated_at: project.updated_at
566
+ },
567
+ links: {
568
+ self: link("Project dashboard", htmlPath, "available"),
569
+ global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
570
+ markdown_fallback: link("Markdown fallback", dashboardMarkdownPath(project.root, readProjectConfig(context, project.id)), "available")
571
+ },
572
+ metrics: [
573
+ metric("active_protocols", activeProtocols.length, "known", "protocols"),
574
+ metric("completed_protocols", protocols.filter((protocol) => !isActiveProtocolStatus(protocol.status)).length, "known", "protocols"),
575
+ metric("merge_queue", queue.filter((job) => isActiveQueueStatus(job.status)).length, "known", "merge_queue"),
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"),
578
+ metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
579
+ metric("running_sessions", sessions.length, "known", "flow_sessions"),
580
+ metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
581
+ ],
582
+ lifecycle_summary: lifecycleSummary(activeProtocols),
583
+ resource_summary: resourceSummary(queue, locks, waiters),
584
+ protocol_cards: protocolCards,
585
+ protocol_set_boards: protocolSetBoards,
586
+ review_runs: reviewRuns,
587
+ queued_protocols: queuedProtocols,
588
+ merge_queue: queuedProtocols,
589
+ sessions: sessions.slice(0, 12).map((session) => ({
590
+ id: session.session_id,
591
+ flow_kind: session.flow_kind,
592
+ display_flow_kind: normalizeFlowKind(session.flow_kind),
593
+ status: session.status,
594
+ display_status: normalizeDisplayStatus(session.status),
595
+ worker_id: session.worker_id,
596
+ workspace_path: session.workspace_path,
597
+ current_stage: session.current_stage,
598
+ next_action: session.next_action,
599
+ updated_at: session.updated_at
600
+ })),
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
+ })),
612
+ warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }]
613
+ };
614
+ }
615
+ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
616
+ const protocol = protocolsForProject(context, project.id).find((candidate) => candidate.id === protocolId);
617
+ if (!protocol) {
618
+ throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1, { protocol_id: protocolId });
619
+ }
620
+ const runtimeProtocol = requireProtocol(context, protocol.id);
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;
624
+ const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
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
626
+ FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
627
+ ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
628
+ const primaryRun = runs.find((run) => run.status === "running") ?? runs[0];
629
+ const warnings = [...runDiagnostics.diagnostics];
630
+ const protocolSetBoard = protocolSetBoardForProtocol(context, project.id, project.root, protocolId);
631
+ const reviewRuns = recentReviewRunsForProtocol(context, project.id, protocolId, 8);
632
+ const runHistory = runs.map((run) => {
633
+ const index = safeRunIndex(run.index_json, warnings, run.id);
634
+ return {
635
+ id: run.id,
636
+ short_id: run.short_id,
637
+ flow_kind: run.flow_kind,
638
+ display_flow_kind: normalizeFlowKind(run.flow_kind),
639
+ status: run.status,
640
+ display_status: normalizeDisplayStatus(run.status),
641
+ verdict: run.verdict,
642
+ next_action: run.next_action,
643
+ run_index_path: run.run_index_path,
644
+ updated_at: run.updated_at,
645
+ completed_at: run.completed_at,
646
+ stages: Array.isArray(index?.stage_runs) ? index.stage_runs.map((stage) => stageLink(project.root, run, stage)) : []
647
+ };
648
+ });
649
+ return {
650
+ schema_id: "dd-flow/protocol-dashboard-data@1",
651
+ generated_at: context.now(),
652
+ target_language: "ru",
653
+ page: { kind: "protocol", screen_id: "SCR-DD-FLOW-PROTOCOL-PAGE", title: `${shortId(protocolId)} protocol`, html_path: htmlPath, json_path: protocolDashboardJsonPath(context, project.id, protocolId) },
654
+ project: {
655
+ id: project.id,
656
+ title: path.basename(project.root) || project.id,
657
+ root: project.root
658
+ },
659
+ protocol: {
660
+ id: protocol.id,
661
+ short_id: shortId(protocol.id),
662
+ title: protocol.id,
663
+ raw_status: protocol.status,
664
+ raw_stage: protocol.stage,
665
+ lifecycle,
666
+ lifecycle_stage: lifecycle.stage,
667
+ lifecycle_status: lifecycle.status,
668
+ display_status: normalizeDisplayStatus(protocol.status),
669
+ action_level: actionLevel(protocol.status, jsonArray(protocol.blockers_json).length + jsonArray(protocol.active_def_json).length),
670
+ next_action: protocol.next_action,
671
+ updated_at: protocol.updated_at,
672
+ blockers: jsonArray(protocol.blockers_json),
673
+ active_def: jsonArray(protocol.active_def_json),
674
+ diagnostics: runDiagnostics.diagnostics,
675
+ source_path: path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`),
676
+ summary_path: path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")
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,
690
+ links: {
691
+ self: link("Protocol page", htmlPath, "available"),
692
+ project_dashboard: link("Project dashboard", projectDashboardHtmlPath(context, project.id), "available"),
693
+ global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
694
+ protocol_source: link("Protocol markdown", path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`), fs.existsSync(path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`)) ? "available" : "missing"),
695
+ protocol_summary: link("Protocol summary", path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md"), fs.existsSync(path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")) ? "available" : "missing")
696
+ },
697
+ primary_run_id: primaryRun?.id ?? null,
698
+ primary_run_reason: primaryRun ? (primaryRun.status === "running" ? "latest running run" : "latest updated run") : "no runs registered",
699
+ protocol_set_board: protocolSetBoard,
700
+ review_runs: reviewRuns,
701
+ run_history: runHistory,
702
+ stage_pipeline: latestStages(runHistory),
703
+ metrics: [
704
+ metric("runs", runs.length, "known", "flow_runs"),
705
+ metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
706
+ metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
707
+ metric("completed_runs", runs.filter((run) => run.status === "done").length, "known", "flow_runs"),
708
+ metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
709
+ ],
710
+ warnings
711
+ };
712
+ }
151
713
  function renderProjectDashboardMarkdown(context, project, output) {
152
714
  const protocols = protocolsForProject(context, project.id);
153
715
  const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
@@ -370,6 +932,45 @@ function queueStatusSummary(queue) {
370
932
  }
371
933
  return [...counts.entries()].map(([status, count]) => `${status}:${count}`).join(", ");
372
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
+ }
373
974
  function workspaceForProtocol(worktrees, protocolId) {
374
975
  return worktrees.find((worktree) => worktree.protocol_id === protocolId)?.worktree_path;
375
976
  }
@@ -389,6 +990,15 @@ function compactPath(value) {
389
990
  function truncate(value, maxLength) {
390
991
  return value.length > maxLength ? `${value.slice(0, Math.max(0, maxLength - 3))}...` : value;
391
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
+ }
392
1002
  function statusIcon(status) {
393
1003
  if (["active", "running", "ready", "ready_for_merge", "claimed", "in_progress"].includes(status))
394
1004
  return "🟢";
@@ -402,6 +1012,358 @@ function statusIcon(status) {
402
1012
  return "⚪";
403
1013
  return "•";
404
1014
  }
1015
+ function metric(id, value, status, source) {
1016
+ return {
1017
+ id,
1018
+ label: id.replace(/_/g, " "),
1019
+ value,
1020
+ status,
1021
+ source
1022
+ };
1023
+ }
1024
+ function link(label, href, status, reason) {
1025
+ return {
1026
+ label,
1027
+ href,
1028
+ path_kind: path.isAbsolute(href) ? "absolute" : "relative",
1029
+ status,
1030
+ ...(reason ? { degraded_reason: reason } : {})
1031
+ };
1032
+ }
1033
+ function buildProtocolCard(context, project, protocol, generatePage) {
1034
+ const summary = planSummaryForProtocol(context, protocol.id);
1035
+ const activeDefCount = jsonArray(protocol.active_def_json).length;
1036
+ const blockerCount = jsonArray(protocol.blockers_json).length;
1037
+ const latestRun = context.db.get(`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
1038
+ FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
1039
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
1040
+ let diagnostics = [];
1041
+ let lifecycle = normalizeProtocolLifecycle({ rawStage: protocol.stage, rawStatus: protocol.status });
1042
+ try {
1043
+ const runtimeProtocol = requireProtocol(context, protocol.id);
1044
+ const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
1045
+ lifecycle = normalizeProtocolLifecycle({ state: runtimeState });
1046
+ diagnostics = [...protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics, ...lifecycle.diagnostics];
1047
+ }
1048
+ catch {
1049
+ diagnostics = [];
1050
+ }
1051
+ return {
1052
+ id: protocol.id,
1053
+ short_id: shortId(protocol.id),
1054
+ title: protocol.id,
1055
+ href: protocolDashboardHtmlPath(context, project.id, protocol.id),
1056
+ raw_status: protocol.status,
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
+ },
1067
+ display_status: normalizeDisplayStatus(protocol.status),
1068
+ action_level: actionLevel(protocol.status, activeDefCount + blockerCount),
1069
+ flow_kind: latestRun?.flow_kind ?? "unknown",
1070
+ display_flow_kind: normalizeFlowKind(latestRun?.flow_kind ?? "unknown"),
1071
+ current_stage: protocol.stage,
1072
+ next_action: protocol.next_action,
1073
+ plan: summary,
1074
+ active_def_count: activeDefCount,
1075
+ blocker_count: blockerCount,
1076
+ latest_run_id: latestRun?.id ?? null,
1077
+ latest_run_status: latestRun?.status ?? "unknown",
1078
+ diagnostics,
1079
+ warning_count: diagnostics.length,
1080
+ updated_at: protocol.updated_at,
1081
+ generate_page: generatePage
1082
+ };
1083
+ }
1084
+ function recentReviewRunsForProject(context, projectId, limit) {
1085
+ 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
1086
+ FROM flow_runs
1087
+ WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
1088
+ ORDER BY updated_at DESC, id DESC
1089
+ LIMIT ?`, [projectId, limit]);
1090
+ return runs.map(reviewRunSummary);
1091
+ }
1092
+ function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
1093
+ 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
1094
+ FROM flow_runs
1095
+ WHERE project_id = ?
1096
+ AND flow_kind IN ('mb-sdlc-review', 'review')
1097
+ AND subject_type = 'protocol'
1098
+ AND subject_id = ?
1099
+ ORDER BY updated_at DESC, id DESC
1100
+ LIMIT ?`, [projectId, protocolId, limit]);
1101
+ return runs.map(reviewRunSummary);
1102
+ }
1103
+ function reviewRunSummary(run) {
1104
+ const index = safeParseIndex(run.index_json);
1105
+ const stages = Array.isArray(index?.stage_runs) ? index.stage_runs : [];
1106
+ const reviewStage = stages.find((stage) => stage.stage === "review") ?? stages[stages.length - 1];
1107
+ return {
1108
+ id: run.id,
1109
+ short_id: run.short_id,
1110
+ flow_kind: run.flow_kind,
1111
+ display_flow_kind: normalizeFlowKind(run.flow_kind),
1112
+ subject_type: run.subject_type,
1113
+ subject_id: run.subject_id,
1114
+ status: run.status,
1115
+ display_status: normalizeDisplayStatus(run.status),
1116
+ verdict: run.verdict,
1117
+ next_action: run.next_action,
1118
+ run_index_path: run.run_index_path,
1119
+ report: reviewStage ? stageLink("", run, reviewStage).report : link("Run index", run.run_index_path, fs.existsSync(run.run_index_path) ? "available" : "missing"),
1120
+ updated_at: run.updated_at,
1121
+ completed_at: run.completed_at
1122
+ };
1123
+ }
1124
+ function safeParseIndex(text) {
1125
+ try {
1126
+ const parsed = JSON.parse(text);
1127
+ return parsed && typeof parsed === "object" ? parsed : null;
1128
+ }
1129
+ catch {
1130
+ return null;
1131
+ }
1132
+ }
1133
+ function normalizeDisplayStatus(status) {
1134
+ if (["active", "running", "in_progress"].includes(status))
1135
+ return "running";
1136
+ if (["waiting_user", "waiting_for_user"].includes(status))
1137
+ return "waiting_for_user";
1138
+ if (["ready_for_merge"].includes(status))
1139
+ return "ready_for_merge";
1140
+ if (["ready", "queued_for_merge", "claimed", "requeued"].includes(status))
1141
+ return "queued";
1142
+ if (["closed", "closed_local", "done", "merged"].includes(status))
1143
+ return "done";
1144
+ if (["blocked"].includes(status))
1145
+ return "blocked";
1146
+ if (["failed", "error"].includes(status))
1147
+ return "failed";
1148
+ if (["cancelled"].includes(status))
1149
+ return "cancelled";
1150
+ if (["registered", "pending", "priming", "specify", "plan", "implementation", "readiness", "integration"].includes(status))
1151
+ return "active";
1152
+ if (["archived", "expired", "stopped"].includes(status))
1153
+ return "stale";
1154
+ return "unknown";
1155
+ }
1156
+ function normalizeFlowKind(flowKind) {
1157
+ if (flowKind === "mb_sdlc")
1158
+ return "mb-sdlc";
1159
+ if (flowKind === "planning" || flowKind === "implementation")
1160
+ return "coding";
1161
+ if (flowKind === "merge_worker" || flowKind === "merge_job")
1162
+ return "merge";
1163
+ if (flowKind === "memory_flow")
1164
+ return "custom";
1165
+ if ([
1166
+ "mb-sdlc",
1167
+ "coding",
1168
+ "interactive",
1169
+ "finish",
1170
+ "merge",
1171
+ "mb-init",
1172
+ "mb-upgrade",
1173
+ "mb-audit",
1174
+ "mb-fix",
1175
+ "mb-distill",
1176
+ "mb-upgrade-review",
1177
+ "mb-sdlc-review",
1178
+ "review",
1179
+ "release",
1180
+ "deploy",
1181
+ "publish",
1182
+ "experiment",
1183
+ "custom"
1184
+ ].includes(flowKind)) {
1185
+ return flowKind;
1186
+ }
1187
+ return "unknown";
1188
+ }
1189
+ function actionLevel(status, problemCount) {
1190
+ if (problemCount > 0 || ["blocked", "failed", "error"].includes(status))
1191
+ return "blocked";
1192
+ if (["waiting_user", "waiting_for_user", "ready_for_merge", "ready", "queued_for_merge", "requeued"].includes(status))
1193
+ return "actionable";
1194
+ if (["running", "active", "claimed", "in_progress"].includes(status))
1195
+ return "watch";
1196
+ return "none";
1197
+ }
1198
+ function safeRunIndex(text, warnings, runId) {
1199
+ try {
1200
+ const parsed = JSON.parse(text);
1201
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1202
+ }
1203
+ catch {
1204
+ warnings.push({ code: "run_index_invalid_json", run_id: runId });
1205
+ return null;
1206
+ }
1207
+ }
1208
+ function stageLink(projectRoot, run, stage) {
1209
+ const item = stage && typeof stage === "object" && !Array.isArray(stage) ? stage : {};
1210
+ const report = typeof item.stage_report === "string" ? item.stage_report : undefined;
1211
+ const href = report ? path.join(path.dirname(run.run_index_path), report) : "";
1212
+ return {
1213
+ order: typeof item.order === "number" ? item.order : 0,
1214
+ stage: String(item.stage ?? "unknown"),
1215
+ dir: String(item.dir ?? ""),
1216
+ status: String(item.status ?? "unknown"),
1217
+ display_status: normalizeDisplayStatus(String(item.status ?? "unknown")),
1218
+ report: report ? link("Stage report", href, fs.existsSync(href) ? "available" : "missing", fs.existsSync(href) ? undefined : "stage_report_missing") : link("Stage report", "", "pending", "stage_report_not_created_yet"),
1219
+ data: typeof item.data === "string" ? path.join(path.dirname(run.run_index_path), item.data) : null,
1220
+ run_index: path.relative(projectRoot, run.run_index_path)
1221
+ };
1222
+ }
1223
+ function latestStages(runHistory) {
1224
+ const byStage = new Map();
1225
+ for (const run of runHistory) {
1226
+ const stages = Array.isArray(run.stages) ? run.stages : [];
1227
+ for (const stage of stages) {
1228
+ const key = String(stage.stage ?? "unknown");
1229
+ if (!byStage.has(key))
1230
+ byStage.set(key, { ...stage, run_id: run.id });
1231
+ }
1232
+ }
1233
+ return [...byStage.values()].sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
1234
+ }
1235
+ function renderDashboardHtmlPage(data) {
1236
+ const json = JSON.stringify(data, null, 2).replace(/<\//g, "<\\/");
1237
+ return `<!doctype html>
1238
+ <html lang="ru">
1239
+ <head>
1240
+ <meta charset="utf-8">
1241
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1242
+ <title>dd-flow dashboard</title>
1243
+ <style>
1244
+ :root{color-scheme:light dark;--bg:#eef0ea;--paper:#fbfaf4;--paper-2:#f1f4ee;--ink:#171b17;--muted:#626a63;--line:#c7d0c4;--line-strong:#9fab9d;--green:#176b5c;--green-soft:#dcebe4;--blue:#2d5d83;--blue-soft:#dbe8ee;--amber:#b56a24;--amber-soft:#f4e3c7;--red:#ad3f35;--shadow:0 18px 42px rgba(32,42,31,.10)}
1245
+ [data-theme=dark]{color-scheme:dark;--bg:#11161a;--paper:#192026;--paper-2:#202932;--ink:#f2eadc;--muted:#b5bbb8;--line:#33404a;--line-strong:#536471;--green:#79c8ae;--green-soft:#17372f;--blue:#8ebbd3;--blue-soft:#1d3342;--amber:#dfae62;--amber-soft:#3d2c16;--red:#e18078;--shadow:0 18px 42px rgba(0,0,0,.28)}
1246
+ [data-theme=light]{color-scheme:light}
1247
+ @media (prefers-color-scheme: dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#11161a;--paper:#192026;--paper-2:#202932;--ink:#f2eadc;--muted:#b5bbb8;--line:#33404a;--line-strong:#536471;--green:#79c8ae;--green-soft:#17372f;--blue:#8ebbd3;--blue-soft:#1d3342;--amber:#dfae62;--amber-soft:#3d2c16;--red:#e18078;--shadow:0 18px 42px rgba(0,0,0,.28)}}
1248
+ *{box-sizing:border-box}body{margin:0;padding:14px;background:var(--bg);color:var(--ink);font-family:"Avenir Next","Gill Sans",Verdana,sans-serif;letter-spacing:0;overflow-x:hidden}
1249
+ body:before{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background:repeating-linear-gradient(90deg,color-mix(in srgb,var(--green) 5%,transparent) 0 1px,transparent 1px 38px),repeating-linear-gradient(0deg,color-mix(in srgb,var(--green) 4%,transparent) 0 1px,transparent 1px 38px),linear-gradient(135deg,color-mix(in srgb,var(--paper) 32%,transparent),transparent 58%);filter:blur(1px);opacity:.54}
1250
+ a{color:var(--green);text-decoration:none}a:hover{text-decoration:underline}a:focus-visible,button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.page{width:min(1480px,100%);margin:0 auto;padding-bottom:20px}.topbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px}.breadcrumb{color:var(--muted);font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.switches{display:flex;gap:8px;flex-wrap:wrap}.seg{display:inline-grid;grid-auto-flow:column;gap:4px;padding:5px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper) 72%,transparent)}button{font:inherit}.seg button{border:0;border-radius:6px;padding:7px 10px;min-width:64px;background:transparent;color:var(--muted);cursor:pointer;font-size:13px}.seg button[aria-pressed=true]{background:var(--ink);color:var(--paper)}
1251
+ .hero{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:12px;margin-bottom:12px}.card{position:relative;min-width:0;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper) 94%,transparent);box-shadow:var(--shadow)}.headline{padding:14px;min-height:128px;display:flex;flex-direction:column;justify-content:space-between}.eyebrow{color:var(--green);font-size:13px;font-weight:900}h1{margin:2px 0 7px;font-family:"Iowan Old Style",Georgia,serif;font-size:clamp(28px,2.6vw,36px);line-height:1.02;letter-spacing:0}h2{margin:0;font-size:17px;line-height:1.15;font-weight:850}.lead{max-width:900px;color:var(--muted);font-size:14px;line-height:1.42}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.pill{display:inline-flex;align-items:center;gap:6px;min-height:31px;padding:6px 9px;border:1px solid var(--line);border-radius:7px;background:color-mix(in srgb,var(--paper-2) 80%,transparent);color:var(--muted);font-size:13px;max-width:100%;overflow-wrap:anywhere}.pill b{color:var(--ink)}
1252
+ .verdict{display:grid;gap:10px;overflow:hidden;padding:16px;background:linear-gradient(180deg,var(--ink),color-mix(in srgb,var(--ink) 92%,var(--green)));color:var(--paper);border-color:color-mix(in srgb,var(--ink) 80%,var(--line))}.verdictLabel{color:color-mix(in srgb,var(--paper) 70%,transparent);font-size:12px;text-transform:uppercase;letter-spacing:.08em;font-weight:900}.verdictText{font-size:30px;line-height:1.02;font-weight:950;overflow-wrap:anywhere}.metricGrid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin-bottom:12px}.metric{padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper-2) 68%,transparent)}.metric strong{display:block;font-size:24px;line-height:1}.metric span{display:block;margin-top:4px;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em}
1253
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}.entity{display:grid;gap:9px;padding:12px;min-height:160px}.entity .primary{display:block;color:var(--ink);font-weight:900;font-size:16px;line-height:1.18;overflow-wrap:anywhere}.entity .primary:after{content:"";position:absolute;inset:0;border-radius:8px}.entity :is(a,button,details){position:relative;z-index:1}.sub{color:var(--muted);font-size:13px;line-height:1.35;overflow-wrap:anywhere}.badges{display:flex;flex-wrap:wrap;gap:6px}.badge{display:inline-flex;align-items:center;max-width:100%;padding:4px 7px;border-radius:999px;background:var(--green-soft);color:var(--green);font-size:12px;font-weight:800;overflow-wrap:anywhere}.badge.watch,.badge.queued,.badge.waiting_for_user,.badge.stale,.badge.unknown{background:var(--amber-soft);color:var(--amber)}.badge.blocked,.badge.failed{background:color-mix(in srgb,var(--red) 18%,transparent);color:var(--red)}.badge.done{background:var(--blue-soft);color:var(--blue)}.miniMetrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}.miniMetrics div{padding:7px;border:1px solid var(--line);border-radius:7px;background:color-mix(in srgb,var(--paper) 74%,transparent);font-size:12px;color:var(--muted)}.miniMetrics b{display:block;color:var(--ink);font-size:16px}.panel{padding:14px;margin-bottom:12px}.sectionHead{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}.list{display:grid;gap:7px}.row{display:grid;grid-template-columns:minmax(0,1fr) max-content;gap:10px;padding:10px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper-2) 66%,transparent)}.row a{overflow-wrap:anywhere}.disabled{color:var(--muted);text-decoration:none;cursor:not-allowed}.warn{color:var(--amber);font-size:13px}.hidden{display:none!important}
1254
+ [data-view=compact] .entity{min-height:120px}[data-view=compact] .expandedOnly{display:none}[data-view=expanded] .entity{min-height:210px}@media(max-width:980px){.hero{grid-template-columns:1fr}.metricGrid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){body{padding:10px}.topbar{align-items:stretch;flex-direction:column}.switches,.seg{width:100%}.seg{grid-auto-flow:column}.metricGrid{grid-template-columns:1fr}.row{grid-template-columns:1fr}}
1255
+ </style>
1256
+ </head>
1257
+ <body>
1258
+ <script id="dashboard-data" type="application/json">${json}</script>
1259
+ <main class="page" id="dashboard-root" data-testid="dashboard-root">
1260
+ <div class="topbar">
1261
+ <div class="breadcrumb" id="breadcrumb"></div>
1262
+ <div class="switches">
1263
+ <div class="seg" role="group" aria-label="View mode" data-testid="view-switch">
1264
+ <button type="button" data-view-button="compact">Compact</button>
1265
+ <button type="button" data-view-button="expanded">Expanded</button>
1266
+ <button type="button" data-view-button="focus">Focus</button>
1267
+ </div>
1268
+ <div class="seg" role="group" aria-label="Theme mode" data-testid="theme-switch">
1269
+ <button type="button" data-theme-button="system">System</button>
1270
+ <button type="button" data-theme-button="light">Light</button>
1271
+ <button type="button" data-theme-button="dark">Dark</button>
1272
+ </div>
1273
+ </div>
1274
+ </div>
1275
+ <section class="hero">
1276
+ <section class="card headline">
1277
+ <div><div class="eyebrow" id="eyebrow"></div><h1 id="title"></h1><p class="lead" id="lead"></p></div>
1278
+ <div class="meta" id="meta"></div>
1279
+ </section>
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>
1281
+ </section>
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>
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>
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>
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>
1288
+ <section class="card panel"><div class="sectionHead"><h2>Навигация и артефакты</h2></div><div class="list" id="links"></div></section>
1289
+ <section class="card panel"><div class="sectionHead"><h2>Warnings</h2></div><div class="list" id="warnings"></div></section>
1290
+ </main>
1291
+ <script>
1292
+ const data = JSON.parse(document.getElementById("dashboard-data").textContent);
1293
+ const root = document.getElementById("dashboard-root");
1294
+ const storagePrefix = "dd-flow.dashboard.";
1295
+ const page = data.page || {};
1296
+ root.dataset.screenId = page.screen_id || "";
1297
+ function text(id, value){ document.getElementById(id).textContent = value == null ? "" : String(value); }
1298
+ function el(tag, className, value){ const node = document.createElement(tag); if(className) node.className = className; if(value != null) node.textContent = String(value); return node; }
1299
+ function safeHref(href){ const value = String(href || ""); return /^javascript:/i.test(value) ? "" : value; }
1300
+ function linkNode(label, href, className){ const a = document.createElement("a"); a.className = className || ""; a.textContent = label || href || "link"; a.href = safeHref(href); a.title = href || label || ""; return a; }
1301
+ function statusBadge(value){ const b = el("span","badge " + String(value || "unknown"), value || "unknown"); return b; }
1302
+ function metricValue(metric){ return metric && metric.value !== null && metric.value !== undefined ? metric.value : "N/A"; }
1303
+ function pageKind(){ return page.kind || "dashboard"; }
1304
+ function cards(){ if(pageKind()==="global") return data.project_cards || []; if(pageKind()==="project") return data.protocol_cards || []; return data.run_history || []; }
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; }
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(); } }
1308
+ document.querySelectorAll("[data-theme-button]").forEach(b => b.addEventListener("click", () => setMode("theme", b.dataset.themeButton)));
1309
+ document.querySelectorAll("[data-view-button]").forEach(b => b.addEventListener("click", () => setMode("view", b.dataset.viewButton)));
1310
+ function renderHeader(){
1311
+ text("breadcrumb", ["dd-flow", page.kind, data.project?.title || data.project?.id, data.protocol?.short_id].filter(Boolean).join(" / "));
1312
+ text("eyebrow", page.screen_id || "dd-flow");
1313
+ text("title", page.title || "dd-flow dashboard");
1314
+ const lead = pageKind()==="global" ? "Общий обзор локальной машины: проекты, активные протоколы, очереди, locks и degraded состояния." : pageKind()==="project" ? "Рабочая карта проекта: протоколы, сессии, очередь merge, locks и DEF." : "Страница протокола как задачи: состояние, run history, stage reports и next action.";
1315
+ text("lead", lead); text("verdictText", pageKind()); text("verdictNote", data.protocol?.next_action || data.primary_run_reason || "fresh local static dashboard");
1316
+ const meta = document.getElementById("meta"); meta.replaceChildren();
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); });
1318
+ }
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
+ }
1326
+ function renderCards(){
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");
1328
+ text("cardsTitle", pageKind()==="global" ? "Проекты" : pageKind()==="project" ? "Протоколы" : "Run history");
1329
+ const box=document.getElementById("cards"); box.replaceChildren();
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";
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);
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);
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); });
1335
+ }
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); }); } }
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); }); }); }
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
+ }
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)))); }
1346
+ renderHeader(); renderMetrics(); renderSummaryVersions(); renderLinks(); renderSetBoard(); renderReviewRuns(); renderUnsupportedProjects(); renderWarnings();
1347
+ const savedTheme = ["system","light","dark"].includes(localStorage.getItem(storagePrefix+"theme")) ? localStorage.getItem(storagePrefix+"theme") : "system";
1348
+ const savedView = ["compact","expanded","focus"].includes(localStorage.getItem(storagePrefix+"view")) ? localStorage.getItem(storagePrefix+"view") : "compact";
1349
+ setMode("theme", savedTheme); setMode("view", savedView);
1350
+ </script>
1351
+ </body>
1352
+ </html>`;
1353
+ }
1354
+ function pathToFileUrl(filePath) {
1355
+ return `file://${path.resolve(filePath).split(path.sep).map(encodeURIComponent).join("/")}`;
1356
+ }
1357
+ function writeJsonFile(output, data) {
1358
+ ensureDir(path.dirname(output));
1359
+ const tmpFile = `${output}.tmp-${process.pid}-${Date.now()}`;
1360
+ fs.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}\n`);
1361
+ fs.renameSync(tmpFile, output);
1362
+ }
1363
+ function writeTextFile(output, text) {
1364
+ ensureDir(path.dirname(output));
1365
+ fs.writeFileSync(output, text);
1366
+ }
405
1367
  function writeMarkdown(output, markdown) {
406
1368
  ensureDir(path.dirname(output));
407
1369
  fs.writeFileSync(output, markdown);