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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +13 -0
  2. package/dist/build-info.json +15 -0
  3. package/dist/cli/help.js +133 -14
  4. package/dist/cli/run-cli.js +242 -8
  5. package/dist/schemas/archived-flow-manifest.schema.json +112 -0
  6. package/dist/schemas/compatibility.schema.json +26 -0
  7. package/dist/schemas/flow-guidance.schema.json +73 -0
  8. package/dist/schemas/flow-run-index.schema.json +30 -4
  9. package/dist/schemas/global-dashboard-data.schema.json +68 -0
  10. package/dist/schemas/mb-sdlc-review-report.schema.json +242 -0
  11. package/dist/schemas/merge-stage-report.schema.json +61 -1
  12. package/dist/schemas/plan-stage-report.schema.json +255 -0
  13. package/dist/schemas/project-dashboard-data.schema.json +98 -0
  14. package/dist/schemas/project-flow-pack-manifest.schema.json +127 -0
  15. package/dist/schemas/protocol-dashboard-data.schema.json +89 -0
  16. package/dist/schemas/status-report.schema.json +186 -0
  17. package/dist/schemas/version-report.schema.json +22 -0
  18. package/dist/services/build-info.js +114 -0
  19. package/dist/services/canon.js +298 -0
  20. package/dist/services/cleanup.js +14 -1
  21. package/dist/services/config.js +25 -0
  22. package/dist/services/dashboard.js +655 -10
  23. package/dist/services/flow-guidance.js +214 -0
  24. package/dist/services/ids.js +106 -0
  25. package/dist/services/lanes.js +6 -2
  26. package/dist/services/merge-queue.js +68 -4
  27. package/dist/services/merge-worker.js +177 -0
  28. package/dist/services/projects.js +7 -1
  29. package/dist/services/protocols.js +648 -17
  30. package/dist/services/runs.js +98 -21
  31. package/dist/services/schema-validation.js +88 -9
  32. package/dist/services/sessions.js +3 -2
  33. package/dist/services/status.js +306 -0
  34. package/dist/services/version-status.js +284 -0
  35. package/dist/storage/database.js +13 -0
  36. package/dist/storage/paths.js +21 -0
  37. package/package.json +2 -2
@@ -4,9 +4,10 @@ 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, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
8
8
  import { queueForProject } from "./merge-queue.js";
9
9
  import { requireProjectByRoot } from "./projects.js";
