@cliangdev/flux-plugin 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +11 -7
  2. package/agents/coder.md +150 -25
  3. package/bin/install.cjs +171 -16
  4. package/commands/breakdown.md +47 -10
  5. package/commands/dashboard.md +29 -0
  6. package/commands/flux.md +92 -12
  7. package/commands/implement.md +166 -17
  8. package/commands/linear.md +6 -5
  9. package/commands/prd.md +996 -82
  10. package/manifest.json +2 -1
  11. package/package.json +9 -11
  12. package/skills/flux-orchestrator/SKILL.md +11 -3
  13. package/skills/prd-writer/SKILL.md +761 -0
  14. package/skills/ux-ui-design/SKILL.md +346 -0
  15. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  16. package/src/__tests__/version.test.ts +37 -0
  17. package/src/adapters/local/.gitkeep +0 -0
  18. package/src/dashboard/__tests__/api.test.ts +211 -0
  19. package/src/dashboard/browser.ts +35 -0
  20. package/src/dashboard/public/app.js +869 -0
  21. package/src/dashboard/public/index.html +90 -0
  22. package/src/dashboard/public/styles.css +807 -0
  23. package/src/dashboard/public/vendor/highlight.css +10 -0
  24. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  25. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  26. package/src/dashboard/server.ts +296 -0
  27. package/src/dashboard/watchers.ts +83 -0
  28. package/src/server/__tests__/config.test.ts +163 -0
  29. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  30. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  31. package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
  32. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  33. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  34. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  35. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  36. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  37. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  38. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  39. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  40. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  41. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  42. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  43. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  44. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  45. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  46. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  47. package/src/server/adapters/factory.ts +90 -0
  48. package/src/server/adapters/index.ts +9 -0
  49. package/src/server/adapters/linear/adapter.ts +1141 -0
  50. package/src/server/adapters/linear/client.ts +169 -0
  51. package/src/server/adapters/linear/config.ts +152 -0
  52. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  53. package/src/server/adapters/linear/helpers/index.ts +7 -0
  54. package/src/server/adapters/linear/index.ts +16 -0
  55. package/src/server/adapters/linear/mappers/description.ts +136 -0
  56. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  57. package/src/server/adapters/linear/mappers/index.ts +27 -0
  58. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  59. package/src/server/adapters/linear/mappers/task.ts +82 -0
  60. package/src/server/adapters/linear/types.ts +264 -0
  61. package/src/server/adapters/local-adapter.ts +1009 -0
  62. package/src/server/adapters/types.ts +293 -0
  63. package/src/server/config.ts +73 -0
  64. package/src/server/db/__tests__/queries.test.ts +473 -0
  65. package/src/server/db/ids.ts +17 -0
  66. package/src/server/db/index.ts +69 -0
  67. package/src/server/db/queries.ts +142 -0
  68. package/src/server/db/refs.ts +60 -0
  69. package/src/server/db/schema.ts +97 -0
  70. package/src/server/db/sqlite.ts +10 -0
  71. package/src/server/index.ts +81 -0
  72. package/src/server/tools/__tests__/crud.test.ts +411 -0
  73. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  74. package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
  75. package/src/server/tools/__tests__/query.test.ts +405 -0
  76. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  77. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  78. package/src/server/tools/configure-linear.ts +373 -0
  79. package/src/server/tools/create-epic.ts +44 -0
  80. package/src/server/tools/create-prd.ts +40 -0
  81. package/src/server/tools/create-task.ts +47 -0
  82. package/src/server/tools/criteria.ts +50 -0
  83. package/src/server/tools/delete-entity.ts +76 -0
  84. package/src/server/tools/dependencies.ts +55 -0
  85. package/src/server/tools/get-entity.ts +240 -0
  86. package/src/server/tools/get-linear-url.ts +28 -0
  87. package/src/server/tools/get-stats.ts +52 -0
  88. package/src/server/tools/get-version.ts +20 -0
  89. package/src/server/tools/index.ts +158 -0
  90. package/src/server/tools/init-project.ts +108 -0
  91. package/src/server/tools/query-entities.ts +167 -0
  92. package/src/server/tools/render-status.ts +219 -0
  93. package/src/server/tools/update-entity.ts +140 -0
  94. package/src/server/tools/update-status.ts +166 -0
  95. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  96. package/src/server/utils/logger.ts +9 -0
  97. package/src/server/utils/mcp-response.ts +254 -0
  98. package/src/server/utils/status-transitions.ts +160 -0
  99. package/src/status-line/__tests__/status-line.test.ts +215 -0
  100. package/src/status-line/index.ts +147 -0
  101. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  102. package/src/utils/__tests__/display.test.ts +97 -0
  103. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  104. package/src/utils/display.ts +62 -0
  105. package/src/utils/status-renderer.ts +214 -0
  106. package/src/version.ts +5 -0
  107. package/dist/server/index.js +0 -87063
  108. package/skills/prd-template/SKILL.md +0 -242