10
+ import { protocolRunDiagnostics, protocolSetBoardForProtocol, protocolSetBoardsForProject, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
10
11
  import { activeFlowSessionsForProject } from "./sessions.js";
11
12
  export function getCmuxStatus(context, input) {
12
13
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -14,6 +15,13 @@ export function getCmuxStatus(context, input) {
14
15
  }
15
16
  export function renderDashboard(context, input) {
16
17
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
18
+ const format = parseDashboardFormat(input.format);
19
+ if (format === "html") {
20
+ if (input.protocol) {
21
+ return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
22
+ }
23
+ return renderProjectDashboardHtml(context, project, input.output);
24
+ }
17
25
  const config = readProjectConfig(context, project.id);
18
26
  const output = input.output
19
27
  ? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
@@ -23,6 +31,10 @@ export function renderDashboard(context, input) {
23
31
  return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
24
32
  }
25
33
  export function renderGlobalDashboard(context, input = {}) {
34
+ const format = parseDashboardFormat(input.format);
35
+ if (format === "html") {
36
+ return renderGlobalDashboardHtml(context, input.output);
37
+ }
26
38
  const output = input.output
27
39
  ? path.resolve(input.output)
28
40
  : globalDashboardMarkdownPath(context);
@@ -33,15 +45,17 @@ export function renderGlobalDashboard(context, input = {}) {
33
45
  export function openDashboard(context, input) {
34
46
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
35
47
  const config = readProjectConfig(context, project.id);
48
+ const format = parseDashboardFormat(input.format);
36
49
  if (input.viewer && input.viewer !== "cmux") {
37
50
  throw new AppError("validation", "--viewer must be cmux", 2);
38
51
  }
39
- const dashboardPath = dashboardMarkdownPath(project.root, config);
40
- return openCmuxDashboard(context, project.root, dashboardPath);
52
+ const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
53
+ return openCmuxDashboard(context, project.root, dashboardPath, format);
41
54
  }
42
55
  export function refreshDashboard(context, input) {
43
56
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
44
57
  const config = readProjectConfig(context, project.id);
58
+ const format = parseDashboardFormat(input.format);
45
59
  const openMode = input.open ?? "auto";
46
60
  if (!["auto", "true", "false"].includes(openMode)) {
47
61
  throw new AppError("validation", "--open must be auto, true, or false", 2);
@@ -54,9 +68,12 @@ export function refreshDashboard(context, input) {
54
68
  open: { ok: true, skipped: true, reason: "dashboard_project_disabled" }
55
69
  };
56
70
  }
57
- const rendered = renderDashboard(context, { projectRoot: project.root });
71
+ const rendered = renderDashboard(context, { projectRoot: project.root, format, ...(input.protocol ? { protocol: input.protocol } : {}) });
58
72
  const global = config.dashboard.global
59
- ? renderGlobalDashboard(context, { output: globalDashboardMarkdownPath(context, config) })
73
+ ? renderGlobalDashboard(context, {
74
+ output: format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config),
75
+ format
76
+ })
60
77
  : { dashboard: { skipped: true, reason: "dashboard_global_disabled" } };
61
78
  const shouldOpen = openMode === "true" || (openMode === "auto" && config.integrations.cmux.mode !== "off");
62
79
  return {
@@ -64,7 +81,7 @@ export function refreshDashboard(context, input) {
64
81
  project_root: project.root,
65
82
  dashboard: rendered.dashboard,
66
83
  global_dashboard: global.dashboard,
67
- open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path) : { ok: true, skipped: true, reason: "open_disabled" }
84
+ open: shouldOpen ? openCmuxDashboard(context, project.root, rendered.dashboard.path, format) : { ok: true, skipped: true, reason: "open_disabled" }
68
85
  };
69
86
  }
70
87
  export function refreshGlobalDashboard(context, input = {}) {
@@ -102,13 +119,27 @@ export function dashboardSummary(context, projectId, projectRoot) {
102
119
  return {
103
120
  project_markdown_path: dashboardMarkdownPath(projectRoot, config),
104
121
  global_markdown_path: globalDashboardMarkdownPath(context, config),
122
+ project_html_path: projectDashboardHtmlPath(context, projectId),
123
+ project_json_path: projectDashboardJsonPath(context, projectId),
124
+ global_html_path: globalDashboardHtmlPath(context),
125
+ global_json_path: globalDashboardJsonPath(context),
105
126
  auto_refresh: config.dashboard.auto_refresh,
106
127
  project_enabled: config.dashboard.project,
107
128
  global_enabled: config.dashboard.global,
108
129
  cmux_mode: config.integrations.cmux.mode
109
130
  };
110
131
  }
111
- function openCmuxDashboard(context, projectRoot, dashboardPath) {
132
+ export function dashboardData(context, input) {
133
+ if (input.global) {
134
+ return { ok: true, dashboard: buildGlobalDashboardData(context, globalDashboardHtmlPath(context)) };
135
+ }
136
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot ?? process.cwd()));
137
+ if (input.protocol) {
138
+ return { ok: true, dashboard: buildProtocolDashboardData(context, project, input.protocol, protocolDashboardHtmlPath(context, project.id, input.protocol)) };
139
+ }
140
+ return { ok: true, dashboard: buildProjectDashboardData(context, project, projectDashboardHtmlPath(context, project.id)) };
141
+ }
142
+ function openCmuxDashboard(context, projectRoot, dashboardPath, format = "markdown") {
112
143
  const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
113
144
  const config = readProjectConfig(context, project.id);
114
145
  if (config.integrations.cmux.mode === "off") {
@@ -122,9 +153,10 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
122
153
  return { ok: true, skipped: true, reason: "cmux_unavailable", cmux };
123
154
  }
124
155
  if (context.env.DD_FLOW_CMUX_DRY_RUN === "true") {
125
- return { ok: true, opened: false, dry_run: true, command: ["cmux", "markdown", "open", dashboardPath] };
156
+ return { ok: true, opened: false, dry_run: true, command: cmuxOpenCommand(format, dashboardPath) };
126
157
  }
127
- const result = spawnSync("cmux", ["markdown", "open", dashboardPath], { encoding: "utf8" });
158
+ const command = cmuxOpenCommand(format, dashboardPath);
159
+ const result = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
128
160
  if (result.status !== 0) {
129
161
  if (config.integrations.cmux.mode === "required") {
130
162
  throw new AppError("cmux_open_failed", "cmux markdown open failed", 1, {
@@ -134,7 +166,12 @@ function openCmuxDashboard(context, projectRoot, dashboardPath) {
134
166
  }
135
167
  return { ok: true, skipped: true, reason: "cmux_open_failed", status: result.status, stderr: result.stderr.trim() };
136
168
  }
137
- return { ok: true, opened: true, command: ["cmux", "markdown", "open", dashboardPath] };
169
+ return { ok: true, opened: true, command };
170
+ }
171
+ function cmuxOpenCommand(format, dashboardPath) {
172
+ return format === "html"
173
+ ? ["cmux", "browser", "open", pathToFileUrl(dashboardPath)]
174
+ : ["cmux", "markdown", "open", dashboardPath];
138
175
  }
139
176
  function detectCmux(context) {
140
177
  if (context.env.DD_FLOW_CMUX_AVAILABLE === "true") {
@@ -148,6 +185,279 @@ function detectCmux(context) {
148
185
  ? { available: true, source: "path", version: result.stdout.trim() }
149
186
  : { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
150
187
  }
188
+ function parseDashboardFormat(value) {
189
+ const format = value ?? "markdown";
190
+ if (format !== "markdown" && format !== "html") {
191
+ throw new AppError("validation", "--format must be markdown or html", 2, { format });
192
+ }
193
+ return format;
194
+ }
195
+ function renderProjectDashboardHtml(context, project, output) {
196
+ const htmlPath = output
197
+ ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
198
+ : projectDashboardHtmlPath(context, project.id);
199
+ const jsonPath = projectDashboardJsonPath(context, project.id);
200
+ const data = buildProjectDashboardData(context, project, htmlPath);
201
+ const html = renderDashboardHtmlPage(data);
202
+ writeJsonFile(jsonPath, data);
203
+ writeTextFile(htmlPath, html);
204
+ const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
205
+ const protocolPages = protocolCards
206
+ .filter((card) => card.generate_page !== false)
207
+ .slice(0, 24)
208
+ .map((card) => renderProtocolDashboardHtml(context, project, String(card.id)))
209
+ .map((result) => ({
210
+ id: String(result.protocol_id),
211
+ path: result.dashboard.path,
212
+ json_path: result.dashboard.json_path
213
+ }));
214
+ return {
215
+ ok: true,
216
+ project_root: project.root,
217
+ dashboard: {
218
+ path: htmlPath,
219
+ json_path: jsonPath,
220
+ bytes: Buffer.byteLength(html),
221
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
222
+ },
223
+ protocol_pages: protocolPages
224
+ };
225
+ }
226
+ function renderProtocolDashboardHtml(context, project, protocolId, output) {
227
+ const htmlPath = output
228
+ ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
229
+ : protocolDashboardHtmlPath(context, project.id, protocolId);
230
+ const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
231
+ const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
232
+ const html = renderDashboardHtmlPage(data);
233
+ writeJsonFile(jsonPath, data);
234
+ writeTextFile(htmlPath, html);
235
+ return {
236
+ ok: true,
237
+ project_root: project.root,
238
+ protocol_id: protocolId,
239
+ dashboard: {
240
+ path: htmlPath,
241
+ json_path: jsonPath,
242
+ bytes: Buffer.byteLength(html),
243
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
244
+ }
245
+ };
246
+ }
247
+ function renderGlobalDashboardHtml(context, output) {
248
+ const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
249
+ const jsonPath = globalDashboardJsonPath(context);
250
+ const data = buildGlobalDashboardData(context, htmlPath);
251
+ const html = renderDashboardHtmlPage(data);
252
+ writeJsonFile(jsonPath, data);
253
+ writeTextFile(htmlPath, html);
254
+ return {
255
+ ok: true,
256
+ dashboard: {
257
+ path: htmlPath,
258
+ json_path: jsonPath,
259
+ bytes: Buffer.byteLength(html),
260
+ json_bytes: Buffer.byteLength(JSON.stringify(data))
261
+ }
262
+ };
263
+ }
264
+ function buildGlobalDashboardData(context, htmlPath) {
265
+ const projects = context.db.all("SELECT * FROM projects ORDER BY status ASC, updated_at DESC, root ASC");
266
+ 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
+ });
296
+ return {
297
+ schema_id: "dd-flow/global-dashboard-data@1",
298
+ generated_at: context.now(),
299
+ target_language: "ru",
300
+ page: { kind: "global", screen_id: "SCR-DD-FLOW-GLOBAL-DASHBOARD", title: "dd-flow global dashboard", html_path: htmlPath, json_path: globalDashboardJsonPath(context) },
301
+ links: {
302
+ self: link("Global dashboard", htmlPath, "available"),
303
+ markdown_fallback: link("Markdown fallback", globalDashboardMarkdownPath(context), "available")
304
+ },
305
+ metrics: [
306
+ metric("projects", activeProjects.length, "known", "projects.status=active"),
307
+ metric("active_protocols", projectCards.reduce((sum, card) => sum + Number(card.metrics.active_protocols), 0), "known", "protocols"),
308
+ metric("running_sessions", projectCards.reduce((sum, card) => sum + Number(card.metrics.sessions), 0), "known", "flow_sessions"),
309
+ metric("active_locks", projectCards.reduce((sum, card) => sum + Number(card.metrics.locks), 0), "known", "lane_locks"),
310
+ metric("open_defs", projectCards.reduce((sum, card) => sum + Number(card.metrics.open_defs), 0), "known", "protocols.active_def/blockers")
311
+ ],
312
+ project_cards: projectCards,
313
+ warnings: projectCards.filter((card) => !card.root_exists).map((card) => ({ code: "project_root_missing", project_id: card.id }))
314
+ };
315
+ }
316
+ function buildProjectDashboardData(context, project, htmlPath) {
317
+ const protocols = protocolsForProject(context, project.id);
318
+ const queue = queueForProject(context, project.id);
319
+ const sessions = activeFlowSessionsForProject(context, project.id);
320
+ 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]);
321
+ const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
322
+ const protocolCards = protocols.slice(0, 48).map((protocol, index) => buildProtocolCard(context, project, protocol, index < activeProtocols.length + 12));
323
+ const openDefs = protocols.reduce((count, protocol) => count + jsonArray(protocol.active_def_json).length + jsonArray(protocol.blockers_json).length, 0);
324
+ const protocolSetBoards = protocolSetBoardsForProject(context, project.id, project.root);
325
+ const reviewRuns = recentReviewRunsForProject(context, project.id, 8);
326
+ return {
327
+ schema_id: "dd-flow/project-dashboard-data@1",
328
+ generated_at: context.now(),
329
+ target_language: "ru",
330
+ 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) },
331
+ project: {
332
+ id: project.id,
333
+ title: path.basename(project.root) || project.id,
334
+ root: project.root,
335
+ status: project.status,
336
+ updated_at: project.updated_at
337
+ },
338
+ links: {
339
+ self: link("Project dashboard", htmlPath, "available"),
340
+ global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
341
+ markdown_fallback: link("Markdown fallback", dashboardMarkdownPath(project.root, readProjectConfig(context, project.id)), "available")
342
+ },
343
+ metrics: [
344
+ metric("active_protocols", activeProtocols.length, "known", "protocols"),
345
+ metric("completed_protocols", protocols.filter((protocol) => !isActiveProtocolStatus(protocol.status)).length, "known", "protocols"),
346
+ metric("merge_queue", queue.filter((job) => isActiveQueueStatus(job.status)).length, "known", "merge_queue"),
347
+ metric("active_locks", locks.filter((lock) => lock.status === "active").length, "known", "lane_locks"),
348
+ metric("open_defs", openDefs, "known", "protocols.active_def/blockers"),
349
+ metric("running_sessions", sessions.length, "known", "flow_sessions"),
350
+ metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
351
+ ],
352
+ protocol_cards: protocolCards,
353
+ protocol_set_boards: protocolSetBoards,
354
+ 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
+ })),
364
+ sessions: sessions.slice(0, 12).map((session) => ({
365
+ id: session.session_id,
366
+ flow_kind: session.flow_kind,
367
+ display_flow_kind: normalizeFlowKind(session.flow_kind),
368
+ status: session.status,
369
+ display_status: normalizeDisplayStatus(session.status),
370
+ worker_id: session.worker_id,
371
+ workspace_path: session.workspace_path,
372
+ current_stage: session.current_stage,
373
+ next_action: session.next_action,
374
+ updated_at: session.updated_at
375
+ })),
376
+ locks: locks.slice(0, 12).map((lock) => ({ lane: lock.lane, worker_id: lock.worker_id, status: lock.status, expires_at: lock.expires_at })),
377
+ warnings: fs.existsSync(project.root) ? [] : [{ code: "project_root_missing", path: project.root }]
378
+ };
379
+ }
380
+ function buildProtocolDashboardData(context, project, protocolId, htmlPath) {
381
+ const protocol = protocolsForProject(context, project.id).find((candidate) => candidate.id === protocolId);
382
+ if (!protocol) {
383
+ throw new AppError("not_found", `Protocol is not registered: ${protocolId}`, 1, { protocol_id: protocolId });
384
+ }
385
+ const runtimeProtocol = requireProtocol(context, protocol.id);
386
+ const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
387
+ const runDiagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState);
388
+ 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
+ FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
390
+ ORDER BY updated_at DESC, id DESC`, [project.id, protocolId]);
391
+ const primaryRun = runs.find((run) => run.status === "running") ?? runs[0];
392
+ const warnings = [...runDiagnostics.diagnostics];
393
+ const protocolSetBoard = protocolSetBoardForProtocol(context, project.id, project.root, protocolId);
394
+ const reviewRuns = recentReviewRunsForProtocol(context, project.id, protocolId, 8);
395
+ const runHistory = runs.map((run) => {
396
+ const index = safeRunIndex(run.index_json, warnings, run.id);
397
+ return {
398
+ id: run.id,
399
+ short_id: run.short_id,
400
+ flow_kind: run.flow_kind,
401
+ display_flow_kind: normalizeFlowKind(run.flow_kind),
402
+ status: run.status,
403
+ display_status: normalizeDisplayStatus(run.status),
404
+ verdict: run.verdict,
405
+ next_action: run.next_action,
406
+ run_index_path: run.run_index_path,
407
+ updated_at: run.updated_at,
408
+ completed_at: run.completed_at,
409
+ stages: Array.isArray(index?.stage_runs) ? index.stage_runs.map((stage) => stageLink(project.root, run, stage)) : []
410
+ };
411
+ });
412
+ return {
413
+ schema_id: "dd-flow/protocol-dashboard-data@1",
414
+ generated_at: context.now(),
415
+ target_language: "ru",
416
+ page: { kind: "protocol", screen_id: "SCR-DD-FLOW-PROTOCOL-PAGE", title: `${shortId(protocolId)} protocol`, html_path: htmlPath, json_path: protocolDashboardJsonPath(context, project.id, protocolId) },
417
+ project: {
418
+ id: project.id,
419
+ title: path.basename(project.root) || project.id,
420
+ root: project.root
421
+ },
422
+ protocol: {
423
+ id: protocol.id,
424
+ short_id: shortId(protocol.id),
425
+ title: protocol.id,
426
+ raw_status: protocol.status,
427
+ raw_stage: protocol.stage,
428
+ display_status: normalizeDisplayStatus(protocol.status),
429
+ action_level: actionLevel(protocol.status, jsonArray(protocol.blockers_json).length + jsonArray(protocol.active_def_json).length),
430
+ next_action: protocol.next_action,
431
+ updated_at: protocol.updated_at,
432
+ blockers: jsonArray(protocol.blockers_json),
433
+ active_def: jsonArray(protocol.active_def_json),
434
+ diagnostics: runDiagnostics.diagnostics,
435
+ source_path: path.join(project.root, ".memory-bank", "protocol", `${protocol.id}.md`),
436
+ summary_path: path.join(project.root, ".memory-bank", "protocol", protocol.id, "summary.md")
437
+ },
438
+ links: {
439
+ self: link("Protocol page", htmlPath, "available"),
440
+ project_dashboard: link("Project dashboard", projectDashboardHtmlPath(context, project.id), "available"),
441
+ global_dashboard: link("Global dashboard", globalDashboardHtmlPath(context), "available"),
442
+ 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"),
443
+ 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")
444
+ },
445
+ primary_run_id: primaryRun?.id ?? null,
446
+ primary_run_reason: primaryRun ? (primaryRun.status === "running" ? "latest running run" : "latest updated run") : "no runs registered",
447
+ protocol_set_board: protocolSetBoard,
448
+ review_runs: reviewRuns,
449
+ run_history: runHistory,
450
+ stage_pipeline: latestStages(runHistory),
451
+ metrics: [
452
+ metric("runs", runs.length, "known", "flow_runs"),
453
+ metric("open_defs", jsonArray(protocol.active_def_json).length, "known", "protocols.active_def_json"),
454
+ metric("blockers", jsonArray(protocol.blockers_json).length, "known", "protocols.blockers_json"),
455
+ metric("completed_runs", runs.filter((run) => run.status === "done").length, "known", "flow_runs"),
456
+ metric("review_runs", reviewRuns.length, "known", "flow_runs.flow_kind=mb-sdlc-review|review")
457
+ ],
458
+ warnings
459
+ };
460
+ }
151
461
  function renderProjectDashboardMarkdown(context, project, output) {
152
462
  const protocols = protocolsForProject(context, project.id);
153
463
  const activeProtocols = protocols.filter((protocol) => isActiveProtocolStatus(protocol.status));
@@ -402,6 +712,341 @@ function statusIcon(status) {
402
712
  return "⚪";
403
713
  return "•";
404
714
  }
715
+ function metric(id, value, status, source) {
716
+ return {
717
+ id,
718
+ label: id.replace(/_/g, " "),
719
+ value,
720
+ status,
721
+ source
722
+ };
723
+ }
724
+ function link(label, href, status, reason) {
725
+ return {
726
+ label,
727
+ href,
728
+ path_kind: path.isAbsolute(href) ? "absolute" : "relative",
729
+ status,
730
+ ...(reason ? { degraded_reason: reason } : {})
731
+ };
732
+ }
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
+ function buildProtocolCard(context, project, protocol, generatePage) {
743
+ const summary = planSummaryForProtocol(context, protocol.id);
744
+ const activeDefCount = jsonArray(protocol.active_def_json).length;
745
+ const blockerCount = jsonArray(protocol.blockers_json).length;
746
+ 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
747
+ FROM flow_runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id = ?
748
+ ORDER BY updated_at DESC, id DESC LIMIT 1`, [project.id, protocol.id]);
749
+ let diagnostics = [];
750
+ try {
751
+ const runtimeProtocol = requireProtocol(context, protocol.id);
752
+ const runtimeState = readProtocolRuntimeState(context, runtimeProtocol).state;
753
+ diagnostics = protocolRunDiagnostics(context, runtimeProtocol, runtimeState).diagnostics;
754
+ }
755
+ catch {
756
+ diagnostics = [];
757
+ }
758
+ return {
759
+ id: protocol.id,
760
+ short_id: shortId(protocol.id),
761
+ title: protocol.id,
762
+ href: protocolDashboardHtmlPath(context, project.id, protocol.id),
763
+ raw_status: protocol.status,
764
+ raw_stage: protocol.stage,
765
+ display_status: normalizeDisplayStatus(protocol.status),
766
+ action_level: actionLevel(protocol.status, activeDefCount + blockerCount),
767
+ flow_kind: latestRun?.flow_kind ?? "unknown",
768
+ display_flow_kind: normalizeFlowKind(latestRun?.flow_kind ?? "unknown"),
769
+ current_stage: protocol.stage,
770
+ next_action: protocol.next_action,
771
+ plan: summary,
772
+ active_def_count: activeDefCount,
773
+ blocker_count: blockerCount,
774
+ latest_run_id: latestRun?.id ?? null,
775
+ latest_run_status: latestRun?.status ?? "unknown",
776
+ diagnostics,
777
+ warning_count: diagnostics.length,
778
+ updated_at: protocol.updated_at,
779
+ generate_page: generatePage
780
+ };
781
+ }
782
+ function recentReviewRunsForProject(context, projectId, limit) {
783
+ 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
784
+ FROM flow_runs
785
+ WHERE project_id = ? AND flow_kind IN ('mb-sdlc-review', 'review')
786
+ ORDER BY updated_at DESC, id DESC
787
+ LIMIT ?`, [projectId, limit]);
788
+ return runs.map(reviewRunSummary);
789
+ }
790
+ function recentReviewRunsForProtocol(context, projectId, protocolId, limit) {
791
+ 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
792
+ FROM flow_runs
793
+ WHERE project_id = ?
794
+ AND flow_kind IN ('mb-sdlc-review', 'review')
795
+ AND subject_type = 'protocol'
796
+ AND subject_id = ?
797
+ ORDER BY updated_at DESC, id DESC
798
+ LIMIT ?`, [projectId, protocolId, limit]);
799
+ return runs.map(reviewRunSummary);
800
+ }
801
+ function reviewRunSummary(run) {
802
+ const index = safeParseIndex(run.index_json);
803
+ const stages = Array.isArray(index?.stage_runs) ? index.stage_runs : [];
804
+ const reviewStage = stages.find((stage) => stage.stage === "review") ?? stages[stages.length - 1];
805
+ return {
806
+ id: run.id,
807
+ short_id: run.short_id,
808
+ flow_kind: run.flow_kind,
809
+ display_flow_kind: normalizeFlowKind(run.flow_kind),
810
+ subject_type: run.subject_type,
811
+ subject_id: run.subject_id,
812
+ status: run.status,
813
+ display_status: normalizeDisplayStatus(run.status),
814
+ verdict: run.verdict,
815
+ next_action: run.next_action,
816
+ run_index_path: run.run_index_path,
817
+ report: reviewStage ? stageLink("", run, reviewStage).report : link("Run index", run.run_index_path, fs.existsSync(run.run_index_path) ? "available" : "missing"),
818
+ updated_at: run.updated_at,
819
+ completed_at: run.completed_at
820
+ };
821
+ }
822
+ function safeParseIndex(text) {
823
+ try {
824
+ const parsed = JSON.parse(text);
825
+ return parsed && typeof parsed === "object" ? parsed : null;
826
+ }
827
+ catch {
828
+ return null;
829
+ }
830
+ }
831
+ function normalizeDisplayStatus(status) {
832
+ if (["active", "running", "in_progress"].includes(status))
833
+ return "running";
834
+ if (["waiting_user", "waiting_for_user"].includes(status))
835
+ return "waiting_for_user";
836
+ if (["ready_for_merge"].includes(status))
837
+ return "ready_for_merge";
838
+ if (["ready", "queued_for_merge", "claimed", "requeued"].includes(status))
839
+ return "queued";
840
+ if (["closed", "closed_local", "done", "merged"].includes(status))
841
+ return "done";
842
+ if (["blocked"].includes(status))
843
+ return "blocked";
844
+ if (["failed", "error"].includes(status))
845
+ return "failed";
846
+ if (["cancelled"].includes(status))
847
+ return "cancelled";
848
+ if (["registered", "pending", "priming", "specify", "plan", "implementation", "readiness", "integration"].includes(status))
849
+ return "active";
850
+ if (["archived", "expired", "stopped"].includes(status))
851
+ return "stale";
852
+ return "unknown";
853
+ }
854
+ function normalizeFlowKind(flowKind) {
855
+ if (flowKind === "mb_sdlc")
856
+ return "mb-sdlc";
857
+ if (flowKind === "planning" || flowKind === "implementation")
858
+ return "coding";
859
+ if (flowKind === "merge_worker" || flowKind === "merge_job")
860
+ return "merge";
861
+ if (flowKind === "memory_flow")
862
+ return "custom";
863
+ if ([
864
+ "mb-sdlc",
865
+ "coding",
866
+ "interactive",
867
+ "finish",
868
+ "merge",
869
+ "mb-init",
870
+ "mb-upgrade",
871
+ "mb-audit",
872
+ "mb-fix",
873
+ "mb-distill",
874
+ "mb-upgrade-review",
875
+ "mb-sdlc-review",
876
+ "review",
877
+ "release",
878
+ "deploy",
879
+ "publish",
880
+ "experiment",
881
+ "custom"
882
+ ].includes(flowKind)) {
883
+ return flowKind;
884
+ }
885
+ return "unknown";
886
+ }
887
+ function actionLevel(status, problemCount) {
888
+ if (problemCount > 0 || ["blocked", "failed", "error"].includes(status))
889
+ return "blocked";
890
+ if (["waiting_user", "waiting_for_user", "ready_for_merge", "ready", "queued_for_merge", "requeued"].includes(status))
891
+ return "actionable";
892
+ if (["running", "active", "claimed", "in_progress"].includes(status))
893
+ return "watch";
894
+ return "none";
895
+ }
896
+ function safeRunIndex(text, warnings, runId) {
897
+ try {
898
+ const parsed = JSON.parse(text);
899
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
900
+ }
901
+ catch {
902
+ warnings.push({ code: "run_index_invalid_json", run_id: runId });
903
+ return null;
904
+ }
905
+ }
906
+ function stageLink(projectRoot, run, stage) {
907
+ const item = stage && typeof stage === "object" && !Array.isArray(stage) ? stage : {};
908
+ const report = typeof item.stage_report === "string" ? item.stage_report : undefined;
909
+ const href = report ? path.join(path.dirname(run.run_index_path), report) : "";
910
+ return {
911
+ order: typeof item.order === "number" ? item.order : 0,
912
+ stage: String(item.stage ?? "unknown"),
913
+ dir: String(item.dir ?? ""),
914
+ status: String(item.status ?? "unknown"),
915
+ display_status: normalizeDisplayStatus(String(item.status ?? "unknown")),
916
+ 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"),
917
+ data: typeof item.data === "string" ? path.join(path.dirname(run.run_index_path), item.data) : null,
918
+ run_index: path.relative(projectRoot, run.run_index_path)
919
+ };
920
+ }
921
+ function latestStages(runHistory) {
922
+ const byStage = new Map();
923
+ for (const run of runHistory) {
924
+ const stages = Array.isArray(run.stages) ? run.stages : [];
925
+ for (const stage of stages) {
926
+ const key = String(stage.stage ?? "unknown");
927
+ if (!byStage.has(key))
928
+ byStage.set(key, { ...stage, run_id: run.id });
929
+ }
930
+ }
931
+ return [...byStage.values()].sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0));
932
+ }
933
+ function renderDashboardHtmlPage(data) {
934
+ const json = JSON.stringify(data, null, 2).replace(/<\//g, "<\\/");
935
+ return `<!doctype html>
936
+ <html lang="ru">
937
+ <head>
938
+ <meta charset="utf-8">
939
+ <meta name="viewport" content="width=device-width, initial-scale=1">
940
+ <title>dd-flow dashboard</title>
941
+ <style>
942
+ :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)}
943
+ [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)}
944
+ [data-theme=light]{color-scheme:light}
945
+ @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)}}
946
+ *{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}
947
+ 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}
948
+ 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)}
949
+ .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)}
950
+ .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}
951
+ .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}
952
+ [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}}
953
+ </style>
954
+ </head>
955
+ <body>
956
+ <script id="dashboard-data" type="application/json">${json}</script>
957
+ <main class="page" id="dashboard-root" data-testid="dashboard-root">
958
+ <div class="topbar">
959
+ <div class="breadcrumb" id="breadcrumb"></div>
960
+ <div class="switches">
961
+ <div class="seg" role="group" aria-label="View mode" data-testid="view-switch">
962
+ <button type="button" data-view-button="compact">Compact</button>
963
+ <button type="button" data-view-button="expanded">Expanded</button>
964
+ <button type="button" data-view-button="focus">Focus</button>
965
+ </div>
966
+ <div class="seg" role="group" aria-label="Theme mode" data-testid="theme-switch">
967
+ <button type="button" data-theme-button="system">System</button>
968
+ <button type="button" data-theme-button="light">Light</button>
969
+ <button type="button" data-theme-button="dark">Dark</button>
970
+ </div>
971
+ </div>
972
+ </div>
973
+ <section class="hero">
974
+ <section class="card headline">
975
+ <div><div class="eyebrow" id="eyebrow"></div><h1 id="title"></h1><p class="lead" id="lead"></p></div>
976
+ <div class="meta" id="meta"></div>
977
+ </section>
978
+ <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
+ </section>
980
+ <section class="metricGrid" id="metrics" data-testid="metric-strip"></section>
981
+ <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>
982
+ <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
+ <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
+ <section class="card panel"><div class="sectionHead"><h2>Навигация и артефакты</h2></div><div class="list" id="links"></div></section>
985
+ <section class="card panel"><div class="sectionHead"><h2>Warnings</h2></div><div class="list" id="warnings"></div></section>
986
+ </main>
987
+ <script>
988
+ const data = JSON.parse(document.getElementById("dashboard-data").textContent);
989
+ const root = document.getElementById("dashboard-root");
990
+ const storagePrefix = "dd-flow.dashboard.";
991
+ const page = data.page || {};
992
+ root.dataset.screenId = page.screen_id || "";
993
+ function text(id, value){ document.getElementById(id).textContent = value == null ? "" : String(value); }
994
+ function el(tag, className, value){ const node = document.createElement(tag); if(className) node.className = className; if(value != null) node.textContent = String(value); return node; }
995
+ function safeHref(href){ const value = String(href || ""); return /^javascript:/i.test(value) ? "" : value; }
996
+ 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; }
997
+ function statusBadge(value){ const b = el("span","badge " + String(value || "unknown"), value || "unknown"); return b; }
998
+ function metricValue(metric){ return metric && metric.value !== null && metric.value !== undefined ? metric.value : "N/A"; }
999
+ function pageKind(){ return page.kind || "dashboard"; }
1000
+ 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; }
1002
+ 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
+ document.querySelectorAll("[data-theme-button]").forEach(b => b.addEventListener("click", () => setMode("theme", b.dataset.themeButton)));
1004
+ document.querySelectorAll("[data-view-button]").forEach(b => b.addEventListener("click", () => setMode("view", b.dataset.viewButton)));
1005
+ function renderHeader(){
1006
+ text("breadcrumb", ["dd-flow", page.kind, data.project?.title || data.project?.id, data.protocol?.short_id].filter(Boolean).join(" / "));
1007
+ text("eyebrow", page.screen_id || "dd-flow");
1008
+ text("title", page.title || "dd-flow dashboard");
1009
+ const lead = pageKind()==="global" ? "Общий обзор локальной машины: проекты, активные протоколы, очереди, locks и degraded состояния." : pageKind()==="project" ? "Рабочая карта проекта: протоколы, сессии, очередь merge, locks и DEF." : "Страница протокола как задачи: состояние, run history, stage reports и next action.";
1010
+ text("lead", lead); text("verdictText", pageKind()); text("verdictNote", data.protocol?.next_action || data.primary_run_reason || "fresh local static dashboard");
1011
+ const meta = document.getElementById("meta"); meta.replaceChildren();
1012
+ [["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
+ }
1014
+ 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); }); }
1015
+ function renderCards(){
1016
+ 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
+ text("cardsTitle", pageKind()==="global" ? "Проекты" : pageKind()==="project" ? "Протоколы" : "Run history");
1018
+ const box=document.getElementById("cards"); box.replaceChildren();
1019
+ 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
+ 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);
1023
+ 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
+ }
1025
+ 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
+ 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
+ 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); }); }
1028
+ 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();
1030
+ const savedTheme = ["system","light","dark"].includes(localStorage.getItem(storagePrefix+"theme")) ? localStorage.getItem(storagePrefix+"theme") : "system";
1031
+ const savedView = ["compact","expanded","focus"].includes(localStorage.getItem(storagePrefix+"view")) ? localStorage.getItem(storagePrefix+"view") : "compact";
1032
+ setMode("theme", savedTheme); setMode("view", savedView);
1033
+ </script>
1034
+ </body>
1035
+ </html>`;
1036
+ }
1037
+ function pathToFileUrl(filePath) {
1038
+ return `file://${path.resolve(filePath).split(path.sep).map(encodeURIComponent).join("/")}`;
1039
+ }
1040
+ function writeJsonFile(output, data) {
1041
+ ensureDir(path.dirname(output));
1042
+ const tmpFile = `${output}.tmp-${process.pid}-${Date.now()}`;
1043
+ fs.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}\n`);
1044
+ fs.renameSync(tmpFile, output);
1045
+ }
1046
+ function writeTextFile(output, text) {
1047
+ ensureDir(path.dirname(output));
1048
+ fs.writeFileSync(output, text);
1049
+ }
405
1050
  function writeMarkdown(output, markdown) {
406
1051
  ensureDir(path.dirname(output));
407
1052
  fs.writeFileSync(output, markdown);