@@ -0,0 +1,869 @@
1
+ /**
2
+ * Flux Dashboard Client Application
3
+ */
4
+
5
+ // State
6
+ let treeData = [];
7
+ let tagsData = [];
8
+ let selectedRef = null;
9
+ let activeTag = "All";
10
+ let statusFilter = "";
11
+ let searchQuery = "";
12
+ let ws = null;
13
+ let reconnectAttempts = 0;
14
+ let projectKey = "flux-dashboard";
15
+ const MAX_RECONNECT_ATTEMPTS = 10;
16
+ const RECONNECT_BASE_DELAY = 1000;
17
+
18
+ // DOM Elements
19
+ const elements = {
20
+ tree: document.getElementById("tree"),
21
+ tagsList: document.getElementById("tags-list"),
22
+ contentHeader: document.getElementById("content-header"),
23
+ contentBody: document.getElementById("content-body"),
24
+ connectionStatus: document.getElementById("connection-status"),
25
+ search: document.getElementById("search"),
26
+ statusFilter: document.getElementById("status-filter"),
27
+ themeToggle: document.getElementById("theme-toggle"),
28
+ themeIcon: document.getElementById("theme-icon"),
29
+ sidebar: document.getElementById("sidebar"),
30
+ sidebarResize: document.getElementById("sidebar-resize"),
31
+ depGraphToggle: document.getElementById("dep-graph-toggle"),
32
+ depGraphOverlay: document.getElementById("dep-graph-overlay"),
33
+ depGraphClose: document.getElementById("dep-graph-close"),
34
+ depGraphSvg: document.getElementById("dep-graph-svg"),
35
+ depTypeFilter: document.getElementById("dep-type-filter"),
36
+ };
37
+
38
+ // Initialize
39
+ document.addEventListener("DOMContentLoaded", () => {
40
+ initTheme();
41
+ initSidebarResize();
42
+ connectWebSocket();
43
+ loadData();
44
+ setupEventListeners();
45
+ });
46
+
47
+ // Theme Management
48
+ function initTheme() {
49
+ loadProjectKey().then(() => {
50
+ const savedTheme = localStorage.getItem(`${projectKey}-theme`);
51
+ if (savedTheme === "dark") {
52
+ setTheme("dark");
53
+ } else {
54
+ setTheme("light");
55
+ }
56
+ });
57
+ }
58
+
59
+ async function loadProjectKey() {
60
+ try {
61
+ const response = await fetch("/api/tree");
62
+ const data = await response.json();
63
+ if (data.length > 0 && data[0].projectId) {
64
+ projectKey = `flux-${data[0].projectId}`;
65
+ }
66
+ } catch {}
67
+ }
68
+
69
+ function setTheme(theme) {
70
+ document.documentElement.setAttribute("data-theme", theme);
71
+ elements.themeIcon.innerHTML = theme === "dark" ? "☼" : "☾";
72
+ localStorage.setItem(`${projectKey}-theme`, theme);
73
+ }
74
+
75
+ function toggleTheme() {
76
+ const currentTheme = document.documentElement.getAttribute("data-theme");
77
+ setTheme(currentTheme === "dark" ? "light" : "dark");
78
+ }
79
+
80
+ // Sidebar Resize
81
+ function initSidebarResize() {
82
+ const savedWidth = localStorage.getItem(`${projectKey}-sidebar-width`);
83
+ if (savedWidth) {
84
+ elements.sidebar.style.width = `${savedWidth}px`;
85
+ }
86
+
87
+ let isResizing = false;
88
+
89
+ elements.sidebarResize.addEventListener("mousedown", (e) => {
90
+ isResizing = true;
91
+ elements.sidebarResize.classList.add("dragging");
92
+ document.body.style.cursor = "col-resize";
93
+ document.body.style.userSelect = "none";
94
+ e.preventDefault();
95
+ });
96
+
97
+ document.addEventListener("mousemove", (e) => {
98
+ if (!isResizing) return;
99
+
100
+ const newWidth = e.clientX;
101
+ if (newWidth >= 200 && newWidth <= 500) {
102
+ elements.sidebar.style.width = `${newWidth}px`;
103
+ }
104
+ });
105
+
106
+ document.addEventListener("mouseup", () => {
107
+ if (isResizing) {
108
+ isResizing = false;
109
+ elements.sidebarResize.classList.remove("dragging");
110
+ document.body.style.cursor = "";
111
+ document.body.style.userSelect = "";
112
+ localStorage.setItem(
113
+ `${projectKey}-sidebar-width`,
114
+ elements.sidebar.offsetWidth,
115
+ );
116
+ }
117
+ });
118
+ }
119
+
120
+ // WebSocket Connection
121
+ function connectWebSocket() {
122
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
123
+ ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
124
+
125
+ ws.onopen = () => {
126
+ reconnectAttempts = 0;
127
+ updateConnectionStatus("connected");
128
+ };
129
+
130
+ ws.onmessage = (event) => {
131
+ try {
132
+ const data = JSON.parse(event.data);
133
+ if (data.type === "update") {
134
+ loadData();
135
+ if (selectedRef) {
136
+ loadContent(selectedRef);
137
+ }
138
+ }
139
+ } catch (e) {
140
+ console.error("WebSocket message error:", e);
141
+ }
142
+ };
143
+
144
+ ws.onclose = () => {
145
+ updateConnectionStatus("disconnected");
146
+ attemptReconnect();
147
+ };
148
+
149
+ ws.onerror = () => {
150
+ updateConnectionStatus("disconnected");
151
+ };
152
+ }
153
+
154
+ function attemptReconnect() {
155
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
156
+ return;
157
+ }
158
+
159
+ reconnectAttempts++;
160
+ updateConnectionStatus("reconnecting");
161
+
162
+ const delay = RECONNECT_BASE_DELAY * 2 ** (reconnectAttempts - 1);
163
+ setTimeout(() => {
164
+ if (ws.readyState === WebSocket.CLOSED) {
165
+ connectWebSocket();
166
+ }
167
+ }, delay);
168
+ }
169
+
170
+ function updateConnectionStatus(status) {
171
+ const el = elements.connectionStatus;
172
+ el.className = `status ${status}`;
173
+
174
+ switch (status) {
175
+ case "connected":
176
+ el.innerHTML = "&#9679; Connected";
177
+ break;
178
+ case "reconnecting":
179
+ el.innerHTML = "&#9680; Reconnecting...";
180
+ break;
181
+ case "disconnected":
182
+ el.innerHTML = "&#9675; Disconnected";
183
+ break;
184
+ }
185
+ }
186
+
187
+ // Data Loading
188
+ async function loadData() {
189
+ try {
190
+ const [treeResponse, tagsResponse] = await Promise.all([
191
+ fetch("/api/tree"),
192
+ fetch("/api/tags"),
193
+ ]);
194
+
195
+ treeData = await treeResponse.json();
196
+ tagsData = await tagsResponse.json();
197
+
198
+ renderTags();
199
+ renderTree();
200
+ } catch (error) {
201
+ console.error("Failed to load data:", error);
202
+ }
203
+ }
204
+
205
+ async function loadContent(ref) {
206
+ selectedRef = ref;
207
+
208
+ try {
209
+ let endpoint;
210
+ if (ref.includes("-P")) {
211
+ endpoint = `/api/prd/${ref}`;
212
+ } else if (ref.includes("-E")) {
213
+ endpoint = `/api/epic/${ref}`;
214
+ } else {
215
+ endpoint = `/api/task/${ref}`;
216
+ }
217
+
218
+ const response = await fetch(endpoint);
219
+ const data = await response.json();
220
+
221
+ renderContent(data, ref);
222
+ } catch (error) {
223
+ console.error("Failed to load content:", error);
224
+ elements.contentBody.innerHTML = `<div class="empty-state"><p>Error loading content</p></div>`;
225
+ }
226
+ }
227
+
228
+ // Event Listeners
229
+ function setupEventListeners() {
230
+ elements.search.addEventListener("input", (e) => {
231
+ searchQuery = e.target.value.toLowerCase();
232
+ renderTree();
233
+ });
234
+
235
+ elements.statusFilter.addEventListener("change", (e) => {
236
+ statusFilter = e.target.value;
237
+ renderTree();
238
+ });
239
+
240
+ elements.themeToggle.addEventListener("click", toggleTheme);
241
+
242
+ // Dependency graph events
243
+ elements.depGraphToggle.addEventListener("click", openDepGraph);
244
+ elements.depGraphClose.addEventListener("click", closeDepGraph);
245
+ elements.depGraphOverlay.addEventListener("click", (e) => {
246
+ if (e.target === elements.depGraphOverlay) {
247
+ closeDepGraph();
248
+ }
249
+ });
250
+ elements.depTypeFilter.addEventListener("change", renderDepGraph);
251
+
252
+ // Close on Escape
253
+ document.addEventListener("keydown", (e) => {
254
+ if (
255
+ e.key === "Escape" &&
256
+ !elements.depGraphOverlay.classList.contains("hidden")
257
+ ) {
258
+ closeDepGraph();
259
+ }
260
+ });
261
+ }
262
+
263
+ // Rendering
264
+ function renderTags() {
265
+ elements.tagsList.innerHTML = tagsData
266
+ .map(
267
+ (tag) => `
268
+ <span class="tag-pill ${activeTag === tag.tag ? "active" : ""}" data-tag="${tag.tag}">
269
+ ${tag.tag}<span class="tag-count">(${tag.count})</span>
270
+ </span>
271
+ `,
272
+ )
273
+ .join("");
274
+
275
+ elements.tagsList.querySelectorAll(".tag-pill").forEach((pill) => {
276
+ pill.addEventListener("click", () => {
277
+ activeTag = pill.dataset.tag;
278
+ renderTags();
279
+ renderTree();
280
+ });
281
+ });
282
+ }
283
+
284
+ function renderTree() {
285
+ const filtered = filterTree(treeData);
286
+
287
+ if (filtered.length === 0) {
288
+ elements.tree.innerHTML = `<div class="empty-state"><p>No items match your filters</p></div>`;
289
+ return;
290
+ }
291
+
292
+ elements.tree.innerHTML = filtered.map((prd) => renderPrdNode(prd)).join("");
293
+
294
+ // Add click handlers
295
+ elements.tree.querySelectorAll(".tree-node-content").forEach((node) => {
296
+ node.addEventListener("click", (e) => {
297
+ const ref = node.dataset.ref;
298
+
299
+ // Handle expand/collapse
300
+ if (e.target.classList.contains("tree-expand")) {
301
+ const children = node.parentElement.querySelector(".tree-children");
302
+ if (children) {
303
+ children.classList.toggle("collapsed");
304
+ e.target.textContent = children.classList.contains("collapsed")
305
+ ? "▶"
306
+ : "▼";
307
+ }
308
+ return;
309
+ }
310
+
311
+ // Select node
312
+ elements.tree.querySelectorAll(".tree-node-content").forEach((n) => {
313
+ n.classList.remove("selected");
314
+ });
315
+ node.classList.add("selected");
316
+ loadContent(ref);
317
+ });
318
+ });
319
+ }
320
+
321
+ function filterTree(data) {
322
+ return data.filter((prd) => {
323
+ // Tag filter
324
+ if (activeTag !== "All" && prd.tag !== activeTag) {
325
+ return false;
326
+ }
327
+
328
+ // Status filter
329
+ if (statusFilter && prd.status !== statusFilter) {
330
+ return false;
331
+ }
332
+
333
+ // Search filter
334
+ if (searchQuery) {
335
+ const matchesPrd =
336
+ prd.ref.toLowerCase().includes(searchQuery) ||
337
+ prd.title.toLowerCase().includes(searchQuery);
338
+
339
+ const matchesChildren = prd.epics?.some(
340
+ (epic) =>
341
+ epic.ref.toLowerCase().includes(searchQuery) ||
342
+ epic.title.toLowerCase().includes(searchQuery) ||
343
+ epic.tasks?.some(
344
+ (task) =>
345
+ task.ref.toLowerCase().includes(searchQuery) ||
346
+ task.title.toLowerCase().includes(searchQuery),
347
+ ),
348
+ );
349
+
350
+ return matchesPrd || matchesChildren;
351
+ }
352
+
353
+ return true;
354
+ });
355
+ }
356
+
357
+ function renderPrdNode(prd) {
358
+ const hasChildren = prd.epics && prd.epics.length > 0;
359
+ const statusClass = getStatusClass(prd.status);
360
+ const statusIcon = getStatusIcon(prd.status);
361
+
362
+ return `
363
+ <div class="tree-node">
364
+ <div class="tree-node-content ${selectedRef === prd.ref ? "selected" : ""}" data-ref="${prd.ref}" title="${escapeHtml(prd.title)}">
365
+ <span class="tree-expand">${hasChildren ? "▼" : ""}</span>
366
+ <span class="tree-status ${statusClass}">${statusIcon}</span>
367
+ <span class="tree-ref">${prd.ref}</span>
368
+ <span class="tree-title">${escapeHtml(prd.title)}</span>
369
+ </div>
370
+ ${hasChildren ? `<div class="tree-children">${prd.epics.map((epic) => renderEpicNode(epic)).join("")}</div>` : ""}
371
+ </div>
372
+ `;
373
+ }
374
+
375
+ function renderEpicNode(epic) {
376
+ const hasChildren = epic.tasks && epic.tasks.length > 0;
377
+ const statusClass = getStatusClass(epic.status);
378
+ const statusIcon = getStatusIcon(epic.status);
379
+
380
+ return `
381
+ <div class="tree-node">
382
+ <div class="tree-node-content ${selectedRef === epic.ref ? "selected" : ""}" data-ref="${epic.ref}" title="${escapeHtml(epic.title)}">
383
+ <span class="tree-expand">${hasChildren ? "▼" : ""}</span>
384
+ <span class="tree-status ${statusClass}">${statusIcon}</span>
385
+ <span class="tree-ref">${epic.ref}</span>
386
+ <span class="tree-title">${escapeHtml(epic.title)}</span>
387
+ </div>
388
+ ${hasChildren ? `<div class="tree-children">${epic.tasks.map((task) => renderTaskNode(task)).join("")}</div>` : ""}
389
+ </div>
390
+ `;
391
+ }
392
+
393
+ function renderTaskNode(task) {
394
+ const statusClass = getStatusClass(task.status);
395
+ const statusIcon = getStatusIcon(task.status);
396
+
397
+ return `
398
+ <div class="tree-node">
399
+ <div class="tree-node-content ${selectedRef === task.ref ? "selected" : ""}" data-ref="${task.ref}" title="${escapeHtml(task.title)}">
400
+ <span class="tree-expand"></span>
401
+ <span class="tree-status ${statusClass}">${statusIcon}</span>
402
+ <span class="tree-ref">${task.ref}</span>
403
+ <span class="tree-title">${escapeHtml(task.title)}</span>
404
+ </div>
405
+ </div>
406
+ `;
407
+ }
408
+
409
+ function renderContent(data, ref) {
410
+ const type = ref.includes("-P")
411
+ ? "prd"
412
+ : ref.includes("-E")
413
+ ? "epic"
414
+ : "task";
415
+
416
+ // Header
417
+ let headerHtml = `<h1>${escapeHtml(data.title)}</h1>`;
418
+ headerHtml += `<div class="content-meta">`;
419
+ headerHtml += `<span><strong>Ref:</strong> ${data.ref}</span>`;
420
+ headerHtml += `<span><strong>Status:</strong> ${data.status}</span>`;
421
+
422
+ if (type === "prd" && data.tag) {
423
+ headerHtml += `<span><strong>Tag:</strong> ${data.tag}</span>`;
424
+ }
425
+
426
+ if (type === "task" && data.priority) {
427
+ headerHtml += `<span><strong>Priority:</strong> ${data.priority}</span>`;
428
+ }
429
+
430
+ headerHtml += `</div>`;
431
+
432
+ elements.contentHeader.innerHTML = headerHtml;
433
+
434
+ // Body
435
+ let bodyHtml = "";
436
+
437
+ if (type === "prd" && data.markdown) {
438
+ bodyHtml = marked.parse(data.markdown);
439
+ } else if (data.description) {
440
+ bodyHtml = marked.parse(data.description);
441
+ }
442
+
443
+ // Acceptance Criteria
444
+ if (data.criteria && data.criteria.length > 0) {
445
+ const met = data.criteria.filter((c) => c.is_met || c.isMet).length;
446
+ const total = data.criteria.length;
447
+ const percent = Math.round((met / total) * 100);
448
+
449
+ bodyHtml += `
450
+ <div class="criteria-list">
451
+ <h3>Acceptance Criteria (${met}/${total})</h3>
452
+ <div class="progress-bar">
453
+ <div class="progress-fill" style="width: ${percent}%"></div>
454
+ </div>
455
+ ${data.criteria
456
+ .map(
457
+ (c) => `
458
+ <div class="criteria-item ${c.is_met || c.isMet ? "criteria-met" : ""}">
459
+ <input type="checkbox" class="criteria-checkbox" ${c.is_met || c.isMet ? "checked" : ""} disabled />
460
+ <span class="criteria-text">${escapeHtml(c.criteria)}</span>
461
+ </div>
462
+ `,
463
+ )
464
+ .join("")}
465
+ </div>
466
+ `;
467
+ }
468
+
469
+ // Dependencies
470
+ if (data.dependencies && data.dependencies.length > 0) {
471
+ bodyHtml += `
472
+ <div class="dependencies">
473
+ <h4>Dependencies</h4>
474
+ ${data.dependencies
475
+ .map(
476
+ (dep) => `
477
+ <div class="dependency-item">
478
+ <span>Depends on: ${dep.dependsOnTaskId || dep.depends_on_task_id || "Unknown"}</span>
479
+ </div>
480
+ `,
481
+ )
482
+ .join("")}
483
+ </div>
484
+ `;
485
+ }
486
+
487
+ elements.contentBody.innerHTML = bodyHtml;
488
+
489
+ // Apply syntax highlighting
490
+ elements.contentBody.querySelectorAll("pre code").forEach((block) => {
491
+ if (typeof hljs !== "undefined") {
492
+ hljs.highlightElement(block);
493
+ }
494
+ });
495
+ }
496
+
497
+ // Helpers
498
+ function getStatusClass(status) {
499
+ switch (status) {
500
+ case "COMPLETED":
501
+ return "completed";
502
+ case "IN_PROGRESS":
503
+ return "in-progress";
504
+ default:
505
+ return "pending";
506
+ }
507
+ }
508
+
509
+ function getStatusIcon(status) {
510
+ switch (status) {
511
+ case "COMPLETED":
512
+ return "●";
513
+ case "IN_PROGRESS":
514
+ return "◐";
515
+ default:
516
+ return "○";
517
+ }
518
+ }
519
+
520
+ function escapeHtml(text) {
521
+ const div = document.createElement("div");
522
+ div.textContent = text;
523
+ return div.innerHTML;
524
+ }
525
+
526
+ // Dependency Graph
527
+ let depGraphData = null;
528
+
529
+ async function openDepGraph() {
530
+ elements.depGraphOverlay.classList.remove("hidden");
531
+ await loadDepGraph();
532
+ }
533
+
534
+ function closeDepGraph() {
535
+ elements.depGraphOverlay.classList.add("hidden");
536
+ }
537
+
538
+ async function loadDepGraph() {
539
+ try {
540
+ const [depsResponse, treeResponse] = await Promise.all([
541
+ fetch("/api/dependencies"),
542
+ fetch("/api/tree"),
543
+ ]);
544
+
545
+ const deps = await depsResponse.json();
546
+ const tree = await treeResponse.json();
547
+
548
+ // Build node map from tree data
549
+ const nodes = new Map();
550
+ for (const prd of tree) {
551
+ nodes.set(prd.ref, {
552
+ ref: prd.ref,
553
+ title: prd.title,
554
+ type: "prd",
555
+ status: prd.status,
556
+ });
557
+ if (prd.epics) {
558
+ for (const epic of prd.epics) {
559
+ nodes.set(epic.ref, {
560
+ ref: epic.ref,
561
+ title: epic.title,
562
+ type: "epic",
563
+ status: epic.status,
564
+ });
565
+ if (epic.tasks) {
566
+ for (const task of epic.tasks) {
567
+ nodes.set(task.ref, {
568
+ ref: task.ref,
569
+ title: task.title,
570
+ type: "task",
571
+ status: task.status,
572
+ });
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ depGraphData = { edges: deps.edges || [], nodes };
580
+ renderDepGraph();
581
+ } catch (error) {
582
+ console.error("Failed to load dependency graph:", error);
583
+ elements.depGraphSvg.innerHTML = `
584
+ <foreignObject x="0" y="0" width="100%" height="100%">
585
+ <div class="dep-graph-empty">
586
+ <p>&#9888;</p>
587
+ <p>Failed to load dependencies</p>
588
+ </div>
589
+ </foreignObject>
590
+ `;
591
+ }
592
+ }
593
+
594
+ function renderDepGraph() {
595
+ if (!depGraphData) return;
596
+
597
+ const filterType = elements.depTypeFilter.value;
598
+ let edges = depGraphData.edges;
599
+ const nodes = depGraphData.nodes;
600
+
601
+ // Filter edges by type
602
+ if (filterType !== "all") {
603
+ edges = edges.filter((e) => e.type === filterType);
604
+ }
605
+
606
+ if (edges.length === 0) {
607
+ const container = elements.depGraphSvg.parentElement;
608
+ const w = container.clientWidth || 600;
609
+ const h = container.clientHeight || 400;
610
+ elements.depGraphSvg.setAttribute("viewBox", `0 0 ${w} ${h}`);
611
+ elements.depGraphSvg.style.width = `${w}px`;
612
+ elements.depGraphSvg.style.height = `${h}px`;
613
+ elements.depGraphSvg.innerHTML = `
614
+ <text x="${w / 2}" y="${h / 2 - 20}" text-anchor="middle" class="empty-icon" style="font-size: 48px; fill: var(--text-secondary);">&#9670;</text>
615
+ <text x="${w / 2}" y="${h / 2 + 20}" text-anchor="middle" style="font-size: 14px; fill: var(--text-secondary);">No dependencies${filterType !== "all" ? ` for ${filterType}s` : ""}</text>
616
+ `;
617
+ return;
618
+ }
619
+
620
+ // Collect unique nodes involved in dependencies
621
+ const involvedNodes = new Set();
622
+ for (const edge of edges) {
623
+ involvedNodes.add(edge.from);
624
+ involvedNodes.add(edge.to);
625
+ }
626
+
627
+ // Detect circular dependencies using DFS
628
+ const circularEdges = detectCircularDeps(edges);
629
+
630
+ // Layout: simple layered approach
631
+ const layout = computeLayout(Array.from(involvedNodes), edges, nodes);
632
+
633
+ // Render SVG
634
+ renderSvgGraph(layout, edges, nodes, circularEdges);
635
+ }
636
+
637
+ function detectCircularDeps(edges) {
638
+ const graph = new Map();
639
+ for (const edge of edges) {
640
+ if (!graph.has(edge.from)) graph.set(edge.from, []);
641
+ graph.get(edge.from).push(edge.to);
642
+ }
643
+
644
+ const circular = new Set();
645
+ const visited = new Set();
646
+ const recStack = new Set();
647
+
648
+ function dfs(node, path) {
649
+ visited.add(node);
650
+ recStack.add(node);
651
+
652
+ const neighbors = graph.get(node) || [];
653
+ for (const neighbor of neighbors) {
654
+ if (!visited.has(neighbor)) {
655
+ if (dfs(neighbor, [...path, node])) {
656
+ circular.add(`${node}->${neighbor}`);
657
+ }
658
+ } else if (recStack.has(neighbor)) {
659
+ // Found cycle
660
+ circular.add(`${node}->${neighbor}`);
661
+ return true;
662
+ }
663
+ }
664
+
665
+ recStack.delete(node);
666
+ return false;
667
+ }
668
+
669
+ for (const node of graph.keys()) {
670
+ if (!visited.has(node)) {
671
+ dfs(node, []);
672
+ }
673
+ }
674
+
675
+ return circular;
676
+ }
677
+
678
+ function computeLayout(nodeRefs, edges, _nodesMap) {
679
+ // Compute in-degree for topological layering
680
+ // Edge semantics: {from: A, to: B} means "A depends on B"
681
+ // For layout: B should come before A (B is the root/dependency)
682
+ // So we reverse edge direction: treat as B -> A for layering
683
+ const inDegree = new Map();
684
+ const outEdges = new Map();
685
+
686
+ for (const ref of nodeRefs) {
687
+ inDegree.set(ref, 0);
688
+ outEdges.set(ref, []);
689
+ }
690
+
691
+ for (const edge of edges) {
692
+ if (inDegree.has(edge.from) && inDegree.has(edge.to)) {
693
+ // Reverse: "from depends on to" means to -> from for layout
694
+ inDegree.set(edge.from, inDegree.get(edge.from) + 1);
695
+ outEdges.get(edge.to).push(edge.from);
696
+ }
697
+ }
698
+
699
+ // Assign layers using Kahn's algorithm variant
700
+ const layers = [];
701
+ const assigned = new Set();
702
+ const remaining = new Set(nodeRefs);
703
+
704
+ while (remaining.size > 0) {
705
+ // Find nodes with 0 in-degree among remaining
706
+ const layer = [];
707
+ for (const ref of remaining) {
708
+ if (inDegree.get(ref) === 0) {
709
+ layer.push(ref);
710
+ }
711
+ }
712
+
713
+ // If no 0 in-degree nodes, pick one (handles cycles)
714
+ if (layer.length === 0) {
715
+ layer.push(remaining.values().next().value);
716
+ }
717
+
718
+ layers.push(layer);
719
+
720
+ for (const ref of layer) {
721
+ assigned.add(ref);
722
+ remaining.delete(ref);
723
+ // Decrease in-degree of neighbors
724
+ for (const neighbor of outEdges.get(ref) || []) {
725
+ if (remaining.has(neighbor)) {
726
+ inDegree.set(neighbor, inDegree.get(neighbor) - 1);
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ // Compute positions - larger boxes for title/description/status
733
+ const nodeWidth = 200;
734
+ const nodeHeight = 70;
735
+ const layerGap = 120;
736
+ const nodeGap = 40;
737
+
738
+ const positions = new Map();
739
+ let maxWidth = 0;
740
+
741
+ for (let i = 0; i < layers.length; i++) {
742
+ const layer = layers[i];
743
+ const layerWidth = layer.length * nodeWidth + (layer.length - 1) * nodeGap;
744
+ maxWidth = Math.max(maxWidth, layerWidth);
745
+
746
+ for (let j = 0; j < layer.length; j++) {
747
+ const ref = layer[j];
748
+ positions.set(ref, {
749
+ x: j * (nodeWidth + nodeGap) + nodeWidth / 2,
750
+ y: i * layerGap + nodeHeight / 2 + 40,
751
+ layer: i,
752
+ index: j,
753
+ });
754
+ }
755
+ }
756
+
757
+ // Center each layer
758
+ for (let i = 0; i < layers.length; i++) {
759
+ const layer = layers[i];
760
+ const layerWidth = layer.length * nodeWidth + (layer.length - 1) * nodeGap;
761
+ const offset = (maxWidth - layerWidth) / 2;
762
+
763
+ for (const ref of layer) {
764
+ const pos = positions.get(ref);
765
+ pos.x += offset;
766
+ }
767
+ }
768
+
769
+ return {
770
+ positions,
771
+ width: maxWidth + 80,
772
+ height: layers.length * layerGap + 80,
773
+ };
774
+ }
775
+
776
+ function renderSvgGraph(layout, edges, nodesMap, circularEdges) {
777
+ const { positions, width, height } = layout;
778
+ const boxWidth = 180;
779
+ const boxHeight = 60;
780
+
781
+ // Set SVG dimensions
782
+ elements.depGraphSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
783
+ elements.depGraphSvg.style.width = `${width}px`;
784
+ elements.depGraphSvg.style.height = `${height}px`;
785
+
786
+ let svg = "";
787
+
788
+ // Draw edges first (behind nodes) - simple connector lines
789
+ for (const edge of edges) {
790
+ // Edge: "from depends on to", but layout is reversed so "to" is above "from"
791
+ // Draw line from dependency (to) down to dependent (from)
792
+ const dependent = positions.get(edge.from);
793
+ const dependency = positions.get(edge.to);
794
+ if (!dependent || !dependency) continue;
795
+
796
+ const isCircular = circularEdges.has(`${edge.from}->${edge.to}`);
797
+
798
+ // Line from bottom of dependency to top of dependent
799
+ const startX = dependency.x;
800
+ const startY = dependency.y + boxHeight / 2;
801
+ const endX = dependent.x;
802
+ const endY = dependent.y - boxHeight / 2;
803
+
804
+ // Simple path: vertical down, then horizontal if needed, then vertical to target
805
+ let path;
806
+ if (startX === endX) {
807
+ // Straight vertical line
808
+ path = `M ${startX} ${startY} L ${endX} ${endY}`;
809
+ } else {
810
+ // Step connector: down, across, down
811
+ const midY = (startY + endY) / 2;
812
+ path = `M ${startX} ${startY} L ${startX} ${midY} L ${endX} ${midY} L ${endX} ${endY}`;
813
+ }
814
+
815
+ svg += `
816
+ <path class="edge ${isCircular ? "circular" : ""}" d="${path}" />
817
+ <circle cx="${startX}" cy="${startY}" r="3" class="edge-dot" />
818
+ <circle cx="${endX}" cy="${endY}" r="3" class="edge-dot ${isCircular ? "circular" : ""}" />
819
+ `;
820
+ }
821
+
822
+ // Draw nodes
823
+ for (const [ref, pos] of positions) {
824
+ const node = nodesMap.get(ref);
825
+ const title = node?.title || ref;
826
+ const status = node?.status || "PENDING";
827
+ const statusClass = getStatusClass(status);
828
+
829
+ // Truncate title for display
830
+ const truncatedTitle =
831
+ title.length > 22 ? `${title.substring(0, 22)}...` : title;
832
+
833
+ // Format status for display
834
+ const statusDisplay = status.replace(/_/g, " ");
835
+
836
+ svg += `
837
+ <g class="node" data-ref="${ref}" onclick="selectNodeFromGraph('${ref}')">
838
+ <rect
839
+ x="${pos.x - boxWidth / 2}"
840
+ y="${pos.y - boxHeight / 2}"
841
+ width="${boxWidth}"
842
+ height="${boxHeight}"
843
+ class="node-box ${statusClass}"
844
+ />
845
+ <text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 18}" class="node-ref">${ref}</text>
846
+ <text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 34}" class="node-title">${escapeHtml(truncatedTitle)}</text>
847
+ <text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 50}" class="node-status ${statusClass}">${statusDisplay}</text>
848
+ </g>
849
+ `;
850
+ }
851
+
852
+ elements.depGraphSvg.innerHTML = svg;
853
+ }
854
+
855
+ // Global function for graph node clicks
856
+ window.selectNodeFromGraph = (ref) => {
857
+ closeDepGraph();
858
+ loadContent(ref);
859
+
860
+ // Find and select the node in the tree
861
+ const node = elements.tree.querySelector(`[data-ref="${ref}"]`);
862
+ if (node) {
863
+ elements.tree.querySelectorAll(".tree-node-content").forEach((n) => {
864
+ n.classList.remove("selected");
865
+ });
866
+ node.classList.add("selected");
867
+ node.scrollIntoView({ behavior: "smooth", block: "center" });
868
+ }
869
+ };